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
@@ -13,6 +13,28 @@ public enum LocalUploadStatus: String, Codable, Hashable, Sendable, CaseIterable
case failed
}
public struct LocalTrackArtwork: Codable, Hashable, Sendable {
public var localFilePath: String
public var sha256: String
public var mimeType: String
public var width: Int?
public var height: Int?
public init(
localFilePath: String,
sha256: String,
mimeType: String,
width: Int? = nil,
height: Int? = nil
) {
self.localFilePath = localFilePath
self.sha256 = sha256
self.mimeType = mimeType
self.width = width
self.height = height
}
}
public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
public let id: String
public var title: String
@@ -21,6 +43,7 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
public var durationSeconds: Double?
public var localFilePath: String
public var sha256: String?
public var artwork: LocalTrackArtwork?
public var uploadStatus: LocalUploadStatus?
public var remoteTrackId: String?
public var lastUploadError: String?
@@ -33,6 +56,7 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
durationSeconds: Double? = nil,
localFilePath: String = "",
sha256: String? = nil,
artwork: LocalTrackArtwork? = nil,
uploadStatus: LocalUploadStatus? = nil,
remoteTrackId: String? = nil,
lastUploadError: String? = nil
@@ -44,6 +68,7 @@ public struct LibraryTrack: Identifiable, Codable, Hashable, Sendable {
self.durationSeconds = durationSeconds
self.localFilePath = localFilePath
self.sha256 = sha256
self.artwork = artwork
self.uploadStatus = uploadStatus
self.remoteTrackId = remoteTrackId
self.lastUploadError = lastUploadError
@@ -187,21 +212,46 @@ public struct UploadSessionStatusResponse: Codable, Hashable, Sendable {
}
public struct UploadFinalizeRequest: Codable, Hashable, Sendable {
public struct ArtworkPayload: Codable, Hashable, Sendable {
public var dataBase64: String
public var sha256: String
public var mimeType: String
public var width: Int?
public var height: Int?
public init(
dataBase64: String,
sha256: String,
mimeType: String,
width: Int? = nil,
height: Int? = nil
) {
self.dataBase64 = dataBase64
self.sha256 = sha256
self.mimeType = mimeType
self.width = width
self.height = height
}
}
public var title: String
public var artist: String
public var album: String?
public var durationMs: Int?
public var artwork: ArtworkPayload?
public init(
title: String,
artist: String,
album: String? = nil,
durationMs: Int? = nil
durationMs: Int? = nil,
artwork: ArtworkPayload? = nil
) {
self.title = title
self.artist = artist
self.album = album
self.durationMs = durationMs
self.artwork = artwork
}
}
@@ -0,0 +1,158 @@
import Foundation
public enum LocalArtworkStoreError: LocalizedError, Equatable, Sendable {
case emptyArtworkData
case missingLocalFile(path: String)
public var errorDescription: String? {
switch self {
case .emptyArtworkData:
return "The local artwork data was empty."
case let .missingLocalFile(path):
return "The local artwork file is missing: \(path)"
}
}
}
public protocol LocalArtworkStore: Actor {
func saveArtwork(
_ data: Data,
sha256: String,
mimeType: String
) async throws -> String
func readArtwork(at localFilePath: String) async throws -> Data
func fileExists(at localFilePath: String) async -> Bool
}
public actor FileLocalArtworkStore: LocalArtworkStore {
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,
sha256: String,
mimeType: String
) async throws -> String {
guard !data.isEmpty else {
throw LocalArtworkStoreError.emptyArtworkData
}
try fileManager.createDirectory(
at: baseDirectoryURL,
withIntermediateDirectories: true
)
let fileURL = localFileURL(sha256: sha256, mimeType: mimeType)
if fileManager.fileExists(atPath: fileURL.path) {
return fileURL.standardizedFileURL.path
}
try data.write(to: fileURL, options: .atomic)
return fileURL.standardizedFileURL.path
}
public func readArtwork(at localFilePath: String) async throws -> Data {
let fileURL = URL(fileURLWithPath: localFilePath).standardizedFileURL
guard fileManager.fileExists(atPath: fileURL.path) else {
throw LocalArtworkStoreError.missingLocalFile(path: localFilePath)
}
return try Data(contentsOf: fileURL)
}
public func fileExists(at localFilePath: String) async -> Bool {
let fileURL = URL(fileURLWithPath: localFilePath).standardizedFileURL
return fileManager.fileExists(atPath: fileURL.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("local-artwork", isDirectory: true)
}
private func localFileURL(sha256: String, mimeType: String) -> URL {
baseDirectoryURL.appendingPathComponent(
"\(sha256).\(Self.fileExtension(for: mimeType))"
)
}
private static func fileExtension(for mimeType: String) -> String {
switch mimeType.lowercased() {
case "image/jpeg", "image/jpg":
return "jpg"
case "image/png":
return "png"
default:
return "img"
}
}
}
public actor InMemoryLocalArtworkStore: LocalArtworkStore {
private var files: [String: Data]
public init(files: [String: Data] = [:]) {
self.files = files
}
public func saveArtwork(
_ data: Data,
sha256: String,
mimeType: String
) async throws -> String {
guard !data.isEmpty else {
throw LocalArtworkStoreError.emptyArtworkData
}
let localFilePath = Self.localFilePath(sha256: sha256, mimeType: mimeType)
files[localFilePath] = data
return localFilePath
}
public func readArtwork(at localFilePath: String) async throws -> Data {
guard let data = files[localFilePath] else {
throw LocalArtworkStoreError.missingLocalFile(path: localFilePath)
}
return data
}
public func fileExists(at localFilePath: String) async -> Bool {
files[localFilePath] != nil
}
private static func localFilePath(sha256: String, mimeType: String) -> String {
let fileExtension: String
switch mimeType.lowercased() {
case "image/jpeg", "image/jpg":
fileExtension = "jpg"
case "image/png":
fileExtension = "png"
default:
fileExtension = "img"
}
return "/in-memory/local-artwork/\(sha256).\(fileExtension)"
}
}
@@ -188,6 +188,7 @@ public actor DefaultLocalCatalogService: LocalCatalogService {
|| existingTrack.durationSeconds != scannedTrack.durationSeconds
|| existingTrack.localFilePath != scannedTrack.localFilePath
|| existingTrack.sha256 != scannedTrack.sha256
|| existingTrack.artwork != scannedTrack.artwork
|| existingTrack.fileModifiedAt != scannedTrack.fileModifiedAt
|| existingTrack.isDeleted
@@ -198,6 +199,7 @@ public actor DefaultLocalCatalogService: LocalCatalogService {
updatedTrack.durationSeconds = scannedTrack.durationSeconds
updatedTrack.localFilePath = scannedTrack.localFilePath
updatedTrack.sha256 = scannedTrack.sha256
updatedTrack.artwork = scannedTrack.artwork
updatedTrack.fileModifiedAt = scannedTrack.fileModifiedAt
updatedTrack.lastScannedAt = scannedAt
updatedTrack.isDeleted = false
@@ -2,21 +2,46 @@ import Foundation
import VelodyDomain
public struct LocalTrackMetadata: Hashable, Sendable {
public struct EmbeddedArtworkPayload: Hashable, Sendable {
public var data: Data
public var sha256: String
public var mimeType: String
public var width: Int?
public var height: Int?
public init(
data: Data,
sha256: String,
mimeType: String,
width: Int? = nil,
height: Int? = nil
) {
self.data = data
self.sha256 = sha256
self.mimeType = mimeType
self.width = width
self.height = height
}
}
public var title: String?
public var artist: String?
public var album: String?
public var durationSeconds: Double?
public var artwork: EmbeddedArtworkPayload?
public init(
title: String? = nil,
artist: String? = nil,
album: String? = nil,
durationSeconds: Double? = nil
durationSeconds: Double? = nil,
artwork: EmbeddedArtworkPayload? = nil
) {
self.title = title
self.artist = artist
self.album = album
self.durationSeconds = durationSeconds
self.artwork = artwork
}
}
@@ -0,0 +1,27 @@
import Foundation
import VelodyDomain
public actor LocalTrackArtworkUploadPayloadBuilder {
private let artworkStore: any LocalArtworkStore
public init(artworkStore: any LocalArtworkStore) {
self.artworkStore = artworkStore
}
public func makePayload(
for artwork: LocalTrackArtwork?
) async throws -> UploadFinalizeRequest.ArtworkPayload? {
guard let artwork else {
return nil
}
let data = try await artworkStore.readArtwork(at: artwork.localFilePath)
return UploadFinalizeRequest.ArtworkPayload(
dataBase64: data.base64EncodedString(),
sha256: artwork.sha256,
mimeType: artwork.mimeType,
width: artwork.width,
height: artwork.height
)
}
}
@@ -15,6 +15,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
public var durationSeconds: Double?
public var localFilePath: String
public var sha256: String?
public var artwork: LocalTrackArtwork?
public var uploadStatus: LocalUploadStatus
public var remoteTrackId: String?
public var lastUploadError: String?
@@ -34,6 +35,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
durationSeconds: Double? = nil,
localFilePath: String = "",
sha256: String? = nil,
artwork: LocalTrackArtwork? = nil,
uploadStatus: LocalUploadStatus = .localOnly,
remoteTrackId: String? = nil,
lastUploadError: String? = nil,
@@ -52,6 +54,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
self.durationSeconds = durationSeconds
self.localFilePath = localFilePath
self.sha256 = sha256
self.artwork = artwork
self.uploadStatus = uploadStatus
self.remoteTrackId = remoteTrackId
self.lastUploadError = lastUploadError
@@ -77,6 +80,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
durationSeconds: scannedTrack.durationSeconds,
localFilePath: scannedTrack.localFilePath,
sha256: scannedTrack.sha256,
artwork: scannedTrack.artwork,
uploadStatus: .localOnly,
remoteTrackId: nil,
lastUploadError: nil,
@@ -103,6 +107,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
durationSeconds: libraryTrack.durationSeconds,
localFilePath: libraryTrack.localFilePath,
sha256: libraryTrack.sha256,
artwork: libraryTrack.artwork,
uploadStatus: libraryTrack.uploadStatus ?? .localOnly,
remoteTrackId: libraryTrack.remoteTrackId,
lastUploadError: libraryTrack.lastUploadError,
@@ -132,6 +137,7 @@ public struct LocalTrack: Identifiable, Codable, Hashable, Sendable {
durationSeconds: durationSeconds,
localFilePath: localFilePath,
sha256: sha256,
artwork: artwork,
uploadStatus: uploadStatus,
remoteTrackId: remoteTrackId,
lastUploadError: lastUploadError
@@ -168,6 +174,7 @@ public struct ScannedLocalTrack: Identifiable, Hashable, Sendable {
public var durationSeconds: Double?
public var localFilePath: String
public var sha256: String
public var artwork: LocalTrackArtwork?
public var fileModifiedAt: Date
public init(
@@ -177,6 +184,7 @@ public struct ScannedLocalTrack: Identifiable, Hashable, Sendable {
durationSeconds: Double? = nil,
localFilePath: String,
sha256: String,
artwork: LocalTrackArtwork? = nil,
fileModifiedAt: Date
) {
self.title = title
@@ -185,6 +193,7 @@ public struct ScannedLocalTrack: Identifiable, Hashable, Sendable {
self.durationSeconds = durationSeconds
self.localFilePath = localFilePath
self.sha256 = sha256
self.artwork = artwork
self.fileModifiedAt = fileModifiedAt
}
@@ -0,0 +1,192 @@
import CryptoKit
import Foundation
import ImageIO
public struct MP3EmbeddedArtworkExtractor: Sendable {
public init() {}
public func extractArtwork(
from fileURL: URL
) throws -> LocalTrackMetadata.EmbeddedArtworkPayload? {
let fileHandle = try FileHandle(forReadingFrom: fileURL)
defer {
try? fileHandle.close()
}
let header = try fileHandle.read(upToCount: 10) ?? Data()
guard header.count == 10 else {
return nil
}
guard header.prefix(3) == Data("ID3".utf8) else {
return nil
}
let version = header[3]
let tagSize = synchsafeInteger(from: header[6...9])
guard tagSize > 0 else {
return nil
}
let tagData = try fileHandle.read(upToCount: tagSize) ?? Data()
guard tagData.count == tagSize else {
return nil
}
return parseAPICFrame(from: tagData, version: version)
}
private func parseAPICFrame(
from tagData: Data,
version: UInt8
) -> LocalTrackMetadata.EmbeddedArtworkPayload? {
var offset = 0
while offset + 10 <= tagData.count {
let frameHeader = tagData[offset..<(offset + 10)]
let frameIDData = Data(frameHeader.prefix(4))
if frameIDData.allSatisfy({ $0 == 0 }) {
break
}
guard let frameID = String(data: frameIDData, encoding: .isoLatin1),
frameID.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) })
else {
break
}
let frameSizeSlice = frameHeader.dropFirst(4).prefix(4)
let frameSize = version >= 4
? synchsafeInteger(from: frameSizeSlice)
: bigEndianInteger(from: frameSizeSlice)
guard frameSize > 0, offset + 10 + frameSize <= tagData.count else {
break
}
let frameData = tagData[(offset + 10)..<(offset + 10 + frameSize)]
if frameID == "APIC" {
return parseAPICBody(Data(frameData))
}
offset += 10 + frameSize
}
return nil
}
private func parseAPICBody(
_ frameData: Data
) -> LocalTrackMetadata.EmbeddedArtworkPayload? {
guard frameData.count > 4 else {
return nil
}
let textEncoding = frameData[0]
guard let mimeTypeEnd = frameData[1...].firstIndex(of: 0x00),
mimeTypeEnd < frameData.endIndex
else {
return nil
}
let rawMimeType = String(
data: frameData[1..<mimeTypeEnd],
encoding: .isoLatin1
)?.trimmingCharacters(in: .whitespacesAndNewlines)
guard let mimeType = normalizedMimeType(rawMimeType) else {
return nil
}
let pictureTypeIndex = frameData.index(after: mimeTypeEnd)
guard pictureTypeIndex < frameData.endIndex else {
return nil
}
let descriptionStart = frameData.index(after: pictureTypeIndex)
let imageDataStart = startOfImageData(
in: frameData,
descriptionStart: descriptionStart,
textEncoding: textEncoding
)
guard imageDataStart < frameData.endIndex else {
return nil
}
let imageData = Data(frameData[imageDataStart...])
guard !imageData.isEmpty else {
return nil
}
let dimensions = imageDimensions(for: imageData)
return LocalTrackMetadata.EmbeddedArtworkPayload(
data: imageData,
sha256: SHA256.hash(data: imageData)
.map { String(format: "%02x", $0) }
.joined(),
mimeType: mimeType,
width: dimensions.width,
height: dimensions.height
)
}
private func startOfImageData(
in frameData: Data,
descriptionStart: Data.Index,
textEncoding: UInt8
) -> Data.Index {
switch textEncoding {
case 0x01, 0x02:
var index = descriptionStart
while index + 1 < frameData.endIndex {
if frameData[index] == 0x00, frameData[index + 1] == 0x00 {
return index + 2
}
index += 1
}
default:
if let terminatorIndex = frameData[descriptionStart...].firstIndex(of: 0x00) {
return terminatorIndex + 1
}
}
return descriptionStart
}
private func normalizedMimeType(_ mimeType: String?) -> String? {
switch mimeType?.lowercased() {
case "image/jpeg", "image/jpg":
return "image/jpeg"
case "image/png":
return "image/png"
default:
return nil
}
}
private func imageDimensions(for data: Data) -> (width: Int?, height: Int?) {
guard let imageSource = CGImageSourceCreateWithData(data as CFData, nil),
let properties = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, nil) as? [CFString: Any]
else {
return (nil, nil)
}
return (
properties[kCGImagePropertyPixelWidth] as? Int,
properties[kCGImagePropertyPixelHeight] as? Int
)
}
private func synchsafeInteger<S: Sequence>(from bytes: S) -> Int where S.Element == UInt8 {
bytes.reduce(0) { partial, byte in
(partial << 7) | Int(byte & 0x7f)
}
}
private func bigEndianInteger<S: Sequence>(from bytes: S) -> Int where S.Element == UInt8 {
bytes.reduce(0) { partial, byte in
(partial << 8) | Int(byte)
}
}
}
@@ -13,6 +13,11 @@ final class TrackEntity {
var durationSeconds: Double?
var localFilePath: String
var sha256: String?
var localArtworkFilePath: String?
var artworkSHA256: String?
var artworkMimeType: String?
var artworkWidth: Int?
var artworkHeight: Int?
var uploadStatusRawValue: String?
var remoteTrackID: String?
var lastUploadError: String?
@@ -33,6 +38,11 @@ final class TrackEntity {
durationSeconds = track.durationSeconds
localFilePath = track.localFilePath
sha256 = track.sha256
localArtworkFilePath = track.artwork?.localFilePath
artworkSHA256 = track.artwork?.sha256
artworkMimeType = track.artwork?.mimeType
artworkWidth = track.artwork?.width
artworkHeight = track.artwork?.height
uploadStatusRawValue = track.uploadStatus.rawValue
remoteTrackID = track.remoteTrackId
lastUploadError = track.lastUploadError
@@ -54,6 +64,7 @@ final class TrackEntity {
durationSeconds: durationSeconds,
localFilePath: localFilePath,
sha256: sha256,
artwork: localArtwork,
uploadStatus: LocalUploadStatus(rawValue: uploadStatusRawValue ?? "") ?? .localOnly,
remoteTrackId: remoteTrackID,
lastUploadError: lastUploadError,
@@ -76,6 +87,11 @@ final class TrackEntity {
durationSeconds = track.durationSeconds
localFilePath = track.localFilePath
sha256 = track.sha256
localArtworkFilePath = track.artwork?.localFilePath
artworkSHA256 = track.artwork?.sha256
artworkMimeType = track.artwork?.mimeType
artworkWidth = track.artwork?.width
artworkHeight = track.artwork?.height
uploadStatusRawValue = track.uploadStatus.rawValue
remoteTrackID = track.remoteTrackId
lastUploadError = track.lastUploadError
@@ -86,4 +102,21 @@ final class TrackEntity {
createdAt = track.createdAt
updatedAt = track.updatedAt
}
private var localArtwork: LocalTrackArtwork? {
guard let localArtworkFilePath,
let artworkSHA256,
let artworkMimeType
else {
return nil
}
return LocalTrackArtwork(
localFilePath: localArtworkFilePath,
sha256: artworkSHA256,
mimeType: artworkMimeType,
width: artworkWidth,
height: artworkHeight
)
}
}
@@ -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()
}