Implement artwork download and cache
This commit is contained in:
@@ -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)"
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
import VelodyDomain
|
||||
@testable import VelodyPersistence
|
||||
|
||||
final class ArtworkStoreTests: XCTestCase {
|
||||
func testFileArtworkStoreWritesAndReadsArtworkData() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let store = try FileArtworkStore(baseDirectoryURL: tempDirectory)
|
||||
let artwork = RemoteArtwork(
|
||||
artworkId: "artwork-123",
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1
|
||||
)
|
||||
let bytes = sampleArtworkData()
|
||||
|
||||
let localFilePath = try await store.saveArtwork(bytes, artwork: artwork)
|
||||
let storedBytes = try await store.readArtwork(at: localFilePath)
|
||||
let fileExists = await store.fileExists(at: localFilePath)
|
||||
let cachedFilePath = await store.cachedFilePath(for: artwork)
|
||||
|
||||
XCTAssertEqual(storedBytes, bytes)
|
||||
XCTAssertTrue(fileExists)
|
||||
XCTAssertEqual(cachedFilePath, localFilePath)
|
||||
}
|
||||
|
||||
func testFileArtworkStoreRejectsEmptyArtworkData() async throws {
|
||||
let store = try FileArtworkStore(
|
||||
baseDirectoryURL: FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||
)
|
||||
|
||||
await XCTAssertThrowsErrorAsync {
|
||||
_ = try await store.saveArtwork(
|
||||
Data(),
|
||||
artwork: RemoteArtwork(
|
||||
artworkId: "artwork-123",
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
mimeType: "image/png"
|
||||
)
|
||||
)
|
||||
} assertion: { error in
|
||||
XCTAssertEqual(error as? ArtworkStoreError, .emptyArtworkData)
|
||||
}
|
||||
}
|
||||
|
||||
func testFileArtworkStorePersistsArtworkAcrossInstances() async throws {
|
||||
let fileManager = FileManager.default
|
||||
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
|
||||
UUID().uuidString,
|
||||
isDirectory: true
|
||||
)
|
||||
let artwork = RemoteArtwork(
|
||||
artworkId: "artwork-123",
|
||||
sha256: String(repeating: "a", count: 64),
|
||||
mimeType: "image/png",
|
||||
width: 1,
|
||||
height: 1
|
||||
)
|
||||
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
let firstStore = try FileArtworkStore(baseDirectoryURL: tempDirectory)
|
||||
let firstLocalFilePath = try await firstStore.saveArtwork(
|
||||
sampleArtworkData(),
|
||||
artwork: artwork
|
||||
)
|
||||
|
||||
let secondStore = try FileArtworkStore(baseDirectoryURL: tempDirectory)
|
||||
let restoredLocalFilePath = await secondStore.cachedFilePath(for: artwork)
|
||||
|
||||
XCTAssertEqual(restoredLocalFilePath, firstLocalFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
private func sampleArtworkData() -> Data {
|
||||
Data(
|
||||
base64Encoded:
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
|
||||
)!
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user