parent
b327fc5221
commit
d7b91ac14c
@ -0,0 +1,56 @@ |
||||
import Foundation |
||||
|
||||
nonisolated struct TrackEditWarning: Sendable, Equatable { |
||||
enum Kind: Sendable, Equatable { case dbOnlyUnsupported, fileWriteFailed } |
||||
let trackId: Int64? |
||||
let fileURL: String |
||||
let kind: Kind |
||||
let reason: String |
||||
} |
||||
|
||||
// Orchestrates a metadata save: apply edited fields → best-effort file-tag write |
||||
// → refresh file stats on success → DB update. The DB is ALWAYS updated; file |
||||
// writeback failures are collected as warnings, never blocking the library edit. |
||||
nonisolated final class TrackEditService: Sendable { |
||||
private let database: DatabaseService |
||||
private let writerFactory: @Sendable (URL) -> TagWriter? |
||||
|
||||
init(database: DatabaseService, |
||||
writerFactory: @escaping @Sendable (URL) -> TagWriter? = TagWriterFactory.writer) { |
||||
self.database = database |
||||
self.writerFactory = writerFactory |
||||
} |
||||
|
||||
func save(_ values: EditableTrackFields, |
||||
editing edited: Set<EditableTrackField>, |
||||
to tracks: [Track]) -> [TrackEditWarning] { |
||||
var warnings: [TrackEditWarning] = [] |
||||
for track in tracks { |
||||
var updated = values.apply(editing: edited, to: track) |
||||
// rating is DB-only; only attempt file writes if tag-mappable fields changed. |
||||
let tagFieldsChanged = !edited.subtracting([.rating]).isEmpty |
||||
|
||||
if let url = URL(string: track.fileURL), tagFieldsChanged { |
||||
if let writer = writerFactory(url) { |
||||
do { |
||||
try writer.write(values, to: url) |
||||
if let stats = try? TrackFileStats.compute(for: url) { |
||||
updated.fileSize = stats.fileSize |
||||
updated.dateModified = stats.dateModified |
||||
updated.fileHash = stats.fileHash |
||||
} |
||||
} catch { |
||||
warnings.append(.init(trackId: track.id, fileURL: track.fileURL, |
||||
kind: .fileWriteFailed, reason: error.localizedDescription)) |
||||
} |
||||
} else { |
||||
warnings.append(.init(trackId: track.id, fileURL: track.fileURL, |
||||
kind: .dbOnlyUnsupported, |
||||
reason: "Tag writing not supported for .\(url.pathExtension)")) |
||||
} |
||||
} |
||||
try? database.updateTrack(updated) |
||||
} |
||||
return warnings |
||||
} |
||||
} |
||||
@ -0,0 +1,81 @@ |
||||
import Foundation |
||||
import Testing |
||||
@testable import Music |
||||
|
||||
// Verifies the save orchestration: DB always updated; file writeback best-effort; |
||||
// stats refreshed on success; warnings on unsupported format / writer failure. |
||||
struct TrackEditServiceTests { |
||||
|
||||
// A spy writer we can make succeed or throw. |
||||
struct SpyWriter: TagWriter { |
||||
let shouldThrow: Bool |
||||
func write(_ fields: EditableTrackFields, to url: URL) throws { |
||||
if shouldThrow { throw TagWriterError.exportFailed } |
||||
// simulate a real write by appending a byte so size/mtime change. |
||||
let h = try FileHandle(forWritingTo: url); try h.seekToEnd() |
||||
try h.write(contentsOf: Data([0])); try h.close() |
||||
} |
||||
} |
||||
|
||||
private func tempTrack(ext: String) throws -> Track { |
||||
let url = URL(fileURLWithPath: NSTemporaryDirectory()) |
||||
.appendingPathComponent(UUID().uuidString + "." + ext) |
||||
try Data(repeating: 1, count: 100).write(to: url) |
||||
return .fixture(fileURL: url.absoluteString, fileFormat: ext) |
||||
} |
||||
|
||||
@Test func supportedFormatSuccessUpdatesDBAndRefreshesStats() throws { |
||||
// Step 1: DB + a real temp file + an edit changing the title. |
||||
let db = try DatabaseService(inMemory: true) |
||||
var t = try tempTrack(ext: "mp3"); try db.insert(&t) |
||||
let original = EditableTrackFields(from: t) |
||||
var edited = original; edited.title = "Edited" |
||||
// Step 2: save via a succeeding writer (injected). |
||||
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: false) }) |
||||
let warnings = svc.save(edited, editing: original.changedFields(to: edited), to: [t]) |
||||
// Step 3: no warnings; DB has new title and refreshed hash (file changed). |
||||
#expect(warnings.isEmpty) |
||||
let f = try #require(db.fetchTracksByIds([t.id!]).first) |
||||
#expect(f.title == "Edited") |
||||
#expect(f.fileHash != t.fileHash) |
||||
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) |
||||
} |
||||
|
||||
@Test func unsupportedFormatSavesDBOnlyWithWarning() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
var t = try tempTrack(ext: "flac"); try db.insert(&t) |
||||
var edited = EditableTrackFields(from: t); edited.album = "DB Only" |
||||
let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) // nil for flac |
||||
let warnings = svc.save(edited, editing: [.album], to: [t]) |
||||
#expect(warnings.count == 1) |
||||
#expect(warnings.first?.kind == .dbOnlyUnsupported) |
||||
#expect(try #require(db.fetchTracksByIds([t.id!]).first).album == "DB Only") |
||||
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) |
||||
} |
||||
|
||||
@Test func writerThrowsSavesDBOnlyWithFailureWarning() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
var t = try tempTrack(ext: "mp3"); try db.insert(&t) |
||||
var edited = EditableTrackFields(from: t); edited.genre = "Still Saved" |
||||
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: true) }) |
||||
let warnings = svc.save(edited, editing: [.genre], to: [t]) |
||||
#expect(warnings.first?.kind == .fileWriteFailed) |
||||
#expect(try #require(db.fetchTracksByIds([t.id!]).first).genre == "Still Saved") |
||||
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) |
||||
} |
||||
|
||||
@Test func multiTrackAppliesOnlyEditedFields() throws { |
||||
let db = try DatabaseService(inMemory: true) |
||||
var a = try tempTrack(ext: "flac"); a.album = "OldA"; a.genre = "RockA"; try db.insert(&a) |
||||
var b = try tempTrack(ext: "flac"); b.album = "OldB"; b.genre = "RockB"; try db.insert(&b) |
||||
var edited = EditableTrackFields(from: a); edited.album = "Shared" |
||||
let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) |
||||
_ = svc.save(edited, editing: [.album], to: [a, b]) |
||||
// album applied to both; each genre untouched. |
||||
#expect(try #require(db.fetchTracksByIds([a.id!]).first).album == "Shared") |
||||
#expect(try #require(db.fetchTracksByIds([b.id!]).first).album == "Shared") |
||||
#expect(try #require(db.fetchTracksByIds([b.id!]).first).genre == "RockB") |
||||
try? FileManager.default.removeItem(at: URL(string: a.fileURL)!) |
||||
try? FileManager.default.removeItem(at: URL(string: b.fileURL)!) |
||||
} |
||||
} |
||||
Loading…
Reference in new issue