Fix artwork upload pipeline
This commit is contained in:
+36
@@ -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)
|
||||
|
||||
+50
@@ -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=="
|
||||
)!
|
||||
}
|
||||
+113
@@ -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()
|
||||
}
|
||||
Reference in New Issue
Block a user