Add MP3 upload pipeline foundation
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user