import AVFoundation import Foundation /// Reads and writes audio file metadata (ID3 tags, etc.) using AVFoundation. struct MetadataService { /// Metadata extracted from an audio file. 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 /// Read metadata from an audio file URL. static func readMetadata(from url: URL) async throws -> AudioMetadata { let asset = AVURLAsset(url: url) // Load metadata asynchronously 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), let y = Int(val.prefix(4)), y > 1900 && y < 2100 { year = y } default: break } } // Also check format-specific date tags if year not found via common keys. // ID3v2: TDRC, TYER; iTunes/M4A: ©day; Vorbis/FLAC: 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 { 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 } } 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 a pure date 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 { let trimmed = val.trimmingCharacters(in: .whitespaces) if trimmed.range(of: #"^\d{4}(-\d{2}(-\d{2})?)?$"#, options: .regularExpression) != nil { year = y break } } } } // Get audio format details let sampleRate: Double let channels: Int let bitDepth: Int if OGGDecoder.isOGGFile(url) { // OGG files can't be read by AVAudioFile — use OGGDecoder if let info = OGGDecoder.fileInfo(url: url) { sampleRate = info.sampleRate channels = info.channels } else { sampleRate = 44100 channels = 2 } bitDepth = 16 // OGG Vorbis is variable bitrate, report as 16-bit equivalent } else { let audioFile = try AVAudioFile(forReading: url) let format = audioFile.processingFormat sampleRate = format.sampleRate channels = Int(format.channelCount) 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 /// Read only the year/date from an audio file's metadata tags. /// Lightweight — does not open AVAudioFile, so it won't fail on format issues. static func readYear(from url: URL) async throws -> Int? { let asset = AVURLAsset(url: url) let metadata = try await asset.load(.metadata) var year: Int? // 1. Common key: creationDate for item in metadata { if item.commonKey == .commonKeyCreationDate, let val = try? await item.load(.stringValue), let y = Int(val.prefix(4)), y > 1900 && y < 2100 { year = y break } } // 2. Format-specific date tags 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 { 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("\u{00A9}DAY") || idStr.contains("DAY") { year = y break } } 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("\u{00A9}DAY") || keyUpper == "DAY" { year = y break } } } } } // 3. Fallback: scan for pure date values 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 { let trimmed = val.trimmingCharacters(in: .whitespaces) if trimmed.range(of: #"^\d{4}(-\d{2}(-\d{2})?)?$"#, options: .regularExpression) != nil { year = y break } } } } return year } /// File extensions supported for import. static let supportedExtensions: Set = [ "mp3", "wav", "aif", "aiff", "flac", "m4a", "aac", "ogg", "wma", "alac", "caf" ] /// Check if a file URL is a supported audio format. static func isSupportedAudioFile(_ url: URL) -> Bool { supportedExtensions.contains(url.pathExtension.lowercased()) } }