| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133 |
- import AppKit
- import AVFoundation
- import Foundation
- /// Loads album artwork from folder images (foobar2000 style) or embedded metadata.
- /// Searches for common cover art filenames in the same directory as the audio file.
- actor ArtworkService {
- static let shared = ArtworkService()
- /// In-memory cache: folder path → NSImage
- private var cache: [String: NSImage] = [:]
- /// Common cover art filenames to search for (priority order).
- private static let coverFileNames: [String] = [
- "cover", "folder", "front", "album", "albumart",
- "albumartsmall", "thumb", "artwork", "art", "scan",
- "Cover", "Folder", "Front", "Album"
- ]
- /// Supported image extensions.
- private static let imageExtensions: Set<String> = [
- "jpg", "jpeg", "png", "bmp", "gif", "tiff", "webp"
- ]
- // MARK: - Public API
- /// Get artwork for a track. Checks folder images first, then embedded metadata.
- func artwork(for track: Track) async -> NSImage? {
- let folderPath = track.fileURL.deletingLastPathComponent().path
- // Check cache
- if let cached = cache[folderPath] {
- return cached
- }
- // 1. Try folder images (foobar2000 style)
- if let folderArt = findFolderArtwork(in: track.fileURL.deletingLastPathComponent()) {
- cache[folderPath] = folderArt
- return folderArt
- }
- // 2. Try embedded metadata
- if let embedded = await extractEmbeddedArtwork(from: track.fileURL) {
- cache[folderPath] = embedded
- return embedded
- }
- return nil
- }
- /// Get artwork for a URL directly (without Track model).
- func artwork(forFileAt url: URL) async -> NSImage? {
- let folderPath = url.deletingLastPathComponent().path
- if let cached = cache[folderPath] {
- return cached
- }
- if let folderArt = findFolderArtwork(in: url.deletingLastPathComponent()) {
- cache[folderPath] = folderArt
- return folderArt
- }
- if let embedded = await extractEmbeddedArtwork(from: url) {
- cache[folderPath] = embedded
- return embedded
- }
- return nil
- }
- /// Clear the artwork cache.
- func clearCache() {
- cache.removeAll()
- }
- /// Remove a specific folder from cache.
- func invalidateCache(for folderPath: String) {
- cache.removeValue(forKey: folderPath)
- }
- // MARK: - Folder Artwork (foobar2000 style)
- private func findFolderArtwork(in folderURL: URL) -> NSImage? {
- let fm = FileManager.default
- // First pass: try known filenames
- for name in Self.coverFileNames {
- for ext in Self.imageExtensions {
- let candidate = folderURL.appendingPathComponent("\(name).\(ext)")
- if fm.fileExists(atPath: candidate.path),
- let image = NSImage(contentsOf: candidate) {
- return image
- }
- }
- }
- // Second pass: any image file in the folder
- if let contents = try? fm.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
- let images = contents.filter { Self.imageExtensions.contains($0.pathExtension.lowercased()) }
- // Prefer smaller files (likely covers vs. high-res scans)
- let sorted = images.sorted { url1, url2 in
- let s1 = (try? fm.attributesOfItem(atPath: url1.path)[.size] as? Int64) ?? 0
- let s2 = (try? fm.attributesOfItem(atPath: url2.path)[.size] as? Int64) ?? 0
- return s1 < s2
- }
- if let first = sorted.first, let image = NSImage(contentsOf: first) {
- return image
- }
- }
- return nil
- }
- // MARK: - Embedded Artwork
- private func extractEmbeddedArtwork(from url: URL) async -> NSImage? {
- let asset = AVURLAsset(url: url)
- guard let metadata = try? await asset.load(.metadata) else { return nil }
- for item in metadata {
- guard let key = item.commonKey, key == .commonKeyArtwork else { continue }
- if let data = try? await item.load(.dataValue),
- let image = NSImage(data: data) {
- return image
- }
- }
- return nil
- }
- }
|