Fix artwork upload pipeline

This commit is contained in:
diyaa
2026-05-31 01:20:56 +02:00
parent 7b1952794c
commit 18ed79e3c4
24 changed files with 1652 additions and 56 deletions
@@ -1,34 +1,46 @@
import AVFoundation
import CryptoKit
import Foundation
import VelodyDomain
import VelodyPersistence
final class AVFoundationMetadataReader: MetadataReader {
private let artworkExtractor: MP3EmbeddedArtworkExtractor
init(artworkExtractor: MP3EmbeddedArtworkExtractor = MP3EmbeddedArtworkExtractor()) {
self.artworkExtractor = artworkExtractor
}
func readMetadata(for fileURL: URL) async throws -> LocalTrackMetadata {
let asset = AVURLAsset(url: fileURL)
let commonMetadata = try await asset.load(.commonMetadata)
let duration = try? await asset.load(.duration)
let artwork = try? artworkExtractor.extractArtwork(from: fileURL)
return LocalTrackMetadata(
title: commonMetadata.firstStringValue(for: .commonIdentifierTitle),
artist: commonMetadata.firstStringValue(for: .commonIdentifierArtist),
album: commonMetadata.firstStringValue(for: .commonIdentifierAlbumName),
durationSeconds: duration?.seconds.validDurationSeconds
durationSeconds: duration?.seconds.validDurationSeconds,
artwork: artwork
)
}
}
final class FileSystemLocalMusicScanner: LocalMusicScanner {
private let metadataReader: any MetadataReader
private let artworkStore: any LocalArtworkStore
private let fileHasher: SHA256FileHasher
private let fileManager: FileManager
init(
metadataReader: any MetadataReader,
artworkStore: any LocalArtworkStore,
fileHasher: SHA256FileHasher = SHA256FileHasher(),
fileManager: FileManager = .default
) {
self.metadataReader = metadataReader
self.artworkStore = artworkStore
self.fileHasher = fileHasher
self.fileManager = fileManager
}
@@ -65,6 +77,7 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
do {
let metadata = try await metadataReader.readMetadata(for: fileURL)
let artwork = try await persistedArtwork(from: metadata.artwork)
discoveredTracks.append(
ScannedLocalTrack(
title: metadata.title?.trimmedNonEmpty ?? fallbackTitle,
@@ -73,6 +86,7 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
durationSeconds: metadata.durationSeconds,
localFilePath: fileURL.path,
sha256: sha256,
artwork: artwork,
fileModifiedAt: fileModifiedAt
)
)
@@ -85,6 +99,7 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
durationSeconds: nil,
localFilePath: fileURL.path,
sha256: sha256,
artwork: nil,
fileModifiedAt: fileModifiedAt
)
)
@@ -125,6 +140,28 @@ final class FileSystemLocalMusicScanner: LocalMusicScanner {
return .distantPast
}
private func persistedArtwork(
from artwork: LocalTrackMetadata.EmbeddedArtworkPayload?
) async throws -> LocalTrackArtwork? {
guard let artwork else {
return nil
}
let localFilePath = try await artworkStore.saveArtwork(
artwork.data,
sha256: artwork.sha256,
mimeType: artwork.mimeType
)
return LocalTrackArtwork(
localFilePath: localFilePath,
sha256: artwork.sha256,
mimeType: artwork.mimeType,
width: artwork.width,
height: artwork.height
)
}
}
final class SHA256FileHasher {
@@ -37,6 +37,8 @@ final class MacLibraryViewModel {
private let catalogService: any LocalCatalogService
private let trackRepository: any TrackRepository
private let localMusicScanner: any LocalMusicScanner
private let localArtworkStore: any LocalArtworkStore
private let artworkUploadPayloadBuilder: LocalTrackArtworkUploadPayloadBuilder
private let playbackController: PlaybackController
private let keychainService: any KeychainService
private let userDefaults: UserDefaults
@@ -49,8 +51,10 @@ final class MacLibraryViewModel {
fileManager: FileManager = .default
) {
let folderAccessService = FolderAccessService()
let localArtworkStore = Self.makeLocalArtworkStore()
let localMusicScanner = FileSystemLocalMusicScanner(
metadataReader: AVFoundationMetadataReader()
metadataReader: AVFoundationMetadataReader(),
artworkStore: localArtworkStore
)
let repository = Self.makeTrackRepository()
let playbackController = PlaybackController(
@@ -64,6 +68,10 @@ final class MacLibraryViewModel {
self.catalogService = DefaultLocalCatalogService(repository: repository)
self.trackRepository = repository
self.localMusicScanner = localMusicScanner
self.localArtworkStore = localArtworkStore
self.artworkUploadPayloadBuilder = LocalTrackArtworkUploadPayloadBuilder(
artworkStore: localArtworkStore
)
self.playbackController = playbackController
self.keychainService = keychainService
self.userDefaults = userDefaults
@@ -450,6 +458,35 @@ final class MacLibraryViewModel {
switch prepareResponse.status {
case .exists:
if let uploadId = prepareResponse.uploadId,
let track = currentTrack(for: trackID),
track.artwork != nil
{
try await setTrackUploadState(
trackID: trackID,
status: .uploading,
remoteTrackId: prepareResponse.trackId ?? track.remoteTrackId,
lastUploadError: nil,
progress: 0.7
)
let finalizeResponse = try await finalizePreparedUpload(
trackID: trackID,
uploadId: uploadId,
apiClient: apiClient
)
try await setTrackUploadState(
trackID: trackID,
status: .uploaded,
remoteTrackId: finalizeResponse.trackId,
lastUploadError: nil,
progress: 1
)
lastUploadStatus = "Updated remote artwork for \(track.title) on track \(finalizeResponse.trackId)."
return .success(remoteTrackId: finalizeResponse.trackId)
}
let remoteTrackID = prepareResponse.trackId ?? currentTrack(for: trackID)?.remoteTrackId
try await setTrackUploadState(
trackID: trackID,
@@ -487,26 +524,10 @@ final class MacLibraryViewModel {
throw UploadPipelineError.uploadDidNotComplete(uploadResponse.status.rawValue)
}
try await setTrackUploadState(
let finalizeResponse = try await finalizePreparedUpload(
trackID: trackID,
status: .uploading,
remoteTrackId: currentTrack(for: trackID)?.remoteTrackId,
lastUploadError: nil,
progress: 0.85
)
guard let track = currentTrack(for: trackID) else {
throw UploadPipelineError.trackMissing
}
let finalizeResponse = try await apiClient.finalizeUpload(
uploadId: uploadId,
payload: UploadFinalizeRequest(
title: track.title,
artist: track.artist,
album: track.album,
durationMs: durationMilliseconds(from: track.durationSeconds)
)
apiClient: apiClient
)
try await setTrackUploadState(
@@ -516,7 +537,8 @@ final class MacLibraryViewModel {
lastUploadError: nil,
progress: 1
)
lastUploadStatus = "Uploaded \(track.title) as remote track \(finalizeResponse.trackId)."
let uploadedTrackTitle = currentTrack(for: trackID)?.title ?? initialTrack.title
lastUploadStatus = "Uploaded \(uploadedTrackTitle) as remote track \(finalizeResponse.trackId)."
return .success(remoteTrackId: finalizeResponse.trackId)
}
} catch {
@@ -533,6 +555,37 @@ final class MacLibraryViewModel {
}
}
private func finalizePreparedUpload(
trackID: String,
uploadId: String,
apiClient: any VelodyAPIClient
) async throws -> UploadFinalizeResponse {
try await setTrackUploadState(
trackID: trackID,
status: .uploading,
remoteTrackId: currentTrack(for: trackID)?.remoteTrackId,
lastUploadError: nil,
progress: 0.85
)
guard let track = currentTrack(for: trackID) else {
throw UploadPipelineError.trackMissing
}
return try await apiClient.finalizeUpload(
uploadId: uploadId,
payload: UploadFinalizeRequest(
title: track.title,
artist: track.artist,
album: track.album,
durationMs: durationMilliseconds(from: track.durationSeconds),
artwork: try await artworkUploadPayloadBuilder.makePayload(
for: track.artwork
)
)
)
}
private func restoreDeviceIdentity() async {
do {
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
@@ -734,6 +787,14 @@ final class MacLibraryViewModel {
return InMemoryTrackRepository()
}
private static func makeLocalArtworkStore() -> any LocalArtworkStore {
if let store = try? FileLocalArtworkStore() {
return store
}
return InMemoryLocalArtworkStore()
}
private static func scanStatus(
for result: LocalCatalogScanResult,
activeTrackCount: Int