Fix artwork upload pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user