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 = [ "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 } }