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.. 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.. = [ "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.. 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, _ 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 }