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
@@ -5,6 +5,14 @@ public enum DevicePlatform: String, Codable, Sendable, CaseIterable {
case iphone = "IPHONE"
}
public enum LocalUploadStatus: String, Codable, Hashable, Sendable, CaseIterable {
case localOnly
case preparing
case uploading
case uploaded
case failed
}
public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
public let id: String
public var title: String
@@ -13,6 +21,9 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
public var durationSeconds: Double?
public var localFilePath: String
public var sha256: String?
public var uploadStatus: LocalUploadStatus?
public var remoteTrackId: String?
public var lastUploadError: String?
public init(
id: String = UUID().uuidString,
@@ -21,7 +32,10 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
album: String? = nil,
durationSeconds: Double? = nil,
localFilePath: String = "",
sha256: String? = nil
sha256: String? = nil,
uploadStatus: LocalUploadStatus? = nil,
remoteTrackId: String? = nil,
lastUploadError: String? = nil
) {
self.id = id
self.title = title
@@ -30,6 +44,9 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
self.durationSeconds = durationSeconds
self.localFilePath = localFilePath
self.sha256 = sha256
self.uploadStatus = uploadStatus
self.remoteTrackId = remoteTrackId
self.lastUploadError = lastUploadError
}
}
@@ -91,6 +108,116 @@ public struct DeviceHeartbeatResponse: Codable, Hashable, Sendable {
}
}
public enum UploadPrepareStatus: String, Codable, Hashable, Sendable {
case exists
case uploadRequired = "upload_required"
}
public struct UploadPrepareRequest: Codable, Hashable, Sendable {
public var deviceId: String
public var sha256: String
public var originalFilename: String
public var sizeBytes: Int
public init(
deviceId: String,
sha256: String,
originalFilename: String,
sizeBytes: Int
) {
self.deviceId = deviceId
self.sha256 = sha256
self.originalFilename = originalFilename
self.sizeBytes = sizeBytes
}
}
public struct UploadPrepareResponse: Codable, Hashable, Sendable {
public var status: UploadPrepareStatus
public var uploadId: String?
public var nextOffset: Int?
public var trackId: String?
public var assetId: String?
public init(
status: UploadPrepareStatus,
uploadId: String? = nil,
nextOffset: Int? = nil,
trackId: String? = nil,
assetId: String? = nil
) {
self.status = status
self.uploadId = uploadId
self.nextOffset = nextOffset
self.trackId = trackId
self.assetId = assetId
}
}
public enum UploadSessionState: String, Codable, Hashable, Sendable {
case pending = "PENDING"
case readyToUpload = "READY_TO_UPLOAD"
case completed = "COMPLETED"
case failed = "FAILED"
}
public struct UploadSessionStatusResponse: Codable, Hashable, Sendable {
public var uploadId: String
public var status: UploadSessionState
public var receivedBytes: String
public var expectedSizeBytes: String
public var nextOffset: String
public var finalizedAt: String?
public init(
uploadId: String,
status: UploadSessionState,
receivedBytes: String,
expectedSizeBytes: String,
nextOffset: String,
finalizedAt: String? = nil
) {
self.uploadId = uploadId
self.status = status
self.receivedBytes = receivedBytes
self.expectedSizeBytes = expectedSizeBytes
self.nextOffset = nextOffset
self.finalizedAt = finalizedAt
}
}
public struct UploadFinalizeRequest: Codable, Hashable, Sendable {
public var title: String
public var artist: String
public var album: String?
public var durationMs: Int?
public init(
title: String,
artist: String,
album: String? = nil,
durationMs: Int? = nil
) {
self.title = title
self.artist = artist
self.album = album
self.durationMs = durationMs
}
}
public struct UploadFinalizeResponse: Codable, Hashable, Sendable {
public var trackId: String
public var assetId: String
public init(
trackId: String,
assetId: String
) {
self.trackId = trackId
self.assetId = assetId
}
}
public struct SyncCursor: Codable, Hashable, Sendable {
public var value: String
@@ -38,6 +38,25 @@ public protocol VelodyAPIClient: Sendable {
) async throws -> DeviceHeartbeatResponse
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse
func fetchUploadStatus(
uploadId: String
) async throws -> UploadSessionStatusResponse
func uploadFile(
uploadId: String,
fileURL: URL,
mimeType: String
) async throws -> UploadSessionStatusResponse
func finalizeUpload(
uploadId: String,
payload: UploadFinalizeRequest
) async throws -> UploadFinalizeResponse
}
public struct URLSessionVelodyAPIClient: VelodyAPIClient {
@@ -87,6 +106,71 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
)
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
try await sendRequest(
method: "POST",
pathComponents: ["api", "v1", "uploads", "prepare"],
body: payload,
responseType: UploadPrepareResponse.self
)
}
public func fetchUploadStatus(
uploadId: String
) async throws -> UploadSessionStatusResponse {
try await sendRequest(
method: "GET",
pathComponents: ["api", "v1", "uploads", uploadId],
responseType: UploadSessionStatusResponse.self
)
}
public func uploadFile(
uploadId: String,
fileURL: URL,
mimeType: String = "audio/mpeg"
) async throws -> UploadSessionStatusResponse {
guard FileManager.default.fileExists(atPath: fileURL.path) else {
throw VelodyAPIError.requestFailed("The selected file could not be found.")
}
let request = try buildRequest(
method: "PUT",
pathComponents: ["api", "v1", "uploads", uploadId, "file"],
bodyData: nil,
contentType: mimeType
)
let data: Data
let response: URLResponse
do {
(data, response) = try await session.upload(for: request, fromFile: fileURL)
} catch {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
return try decodeResponse(
data: data,
response: response,
responseType: UploadSessionStatusResponse.self
)
}
public func finalizeUpload(
uploadId: String,
payload: UploadFinalizeRequest
) async throws -> UploadFinalizeResponse {
try await sendRequest(
method: "POST",
pathComponents: ["api", "v1", "uploads", uploadId, "finalize"],
body: payload,
responseType: UploadFinalizeResponse.self
)
}
private func sendRequest<Response: Decodable>(
method: String,
pathComponents: [String],
@@ -118,7 +202,8 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
let request = try buildRequest(
method: method,
pathComponents: pathComponents,
bodyData: bodyData
bodyData: bodyData,
contentType: "application/json"
)
return try await execute(request, responseType: responseType)
@@ -127,7 +212,8 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
private func buildRequest(
method: String,
pathComponents: [String],
bodyData: Data?
bodyData: Data?,
contentType: String? = nil
) throws -> URLRequest {
guard let url = endpointURL(pathComponents: pathComponents) else {
throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString)
@@ -139,7 +225,10 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
if let bodyData {
request.httpBody = bodyData
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
}
if let contentType {
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
}
return request
@@ -158,6 +247,28 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
throw VelodyAPIError.requestFailed(error.localizedDescription)
}
return try decodeResponse(
data: data,
response: response,
responseType: responseType
)
}
private func decodeResponse<Response: Decodable>(
data: Data,
response: URLResponse,
responseType: Response.Type
) throws -> Response {
try validate(response: response, data: data)
do {
return try decoder.decode(responseType, from: data)
} catch {
throw VelodyAPIError.decodingFailed(error.localizedDescription)
}
}
private func validate(response: URLResponse, data: Data) throws {
guard let httpResponse = response as? HTTPURLResponse else {
throw VelodyAPIError.invalidResponse
}
@@ -171,12 +282,6 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
message: message?.isEmpty == true ? nil : message
)
}
do {
return try decoder.decode(responseType, from: data)
} catch {
throw VelodyAPIError.decodingFailed(error.localizedDescription)
}
}
private func endpointURL(pathComponents: [String]) -> URL? {
@@ -234,4 +339,58 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
serverTime: ISO8601DateFormatter().string(from: .now)
)
}
public func prepareUpload(
_ payload: UploadPrepareRequest
) async throws -> UploadPrepareResponse {
_ = payload
return UploadPrepareResponse(
status: .uploadRequired,
uploadId: UUID().uuidString,
nextOffset: 0
)
}
public func fetchUploadStatus(
uploadId: String
) async throws -> UploadSessionStatusResponse {
UploadSessionStatusResponse(
uploadId: uploadId,
status: .completed,
receivedBytes: "0",
expectedSizeBytes: "0",
nextOffset: "0"
)
}
public func uploadFile(
uploadId: String,
fileURL: URL,
mimeType: String
) async throws -> UploadSessionStatusResponse {
_ = fileURL
_ = mimeType
return UploadSessionStatusResponse(
uploadId: uploadId,
status: .completed,
receivedBytes: "0",
expectedSizeBytes: "0",
nextOffset: "0"
)
}
public func finalizeUpload(
uploadId: String,
payload: UploadFinalizeRequest
) async throws -> UploadFinalizeResponse {
_ = uploadId
_ = payload
return UploadFinalizeResponse(
trackId: UUID().uuidString,
assetId: UUID().uuidString
)
}
}
@@ -15,6 +15,9 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
public var durationSeconds: Double?
public var localFilePath: String
public var sha256: String?
public var uploadStatus: LocalUploadStatus
public var remoteTrackId: String?
public var lastUploadError: String?
public var fileModifiedAt: Date?
public var lastScannedAt: Date?
public var isDeleted: Bool
@@ -31,6 +34,9 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
durationSeconds: Double? = nil,
localFilePath: String = "",
sha256: String? = nil,
uploadStatus: LocalUploadStatus = .localOnly,
remoteTrackId: String? = nil,
lastUploadError: String? = nil,
fileModifiedAt: Date? = nil,
lastScannedAt: Date? = nil,
isDeleted: Bool = false,
@@ -46,6 +52,9 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
self.durationSeconds = durationSeconds
self.localFilePath = localFilePath
self.sha256 = sha256
self.uploadStatus = uploadStatus
self.remoteTrackId = remoteTrackId
self.lastUploadError = lastUploadError
self.fileModifiedAt = fileModifiedAt
self.lastScannedAt = lastScannedAt
self.isDeleted = isDeleted
@@ -68,6 +77,9 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
durationSeconds: scannedTrack.durationSeconds,
localFilePath: scannedTrack.localFilePath,
sha256: scannedTrack.sha256,
uploadStatus: .localOnly,
remoteTrackId: nil,
lastUploadError: nil,
fileModifiedAt: scannedTrack.fileModifiedAt,
lastScannedAt: observedAt,
isDeleted: false,
@@ -91,6 +103,9 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
durationSeconds: libraryTrack.durationSeconds,
localFilePath: libraryTrack.localFilePath,
sha256: libraryTrack.sha256,
uploadStatus: libraryTrack.uploadStatus ?? .localOnly,
remoteTrackId: libraryTrack.remoteTrackId,
lastUploadError: libraryTrack.lastUploadError,
fileModifiedAt: nil,
lastScannedAt: origin == .localScan ? observedAt : nil,
isDeleted: false,
@@ -116,7 +131,10 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
album: album,
durationSeconds: durationSeconds,
localFilePath: localFilePath,
sha256: sha256
sha256: sha256,
uploadStatus: uploadStatus,
remoteTrackId: remoteTrackId,
lastUploadError: lastUploadError
)
}
@@ -1,5 +1,6 @@
import Foundation
import SwiftData
import VelodyDomain
@Model
final class TrackEntity {
@@ -12,6 +13,9 @@ final class TrackEntity {
var durationSeconds: Double?
var localFilePath: String
var sha256: String?
var uploadStatusRawValue: String?
var remoteTrackID: String?
var lastUploadError: String?
var fileModifiedAt: Date?
var lastScannedAt: Date?
var isMarkedDeleted: Bool
@@ -29,6 +33,9 @@ final class TrackEntity {
durationSeconds = track.durationSeconds
localFilePath = track.localFilePath
sha256 = track.sha256
uploadStatusRawValue = track.uploadStatus.rawValue
remoteTrackID = track.remoteTrackId
lastUploadError = track.lastUploadError
fileModifiedAt = track.fileModifiedAt
lastScannedAt = track.lastScannedAt
isMarkedDeleted = track.isDeleted
@@ -47,6 +54,9 @@ final class TrackEntity {
durationSeconds: durationSeconds,
localFilePath: localFilePath,
sha256: sha256,
uploadStatus: LocalUploadStatus(rawValue: uploadStatusRawValue ?? "") ?? .localOnly,
remoteTrackId: remoteTrackID,
lastUploadError: lastUploadError,
fileModifiedAt: fileModifiedAt,
lastScannedAt: lastScannedAt,
isDeleted: isMarkedDeleted,
@@ -66,6 +76,9 @@ final class TrackEntity {
durationSeconds = track.durationSeconds
localFilePath = track.localFilePath
sha256 = track.sha256
uploadStatusRawValue = track.uploadStatus.rawValue
remoteTrackID = track.remoteTrackId
lastUploadError = track.lastUploadError
fileModifiedAt = track.fileModifiedAt
lastScannedAt = track.lastScannedAt
isMarkedDeleted = track.isDeleted