ArtworkService.swift 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110
  1. import AVFoundation
  2. import Foundation
  3. import UIKit
  4. /// Loads album artwork from folder images or embedded metadata.
  5. actor ArtworkService {
  6. static let shared = ArtworkService()
  7. /// Cache keyed by track file path (not folder) so each track gets its own artwork.
  8. private var cache: [String: UIImage?] = [:]
  9. private static let coverFileNames: [String] = [
  10. "cover", "folder", "front", "album", "albumart",
  11. "albumartsmall", "thumb", "artwork", "art",
  12. "Cover", "Folder", "Front", "Album"
  13. ]
  14. private static let imageExtensions: Set<String> = [
  15. "jpg", "jpeg", "png", "bmp", "gif", "tiff", "webp"
  16. ]
  17. // MARK: - Public API
  18. /// Get artwork for a track. Pass the URL directly to avoid SwiftData model access issues.
  19. func artwork(for trackURL: URL) async -> UIImage? {
  20. let trackPath = trackURL.path
  21. // Check cache (nil value means we already tried and found nothing)
  22. if let cached = cache[trackPath] {
  23. return cached
  24. }
  25. // Only access files within the app sandbox
  26. let docsDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
  27. guard trackPath.hasPrefix(docsDir.path) else {
  28. cache[trackPath] = Optional<UIImage>.none
  29. return nil
  30. }
  31. // 1. Try embedded metadata FIRST (most accurate per-track)
  32. if FileManager.default.fileExists(atPath: trackPath),
  33. let embedded = await extractEmbeddedArtwork(from: trackURL) {
  34. cache[trackPath] = embedded
  35. return embedded
  36. }
  37. // 2. Try folder images (cover.jpg etc.)
  38. let folderURL = trackURL.deletingLastPathComponent()
  39. if let folderArt = findFolderArtwork(in: folderURL) {
  40. cache[trackPath] = folderArt
  41. return folderArt
  42. }
  43. // Cache nil so we don't retry
  44. cache[trackPath] = Optional<UIImage>.none
  45. return nil
  46. }
  47. func clearCache() {
  48. cache.removeAll()
  49. }
  50. // MARK: - Folder Artwork
  51. private func findFolderArtwork(in folderURL: URL) -> UIImage? {
  52. let fm = FileManager.default
  53. for name in Self.coverFileNames {
  54. for ext in Self.imageExtensions {
  55. let candidate = folderURL.appendingPathComponent("\(name).\(ext)")
  56. if fm.fileExists(atPath: candidate.path),
  57. let data = try? Data(contentsOf: candidate),
  58. let image = UIImage(data: data) {
  59. return image
  60. }
  61. }
  62. }
  63. // Fallback: any image file in the folder
  64. if let contents = try? fm.contentsOfDirectory(at: folderURL, includingPropertiesForKeys: nil, options: .skipsHiddenFiles) {
  65. for fileURL in contents {
  66. if Self.imageExtensions.contains(fileURL.pathExtension.lowercased()),
  67. let data = try? Data(contentsOf: fileURL),
  68. let image = UIImage(data: data) {
  69. return image
  70. }
  71. }
  72. }
  73. return nil
  74. }
  75. // MARK: - Embedded Artwork
  76. private func extractEmbeddedArtwork(from url: URL) async -> UIImage? {
  77. let asset = AVURLAsset(url: url)
  78. guard let metadata = try? await asset.load(.metadata) else { return nil }
  79. for item in metadata {
  80. guard item.commonKey == .commonKeyArtwork else { continue }
  81. if let data = try? await item.load(.dataValue),
  82. let image = UIImage(data: data) {
  83. return image
  84. }
  85. }
  86. return nil
  87. }
  88. }