From dccd7586ea60403119a020dbfb3bdd7a60572676 Mon Sep 17 00:00:00 2001 From: Fabrizio Duroni Date: Sat, 26 Jul 2025 18:07:01 +0200 Subject: [PATCH 1/2] Add support for user-defined text information frames (TXXX/TXX) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements support for ID3 user-defined text information frames following the TXXX (v2.3/v2.4) and TXX (v2.2) specifications. This feature allows storing custom text fields with description + content pairs. - Add ID3FrameUserDefinedTextInformation class with description and content properties - Update FrameType and FrameName enums with userDefinedTextInformation support - Implement parsing operation with proper UTF-16 BOM and null terminator handling - Add frame creation logic supporting all ID3 versions and encodings - Update all tag builders (v2, v3, v4) with userDefinedTextInformation methods - Add comprehensive unit tests for parsing and creation operations - Add integration tests in ID3TagEditorWriteReadAcceptanceTest Fixes #49 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- Demo/Demo.xcodeproj/project.pbxproj | 16 +-- Source/Create/ID3FrameCreatorsFactory.swift | 3 +- ...ithUserDefinedTextInformationCreator.swift | 31 +++++ ...DefinedTextInformationCreatorFactory.swift | 24 ++++ ...erDefinedTextInformationFrameCreator.swift | 59 +++++++++ Source/Frame/FrameName.swift | 3 + Source/Frame/FrameType.swift | 1 + Source/Frame/ID3FrameConfiguration.swift | 12 +- .../ID3FrameUserDefinedTextInformation.swift | 38 ++++++ ...3FrameContentParsingOperationFactory.swift | 3 +- ...ormationFrameContentParsingOperation.swift | 101 ++++++++++++++ ...nFrameContentParsingOperationFactory.swift | 23 ++++ Source/Tag/ID32v2TagBuilder.swift | 13 ++ Source/Tag/ID32v3TagBuilder.swift | 13 ++ Source/Tag/ID32v4TagBuilder.swift | 13 ++ .../ID3TagEditorWriteReadAcceptanceTest.swift | 12 ++ ...serDefinedTextInformationCreatorTest.swift | 70 ++++++++++ ...InformationFrameParsingOperationTest.swift | 123 ++++++++++++++++++ 18 files changed, 544 insertions(+), 14 deletions(-) create mode 100644 Source/Create/ID3FramesWithUserDefinedTextInformationCreator.swift create mode 100644 Source/Create/ID3FramesWithUserDefinedTextInformationCreatorFactory.swift create mode 100644 Source/Create/ID3UserDefinedTextInformationFrameCreator.swift create mode 100644 Source/Frame/ID3FrameUserDefinedTextInformation.swift create mode 100644 Source/Parse/ID3UserDefinedTextInformationFrameContentParsingOperation.swift create mode 100644 Source/Parse/ID3UserDefinedTextInformationFrameContentParsingOperationFactory.swift create mode 100644 Tests/Create/ID3FramesWithUserDefinedTextInformationCreatorTest.swift create mode 100644 Tests/Parse/ID3UserDefinedTextInformationFrameParsingOperationTest.swift diff --git a/Demo/Demo.xcodeproj/project.pbxproj b/Demo/Demo.xcodeproj/project.pbxproj index 520a2580..9662ccc8 100644 --- a/Demo/Demo.xcodeproj/project.pbxproj +++ b/Demo/Demo.xcodeproj/project.pbxproj @@ -680,7 +680,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Demo tvOS/Preview Content\""; - DEVELOPMENT_TEAM = 5Y4K7JX2AU; + DEVELOPMENT_TEAM = Y682K92RZU; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -718,7 +718,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Demo tvOS/Preview Content\""; - DEVELOPMENT_TEAM = 5Y4K7JX2AU; + DEVELOPMENT_TEAM = Y682K92RZU; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -755,7 +755,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Demo iOS/Preview Content\""; - DEVELOPMENT_TEAM = 5Y4K7JX2AU; + DEVELOPMENT_TEAM = Y682K92RZU; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -796,7 +796,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Demo iOS/Preview Content\""; - DEVELOPMENT_TEAM = 5Y4K7JX2AU; + DEVELOPMENT_TEAM = Y682K92RZU; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -839,7 +839,7 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Demo macOS/Preview Content\""; - DEVELOPMENT_TEAM = 5Y4K7JX2AU; + DEVELOPMENT_TEAM = Y682K92RZU; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -877,7 +877,7 @@ CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEVELOPMENT_ASSET_PATHS = "\"Demo macOS/Preview Content\""; - DEVELOPMENT_TEAM = 5Y4K7JX2AU; + DEVELOPMENT_TEAM = Y682K92RZU; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; @@ -910,7 +910,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Demo watchOS/Preview Content\""; - DEVELOPMENT_TEAM = 5Y4K7JX2AU; + DEVELOPMENT_TEAM = Y682K92RZU; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; @@ -950,7 +950,7 @@ CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Demo watchOS/Preview Content\""; - DEVELOPMENT_TEAM = 5Y4K7JX2AU; + DEVELOPMENT_TEAM = Y682K92RZU; ENABLE_PREVIEWS = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu17; diff --git a/Source/Create/ID3FrameCreatorsFactory.swift b/Source/Create/ID3FrameCreatorsFactory.swift index 3b4db841..94abb681 100644 --- a/Source/Create/ID3FrameCreatorsFactory.swift +++ b/Source/Create/ID3FrameCreatorsFactory.swift @@ -42,7 +42,8 @@ class ID3FrameCreatorsFactory { ID3FrameContentCreator(frameCreator: frameFromStringUTF16ContentAdapter, frameName: .originalFilename, frameType: .originalFilename), ID3FrameContentCreator(frameCreator: frameFromIntegerContentAdapter, frameName: .lengthInMilliseconds, frameType: .lengthInMilliseconds), ID3FrameContentCreator(frameCreator: frameFromIntegerContentAdapter, frameName: .sizeInBytes, frameType: .sizeInBytes), - ID3FramesWithLocalizedContentCreatorFactory.make() + ID3FramesWithLocalizedContentCreatorFactory.make(), + ID3FramesWithUserDefinedTextInformationCreatorFactory.make() ] + ID3RecordingTimesFrameCreatorsFactory.make() + ID3iTunesFrameCreatorsFactory.make() diff --git a/Source/Create/ID3FramesWithUserDefinedTextInformationCreator.swift b/Source/Create/ID3FramesWithUserDefinedTextInformationCreator.swift new file mode 100644 index 00000000..7da6ccb4 --- /dev/null +++ b/Source/Create/ID3FramesWithUserDefinedTextInformationCreator.swift @@ -0,0 +1,31 @@ +// +// ID3FramesWithUserDefinedTextInformationCreator.swift +// ID3TagEditor +// +// Created by Fabrizio Duroni on 26/07/2025. +// 2025 Fabrizio Duroni. +// + +import Foundation + +class ID3FramesWithUserDefinedTextInformationCreator: ID3FrameCreator { + private let userDefinedTextInformationFrameCreator: UserDefinedTextInformationFrameCreator + + init(userDefinedTextInformationFrameCreator: UserDefinedTextInformationFrameCreator) { + self.userDefinedTextInformationFrameCreator = userDefinedTextInformationFrameCreator + } + + func createFrames(id3Tag: ID3Tag) -> [UInt8] { + return id3Tag.frames.reduce([], { accumulator, frameElement in + if case .userDefinedTextInformation(_) = frameElement.key, + let frame = frameElement.value as? ID3FrameUserDefinedTextInformation { + return accumulator + userDefinedTextInformationFrameCreator.createFrame( + using: frame, + version: id3Tag.properties.version, + frameType: .userDefinedTextInformation + ) + } + return accumulator + }) + } +} \ No newline at end of file diff --git a/Source/Create/ID3FramesWithUserDefinedTextInformationCreatorFactory.swift b/Source/Create/ID3FramesWithUserDefinedTextInformationCreatorFactory.swift new file mode 100644 index 00000000..86084040 --- /dev/null +++ b/Source/Create/ID3FramesWithUserDefinedTextInformationCreatorFactory.swift @@ -0,0 +1,24 @@ +// +// ID3FramesWithUserDefinedTextInformationCreatorFactory.swift +// ID3TagEditor +// +// Created by Fabrizio Duroni on 26/07/2025. +// 2025 Fabrizio Duroni. +// + +import Foundation + +class ID3FramesWithUserDefinedTextInformationCreatorFactory { + static func make() -> ID3FramesWithUserDefinedTextInformationCreator { + let frameConfiguration = ID3FrameConfiguration() + let paddingAdder = PaddingAdderToEndOfContentUsingNullChar() + + return ID3FramesWithUserDefinedTextInformationCreator( + userDefinedTextInformationFrameCreator: ID3UserDefinedTextInformationFrameCreator( + id3FrameConfiguration: frameConfiguration, + frameHeaderCreator: ID3FrameHeaderCreatorFactory.make(), + paddingAdder: paddingAdder + ) + ) + } +} \ No newline at end of file diff --git a/Source/Create/ID3UserDefinedTextInformationFrameCreator.swift b/Source/Create/ID3UserDefinedTextInformationFrameCreator.swift new file mode 100644 index 00000000..ab762755 --- /dev/null +++ b/Source/Create/ID3UserDefinedTextInformationFrameCreator.swift @@ -0,0 +1,59 @@ +// +// ID3UserDefinedTextInformationFrameCreator.swift +// ID3TagEditor +// +// Created by Fabrizio Duroni on 26/07/2025. +// 2025 Fabrizio Duroni. +// + +import Foundation + +protocol UserDefinedTextInformationFrameCreator { + func createFrame(using frame: ID3FrameUserDefinedTextInformation, version: ID3Version, frameType: FrameType) -> [UInt8] +} + +class ID3UserDefinedTextInformationFrameCreator: UserDefinedTextInformationFrameCreator { + private let id3FrameConfiguration: ID3FrameConfiguration + private let frameHeaderCreator: FrameHeaderCreator + private let paddingAdder: PaddingAdder + + init(id3FrameConfiguration: ID3FrameConfiguration, + frameHeaderCreator: FrameHeaderCreator, + paddingAdder: PaddingAdder) { + self.id3FrameConfiguration = id3FrameConfiguration + self.frameHeaderCreator = frameHeaderCreator + self.paddingAdder = paddingAdder + } + + func createFrame(using frame: ID3FrameUserDefinedTextInformation, version: ID3Version, frameType: FrameType) -> [UInt8] { + let encoding = selectEncoding(for: version) + let stringEncoding = convertToStringEncoding(encoding, version: version) + let encodingByte = id3FrameConfiguration.encodingByteFor(version: version, encoding: encoding) + + let descriptionBytes = [UInt8](frame.description.data(using: stringEncoding) ?? Data()) + let description = paddingAdder.addTo(content: descriptionBytes, numberOfByte: getTerminatorSize(for: stringEncoding)) + let content = [UInt8](frame.content.data(using: stringEncoding) ?? Data()) + + let body = encodingByte + description + content + let header = frameHeaderCreator.createUsing(version: version, frameType: frameType, frameBody: body) + + return header + body + } + + private func selectEncoding(for version: ID3Version) -> ID3StringEncoding { + return version == .version4 ? .UTF8 : .UTF16 + } + + private func convertToStringEncoding(_ id3Encoding: ID3StringEncoding, version: ID3Version) -> String.Encoding { + let encodingMap: [ID3Version: [ID3StringEncoding: String.Encoding]] = [ + .version2: [.ISO88591: .isoLatin1, .UTF16: .utf16], + .version3: [.ISO88591: .isoLatin1, .UTF16: .utf16], + .version4: [.ISO88591: .isoLatin1, .UTF16: .utf16, .UTF8: .utf8] + ] + return encodingMap[version]?[id3Encoding] ?? .utf16 + } + + private func getTerminatorSize(for encoding: String.Encoding) -> Int { + return encoding == .utf16 ? 2 : 1 + } +} \ No newline at end of file diff --git a/Source/Frame/FrameName.swift b/Source/Frame/FrameName.swift index 4cc6fe9d..30dee924 100644 --- a/Source/Frame/FrameName.swift +++ b/Source/Frame/FrameName.swift @@ -143,4 +143,7 @@ public enum FrameName: Equatable, Hashable, CaseIterable { case iTunesPodcastID /// Podcast keywords frame name, Version 2.3 and 2.4 only. case iTunesPodcastKeywords + /// User defined text information frame name (TXXX in v2.3/v2.4, TXX in v2.2). + /// - description: the description that identifies the type of text information. + case userDefinedTextInformation(_ description: String) } diff --git a/Source/Frame/FrameType.swift b/Source/Frame/FrameType.swift index 6b3f6c08..37abf7ab 100644 --- a/Source/Frame/FrameType.swift +++ b/Source/Frame/FrameType.swift @@ -46,6 +46,7 @@ enum FrameType: String, Equatable { case iTunesPodcastDescription = "iTunesPodcastDescription" case iTunesPodcastID = "iTunesPodcastID" case iTunesPodcastKeywords = "iTunesPodcastKeywords" + case userDefinedTextInformation = "userDefinedTextInformation" case invalid = "" static func == (lhs: FrameType, rhs: FrameType) -> Bool { diff --git a/Source/Frame/ID3FrameConfiguration.swift b/Source/Frame/ID3FrameConfiguration.swift index 93253c6c..984c40af 100644 --- a/Source/Frame/ID3FrameConfiguration.swift +++ b/Source/Frame/ID3FrameConfiguration.swift @@ -61,7 +61,8 @@ class ID3FrameConfiguration { .iTunesPodcastCategory: [UInt8]("TCAT".utf8), .iTunesPodcastDescription: [UInt8]("TDES".utf8), .iTunesPodcastID: [UInt8]("TGID".utf8), - .iTunesPodcastKeywords: [UInt8]("TKWD".utf8) + .iTunesPodcastKeywords: [UInt8]("TKWD".utf8), + .userDefinedTextInformation: [UInt8]("TXXX".utf8) ] private var identifiers: [ID3Version: [FrameType: [UInt8]]] = [ .version2: [ @@ -92,7 +93,8 @@ class ID3FrameConfiguration { .lengthInMilliseconds: [UInt8]("TLE".utf8), .sizeInBytes: [UInt8]("TSI".utf8), .unsyncronisedLyrics: [UInt8]("ULT".utf8), - .comment: [UInt8]("COM".utf8) + .comment: [UInt8]("COM".utf8), + .userDefinedTextInformation: [UInt8]("TXX".utf8) ], .version3: [ .recordingDayMonth: [UInt8]("TDAT".utf8), @@ -137,7 +139,8 @@ class ID3FrameConfiguration { "TBPM": .beatsPerMinute, "TOFN": .originalFilename, "TLEN": .lengthInMilliseconds, - "TKEY": .initialKey + "TKEY": .initialKey, + "TXXX": .userDefinedTextInformation ] private var nameForIdentifier: [ID3Version: [String: FrameType]] = [ .version2: [ @@ -168,7 +171,8 @@ class ID3FrameConfiguration { "TOF": .originalFilename, "TLE": .lengthInMilliseconds, "TSI": .sizeInBytes, - "TKE": .initialKey + "TKE": .initialKey, + "TXX": .userDefinedTextInformation ], .version3: [ "TDAT": .recordingDayMonth, diff --git a/Source/Frame/ID3FrameUserDefinedTextInformation.swift b/Source/Frame/ID3FrameUserDefinedTextInformation.swift new file mode 100644 index 00000000..5949ab88 --- /dev/null +++ b/Source/Frame/ID3FrameUserDefinedTextInformation.swift @@ -0,0 +1,38 @@ +// +// ID3FrameUserDefinedTextInformation.swift +// ID3TagEditor +// +// Created by Fabrizio Duroni on 26/07/2025. +// 2025 Fabrizio Duroni. +// + +import Foundation + +/** + A class used to represent an ID3 User Defined Text Information frame (TXXX in v2.3/v2.4, TXX in v2.2). + This frame is intended for one-string text information concerning the audio file. + The frame contains a description field that identifies the type of text and the actual text value. + Multiple TXXX frames are allowed in a tag, but only one with the same description. + */ +public class ID3FrameUserDefinedTextInformation: ID3FrameWithStringContent, CustomDebugStringConvertible { + /// A description that identifies the type of text information + public let description: String + /// ID3FrameUserDefinedTextInformation debug description. + public var debugDescription: String { + return """ + description: \(description) + content: \(content) + """ + } + + /** + Init an ID3 User Defined Text Information frame. + + - parameter description: a description that identifies the type of text information. + - parameter content: the actual text content. + */ + public init(description: String, content: String) { + self.description = description + super.init(content: content) + } +} \ No newline at end of file diff --git a/Source/Parse/ID3FrameContentParsingOperationFactory.swift b/Source/Parse/ID3FrameContentParsingOperationFactory.swift index cae582a4..fbd46b15 100644 --- a/Source/Parse/ID3FrameContentParsingOperationFactory.swift +++ b/Source/Parse/ID3FrameContentParsingOperationFactory.swift @@ -49,7 +49,8 @@ class ID3FrameContentParsingOperationFactory { .iTunesGrouping: ID3ParsingOperationForID3FrameWithStringFactory.make(frameName: .iTunesGrouping), .iTunesMovementIndex: ID3ParsingOperationForID3FrameWithIntegerFactory.make(frameName: .iTunesMovementIndex), .iTunesMovementCount: ID3ParsingOperationForID3FrameWithIntegerFactory.make(frameName: .iTunesMovementCount), - .iTunesMovementName: ID3ParsingOperationForID3FrameWithStringFactory.make(frameName: .iTunesMovementName) + .iTunesMovementName: ID3ParsingOperationForID3FrameWithStringFactory.make(frameName: .iTunesMovementName), + .userDefinedTextInformation: ID3UserDefinedTextInformationFrameContentParsingOperationFactory.make() ] } } diff --git a/Source/Parse/ID3UserDefinedTextInformationFrameContentParsingOperation.swift b/Source/Parse/ID3UserDefinedTextInformationFrameContentParsingOperation.swift new file mode 100644 index 00000000..f0e4944b --- /dev/null +++ b/Source/Parse/ID3UserDefinedTextInformationFrameContentParsingOperation.swift @@ -0,0 +1,101 @@ +// +// ID3UserDefinedTextInformationFrameContentParsingOperation.swift +// ID3TagEditor +// +// Created by Fabrizio Duroni on 26/07/2025. +// 2025 Fabrizio Duroni. +// + +import Foundation + +class ID3UserDefinedTextInformationFrameContentParsingOperation: FrameContentParsingOperation { + private let id3FrameConfiguration: ID3FrameConfiguration + private let stringEncodingDetector: ID3FrameStringEncodingDetector + private let paddingRemover: PaddingRemover + + init(id3FrameConfiguration: ID3FrameConfiguration, + paddingRemover: PaddingRemover, + stringEncodingDetector: ID3FrameStringEncodingDetector) { + self.id3FrameConfiguration = id3FrameConfiguration + self.stringEncodingDetector = stringEncodingDetector + self.paddingRemover = paddingRemover + } + + func parse(frame: Data, version: ID3Version, completed: (FrameName, ID3Frame) -> Void) { + let headerSize = id3FrameConfiguration.headerSizeFor(version: version) + let encoding = stringEncodingDetector.detect(frame: frame, version: version) + let (description, content) = parseBodyFrom(frame: frame, using: headerSize, and: encoding) + let userDefinedFrame = ID3FrameUserDefinedTextInformation(description: description, content: content) + completed(.userDefinedTextInformation(description), userDefinedFrame) + } + + private func parseBodyFrom(frame: Data, using headerSize: Int, and encoding: String.Encoding) -> (String, String) { + let encodingSize = id3FrameConfiguration.encodingSize() + let frameContentStartIndex = headerSize + encodingSize + let allContent = frame.subdata(in: frameContentStartIndex.. (String, String) { + // For UTF-16, we need to handle the BOM and find the actual null terminator + var currentIndex = 0 + + // Skip BOM if present + if data.count >= 2 && data[0] == 0xFF && data[1] == 0xFE { + currentIndex = 2 + } + + // Find the null terminator (00 00) + var descriptionEnd = currentIndex + while descriptionEnd < data.count - 1 { + if data[descriptionEnd] == 0x00 && data[descriptionEnd + 1] == 0x00 { + break + } + descriptionEnd += 2 // UTF-16 uses 2 bytes per character + } + + // Extract description (including BOM if present) + let descriptionData = data.subdata(in: 0.. (String, String) { + let separatorBytes = Data([0x00]) + guard let separatorRange = data.range(of: separatorBytes) else { + // If no separator found, treat all as description with empty content + let description = String(data: data, encoding: encoding) ?? "" + return (paddingRemover.removeFrom(string: description), "") + } + + let description = String( + data: data.subdata(in: 0.. [UInt8] { + return encoding == String.Encoding.utf16 ? [0x00, 0x00] : [0x00] + } +} \ No newline at end of file diff --git a/Source/Parse/ID3UserDefinedTextInformationFrameContentParsingOperationFactory.swift b/Source/Parse/ID3UserDefinedTextInformationFrameContentParsingOperationFactory.swift new file mode 100644 index 00000000..ac51938f --- /dev/null +++ b/Source/Parse/ID3UserDefinedTextInformationFrameContentParsingOperationFactory.swift @@ -0,0 +1,23 @@ +// +// ID3UserDefinedTextInformationFrameContentParsingOperationFactory.swift +// ID3TagEditor +// +// Created by Fabrizio Duroni on 26/07/2025. +// 2025 Fabrizio Duroni. +// + +import Foundation + +class ID3UserDefinedTextInformationFrameContentParsingOperationFactory { + static func make() -> ID3UserDefinedTextInformationFrameContentParsingOperation { + let id3FrameConfiguration = ID3FrameConfiguration() + return ID3UserDefinedTextInformationFrameContentParsingOperation( + id3FrameConfiguration: id3FrameConfiguration, + paddingRemover: PaddingRemoverUsingTrimming(), + stringEncodingDetector: ID3FrameStringEncodingDetector( + id3FrameConfiguration: id3FrameConfiguration, + id3StringEncodingConverter: ID3StringEncodingConverter() + ) + ) + } +} \ No newline at end of file diff --git a/Source/Tag/ID32v2TagBuilder.swift b/Source/Tag/ID32v2TagBuilder.swift index 4bddd76e..fc5cf0fd 100644 --- a/Source/Tag/ID32v2TagBuilder.swift +++ b/Source/Tag/ID32v2TagBuilder.swift @@ -364,6 +364,19 @@ public class ID32v2TagBuilder: TagBuilder { frames[.initialKey] = frame return self } + + /** + Set a user defined text information frame to be written by ID3TagEditor. + + - parameter description: a description that identifies the type of text information. + - parameter frame: the user defined text information frame as a ID3FrameUserDefinedTextInformation instance. + + - returns: the instance of the builder. + */ + public func userDefinedTextInformation(description: String, frame: ID3FrameUserDefinedTextInformation) -> Self { + frames[.userDefinedTextInformation(description)] = frame + return self + } /** Build and ID3Tag version 2. diff --git a/Source/Tag/ID32v3TagBuilder.swift b/Source/Tag/ID32v3TagBuilder.swift index 995e7b62..48b7d40b 100644 --- a/Source/Tag/ID32v3TagBuilder.swift +++ b/Source/Tag/ID32v3TagBuilder.swift @@ -475,6 +475,19 @@ public class ID32v3TagBuilder: TagBuilder { return self } + /** + Set a user defined text information frame to be written by ID3TagEditor. + + - parameter description: a description that identifies the type of text information. + - parameter frame: the user defined text information frame as a ID3FrameUserDefinedTextInformation instance. + + - returns: the instance of the builder. + */ + public func userDefinedTextInformation(description: String, frame: ID3FrameUserDefinedTextInformation) -> Self { + frames[.userDefinedTextInformation(description)] = frame + return self + } + /** Build and ID3Tag version 3. diff --git a/Source/Tag/ID32v4TagBuilder.swift b/Source/Tag/ID32v4TagBuilder.swift index ff4482f9..cc4635c8 100644 --- a/Source/Tag/ID32v4TagBuilder.swift +++ b/Source/Tag/ID32v4TagBuilder.swift @@ -438,6 +438,19 @@ public class ID32v4TagBuilder: TagBuilder { frames[.initialKey] = frame return self } + + /** + Set a user defined text information frame to be written by ID3TagEditor. + + - parameter description: a description that identifies the type of text information. + - parameter frame: the user defined text information frame as a ID3FrameUserDefinedTextInformation instance. + + - returns: the instance of the builder. + */ + public func userDefinedTextInformation(description: String, frame: ID3FrameUserDefinedTextInformation) -> Self { + frames[.userDefinedTextInformation(description)] = frame + return self + } /** Build and ID3Tag version 4. diff --git a/Tests/Acceptance/ID3TagEditorWriteReadAcceptanceTest.swift b/Tests/Acceptance/ID3TagEditorWriteReadAcceptanceTest.swift index 367c024a..eb13c0f0 100644 --- a/Tests/Acceptance/ID3TagEditorWriteReadAcceptanceTest.swift +++ b/Tests/Acceptance/ID3TagEditorWriteReadAcceptanceTest.swift @@ -61,6 +61,8 @@ struct ID3TagEditorWriteReadAcceptanceTest { .unsynchronisedLyrics(language: .eng, frame: ID3FrameWithLocalizedContent(language: ID3FrameContentLanguage.eng, contentDescription: "CD", content: "v2 eng unsync lyrics")) .comment(language: .ita, frame: ID3FrameWithLocalizedContent(language: ID3FrameContentLanguage.ita, contentDescription: "CD", content: "v2 ita comment")) .comment(language: .eng, frame: ID3FrameWithLocalizedContent(language: ID3FrameContentLanguage.eng, contentDescription: "CD", content: "v2 eng comment")) + .userDefinedTextInformation(description: "CustomField", frame: ID3FrameUserDefinedTextInformation(description: "CustomField", content: "CustomValue")) + .userDefinedTextInformation(description: "AnotherField", frame: ID3FrameUserDefinedTextInformation(description: "AnotherField", content: "AnotherValue")) .build() try id3TagEditor.write( @@ -117,6 +119,10 @@ struct ID3TagEditorWriteReadAcceptanceTest { #expect((id3TagWritten?.frames[.comment(.eng)] as? ID3FrameWithLocalizedContent)?.language == .eng) #expect((id3TagWritten?.frames[.comment(.eng)] as? ID3FrameWithLocalizedContent)?.contentDescription == "CD") #expect((id3TagWritten?.frames[.comment(.eng)] as? ID3FrameWithLocalizedContent)!.content == "v2 eng comment") + #expect((id3TagWritten?.frames[.userDefinedTextInformation("CustomField")] as? ID3FrameUserDefinedTextInformation)?.description == "CustomField") + #expect((id3TagWritten?.frames[.userDefinedTextInformation("CustomField")] as? ID3FrameUserDefinedTextInformation)?.content == "CustomValue") + #expect((id3TagWritten?.frames[.userDefinedTextInformation("AnotherField")] as? ID3FrameUserDefinedTextInformation)?.description == "AnotherField") + #expect((id3TagWritten?.frames[.userDefinedTextInformation("AnotherField")] as? ID3FrameUserDefinedTextInformation)?.content == "AnotherValue") #expect((id3TagWritten?.frames[.initialKey] as? ID3FrameWithStringContent)?.content == "Cbm") let tagReader = ID3TagContentReader(id3Tag: id3TagWritten!) @@ -216,6 +222,7 @@ struct ID3TagEditorWriteReadAcceptanceTest { .iTunesPodcastDescription(frame: ID3FrameWithStringContent(content: "PodcastDescription V3")) .iTunesPodcastID(frame: ID3FrameWithStringContent(content: "PodcastID V3")) .iTunesPodcastKeywords(frame: ID3FrameWithStringContent(content: "PodcastKeywords V3")) + .userDefinedTextInformation(description: "V3CustomField", frame: ID3FrameUserDefinedTextInformation(description: "V3CustomField", content: "V3CustomValue")) .build() try id3TagEditor.write( @@ -281,6 +288,8 @@ struct ID3TagEditorWriteReadAcceptanceTest { #expect((id3TagWritten?.frames[.iTunesPodcastDescription] as? ID3FrameWithStringContent)?.content == "PodcastDescription V3") #expect((id3TagWritten?.frames[.iTunesPodcastID] as? ID3FrameWithStringContent)?.content == "PodcastID V3") #expect((id3TagWritten?.frames[.iTunesPodcastKeywords] as? ID3FrameWithStringContent)?.content == "PodcastKeywords V3") + #expect((id3TagWritten?.frames[.userDefinedTextInformation("V3CustomField")] as? ID3FrameUserDefinedTextInformation)?.description == "V3CustomField") + #expect((id3TagWritten?.frames[.userDefinedTextInformation("V3CustomField")] as? ID3FrameUserDefinedTextInformation)?.content == "V3CustomValue") let tagReader = ID3TagContentReader(id3Tag: id3TagWritten!) #expect(tagReader.title() == "title V3") @@ -384,6 +393,7 @@ struct ID3TagEditorWriteReadAcceptanceTest { .iTunesPodcastDescription(frame: ID3FrameWithStringContent(content: "PodcastDescription V4")) .iTunesPodcastID(frame: ID3FrameWithStringContent(content: "PodcastID V4")) .iTunesPodcastKeywords(frame: ID3FrameWithStringContent(content: "PodcastKeywords V4")) + .userDefinedTextInformation(description: "V4CustomField", frame: ID3FrameUserDefinedTextInformation(description: "V4CustomField", content: "V4CustomValue 🎵")) .build() try id3TagEditor.write( @@ -448,6 +458,8 @@ struct ID3TagEditorWriteReadAcceptanceTest { #expect((id3TagWritten?.frames[.iTunesPodcastDescription] as? ID3FrameWithStringContent)?.content == "PodcastDescription V4") #expect((id3TagWritten?.frames[.iTunesPodcastID] as? ID3FrameWithStringContent)?.content == "PodcastID V4") #expect((id3TagWritten?.frames[.iTunesPodcastKeywords] as? ID3FrameWithStringContent)?.content == "PodcastKeywords V4") + #expect((id3TagWritten?.frames[.userDefinedTextInformation("V4CustomField")] as? ID3FrameUserDefinedTextInformation)?.description == "V4CustomField") + #expect((id3TagWritten?.frames[.userDefinedTextInformation("V4CustomField")] as? ID3FrameUserDefinedTextInformation)?.content == "V4CustomValue 🎵") let tagReader = ID3TagContentReader(id3Tag: id3TagWritten!) #expect(tagReader.title() == "title V4") diff --git a/Tests/Create/ID3FramesWithUserDefinedTextInformationCreatorTest.swift b/Tests/Create/ID3FramesWithUserDefinedTextInformationCreatorTest.swift new file mode 100644 index 00000000..4b7e3666 --- /dev/null +++ b/Tests/Create/ID3FramesWithUserDefinedTextInformationCreatorTest.swift @@ -0,0 +1,70 @@ +// +// ID3FramesWithUserDefinedTextInformationCreatorTest.swift +// ID3TagEditor +// +// Created by Fabrizio Duroni on 26/07/2025. +// 2025 Fabrizio Duroni. +// + +import Testing +@testable import ID3TagEditor + +struct ID3FramesWithUserDefinedTextInformationCreatorTest { + @Test func testNothingIsCreatedWhenUserDefinedTextInformationDataIsNotSet() { + let creator = ID3FramesWithUserDefinedTextInformationCreator( + userDefinedTextInformationFrameCreator: MockUserDefinedTextInformationFrameCreator() + ) + + let frame = creator.createFrames(id3Tag: ID32v3TagBuilder().build()) + + #expect(frame == []) + } + + @Test func testCreateFrameForValidData() { + let creator = ID3FramesWithUserDefinedTextInformationCreator( + userDefinedTextInformationFrameCreator: MockUserDefinedTextInformationFrameCreator() + ) + + let frame = creator.createFrames(id3Tag: aTagWithUserDefinedTextInformation()) + + #expect(frame == [0x01]) + } + + @Test func testCreateMultipleFramesForMultipleUserDefinedTextInformation() { + let creator = ID3FramesWithUserDefinedTextInformationCreator( + userDefinedTextInformationFrameCreator: MockUserDefinedTextInformationFrameCreator() + ) + + let frame = creator.createFrames(id3Tag: aTagWithMultipleUserDefinedTextInformation()) + + #expect(frame == [0x01, 0x01]) + } + + private func aTagWithUserDefinedTextInformation() -> ID3Tag { + return ID32v3TagBuilder() + .userDefinedTextInformation(description: "Custom Field", frame: ID3FrameUserDefinedTextInformation( + description: "Custom Field", + content: "Custom Value" + )) + .build() + } + + private func aTagWithMultipleUserDefinedTextInformation() -> ID3Tag { + return ID32v3TagBuilder() + .userDefinedTextInformation(description: "Field1", frame: ID3FrameUserDefinedTextInformation( + description: "Field1", + content: "Value1" + )) + .userDefinedTextInformation(description: "Field2", frame: ID3FrameUserDefinedTextInformation( + description: "Field2", + content: "Value2" + )) + .build() + } +} + +class MockUserDefinedTextInformationFrameCreator: UserDefinedTextInformationFrameCreator { + func createFrame(using frame: ID3FrameUserDefinedTextInformation, version: ID3Version, frameType: FrameType) -> [UInt8] { + return [0x01] + } +} \ No newline at end of file diff --git a/Tests/Parse/ID3UserDefinedTextInformationFrameParsingOperationTest.swift b/Tests/Parse/ID3UserDefinedTextInformationFrameParsingOperationTest.swift new file mode 100644 index 00000000..ffa14005 --- /dev/null +++ b/Tests/Parse/ID3UserDefinedTextInformationFrameParsingOperationTest.swift @@ -0,0 +1,123 @@ +// +// ID3UserDefinedTextInformationFrameParsingOperationTest.swift +// ID3TagEditor +// +// Created by Fabrizio Duroni on 26/07/2025. +// 2025 Fabrizio Duroni. +// + +import Foundation +import Testing + +@testable import ID3TagEditor + +struct ID3UserDefinedTextInformationFrameParsingOperationTest { + @Test func testParsingValidFrame() async { + let userDefinedOperation = ID3UserDefinedTextInformationFrameContentParsingOperationFactory.make() + + await confirmation("user defined text information") { fulfill in + userDefinedOperation.parse(frame: frameV3Valid(), version: .version3) { (frameName, frame) in + if case let .userDefinedTextInformation(description) = frameName { + #expect(description == "Custom Field") + } + #expect((frame as? ID3FrameUserDefinedTextInformation)?.description == "Custom Field") + #expect((frame as? ID3FrameUserDefinedTextInformation)?.content == "Custom Value") + fulfill() + } + } + } + + @Test func testParsingFrameWithEmptyContent() async { + let userDefinedOperation = ID3UserDefinedTextInformationFrameContentParsingOperationFactory.make() + + await confirmation("user defined text information with empty content") { fulfill in + userDefinedOperation.parse(frame: frameV3EmptyContent(), version: .version3) { (frameName, frame) in + if case let .userDefinedTextInformation(description) = frameName { + #expect(description == "Empty Test") + } + #expect((frame as? ID3FrameUserDefinedTextInformation)?.description == "Empty Test") + #expect((frame as? ID3FrameUserDefinedTextInformation)?.content == "") + fulfill() + } + } + } + + @Test func testParsingFrameV2() async { + let userDefinedOperation = ID3UserDefinedTextInformationFrameContentParsingOperationFactory.make() + + await confirmation("user defined text information v2") { fulfill in + userDefinedOperation.parse(frame: frameV2Valid(), version: .version2) { (frameName, frame) in + if case let .userDefinedTextInformation(description) = frameName { + #expect(description == "V2 Field") + } + #expect((frame as? ID3FrameUserDefinedTextInformation)?.description == "V2 Field") + #expect((frame as? ID3FrameUserDefinedTextInformation)?.content == "V2 Value") + fulfill() + } + } + } + + @Test func testParsingFrameV4WithUTF8() async { + let userDefinedOperation = ID3UserDefinedTextInformationFrameContentParsingOperationFactory.make() + + await confirmation("user defined text information v4 utf8") { fulfill in + userDefinedOperation.parse(frame: frameV4ValidUTF8(), version: .version4) { (frameName, frame) in + if case let .userDefinedTextInformation(description) = frameName { + #expect(description == "UTF8 Field") + } + #expect((frame as? ID3FrameUserDefinedTextInformation)?.description == "UTF8 Field") + #expect((frame as? ID3FrameUserDefinedTextInformation)?.content == "UTF8 Value 🎵") + fulfill() + } + } + } + + private func frameV3Valid() -> Data { + let content = "Custom Field".data(using: .utf16)! + + Data([0x00, 0x00]) // UTF16 null terminator + + "Custom Value".data(using: .utf16)! + return Data([ + 0x54, 0x58, 0x58, 0x58, // TXXX identifier + 0x00, 0x00, 0x00, UInt8(content.count + 1), // size + 0x00, 0x00, // flags + 0x01 // UTF16 encoding + ]) + content + } + + private func frameV3EmptyContent() -> Data { + let content = "Empty Test".data(using: .utf16)! + + Data([0x00, 0x00]) // UTF16 null terminator + + "".data(using: .utf16)! + return Data([ + 0x54, 0x58, 0x58, 0x58, // TXXX identifier + 0x00, 0x00, 0x00, UInt8(content.count + 1), // size + 0x00, 0x00, // flags + 0x01 // UTF16 encoding + ]) + content + } + + private func frameV2Valid() -> Data { + let content = "V2 Field".data(using: .isoLatin1)! + + Data([0x00]) // ISO-8859-1 null terminator + + "V2 Value".data(using: .isoLatin1)! + return Data([ + 0x54, 0x58, 0x58, // TXX identifier + UInt8((content.count + 1) >> 16), + UInt8((content.count + 1) >> 8), + UInt8(content.count + 1), // size (3 bytes) + 0x00 // ISO-8859-1 encoding + ]) + content + } + + private func frameV4ValidUTF8() -> Data { + let content = "UTF8 Field".data(using: .utf8)! + + Data([0x00]) // UTF8 null terminator + + "UTF8 Value 🎵".data(using: .utf8)! + return Data([ + 0x54, 0x58, 0x58, 0x58, // TXXX identifier + 0x00, 0x00, 0x00, UInt8(content.count + 1), // size + 0x00, 0x00, // flags + 0x03 // UTF8 encoding + ]) + content + } +} \ No newline at end of file From 9c5404eaa9c34d6b5fd80d6b1c73495cac3db871 Mon Sep 17 00:00:00 2001 From: Fabrizio Duroni Date: Sun, 31 Aug 2025 18:34:35 +0200 Subject: [PATCH 2/2] feat(ci): new simulator selection for iOS :rocket: --- .github/workflows/build-ios.yml | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build-ios.yml b/.github/workflows/build-ios.yml index 994541b2..343b1612 100644 --- a/.github/workflows/build-ios.yml +++ b/.github/workflows/build-ios.yml @@ -17,11 +17,16 @@ jobs: steps: - name: Checkout uses: actions/checkout@v2 + - name: Select iOS Simulator + id: select_sim + run: | + SIMULATOR_ID=$(xcrun simctl list devices available | grep -m1 'iPhone' | awk -F '[()]' '{print $2}') + echo "SIMULATOR_ID=$SIMULATOR_ID" >> $GITHUB_ENV - name: Build iOS framework run: | - set -o pipefail && xcodebuild -project ID3TagEditor.xcodeproj -scheme "ID3TagEditor iOS Tests" clean test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "platform=iOS Simulator,name=iPhone 16,OS=18.1" -skipPackagePluginValidation | xcpretty + set -o pipefail && xcodebuild -project ID3TagEditor.xcodeproj -scheme "ID3TagEditor iOS Tests" clean test CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "id=$SIMULATOR_ID" -skipPackagePluginValidation | xcpretty - name: Build iOS Demo - run: | - set -o pipefail && xcodebuild -project Demo/Demo.xcodeproj -scheme "Demo iOS" clean build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "platform=iOS Simulator,name=iPhone 16,OS=18.1" -skipPackagePluginValidation | xcpretty + run: | + set -o pipefail && xcodebuild -project Demo/Demo.xcodeproj -scheme "Demo iOS" clean build CODE_SIGN_IDENTITY="" CODE_SIGNING_REQUIRED=NO -destination "id=$SIMULATOR_ID" -skipPackagePluginValidation | xcpretty - name: Upload coverage to Codecov uses: codecov/codecov-action@v1