Implement Milestone 7.2 offline audio downloads
This commit is contained in:
+209
@@ -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
|
||||
}
|
||||
}
|
||||
+93
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user