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 0000000..a15e8e3 Binary files /dev/null and b/MusicTests/Fixtures/sample.m4a differ diff --git a/MusicTests/Fixtures/sample.mp3 b/MusicTests/Fixtures/sample.mp3 new file mode 100644 index 0000000..2e5c710 Binary files /dev/null and b/MusicTests/Fixtures/sample.mp3 differ 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") + } +}