import Foundation import SwiftData /// Imports playlist data from the iOS MixBoard app's sync JSON file. /// Matches tracks by filename across devices. struct SyncImporter { // MARK: - Sync Data Models (must match iOS export format) struct SyncPayload: Codable { let version: Int let exportedAt: Date let exportedFrom: String let playlists: [SyncPlaylist] } struct SyncPlaylist: Codable { let id: UUID let name: String let notes: String let color: String let dateCreated: Date let dateModified: Date let targetBPM: Double? let entries: [SyncEntry] } struct SyncEntry: Codable { let id: UUID let position: Int let filename: String let title: String let artist: String let album: String let duration: TimeInterval let bpm: Double? let musicalKey: String? let crossfadeDuration: TimeInterval let startOffset: TimeInterval let endOffset: TimeInterval let gainAdjustment: Double let notes: String } // MARK: - Import /// Import playlists from a sync JSON file. Returns a summary. @MainActor static func importFromFile( _ url: URL, context: ModelContext ) throws -> ImportResult { let data = try Data(contentsOf: url) let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 let payload = try decoder.decode(SyncPayload.self, from: data) // Fetch all existing tracks for matching let trackDescriptor = FetchDescriptor() let allTracks = (try? context.fetch(trackDescriptor)) ?? [] // Build a filename → Track lookup var tracksByFilename: [String: Track] = [:] for track in allTracks { let filename = (track.filePath as NSString).lastPathComponent tracksByFilename[filename] = track } var created = 0 var matched = 0 var unmatched = 0 var unmatchedFiles: [String] = [] for sp in payload.playlists { // Create the playlist with "[iOS]" prefix to distinguish let playlist = Playlist(name: "📱 \(sp.name)", notes: sp.notes, color: sp.color) playlist.targetBPM = sp.targetBPM context.insert(playlist) created += 1 for entry in sp.entries.sorted(by: { $0.position < $1.position }) { if let track = tracksByFilename[entry.filename] { // Found matching track let pe = PlaylistEntry( position: entry.position, track: track, crossfadeDuration: entry.crossfadeDuration, startOffset: entry.startOffset, endOffset: entry.endOffset, gainAdjustment: entry.gainAdjustment, notes: entry.notes ) playlist.entries.append(pe) matched += 1 } else { // No matching track — create placeholder entry with info in notes let info = "\(entry.artist) — \(entry.title) [\(entry.filename)]" let pe = PlaylistEntry( position: entry.position, track: nil, crossfadeDuration: entry.crossfadeDuration, notes: "⚠ Not found: \(info)\n\(entry.notes)" ) playlist.entries.append(pe) unmatched += 1 unmatchedFiles.append(entry.filename) } } } try context.save() return ImportResult( playlistsCreated: created, tracksMatched: matched, tracksUnmatched: unmatched, unmatchedFiles: unmatchedFiles, exportedFrom: payload.exportedFrom, exportedAt: payload.exportedAt ) } struct ImportResult { let playlistsCreated: Int let tracksMatched: Int let tracksUnmatched: Int let unmatchedFiles: [String] let exportedFrom: String let exportedAt: Date var summary: String { var lines = [ "Imported \(playlistsCreated) playlist(s) from \(exportedFrom)", " \(tracksMatched) tracks matched", ] if tracksUnmatched > 0 { lines.append(" \(tracksUnmatched) tracks not found locally:") for file in unmatchedFiles.prefix(10) { lines.append(" • \(file)") } if unmatchedFiles.count > 10 { lines.append(" ... and \(unmatchedFiles.count - 10) more") } } return lines.joined(separator: "\n") } } }