feat: add TrackEditService save orchestration

feat/music-streaming
Laurent 1 month ago
parent b327fc5221
commit d7b91ac14c
  1. 56
      Music/Services/TrackEditService.swift
  2. 81
      MusicTests/TrackEditServiceTests.swift

@ -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…
Cancel
Save