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
@@ -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
)
}
}