| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222 |
- 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<String> = [
- "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())
- }
- }
|