Implement artwork download and cache

This commit is contained in:
diyaa
2026-05-30 09:43:14 +02:00
parent 8caf29f186
commit 7b1952794c
23 changed files with 1261 additions and 66 deletions
@@ -0,0 +1,200 @@
import Foundation
import VelodyDomain
public enum ArtworkStoreError: LocalizedError, Equatable, Sendable {
case emptyArtworkData
case missingLocalFile(path: String)
public var errorDescription: String? {
switch self {
case .emptyArtworkData:
return "The downloaded artwork file was empty."
case let .missingLocalFile(path):
return "The local artwork file is missing: \(path)"
}
}
}
public protocol ArtworkStore: Actor {
func saveArtwork(_ data: Data, artwork: RemoteArtwork) async throws -> String
func readArtwork(at localFilePath: String) async throws -> Data
func fileExists(at localFilePath: String) async -> Bool
func cachedFilePath(for artwork: RemoteArtwork) async -> String?
}
public actor FileArtworkStore: ArtworkStore {
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 saveArtwork(_ data: Data, artwork: RemoteArtwork) async throws -> String {
guard !data.isEmpty else {
throw ArtworkStoreError.emptyArtworkData
}
try fileManager.createDirectory(
at: baseDirectoryURL,
withIntermediateDirectories: true
)
let fileURL = localFileURL(for: artwork)
try data.write(to: fileURL, options: .atomic)
let storedData = try Data(contentsOf: fileURL)
guard !storedData.isEmpty else {
try? fileManager.removeItem(at: fileURL)
throw ArtworkStoreError.emptyArtworkData
}
return fileURL.standardizedFileURL.path
}
public func readArtwork(at localFilePath: String) async throws -> Data {
let standardizedPath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path
guard fileManager.fileExists(atPath: standardizedPath) else {
throw ArtworkStoreError.missingLocalFile(path: localFilePath)
}
return try Data(contentsOf: URL(fileURLWithPath: standardizedPath))
}
public func fileExists(at localFilePath: String) async -> Bool {
let standardizedPath = URL(fileURLWithPath: localFilePath).standardizedFileURL.path
return fileManager.fileExists(atPath: standardizedPath)
}
public func cachedFilePath(for artwork: RemoteArtwork) async -> String? {
let expectedFileURL = localFileURL(for: artwork).standardizedFileURL
if fileManager.fileExists(atPath: expectedFileURL.path) {
return expectedFileURL.path
}
guard let contents = try? fileManager.contentsOfDirectory(
at: baseDirectoryURL,
includingPropertiesForKeys: nil
) else {
return nil
}
let fallbackPrefix = "\(artwork.artworkId)."
return contents
.first(where: {
$0.lastPathComponent.hasPrefix(fallbackPrefix) &&
fileManager.fileExists(atPath: $0.path)
})?
.standardizedFileURL
.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("artwork", isDirectory: true)
}
private func localFileURL(for artwork: RemoteArtwork) -> URL {
baseDirectoryURL.appendingPathComponent(
"\(artwork.artworkId).\(Self.fileExtension(for: artwork.mimeType))"
)
}
private static func fileExtension(for mimeType: String) -> String {
switch mimeType.lowercased() {
case "image/jpeg", "image/jpg":
return "jpg"
case "image/png":
return "png"
case "image/webp":
return "webp"
case "image/heic":
return "heic"
case "image/heif":
return "heif"
case "image/gif":
return "gif"
default:
return "img"
}
}
}
public actor InMemoryArtworkStore: ArtworkStore {
private var files: [String: Data]
public init(files: [String: Data] = [:]) {
self.files = files
}
public func saveArtwork(_ data: Data, artwork: RemoteArtwork) async throws -> String {
guard !data.isEmpty else {
throw ArtworkStoreError.emptyArtworkData
}
let localFilePath = Self.localFilePath(for: artwork)
files[localFilePath] = data
return localFilePath
}
public func readArtwork(at localFilePath: String) async throws -> Data {
guard let data = files[localFilePath] else {
throw ArtworkStoreError.missingLocalFile(path: localFilePath)
}
return data
}
public func fileExists(at localFilePath: String) async -> Bool {
files[localFilePath] != nil
}
public func cachedFilePath(for artwork: RemoteArtwork) async -> String? {
let expectedFilePath = Self.localFilePath(for: artwork)
if files[expectedFilePath] != nil {
return expectedFilePath
}
let fallbackPrefix = "/in-memory/\(artwork.artworkId)."
return files.keys.first(where: { $0.hasPrefix(fallbackPrefix) })
}
private static func localFilePath(for artwork: RemoteArtwork) -> String {
let fileExtension: String
switch artwork.mimeType.lowercased() {
case "image/jpeg", "image/jpg":
fileExtension = "jpg"
case "image/png":
fileExtension = "png"
case "image/webp":
fileExtension = "webp"
case "image/heic":
fileExtension = "heic"
case "image/heif":
fileExtension = "heif"
case "image/gif":
fileExtension = "gif"
default:
fileExtension = "img"
}
return "/in-memory/\(artwork.artworkId).\(fileExtension)"
}
}