MetadataService.swift 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
  1. import AVFoundation
  2. import Foundation
  3. /// Reads and writes audio file metadata (ID3 tags, etc.) using AVFoundation.
  4. struct MetadataService {
  5. /// Metadata extracted from an audio file.
  6. struct AudioMetadata {
  7. var title: String
  8. var artist: String
  9. var album: String
  10. var genre: String
  11. var year: Int?
  12. var duration: TimeInterval
  13. var sampleRate: Double
  14. var bitDepth: Int
  15. var channels: Int
  16. var fileFormat: String
  17. var fileSizeBytes: Int64
  18. }
  19. // MARK: - Read Metadata
  20. /// Read metadata from an audio file URL.
  21. static func readMetadata(from url: URL) async throws -> AudioMetadata {
  22. let asset = AVURLAsset(url: url)
  23. // Load metadata asynchronously
  24. let metadata = try await asset.load(.metadata)
  25. let duration = try await asset.load(.duration)
  26. var title = url.deletingPathExtension().lastPathComponent
  27. var artist = ""
  28. var album = ""
  29. var genre = ""
  30. var year: Int?
  31. for item in metadata {
  32. guard let key = item.commonKey else { continue }
  33. switch key {
  34. case .commonKeyTitle:
  35. if let val = try? await item.load(.stringValue) { title = val }
  36. case .commonKeyArtist:
  37. if let val = try? await item.load(.stringValue) { artist = val }
  38. case .commonKeyAlbumName:
  39. if let val = try? await item.load(.stringValue) { album = val }
  40. case .commonKeyType:
  41. if let val = try? await item.load(.stringValue) { genre = val }
  42. case .commonKeyCreationDate:
  43. if let val = try? await item.load(.stringValue),
  44. let y = Int(val.prefix(4)), y > 1900 && y < 2100 {
  45. year = y
  46. }
  47. default:
  48. break
  49. }
  50. }
  51. // Also check format-specific date tags if year not found via common keys.
  52. // ID3v2: TDRC, TYER; iTunes/M4A: ©day; Vorbis/FLAC: DATE
  53. if year == nil {
  54. for item in metadata {
  55. if let val = try? await item.load(.stringValue),
  56. let y = Int(val.prefix(4)),
  57. y > 1900 && y < 2100 {
  58. if let identifier = item.identifier {
  59. let idStr = identifier.rawValue.uppercased()
  60. if idStr.contains("TDRC") || idStr.contains("TYER") || idStr.contains("DATE")
  61. || idStr.contains("YEAR") || idStr.contains("©DAY") || idStr.contains("DAY") {
  62. year = y
  63. break
  64. }
  65. }
  66. if let key = item.key as? String {
  67. let keyUpper = key.uppercased()
  68. if keyUpper.contains("DATE") || keyUpper.contains("YEAR")
  69. || keyUpper.contains("TDRC") || keyUpper.contains("TYER")
  70. || keyUpper.contains("©DAY") || keyUpper == "DAY" {
  71. year = y
  72. break
  73. }
  74. }
  75. }
  76. }
  77. }
  78. // Final fallback: scan all metadata string values for a pure date
  79. if year == nil {
  80. for item in metadata {
  81. if let val = try? await item.load(.stringValue),
  82. val.count >= 4 && val.count <= 10,
  83. let y = Int(val.prefix(4)),
  84. y > 1900 && y < 2100 {
  85. let trimmed = val.trimmingCharacters(in: .whitespaces)
  86. if trimmed.range(of: #"^\d{4}(-\d{2}(-\d{2})?)?$"#, options: .regularExpression) != nil {
  87. year = y
  88. break
  89. }
  90. }
  91. }
  92. }
  93. // Get audio format details
  94. let sampleRate: Double
  95. let channels: Int
  96. let bitDepth: Int
  97. if OGGDecoder.isOGGFile(url) {
  98. // OGG files can't be read by AVAudioFile — use OGGDecoder
  99. if let info = OGGDecoder.fileInfo(url: url) {
  100. sampleRate = info.sampleRate
  101. channels = info.channels
  102. } else {
  103. sampleRate = 44100
  104. channels = 2
  105. }
  106. bitDepth = 16 // OGG Vorbis is variable bitrate, report as 16-bit equivalent
  107. } else {
  108. let audioFile = try AVAudioFile(forReading: url)
  109. let format = audioFile.processingFormat
  110. sampleRate = format.sampleRate
  111. channels = Int(format.channelCount)
  112. switch format.commonFormat {
  113. case .pcmFormatFloat32: bitDepth = 32
  114. case .pcmFormatFloat64: bitDepth = 64
  115. case .pcmFormatInt16: bitDepth = 16
  116. case .pcmFormatInt32: bitDepth = 32
  117. default: bitDepth = 16
  118. }
  119. }
  120. let fileFormat = url.pathExtension.uppercased()
  121. let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
  122. return AudioMetadata(
  123. title: title,
  124. artist: artist,
  125. album: album,
  126. genre: genre,
  127. year: year,
  128. duration: CMTimeGetSeconds(duration),
  129. sampleRate: sampleRate,
  130. bitDepth: bitDepth,
  131. channels: channels,
  132. fileFormat: fileFormat,
  133. fileSizeBytes: fileSize
  134. )
  135. }
  136. // MARK: - Supported Formats
  137. /// Read only the year/date from an audio file's metadata tags.
  138. /// Lightweight — does not open AVAudioFile, so it won't fail on format issues.
  139. static func readYear(from url: URL) async throws -> Int? {
  140. let asset = AVURLAsset(url: url)
  141. let metadata = try await asset.load(.metadata)
  142. var year: Int?
  143. // 1. Common key: creationDate
  144. for item in metadata {
  145. if item.commonKey == .commonKeyCreationDate,
  146. let val = try? await item.load(.stringValue),
  147. let y = Int(val.prefix(4)), y > 1900 && y < 2100 {
  148. year = y
  149. break
  150. }
  151. }
  152. // 2. Format-specific date tags
  153. if year == nil {
  154. for item in metadata {
  155. if let val = try? await item.load(.stringValue),
  156. let y = Int(val.prefix(4)), y > 1900 && y < 2100 {
  157. if let identifier = item.identifier {
  158. let idStr = identifier.rawValue.uppercased()
  159. if idStr.contains("TDRC") || idStr.contains("TYER") || idStr.contains("DATE")
  160. || idStr.contains("YEAR") || idStr.contains("\u{00A9}DAY") || idStr.contains("DAY") {
  161. year = y
  162. break
  163. }
  164. }
  165. if let key = item.key as? String {
  166. let keyUpper = key.uppercased()
  167. if keyUpper.contains("DATE") || keyUpper.contains("YEAR")
  168. || keyUpper.contains("TDRC") || keyUpper.contains("TYER")
  169. || keyUpper.contains("\u{00A9}DAY") || keyUpper == "DAY" {
  170. year = y
  171. break
  172. }
  173. }
  174. }
  175. }
  176. }
  177. // 3. Fallback: scan for pure date values
  178. if year == nil {
  179. for item in metadata {
  180. if let val = try? await item.load(.stringValue),
  181. val.count >= 4 && val.count <= 10,
  182. let y = Int(val.prefix(4)), y > 1900 && y < 2100 {
  183. let trimmed = val.trimmingCharacters(in: .whitespaces)
  184. if trimmed.range(of: #"^\d{4}(-\d{2}(-\d{2})?)?$"#, options: .regularExpression) != nil {
  185. year = y
  186. break
  187. }
  188. }
  189. }
  190. }
  191. return year
  192. }
  193. /// File extensions supported for import.
  194. static let supportedExtensions: Set<String> = [
  195. "mp3", "wav", "aif", "aiff", "flac", "m4a", "aac", "ogg", "wma", "alac", "caf"
  196. ]
  197. /// Check if a file URL is a supported audio format.
  198. static func isSupportedAudioFile(_ url: URL) -> Bool {
  199. supportedExtensions.contains(url.pathExtension.lowercased())
  200. }
  201. }