Add MP3 upload pipeline foundation

This commit is contained in:
diyaa
2026-05-28 17:43:00 +02:00
parent 799c07b068
commit 98edfa0faf
24 changed files with 2469 additions and 93 deletions
@@ -29,6 +29,7 @@ struct MacLibraryView: View {
)
statusRow(title: "Last heartbeat", value: viewModel.lastHeartbeatStatus)
statusRow(title: "Last bootstrap", value: viewModel.lastSyncBootstrapStatus)
statusRow(title: "Last upload", value: viewModel.lastUploadStatus)
if let lastBootstrapTrackCount = viewModel.lastBootstrapTrackCount {
statusRow(title: "Bootstrap tracks", value: "\(lastBootstrapTrackCount)")
@@ -95,7 +96,38 @@ struct MacLibraryView: View {
Text(viewModel.scanStatus)
.foregroundStyle(.secondary)
List {
HStack(spacing: 12) {
Button("Upload Selected Track") {
Task {
await viewModel.uploadSelectedTrack()
}
}
.disabled(
viewModel.selectedTrackID == nil
|| viewModel.registeredDeviceId == nil
|| viewModel.isUploadingAnyTrack
|| viewModel.isUploadingAllTracks
)
Button("Upload All Local Tracks") {
Task {
await viewModel.uploadAllLocalTracks()
}
}
.disabled(
viewModel.tracks.isEmpty
|| viewModel.registeredDeviceId == nil
|| viewModel.isUploadingAnyTrack
|| viewModel.isUploadingAllTracks
)
if viewModel.isUploadingAnyTrack || viewModel.isUploadingAllTracks {
ProgressView()
.controlSize(.small)
}
}
List(selection: $viewModel.selectedTrackID) {
ForEach(viewModel.tracks, id: \.id) { track in
HStack(alignment: .top, spacing: 12) {
Button {
@@ -106,9 +138,19 @@ struct MacLibraryView: View {
}
.buttonStyle(.borderless)
VStack(alignment: .leading, spacing: 4) {
Text(track.title)
.font(.headline)
VStack(alignment: .leading, spacing: 6) {
HStack(alignment: .center, spacing: 8) {
Text(track.title)
.font(.headline)
Text(viewModel.uploadStatusLabel(for: track))
.font(.caption)
.foregroundStyle(uploadBadgeColor(for: track))
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(uploadBadgeColor(for: track).opacity(0.12), in: Capsule())
}
Text(track.artist)
.foregroundStyle(.secondary)
@@ -124,6 +166,27 @@ struct MacLibraryView: View {
.foregroundStyle(.tertiary)
}
if let remoteTrackId = track.remoteTrackId, !remoteTrackId.isEmpty {
Text("Remote track ID: \(remoteTrackId)")
.font(.caption2)
.foregroundStyle(.secondary)
.textSelection(.enabled)
}
if let uploadProgress = viewModel.uploadProgress(for: track) {
ProgressView(value: uploadProgress)
.frame(maxWidth: 220)
} else if [.preparing, .uploading].contains(viewModel.uploadStatus(for: track)) {
ProgressView()
.frame(maxWidth: 220, alignment: .leading)
}
if let lastUploadError = track.lastUploadError, !lastUploadError.isEmpty {
Text(lastUploadError)
.font(.caption2)
.foregroundStyle(.red)
}
Text(track.localFilePath)
.font(.caption2)
.foregroundStyle(.tertiary)
@@ -142,6 +205,7 @@ struct MacLibraryView: View {
}
}
.padding(.vertical, 4)
.tag(track.id)
}
}
.overlay {
@@ -169,6 +233,21 @@ struct MacLibraryView: View {
return String(format: "%d:%02d", minutes, seconds)
}
private func uploadBadgeColor(for track: LibraryTrack) -> Color {
switch viewModel.uploadStatus(for: track) {
case .localOnly:
return .secondary
case .preparing:
return .orange
case .uploading:
return .blue
case .uploaded:
return .green
case .failed:
return .red
}
}
private var displayedPlaybackTime: Double {
scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime
}
@@ -10,6 +10,7 @@ import VelodyUtilities
@Observable
final class MacLibraryViewModel {
var tracks: [LibraryTrack] = []
var selectedTrackID: String?
var selectedFolderPath = "No folder selected"
var scanStatus = "Choose a folder to begin local discovery."
var discoveredTrackCount = 0
@@ -21,23 +22,30 @@ final class MacLibraryViewModel {
var registeredDeviceId: String?
var lastHeartbeatStatus = "No heartbeat sent yet."
var lastSyncBootstrapStatus = "No sync bootstrap run yet."
var lastUploadStatus = "No uploads run yet."
var lastBootstrapTrackCount: Int?
var lastBootstrapCursor: String?
var isRegisteringDevice = false
var isSendingHeartbeat = false
var isRunningSyncBootstrap = false
var isUploadingAllTracks = false
var activeUploadTrackIDs: Set<String> = []
var uploadProgressByTrackID: [String: Double] = [:]
private let folderAccessService: any VelodyPersistence.FolderAccessService
private let catalogService: any LocalCatalogService
private let trackRepository: any TrackRepository
private let localMusicScanner: any LocalMusicScanner
private let playbackController: PlaybackController
private let keychainService: any KeychainService
private let userDefaults: UserDefaults
private let fileManager: FileManager
private var hasLoaded = false
init(
userDefaults: UserDefaults = .standard,
keychainService: any KeychainService = SystemKeychainService(service: "de.diyaa.velody.mac")
keychainService: any KeychainService = SystemKeychainService(service: "de.diyaa.velody.mac"),
fileManager: FileManager = .default
) {
let folderAccessService = FolderAccessService()
let localMusicScanner = FileSystemLocalMusicScanner(
@@ -53,10 +61,12 @@ final class MacLibraryViewModel {
self.folderAccessService = folderAccessService
self.catalogService = DefaultLocalCatalogService(repository: repository)
self.trackRepository = repository
self.localMusicScanner = localMusicScanner
self.playbackController = playbackController
self.keychainService = keychainService
self.userDefaults = userDefaults
self.fileManager = fileManager
self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey)
?? ServerEnvironment.defaultLocalBaseURL.absoluteString
self.nowPlayingState = playbackController.nowPlayingState
@@ -71,6 +81,10 @@ final class MacLibraryViewModel {
}
}
var isUploadingAnyTrack: Bool {
!activeUploadTrackIDs.isEmpty
}
func loadIfNeeded() async {
guard !hasLoaded else { return }
hasLoaded = true
@@ -82,6 +96,7 @@ final class MacLibraryViewModel {
}
playbackController.setCatalogTracks(tracks)
refreshSelectedTrackIfNeeded()
await restoreDeviceIdentity()
}
@@ -91,6 +106,7 @@ final class MacLibraryViewModel {
scanStatus = "Folder selected. Run a manual scan to discover MP3 files."
tracks = []
discoveredTrackCount = 0
selectedTrackID = nil
playbackController.setCatalogTracks([])
Task {
do {
@@ -124,6 +140,7 @@ final class MacLibraryViewModel {
tracks = scanResult.tracks
discoveredTrackCount = tracks.count
playbackController.setCatalogTracks(tracks)
refreshSelectedTrackIfNeeded()
scanStatus = Self.scanStatus(
for: scanResult,
activeTrackCount: discoveredTrackCount
@@ -202,6 +219,101 @@ final class MacLibraryViewModel {
nowPlayingState.error?.errorDescription
}
func uploadSelectedTrack() async {
guard let selectedTrackID else {
lastUploadStatus = "Select a local track before uploading."
return
}
_ = await uploadTrack(trackID: selectedTrackID)
}
func uploadAllLocalTracks() async {
guard !tracks.isEmpty else {
lastUploadStatus = "Scan a folder before starting uploads."
return
}
isUploadingAllTracks = true
lastUploadStatus = "Uploading all local tracks..."
defer {
isUploadingAllTracks = false
}
var uploadedCount = 0
var failedCount = 0
var skippedDuplicateCount = 0
var uploadedTrackIDsBySHA: [String: String] = [:]
var seenSHA256Values = Set<String>()
for track in tracks {
let sha256: String
do {
sha256 = try await ensureSHA256(forTrackID: track.id)
} catch {
failedCount += 1
lastUploadStatus = "Upload failed for \(track.title): \(error.localizedDescription)"
try? await setTrackUploadState(
trackID: track.id,
status: .failed,
remoteTrackId: track.remoteTrackId,
lastUploadError: error.localizedDescription,
progress: nil
)
continue
}
if !seenSHA256Values.insert(sha256).inserted {
skippedDuplicateCount += 1
if let remoteTrackID = uploadedTrackIDsBySHA[sha256] {
try? await setTrackUploadState(
trackID: track.id,
status: .uploaded,
remoteTrackId: remoteTrackID,
lastUploadError: nil,
progress: 1
)
}
continue
}
switch await uploadTrack(trackID: track.id, knownSHA256: sha256) {
case .success(let remoteTrackID):
uploadedCount += 1
if let remoteTrackID {
uploadedTrackIDsBySHA[sha256] = remoteTrackID
}
case .failure:
failedCount += 1
}
}
lastUploadStatus = "Bulk upload finished. Uploaded: \(uploadedCount). Failed: \(failedCount). Duplicates skipped: \(skippedDuplicateCount)."
}
func uploadStatus(for track: LibraryTrack) -> LocalUploadStatus {
track.uploadStatus ?? .localOnly
}
func uploadStatusLabel(for track: LibraryTrack) -> String {
switch uploadStatus(for: track) {
case .localOnly:
return "Local only"
case .preparing:
return "Preparing"
case .uploading:
return "Uploading"
case .uploaded:
return "Uploaded"
case .failed:
return "Failed"
}
}
func uploadProgress(for track: LibraryTrack) -> Double? {
uploadProgressByTrackID[track.id]
}
func persistServerURLSelection() {
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
return
@@ -282,6 +394,146 @@ final class MacLibraryViewModel {
}
}
private func uploadTrack(
trackID: String,
knownSHA256: String? = nil
) async -> UploadOutcome {
guard !activeUploadTrackIDs.contains(trackID) else {
return .failure
}
guard let initialTrack = track(for: trackID) else {
lastUploadStatus = "The selected track is no longer in the local catalog."
return .failure
}
activeUploadTrackIDs.insert(trackID)
defer {
activeUploadTrackIDs.remove(trackID)
}
do {
let environment = try currentEnvironment()
let deviceId = try await currentDeviceId()
let fileURL = URL(fileURLWithPath: initialTrack.localFilePath)
try await withStoredFolderAccess {
guard fileManager.fileExists(atPath: fileURL.path) else {
throw UploadPipelineError.localFileMissing
}
}
try await setTrackUploadState(
trackID: trackID,
status: .preparing,
remoteTrackId: initialTrack.remoteTrackId,
lastUploadError: nil,
progress: 0.15
)
let sha256 = try await ensureSHA256(
forTrackID: trackID,
knownValue: knownSHA256
)
let sizeBytes = try await withStoredFolderAccess {
try fileSize(at: fileURL)
}
let apiClient = makeAPIClient(for: environment)
let prepareResponse = try await apiClient.prepareUpload(
UploadPrepareRequest(
deviceId: deviceId,
sha256: sha256,
originalFilename: fileURL.lastPathComponent,
sizeBytes: sizeBytes
)
)
switch prepareResponse.status {
case .exists:
let remoteTrackID = prepareResponse.trackId ?? currentTrack(for: trackID)?.remoteTrackId
try await setTrackUploadState(
trackID: trackID,
status: .uploaded,
remoteTrackId: remoteTrackID,
lastUploadError: nil,
progress: 1
)
lastUploadStatus = remoteTrackID.map {
"Track already exists on the server as \($0)."
} ?? "Track already exists on the server."
return .success(remoteTrackId: remoteTrackID)
case .uploadRequired:
guard let uploadId = prepareResponse.uploadId else {
throw UploadPipelineError.invalidPrepareResponse
}
try await setTrackUploadState(
trackID: trackID,
status: .uploading,
remoteTrackId: currentTrack(for: trackID)?.remoteTrackId,
lastUploadError: nil,
progress: 0.55
)
let uploadResponse = try await withStoredFolderAccess {
try await apiClient.uploadFile(
uploadId: uploadId,
fileURL: fileURL,
mimeType: "audio/mpeg"
)
}
guard uploadResponse.status == .completed else {
throw UploadPipelineError.uploadDidNotComplete(uploadResponse.status.rawValue)
}
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
}
let finalizeResponse = try await apiClient.finalizeUpload(
uploadId: uploadId,
payload: UploadFinalizeRequest(
title: track.title,
artist: track.artist,
album: track.album,
durationMs: durationMilliseconds(from: track.durationSeconds)
)
)
try await setTrackUploadState(
trackID: trackID,
status: .uploaded,
remoteTrackId: finalizeResponse.trackId,
lastUploadError: nil,
progress: 1
)
lastUploadStatus = "Uploaded \(track.title) as remote track \(finalizeResponse.trackId)."
return .success(remoteTrackId: finalizeResponse.trackId)
}
} catch {
let remoteTrackID = currentTrack(for: trackID)?.remoteTrackId
try? await setTrackUploadState(
trackID: trackID,
status: .failed,
remoteTrackId: remoteTrackID,
lastUploadError: error.localizedDescription,
progress: nil
)
lastUploadStatus = "Upload failed for \(initialTrack.title): \(error.localizedDescription)"
return .failure
}
}
private func restoreDeviceIdentity() async {
do {
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
@@ -336,6 +588,145 @@ final class MacLibraryViewModel {
URLSessionVelodyAPIClient(environment: environment)
}
private func currentTrack(for trackID: String) -> LibraryTrack? {
tracks.first(where: { $0.id == trackID })
}
private func track(for trackID: String) -> LibraryTrack? {
tracks.first(where: { $0.id == trackID })
}
private func ensureSHA256(
forTrackID trackID: String,
knownValue: String? = nil
) async throws -> String {
if let knownValue, !knownValue.isEmpty {
return knownValue
}
guard let track = currentTrack(for: trackID) else {
throw UploadPipelineError.trackMissing
}
if let sha256 = track.sha256, !sha256.isEmpty {
return sha256
}
let fileURL = URL(fileURLWithPath: track.localFilePath)
let sha256 = try await withStoredFolderAccess {
try await Task.detached(priority: .utility) {
try SHA256FileHasher().hashFile(at: fileURL)
}.value
}
try await setTrackUploadState(
trackID: trackID,
status: track.uploadStatus ?? .localOnly,
remoteTrackId: track.remoteTrackId,
lastUploadError: track.lastUploadError,
progress: uploadProgressByTrackID[trackID],
sha256: sha256
)
return sha256
}
private func setTrackUploadState(
trackID: String,
status: LocalUploadStatus,
remoteTrackId: String?,
lastUploadError: String?,
progress: Double? = nil,
sha256: String? = nil
) async throws {
guard let index = tracks.firstIndex(where: { $0.id == trackID }) else {
throw UploadPipelineError.trackMissing
}
var updatedTrack = tracks[index]
updatedTrack.uploadStatus = status
updatedTrack.remoteTrackId = remoteTrackId
updatedTrack.lastUploadError = lastUploadError
if let sha256 {
updatedTrack.sha256 = sha256
}
tracks[index] = updatedTrack
playbackController.setCatalogTracks(tracks)
if let progress {
uploadProgressByTrackID[trackID] = progress
} else {
uploadProgressByTrackID.removeValue(forKey: trackID)
}
try await persistTrackState(updatedTrack)
}
private func persistTrackState(_ track: LibraryTrack) async throws {
guard var localTrack = try await trackRepository.findTrack(trackID: track.id) else {
throw UploadPipelineError.trackMissing
}
localTrack.title = track.title
localTrack.artist = track.artist
localTrack.album = track.album
localTrack.durationSeconds = track.durationSeconds
localTrack.localFilePath = track.localFilePath
localTrack.sha256 = track.sha256
localTrack.uploadStatus = track.uploadStatus ?? .localOnly
localTrack.remoteTrackId = track.remoteTrackId
localTrack.lastUploadError = track.lastUploadError
localTrack.updatedAt = Date()
try await trackRepository.saveLocalTrack(localTrack)
}
private func fileSize(at fileURL: URL) throws -> Int {
if let fileSize = try fileURL.resourceValues(forKeys: [.fileSizeKey]).fileSize {
return fileSize
}
let attributes = try fileManager.attributesOfItem(atPath: fileURL.path)
if let fileSize = attributes[.size] as? NSNumber {
return fileSize.intValue
}
throw UploadPipelineError.unreadableLocalFile
}
private func durationMilliseconds(from durationSeconds: Double?) -> Int? {
guard let durationSeconds, durationSeconds > 0 else {
return nil
}
return Int((durationSeconds * 1000).rounded())
}
private func withStoredFolderAccess<T>(
_ operation: () async throws -> T
) async throws -> T {
guard let folderURL = folderAccessService.storedFolderURL() else {
return try await operation()
}
let hasScopedAccess = folderURL.startAccessingSecurityScopedResource()
defer {
if hasScopedAccess {
folderURL.stopAccessingSecurityScopedResource()
}
}
return try await operation()
}
private func refreshSelectedTrackIfNeeded() {
guard let selectedTrackID else { return }
if !tracks.contains(where: { $0.id == selectedTrackID }) {
self.selectedTrackID = nil
}
}
private static func makeTrackRepository() -> any TrackRepository {
if let repository = try? SwiftDataTrackRepository() {
return repository
@@ -400,6 +791,11 @@ final class MacLibraryViewModel {
private static let playbackSessionDefaultsKey = "velody.playback.session"
}
private enum UploadOutcome {
case success(remoteTrackId: String?)
case failure
}
private enum BackendConnectionError: LocalizedError {
case invalidServerURL
case missingDeviceIdentity
@@ -409,7 +805,30 @@ private enum BackendConnectionError: LocalizedError {
case .invalidServerURL:
return "Enter a valid backend URL, such as http://localhost:3000."
case .missingDeviceIdentity:
return "Register this Mac before sending a heartbeat."
return "Register this Mac before uploading or sending a heartbeat."
}
}
}
private enum UploadPipelineError: LocalizedError {
case localFileMissing
case unreadableLocalFile
case invalidPrepareResponse
case uploadDidNotComplete(String)
case trackMissing
var errorDescription: String? {
switch self {
case .localFileMissing:
return "The local MP3 file could not be found."
case .unreadableLocalFile:
return "The local MP3 file could not be read."
case .invalidPrepareResponse:
return "The backend did not return an upload session."
case let .uploadDidNotComplete(status):
return "The backend reported upload status \(status) instead of COMPLETED."
case .trackMissing:
return "The selected track is no longer available in the local catalog."
}
}
}