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
parent
a4c59fc8c6
commit
b327fc5221
@ -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 |
||||
} |
||||
} |
||||
} |
||||
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…
Reference in new issue