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