Implement Milestone 7.2 offline audio downloads

This commit is contained in:
diyaa
2026-05-30 07:11:20 +02:00
parent 6e73c1878e
commit 56f030e651
19 changed files with 1822 additions and 31 deletions
@@ -0,0 +1,209 @@
import CryptoKit
import Foundation
public enum OfflineAudioFileStoreError: LocalizedError, Equatable, Sendable {
case emptyAudioData
case sha256Mismatch(expected: String, actual: String)
case missingLocalFile(path: String)
public var errorDescription: String? {
switch self {
case .emptyAudioData:
return "The downloaded audio file was empty."
case let .sha256Mismatch(expected, actual):
return "The downloaded audio file hash did not match. Expected \(expected), received \(actual)."
case let .missingLocalFile(path):
return "The local audio file is missing: \(path)"
}
}
}
public protocol OfflineAudioFileStore: Actor {
func saveAudioFile(
_ data: Data,
assetId: String,
sha256: String?
) async throws -> String
func readAudioFile(at localFilePath: String) async throws -> Data
func fileExists(at localFilePath: String) async -> Bool
func resolveLocalFilePath(
persistedLocalFilePath: String,
assetId: String
) async -> String?
}
public actor FileOfflineAudioFileStore: OfflineAudioFileStore {
private let baseDirectoryURL: URL
private let fileManager: FileManager
public init(
baseDirectoryURL: URL? = nil,
fileManager: FileManager = .default
) throws {
self.fileManager = fileManager
if let baseDirectoryURL {
self.baseDirectoryURL = baseDirectoryURL
} else {
self.baseDirectoryURL = try Self.defaultBaseDirectoryURL(fileManager: fileManager)
}
}
public func saveAudioFile(
_ data: Data,
assetId: String,
sha256: String?
) async throws -> String {
guard !data.isEmpty else {
throw OfflineAudioFileStoreError.emptyAudioData
}
try fileManager.createDirectory(
at: baseDirectoryURL,
withIntermediateDirectories: true
)
let fileURL = localFileURL(for: assetId)
try data.write(to: fileURL, options: .atomic)
let storedData = try Data(contentsOf: fileURL)
guard !storedData.isEmpty else {
try? fileManager.removeItem(at: fileURL)
throw OfflineAudioFileStoreError.emptyAudioData
}
if let sha256 {
let actualHash = Self.sha256Hex(for: storedData)
if actualHash != sha256 {
try? fileManager.removeItem(at: fileURL)
throw OfflineAudioFileStoreError.sha256Mismatch(
expected: sha256,
actual: actualHash
)
}
}
return fileURL.standardizedFileURL.path
}
public func readAudioFile(at localFilePath: String) async throws -> Data {
guard let resolvedLocalFilePath = await resolveLocalFilePath(
persistedLocalFilePath: localFilePath,
assetId: URL(fileURLWithPath: localFilePath).deletingPathExtension().lastPathComponent
) else {
throw OfflineAudioFileStoreError.missingLocalFile(path: localFilePath)
}
return try Data(contentsOf: URL(fileURLWithPath: resolvedLocalFilePath))
}
public func fileExists(at localFilePath: String) async -> Bool {
let resolvedLocalFilePath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path
return fileManager.fileExists(atPath: resolvedLocalFilePath)
}
public func resolveLocalFilePath(
persistedLocalFilePath: String,
assetId: String
) async -> String? {
let trimmedPersistedPath = persistedLocalFilePath
.trimmingCharacters(in: .whitespacesAndNewlines)
if !trimmedPersistedPath.isEmpty {
let persistedURL = URL(fileURLWithPath: trimmedPersistedPath).standardizedFileURL
if fileManager.fileExists(atPath: persistedURL.path) {
return persistedURL.path
}
}
let currentFileURL = localFileURL(for: assetId).standardizedFileURL
guard fileManager.fileExists(atPath: currentFileURL.path) else {
return nil
}
return currentFileURL.path
}
private static func defaultBaseDirectoryURL(fileManager: FileManager) throws -> URL {
guard let applicationSupportURL = fileManager.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
throw CocoaError(.fileNoSuchFile)
}
return applicationSupportURL
.appendingPathComponent("Velody", isDirectory: true)
.appendingPathComponent("audio", isDirectory: true)
}
private func localFileURL(for assetId: String) -> URL {
baseDirectoryURL.appendingPathComponent("\(assetId).mp3")
}
private static func sha256Hex(for data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
}
public actor InMemoryOfflineAudioFileStore: OfflineAudioFileStore {
private var files: [String: Data]
public init(files: [String: Data] = [:]) {
self.files = files
}
public func saveAudioFile(
_ data: Data,
assetId: String,
sha256: String?
) async throws -> String {
guard !data.isEmpty else {
throw OfflineAudioFileStoreError.emptyAudioData
}
if let sha256 {
let actualHash = SHA256.hash(data: data)
.map { String(format: "%02x", $0) }
.joined()
if actualHash != sha256 {
throw OfflineAudioFileStoreError.sha256Mismatch(
expected: sha256,
actual: actualHash
)
}
}
let localFilePath = "/in-memory/\(assetId).mp3"
files[localFilePath] = data
return localFilePath
}
public func readAudioFile(at localFilePath: String) async throws -> Data {
guard let data = files[localFilePath] else {
throw OfflineAudioFileStoreError.missingLocalFile(path: localFilePath)
}
return data
}
public func fileExists(at localFilePath: String) async -> Bool {
files[localFilePath] != nil
}
public func resolveLocalFilePath(
persistedLocalFilePath: String,
assetId: String
) async -> String? {
let trimmedPersistedPath = persistedLocalFilePath
.trimmingCharacters(in: .whitespacesAndNewlines)
if files[trimmedPersistedPath] != nil {
return trimmedPersistedPath
}
let fallbackLocalFilePath = "/in-memory/\(assetId).mp3"
guard files[fallbackLocalFilePath] != nil else {
return nil
}
return fallbackLocalFilePath
}
}
@@ -0,0 +1,93 @@
import Foundation
import VelodyDomain
public protocol RemoteTrackDownloadStateStore: Actor {
func loadDownloadStates() async throws -> [RemoteTrackDownloadState]
func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws
}
public extension RemoteTrackDownloadStateStore {
func saveDownloadState(_ state: RemoteTrackDownloadState) async throws {
var states = try await loadDownloadStates()
if let existingIndex = states.firstIndex(where: { $0.remoteTrackId == state.remoteTrackId }) {
states[existingIndex] = state
} else {
states.append(state)
}
try await saveDownloadStates(states)
}
}
public actor FileRemoteTrackDownloadStateStore: RemoteTrackDownloadStateStore {
private let fileURL: URL
private let fileManager: FileManager
private let encoder = JSONEncoder()
private let decoder = JSONDecoder()
public init(
fileURL: URL? = nil,
fileManager: FileManager = .default
) throws {
self.fileManager = fileManager
if let fileURL {
self.fileURL = fileURL
} else {
self.fileURL = try Self.defaultFileURL(fileManager: fileManager)
}
encoder.dateEncodingStrategy = .iso8601
decoder.dateDecodingStrategy = .iso8601
}
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
guard fileManager.fileExists(atPath: fileURL.path) else {
return []
}
let data = try Data(contentsOf: fileURL)
return try decoder.decode([RemoteTrackDownloadState].self, from: data)
}
public func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws {
try fileManager.createDirectory(
at: fileURL.deletingLastPathComponent(),
withIntermediateDirectories: true
)
let sortedStates = states.sorted { lhs, rhs in
lhs.remoteTrackId.localizedCaseInsensitiveCompare(rhs.remoteTrackId) == .orderedAscending
}
let data = try encoder.encode(sortedStates)
try data.write(to: fileURL, options: .atomic)
}
private static func defaultFileURL(fileManager: FileManager) throws -> URL {
guard let applicationSupportURL = fileManager.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first else {
throw CocoaError(.fileNoSuchFile)
}
return applicationSupportURL
.appendingPathComponent("Velody", isDirectory: true)
.appendingPathComponent("remote-download-states.json")
}
}
public actor InMemoryRemoteTrackDownloadStateStore: RemoteTrackDownloadStateStore {
private var states: [RemoteTrackDownloadState]
public init(states: [RemoteTrackDownloadState] = []) {
self.states = states
}
public func loadDownloadStates() async throws -> [RemoteTrackDownloadState] {
states
}
public func saveDownloadStates(_ states: [RemoteTrackDownloadState]) async throws {
self.states = states
}
}
@@ -0,0 +1,133 @@
import CryptoKit
import Foundation
import XCTest
@testable import VelodyPersistence
final class OfflineAudioFileStoreTests: XCTestCase {
func testFileOfflineAudioFileStoreWritesAndReadsAudioData() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory)
let bytes = sampleMp3Data(seed: "offline-audio")
let localFilePath = try await store.saveAudioFile(
bytes,
assetId: "asset-123",
sha256: sha256Hex(bytes)
)
let storedBytes = try await store.readAudioFile(at: localFilePath)
let fileExists = await store.fileExists(at: localFilePath)
XCTAssertEqual(storedBytes, bytes)
XCTAssertTrue(fileExists)
}
func testFileOfflineAudioFileStoreRejectsEmptyAudioData() async throws {
let store = try FileOfflineAudioFileStore(
baseDirectoryURL: FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
)
await XCTAssertThrowsErrorAsync {
_ = try await store.saveAudioFile(
Data(),
assetId: "asset-123",
sha256: nil
)
} assertion: { error in
XCTAssertEqual(error as? OfflineAudioFileStoreError, .emptyAudioData)
}
}
func testFileOfflineAudioFileStoreRejectsShaMismatch() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let store = try FileOfflineAudioFileStore(baseDirectoryURL: tempDirectory)
let bytes = sampleMp3Data(seed: "sha-mismatch")
await XCTAssertThrowsErrorAsync {
_ = try await store.saveAudioFile(
bytes,
assetId: "asset-123",
sha256: String(repeating: "f", count: 64)
)
} assertion: { error in
guard case .sha256Mismatch = error as? OfflineAudioFileStoreError else {
return XCTFail("Expected a sha256Mismatch error.")
}
}
}
func testFileOfflineAudioFileStoreResolvesCurrentBaseDirectoryWhenPersistedPathIsStale() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let firstAudioDirectory = tempDirectory.appendingPathComponent("audio-v1", isDirectory: true)
let secondAudioDirectory = tempDirectory.appendingPathComponent("audio-v2", isDirectory: true)
let bytes = sampleMp3Data(seed: "path-repair")
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let staleFilePath = firstAudioDirectory
.appendingPathComponent("asset-123.mp3")
.standardizedFileURL
.path
let secondStore = try FileOfflineAudioFileStore(baseDirectoryURL: secondAudioDirectory)
let currentFilePath = try await secondStore.saveAudioFile(
bytes,
assetId: "asset-123",
sha256: sha256Hex(bytes)
)
let resolvedFilePath = await secondStore.resolveLocalFilePath(
persistedLocalFilePath: staleFilePath,
assetId: "asset-123"
)
XCTAssertEqual(resolvedFilePath, currentFilePath)
}
}
private func sampleMp3Data(seed: String) -> Data {
Data([
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x21,
] + Array(seed.utf8))
}
private func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
}
private func XCTAssertThrowsErrorAsync(
_ expression: @escaping () async throws -> Void,
assertion: (Error) -> Void,
file: StaticString = #filePath,
line: UInt = #line
) async {
do {
try await expression()
XCTFail("Expected expression to throw an error.", file: file, line: line)
} catch {
assertion(error)
}
}
@@ -0,0 +1,38 @@
import Foundation
import XCTest
import VelodyDomain
@testable import VelodyPersistence
final class RemoteTrackDownloadStateStoreTests: XCTestCase {
func testFileDownloadStateStorePersistsAcrossInstances() async throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let fileURL = tempDirectory.appendingPathComponent("remote-download-states.json")
defer {
try? fileManager.removeItem(at: tempDirectory)
}
let firstStore = try FileRemoteTrackDownloadStateStore(fileURL: fileURL)
let states = [
RemoteTrackDownloadState(
remoteTrackId: "track-123",
assetId: "asset-456",
localFilePath: "/tmp/asset-456.mp3",
downloadedAt: Date(timeIntervalSince1970: 1_000),
downloadStatus: .downloaded,
lastDownloadError: nil
),
]
try await firstStore.saveDownloadStates(states)
let secondStore = try FileRemoteTrackDownloadStateStore(fileURL: fileURL)
let restoredStates = try await secondStore.loadDownloadStates()
XCTAssertEqual(restoredStates, states)
}
}