Fix artwork upload pipeline

This commit is contained in:
diyaa
2026-05-31 01:20:56 +02:00
parent 7b1952794c
commit 18ed79e3c4
24 changed files with 1652 additions and 56 deletions
@@ -91,6 +91,42 @@ final class LocalCatalogServiceTests: XCTestCase {
XCTAssertEqual(storedTracks.first?.sha256, "sha-updated")
}
func testScanPersistsEmbeddedArtworkReferenceWithoutStoringArtworkBytesInSwiftData() async throws {
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
let service = DefaultLocalCatalogService(repository: repository)
let modifiedAt = Date(timeIntervalSince1970: 2_500)
let artwork = LocalTrackArtwork(
localFilePath: "/Application Support/Velody/local-artwork/artwork-sha.png",
sha256: "artwork-sha",
mimeType: "image/png",
width: 512,
height: 512
)
let result = try await service.reconcileScanResults(
[
ScannedLocalTrack(
title: "Art Track",
artist: "Artist",
album: "Album",
durationSeconds: 180,
localFilePath: "/Music/ArtTrack.mp3",
sha256: "sha-art-track",
artwork: artwork,
fileModifiedAt: modifiedAt
),
],
in: URL(fileURLWithPath: "/Music"),
scannedAt: Date(timeIntervalSince1970: 2_600)
)
let storedTrack = try XCTUnwrap(result.tracks.first)
XCTAssertEqual(storedTrack.artwork, artwork)
let reloadedTrack = try await repository.findTrack(trackID: storedTrack.id)
XCTAssertEqual(reloadedTrack?.artwork, artwork)
}
func testRescanMarksDeletedTracksAndReactivatesExistingSHA() async throws {
let repository = try SwiftDataTrackRepository(isStoredInMemoryOnly: true)
let service = DefaultLocalCatalogService(repository: repository)
@@ -0,0 +1,50 @@
import Foundation
import XCTest
import VelodyDomain
@testable import VelodyPersistence
final class LocalTrackArtworkUploadPayloadBuilderTests: XCTestCase {
func testBuilderIncludesArtworkPayloadWhenArtworkExists() async throws {
let store = InMemoryLocalArtworkStore()
let data = sampleArtworkData()
let localFilePath = try await store.saveArtwork(
data,
sha256: "artwork-sha",
mimeType: "image/png"
)
let builder = LocalTrackArtworkUploadPayloadBuilder(artworkStore: store)
let payload = try await builder.makePayload(
for: LocalTrackArtwork(
localFilePath: localFilePath,
sha256: "artwork-sha",
mimeType: "image/png",
width: 1,
height: 1
)
)
XCTAssertEqual(payload?.dataBase64, data.base64EncodedString())
XCTAssertEqual(payload?.sha256, "artwork-sha")
XCTAssertEqual(payload?.mimeType, "image/png")
XCTAssertEqual(payload?.width, 1)
XCTAssertEqual(payload?.height, 1)
}
func testBuilderReturnsNilWhenArtworkIsMissing() async throws {
let builder = LocalTrackArtworkUploadPayloadBuilder(
artworkStore: InMemoryLocalArtworkStore()
)
let payload = try await builder.makePayload(for: nil)
XCTAssertNil(payload)
}
}
private func sampleArtworkData() -> Data {
Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
)!
}
@@ -0,0 +1,113 @@
import CryptoKit
import Foundation
import XCTest
@testable import VelodyPersistence
final class MP3EmbeddedArtworkExtractorTests: XCTestCase {
func testExtractorReadsEmbeddedPNGArtworkFromID3APICFrame() throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let fileURL = tempDirectory.appendingPathComponent("artwork-test.mp3")
let extractor = MP3EmbeddedArtworkExtractor()
let artworkData = sampleArtworkData()
defer {
try? fileManager.removeItem(at: tempDirectory)
}
try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
try makeMP3FileWithEmbeddedArtwork(
artworkData: artworkData,
mimeType: "image/png",
at: fileURL
)
let extractedArtwork = try XCTUnwrap(
extractor.extractArtwork(from: fileURL)
)
XCTAssertEqual(extractedArtwork.data, artworkData)
XCTAssertEqual(extractedArtwork.mimeType, "image/png")
XCTAssertEqual(extractedArtwork.width, 1)
XCTAssertEqual(extractedArtwork.height, 1)
XCTAssertEqual(extractedArtwork.sha256, sha256Hex(artworkData))
}
func testExtractorReturnsNilWhenMP3HasNoEmbeddedArtwork() throws {
let fileManager = FileManager.default
let tempDirectory = fileManager.temporaryDirectory.appendingPathComponent(
UUID().uuidString,
isDirectory: true
)
let fileURL = tempDirectory.appendingPathComponent("no-artwork.mp3")
let extractor = MP3EmbeddedArtworkExtractor()
defer {
try? fileManager.removeItem(at: tempDirectory)
}
try fileManager.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
try Data([
0x49, 0x44, 0x33, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
]).write(to: fileURL)
XCTAssertNil(try extractor.extractArtwork(from: fileURL))
}
}
private func makeMP3FileWithEmbeddedArtwork(
artworkData: Data,
mimeType: String,
at fileURL: URL
) throws {
let mimeTypeData = Data(mimeType.utf8) + Data([0x00])
let frameBody = Data([0x00]) // ISO-8859-1 encoding
+ mimeTypeData
+ Data([0x03]) // front cover
+ Data([0x00]) // empty description
+ artworkData
let frameSize = UInt32(frameBody.count)
let frameHeader = Data("APIC".utf8)
+ Data([
UInt8((frameSize >> 24) & 0xff),
UInt8((frameSize >> 16) & 0xff),
UInt8((frameSize >> 8) & 0xff),
UInt8(frameSize & 0xff),
0x00,
0x00,
])
let tagBody = frameHeader + frameBody
let tagSize = synchsafeBytes(for: tagBody.count)
let id3Header = Data([0x49, 0x44, 0x33, 0x03, 0x00, 0x00]) + tagSize
let mp3Bytes = id3Header + tagBody + Data([
0xff, 0xfb, 0x90, 0x64, 0x00, 0x00, 0x00, 0x00,
])
try mp3Bytes.write(to: fileURL)
}
private func synchsafeBytes(for value: Int) -> Data {
Data([
UInt8((value >> 21) & 0x7f),
UInt8((value >> 14) & 0x7f),
UInt8((value >> 7) & 0x7f),
UInt8(value & 0x7f),
])
}
private func sampleArtworkData() -> Data {
Data(
base64Encoded:
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVQIW2P8z8DwHwAFgwJ/lBi4NwAAAABJRU5ErkJggg=="
)!
}
private func sha256Hex(_ data: Data) -> String {
SHA256.hash(data: data)
.map { String(format: "%02x", $0) }
.joined()
}