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