Add MP3 upload pipeline foundation
This commit is contained in:
parent
799c07b068
commit
98edfa0faf
@ -29,6 +29,7 @@ struct MacLibraryView: View {
|
|||||||
)
|
)
|
||||||
statusRow(title: "Last heartbeat", value: viewModel.lastHeartbeatStatus)
|
statusRow(title: "Last heartbeat", value: viewModel.lastHeartbeatStatus)
|
||||||
statusRow(title: "Last bootstrap", value: viewModel.lastSyncBootstrapStatus)
|
statusRow(title: "Last bootstrap", value: viewModel.lastSyncBootstrapStatus)
|
||||||
|
statusRow(title: "Last upload", value: viewModel.lastUploadStatus)
|
||||||
|
|
||||||
if let lastBootstrapTrackCount = viewModel.lastBootstrapTrackCount {
|
if let lastBootstrapTrackCount = viewModel.lastBootstrapTrackCount {
|
||||||
statusRow(title: "Bootstrap tracks", value: "\(lastBootstrapTrackCount)")
|
statusRow(title: "Bootstrap tracks", value: "\(lastBootstrapTrackCount)")
|
||||||
@ -95,7 +96,38 @@ struct MacLibraryView: View {
|
|||||||
Text(viewModel.scanStatus)
|
Text(viewModel.scanStatus)
|
||||||
.foregroundStyle(.secondary)
|
.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
|
ForEach(viewModel.tracks, id: \.id) { track in
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
Button {
|
Button {
|
||||||
@ -106,9 +138,19 @@ struct MacLibraryView: View {
|
|||||||
}
|
}
|
||||||
.buttonStyle(.borderless)
|
.buttonStyle(.borderless)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 4) {
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
Text(track.title)
|
HStack(alignment: .center, spacing: 8) {
|
||||||
.font(.headline)
|
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)
|
Text(track.artist)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
@ -124,6 +166,27 @@ struct MacLibraryView: View {
|
|||||||
.foregroundStyle(.tertiary)
|
.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)
|
Text(track.localFilePath)
|
||||||
.font(.caption2)
|
.font(.caption2)
|
||||||
.foregroundStyle(.tertiary)
|
.foregroundStyle(.tertiary)
|
||||||
@ -142,6 +205,7 @@ struct MacLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding(.vertical, 4)
|
.padding(.vertical, 4)
|
||||||
|
.tag(track.id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
@ -169,6 +233,21 @@ struct MacLibraryView: View {
|
|||||||
return String(format: "%d:%02d", minutes, seconds)
|
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 {
|
private var displayedPlaybackTime: Double {
|
||||||
scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime
|
scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import VelodyUtilities
|
|||||||
@Observable
|
@Observable
|
||||||
final class MacLibraryViewModel {
|
final class MacLibraryViewModel {
|
||||||
var tracks: [LibraryTrack] = []
|
var tracks: [LibraryTrack] = []
|
||||||
|
var selectedTrackID: String?
|
||||||
var selectedFolderPath = "No folder selected"
|
var selectedFolderPath = "No folder selected"
|
||||||
var scanStatus = "Choose a folder to begin local discovery."
|
var scanStatus = "Choose a folder to begin local discovery."
|
||||||
var discoveredTrackCount = 0
|
var discoveredTrackCount = 0
|
||||||
@ -21,23 +22,30 @@ final class MacLibraryViewModel {
|
|||||||
var registeredDeviceId: String?
|
var registeredDeviceId: String?
|
||||||
var lastHeartbeatStatus = "No heartbeat sent yet."
|
var lastHeartbeatStatus = "No heartbeat sent yet."
|
||||||
var lastSyncBootstrapStatus = "No sync bootstrap run yet."
|
var lastSyncBootstrapStatus = "No sync bootstrap run yet."
|
||||||
|
var lastUploadStatus = "No uploads run yet."
|
||||||
var lastBootstrapTrackCount: Int?
|
var lastBootstrapTrackCount: Int?
|
||||||
var lastBootstrapCursor: String?
|
var lastBootstrapCursor: String?
|
||||||
var isRegisteringDevice = false
|
var isRegisteringDevice = false
|
||||||
var isSendingHeartbeat = false
|
var isSendingHeartbeat = false
|
||||||
var isRunningSyncBootstrap = false
|
var isRunningSyncBootstrap = false
|
||||||
|
var isUploadingAllTracks = false
|
||||||
|
var activeUploadTrackIDs: Set<String> = []
|
||||||
|
var uploadProgressByTrackID: [String: Double] = [:]
|
||||||
|
|
||||||
private let folderAccessService: any VelodyPersistence.FolderAccessService
|
private let folderAccessService: any VelodyPersistence.FolderAccessService
|
||||||
private let catalogService: any LocalCatalogService
|
private let catalogService: any LocalCatalogService
|
||||||
|
private let trackRepository: any TrackRepository
|
||||||
private let localMusicScanner: any LocalMusicScanner
|
private let localMusicScanner: any LocalMusicScanner
|
||||||
private let playbackController: PlaybackController
|
private let playbackController: PlaybackController
|
||||||
private let keychainService: any KeychainService
|
private let keychainService: any KeychainService
|
||||||
private let userDefaults: UserDefaults
|
private let userDefaults: UserDefaults
|
||||||
|
private let fileManager: FileManager
|
||||||
private var hasLoaded = false
|
private var hasLoaded = false
|
||||||
|
|
||||||
init(
|
init(
|
||||||
userDefaults: UserDefaults = .standard,
|
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 folderAccessService = FolderAccessService()
|
||||||
let localMusicScanner = FileSystemLocalMusicScanner(
|
let localMusicScanner = FileSystemLocalMusicScanner(
|
||||||
@ -53,10 +61,12 @@ final class MacLibraryViewModel {
|
|||||||
|
|
||||||
self.folderAccessService = folderAccessService
|
self.folderAccessService = folderAccessService
|
||||||
self.catalogService = DefaultLocalCatalogService(repository: repository)
|
self.catalogService = DefaultLocalCatalogService(repository: repository)
|
||||||
|
self.trackRepository = repository
|
||||||
self.localMusicScanner = localMusicScanner
|
self.localMusicScanner = localMusicScanner
|
||||||
self.playbackController = playbackController
|
self.playbackController = playbackController
|
||||||
self.keychainService = keychainService
|
self.keychainService = keychainService
|
||||||
self.userDefaults = userDefaults
|
self.userDefaults = userDefaults
|
||||||
|
self.fileManager = fileManager
|
||||||
self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey)
|
self.serverURLString = userDefaults.string(forKey: Self.serverURLDefaultsKey)
|
||||||
?? ServerEnvironment.defaultLocalBaseURL.absoluteString
|
?? ServerEnvironment.defaultLocalBaseURL.absoluteString
|
||||||
self.nowPlayingState = playbackController.nowPlayingState
|
self.nowPlayingState = playbackController.nowPlayingState
|
||||||
@ -71,6 +81,10 @@ final class MacLibraryViewModel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var isUploadingAnyTrack: Bool {
|
||||||
|
!activeUploadTrackIDs.isEmpty
|
||||||
|
}
|
||||||
|
|
||||||
func loadIfNeeded() async {
|
func loadIfNeeded() async {
|
||||||
guard !hasLoaded else { return }
|
guard !hasLoaded else { return }
|
||||||
hasLoaded = true
|
hasLoaded = true
|
||||||
@ -82,6 +96,7 @@ final class MacLibraryViewModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
playbackController.setCatalogTracks(tracks)
|
playbackController.setCatalogTracks(tracks)
|
||||||
|
refreshSelectedTrackIfNeeded()
|
||||||
await restoreDeviceIdentity()
|
await restoreDeviceIdentity()
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,6 +106,7 @@ final class MacLibraryViewModel {
|
|||||||
scanStatus = "Folder selected. Run a manual scan to discover MP3 files."
|
scanStatus = "Folder selected. Run a manual scan to discover MP3 files."
|
||||||
tracks = []
|
tracks = []
|
||||||
discoveredTrackCount = 0
|
discoveredTrackCount = 0
|
||||||
|
selectedTrackID = nil
|
||||||
playbackController.setCatalogTracks([])
|
playbackController.setCatalogTracks([])
|
||||||
Task {
|
Task {
|
||||||
do {
|
do {
|
||||||
@ -124,6 +140,7 @@ final class MacLibraryViewModel {
|
|||||||
tracks = scanResult.tracks
|
tracks = scanResult.tracks
|
||||||
discoveredTrackCount = tracks.count
|
discoveredTrackCount = tracks.count
|
||||||
playbackController.setCatalogTracks(tracks)
|
playbackController.setCatalogTracks(tracks)
|
||||||
|
refreshSelectedTrackIfNeeded()
|
||||||
scanStatus = Self.scanStatus(
|
scanStatus = Self.scanStatus(
|
||||||
for: scanResult,
|
for: scanResult,
|
||||||
activeTrackCount: discoveredTrackCount
|
activeTrackCount: discoveredTrackCount
|
||||||
@ -202,6 +219,101 @@ final class MacLibraryViewModel {
|
|||||||
nowPlayingState.error?.errorDescription
|
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() {
|
func persistServerURLSelection() {
|
||||||
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
|
guard let serverURL = Self.normalizedServerURL(from: serverURLString) else {
|
||||||
return
|
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 {
|
private func restoreDeviceIdentity() async {
|
||||||
do {
|
do {
|
||||||
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
|
let deviceId = try await keychainService.loadValue(forKey: Self.deviceIdKey)
|
||||||
@ -336,6 +588,145 @@ final class MacLibraryViewModel {
|
|||||||
URLSessionVelodyAPIClient(environment: environment)
|
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 {
|
private static func makeTrackRepository() -> any TrackRepository {
|
||||||
if let repository = try? SwiftDataTrackRepository() {
|
if let repository = try? SwiftDataTrackRepository() {
|
||||||
return repository
|
return repository
|
||||||
@ -400,6 +791,11 @@ final class MacLibraryViewModel {
|
|||||||
private static let playbackSessionDefaultsKey = "velody.playback.session"
|
private static let playbackSessionDefaultsKey = "velody.playback.session"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private enum UploadOutcome {
|
||||||
|
case success(remoteTrackId: String?)
|
||||||
|
case failure
|
||||||
|
}
|
||||||
|
|
||||||
private enum BackendConnectionError: LocalizedError {
|
private enum BackendConnectionError: LocalizedError {
|
||||||
case invalidServerURL
|
case invalidServerURL
|
||||||
case missingDeviceIdentity
|
case missingDeviceIdentity
|
||||||
@ -409,7 +805,30 @@ private enum BackendConnectionError: LocalizedError {
|
|||||||
case .invalidServerURL:
|
case .invalidServerURL:
|
||||||
return "Enter a valid backend URL, such as http://localhost:3000."
|
return "Enter a valid backend URL, such as http://localhost:3000."
|
||||||
case .missingDeviceIdentity:
|
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."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -145,10 +145,82 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/uploads/{uploadId}/file": {
|
||||||
|
"put": {
|
||||||
|
"operationId": "UploadsController_uploadFile_v1",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "uploadId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"audio/mpeg": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"audio/mp3": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"application/octet-stream": {
|
||||||
|
"schema": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "binary"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UploadSessionStatusResponseDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"tags": [
|
||||||
|
"uploads"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/uploads/{uploadId}/finalize": {
|
"/api/v1/uploads/{uploadId}/finalize": {
|
||||||
"post": {
|
"post": {
|
||||||
"operationId": "UploadsController_finalize_v1",
|
"operationId": "UploadsController_finalize_v1",
|
||||||
"parameters": [],
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "uploadId",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UploadFinalizeRequestDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"responses": {
|
"responses": {
|
||||||
"200": {
|
"200": {
|
||||||
"description": "",
|
"description": "",
|
||||||
@ -161,7 +233,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"summary": "Reserved for the next milestone",
|
|
||||||
"tags": [
|
"tags": [
|
||||||
"uploads"
|
"uploads"
|
||||||
]
|
]
|
||||||
@ -414,6 +485,14 @@
|
|||||||
"nextOffset": {
|
"nextOffset": {
|
||||||
"type": "number",
|
"type": "number",
|
||||||
"example": 0
|
"example": 0
|
||||||
|
},
|
||||||
|
"trackId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"assetId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -447,6 +526,10 @@
|
|||||||
"nextOffset": {
|
"nextOffset": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": 0
|
"example": 0
|
||||||
|
},
|
||||||
|
"finalizedAt": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "2026-05-28T12:00:00.000Z"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@ -457,21 +540,46 @@
|
|||||||
"nextOffset"
|
"nextOffset"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"UploadFinalizeResponseDto": {
|
"UploadFinalizeRequestDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
"statusCode": {
|
"title": {
|
||||||
"type": "number",
|
|
||||||
"example": 501
|
|
||||||
},
|
|
||||||
"message": {
|
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"example": "Upload finalization is not implemented yet."
|
"example": "Track Title"
|
||||||
|
},
|
||||||
|
"artist": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Track Artist"
|
||||||
|
},
|
||||||
|
"album": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Album Title"
|
||||||
|
},
|
||||||
|
"durationMs": {
|
||||||
|
"type": "number",
|
||||||
|
"example": 245000
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
"statusCode",
|
"title",
|
||||||
"message"
|
"artist"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"UploadFinalizeResponseDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"trackId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"assetId": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"trackId",
|
||||||
|
"assetId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"LibraryTrackDto": {
|
"LibraryTrackDto": {
|
||||||
|
|||||||
@ -0,0 +1,135 @@
|
|||||||
|
CREATE TABLE "users" (
|
||||||
|
"id" UUID NOT NULL DEFAULT gen_random_uuid(),
|
||||||
|
"slug" TEXT NOT NULL,
|
||||||
|
"display_name" TEXT NOT NULL,
|
||||||
|
"is_default" BOOLEAN NOT NULL DEFAULT false,
|
||||||
|
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
"updated_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
CONSTRAINT "users_pkey" PRIMARY KEY ("id")
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "users_slug_key" ON "users"("slug");
|
||||||
|
|
||||||
|
INSERT INTO "users" (
|
||||||
|
"id",
|
||||||
|
"slug",
|
||||||
|
"display_name",
|
||||||
|
"is_default",
|
||||||
|
"created_at",
|
||||||
|
"updated_at"
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
gen_random_uuid(),
|
||||||
|
'default-owner',
|
||||||
|
'Default Owner',
|
||||||
|
true,
|
||||||
|
CURRENT_TIMESTAMP,
|
||||||
|
CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
ON CONFLICT ("slug") DO NOTHING;
|
||||||
|
|
||||||
|
ALTER TABLE "artwork_assets"
|
||||||
|
ALTER COLUMN "id" SET DEFAULT gen_random_uuid();
|
||||||
|
|
||||||
|
ALTER TABLE "audio_assets"
|
||||||
|
ALTER COLUMN "id" SET DEFAULT gen_random_uuid(),
|
||||||
|
ADD COLUMN "user_id" UUID;
|
||||||
|
|
||||||
|
ALTER TABLE "device_sync_cursors"
|
||||||
|
ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP;
|
||||||
|
|
||||||
|
ALTER TABLE "devices"
|
||||||
|
ALTER COLUMN "id" SET DEFAULT gen_random_uuid(),
|
||||||
|
ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "user_id" UUID;
|
||||||
|
|
||||||
|
ALTER TABLE "library_events"
|
||||||
|
ADD COLUMN "user_id" UUID;
|
||||||
|
|
||||||
|
ALTER TABLE "tracks"
|
||||||
|
ALTER COLUMN "id" SET DEFAULT gen_random_uuid(),
|
||||||
|
ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "user_id" UUID;
|
||||||
|
|
||||||
|
ALTER TABLE "upload_sessions"
|
||||||
|
ALTER COLUMN "id" SET DEFAULT gen_random_uuid(),
|
||||||
|
ALTER COLUMN "updated_at" SET DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
ADD COLUMN "user_id" UUID,
|
||||||
|
ADD COLUMN "track_id" UUID,
|
||||||
|
ADD COLUMN "audio_asset_id" UUID,
|
||||||
|
ADD COLUMN "original_filename" TEXT,
|
||||||
|
ADD COLUMN "completed_at" TIMESTAMP(3),
|
||||||
|
ADD COLUMN "finalized_at" TIMESTAMP(3);
|
||||||
|
|
||||||
|
UPDATE "devices"
|
||||||
|
SET "user_id" = (SELECT "id" FROM "users" WHERE "slug" = 'default-owner')
|
||||||
|
WHERE "user_id" IS NULL;
|
||||||
|
|
||||||
|
UPDATE "tracks"
|
||||||
|
SET "user_id" = (SELECT "id" FROM "users" WHERE "slug" = 'default-owner')
|
||||||
|
WHERE "user_id" IS NULL;
|
||||||
|
|
||||||
|
UPDATE "audio_assets"
|
||||||
|
SET "user_id" = (SELECT "id" FROM "users" WHERE "slug" = 'default-owner')
|
||||||
|
WHERE "user_id" IS NULL;
|
||||||
|
|
||||||
|
UPDATE "upload_sessions"
|
||||||
|
SET
|
||||||
|
"user_id" = (SELECT "id" FROM "users" WHERE "slug" = 'default-owner'),
|
||||||
|
"original_filename" = COALESCE("original_filename", "expected_sha256" || '.mp3')
|
||||||
|
WHERE "user_id" IS NULL
|
||||||
|
OR "original_filename" IS NULL;
|
||||||
|
|
||||||
|
UPDATE "library_events"
|
||||||
|
SET "user_id" = (SELECT "id" FROM "users" WHERE "slug" = 'default-owner')
|
||||||
|
WHERE "user_id" IS NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "audio_assets"
|
||||||
|
ALTER COLUMN "user_id" SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "devices"
|
||||||
|
ALTER COLUMN "user_id" SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "library_events"
|
||||||
|
ALTER COLUMN "user_id" SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "tracks"
|
||||||
|
ALTER COLUMN "user_id" SET NOT NULL;
|
||||||
|
|
||||||
|
ALTER TABLE "upload_sessions"
|
||||||
|
ALTER COLUMN "user_id" SET NOT NULL,
|
||||||
|
ALTER COLUMN "original_filename" SET NOT NULL;
|
||||||
|
|
||||||
|
DROP INDEX "audio_assets_sha256_key";
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX "audio_assets_user_id_sha256_key" ON "audio_assets"("user_id", "sha256");
|
||||||
|
CREATE INDEX "audio_assets_user_id_idx" ON "audio_assets"("user_id");
|
||||||
|
CREATE INDEX "devices_user_id_idx" ON "devices"("user_id");
|
||||||
|
CREATE INDEX "library_events_user_id_idx" ON "library_events"("user_id");
|
||||||
|
CREATE INDEX "tracks_user_id_idx" ON "tracks"("user_id");
|
||||||
|
CREATE INDEX "upload_sessions_user_id_idx" ON "upload_sessions"("user_id");
|
||||||
|
|
||||||
|
ALTER TABLE "audio_assets"
|
||||||
|
ADD CONSTRAINT "audio_assets_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "devices"
|
||||||
|
ADD CONSTRAINT "devices_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "library_events"
|
||||||
|
ADD CONSTRAINT "library_events_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "tracks"
|
||||||
|
ADD CONSTRAINT "tracks_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
|
|
||||||
|
ALTER TABLE "upload_sessions"
|
||||||
|
ADD CONSTRAINT "upload_sessions_user_id_fkey"
|
||||||
|
FOREIGN KEY ("user_id") REFERENCES "users"("id")
|
||||||
|
ON DELETE CASCADE ON UPDATE CASCADE;
|
||||||
@ -7,8 +7,25 @@ datasource db {
|
|||||||
url = env("DATABASE_URL")
|
url = env("DATABASE_URL")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model User {
|
||||||
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
slug String @unique
|
||||||
|
displayName String @map("display_name")
|
||||||
|
isDefault Boolean @default(false) @map("is_default")
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
devices Device[]
|
||||||
|
tracks Track[]
|
||||||
|
audioAssets AudioAsset[]
|
||||||
|
uploadSessions UploadSession[]
|
||||||
|
libraryEvents LibraryEvent[]
|
||||||
|
|
||||||
|
@@map("users")
|
||||||
|
}
|
||||||
|
|
||||||
model Device {
|
model Device {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @db.Uuid @map("user_id")
|
||||||
platform DevicePlatform
|
platform DevicePlatform
|
||||||
deviceName String @map("device_name")
|
deviceName String @map("device_name")
|
||||||
appVersion String @map("app_version")
|
appVersion String @map("app_version")
|
||||||
@ -19,12 +36,15 @@ model Device {
|
|||||||
uploadSessions UploadSession[]
|
uploadSessions UploadSession[]
|
||||||
syncCursor DeviceSyncCursor?
|
syncCursor DeviceSyncCursor?
|
||||||
audioAssets AudioAsset[]
|
audioAssets AudioAsset[]
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
@@map("devices")
|
@@map("devices")
|
||||||
}
|
}
|
||||||
|
|
||||||
model Track {
|
model Track {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @db.Uuid @map("user_id")
|
||||||
primaryAudioAssetId String? @unique @db.Uuid @map("primary_audio_asset_id")
|
primaryAudioAssetId String? @unique @db.Uuid @map("primary_audio_asset_id")
|
||||||
artworkAssetId String? @unique @db.Uuid @map("artwork_asset_id")
|
artworkAssetId String? @unique @db.Uuid @map("artwork_asset_id")
|
||||||
title String
|
title String
|
||||||
@ -43,14 +63,17 @@ model Track {
|
|||||||
primaryAudioAsset AudioAsset? @relation("PrimaryAudioAsset", fields: [primaryAudioAssetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
primaryAudioAsset AudioAsset? @relation("PrimaryAudioAsset", fields: [primaryAudioAssetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
artworkAsset ArtworkAsset? @relation("TrackArtwork", fields: [artworkAssetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
artworkAsset ArtworkAsset? @relation("TrackArtwork", fields: [artworkAssetId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
audioAssets AudioAsset[] @relation("TrackAudioAssets")
|
audioAssets AudioAsset[] @relation("TrackAudioAssets")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
@@map("tracks")
|
@@map("tracks")
|
||||||
}
|
}
|
||||||
|
|
||||||
model AudioAsset {
|
model AudioAsset {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @db.Uuid @map("user_id")
|
||||||
trackId String? @db.Uuid @map("track_id")
|
trackId String? @db.Uuid @map("track_id")
|
||||||
sha256 String @unique
|
sha256 String
|
||||||
storageKey String @unique @map("storage_key")
|
storageKey String @unique @map("storage_key")
|
||||||
originalFilename String @map("original_filename")
|
originalFilename String @map("original_filename")
|
||||||
mimeType String @map("mime_type")
|
mimeType String @map("mime_type")
|
||||||
@ -65,7 +88,10 @@ model AudioAsset {
|
|||||||
track Track? @relation("TrackAudioAssets", fields: [trackId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
track Track? @relation("TrackAudioAssets", fields: [trackId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
primaryForTrack Track? @relation("PrimaryAudioAsset")
|
primaryForTrack Track? @relation("PrimaryAudioAsset")
|
||||||
sourceDevice Device? @relation(fields: [sourceDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
sourceDevice Device? @relation(fields: [sourceDeviceId], references: [id], onDelete: SetNull, onUpdate: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@unique([userId, sha256])
|
||||||
|
@@index([userId])
|
||||||
@@map("audio_assets")
|
@@map("audio_assets")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -85,28 +111,39 @@ model ArtworkAsset {
|
|||||||
|
|
||||||
model UploadSession {
|
model UploadSession {
|
||||||
id String @id @default(uuid()) @db.Uuid
|
id String @id @default(uuid()) @db.Uuid
|
||||||
|
userId String @db.Uuid @map("user_id")
|
||||||
deviceId String @db.Uuid @map("device_id")
|
deviceId String @db.Uuid @map("device_id")
|
||||||
|
trackId String? @db.Uuid @map("track_id")
|
||||||
|
audioAssetId String? @db.Uuid @map("audio_asset_id")
|
||||||
expectedSha256 String @map("expected_sha256")
|
expectedSha256 String @map("expected_sha256")
|
||||||
|
originalFilename String @map("original_filename")
|
||||||
expectedSizeBytes BigInt @map("expected_size_bytes")
|
expectedSizeBytes BigInt @map("expected_size_bytes")
|
||||||
receivedBytes BigInt @default(0) @map("received_bytes")
|
receivedBytes BigInt @default(0) @map("received_bytes")
|
||||||
tempStoragePath String @map("temp_storage_path")
|
tempStoragePath String @map("temp_storage_path")
|
||||||
status UploadSessionStatus @default(PENDING)
|
status UploadSessionStatus @default(PENDING)
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
finalizedAt DateTime? @map("finalized_at")
|
||||||
expiresAt DateTime @map("expires_at")
|
expiresAt DateTime @map("expires_at")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
device Device @relation(fields: [deviceId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
@@map("upload_sessions")
|
@@map("upload_sessions")
|
||||||
}
|
}
|
||||||
|
|
||||||
model LibraryEvent {
|
model LibraryEvent {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
|
userId String @db.Uuid @map("user_id")
|
||||||
entityType EntityType @map("entity_type")
|
entityType EntityType @map("entity_type")
|
||||||
entityId String @db.Uuid @map("entity_id")
|
entityId String @db.Uuid @map("entity_id")
|
||||||
action EventAction
|
action EventAction
|
||||||
payloadVersion Int @default(1) @map("payload_version")
|
payloadVersion Int @default(1) @map("payload_version")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||||
|
|
||||||
|
@@index([userId])
|
||||||
@@map("library_events")
|
@@map("library_events")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { DevicesController } from './devices.controller';
|
import { DevicesController } from './devices.controller';
|
||||||
import { DevicesService } from './devices.service';
|
import { DevicesService } from './devices.service';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule, UsersModule],
|
||||||
controllers: [DevicesController],
|
controllers: [DevicesController],
|
||||||
providers: [DevicesService],
|
providers: [DevicesService],
|
||||||
exports: [DevicesService],
|
exports: [DevicesService],
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||||
import { createHash, randomBytes } from 'node:crypto';
|
import { createHash, randomBytes } from 'node:crypto';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
import { DefaultUserService } from '../users/default-user.service';
|
||||||
import {
|
import {
|
||||||
DeviceHeartbeatRequestDto,
|
DeviceHeartbeatRequestDto,
|
||||||
DeviceHeartbeatResponseDto,
|
DeviceHeartbeatResponseDto,
|
||||||
@ -10,7 +11,10 @@ import {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DevicesService {
|
export class DevicesService {
|
||||||
constructor(private readonly prismaService: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly defaultUserService: DefaultUserService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async register(
|
async register(
|
||||||
body: RegisterDeviceRequestDto,
|
body: RegisterDeviceRequestDto,
|
||||||
@ -19,9 +23,11 @@ export class DevicesService {
|
|||||||
const installTokenHash = createHash('sha256')
|
const installTokenHash = createHash('sha256')
|
||||||
.update(bootstrapToken)
|
.update(bootstrapToken)
|
||||||
.digest('hex');
|
.digest('hex');
|
||||||
|
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||||
|
|
||||||
const device = await this.prismaService.device.create({
|
const device = await this.prismaService.device.create({
|
||||||
data: {
|
data: {
|
||||||
|
userId: defaultUser.id,
|
||||||
platform: body.platform,
|
platform: body.platform,
|
||||||
deviceName: body.deviceName,
|
deviceName: body.deviceName,
|
||||||
appVersion: body.appVersion,
|
appVersion: body.appVersion,
|
||||||
|
|||||||
@ -1,7 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
import { LibraryService } from './library.service';
|
import { LibraryService } from './library.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [PrismaModule, UsersModule],
|
||||||
providers: [LibraryService],
|
providers: [LibraryService],
|
||||||
exports: [LibraryService],
|
exports: [LibraryService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,9 +1,36 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { LibraryTrackDto } from '../sync/sync.dto';
|
import { LibraryTrackDto } from '../sync/sync.dto';
|
||||||
|
import { DefaultUserService } from '../users/default-user.service';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class LibraryService {
|
export class LibraryService {
|
||||||
|
constructor(
|
||||||
|
private readonly prismaService: PrismaService,
|
||||||
|
private readonly defaultUserService: DefaultUserService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
|
async getBootstrapTracks(): Promise<LibraryTrackDto[]> {
|
||||||
return [];
|
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||||
|
const tracks = await this.prismaService.track.findMany({
|
||||||
|
where: {
|
||||||
|
userId: defaultUser.id,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
orderBy: {
|
||||||
|
createdAt: 'asc',
|
||||||
|
},
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
title: true,
|
||||||
|
artist: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return tracks.map((track) => ({
|
||||||
|
id: track.id,
|
||||||
|
title: track.title,
|
||||||
|
artist: track.artist,
|
||||||
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { mkdir, access } from 'node:fs/promises';
|
import { mkdir, access } from 'node:fs/promises';
|
||||||
import { constants } from 'node:fs';
|
import { constants } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { dirname, join } from 'node:path';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
|
||||||
export interface StorageStatus {
|
export interface StorageStatus {
|
||||||
@ -17,14 +17,44 @@ export class LocalFilesystemStorageService {
|
|||||||
return this.configService.storageRoot;
|
return this.configService.storageRoot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolve(relativePath: string): string {
|
||||||
|
return join(this.root, relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
userAudioAssetStorageKey(userId: string, sha256: string): string {
|
||||||
|
return join('users', userId, 'audio', `${sha256}.mp3`);
|
||||||
|
}
|
||||||
|
|
||||||
|
userAudioAssetPath(userId: string, sha256: string): string {
|
||||||
|
return this.resolve(this.userAudioAssetStorageKey(userId, sha256));
|
||||||
|
}
|
||||||
|
|
||||||
|
tempUploadStorageKey(uploadId: string): string {
|
||||||
|
return join('temp', 'uploads', `${uploadId}.part`);
|
||||||
|
}
|
||||||
|
|
||||||
|
tempUploadPath(uploadId: string): string {
|
||||||
|
return this.resolve(this.tempUploadStorageKey(uploadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureDirectory(path: string): Promise<void> {
|
||||||
|
await mkdir(path, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async ensureParentDirectory(path: string): Promise<void> {
|
||||||
|
await this.ensureDirectory(dirname(path));
|
||||||
|
}
|
||||||
|
|
||||||
async checkReadiness(): Promise<StorageStatus> {
|
async checkReadiness(): Promise<StorageStatus> {
|
||||||
const paths = [
|
const paths = [
|
||||||
this.root,
|
this.root,
|
||||||
|
join(this.root, 'users'),
|
||||||
|
join(this.root, 'temp'),
|
||||||
|
join(this.root, 'temp', 'uploads'),
|
||||||
join(this.root, 'incoming'),
|
join(this.root, 'incoming'),
|
||||||
join(this.root, 'quarantine'),
|
join(this.root, 'quarantine'),
|
||||||
join(this.root, 'library', 'audio'),
|
join(this.root, 'library', 'audio'),
|
||||||
join(this.root, 'library', 'artwork'),
|
join(this.root, 'library', 'artwork'),
|
||||||
join(this.root, 'temp'),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
|
|||||||
@ -1,11 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
import { LibraryModule } from '../library/library.module';
|
import { LibraryModule } from '../library/library.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
import { SyncController } from './sync.controller';
|
import { SyncController } from './sync.controller';
|
||||||
import { SyncService } from './sync.service';
|
import { SyncService } from './sync.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule, LibraryModule],
|
imports: [PrismaModule, LibraryModule, UsersModule],
|
||||||
controllers: [SyncController],
|
controllers: [SyncController],
|
||||||
providers: [SyncService],
|
providers: [SyncService],
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { LibraryService } from '../library/library.service';
|
import { LibraryService } from '../library/library.service';
|
||||||
|
import { DefaultUserService } from '../users/default-user.service';
|
||||||
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
|
import { SyncBootstrapResponseDto, SyncChangesResponseDto } from './sync.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@ -8,6 +9,7 @@ export class SyncService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly libraryService: LibraryService,
|
private readonly libraryService: LibraryService,
|
||||||
|
private readonly defaultUserService: DefaultUserService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
async bootstrap(): Promise<SyncBootstrapResponseDto> {
|
||||||
@ -37,7 +39,11 @@ export class SyncService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async getLatestCursor(): Promise<string> {
|
private async getLatestCursor(): Promise<string> {
|
||||||
|
const defaultUser = await this.defaultUserService.getOrCreateDefaultUser();
|
||||||
const latest = await this.prismaService.libraryEvent.findFirst({
|
const latest = await this.prismaService.libraryEvent.findFirst({
|
||||||
|
where: {
|
||||||
|
userId: defaultUser.id,
|
||||||
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
id: 'desc',
|
id: 'desc',
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,17 +2,21 @@ import {
|
|||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
Get,
|
Get,
|
||||||
NotImplementedException,
|
|
||||||
Param,
|
Param,
|
||||||
Post,
|
Post,
|
||||||
|
Put,
|
||||||
|
Req,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
|
import type { Request } from 'express';
|
||||||
import {
|
import {
|
||||||
|
ApiBody,
|
||||||
ApiCreatedResponse,
|
ApiCreatedResponse,
|
||||||
|
ApiConsumes,
|
||||||
ApiOkResponse,
|
ApiOkResponse,
|
||||||
ApiOperation,
|
|
||||||
ApiTags,
|
ApiTags,
|
||||||
} from '@nestjs/swagger';
|
} from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
|
UploadFinalizeRequestDto,
|
||||||
UploadFinalizeResponseDto,
|
UploadFinalizeResponseDto,
|
||||||
UploadPrepareRequestDto,
|
UploadPrepareRequestDto,
|
||||||
UploadPrepareResponseDto,
|
UploadPrepareResponseDto,
|
||||||
@ -44,10 +48,28 @@ export class UploadsController {
|
|||||||
return this.uploadsService.getStatus(uploadId);
|
return this.uploadsService.getStatus(uploadId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put(':uploadId/file')
|
||||||
|
@ApiConsumes('audio/mpeg', 'audio/mp3', 'application/octet-stream')
|
||||||
|
@ApiBody({
|
||||||
|
schema: {
|
||||||
|
type: 'string',
|
||||||
|
format: 'binary',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: UploadSessionStatusResponseDto })
|
||||||
|
async uploadFile(
|
||||||
|
@Param('uploadId') uploadId: string,
|
||||||
|
@Req() request: Request,
|
||||||
|
): Promise<UploadSessionStatusResponseDto> {
|
||||||
|
return this.uploadsService.uploadFile(uploadId, request);
|
||||||
|
}
|
||||||
|
|
||||||
@Post(':uploadId/finalize')
|
@Post(':uploadId/finalize')
|
||||||
@ApiOperation({ summary: 'Reserved for the next milestone' })
|
|
||||||
@ApiOkResponse({ type: UploadFinalizeResponseDto })
|
@ApiOkResponse({ type: UploadFinalizeResponseDto })
|
||||||
async finalize(): Promise<UploadFinalizeResponseDto> {
|
async finalize(
|
||||||
throw new NotImplementedException('Upload finalization is not implemented yet.');
|
@Param('uploadId') uploadId: string,
|
||||||
|
@Body() body: UploadFinalizeRequestDto,
|
||||||
|
): Promise<UploadFinalizeResponseDto> {
|
||||||
|
return this.uploadsService.finalize(uploadId, body);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,15 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
import { UploadSessionStatus } from '@prisma/client';
|
import { UploadSessionStatus } from '@prisma/client';
|
||||||
import { IsInt, IsString, IsUUID, Matches, Max, Min } from 'class-validator';
|
import {
|
||||||
|
IsInt,
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsUUID,
|
||||||
|
Matches,
|
||||||
|
Max,
|
||||||
|
Min,
|
||||||
|
MinLength,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
export class UploadPrepareRequestDto {
|
export class UploadPrepareRequestDto {
|
||||||
@ApiProperty({ format: 'uuid' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
@ -16,6 +25,7 @@ export class UploadPrepareRequestDto {
|
|||||||
|
|
||||||
@ApiProperty({ example: 'track.mp3' })
|
@ApiProperty({ example: 'track.mp3' })
|
||||||
@IsString()
|
@IsString()
|
||||||
|
@Matches(/\.mp3$/i)
|
||||||
originalFilename!: string;
|
originalFilename!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 10485760 })
|
@ApiProperty({ example: 10485760 })
|
||||||
@ -34,6 +44,12 @@ export class UploadPrepareResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({ required: false, example: 0 })
|
@ApiProperty({ required: false, example: 0 })
|
||||||
nextOffset?: number;
|
nextOffset?: number;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, format: 'uuid' })
|
||||||
|
trackId?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, format: 'uuid' })
|
||||||
|
assetId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UploadSessionStatusResponseDto {
|
export class UploadSessionStatusResponseDto {
|
||||||
@ -51,12 +67,39 @@ export class UploadSessionStatusResponseDto {
|
|||||||
|
|
||||||
@ApiProperty({ example: 0 })
|
@ApiProperty({ example: 0 })
|
||||||
nextOffset!: string;
|
nextOffset!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: '2026-05-28T12:00:00.000Z' })
|
||||||
|
finalizedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UploadFinalizeRequestDto {
|
||||||
|
@ApiProperty({ example: 'Track Title' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
title!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ example: 'Track Artist' })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
artist!: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 'Album Title' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
album?: string;
|
||||||
|
|
||||||
|
@ApiProperty({ required: false, example: 245000 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(Number.MAX_SAFE_INTEGER)
|
||||||
|
durationMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UploadFinalizeResponseDto {
|
export class UploadFinalizeResponseDto {
|
||||||
@ApiProperty({ example: 501 })
|
@ApiProperty({ format: 'uuid' })
|
||||||
statusCode!: number;
|
trackId!: string;
|
||||||
|
|
||||||
@ApiProperty({ example: 'Upload finalization is not implemented yet.' })
|
@ApiProperty({ format: 'uuid' })
|
||||||
message!: string;
|
assetId!: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { StorageModule } from '../storage/storage.module';
|
||||||
|
import { UsersModule } from '../users/users.module';
|
||||||
import { UploadsController } from './uploads.controller';
|
import { UploadsController } from './uploads.controller';
|
||||||
import { UploadsService } from './uploads.service';
|
import { UploadsService } from './uploads.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [PrismaModule],
|
imports: [PrismaModule, StorageModule, UsersModule],
|
||||||
controllers: [UploadsController],
|
controllers: [UploadsController],
|
||||||
providers: [UploadsService],
|
providers: [UploadsService],
|
||||||
})
|
})
|
||||||
|
|||||||
405
backend/src/modules/uploads/uploads.service.spec.ts
Normal file
405
backend/src/modules/uploads/uploads.service.spec.ts
Normal file
@ -0,0 +1,405 @@
|
|||||||
|
import { randomUUID, createHash } from 'node:crypto';
|
||||||
|
import { readFile, mkdtemp, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
|
import { UnprocessableEntityException } from '@nestjs/common';
|
||||||
|
import { UploadSessionStatus } from '@prisma/client';
|
||||||
|
import { AppConfigService } from '../config/config.service';
|
||||||
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
|
import { UploadsService } from './uploads.service';
|
||||||
|
|
||||||
|
type MockState = ReturnType<typeof createPrismaMock>['state'];
|
||||||
|
|
||||||
|
function createPrismaMock() {
|
||||||
|
const users = new Map<string, any>();
|
||||||
|
const devices = new Map<string, any>();
|
||||||
|
const tracks = new Map<string, any>();
|
||||||
|
const audioAssets = new Map<string, any>();
|
||||||
|
const uploadSessions = new Map<string, any>();
|
||||||
|
const libraryEvents = new Map<bigint, any>();
|
||||||
|
let nextLibraryEventId = 1n;
|
||||||
|
|
||||||
|
const defaultUser = {
|
||||||
|
id: randomUUID(),
|
||||||
|
slug: 'default-owner',
|
||||||
|
displayName: 'Default Owner',
|
||||||
|
isDefault: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
users.set(defaultUser.id, defaultUser);
|
||||||
|
|
||||||
|
const prismaMock: any = {
|
||||||
|
$queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
|
||||||
|
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
|
||||||
|
user: {
|
||||||
|
upsert: jest.fn().mockResolvedValue(defaultUser),
|
||||||
|
},
|
||||||
|
device: {
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return devices.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const record = {
|
||||||
|
id: data.id ?? randomUUID(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
devices.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
const current = devices.get(where.id);
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
devices.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
track: {
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return tracks.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
findMany: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return [...tracks.values()]
|
||||||
|
.filter((track) => {
|
||||||
|
const userMatches = where?.userId ? track.userId === where.userId : true;
|
||||||
|
const statusMatches = where?.status ? track.status === where.status : true;
|
||||||
|
return userMatches && statusMatches;
|
||||||
|
})
|
||||||
|
.sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime());
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const now = new Date();
|
||||||
|
const record = {
|
||||||
|
id: randomUUID(),
|
||||||
|
primaryAudioAssetId: null,
|
||||||
|
artworkAssetId: null,
|
||||||
|
albumArtist: null,
|
||||||
|
genre: null,
|
||||||
|
discNumber: null,
|
||||||
|
trackNumber: null,
|
||||||
|
year: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
tracks.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
const current = tracks.get(where.id);
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
tracks.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
audioAsset: {
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
if (where.id) {
|
||||||
|
return audioAssets.get(where.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composite = where.userId_sha256;
|
||||||
|
if (!composite) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
[...audioAssets.values()].find(
|
||||||
|
(asset) =>
|
||||||
|
asset.userId === composite.userId &&
|
||||||
|
asset.sha256 === composite.sha256,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const record = {
|
||||||
|
id: randomUUID(),
|
||||||
|
bitRateKbps: null,
|
||||||
|
sampleRateHz: null,
|
||||||
|
channels: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
audioAssets.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
const current = audioAssets.get(where.id);
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
audioAssets.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
uploadSession: {
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const now = new Date();
|
||||||
|
const record = {
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
completedAt: null,
|
||||||
|
finalizedAt: null,
|
||||||
|
trackId: null,
|
||||||
|
audioAssetId: null,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
uploadSessions.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return uploadSessions.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
const current = uploadSessions.get(where.id);
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
uploadSessions.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
libraryEvent: {
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const record = {
|
||||||
|
id: nextLibraryEventId,
|
||||||
|
payloadVersion: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
libraryEvents.set(record.id, record);
|
||||||
|
nextLibraryEventId += 1n;
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
findFirst: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
const filteredEvents = [...libraryEvents.values()].filter((event) =>
|
||||||
|
where?.userId ? event.userId === where.userId : true,
|
||||||
|
);
|
||||||
|
return filteredEvents.sort((lhs, rhs) => Number(rhs.id - lhs.id))[0] ?? null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
state: {
|
||||||
|
defaultUser,
|
||||||
|
users,
|
||||||
|
devices,
|
||||||
|
tracks,
|
||||||
|
audioAssets,
|
||||||
|
uploadSessions,
|
||||||
|
libraryEvents,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
prismaMock,
|
||||||
|
state: prismaMock.state,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAppConfig(storageRoot: string): AppConfigService {
|
||||||
|
return {
|
||||||
|
maxUploadSizeBytes: 10 * 1024 * 1024,
|
||||||
|
storageRoot,
|
||||||
|
} as AppConfigService;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUploadRequest(data: Buffer): any {
|
||||||
|
const request = Readable.from([data]) as any;
|
||||||
|
request.headers = {
|
||||||
|
'content-type': 'audio/mpeg',
|
||||||
|
'content-length': String(data.length),
|
||||||
|
};
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sampleMp3Bytes(seed: string): Buffer {
|
||||||
|
return Buffer.concat([
|
||||||
|
Buffer.from('ID3', 'ascii'),
|
||||||
|
Buffer.from([0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21]),
|
||||||
|
Buffer.from(seed, 'utf8'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Hex(data: Buffer): string {
|
||||||
|
return createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('UploadsService', () => {
|
||||||
|
let prismaMock: any;
|
||||||
|
let state: MockState;
|
||||||
|
let storageRoot: string;
|
||||||
|
let storageService: LocalFilesystemStorageService;
|
||||||
|
let service: UploadsService;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const mock = createPrismaMock();
|
||||||
|
prismaMock = mock.prismaMock;
|
||||||
|
state = mock.state;
|
||||||
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-upload-spec-'));
|
||||||
|
storageService = new LocalFilesystemStorageService(createAppConfig(storageRoot));
|
||||||
|
service = new UploadsService(
|
||||||
|
prismaMock,
|
||||||
|
createAppConfig(storageRoot),
|
||||||
|
storageService,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
function seedDevice() {
|
||||||
|
const deviceId = randomUUID();
|
||||||
|
const device = {
|
||||||
|
id: deviceId,
|
||||||
|
userId: state.defaultUser.id,
|
||||||
|
platform: 'MACOS',
|
||||||
|
deviceName: 'Velody Mac',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
installTokenHash: sha256Hex(Buffer.from(deviceId, 'utf8')),
|
||||||
|
lastSeenAt: new Date(),
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
state.devices.set(deviceId, device);
|
||||||
|
return device;
|
||||||
|
}
|
||||||
|
|
||||||
|
it('returns upload_required for a new sha during prepare', async () => {
|
||||||
|
const device = seedDevice();
|
||||||
|
const bytes = sampleMp3Bytes('prepare-new-sha');
|
||||||
|
|
||||||
|
const response = await service.prepare({
|
||||||
|
deviceId: device.id,
|
||||||
|
sha256: sha256Hex(bytes),
|
||||||
|
originalFilename: 'track.mp3',
|
||||||
|
sizeBytes: bytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(response.status).toBe('upload_required');
|
||||||
|
expect(response.uploadId).toBeDefined();
|
||||||
|
expect(response.nextOffset).toBe(0);
|
||||||
|
expect(state.uploadSessions.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects an upload whose bytes do not match the prepared sha', async () => {
|
||||||
|
const device = seedDevice();
|
||||||
|
const uploadedBytes = sampleMp3Bytes('wrong-sha-upload');
|
||||||
|
const response = await service.prepare({
|
||||||
|
deviceId: device.id,
|
||||||
|
sha256: sha256Hex(sampleMp3Bytes('different-bytes')),
|
||||||
|
originalFilename: 'mismatch.mp3',
|
||||||
|
sizeBytes: uploadedBytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes)),
|
||||||
|
).rejects.toBeInstanceOf(UnprocessableEntityException);
|
||||||
|
|
||||||
|
const session = state.uploadSessions.get(response.uploadId!);
|
||||||
|
expect(session.status).toBe(UploadSessionStatus.FAILED);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores valid MP3 bytes under the deterministic user storage path', async () => {
|
||||||
|
const device = seedDevice();
|
||||||
|
const uploadedBytes = sampleMp3Bytes('valid-upload');
|
||||||
|
const sha256 = sha256Hex(uploadedBytes);
|
||||||
|
const response = await service.prepare({
|
||||||
|
deviceId: device.id,
|
||||||
|
sha256,
|
||||||
|
originalFilename: 'valid-upload.mp3',
|
||||||
|
sizeBytes: uploadedBytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
const status = await service.uploadFile(
|
||||||
|
response.uploadId!,
|
||||||
|
createUploadRequest(uploadedBytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(status.status).toBe(UploadSessionStatus.COMPLETED);
|
||||||
|
|
||||||
|
const storedBytes = await readFile(
|
||||||
|
join(storageRoot, 'users', state.defaultUser.id, 'audio', `${sha256}.mp3`),
|
||||||
|
);
|
||||||
|
expect(storedBytes.equals(uploadedBytes)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('finalize creates track, audio_asset, and library_event records', async () => {
|
||||||
|
const device = seedDevice();
|
||||||
|
const uploadedBytes = sampleMp3Bytes('finalize-records');
|
||||||
|
const sha256 = sha256Hex(uploadedBytes);
|
||||||
|
const response = await service.prepare({
|
||||||
|
deviceId: device.id,
|
||||||
|
sha256,
|
||||||
|
originalFilename: 'finalize-records.mp3',
|
||||||
|
sizeBytes: uploadedBytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.uploadFile(response.uploadId!, createUploadRequest(uploadedBytes));
|
||||||
|
const finalizeResponse = await service.finalize(response.uploadId!, {
|
||||||
|
title: 'Finalize Track',
|
||||||
|
artist: 'Velody',
|
||||||
|
album: 'Milestone 6',
|
||||||
|
durationMs: 245000,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(finalizeResponse.trackId).toBeDefined();
|
||||||
|
expect(finalizeResponse.assetId).toBeDefined();
|
||||||
|
expect(state.tracks.size).toBe(1);
|
||||||
|
expect(state.audioAssets.size).toBe(1);
|
||||||
|
expect(state.libraryEvents.size).toBe(1);
|
||||||
|
|
||||||
|
const session = state.uploadSessions.get(response.uploadId!);
|
||||||
|
expect(session.finalizedAt).toBeInstanceOf(Date);
|
||||||
|
expect(session.trackId).toBe(finalizeResponse.trackId);
|
||||||
|
expect(session.audioAssetId).toBe(finalizeResponse.assetId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns exists from prepare after a successful upload and finalize', async () => {
|
||||||
|
const device = seedDevice();
|
||||||
|
const uploadedBytes = sampleMp3Bytes('duplicate-handling');
|
||||||
|
const sha256 = sha256Hex(uploadedBytes);
|
||||||
|
const firstPrepare = await service.prepare({
|
||||||
|
deviceId: device.id,
|
||||||
|
sha256,
|
||||||
|
originalFilename: 'duplicate-handling.mp3',
|
||||||
|
sizeBytes: uploadedBytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
await service.uploadFile(
|
||||||
|
firstPrepare.uploadId!,
|
||||||
|
createUploadRequest(uploadedBytes),
|
||||||
|
);
|
||||||
|
const finalizeResponse = await service.finalize(firstPrepare.uploadId!, {
|
||||||
|
title: 'Duplicate Track',
|
||||||
|
artist: 'Velody',
|
||||||
|
album: 'Milestone 6',
|
||||||
|
durationMs: 123000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const secondPrepare = await service.prepare({
|
||||||
|
deviceId: device.id,
|
||||||
|
sha256,
|
||||||
|
originalFilename: 'duplicate-handling.mp3',
|
||||||
|
sizeBytes: uploadedBytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(secondPrepare.status).toBe('exists');
|
||||||
|
expect(secondPrepare.trackId).toBe(finalizeResponse.trackId);
|
||||||
|
expect(secondPrepare.assetId).toBe(finalizeResponse.assetId);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -3,25 +3,41 @@ import {
|
|||||||
NotFoundException,
|
NotFoundException,
|
||||||
UnprocessableEntityException,
|
UnprocessableEntityException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { join } from 'node:path';
|
import {
|
||||||
|
EntityType,
|
||||||
|
EventAction,
|
||||||
|
type UploadSession,
|
||||||
|
UploadSessionStatus,
|
||||||
|
} from '@prisma/client';
|
||||||
|
import type { Request } from 'express';
|
||||||
|
import { createHash, randomUUID } from 'node:crypto';
|
||||||
|
import { constants } from 'node:fs';
|
||||||
|
import { access, open, rename, unlink } from 'node:fs/promises';
|
||||||
|
import { extname } from 'node:path';
|
||||||
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
import { AppConfigService } from '../config/config.service';
|
import { AppConfigService } from '../config/config.service';
|
||||||
import { UploadPrepareRequestDto, UploadPrepareResponseDto, UploadSessionStatusResponseDto } from './uploads.dto';
|
import { LocalFilesystemStorageService } from '../storage/storage.service';
|
||||||
import { UploadSessionStatus } from '@prisma/client';
|
import {
|
||||||
|
UploadFinalizeRequestDto,
|
||||||
|
UploadFinalizeResponseDto,
|
||||||
|
UploadPrepareRequestDto,
|
||||||
|
UploadPrepareResponseDto,
|
||||||
|
UploadSessionStatusResponseDto,
|
||||||
|
} from './uploads.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class UploadsService {
|
export class UploadsService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prismaService: PrismaService,
|
private readonly prismaService: PrismaService,
|
||||||
private readonly configService: AppConfigService,
|
private readonly configService: AppConfigService,
|
||||||
|
private readonly storageService: LocalFilesystemStorageService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async prepare(
|
async prepare(
|
||||||
body: UploadPrepareRequestDto,
|
body: UploadPrepareRequestDto,
|
||||||
): Promise<UploadPrepareResponseDto> {
|
): Promise<UploadPrepareResponseDto> {
|
||||||
if (body.sizeBytes > this.configService.maxUploadSizeBytes) {
|
this.assertFileSizeWithinLimit(body.sizeBytes);
|
||||||
throw new UnprocessableEntityException('Upload exceeds the configured maximum size.');
|
this.assertMp3Filename(body.originalFilename);
|
||||||
}
|
|
||||||
|
|
||||||
const device = await this.prismaService.device.findUnique({
|
const device = await this.prismaService.device.findUnique({
|
||||||
where: { id: body.deviceId },
|
where: { id: body.deviceId },
|
||||||
@ -32,22 +48,33 @@ export class UploadsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
const existingAsset = await this.prismaService.audioAsset.findUnique({
|
||||||
where: { sha256: body.sha256 },
|
where: {
|
||||||
|
userId_sha256: {
|
||||||
|
userId: device.userId,
|
||||||
|
sha256: body.sha256,
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (existingAsset) {
|
if (existingAsset) {
|
||||||
return {
|
return {
|
||||||
status: 'exists',
|
status: 'exists',
|
||||||
|
trackId: existingAsset.trackId ?? undefined,
|
||||||
|
assetId: existingAsset.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const uploadId = randomUUID();
|
||||||
const uploadSession = await this.prismaService.uploadSession.create({
|
const uploadSession = await this.prismaService.uploadSession.create({
|
||||||
data: {
|
data: {
|
||||||
|
id: uploadId,
|
||||||
|
userId: device.userId,
|
||||||
deviceId: body.deviceId,
|
deviceId: body.deviceId,
|
||||||
expectedSha256: body.sha256,
|
expectedSha256: body.sha256,
|
||||||
|
originalFilename: body.originalFilename,
|
||||||
expectedSizeBytes: BigInt(body.sizeBytes),
|
expectedSizeBytes: BigInt(body.sizeBytes),
|
||||||
receivedBytes: BigInt(0),
|
receivedBytes: BigInt(0),
|
||||||
tempStoragePath: join('incoming', `${body.sha256}.part`),
|
tempStoragePath: this.storageService.tempUploadStorageKey(uploadId),
|
||||||
status: UploadSessionStatus.READY_TO_UPLOAD,
|
status: UploadSessionStatus.READY_TO_UPLOAD,
|
||||||
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
expiresAt: new Date(Date.now() + 24 * 60 * 60 * 1000),
|
||||||
},
|
},
|
||||||
@ -61,6 +88,306 @@ export class UploadsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> {
|
async getStatus(uploadId: string): Promise<UploadSessionStatusResponseDto> {
|
||||||
|
return this.toStatusResponse(await this.getUploadSessionOrThrow(uploadId));
|
||||||
|
}
|
||||||
|
|
||||||
|
async uploadFile(
|
||||||
|
uploadId: string,
|
||||||
|
request: Request,
|
||||||
|
): Promise<UploadSessionStatusResponseDto> {
|
||||||
|
const uploadSession = await this.getUploadSessionOrThrow(uploadId);
|
||||||
|
|
||||||
|
if (uploadSession.status === UploadSessionStatus.COMPLETED) {
|
||||||
|
return this.toStatusResponse(uploadSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertUploadSessionReadyForBinaryTransfer(uploadSession);
|
||||||
|
this.assertAllowedMimeType(request.headers['content-type']);
|
||||||
|
|
||||||
|
const contentLength = this.parseContentLength(
|
||||||
|
request.headers['content-length'],
|
||||||
|
);
|
||||||
|
if (contentLength !== undefined) {
|
||||||
|
this.assertFileSizeWithinLimit(contentLength);
|
||||||
|
if (contentLength !== Number(uploadSession.expectedSizeBytes)) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Upload size does not match the prepared session.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const tempPath = this.storageService.resolve(uploadSession.tempStoragePath);
|
||||||
|
const finalPath = this.storageService.userAudioAssetPath(
|
||||||
|
uploadSession.userId,
|
||||||
|
uploadSession.expectedSha256,
|
||||||
|
);
|
||||||
|
|
||||||
|
await this.storageService.ensureParentDirectory(tempPath);
|
||||||
|
await this.storageService.ensureParentDirectory(finalPath);
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
|
||||||
|
const fileHandle = await open(tempPath, 'w');
|
||||||
|
const hasher = createHash('sha256');
|
||||||
|
const expectedSizeBytes = Number(uploadSession.expectedSizeBytes);
|
||||||
|
let receivedBytes = 0;
|
||||||
|
let sniffedBytes = Buffer.alloc(0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
for await (const chunkValue of request) {
|
||||||
|
const chunk = Buffer.isBuffer(chunkValue)
|
||||||
|
? chunkValue
|
||||||
|
: Buffer.from(chunkValue);
|
||||||
|
|
||||||
|
receivedBytes += chunk.byteLength;
|
||||||
|
if (receivedBytes > expectedSizeBytes) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Upload size exceeds the prepared session size.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sniffedBytes.length < 4) {
|
||||||
|
sniffedBytes = Buffer.concat([
|
||||||
|
sniffedBytes,
|
||||||
|
chunk.subarray(0, Math.max(0, 4 - sniffedBytes.length)),
|
||||||
|
]).subarray(0, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
hasher.update(chunk);
|
||||||
|
await fileHandle.write(chunk);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await fileHandle.close();
|
||||||
|
await this.markUploadFailed(uploadId, receivedBytes);
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fileHandle.close();
|
||||||
|
|
||||||
|
if (receivedBytes !== expectedSizeBytes) {
|
||||||
|
await this.markUploadFailed(uploadId, receivedBytes);
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Uploaded bytes do not match the prepared session size.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.looksLikeMp3(sniffedBytes)) {
|
||||||
|
await this.markUploadFailed(uploadId, receivedBytes);
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
throw new UnprocessableEntityException('Uploaded file is not a valid MP3.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const actualSha256 = hasher.digest('hex');
|
||||||
|
if (actualSha256 !== uploadSession.expectedSha256) {
|
||||||
|
await this.markUploadFailed(uploadId, receivedBytes);
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Uploaded file hash does not match the prepared session hash.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (await this.fileExists(finalPath)) {
|
||||||
|
await this.safeUnlink(tempPath);
|
||||||
|
} else {
|
||||||
|
await rename(tempPath, finalPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedSession = await this.prismaService.uploadSession.update({
|
||||||
|
where: { id: uploadId },
|
||||||
|
data: {
|
||||||
|
receivedBytes: BigInt(receivedBytes),
|
||||||
|
status: UploadSessionStatus.COMPLETED,
|
||||||
|
completedAt: new Date(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return this.toStatusResponse(updatedSession);
|
||||||
|
}
|
||||||
|
|
||||||
|
async finalize(
|
||||||
|
uploadId: string,
|
||||||
|
body: UploadFinalizeRequestDto,
|
||||||
|
): Promise<UploadFinalizeResponseDto> {
|
||||||
|
const uploadSession = await this.getUploadSessionOrThrow(uploadId);
|
||||||
|
|
||||||
|
if (
|
||||||
|
uploadSession.finalizedAt &&
|
||||||
|
uploadSession.trackId &&
|
||||||
|
uploadSession.audioAssetId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
trackId: uploadSession.trackId,
|
||||||
|
assetId: uploadSession.audioAssetId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadSession.status !== UploadSessionStatus.COMPLETED) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Upload must complete before it can be finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalStorageKey = this.storageService.userAudioAssetStorageKey(
|
||||||
|
uploadSession.userId,
|
||||||
|
uploadSession.expectedSha256,
|
||||||
|
);
|
||||||
|
const finalPath = this.storageService.resolve(finalStorageKey);
|
||||||
|
|
||||||
|
if (!(await this.fileExists(finalPath))) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Uploaded file is missing from storage and cannot be finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const title = body.title.trim();
|
||||||
|
const artist = body.artist.trim();
|
||||||
|
const album = this.trimOptional(body.album);
|
||||||
|
|
||||||
|
if (!title || !artist) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Track title and artist are required to finalize an upload.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.prismaService.$transaction(async (tx) => {
|
||||||
|
const currentSession = await tx.uploadSession.findUnique({
|
||||||
|
where: { id: uploadId },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!currentSession) {
|
||||||
|
throw new NotFoundException('Upload session not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentSession.finalizedAt &&
|
||||||
|
currentSession.trackId &&
|
||||||
|
currentSession.audioAssetId
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
trackId: currentSession.trackId,
|
||||||
|
assetId: currentSession.audioAssetId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSession.status !== UploadSessionStatus.COMPLETED) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Upload must complete before it can be finalized.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let audioAsset = await tx.audioAsset.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_sha256: {
|
||||||
|
userId: currentSession.userId,
|
||||||
|
sha256: currentSession.expectedSha256,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
let track =
|
||||||
|
currentSession.trackId != null
|
||||||
|
? await tx.track.findUnique({ where: { id: currentSession.trackId } })
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!track && audioAsset?.trackId) {
|
||||||
|
track = await tx.track.findUnique({
|
||||||
|
where: { id: audioAsset.trackId },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdTrack = !track;
|
||||||
|
if (!track) {
|
||||||
|
track = await tx.track.create({
|
||||||
|
data: {
|
||||||
|
userId: currentSession.userId,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
album,
|
||||||
|
durationMs: body.durationMs,
|
||||||
|
status: 'ACTIVE',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (audioAsset) {
|
||||||
|
const nextDurationMs = body.durationMs ?? audioAsset.durationMs;
|
||||||
|
const shouldUpdateAsset =
|
||||||
|
audioAsset.trackId !== track.id ||
|
||||||
|
audioAsset.originalFilename !== currentSession.originalFilename ||
|
||||||
|
audioAsset.mimeType !== 'audio/mpeg' ||
|
||||||
|
audioAsset.fileExtension !== 'mp3' ||
|
||||||
|
audioAsset.fileSizeBytes !== currentSession.expectedSizeBytes ||
|
||||||
|
audioAsset.sourceDeviceId !== currentSession.deviceId ||
|
||||||
|
audioAsset.durationMs !== nextDurationMs;
|
||||||
|
|
||||||
|
if (shouldUpdateAsset) {
|
||||||
|
audioAsset = await tx.audioAsset.update({
|
||||||
|
where: { id: audioAsset.id },
|
||||||
|
data: {
|
||||||
|
trackId: track.id,
|
||||||
|
originalFilename: currentSession.originalFilename,
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
fileExtension: 'mp3',
|
||||||
|
fileSizeBytes: currentSession.expectedSizeBytes,
|
||||||
|
sourceDeviceId: currentSession.deviceId,
|
||||||
|
durationMs: nextDurationMs,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
audioAsset = await tx.audioAsset.create({
|
||||||
|
data: {
|
||||||
|
userId: currentSession.userId,
|
||||||
|
trackId: track.id,
|
||||||
|
sha256: currentSession.expectedSha256,
|
||||||
|
storageKey: finalStorageKey,
|
||||||
|
originalFilename: currentSession.originalFilename,
|
||||||
|
mimeType: 'audio/mpeg',
|
||||||
|
fileExtension: 'mp3',
|
||||||
|
fileSizeBytes: currentSession.expectedSizeBytes,
|
||||||
|
durationMs: body.durationMs,
|
||||||
|
sourceDeviceId: currentSession.deviceId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track.primaryAudioAssetId !== audioAsset.id) {
|
||||||
|
track = await tx.track.update({
|
||||||
|
where: { id: track.id },
|
||||||
|
data: {
|
||||||
|
primaryAudioAssetId: audioAsset.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await tx.libraryEvent.create({
|
||||||
|
data: {
|
||||||
|
userId: currentSession.userId,
|
||||||
|
entityType: EntityType.TRACK,
|
||||||
|
entityId: track.id,
|
||||||
|
action: createdTrack ? EventAction.CREATED : EventAction.UPDATED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
await tx.uploadSession.update({
|
||||||
|
where: { id: currentSession.id },
|
||||||
|
data: {
|
||||||
|
trackId: track.id,
|
||||||
|
audioAssetId: audioAsset.id,
|
||||||
|
finalizedAt: new Date(),
|
||||||
|
status: UploadSessionStatus.COMPLETED,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
trackId: track.id,
|
||||||
|
assetId: audioAsset.id,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async getUploadSessionOrThrow(uploadId: string): Promise<UploadSession> {
|
||||||
const uploadSession = await this.prismaService.uploadSession.findUnique({
|
const uploadSession = await this.prismaService.uploadSession.findUnique({
|
||||||
where: { id: uploadId },
|
where: { id: uploadId },
|
||||||
});
|
});
|
||||||
@ -69,12 +396,149 @@ export class UploadsService {
|
|||||||
throw new NotFoundException('Upload session not found');
|
throw new NotFoundException('Upload session not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return uploadSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
private toStatusResponse(
|
||||||
|
uploadSession: Pick<
|
||||||
|
UploadSession,
|
||||||
|
'id' | 'status' | 'receivedBytes' | 'expectedSizeBytes' | 'finalizedAt'
|
||||||
|
>,
|
||||||
|
): UploadSessionStatusResponseDto {
|
||||||
return {
|
return {
|
||||||
uploadId: uploadSession.id,
|
uploadId: uploadSession.id,
|
||||||
status: uploadSession.status,
|
status: uploadSession.status,
|
||||||
receivedBytes: uploadSession.receivedBytes.toString(),
|
receivedBytes: uploadSession.receivedBytes.toString(),
|
||||||
expectedSizeBytes: uploadSession.expectedSizeBytes.toString(),
|
expectedSizeBytes: uploadSession.expectedSizeBytes.toString(),
|
||||||
nextOffset: uploadSession.receivedBytes.toString(),
|
nextOffset: uploadSession.receivedBytes.toString(),
|
||||||
|
finalizedAt: uploadSession.finalizedAt?.toISOString(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private assertFileSizeWithinLimit(sizeBytes: number): void {
|
||||||
|
if (sizeBytes > this.configService.maxUploadSizeBytes) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Upload exceeds the configured maximum size.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertMp3Filename(filename: string): void {
|
||||||
|
if (extname(filename).toLowerCase() !== '.mp3') {
|
||||||
|
throw new UnprocessableEntityException('Only MP3 uploads are supported.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertUploadSessionReadyForBinaryTransfer(
|
||||||
|
uploadSession: Pick<UploadSession, 'status' | 'expiresAt' | 'originalFilename'>,
|
||||||
|
): void {
|
||||||
|
if (
|
||||||
|
uploadSession.status !== UploadSessionStatus.READY_TO_UPLOAD &&
|
||||||
|
uploadSession.status !== UploadSessionStatus.FAILED
|
||||||
|
) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Upload session is not ready to receive file bytes.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (uploadSession.expiresAt.getTime() <= Date.now()) {
|
||||||
|
throw new UnprocessableEntityException('Upload session has expired.');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.assertMp3Filename(uploadSession.originalFilename);
|
||||||
|
}
|
||||||
|
|
||||||
|
private assertAllowedMimeType(contentTypeHeader?: string | string[]): void {
|
||||||
|
const contentType = Array.isArray(contentTypeHeader)
|
||||||
|
? contentTypeHeader[0]
|
||||||
|
: contentTypeHeader;
|
||||||
|
const normalizedContentType = contentType
|
||||||
|
?.split(';', 1)[0]
|
||||||
|
?.trim()
|
||||||
|
.toLowerCase();
|
||||||
|
const allowedMimeTypes = new Set([
|
||||||
|
'audio/mpeg',
|
||||||
|
'audio/mp3',
|
||||||
|
'application/octet-stream',
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (
|
||||||
|
normalizedContentType != null &&
|
||||||
|
normalizedContentType.length > 0 &&
|
||||||
|
!allowedMimeTypes.has(normalizedContentType)
|
||||||
|
) {
|
||||||
|
throw new UnprocessableEntityException(
|
||||||
|
'Only MP3 audio uploads are accepted.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseContentLength(
|
||||||
|
contentLengthHeader?: string | string[],
|
||||||
|
): number | undefined {
|
||||||
|
const rawValue = Array.isArray(contentLengthHeader)
|
||||||
|
? contentLengthHeader[0]
|
||||||
|
: contentLengthHeader;
|
||||||
|
|
||||||
|
if (!rawValue) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedValue = Number(rawValue);
|
||||||
|
if (!Number.isFinite(parsedValue) || parsedValue < 0) {
|
||||||
|
throw new UnprocessableEntityException('Invalid Content-Length header.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private looksLikeMp3(sniffedBytes: Buffer): boolean {
|
||||||
|
if (
|
||||||
|
sniffedBytes.length >= 3 &&
|
||||||
|
sniffedBytes.subarray(0, 3).equals(Buffer.from('ID3'))
|
||||||
|
) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
sniffedBytes.length >= 2 &&
|
||||||
|
sniffedBytes[0] === 0xff &&
|
||||||
|
(sniffedBytes[1] & 0xe0) === 0xe0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markUploadFailed(
|
||||||
|
uploadId: string,
|
||||||
|
receivedBytes: number,
|
||||||
|
): Promise<void> {
|
||||||
|
await this.prismaService.uploadSession.update({
|
||||||
|
where: { id: uploadId },
|
||||||
|
data: {
|
||||||
|
status: UploadSessionStatus.FAILED,
|
||||||
|
receivedBytes: BigInt(receivedBytes),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private async fileExists(path: string): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
await access(path, constants.F_OK);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async safeUnlink(path: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await unlink(path);
|
||||||
|
} catch {
|
||||||
|
// Ignore missing temp files during retries and cleanup.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private trimOptional(value?: string): string | undefined {
|
||||||
|
const trimmed = value?.trim();
|
||||||
|
return trimmed ? trimmed : undefined;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
28
backend/src/modules/users/default-user.service.ts
Normal file
28
backend/src/modules/users/default-user.service.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { User } from '@prisma/client';
|
||||||
|
import { PrismaService } from '../../infrastructure/database/prisma.service';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class DefaultUserService {
|
||||||
|
static readonly defaultOwnerSlug = 'default-owner';
|
||||||
|
static readonly defaultOwnerDisplayName = 'Default Owner';
|
||||||
|
|
||||||
|
constructor(private readonly prismaService: PrismaService) {}
|
||||||
|
|
||||||
|
async getOrCreateDefaultUser(): Promise<User> {
|
||||||
|
return this.prismaService.user.upsert({
|
||||||
|
where: {
|
||||||
|
slug: DefaultUserService.defaultOwnerSlug,
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
slug: DefaultUserService.defaultOwnerSlug,
|
||||||
|
displayName: DefaultUserService.defaultOwnerDisplayName,
|
||||||
|
isDefault: true,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
10
backend/src/modules/users/users.module.ts
Normal file
10
backend/src/modules/users/users.module.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { PrismaModule } from '../../infrastructure/database/prisma.module';
|
||||||
|
import { DefaultUserService } from './default-user.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [PrismaModule],
|
||||||
|
providers: [DefaultUserService],
|
||||||
|
exports: [DefaultUserService],
|
||||||
|
})
|
||||||
|
export class UsersModule {}
|
||||||
@ -1,4 +1,8 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID, createHash } from 'node:crypto';
|
||||||
|
import { mkdtemp, readFile, rm } from 'node:fs/promises';
|
||||||
|
import { tmpdir } from 'node:os';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { Readable } from 'node:stream';
|
||||||
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
|
import { INestApplication, ValidationPipe, VersioningType } from '@nestjs/common';
|
||||||
import { Test } from '@nestjs/testing';
|
import { Test } from '@nestjs/testing';
|
||||||
import { AppModule } from '../../src/app.module';
|
import { AppModule } from '../../src/app.module';
|
||||||
@ -6,19 +10,65 @@ import { AppConfigService } from '../../src/modules/config/config.service';
|
|||||||
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
import { DevicesController } from '../../src/modules/devices/devices.controller';
|
||||||
import { HealthController } from '../../src/modules/health/health.controller';
|
import { HealthController } from '../../src/modules/health/health.controller';
|
||||||
import { SyncController } from '../../src/modules/sync/sync.controller';
|
import { SyncController } from '../../src/modules/sync/sync.controller';
|
||||||
import { LocalFilesystemStorageService } from '../../src/modules/storage/storage.service';
|
import { UploadsController } from '../../src/modules/uploads/uploads.controller';
|
||||||
|
import { UploadsService } from '../../src/modules/uploads/uploads.service';
|
||||||
import { PrismaService } from '../../src/infrastructure/database/prisma.service';
|
import { PrismaService } from '../../src/infrastructure/database/prisma.service';
|
||||||
|
|
||||||
function createPrismaMock() {
|
function sampleMp3Bytes(seed: string): Buffer {
|
||||||
const devices = new Map<string, any>();
|
return Buffer.concat([
|
||||||
|
Buffer.from('ID3', 'ascii'),
|
||||||
|
Buffer.from([0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21]),
|
||||||
|
Buffer.from(seed, 'utf8'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
function sha256Hex(data: Buffer): string {
|
||||||
|
return createHash('sha256').update(data).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createUploadRequest(data: Buffer): any {
|
||||||
|
const request = Readable.from([data]) as any;
|
||||||
|
request.headers = {
|
||||||
|
'content-type': 'audio/mpeg',
|
||||||
|
'content-length': String(data.length),
|
||||||
|
};
|
||||||
|
return request;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPrismaMock() {
|
||||||
|
const users = new Map<string, any>();
|
||||||
|
const devices = new Map<string, any>();
|
||||||
|
const tracks = new Map<string, any>();
|
||||||
|
const audioAssets = new Map<string, any>();
|
||||||
|
const uploadSessions = new Map<string, any>();
|
||||||
|
const libraryEvents = new Map<bigint, any>();
|
||||||
|
let nextLibraryEventId = 1n;
|
||||||
|
|
||||||
|
const defaultUser = {
|
||||||
|
id: randomUUID(),
|
||||||
|
slug: 'default-owner',
|
||||||
|
displayName: 'Default Owner',
|
||||||
|
isDefault: true,
|
||||||
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
users.set(defaultUser.id, defaultUser);
|
||||||
|
|
||||||
|
const prismaMock: any = {
|
||||||
$queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
|
$queryRawUnsafe: jest.fn().mockResolvedValue([{ '?column?': 1 }]),
|
||||||
|
$transaction: jest.fn().mockImplementation(async (callback: any) => callback(prismaMock)),
|
||||||
|
user: {
|
||||||
|
upsert: jest.fn().mockResolvedValue(defaultUser),
|
||||||
|
},
|
||||||
device: {
|
device: {
|
||||||
create: jest.fn().mockImplementation(async ({ data }) => {
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
const id = randomUUID();
|
const record = {
|
||||||
const record = { id, ...data };
|
id: randomUUID(),
|
||||||
devices.set(id, record);
|
createdAt: new Date(),
|
||||||
|
updatedAt: new Date(),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
devices.set(record.id, record);
|
||||||
return record;
|
return record;
|
||||||
}),
|
}),
|
||||||
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
@ -26,25 +76,158 @@ function createPrismaMock() {
|
|||||||
}),
|
}),
|
||||||
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
const current = devices.get(where.id);
|
const current = devices.get(where.id);
|
||||||
const updated = { ...current, ...data };
|
const updated = {
|
||||||
|
...current,
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
devices.set(where.id, updated);
|
devices.set(where.id, updated);
|
||||||
return updated;
|
return updated;
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
|
track: {
|
||||||
|
findMany: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return [...tracks.values()]
|
||||||
|
.filter((track) => {
|
||||||
|
const userMatches = where?.userId ? track.userId === where.userId : true;
|
||||||
|
const statusMatches = where?.status ? track.status === where.status : true;
|
||||||
|
return userMatches && statusMatches;
|
||||||
|
})
|
||||||
|
.sort((lhs, rhs) => lhs.createdAt.getTime() - rhs.createdAt.getTime());
|
||||||
|
}),
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return tracks.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const now = new Date();
|
||||||
|
const record = {
|
||||||
|
id: randomUUID(),
|
||||||
|
primaryAudioAssetId: null,
|
||||||
|
artworkAssetId: null,
|
||||||
|
albumArtist: null,
|
||||||
|
genre: null,
|
||||||
|
discNumber: null,
|
||||||
|
trackNumber: null,
|
||||||
|
year: null,
|
||||||
|
deletedAt: null,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
tracks.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
const current = tracks.get(where.id);
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
tracks.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
|
},
|
||||||
audioAsset: {
|
audioAsset: {
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
if (where.id) {
|
||||||
|
return audioAssets.get(where.id) ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const composite = where.userId_sha256;
|
||||||
|
if (!composite) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
[...audioAssets.values()].find(
|
||||||
|
(asset) =>
|
||||||
|
asset.userId === composite.userId &&
|
||||||
|
asset.sha256 === composite.sha256,
|
||||||
|
) ?? null
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const record = {
|
||||||
|
id: randomUUID(),
|
||||||
|
bitRateKbps: null,
|
||||||
|
sampleRateHz: null,
|
||||||
|
channels: null,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
audioAssets.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
const current = audioAssets.get(where.id);
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
audioAssets.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
uploadSession: {
|
uploadSession: {
|
||||||
create: jest.fn().mockImplementation(async ({ data }) => {
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
return {
|
const now = new Date();
|
||||||
id: randomUUID(),
|
const record = {
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
completedAt: null,
|
||||||
|
finalizedAt: null,
|
||||||
|
trackId: null,
|
||||||
|
audioAssetId: null,
|
||||||
...data,
|
...data,
|
||||||
};
|
};
|
||||||
|
uploadSessions.set(record.id, record);
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
findUnique: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
return uploadSessions.get(where.id) ?? null;
|
||||||
|
}),
|
||||||
|
update: jest.fn().mockImplementation(async ({ where, data }) => {
|
||||||
|
const current = uploadSessions.get(where.id);
|
||||||
|
const updated = {
|
||||||
|
...current,
|
||||||
|
...data,
|
||||||
|
updatedAt: new Date(),
|
||||||
|
};
|
||||||
|
uploadSessions.set(where.id, updated);
|
||||||
|
return updated;
|
||||||
}),
|
}),
|
||||||
findUnique: jest.fn().mockResolvedValue(null),
|
|
||||||
},
|
},
|
||||||
libraryEvent: {
|
libraryEvent: {
|
||||||
findFirst: jest.fn().mockResolvedValue(null),
|
create: jest.fn().mockImplementation(async ({ data }) => {
|
||||||
|
const record = {
|
||||||
|
id: nextLibraryEventId,
|
||||||
|
payloadVersion: 1,
|
||||||
|
createdAt: new Date(),
|
||||||
|
...data,
|
||||||
|
};
|
||||||
|
libraryEvents.set(record.id, record);
|
||||||
|
nextLibraryEventId += 1n;
|
||||||
|
return record;
|
||||||
|
}),
|
||||||
|
findFirst: jest.fn().mockImplementation(async ({ where }) => {
|
||||||
|
const filteredEvents = [...libraryEvents.values()].filter((event) =>
|
||||||
|
where?.userId ? event.userId === where.userId : true,
|
||||||
|
);
|
||||||
|
return filteredEvents.sort((lhs, rhs) => Number(rhs.id - lhs.id))[0] ?? null;
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
prismaMock,
|
||||||
|
state: {
|
||||||
|
defaultUser,
|
||||||
|
devices,
|
||||||
|
tracks,
|
||||||
|
audioAssets,
|
||||||
|
uploadSessions,
|
||||||
|
libraryEvents,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@ -54,9 +237,16 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
let healthController: HealthController;
|
let healthController: HealthController;
|
||||||
let devicesController: DevicesController;
|
let devicesController: DevicesController;
|
||||||
let syncController: SyncController;
|
let syncController: SyncController;
|
||||||
|
let uploadsController: UploadsController;
|
||||||
|
let uploadsService: UploadsService;
|
||||||
|
let prismaState: ReturnType<typeof createPrismaMock>['state'];
|
||||||
|
let storageRoot: string;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
const prismaMock = createPrismaMock();
|
const { prismaMock, state } = createPrismaMock();
|
||||||
|
prismaState = state;
|
||||||
|
storageRoot = await mkdtemp(join(tmpdir(), 'velody-e2e-'));
|
||||||
|
|
||||||
const moduleRef = await Test.createTestingModule({
|
const moduleRef = await Test.createTestingModule({
|
||||||
imports: [AppModule],
|
imports: [AppModule],
|
||||||
})
|
})
|
||||||
@ -64,15 +254,7 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
.useValue({
|
.useValue({
|
||||||
appVersion: '0.1.0',
|
appVersion: '0.1.0',
|
||||||
maxUploadSizeBytes: 1024 * 1024 * 1024,
|
maxUploadSizeBytes: 1024 * 1024 * 1024,
|
||||||
storageRoot: '/tmp/velody-storage',
|
storageRoot,
|
||||||
})
|
|
||||||
.overrideProvider(LocalFilesystemStorageService)
|
|
||||||
.useValue({
|
|
||||||
root: '/tmp/velody-storage',
|
|
||||||
checkReadiness: jest.fn().mockResolvedValue({
|
|
||||||
root: '/tmp/velody-storage',
|
|
||||||
writable: true,
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
.overrideProvider(PrismaService)
|
.overrideProvider(PrismaService)
|
||||||
.useValue(prismaMock)
|
.useValue(prismaMock)
|
||||||
@ -93,12 +275,16 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
healthController = moduleRef.get(HealthController);
|
healthController = moduleRef.get(HealthController);
|
||||||
devicesController = moduleRef.get(DevicesController);
|
devicesController = moduleRef.get(DevicesController);
|
||||||
syncController = moduleRef.get(SyncController);
|
syncController = moduleRef.get(SyncController);
|
||||||
|
uploadsController = moduleRef.get(UploadsController);
|
||||||
|
uploadsService = moduleRef.get(UploadsService);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
if (app) {
|
if (app) {
|
||||||
await app.close();
|
await app.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await rm(storageRoot, { recursive: true, force: true });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns health information', async () => {
|
it('returns health information', async () => {
|
||||||
@ -109,33 +295,25 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(response.version).toBe('0.1.0');
|
expect(response.version).toBe('0.1.0');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('registers a device', async () => {
|
it('registers a device and accepts heartbeat', async () => {
|
||||||
const response = await devicesController.register({
|
const registerResponse = await devicesController.register({
|
||||||
platform: 'MACOS',
|
platform: 'MACOS',
|
||||||
deviceName: 'Diya MacBook Pro',
|
deviceName: 'Diya MacBook Pro',
|
||||||
appVersion: '0.1.0',
|
appVersion: '0.1.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.deviceId).toBeDefined();
|
expect(registerResponse.deviceId).toBeDefined();
|
||||||
expect(response.bootstrapToken).toBeDefined();
|
expect(registerResponse.bootstrapToken).toBeDefined();
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts device heartbeat', async () => {
|
const heartbeatResponse = await devicesController.heartbeat({
|
||||||
const registerResponse = await devicesController.register({
|
|
||||||
platform: 'IPHONE',
|
|
||||||
deviceName: 'Diya iPhone',
|
|
||||||
appVersion: '0.1.0',
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await devicesController.heartbeat({
|
|
||||||
deviceId: registerResponse.deviceId,
|
deviceId: registerResponse.deviceId,
|
||||||
appVersion: '0.1.1',
|
appVersion: '0.1.1',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(response.ok).toBe(true);
|
expect(heartbeatResponse.ok).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns empty sync bootstrap and changes payloads', async () => {
|
it('returns sync bootstrap and changes payloads', async () => {
|
||||||
const bootstrapResponse = await syncController.bootstrap();
|
const bootstrapResponse = await syncController.bootstrap();
|
||||||
const changesResponse = await syncController.changes({ after: '0' });
|
const changesResponse = await syncController.changes({ after: '0' });
|
||||||
|
|
||||||
@ -143,4 +321,59 @@ describe('Velody API wiring (e2e)', () => {
|
|||||||
expect(changesResponse.events).toEqual([]);
|
expect(changesResponse.events).toEqual([]);
|
||||||
expect(changesResponse.nextCursor).toBe('0');
|
expect(changesResponse.nextCursor).toBe('0');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('supports the MP3 upload pipeline through the Nest app wiring', async () => {
|
||||||
|
const registerResponse = await devicesController.register({
|
||||||
|
platform: 'MACOS',
|
||||||
|
deviceName: 'Upload Mac',
|
||||||
|
appVersion: '0.1.0',
|
||||||
|
});
|
||||||
|
const bytes = sampleMp3Bytes('e2e-upload');
|
||||||
|
const sha256 = sha256Hex(bytes);
|
||||||
|
|
||||||
|
const prepareResponse = await uploadsController.prepare({
|
||||||
|
deviceId: registerResponse.deviceId,
|
||||||
|
sha256,
|
||||||
|
originalFilename: 'e2e-upload.mp3',
|
||||||
|
sizeBytes: bytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(prepareResponse.status).toBe('upload_required');
|
||||||
|
|
||||||
|
const uploadResponse = await uploadsService.uploadFile(
|
||||||
|
prepareResponse.uploadId!,
|
||||||
|
createUploadRequest(bytes),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(uploadResponse.status).toBe('COMPLETED');
|
||||||
|
|
||||||
|
const finalizeResponse = await uploadsController.finalize(
|
||||||
|
prepareResponse.uploadId!,
|
||||||
|
{
|
||||||
|
title: 'Uploaded Track',
|
||||||
|
artist: 'Velody',
|
||||||
|
album: 'Milestone 6',
|
||||||
|
durationMs: 222000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(finalizeResponse.trackId).toBeDefined();
|
||||||
|
expect(finalizeResponse.assetId).toBeDefined();
|
||||||
|
|
||||||
|
const storedBytes = await readFile(
|
||||||
|
join(storageRoot, 'users', prismaState.defaultUser.id, 'audio', `${sha256}.mp3`),
|
||||||
|
);
|
||||||
|
expect(storedBytes.equals(bytes)).toBe(true);
|
||||||
|
|
||||||
|
const duplicatePrepare = await uploadsController.prepare({
|
||||||
|
deviceId: registerResponse.deviceId,
|
||||||
|
sha256,
|
||||||
|
originalFilename: 'e2e-upload.mp3',
|
||||||
|
sizeBytes: bytes.length,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(duplicatePrepare.status).toBe('exists');
|
||||||
|
expect(prismaState.audioAssets.size).toBe(1);
|
||||||
|
expect(prismaState.libraryEvents.size).toBe(1);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,14 @@ public enum DevicePlatform: String, Codable, Sendable, CaseIterable {
|
|||||||
case iphone = "IPHONE"
|
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 struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
|
||||||
public let id: String
|
public let id: String
|
||||||
public var title: String
|
public var title: String
|
||||||
@ -13,6 +21,9 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
public var durationSeconds: Double?
|
public var durationSeconds: Double?
|
||||||
public var localFilePath: String
|
public var localFilePath: String
|
||||||
public var sha256: String?
|
public var sha256: String?
|
||||||
|
public var uploadStatus: LocalUploadStatus?
|
||||||
|
public var remoteTrackId: String?
|
||||||
|
public var lastUploadError: String?
|
||||||
|
|
||||||
public init(
|
public init(
|
||||||
id: String = UUID().uuidString,
|
id: String = UUID().uuidString,
|
||||||
@ -21,7 +32,10 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
album: String? = nil,
|
album: String? = nil,
|
||||||
durationSeconds: Double? = nil,
|
durationSeconds: Double? = nil,
|
||||||
localFilePath: String = "",
|
localFilePath: String = "",
|
||||||
sha256: String? = nil
|
sha256: String? = nil,
|
||||||
|
uploadStatus: LocalUploadStatus? = nil,
|
||||||
|
remoteTrackId: String? = nil,
|
||||||
|
lastUploadError: String? = nil
|
||||||
) {
|
) {
|
||||||
self.id = id
|
self.id = id
|
||||||
self.title = title
|
self.title = title
|
||||||
@ -30,6 +44,9 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
self.durationSeconds = durationSeconds
|
self.durationSeconds = durationSeconds
|
||||||
self.localFilePath = localFilePath
|
self.localFilePath = localFilePath
|
||||||
self.sha256 = sha256
|
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 struct SyncCursor: Codable, Hashable, Sendable {
|
||||||
public var value: String
|
public var value: String
|
||||||
|
|
||||||
|
|||||||
@ -38,6 +38,25 @@ public protocol VelodyAPIClient: Sendable {
|
|||||||
) async throws -> DeviceHeartbeatResponse
|
) async throws -> DeviceHeartbeatResponse
|
||||||
|
|
||||||
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse
|
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 {
|
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>(
|
private func sendRequest<Response: Decodable>(
|
||||||
method: String,
|
method: String,
|
||||||
pathComponents: [String],
|
pathComponents: [String],
|
||||||
@ -118,7 +202,8 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
let request = try buildRequest(
|
let request = try buildRequest(
|
||||||
method: method,
|
method: method,
|
||||||
pathComponents: pathComponents,
|
pathComponents: pathComponents,
|
||||||
bodyData: bodyData
|
bodyData: bodyData,
|
||||||
|
contentType: "application/json"
|
||||||
)
|
)
|
||||||
|
|
||||||
return try await execute(request, responseType: responseType)
|
return try await execute(request, responseType: responseType)
|
||||||
@ -127,7 +212,8 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
private func buildRequest(
|
private func buildRequest(
|
||||||
method: String,
|
method: String,
|
||||||
pathComponents: [String],
|
pathComponents: [String],
|
||||||
bodyData: Data?
|
bodyData: Data?,
|
||||||
|
contentType: String? = nil
|
||||||
) throws -> URLRequest {
|
) throws -> URLRequest {
|
||||||
guard let url = endpointURL(pathComponents: pathComponents) else {
|
guard let url = endpointURL(pathComponents: pathComponents) else {
|
||||||
throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString)
|
throw VelodyAPIError.invalidServerURL(environment.baseURL.absoluteString)
|
||||||
@ -139,7 +225,10 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
|
|
||||||
if let bodyData {
|
if let bodyData {
|
||||||
request.httpBody = bodyData
|
request.httpBody = bodyData
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
}
|
||||||
|
|
||||||
|
if let contentType {
|
||||||
|
request.setValue(contentType, forHTTPHeaderField: "Content-Type")
|
||||||
}
|
}
|
||||||
|
|
||||||
return request
|
return request
|
||||||
@ -158,6 +247,28 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
throw VelodyAPIError.requestFailed(error.localizedDescription)
|
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 {
|
guard let httpResponse = response as? HTTPURLResponse else {
|
||||||
throw VelodyAPIError.invalidResponse
|
throw VelodyAPIError.invalidResponse
|
||||||
}
|
}
|
||||||
@ -171,12 +282,6 @@ public struct URLSessionVelodyAPIClient: VelodyAPIClient {
|
|||||||
message: message?.isEmpty == true ? nil : message
|
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? {
|
private func endpointURL(pathComponents: [String]) -> URL? {
|
||||||
@ -234,4 +339,58 @@ public struct StubVelodyAPIClient: VelodyAPIClient {
|
|||||||
serverTime: ISO8601DateFormatter().string(from: .now)
|
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 durationSeconds: Double?
|
||||||
public var localFilePath: String
|
public var localFilePath: String
|
||||||
public var sha256: String?
|
public var sha256: String?
|
||||||
|
public var uploadStatus: LocalUploadStatus
|
||||||
|
public var remoteTrackId: String?
|
||||||
|
public var lastUploadError: String?
|
||||||
public var fileModifiedAt: Date?
|
public var fileModifiedAt: Date?
|
||||||
public var lastScannedAt: Date?
|
public var lastScannedAt: Date?
|
||||||
public var isDeleted: Bool
|
public var isDeleted: Bool
|
||||||
@ -31,6 +34,9 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
durationSeconds: Double? = nil,
|
durationSeconds: Double? = nil,
|
||||||
localFilePath: String = "",
|
localFilePath: String = "",
|
||||||
sha256: String? = nil,
|
sha256: String? = nil,
|
||||||
|
uploadStatus: LocalUploadStatus = .localOnly,
|
||||||
|
remoteTrackId: String? = nil,
|
||||||
|
lastUploadError: String? = nil,
|
||||||
fileModifiedAt: Date? = nil,
|
fileModifiedAt: Date? = nil,
|
||||||
lastScannedAt: Date? = nil,
|
lastScannedAt: Date? = nil,
|
||||||
isDeleted: Bool = false,
|
isDeleted: Bool = false,
|
||||||
@ -46,6 +52,9 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
self.durationSeconds = durationSeconds
|
self.durationSeconds = durationSeconds
|
||||||
self.localFilePath = localFilePath
|
self.localFilePath = localFilePath
|
||||||
self.sha256 = sha256
|
self.sha256 = sha256
|
||||||
|
self.uploadStatus = uploadStatus
|
||||||
|
self.remoteTrackId = remoteTrackId
|
||||||
|
self.lastUploadError = lastUploadError
|
||||||
self.fileModifiedAt = fileModifiedAt
|
self.fileModifiedAt = fileModifiedAt
|
||||||
self.lastScannedAt = lastScannedAt
|
self.lastScannedAt = lastScannedAt
|
||||||
self.isDeleted = isDeleted
|
self.isDeleted = isDeleted
|
||||||
@ -68,6 +77,9 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
durationSeconds: scannedTrack.durationSeconds,
|
durationSeconds: scannedTrack.durationSeconds,
|
||||||
localFilePath: scannedTrack.localFilePath,
|
localFilePath: scannedTrack.localFilePath,
|
||||||
sha256: scannedTrack.sha256,
|
sha256: scannedTrack.sha256,
|
||||||
|
uploadStatus: .localOnly,
|
||||||
|
remoteTrackId: nil,
|
||||||
|
lastUploadError: nil,
|
||||||
fileModifiedAt: scannedTrack.fileModifiedAt,
|
fileModifiedAt: scannedTrack.fileModifiedAt,
|
||||||
lastScannedAt: observedAt,
|
lastScannedAt: observedAt,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
@ -91,6 +103,9 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
durationSeconds: libraryTrack.durationSeconds,
|
durationSeconds: libraryTrack.durationSeconds,
|
||||||
localFilePath: libraryTrack.localFilePath,
|
localFilePath: libraryTrack.localFilePath,
|
||||||
sha256: libraryTrack.sha256,
|
sha256: libraryTrack.sha256,
|
||||||
|
uploadStatus: libraryTrack.uploadStatus ?? .localOnly,
|
||||||
|
remoteTrackId: libraryTrack.remoteTrackId,
|
||||||
|
lastUploadError: libraryTrack.lastUploadError,
|
||||||
fileModifiedAt: nil,
|
fileModifiedAt: nil,
|
||||||
lastScannedAt: origin == .localScan ? observedAt : nil,
|
lastScannedAt: origin == .localScan ? observedAt : nil,
|
||||||
isDeleted: false,
|
isDeleted: false,
|
||||||
@ -116,7 +131,10 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
|
|||||||
album: album,
|
album: album,
|
||||||
durationSeconds: durationSeconds,
|
durationSeconds: durationSeconds,
|
||||||
localFilePath: localFilePath,
|
localFilePath: localFilePath,
|
||||||
sha256: sha256
|
sha256: sha256,
|
||||||
|
uploadStatus: uploadStatus,
|
||||||
|
remoteTrackId: remoteTrackId,
|
||||||
|
lastUploadError: lastUploadError
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
import VelodyDomain
|
||||||
|
|
||||||
@Model
|
@Model
|
||||||
final class TrackEntity {
|
final class TrackEntity {
|
||||||
@ -12,6 +13,9 @@ final class TrackEntity {
|
|||||||
var durationSeconds: Double?
|
var durationSeconds: Double?
|
||||||
var localFilePath: String
|
var localFilePath: String
|
||||||
var sha256: String?
|
var sha256: String?
|
||||||
|
var uploadStatusRawValue: String?
|
||||||
|
var remoteTrackID: String?
|
||||||
|
var lastUploadError: String?
|
||||||
var fileModifiedAt: Date?
|
var fileModifiedAt: Date?
|
||||||
var lastScannedAt: Date?
|
var lastScannedAt: Date?
|
||||||
var isMarkedDeleted: Bool
|
var isMarkedDeleted: Bool
|
||||||
@ -29,6 +33,9 @@ final class TrackEntity {
|
|||||||
durationSeconds = track.durationSeconds
|
durationSeconds = track.durationSeconds
|
||||||
localFilePath = track.localFilePath
|
localFilePath = track.localFilePath
|
||||||
sha256 = track.sha256
|
sha256 = track.sha256
|
||||||
|
uploadStatusRawValue = track.uploadStatus.rawValue
|
||||||
|
remoteTrackID = track.remoteTrackId
|
||||||
|
lastUploadError = track.lastUploadError
|
||||||
fileModifiedAt = track.fileModifiedAt
|
fileModifiedAt = track.fileModifiedAt
|
||||||
lastScannedAt = track.lastScannedAt
|
lastScannedAt = track.lastScannedAt
|
||||||
isMarkedDeleted = track.isDeleted
|
isMarkedDeleted = track.isDeleted
|
||||||
@ -47,6 +54,9 @@ final class TrackEntity {
|
|||||||
durationSeconds: durationSeconds,
|
durationSeconds: durationSeconds,
|
||||||
localFilePath: localFilePath,
|
localFilePath: localFilePath,
|
||||||
sha256: sha256,
|
sha256: sha256,
|
||||||
|
uploadStatus: LocalUploadStatus(rawValue: uploadStatusRawValue ?? "") ?? .localOnly,
|
||||||
|
remoteTrackId: remoteTrackID,
|
||||||
|
lastUploadError: lastUploadError,
|
||||||
fileModifiedAt: fileModifiedAt,
|
fileModifiedAt: fileModifiedAt,
|
||||||
lastScannedAt: lastScannedAt,
|
lastScannedAt: lastScannedAt,
|
||||||
isDeleted: isMarkedDeleted,
|
isDeleted: isMarkedDeleted,
|
||||||
@ -66,6 +76,9 @@ final class TrackEntity {
|
|||||||
durationSeconds = track.durationSeconds
|
durationSeconds = track.durationSeconds
|
||||||
localFilePath = track.localFilePath
|
localFilePath = track.localFilePath
|
||||||
sha256 = track.sha256
|
sha256 = track.sha256
|
||||||
|
uploadStatusRawValue = track.uploadStatus.rawValue
|
||||||
|
remoteTrackID = track.remoteTrackId
|
||||||
|
lastUploadError = track.lastUploadError
|
||||||
fileModifiedAt = track.fileModifiedAt
|
fileModifiedAt = track.fileModifiedAt
|
||||||
lastScannedAt = track.lastScannedAt
|
lastScannedAt = track.lastScannedAt
|
||||||
isMarkedDeleted = track.isDeleted
|
isMarkedDeleted = track.isDeleted
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user