ArtworkService.swift 4.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133
  1. import AppKit
  2. import AVFoundation
  3. import Foundation
  4. /// Loads album artwork from folder images (foobar2000 style) or embedded metadata.
  5. /// Searches for common cover art filenames in the same directory as the audio file.
  6. actor ArtworkService {
  7. static let shared = ArtworkService()
  8. /// In-memory cache: folder path → NSImage
  9. private var cache: [String: NSImage] = [:]
  10. /// Common cover art filenames to search for (priority order).
  11. private static let coverFileNames: [String] = [
  12. "cover", "folder", "front", "album", "albumart",
  13. "albumartsmall", "thumb", "artwork", "art", "scan",
  14. "Cover", "Folder", "Front", "Album"
  15. ]
  16. /// Supported image extensions.
  17. private static let imageExtensions: Set<String> = [
  18. "jpg", "jpeg", "png", "bmp", "gif", "tiff", "webp"
  19. ]
  20. // MARK: - Public API
  21. /// Get artwork for a track. Checks folder images first, then embedded metadata.
  22. func artwork(for track: Track) async -> NSImage? {
  23. let folderPath = track.fileURL.deletingLastPathComponent().path
  24. // Check cache
  25. if let cached = cache[folderPath] {
  26. return cached
  27. }
  28. // 1. Try folder images (foobar2000 style)
  29. if let folderArt = findFolderArtwork(in: track.fileURL.deletingLastPathComponent()) {
  30. cache[folderPath] = folderArt
  31. return folderArt
  32. }
  33. // 2. Try embedded metadata
  34. if let embedded = await extractEmbeddedArtwork(from: track.fileURL) {
  35. cache[folderPath] = embedded
  36. return embedded
  37. }
  38. return nil
  39. }
  40. /// Get artwork for a URL directly (without Track model).
  41. func artwork(forFileAt url: URL) async -> NSImage? {
  42. let folderPath = url.deletingLastPathComponent().path
  43. if let cached = cache[folderPath] {
  44. return cached
  45. }
  46. if let folderArt = findFolderArtwork(in: url.deletingLastPathComponent()) {
  47. cache[folderPath] = folderArt
  48. return folderArt
  49. }
  50. if let embedded = await extractEmbeddedArtwork(from: url) {
  51. cache[folderPath] = embedded
  52. return embedded
  53. }
  54. return nil
  55. }
  56. /// Clear the artwork cache.
  57. func clearCache() {
  58. cache.removeAll()
  59. }
  60. /// Remove a specific folder from cache.
  61. func invalidateCache(for folderPath: String) {
  62. cache.removeValue(forKey: folderPath)
  63. }
  64. // MARK: - Folder Artwork (foobar2000 style)
  65. private func findFolderArtwork(in folderURL: URL) -> NSImage? {
  66. let fm = FileManager.default
  67. // First pass: try known filenames
  68. for name in Self.coverFileNames {
  69. for ext in Self.imageExtensions {
  70. let candidate = folderURL.appendingPathComponent("\(name).\(ext)")
  71. if fm.fileExists(atPath: candidate.path),
  72. let image = NSImage(contentsOf: candidate) {
  73. return image
  74. }
  75. }
  76. }
  77. // Second pass: any image file in the folder
  78. if let contents = try? fm.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
  79. let images = contents.filter { Self.imageExtensions.contains($0.pathExtension.lowercased()) }
  80. // Prefer smaller files (likely covers vs. high-res scans)
  81. let sorted = images.sorted { url1, url2 in
  82. let s1 = (try? fm.attributesOfItem(atPath: url1.path)[.size] as? Int64) ?? 0
  83. let s2 = (try? fm.attributesOfItem(atPath: url2.path)[.size] as? Int64) ?? 0
  84. return s1 < s2
  85. }
  86. if let first = sorted.first, let image = NSImage(contentsOf: first) {
  87. return image
  88. }
  89. }
  90. return nil
  91. }
  92. // MARK: - Embedded Artwork
  93. private func extractEmbeddedArtwork(from url: URL) async -> NSImage? {
  94. let asset = AVURLAsset(url: url)
  95. guard let metadata = try? await asset.load(.metadata) else { return nil }
  96. for item in metadata {
  97. guard let key = item.commonKey, key == .commonKeyArtwork else { continue }
  98. if let data = try? await item.load(.dataValue),
  99. let image = NSImage(data: data) {
  100. return image
  101. }
  102. }
  103. return nil
  104. }
  105. }