MetadataService.swift 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354
  1. import AVFoundation
  2. import Foundation
  3. /// Reads audio file metadata (ID3 tags, etc.) using AVFoundation.
  4. struct MetadataService {
  5. struct AudioMetadata {
  6. var title: String
  7. var artist: String
  8. var album: String
  9. var genre: String
  10. var year: Int?
  11. var duration: TimeInterval
  12. var sampleRate: Double
  13. var bitDepth: Int
  14. var channels: Int
  15. var fileFormat: String
  16. var fileSizeBytes: Int64
  17. }
  18. // MARK: - Read Metadata
  19. static func readMetadata(from url: URL) async throws -> AudioMetadata {
  20. // OGG/Opus files need special handling since AVFoundation may not support them
  21. let ext = url.pathExtension.lowercased()
  22. if ext == "ogg" {
  23. return readOGGMetadata(from: url)
  24. }
  25. #if !DISABLE_OPUS
  26. if ext == "opus" {
  27. return readOpusMetadata(from: url)
  28. }
  29. #endif
  30. let asset = AVURLAsset(url: url)
  31. let metadata = try await asset.load(.metadata)
  32. let duration = try await asset.load(.duration)
  33. var title = url.deletingPathExtension().lastPathComponent
  34. var artist = ""
  35. var album = ""
  36. var genre = ""
  37. var year: Int?
  38. for item in metadata {
  39. guard let key = item.commonKey else { continue }
  40. switch key {
  41. case .commonKeyTitle:
  42. if let val = try? await item.load(.stringValue) { title = val }
  43. case .commonKeyArtist:
  44. if let val = try? await item.load(.stringValue) { artist = val }
  45. case .commonKeyAlbumName:
  46. if let val = try? await item.load(.stringValue) { album = val }
  47. case .commonKeyType:
  48. if let val = try? await item.load(.stringValue) { genre = val }
  49. case .commonKeyCreationDate:
  50. if let val = try? await item.load(.stringValue) {
  51. year = Int(val.prefix(4))
  52. }
  53. default:
  54. break
  55. }
  56. }
  57. // Also check format-specific date tags if year not found via common keys.
  58. // AVFoundation identifiers vary by format:
  59. // ID3v2: "id3/%00TDRC", "id3/%00TYER", "id3/%00TDAT", "id3/%00DATE"
  60. // iTunes/M4A: "itsk/©day"
  61. // Vorbis/FLAC: "org.xiph.vorbiscomment/DATE"
  62. if year == nil {
  63. for item in metadata {
  64. if let val = try? await item.load(.stringValue),
  65. let y = Int(val.prefix(4)),
  66. y > 1900 && y < 2100 {
  67. // Check by identifier
  68. if let identifier = item.identifier {
  69. let idStr = identifier.rawValue.uppercased()
  70. if idStr.contains("TDRC") || idStr.contains("TYER") || idStr.contains("DATE")
  71. || idStr.contains("YEAR") || idStr.contains("©DAY") || idStr.contains("DAY") {
  72. year = y
  73. break
  74. }
  75. }
  76. // Also check by key space — some items don't have identifiers
  77. // but have keys in their key space that indicate a date
  78. if let key = item.key as? String {
  79. let keyUpper = key.uppercased()
  80. if keyUpper.contains("DATE") || keyUpper.contains("YEAR")
  81. || keyUpper.contains("TDRC") || keyUpper.contains("TYER")
  82. || keyUpper.contains("©DAY") || keyUpper == "DAY" {
  83. year = y
  84. break
  85. }
  86. }
  87. }
  88. }
  89. }
  90. // Final fallback: scan ALL metadata string values for anything that looks like a year
  91. if year == nil {
  92. for item in metadata {
  93. if let val = try? await item.load(.stringValue),
  94. val.count >= 4 && val.count <= 10,
  95. let y = Int(val.prefix(4)),
  96. y > 1900 && y < 2100 {
  97. // Only accept if the value looks like a pure date (not a random string containing digits)
  98. let trimmed = val.trimmingCharacters(in: .whitespaces)
  99. if trimmed.range(of: #"^\d{4}(-\d{2}(-\d{2})?)?$"#, options: .regularExpression) != nil {
  100. year = y
  101. break
  102. }
  103. }
  104. }
  105. }
  106. let audioFile = try AVAudioFile(forReading: url)
  107. let format = audioFile.processingFormat
  108. let sampleRate = format.sampleRate
  109. let channels = Int(format.channelCount)
  110. let bitDepth: Int
  111. switch format.commonFormat {
  112. case .pcmFormatFloat32: bitDepth = 32
  113. case .pcmFormatFloat64: bitDepth = 64
  114. case .pcmFormatInt16: bitDepth = 16
  115. case .pcmFormatInt32: bitDepth = 32
  116. default: bitDepth = 16
  117. }
  118. let fileFormat = url.pathExtension.uppercased()
  119. let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
  120. return AudioMetadata(
  121. title: title,
  122. artist: artist,
  123. album: album,
  124. genre: genre,
  125. year: year,
  126. duration: CMTimeGetSeconds(duration),
  127. sampleRate: sampleRate,
  128. bitDepth: bitDepth,
  129. channels: channels,
  130. fileFormat: fileFormat,
  131. fileSizeBytes: fileSize
  132. )
  133. }
  134. // MARK: - Supported Formats
  135. /// Dump all raw metadata from a file for diagnostic purposes.
  136. /// Returns a string listing every metadata item AVFoundation can see.
  137. static func dumpAllMetadata(from url: URL) async -> String {
  138. let ext = url.pathExtension.lowercased()
  139. if ext == "ogg" {
  140. return dumpOGGTags(from: url)
  141. }
  142. #if !DISABLE_OPUS
  143. if ext == "opus" {
  144. return dumpOpusTags(from: url)
  145. }
  146. #endif
  147. var lines: [String] = ["=== Metadata for \(url.lastPathComponent) ==="]
  148. do {
  149. let asset = AVURLAsset(url: url)
  150. let metadata = try await asset.load(.metadata)
  151. lines.append("Total items: \(metadata.count)")
  152. for (i, item) in metadata.enumerated() {
  153. let commonKey = item.commonKey?.rawValue ?? "(none)"
  154. let identifier = item.identifier?.rawValue ?? "(none)"
  155. let keySpace = item.keySpace?.rawValue ?? "(none)"
  156. let key = (item.key as? String) ?? String(describing: item.key)
  157. let value = (try? await item.load(.stringValue)) ?? "(nil)"
  158. lines.append("[\(i)] commonKey=\(commonKey) id=\(identifier) keySpace=\(keySpace) key=\(key) value=\(value)")
  159. }
  160. } catch {
  161. lines.append("Error: \(error)")
  162. }
  163. return lines.joined(separator: "\n")
  164. }
  165. private static func dumpOGGTags(from url: URL) -> String {
  166. var lines: [String] = ["=== OGG Tags for \(url.lastPathComponent) ==="]
  167. var error: Int32 = 0
  168. guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else {
  169. lines.append("Failed to open OGG file (error \(error))")
  170. return lines.joined(separator: "\n")
  171. }
  172. defer { stb_vorbis_close(vorbis) }
  173. let comment = stb_vorbis_get_comment(vorbis)
  174. lines.append("Vendor: \(comment.vendor.map { String(cString: $0) } ?? "(nil)")")
  175. lines.append("Comment count: \(comment.comment_list_length)")
  176. if let commentList = comment.comment_list {
  177. for i in 0..<Int(comment.comment_list_length) {
  178. if let cStr = commentList[i] {
  179. lines.append(" [\(i)] \(String(cString: cStr))")
  180. }
  181. }
  182. }
  183. return lines.joined(separator: "\n")
  184. }
  185. #if !DISABLE_OPUS
  186. private static func dumpOpusTags(from url: URL) -> String {
  187. var lines: [String] = ["=== Opus Tags for \(url.lastPathComponent) ==="]
  188. var error: Int32 = 0
  189. guard let opusFile = op_open_file(url.path, &error) else {
  190. lines.append("Failed to open Opus file (error \(error))")
  191. return lines.joined(separator: "\n")
  192. }
  193. defer { op_free(opusFile) }
  194. if let tags = op_tags(opusFile, -1) {
  195. let vendor = tags.pointee.vendor.map { String(cString: $0) } ?? "(nil)"
  196. lines.append("Vendor: \(vendor)")
  197. let count = tags.pointee.comments
  198. lines.append("Comment count: \(count)")
  199. for i in 0..<Int(count) {
  200. if let lengths = tags.pointee.comment_lengths,
  201. let comments = tags.pointee.user_comments,
  202. let cStr = comments[Int(i)] {
  203. let len = Int(lengths[Int(i)])
  204. let str = String(cString: cStr)
  205. lines.append(" [\(i)] (len=\(len)) \(str)")
  206. }
  207. }
  208. } else {
  209. lines.append("No tags found")
  210. }
  211. return lines.joined(separator: "\n")
  212. }
  213. #endif
  214. static let supportedExtensions: Set<String> = [
  215. "mp3", "wav", "aif", "aiff", "flac", "m4a", "aac", "caf", "alac", "ogg", "opus"
  216. ]
  217. static func isSupportedAudioFile(_ url: URL) -> Bool {
  218. supportedExtensions.contains(url.pathExtension.lowercased())
  219. }
  220. // MARK: - OGG Metadata
  221. private static func readOGGMetadata(from url: URL) -> AudioMetadata {
  222. let fallbackTitle = url.deletingPathExtension().lastPathComponent
  223. let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
  224. var error: Int32 = 0
  225. guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else {
  226. return AudioMetadata(
  227. title: fallbackTitle, artist: "", album: "", genre: "", year: nil,
  228. duration: 0, sampleRate: 44100, bitDepth: 16,
  229. channels: 2, fileFormat: "OGG", fileSizeBytes: fileSize
  230. )
  231. }
  232. defer { stb_vorbis_close(vorbis) }
  233. let info = stb_vorbis_get_info(vorbis)
  234. let totalSamples = stb_vorbis_stream_length_in_samples(vorbis)
  235. let sampleRate = Double(info.sample_rate)
  236. let channels = Int(info.channels)
  237. let duration = sampleRate > 0 ? Double(totalSamples) / sampleRate : 0
  238. // Read Vorbis Comment tags
  239. var title = fallbackTitle
  240. var artist = ""
  241. var album = ""
  242. var genre = ""
  243. var year: Int?
  244. let comment = stb_vorbis_get_comment(vorbis)
  245. if comment.comment_list_length > 0, let commentList = comment.comment_list {
  246. for i in 0..<Int(comment.comment_list_length) {
  247. guard let cStr = commentList[i] else { continue }
  248. let entry = String(cString: cStr)
  249. // Vorbis comments are "TAG=VALUE" format (case-insensitive tag names)
  250. guard let eqIndex = entry.firstIndex(of: "=") else { continue }
  251. let tag = entry[..<eqIndex].uppercased()
  252. let value = String(entry[entry.index(after: eqIndex)...])
  253. guard !value.isEmpty else { continue }
  254. switch tag {
  255. case "TITLE": title = value
  256. case "ARTIST": artist = value
  257. case "ALBUM": album = value
  258. case "GENRE": genre = value
  259. case "DATE", "YEAR", "ORIGINALDATE", "ORIGINALYEAR":
  260. if year == nil { year = Int(value.prefix(4)) }
  261. default: break
  262. }
  263. }
  264. }
  265. return AudioMetadata(
  266. title: title, artist: artist, album: album, genre: genre, year: year,
  267. duration: duration, sampleRate: sampleRate, bitDepth: 16,
  268. channels: channels, fileFormat: "OGG", fileSizeBytes: fileSize
  269. )
  270. }
  271. #if !DISABLE_OPUS
  272. private static func readOpusMetadata(from url: URL) -> AudioMetadata {
  273. let fallbackTitle = url.deletingPathExtension().lastPathComponent
  274. let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
  275. var error: Int32 = 0
  276. guard let opusFile = op_open_file(url.path, &error) else {
  277. return AudioMetadata(
  278. title: fallbackTitle, artist: "", album: "", genre: "", year: nil,
  279. duration: 0, sampleRate: 48000, bitDepth: 16,
  280. channels: 2, fileFormat: "OPUS", fileSizeBytes: fileSize
  281. )
  282. }
  283. defer { op_free(opusFile) }
  284. let channels = Int(op_channel_count(opusFile, -1))
  285. let totalSamples = op_pcm_total(opusFile, -1)
  286. let duration = Double(totalSamples) / 48000.0
  287. // Read Vorbis Comment tags
  288. var title = fallbackTitle
  289. var artist = ""
  290. var album = ""
  291. var genre = ""
  292. var year: Int?
  293. if let tags = op_tags(opusFile, -1) {
  294. title = opusTagValue(tags, "TITLE") ?? fallbackTitle
  295. artist = opusTagValue(tags, "ARTIST") ?? ""
  296. album = opusTagValue(tags, "ALBUM") ?? ""
  297. genre = opusTagValue(tags, "GENRE") ?? ""
  298. if let dateStr = opusTagValue(tags, "DATE") ?? opusTagValue(tags, "YEAR")
  299. ?? opusTagValue(tags, "ORIGINALDATE") ?? opusTagValue(tags, "ORIGINALYEAR") {
  300. year = Int(dateStr.prefix(4))
  301. }
  302. }
  303. return AudioMetadata(
  304. title: title, artist: artist, album: album, genre: genre, year: year,
  305. duration: duration, sampleRate: 48000, bitDepth: 16,
  306. channels: channels, fileFormat: "OPUS", fileSizeBytes: fileSize
  307. )
  308. }
  309. /// Read a single Vorbis Comment tag from an OpusTags struct.
  310. private static func opusTagValue(_ tags: UnsafePointer<OpusTags>, _ tag: String) -> String? {
  311. let count = opus_tags_query_count(tags, tag)
  312. guard count > 0 else { return nil }
  313. guard let value = opus_tags_query(tags, tag, 0) else { return nil }
  314. let str = String(cString: value)
  315. return str.isEmpty ? nil : str
  316. }
  317. #endif
  318. }