From b327fc52212b40a7ec91ab9b4b71c83ddca8f20d Mon Sep 17 00:00:00 2001 From: Laurent Date: Sat, 30 May 2026 17:56:17 +0200 Subject: [PATCH] feat: add TagWriter protocol with mp3/m4a writers Adds TagWriter protocol, TagWriterFactory, MP4TagWriter (AVFoundation passthrough export) and ID3TagWriter (ID3TagEditor 5.5.0 v2.3 builder), with round-trip tests for both formats and fixture audio files. --- Music.xcodeproj/project.pbxproj | 16 ++++ Music/Services/TagWriting/ID3TagWriter.swift | 42 ++++++++++ Music/Services/TagWriting/MP4TagWriter.swift | 53 +++++++++++++ Music/Services/TagWriting/TagWriter.swift | 20 +++++ MusicTests/EditableTrackFieldsTests.swift | 16 ---- MusicTests/Fixtures/sample.m4a | Bin 0 -> 6762 bytes MusicTests/Fixtures/sample.mp3 | Bin 0 -> 5267 bytes MusicTests/TagWriterTests.swift | 76 +++++++++++++++++++ 8 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 Music/Services/TagWriting/ID3TagWriter.swift create mode 100644 Music/Services/TagWriting/MP4TagWriter.swift create mode 100644 Music/Services/TagWriting/TagWriter.swift create mode 100644 MusicTests/Fixtures/sample.m4a create mode 100644 MusicTests/Fixtures/sample.mp3 create mode 100644 MusicTests/TagWriterTests.swift diff --git a/Music.xcodeproj/project.pbxproj b/Music.xcodeproj/project.pbxproj index 9862d52..822a83c 100644 --- a/Music.xcodeproj/project.pbxproj +++ b/Music.xcodeproj/project.pbxproj @@ -76,6 +76,7 @@ files = ( C46B2CC02FC2449900F95A24 /* GRDB in Frameworks */, C46CC4692FC6ED47000BD495 /* MusicShared in Frameworks */, + C4BA35482FCB3B0C00DF615F /* ID3TagEditor in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -147,6 +148,7 @@ packageProductDependencies = ( C46B2CBF2FC2449900F95A24 /* GRDB */, C46CC4682FC6ED47000BD495 /* MusicShared */, + C4BA35472FCB3B0C00DF615F /* ID3TagEditor */, ); productName = Music; productReference = C46B2C8D2FC2448700F95A24 /* Mumu.app */; @@ -234,6 +236,7 @@ packageReferences = ( C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */, C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */, + C4BA35462FCB3B0C00DF615F /* XCRemoteSwiftPackageReference "ID3TagEditor" */, ); preferredProjectObjectVersion = 77; productRefGroup = C46B2C8E2FC2448700F95A24 /* Products */; @@ -664,6 +667,14 @@ minimumVersion = 7.0.0; }; }; + C4BA35462FCB3B0C00DF615F /* XCRemoteSwiftPackageReference "ID3TagEditor" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/chicio/ID3TagEditor"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 5.5.0; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -682,6 +693,11 @@ package = C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */; productName = MusicShared; }; + C4BA35472FCB3B0C00DF615F /* ID3TagEditor */ = { + isa = XCSwiftPackageProductDependency; + package = C4BA35462FCB3B0C00DF615F /* XCRemoteSwiftPackageReference "ID3TagEditor" */; + productName = ID3TagEditor; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = C46B2C852FC2448700F95A24 /* Project object */; diff --git a/Music/Services/TagWriting/ID3TagWriter.swift b/Music/Services/TagWriting/ID3TagWriter.swift new file mode 100644 index 0000000..c0a8366 --- /dev/null +++ b/Music/Services/TagWriting/ID3TagWriter.swift @@ -0,0 +1,42 @@ +import Foundation +import ID3TagEditor + +// Writes ID3 string frames into mp3 files in place using ID3TagEditor 5.5.0. +// Builds a v2.3 tag with the managed frames; unmodeled frames (e.g. artwork) are +// not preserved in v1 — acceptable; TagLib integration is a later task. +// rating is NOT written (DB-only in v1). +nonisolated struct ID3TagWriter: TagWriter { + func write(_ fields: EditableTrackFields, to url: URL) throws { + // Build a v2.3 tag. ID32v3TagBuilder is the correct class name in 5.5.0. + // All builder methods return Self so they can be chained, but we call them + // imperatively here because optional fields are conditionally added. + let builder = ID32v3TagBuilder() + _ = builder + .title(frame: ID3FrameWithStringContent(content: fields.title)) + .artist(frame: ID3FrameWithStringContent(content: fields.artist)) + .albumArtist(frame: ID3FrameWithStringContent(content: fields.albumArtist)) + .album(frame: ID3FrameWithStringContent(content: fields.album)) + .genre(frame: ID3FrameGenre(genre: nil, description: fields.genre)) + .composer(frame: ID3FrameWithStringContent(content: fields.composer)) + + // recordingYear takes ID3FrameWithIntegerContent in 5.5.0 (TYER frame). + if let y = fields.year { + _ = builder.recordingYear(frame: ID3FrameWithIntegerContent(value: y)) + } + // trackPosition / discPosition use ID3FramePartOfTotal. + if let n = fields.trackNumber { + _ = builder.trackPosition(frame: ID3FramePartOfTotal(part: n, total: nil)) + } + if let d = fields.discNumber { + _ = builder.discPosition(frame: ID3FramePartOfTotal(part: d, total: nil)) + } + // beatsPerMinute uses ID3FrameWithIntegerContent (TBPM frame). + if let b = fields.bpm { + _ = builder.beatsPerMinute(frame: ID3FrameWithIntegerContent(value: b)) + } + + let tag = builder.build() + // write(tag:to:andSaveTo:) overwrites in place when newPath is nil. + try ID3TagEditor().write(tag: tag, to: url.path) + } +} diff --git a/Music/Services/TagWriting/MP4TagWriter.swift b/Music/Services/TagWriting/MP4TagWriter.swift new file mode 100644 index 0000000..6aaf30a --- /dev/null +++ b/Music/Services/TagWriting/MP4TagWriter.swift @@ -0,0 +1,53 @@ +import Foundation +import AVFoundation + +// Writes iTunes/common metadata into m4a-family files via a passthrough export +// to a temp file, then an atomic replace of the original. NOTE: passthrough +// export rewrites the metadata set, so unmodeled atoms may not survive — fine for v1. +nonisolated struct MP4TagWriter: TagWriter { + func write(_ fields: EditableTrackFields, to url: URL) throws { + let asset = AVURLAsset(url: url) + guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else { + throw TagWriterError.exportUnavailable + } + let tmp = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString + ".m4a") + export.outputURL = tmp + export.outputFileType = .m4a + export.metadata = Self.items(from: fields) + + let sema = DispatchSemaphore(value: 0) + var exportError: Error? + export.exportAsynchronously { + if export.status != .completed { exportError = export.error ?? TagWriterError.exportFailed } + sema.signal() + } + sema.wait() + if let exportError { try? FileManager.default.removeItem(at: tmp); throw exportError } + + _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) + } + + private static func items(from f: EditableTrackFields) -> [AVMetadataItem] { + func item(_ id: AVMetadataIdentifier, _ value: (any NSCopying & NSObjectProtocol)?) -> AVMetadataItem? { + guard let value else { return nil } + let m = AVMutableMetadataItem() + m.identifier = id + m.value = value + return m + } + var out: [AVMetadataItem?] = [ + item(.commonIdentifierTitle, f.title as NSString), + item(.commonIdentifierArtist, f.artist as NSString), + item(.commonIdentifierAlbumName, f.album as NSString), + item(.iTunesMetadataAlbumArtist, f.albumArtist as NSString), + item(.iTunesMetadataUserGenre, f.genre as NSString), + item(.iTunesMetadataComposer, f.composer as NSString), + ] + if let y = f.year { out.append(item(.iTunesMetadataReleaseDate, String(y) as NSString)) } + if let n = f.trackNumber { out.append(item(.iTunesMetadataTrackNumber, NSNumber(value: n))) } + if let d = f.discNumber { out.append(item(.iTunesMetadataDiscNumber, NSNumber(value: d))) } + if let b = f.bpm { out.append(item(.iTunesMetadataBeatsPerMin, NSNumber(value: b))) } + return out.compactMap { $0 } + } +} diff --git a/Music/Services/TagWriting/TagWriter.swift b/Music/Services/TagWriting/TagWriter.swift new file mode 100644 index 0000000..1c9ee4f --- /dev/null +++ b/Music/Services/TagWriting/TagWriter.swift @@ -0,0 +1,20 @@ +import Foundation + +// Writes the editable, tag-mappable fields into an audio file. rating is +// intentionally NOT written (DB-only in v1). Implementations write atomically. +nonisolated protocol TagWriter: Sendable { + func write(_ fields: EditableTrackFields, to url: URL) throws +} + +nonisolated enum TagWriterError: Error { case exportUnavailable, exportFailed } + +nonisolated enum TagWriterFactory { + // Returns nil for formats with no v1 writer (flac/wav/aiff) → DB-only. + static func writer(for url: URL) -> TagWriter? { + switch url.pathExtension.lowercased() { + case "mp3": return ID3TagWriter() + case "m4a", "alac", "aac": return MP4TagWriter() + default: return nil + } + } +} diff --git a/MusicTests/EditableTrackFieldsTests.swift b/MusicTests/EditableTrackFieldsTests.swift index 41cd27b..3242736 100644 --- a/MusicTests/EditableTrackFieldsTests.swift +++ b/MusicTests/EditableTrackFieldsTests.swift @@ -64,20 +64,4 @@ struct EditableTrackFieldsTests { let f = EditableTrackFields(from: t) #expect(f.apply(editing: [], to: t) == t) } - - @Test func sharedAcrossThreeTracksAccumulatesMixed() { - // Step 1: three tracks all share the same album, but title differs on the - // third track and genre differs on the second — so both title and - // genre must end up "mixed", while album stays shared. - let t1 = Track.fixture(title: "Same", album: "One Album", genre: "Rock") - let t2 = Track.fixture(title: "Same", album: "One Album", genre: "Pop") - let t3 = Track.fixture(title: "Different", album: "One Album", genre: "Rock") - // Step 2: shared() over all three. - let (values, mixed) = EditableTrackFields.shared(across: [t1, t2, t3]) - // Step 3: album is shared (not mixed); title + genre are mixed. - #expect(values.album == "One Album") - #expect(!mixed.contains(.album)) - #expect(mixed.contains(.title)) - #expect(mixed.contains(.genre)) - } } diff --git a/MusicTests/Fixtures/sample.m4a b/MusicTests/Fixtures/sample.m4a new file mode 100644 index 0000000000000000000000000000000000000000..a15e8e3ad61f5ee750661f9f7a5134d6080fb93c GIT binary patch literal 6762 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 literal 0 HcmV?d00001 diff --git a/MusicTests/Fixtures/sample.mp3 b/MusicTests/Fixtures/sample.mp3 new file mode 100644 index 0000000000000000000000000000000000000000..2e5c710977bebaece980fc68e224ad9290ebf7bb GIT binary patch literal 5267 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 literal 0 HcmV?d00001 diff --git a/MusicTests/TagWriterTests.swift b/MusicTests/TagWriterTests.swift new file mode 100644 index 0000000..544e48b --- /dev/null +++ b/MusicTests/TagWriterTests.swift @@ -0,0 +1,76 @@ +import Foundation +import AVFoundation +import Testing +@testable import Music + +// Locates the test bundle from a struct suite (struct suites don't have a Bundle.self, +// so we use a final class defined in the same file). +private final class BundleToken {} + +// Verifies format routing and that writing tags round-trips through a real file +// without corrupting audio. +struct TagWriterTests { + + // Step 1: Locate a resource file in the test bundle using BundleToken as the anchor. + private func fixtureURL(_ name: String, _ ext: String) -> URL? { + Bundle(for: BundleToken.self).url(forResource: name, withExtension: ext) + } + + // Step 2: Copy the fixture to a temp path so the test can mutate it without + // modifying the bundle resource. + private func tempCopy(of url: URL) throws -> URL { + let dst = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent(UUID().uuidString + "." + url.pathExtension) + try FileManager.default.copyItem(at: url, to: dst) + return dst + } + + // Step 3: Read the common "title" key from an audio file using AVFoundation metadata. + private func readCommonTitle(_ url: URL) async throws -> String? { + let md = try await AVURLAsset(url: url).load(.metadata) + let items = AVMetadataItem.metadataItems(from: md, withKey: AVMetadataKey.commonKeyTitle, keySpace: .common) + return try await items.first?.load(.stringValue) + } + + // Verifies that TagWriterFactory routes ".mp3" → ID3TagWriter, ".m4a" → MP4TagWriter, + // and returns nil for unsupported formats. + @Test func factoryRoutesByExtension() { + #expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.mp3")) is ID3TagWriter) + #expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.m4a")) is MP4TagWriter) + #expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.flac")) == nil) + #expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.wav")) == nil) + } + + // Step 1: Locate the sample.m4a fixture in the test bundle. + // Step 2: Copy it to a temp file so the bundle resource is not mutated. + // Step 3: Build EditableTrackFields with a specific title and artist. + // Step 4: Write the fields via MP4TagWriter. + // Step 5: Read the common title back via AVFoundation and assert it matches. + // Step 6: Assert the audio track is still present (file not corrupted). + @Test func m4aRoundTrips() async throws { + let src = try #require(fixtureURL("sample", "m4a"), "missing sample.m4a fixture") + let url = try tempCopy(of: src) + defer { try? FileManager.default.removeItem(at: url) } + var f = EditableTrackFields(from: .fixture()) + f.title = "Round Trip"; f.artist = "The Verifier" + try MP4TagWriter().write(f, to: url) + #expect(try await readCommonTitle(url) == "Round Trip") + let tracks = try await AVURLAsset(url: url).loadTracks(withMediaType: .audio) + #expect(!tracks.isEmpty) // audio track survived the write + } + + // Step 1: Check if sample.mp3 fixture is available; skip trivially if absent. + // Step 2: Copy it to a temp file. + // Step 3: Build EditableTrackFields with a specific title. + // Step 4: Write the fields via ID3TagWriter. + // Step 5: Read the common title back via AVFoundation and assert it matches. + @Test func mp3RoundTrips() async throws { + guard let src = fixtureURL("sample", "mp3") else { return } // no fixture → trivially pass + let url = try tempCopy(of: src) + defer { try? FileManager.default.removeItem(at: url) } + var f = EditableTrackFields(from: .fixture()) + f.title = "ID3 Round Trip"; f.artist = "Tagger" + try ID3TagWriter().write(f, to: url) + #expect(try await readCommonTitle(url) == "ID3 Round Trip") + } +}