SyncImporter.swift 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148
  1. import Foundation
  2. import SwiftData
  3. /// Imports playlist data from the iOS MixBoard app's sync JSON file.
  4. /// Matches tracks by filename across devices.
  5. struct SyncImporter {
  6. // MARK: - Sync Data Models (must match iOS export format)
  7. struct SyncPayload: Codable {
  8. let version: Int
  9. let exportedAt: Date
  10. let exportedFrom: String
  11. let playlists: [SyncPlaylist]
  12. }
  13. struct SyncPlaylist: Codable {
  14. let id: UUID
  15. let name: String
  16. let notes: String
  17. let color: String
  18. let dateCreated: Date
  19. let dateModified: Date
  20. let targetBPM: Double?
  21. let entries: [SyncEntry]
  22. }
  23. struct SyncEntry: Codable {
  24. let id: UUID
  25. let position: Int
  26. let filename: String
  27. let title: String
  28. let artist: String
  29. let album: String
  30. let duration: TimeInterval
  31. let bpm: Double?
  32. let musicalKey: String?
  33. let crossfadeDuration: TimeInterval
  34. let startOffset: TimeInterval
  35. let endOffset: TimeInterval
  36. let gainAdjustment: Double
  37. let notes: String
  38. }
  39. // MARK: - Import
  40. /// Import playlists from a sync JSON file. Returns a summary.
  41. @MainActor
  42. static func importFromFile(
  43. _ url: URL,
  44. context: ModelContext
  45. ) throws -> ImportResult {
  46. let data = try Data(contentsOf: url)
  47. let decoder = JSONDecoder()
  48. decoder.dateDecodingStrategy = .iso8601
  49. let payload = try decoder.decode(SyncPayload.self, from: data)
  50. // Fetch all existing tracks for matching
  51. let trackDescriptor = FetchDescriptor<Track>()
  52. let allTracks = (try? context.fetch(trackDescriptor)) ?? []
  53. // Build a filename → Track lookup
  54. var tracksByFilename: [String: Track] = [:]
  55. for track in allTracks {
  56. let filename = (track.filePath as NSString).lastPathComponent
  57. tracksByFilename[filename] = track
  58. }
  59. var created = 0
  60. var matched = 0
  61. var unmatched = 0
  62. var unmatchedFiles: [String] = []
  63. for sp in payload.playlists {
  64. // Create the playlist with "[iOS]" prefix to distinguish
  65. let playlist = Playlist(name: "📱 \(sp.name)", notes: sp.notes, color: sp.color)
  66. playlist.targetBPM = sp.targetBPM
  67. context.insert(playlist)
  68. created += 1
  69. for entry in sp.entries.sorted(by: { $0.position < $1.position }) {
  70. if let track = tracksByFilename[entry.filename] {
  71. // Found matching track
  72. let pe = PlaylistEntry(
  73. position: entry.position,
  74. track: track,
  75. crossfadeDuration: entry.crossfadeDuration,
  76. startOffset: entry.startOffset,
  77. endOffset: entry.endOffset,
  78. gainAdjustment: entry.gainAdjustment,
  79. notes: entry.notes
  80. )
  81. playlist.entries.append(pe)
  82. matched += 1
  83. } else {
  84. // No matching track — create placeholder entry with info in notes
  85. let info = "\(entry.artist) — \(entry.title) [\(entry.filename)]"
  86. let pe = PlaylistEntry(
  87. position: entry.position,
  88. track: nil,
  89. crossfadeDuration: entry.crossfadeDuration,
  90. notes: "⚠ Not found: \(info)\n\(entry.notes)"
  91. )
  92. playlist.entries.append(pe)
  93. unmatched += 1
  94. unmatchedFiles.append(entry.filename)
  95. }
  96. }
  97. }
  98. try context.save()
  99. return ImportResult(
  100. playlistsCreated: created,
  101. tracksMatched: matched,
  102. tracksUnmatched: unmatched,
  103. unmatchedFiles: unmatchedFiles,
  104. exportedFrom: payload.exportedFrom,
  105. exportedAt: payload.exportedAt
  106. )
  107. }
  108. struct ImportResult {
  109. let playlistsCreated: Int
  110. let tracksMatched: Int
  111. let tracksUnmatched: Int
  112. let unmatchedFiles: [String]
  113. let exportedFrom: String
  114. let exportedAt: Date
  115. var summary: String {
  116. var lines = [
  117. "Imported \(playlistsCreated) playlist(s) from \(exportedFrom)",
  118. " \(tracksMatched) tracks matched",
  119. ]
  120. if tracksUnmatched > 0 {
  121. lines.append(" \(tracksUnmatched) tracks not found locally:")
  122. for file in unmatchedFiles.prefix(10) {
  123. lines.append(" • \(file)")
  124. }
  125. if unmatchedFiles.count > 10 {
  126. lines.append(" ... and \(unmatchedFiles.count - 10) more")
  127. }
  128. }
  129. return lines.joined(separator: "\n")
  130. }
  131. }
  132. }