| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354 |
- import AVFoundation
- import Foundation
- /// Reads audio file metadata (ID3 tags, etc.) using AVFoundation.
- struct MetadataService {
- struct AudioMetadata {
- var title: String
- var artist: String
- var album: String
- var genre: String
- var year: Int?
- var duration: TimeInterval
- var sampleRate: Double
- var bitDepth: Int
- var channels: Int
- var fileFormat: String
- var fileSizeBytes: Int64
- }
- // MARK: - Read Metadata
- static func readMetadata(from url: URL) async throws -> AudioMetadata {
- // OGG/Opus files need special handling since AVFoundation may not support them
- let ext = url.pathExtension.lowercased()
- if ext == "ogg" {
- return readOGGMetadata(from: url)
- }
- #if !DISABLE_OPUS
- if ext == "opus" {
- return readOpusMetadata(from: url)
- }
- #endif
- let asset = AVURLAsset(url: url)
- let metadata = try await asset.load(.metadata)
- let duration = try await asset.load(.duration)
- var title = url.deletingPathExtension().lastPathComponent
- var artist = ""
- var album = ""
- var genre = ""
- var year: Int?
- for item in metadata {
- guard let key = item.commonKey else { continue }
- switch key {
- case .commonKeyTitle:
- if let val = try? await item.load(.stringValue) { title = val }
- case .commonKeyArtist:
- if let val = try? await item.load(.stringValue) { artist = val }
- case .commonKeyAlbumName:
- if let val = try? await item.load(.stringValue) { album = val }
- case .commonKeyType:
- if let val = try? await item.load(.stringValue) { genre = val }
- case .commonKeyCreationDate:
- if let val = try? await item.load(.stringValue) {
- year = Int(val.prefix(4))
- }
- default:
- break
- }
- }
- // Also check format-specific date tags if year not found via common keys.
- // AVFoundation identifiers vary by format:
- // ID3v2: "id3/%00TDRC", "id3/%00TYER", "id3/%00TDAT", "id3/%00DATE"
- // iTunes/M4A: "itsk/©day"
- // Vorbis/FLAC: "org.xiph.vorbiscomment/DATE"
- if year == nil {
- for item in metadata {
- if let val = try? await item.load(.stringValue),
- let y = Int(val.prefix(4)),
- y > 1900 && y < 2100 {
- // Check by identifier
- if let identifier = item.identifier {
- let idStr = identifier.rawValue.uppercased()
- if idStr.contains("TDRC") || idStr.contains("TYER") || idStr.contains("DATE")
- || idStr.contains("YEAR") || idStr.contains("©DAY") || idStr.contains("DAY") {
- year = y
- break
- }
- }
- // Also check by key space — some items don't have identifiers
- // but have keys in their key space that indicate a date
- if let key = item.key as? String {
- let keyUpper = key.uppercased()
- if keyUpper.contains("DATE") || keyUpper.contains("YEAR")
- || keyUpper.contains("TDRC") || keyUpper.contains("TYER")
- || keyUpper.contains("©DAY") || keyUpper == "DAY" {
- year = y
- break
- }
- }
- }
- }
- }
- // Final fallback: scan ALL metadata string values for anything that looks like a year
- if year == nil {
- for item in metadata {
- if let val = try? await item.load(.stringValue),
- val.count >= 4 && val.count <= 10,
- let y = Int(val.prefix(4)),
- y > 1900 && y < 2100 {
- // Only accept if the value looks like a pure date (not a random string containing digits)
- let trimmed = val.trimmingCharacters(in: .whitespaces)
- if trimmed.range(of: #"^\d{4}(-\d{2}(-\d{2})?)?$"#, options: .regularExpression) != nil {
- year = y
- break
- }
- }
- }
- }
- let audioFile = try AVAudioFile(forReading: url)
- let format = audioFile.processingFormat
- let sampleRate = format.sampleRate
- let channels = Int(format.channelCount)
- let bitDepth: Int
- switch format.commonFormat {
- case .pcmFormatFloat32: bitDepth = 32
- case .pcmFormatFloat64: bitDepth = 64
- case .pcmFormatInt16: bitDepth = 16
- case .pcmFormatInt32: bitDepth = 32
- default: bitDepth = 16
- }
- let fileFormat = url.pathExtension.uppercased()
- let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
- return AudioMetadata(
- title: title,
- artist: artist,
- album: album,
- genre: genre,
- year: year,
- duration: CMTimeGetSeconds(duration),
- sampleRate: sampleRate,
- bitDepth: bitDepth,
- channels: channels,
- fileFormat: fileFormat,
- fileSizeBytes: fileSize
- )
- }
- // MARK: - Supported Formats
- /// Dump all raw metadata from a file for diagnostic purposes.
- /// Returns a string listing every metadata item AVFoundation can see.
- static func dumpAllMetadata(from url: URL) async -> String {
- let ext = url.pathExtension.lowercased()
-
- if ext == "ogg" {
- return dumpOGGTags(from: url)
- }
- #if !DISABLE_OPUS
- if ext == "opus" {
- return dumpOpusTags(from: url)
- }
- #endif
- var lines: [String] = ["=== Metadata for \(url.lastPathComponent) ==="]
- do {
- let asset = AVURLAsset(url: url)
- let metadata = try await asset.load(.metadata)
- lines.append("Total items: \(metadata.count)")
- for (i, item) in metadata.enumerated() {
- let commonKey = item.commonKey?.rawValue ?? "(none)"
- let identifier = item.identifier?.rawValue ?? "(none)"
- let keySpace = item.keySpace?.rawValue ?? "(none)"
- let key = (item.key as? String) ?? String(describing: item.key)
- let value = (try? await item.load(.stringValue)) ?? "(nil)"
- lines.append("[\(i)] commonKey=\(commonKey) id=\(identifier) keySpace=\(keySpace) key=\(key) value=\(value)")
- }
- } catch {
- lines.append("Error: \(error)")
- }
- return lines.joined(separator: "\n")
- }
- private static func dumpOGGTags(from url: URL) -> String {
- var lines: [String] = ["=== OGG Tags for \(url.lastPathComponent) ==="]
- var error: Int32 = 0
- guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else {
- lines.append("Failed to open OGG file (error \(error))")
- return lines.joined(separator: "\n")
- }
- defer { stb_vorbis_close(vorbis) }
- let comment = stb_vorbis_get_comment(vorbis)
- lines.append("Vendor: \(comment.vendor.map { String(cString: $0) } ?? "(nil)")")
- lines.append("Comment count: \(comment.comment_list_length)")
- if let commentList = comment.comment_list {
- for i in 0..<Int(comment.comment_list_length) {
- if let cStr = commentList[i] {
- lines.append(" [\(i)] \(String(cString: cStr))")
- }
- }
- }
- return lines.joined(separator: "\n")
- }
- #if !DISABLE_OPUS
- private static func dumpOpusTags(from url: URL) -> String {
- var lines: [String] = ["=== Opus Tags for \(url.lastPathComponent) ==="]
- var error: Int32 = 0
- guard let opusFile = op_open_file(url.path, &error) else {
- lines.append("Failed to open Opus file (error \(error))")
- return lines.joined(separator: "\n")
- }
- defer { op_free(opusFile) }
- if let tags = op_tags(opusFile, -1) {
- let vendor = tags.pointee.vendor.map { String(cString: $0) } ?? "(nil)"
- lines.append("Vendor: \(vendor)")
- let count = tags.pointee.comments
- lines.append("Comment count: \(count)")
- for i in 0..<Int(count) {
- if let lengths = tags.pointee.comment_lengths,
- let comments = tags.pointee.user_comments,
- let cStr = comments[Int(i)] {
- let len = Int(lengths[Int(i)])
- let str = String(cString: cStr)
- lines.append(" [\(i)] (len=\(len)) \(str)")
- }
- }
- } else {
- lines.append("No tags found")
- }
- return lines.joined(separator: "\n")
- }
- #endif
- static let supportedExtensions: Set<String> = [
- "mp3", "wav", "aif", "aiff", "flac", "m4a", "aac", "caf", "alac", "ogg", "opus"
- ]
- static func isSupportedAudioFile(_ url: URL) -> Bool {
- supportedExtensions.contains(url.pathExtension.lowercased())
- }
- // MARK: - OGG Metadata
- private static func readOGGMetadata(from url: URL) -> AudioMetadata {
- let fallbackTitle = url.deletingPathExtension().lastPathComponent
- let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
- var error: Int32 = 0
- guard let vorbis = stb_vorbis_open_filename(url.path, &error, nil) else {
- return AudioMetadata(
- title: fallbackTitle, artist: "", album: "", genre: "", year: nil,
- duration: 0, sampleRate: 44100, bitDepth: 16,
- channels: 2, fileFormat: "OGG", fileSizeBytes: fileSize
- )
- }
- defer { stb_vorbis_close(vorbis) }
- let info = stb_vorbis_get_info(vorbis)
- let totalSamples = stb_vorbis_stream_length_in_samples(vorbis)
- let sampleRate = Double(info.sample_rate)
- let channels = Int(info.channels)
- let duration = sampleRate > 0 ? Double(totalSamples) / sampleRate : 0
- // Read Vorbis Comment tags
- var title = fallbackTitle
- var artist = ""
- var album = ""
- var genre = ""
- var year: Int?
- let comment = stb_vorbis_get_comment(vorbis)
- if comment.comment_list_length > 0, let commentList = comment.comment_list {
- for i in 0..<Int(comment.comment_list_length) {
- guard let cStr = commentList[i] else { continue }
- let entry = String(cString: cStr)
- // Vorbis comments are "TAG=VALUE" format (case-insensitive tag names)
- guard let eqIndex = entry.firstIndex(of: "=") else { continue }
- let tag = entry[..<eqIndex].uppercased()
- let value = String(entry[entry.index(after: eqIndex)...])
- guard !value.isEmpty else { continue }
- switch tag {
- case "TITLE": title = value
- case "ARTIST": artist = value
- case "ALBUM": album = value
- case "GENRE": genre = value
- case "DATE", "YEAR", "ORIGINALDATE", "ORIGINALYEAR":
- if year == nil { year = Int(value.prefix(4)) }
- default: break
- }
- }
- }
- return AudioMetadata(
- title: title, artist: artist, album: album, genre: genre, year: year,
- duration: duration, sampleRate: sampleRate, bitDepth: 16,
- channels: channels, fileFormat: "OGG", fileSizeBytes: fileSize
- )
- }
- #if !DISABLE_OPUS
- private static func readOpusMetadata(from url: URL) -> AudioMetadata {
- let fallbackTitle = url.deletingPathExtension().lastPathComponent
- let fileSize = (try? FileManager.default.attributesOfItem(atPath: url.path)[.size] as? Int64) ?? 0
- var error: Int32 = 0
- guard let opusFile = op_open_file(url.path, &error) else {
- return AudioMetadata(
- title: fallbackTitle, artist: "", album: "", genre: "", year: nil,
- duration: 0, sampleRate: 48000, bitDepth: 16,
- channels: 2, fileFormat: "OPUS", fileSizeBytes: fileSize
- )
- }
- defer { op_free(opusFile) }
- let channels = Int(op_channel_count(opusFile, -1))
- let totalSamples = op_pcm_total(opusFile, -1)
- let duration = Double(totalSamples) / 48000.0
- // Read Vorbis Comment tags
- var title = fallbackTitle
- var artist = ""
- var album = ""
- var genre = ""
- var year: Int?
- if let tags = op_tags(opusFile, -1) {
- title = opusTagValue(tags, "TITLE") ?? fallbackTitle
- artist = opusTagValue(tags, "ARTIST") ?? ""
- album = opusTagValue(tags, "ALBUM") ?? ""
- genre = opusTagValue(tags, "GENRE") ?? ""
- if let dateStr = opusTagValue(tags, "DATE") ?? opusTagValue(tags, "YEAR")
- ?? opusTagValue(tags, "ORIGINALDATE") ?? opusTagValue(tags, "ORIGINALYEAR") {
- year = Int(dateStr.prefix(4))
- }
- }
- return AudioMetadata(
- title: title, artist: artist, album: album, genre: genre, year: year,
- duration: duration, sampleRate: 48000, bitDepth: 16,
- channels: channels, fileFormat: "OPUS", fileSizeBytes: fileSize
- )
- }
- /// Read a single Vorbis Comment tag from an OpusTags struct.
- private static func opusTagValue(_ tags: UnsafePointer<OpusTags>, _ tag: String) -> String? {
- let count = opus_tags_query_count(tags, tag)
- guard count > 0 else { return nil }
- guard let value = opus_tags_query(tags, tag, 0) else { return nil }
- let str = String(cString: value)
- return str.isEmpty ? nil : str
- }
- #endif
- }
|