From d7b91ac14c91111a192cc5ed5c9b252293777ffa Mon Sep 17 00:00:00 2001 From: Laurent Date: Sat, 30 May 2026 18:05:33 +0200 Subject: [PATCH] feat: add TrackEditService save orchestration --- Music/Services/TrackEditService.swift | 56 ++++++++++++++++++ MusicTests/TrackEditServiceTests.swift | 81 ++++++++++++++++++++++++++ 2 files changed, 137 insertions(+) create mode 100644 Music/Services/TrackEditService.swift create mode 100644 MusicTests/TrackEditServiceTests.swift diff --git a/Music/Services/TrackEditService.swift b/Music/Services/TrackEditService.swift new file mode 100644 index 0000000..78ab210 --- /dev/null +++ b/Music/Services/TrackEditService.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, + 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 + } +} diff --git a/MusicTests/TrackEditServiceTests.swift b/MusicTests/TrackEditServiceTests.swift new file mode 100644 index 0000000..69912ad --- /dev/null +++ b/MusicTests/TrackEditServiceTests.swift @@ -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)!) + } +}