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