Fix artwork upload pipeline
This commit is contained in:
@@ -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
|
||||
|
||||
+26
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+27
@@ -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
|
||||
}
|
||||
|
||||
|
||||
+192
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user