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.
feat/music-streaming
Laurent 1 month ago
parent a4c59fc8c6
commit b327fc5221
  1. 16
      Music.xcodeproj/project.pbxproj
  2. 42
      Music/Services/TagWriting/ID3TagWriter.swift
  3. 53
      Music/Services/TagWriting/MP4TagWriter.swift
  4. 20
      Music/Services/TagWriting/TagWriter.swift
  5. 16
      MusicTests/EditableTrackFieldsTests.swift
  6. BIN
      MusicTests/Fixtures/sample.m4a
  7. BIN
      MusicTests/Fixtures/sample.mp3
  8. 76
      MusicTests/TagWriterTests.swift

@ -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 */;

@ -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)
}
}

@ -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 }
}
}

@ -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
}
}
}

@ -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))
}
}

Binary file not shown.

Binary file not shown.

@ -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")
}
}
Loading…
Cancel
Save