Compare commits

...

29 Commits

Author SHA1 Message Date
Laurent 5bcf55f808 fixes and improvements 1 month ago
Laurent 0e018a79dd feat: add LibraryViewModel.applyTrackEdits 1 month ago
Laurent d7b91ac14c feat: add TrackEditService save orchestration 1 month ago
Laurent b327fc5221 feat: add TagWriter protocol with mp3/m4a writers 1 month ago
Laurent a4c59fc8c6 harden: precondition + multi-track test for EditableTrackFields.shared 1 month ago
Laurent baed9e782a harden: precondition + multi-track test for EditableTrackFields.shared 1 month ago
Laurent 31ee9a15df feat: enable read-write file access for tag writeback 1 month ago
Laurent f0a5677f68 feat: add tabbed TrackInfoSheet 1 month ago
Laurent 515f257f83 feat: add DatabaseService.updateTrack 1 month ago
Laurent 378b71a857 feat: add EditableTrackFields with diff/shared/apply logic 1 month ago
Laurent 1970ab58c2 refactor: extract TrackFileStats shared stat/hash helper 1 month ago
Laurent c04533d0a8 chore: add import Foundation and clarifying comments to context menu code 1 month ago
Laurent bd97d060a0 feat: wire TrackContextMenuConfig to bottom controls and track table 1 month ago
Laurent 3ccdfbfc79 feat: add contextMenuConfig param to PlayerControlsView 1 month ago
Laurent 7a9564f026 fix: guard against out-of-bounds tag in addToPlaylist context menu handler 1 month ago
Laurent b006bf75c3 refactor: replace TrackTableView playlist params with TrackContextMenuConfig 1 month ago
Laurent cce5779430 fix: stable view identity in TrackContextMenuModifier, remove @Sendable from config closures 1 month ago
Laurent 8920cad499 feat: add TrackContextMenuModifier SwiftUI view modifier 1 month ago
Laurent 0ad417e682 fix: add nonisolated and @Sendable to TrackContextMenuConfig 1 month ago
Laurent c9cdf80a14 feat: add TrackContextMenuConfig value type 1 month ago
Laurent 793fe036ad test: verify startsWith LIKE metachar escaping; document non-atomic edit writes 1 month ago
Laurent 7b88b180fd feat: wire SmartPlaylistBuilderSheet into menu and context menu 1 month ago
Laurent 9eb47b61e1 feat: add SmartPlaylistBuilderSheet with ConditionRowView 1 month ago
Laurent 1dff7cb5d1 fix: escape LIKE metacharacters in startsWith; add lessThan test 1 month ago
Laurent e3930821f4 feat: add migration v5 and structured condition query support to DatabaseService 1 month ago
Laurent 50daf23e11 feat: add conditions field to SmartPlaylist model 1 month ago
Laurent 47beb9f899 fix: add Sendable to FieldType enum 1 month ago
Laurent bebd30974a feat: add SmartPlaylistCondition model with Codable types 1 month ago
Laurent 6f07349a1e fix: support HTTP byte-range requests on /file so streamed tracks auto-advance 1 month ago
  1. 27
      Music.xcodeproj/project.pbxproj
  2. 11
      Music.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  3. 379
      Music/ContentView.swift
  4. 84
      Music/Models/EditableTrackFields.swift
  5. 9
      Music/Models/QueueEntry.swift
  6. 9
      Music/Models/SmartPlaylist.swift
  7. 152
      Music/Models/SmartPlaylistCondition.swift
  8. 121
      Music/Models/TrackContextMenuConfig.swift
  9. 2
      Music/Music.entitlements
  10. 8
      Music/MusicApp.swift
  11. 16
      Music/Protocols/PlaylistRepresentable.swift
  12. 52
      Music/Remote/HostServer.swift
  13. 66
      Music/Remote/RemoteClient.swift
  14. 178
      Music/Services/DatabaseService.swift
  15. 50
      Music/Services/ScannerService.swift
  16. 42
      Music/Services/TagWriting/ID3TagWriter.swift
  17. 53
      Music/Services/TagWriting/MP4TagWriter.swift
  18. 20
      Music/Services/TagWriting/TagWriter.swift
  19. 57
      Music/Services/TrackEditService.swift
  20. 22
      Music/Services/TrackFileStats.swift
  21. 128
      Music/Streaming/StreamingServer.swift
  22. 15
      Music/ViewModels/LibraryViewModel.swift
  23. 74
      Music/ViewModels/PlayerViewModel.swift
  24. 44
      Music/ViewModels/PlaylistViewModel.swift
  25. 195
      Music/Views/PlayerControlsView.swift
  26. 13
      Music/Views/PlaylistBarView.swift
  27. 67
      Music/Views/QueueView.swift
  28. 149
      Music/Views/SmartPlaylistBuilderSheet.swift
  29. 44
      Music/Views/TrackContextMenuModifier.swift
  30. 156
      Music/Views/TrackInfoSheet.swift
  31. 104
      Music/Views/TrackTableView.swift
  32. 42
      MusicTests/DBBackupFTS5Tests.swift
  33. 44
      MusicTests/DatabaseServiceTests.swift
  34. 82
      MusicTests/EditableTrackFieldsTests.swift
  35. BIN
      MusicTests/Fixtures/sample.m4a
  36. BIN
      MusicTests/Fixtures/sample.mp3
  37. 108
      MusicTests/PlayerViewModelTests.swift
  38. 54
      MusicTests/PlaylistBarIdentityTests.swift
  39. 36
      MusicTests/PlaylistViewModelTests.swift
  40. 123
      MusicTests/RemoteDBIntegrityTests.swift
  41. 98
      MusicTests/RemoteLibraryDisplayTests.swift
  42. 75
      MusicTests/ScannerServiceTests.swift
  43. 228
      MusicTests/SmartPlaylistTests.swift
  44. 139
      MusicTests/StreamingIntegrationTests.swift
  45. 82
      MusicTests/StreamingPlaybackEndToEndTests.swift
  46. 76
      MusicTests/TagWriterTests.swift
  47. 96
      MusicTests/TrackContextMenuConfigTests.swift
  48. 101
      MusicTests/TrackEditServiceTests.swift
  49. 26
      MusicTests/TrackFileStatsTests.swift
  50. 327
      docs/superpowers/plans/2026-05-30-add-to-new-playlist.md
  51. 607
      docs/superpowers/plans/2026-05-30-fix-zero-bitrate.md
  52. 780
      docs/superpowers/plans/2026-05-30-playing-queue.md
  53. 1143
      docs/superpowers/plans/2026-05-30-smart-playlist-conditions.md
  54. 525
      docs/superpowers/plans/2026-05-30-track-context-menu-bottom-controls.md
  55. 1194
      docs/superpowers/plans/2026-05-30-track-get-info.md
  56. 110
      docs/superpowers/specs/2026-05-30-add-to-new-playlist-design.md
  57. 166
      docs/superpowers/specs/2026-05-30-fix-zero-bitrate-design.md
  58. 97
      docs/superpowers/specs/2026-05-30-itunes-date-backfill-design.md
  59. 243
      docs/superpowers/specs/2026-05-30-playing-queue-design.md
  60. 166
      docs/superpowers/specs/2026-05-30-smart-playlist-conditions-design.md
  61. 86
      docs/superpowers/specs/2026-05-30-track-context-menu-bottom-controls-design.md
  62. 206
      docs/superpowers/specs/2026-05-30-track-get-info-design.md
  63. BIN
      scripts/__pycache__/backfill_itunes_dates.cpython-312.pyc
  64. BIN
      scripts/__pycache__/test_backfill_itunes_dates.cpython-312.pyc
  65. 272
      scripts/backfill_bitrate.py
  66. 253
      scripts/backfill_itunes_dates.py
  67. 178
      scripts/test_backfill_itunes_dates.py

@ -10,6 +10,7 @@
C46B2CC02FC2449900F95A24 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = C46B2CBF2FC2449900F95A24 /* GRDB */; };
C46B2CC22FC2449900F95A24 /* GRDB in Frameworks */ = {isa = PBXBuildFile; productRef = C46B2CC12FC2449900F95A24 /* GRDB */; };
C46CC4692FC6ED47000BD495 /* MusicShared in Frameworks */ = {isa = PBXBuildFile; productRef = C46CC4682FC6ED47000BD495 /* MusicShared */; };
C4BA36A52FCB3F7600DF615F /* ID3TagEditor in Frameworks */ = {isa = PBXBuildFile; productRef = C4BA35472FCB3B0C00DF615F /* ID3TagEditor */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -74,6 +75,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C4BA36A52FCB3F7600DF615F /* ID3TagEditor in Frameworks */,
C46B2CC02FC2449900F95A24 /* GRDB in Frameworks */,
C46CC4692FC6ED47000BD495 /* MusicShared in Frameworks */,
);
@ -147,6 +149,7 @@
packageProductDependencies = (
C46B2CBF2FC2449900F95A24 /* GRDB */,
C46CC4682FC6ED47000BD495 /* MusicShared */,
C4BA35472FCB3B0C00DF615F /* ID3TagEditor */,
);
productName = Music;
productReference = C46B2C8D2FC2448700F95A24 /* Mumu.app */;
@ -234,6 +237,7 @@
packageReferences = (
C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */,
C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */,
C4BA35462FCB3B0C00DF615F /* XCRemoteSwiftPackageReference "ID3TagEditor" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = C46B2C8E2FC2448700F95A24 /* Products */;
@ -443,7 +447,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 26;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
@ -452,13 +456,14 @@
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_USER_SELECTED_FILES = readonly;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Mumu;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Music uses the microphone to identify songs with Shazam.";
INFOPLIST_KEY_NSRequiresAquaSystemAppearance = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@ -488,7 +493,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 21;
CURRENT_PROJECT_VERSION = 26;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
@ -497,13 +502,14 @@
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_USER_SELECTED_FILES = readonly;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Mumu;
INFOPLIST_KEY_ITSAppUsesNonExemptEncryption = NO;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.music";
INFOPLIST_KEY_NSHumanReadableCopyright = "";
INFOPLIST_KEY_NSMicrophoneUsageDescription = "Music uses the microphone to identify songs with Shazam.";
INFOPLIST_KEY_NSRequiresAquaSystemAppearance = YES;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/../Frameworks",
@ -664,6 +670,14 @@
minimumVersion = 7.0.0;
};
};
C4BA35462FCB3B0C00DF615F /* XCRemoteSwiftPackageReference "ID3TagEditor" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/chicio/ID3TagEditor";
requirement = {
kind = upToNextMajorVersion;
minimumVersion = 5.5.0;
};
};
/* End XCRemoteSwiftPackageReference section */
/* Begin XCSwiftPackageProductDependency section */
@ -682,6 +696,11 @@
package = C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */;
productName = MusicShared;
};
C4BA35472FCB3B0C00DF615F /* ID3TagEditor */ = {
isa = XCSwiftPackageProductDependency;
package = C4BA35462FCB3B0C00DF615F /* XCRemoteSwiftPackageReference "ID3TagEditor" */;
productName = ID3TagEditor;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = C46B2C852FC2448700F95A24 /* Project object */;

@ -1,5 +1,5 @@
{
"originHash" : "b77ffc6518242a68c7f6b38dd3ba60b2719cc56c35b27391784d4b0d94e08b32",
"originHash" : "fd0f5ecf1cad35fa15b2a92be5f094df9927b358dc72b36a269e799e8cfb64a9",
"pins" : [
{
"identity" : "async-http-client",
@ -28,6 +28,15 @@
"version" : "2.22.0"
}
},
{
"identity" : "id3tageditor",
"kind" : "remoteSourceControl",
"location" : "https://github.com/chicio/ID3TagEditor",
"state" : {
"revision" : "e08dd0118d4418900ac3b8f621a6b3d24ae5416f",
"version" : "5.5.0"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",

@ -1,6 +1,13 @@
import SwiftUI
import UniformTypeIdentifiers
// Identifiable wrapper so the Get Info sheet can be driven by `.sheet(item:)`
// with one or many target tracks.
struct TrackInfoRequest: Identifiable {
let id = UUID()
let tracks: [Track]
}
struct ContentView: View {
var library: LibraryViewModel
var player: PlayerViewModel
@ -9,10 +16,16 @@ struct ContentView: View {
var shazam: ShazamService
var db: DatabaseService
@Binding var showNewPlaylistAlert: Bool
@Binding var showSmartPlaylistBuilder: Bool
var networkStatus: NetworkStatus?
@State private var infoRequest: TrackInfoRequest?
@State private var saveWarning: String?
@State private var showRenameAlert = false
@State private var showEditQueryAlert = false
@State private var smartPlaylistBuilderEditing: SmartPlaylist?
@State private var playlistNameInput = ""
@State private var newPlaylistTrack: Track?
@State private var newPlaylistNameInput = ""
@State private var editQueryInput = ""
@State private var itemToRename: (any PlaylistRepresentable)?
@State private var smartPlaylistToEdit: SmartPlaylist?
@ -20,56 +33,65 @@ struct ContentView: View {
@State private var searchText = ""
@State private var keyMonitor: Any?
@State private var showHome = false
@State private var showQueue = false
@State private var recentTracks: [Track] = []
@State private var totalDuration: Double = 0
@State private var monthlyAdditions: [MonthlyCount] = []
var body: some View {
VStack(spacing: 0) {
if let status = networkStatus {
switch status.mode {
case .remote(let hostName):
HStack(spacing: 8) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10)).foregroundStyle(.blue)
Text("Connected to \(hostName)")
.font(.system(size: 11, weight: .medium)).foregroundStyle(.blue)
Spacer()
/// The remote/streaming connection status banner. Extracted from `body` so the
/// type-checker doesn't have to solve the whole (very large) view in one expression
/// without this, a clean build fails with "unable to type-check in reasonable time".
@ViewBuilder
private var networkBanner: some View {
if let status = networkStatus {
switch status.mode {
case .remote(let hostName):
HStack(spacing: 8) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10)).foregroundStyle(.blue)
Text("Connected to \(hostName)")
.font(.system(size: 11, weight: .medium)).foregroundStyle(.blue)
Spacer()
Button("Refresh") { status.onRefreshLibrary?() }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary)
Button("Disconnect") { status.onDisconnect?() }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red)
}
.padding(.horizontal, 12).padding(.vertical, 4)
.background(Color.blue.opacity(0.08))
case .hosting(let remoteName):
HStack(spacing: 8) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10)).foregroundStyle(.green)
Text(remoteName != nil ? "Hosting · \(remoteName!) connected" : "Hosting")
.font(.system(size: 11, weight: .medium)).foregroundStyle(.green)
Spacer()
}
.padding(.horizontal, 12).padding(.vertical, 4)
.background(Color.green.opacity(0.08))
case .streamHosting, .streamClient:
HStack(spacing: 8) {
Image(systemName: "cloud")
.font(.system(size: 10)).foregroundStyle(.purple)
Text(status.statusMessage)
.font(.system(size: 11, weight: .medium)).foregroundStyle(.purple)
Spacer()
if case .streamClient = status.mode {
Button("Refresh") { status.onRefreshLibrary?() }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary)
Button("Disconnect") { status.onDisconnect?() }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red)
}
.padding(.horizontal, 12).padding(.vertical, 4)
.background(Color.blue.opacity(0.08))
case .hosting(let remoteName):
HStack(spacing: 8) {
Image(systemName: "antenna.radiowaves.left.and.right")
.font(.system(size: 10)).foregroundStyle(.green)
Text(remoteName != nil ? "Hosting · \(remoteName!) connected" : "Hosting")
.font(.system(size: 11, weight: .medium)).foregroundStyle(.green)
Spacer()
}
.padding(.horizontal, 12).padding(.vertical, 4)
.background(Color.green.opacity(0.08))
case .streamHosting, .streamClient:
HStack(spacing: 8) {
Image(systemName: "cloud")
.font(.system(size: 10)).foregroundStyle(.purple)
Text(status.statusMessage)
.font(.system(size: 11, weight: .medium)).foregroundStyle(.purple)
Spacer()
if case .streamClient = status.mode {
Button("Refresh") { status.onRefreshLibrary?() }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary)
}
Button("Disconnect") { status.onDisconnect?() }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red)
}
.padding(.horizontal, 12).padding(.vertical, 4)
.background(Color.purple.opacity(0.08))
Button("Disconnect") { status.onDisconnect?() }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red)
}
.padding(.horizontal, 12).padding(.vertical, 4)
.background(Color.purple.opacity(0.08))
}
}
}
var body: some View {
VStack(spacing: 0) {
networkBanner
SearchBarView(
searchText: $searchText,
@ -105,93 +127,87 @@ struct ContentView: View {
.padding(.vertical, 4)
}
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil {
HStack(spacing: 4) {
Button(action: {
playlist.deselectPlaylist()
searchText = ""
showHome = false
}) {
HStack(spacing: 2) {
Image(systemName: "chevron.left")
.font(.system(size: 10))
Text("Library")
.font(.system(size: 12))
HStack(spacing: 0) {
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil {
HStack(spacing: 4) {
Button(action: {
playlist.deselectPlaylist()
searchText = ""
showHome = false
}) {
HStack(spacing: 2) {
Image(systemName: "chevron.left")
.font(.system(size: 10))
Text("Library")
.font(.system(size: 12))
}
.foregroundStyle(.secondary)
}
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
.buttonStyle(.plain)
Text("/")
.font(.system(size: 12))
.foregroundStyle(.quaternary)
Text("/")
.font(.system(size: 12))
.foregroundStyle(.quaternary)
Text(showHome ? "Home" : (playlist.selectedItem?.name ?? ""))
.font(.system(size: 12, weight: .medium))
Text(showHome ? "Home" : (playlist.selectedItem?.name ?? ""))
.font(.system(size: 12, weight: .medium))
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
.padding(.horizontal, 12)
.padding(.vertical, 4)
.frame(maxWidth: .infinity, alignment: .leading)
}
if showHome && playlist.selectedItem == nil {
HomeView(
recentTracks: recentTracks,
trackCount: library.trackCount,
totalDuration: totalDuration,
monthlyAdditions: monthlyAdditions,
onTrackDoubleClick: { track in
player.setQueue(recentTracks)
player.play(track)
},
onShowAll: {
showHome = false
}
)
.onAppear { loadHomeData() }
} else {
TrackTableView(
tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks,
playingTrackId: player.currentTrack?.id,
sortColumn: playlist.selectedSmartPlaylist != nil ? playlist.sortColumn : library.sortColumn,
sortAscending: playlist.selectedSmartPlaylist != nil ? playlist.sortAscending : library.sortAscending,
onSort: { column in
if playlist.selectedSmartPlaylist != nil {
playlist.sort(by: column)
} else if playlist.selectedItem == nil {
library.sort(by: column)
}
},
onDoubleClick: { track in
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
player.setQueue(trackList)
player.play(track)
},
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
if showHome && playlist.selectedItem == nil {
HomeView(
recentTracks: recentTracks,
trackCount: library.trackCount,
totalDuration: totalDuration,
monthlyAdditions: monthlyAdditions,
onTrackDoubleClick: { track in
player.setQueue(recentTracks, contextName: "Recently Added")
player.play(track)
},
onShowAll: {
showHome = false
}
} : nil,
onReorder: playlist.selectedPlaylist != nil ? { from, to in
if let selected = playlist.selectedPlaylist {
try? playlist.moveTrack(in: selected, from: from, to: to)
}
} : nil,
scrollToPlayingTrigger: scrollToPlayingTrigger
)
)
.onAppear { loadHomeData() }
} else {
TrackTableView(
tracks: playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks,
playingTrackId: player.currentTrack?.id,
sortColumn: playlist.selectedSmartPlaylist != nil ? playlist.sortColumn : library.sortColumn,
sortAscending: playlist.selectedSmartPlaylist != nil ? playlist.sortAscending : library.sortAscending,
onSort: { column in
if playlist.selectedSmartPlaylist != nil {
playlist.sort(by: column)
} else if playlist.selectedItem == nil {
library.sort(by: column)
}
},
onDoubleClick: { track in
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")
player.play(track)
},
contextMenuConfig: trackContextMenuConfig,
onReorder: playlist.selectedPlaylist != nil ? { from, to in
if let selected = playlist.selectedPlaylist {
try? playlist.moveTrack(in: selected, from: from, to: to)
}
} : nil,
scrollToPlayingTrigger: scrollToPlayingTrigger
)
}
}
.frame(maxHeight: .infinity)
if showQueue && !isDrivingRemoteDevice {
Divider()
QueueView(player: player)
}
}
.frame(maxHeight: .infinity)
PlaylistBarView(
playlists: playlist.allPlaylists,
@ -239,6 +255,9 @@ struct ContentView: View {
smartPlaylistToEdit = smart
editQueryInput = smart.searchQuery
showEditQueryAlert = true
},
onEditConditions: { smart in
smartPlaylistBuilderEditing = smart
}
)
@ -264,6 +283,24 @@ struct ContentView: View {
playlistNameInput = ""
}
}
.alert("New Playlist", isPresented: Binding(
get: { newPlaylistTrack != nil },
set: { if !$0 { newPlaylistTrack = nil; newPlaylistNameInput = "" } }
)) {
TextField("Playlist name", text: $newPlaylistNameInput)
Button("Cancel", role: .cancel) {
newPlaylistNameInput = ""
newPlaylistTrack = nil
}
Button("Create") {
let name = newPlaylistNameInput.trimmingCharacters(in: .whitespaces)
if !name.isEmpty, let track = newPlaylistTrack {
try? playlist.createPlaylistAndAddTrack(name: name, track: track)
}
newPlaylistNameInput = ""
newPlaylistTrack = nil
}
}
.alert("Rename", isPresented: $showRenameAlert) {
TextField("Name", text: $playlistNameInput)
Button("Cancel", role: .cancel) { playlistNameInput = "" }
@ -327,6 +364,98 @@ struct ContentView: View {
Text(error)
}
}
.sheet(isPresented: $showSmartPlaylistBuilder) {
SmartPlaylistBuilderSheet(
editingPlaylist: nil,
onSave: { name, conditions in
try? playlist.createSmartPlaylist(name: name, conditions: conditions)
showSmartPlaylistBuilder = false
},
onCancel: { showSmartPlaylistBuilder = false }
)
}
.sheet(item: $smartPlaylistBuilderEditing) { smart in
SmartPlaylistBuilderSheet(
editingPlaylist: smart,
onSave: { name, conditions in
// Two separate writes: consistent with how mutations are handled throughout the UI.
// Partial failure (rename succeeds, conditions fail) is accepted given error
// feedback is not implemented at the UI layer.
if name != smart.name {
try? playlist.renameSmartPlaylist(smart, to: name)
}
try? playlist.updateSmartPlaylistConditions(smart, to: conditions)
smartPlaylistBuilderEditing = nil
},
onCancel: { smartPlaylistBuilderEditing = nil }
)
}
.sheet(item: $infoRequest) { req in
TrackInfoSheet(
tracks: req.tracks,
onSave: { values, edited in
let targets = req.tracks
infoRequest = nil
Task {
let warnings = await library.applyTrackEdits(values, editing: edited, to: targets)
if !warnings.isEmpty {
let failed = warnings.filter { $0.kind == .fileWriteFailed }.count
let dbOnly = warnings.filter { $0.kind == .dbOnlyUnsupported }.count
var msg = "Saved to your library."
if failed > 0 { msg += " Couldn’t write tags to \(failed) file(s)." }
if dbOnly > 0 { msg += " \(dbOnly) file(s) use a format without tag writing." }
saveWarning = msg
}
}
},
onCancel: { infoRequest = nil }
)
}
.alert("Edit Saved", isPresented: Binding(
get: { saveWarning != nil },
set: { if !$0 { saveWarning = nil } }
)) {
Button("OK", role: .cancel) { saveWarning = nil }
} message: {
Text(saveWarning ?? "")
}
}
/// True only when driving a separate remote device (RemotePlaybackProvider is active).
/// Stream-client mode plays locally, so the manual queue is available there and is NOT gated.
private var isDrivingRemoteDevice: Bool {
guard let mode = networkStatus?.mode else { return false }
if case .remote = mode { return true }
return false
}
private var trackContextMenuConfig: TrackContextMenuConfig {
// Queue actions are local-only for v1: hidden when driving a remote device.
let queueEnabled = !isDrivingRemoteDevice
return TrackContextMenuConfig(
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
// Outer nil hides the "Remove from Playlist" menu item when not in a playlist view.
// Inner re-check defends against the playlist being deselected between menu display and action.
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil,
onPlayNext: queueEnabled ? { track in player.playNext(track) } : nil,
onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil,
onAddToNewPlaylist: { track in newPlaylistTrack = track },
onGetInfo: { tracks in
if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks) }
}
)
}
private var playerControls: some View {
@ -343,7 +472,7 @@ struct ContentView: View {
if player.currentTrack == nil {
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
if let first = trackList.first {
player.setQueue(trackList)
player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")
player.play(first)
}
} else {
@ -358,7 +487,11 @@ struct ContentView: View {
onScrubEnd: { player.endScrubbing(at: $0) },
onVolumeChange: { player.setVolume($0) },
onShuffleToggle: { player.toggleShuffle() },
onNowPlayingTap: { scrollToPlayingTrigger = UUID() }
onNowPlayingTap: { scrollToPlayingTrigger = UUID() },
contextMenuConfig: trackContextMenuConfig,
isQueueVisible: showQueue,
showQueueButton: !isDrivingRemoteDevice,
onToggleQueue: { showQueue.toggle() }
)
}
@ -376,7 +509,7 @@ struct ContentView: View {
if player.currentTrack == nil {
let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks
if let first = trackList.first {
player.setQueue(trackList)
player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")
player.play(first)
}
} else {

@ -0,0 +1,84 @@
import Foundation
// The user-editable subset of Track, plus the pure logic for single- and
// multi-track editing. No UI, no I/O fully unit-testable.
//
// Note: Named `EditableTrackField` (not `TrackField`) to avoid collision with
// the existing `TrackField` enum in SmartPlaylistCondition.swift, which covers
// all filterable columns including non-editable ones.
nonisolated enum EditableTrackField: CaseIterable, Sendable {
case title, artist, albumArtist, album, genre, composer
case year, trackNumber, discNumber, bpm, rating
// Library-managed, not a file tag edits persist to the DB only (like rating).
case dateAdded
}
nonisolated struct EditableTrackFields: Equatable, Sendable {
var title: String
var artist: String
var albumArtist: String
var album: String
var genre: String
var composer: String
var year: Int?
var trackNumber: Int?
var discNumber: Int?
var bpm: Int?
var rating: Int
var dateAdded: Date
init(from t: Track) {
title = t.title; artist = t.artist; albumArtist = t.albumArtist
album = t.album; genre = t.genre; composer = t.composer
year = t.year; trackNumber = t.trackNumber; discNumber = t.discNumber
bpm = t.bpm; rating = t.rating; dateAdded = t.dateAdded
}
func changedFields(to other: EditableTrackFields) -> Set<EditableTrackField> {
var changed: Set<EditableTrackField> = []
if title != other.title { changed.insert(.title) }
if artist != other.artist { changed.insert(.artist) }
if albumArtist != other.albumArtist { changed.insert(.albumArtist) }
if album != other.album { changed.insert(.album) }
if genre != other.genre { changed.insert(.genre) }
if composer != other.composer { changed.insert(.composer) }
if year != other.year { changed.insert(.year) }
if trackNumber != other.trackNumber { changed.insert(.trackNumber) }
if discNumber != other.discNumber { changed.insert(.discNumber) }
if bpm != other.bpm { changed.insert(.bpm) }
if rating != other.rating { changed.insert(.rating) }
if dateAdded != other.dateAdded { changed.insert(.dateAdded) }
return changed
}
// Returns prefill values (from the first track) plus the set of fields whose
// values are NOT identical across all tracks (shown as "Mixed" in the UI).
// Precondition: caller must pass at least one track; an empty array will trap.
static func shared(across tracks: [Track]) -> (values: EditableTrackFields, mixed: Set<EditableTrackField>) {
precondition(!tracks.isEmpty, "shared(across:) requires at least one track")
let base = EditableTrackFields(from: tracks[0])
var mixed: Set<EditableTrackField> = []
for t in tracks.dropFirst() {
mixed.formUnion(base.changedFields(to: EditableTrackFields(from: t)))
}
return (base, mixed)
}
// Copies ONLY the edited fields onto the track; everything else is untouched.
func apply(editing edited: Set<EditableTrackField>, to track: Track) -> Track {
var t = track
if edited.contains(.title) { t.title = title }
if edited.contains(.artist) { t.artist = artist }
if edited.contains(.albumArtist) { t.albumArtist = albumArtist }
if edited.contains(.album) { t.album = album }
if edited.contains(.genre) { t.genre = genre }
if edited.contains(.composer) { t.composer = composer }
if edited.contains(.year) { t.year = year }
if edited.contains(.trackNumber) { t.trackNumber = trackNumber }
if edited.contains(.discNumber) { t.discNumber = discNumber }
if edited.contains(.bpm) { t.bpm = bpm }
if edited.contains(.rating) { t.rating = rating }
if edited.contains(.dateAdded) { t.dateAdded = dateAdded }
return t
}
}

@ -0,0 +1,9 @@
import Foundation
// A single slot in the manual "Up Next" queue. Carries its own stable identity so
// the same track can be queued more than once without SwiftUI confusing the rows
// Track.id alone is not unique across duplicate queue entries.
nonisolated struct QueueEntry: Identifiable, Sendable {
let id = UUID()
let track: Track
}

@ -6,6 +6,7 @@ nonisolated struct SmartPlaylist: Codable, Identifiable, Equatable, Hashable, Se
var name: String
var searchQuery: String
var createdAt: Date
var conditions: [SmartPlaylistCondition]?
}
nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord {
@ -26,9 +27,13 @@ extension SmartPlaylist {
id: Int64? = nil,
name: String = "Test Smart Playlist",
searchQuery: String = "test query",
createdAt: Date = Date()
createdAt: Date = Date(),
conditions: [SmartPlaylistCondition]? = nil
) -> SmartPlaylist {
SmartPlaylist(id: id, name: name, searchQuery: searchQuery, createdAt: createdAt)
SmartPlaylist(
id: id, name: name, searchQuery: searchQuery,
createdAt: createdAt, conditions: conditions
)
}
}
#endif

@ -0,0 +1,152 @@
import Foundation
// Classifies a track field for operator and UI purposes.
enum FieldType: Sendable {
case string, int, double, date
}
// Represents a track column that can be filtered on.
// Raw value matches the SQLite column name in the "tracks" table.
enum TrackField: String, Codable, CaseIterable, Identifiable, Sendable {
case title, artist, albumArtist, album, genre, composer, fileFormat
case year, bpm, rating, playCount, trackNumber, discNumber, bitrate, sampleRate
case fileSize, duration
case dateAdded, dateModified, lastPlayedAt
var id: String { rawValue }
var displayName: String {
switch self {
case .title: return "Title"
case .artist: return "Artist"
case .albumArtist: return "Album Artist"
case .album: return "Album"
case .genre: return "Genre"
case .composer: return "Composer"
case .fileFormat: return "File Format"
case .year: return "Year"
case .bpm: return "BPM"
case .rating: return "Rating"
case .playCount: return "Play Count"
case .trackNumber: return "Track Number"
case .discNumber: return "Disc Number"
case .bitrate: return "Bitrate"
case .sampleRate: return "Sample Rate"
case .fileSize: return "File Size"
case .duration: return "Duration"
case .dateAdded: return "Date Added"
case .dateModified: return "Date Modified"
case .lastPlayedAt: return "Last Played"
}
}
var fieldType: FieldType {
switch self {
case .title, .artist, .albumArtist, .album, .genre, .composer, .fileFormat:
return .string
case .year, .bpm, .rating, .playCount, .trackNumber, .discNumber, .bitrate, .sampleRate, .fileSize:
return .int
case .duration:
return .double
case .dateAdded, .dateModified, .lastPlayedAt:
return .date
}
}
var validOperators: [ConditionOperator] {
switch fieldType {
case .string: return [.equals, .startsWith]
case .int, .double, .date: return [.equals, .greaterThan, .lessThan]
}
}
var defaultValue: ConditionValue {
switch fieldType {
case .string: return .string("")
case .int: return .int(0)
case .double: return .double(0)
case .date: return .date(Date())
}
}
}
enum ConditionOperator: String, Codable, Identifiable, Sendable {
case equals
case startsWith
case greaterThan
case lessThan
var id: String { rawValue }
var displayName: String {
switch self {
case .equals: return "is"
case .startsWith: return "starts with"
case .greaterThan: return "is greater than"
case .lessThan: return "is less than"
}
}
}
// Tagged union storing the actual filter value with its type.
// Uses custom Codable to survive JSON round-trips cleanly.
enum ConditionValue: Equatable, Hashable, Sendable {
case string(String)
case int(Int)
case double(Double)
case date(Date)
var isEmpty: Bool {
if case .string(let s) = self {
return s.trimmingCharacters(in: .whitespaces).isEmpty
}
return false
}
}
extension ConditionValue: Codable {
private enum CodingKeys: String, CodingKey { case type, value }
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .string(let s):
try container.encode("string", forKey: .type)
try container.encode(s, forKey: .value)
case .int(let i):
try container.encode("int", forKey: .type)
try container.encode(i, forKey: .value)
case .double(let d):
try container.encode("double", forKey: .type)
try container.encode(d, forKey: .value)
case .date(let date):
try container.encode("date", forKey: .type)
try container.encode(date.timeIntervalSince1970, forKey: .value)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(String.self, forKey: .type)
switch type {
case "string":
self = .string(try container.decode(String.self, forKey: .value))
case "int":
self = .int(try container.decode(Int.self, forKey: .value))
case "double":
self = .double(try container.decode(Double.self, forKey: .value))
case "date":
self = .date(Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .value)))
default:
throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)")
}
}
}
nonisolated struct SmartPlaylistCondition: Codable, Equatable, Hashable, Sendable {
var field: TrackField
var op: ConditionOperator
var value: ConditionValue
var isEmpty: Bool { value.isEmpty }
}

@ -0,0 +1,121 @@
import Foundation
// `nonisolated` opts this struct out of the project-wide `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`
// setting (same reason as Track, Playlist, etc.). `Sendable` is omitted because the closure
// properties are not Sendable the config is created and consumed exclusively on @MainActor.
nonisolated struct TrackContextMenuConfig {
let playlists: [Playlist]
let lastUsedPlaylistName: String?
let selectedPlaylist: Playlist?
let onAddToPlaylist: (Track, Playlist) -> Void
let onAddToLastPlaylist: ((Track) -> Void)? // nil hides the "Add to [last]" button; always non-nil in practice
let onRemoveFromPlaylist: ((Track) -> Void)?
// nil hides the corresponding item (e.g. when driving a remote device).
let onPlayNext: ((Track) -> Void)?
let onAddToQueue: ((Track) -> Void)?
// nil hides the "New Playlist" item; always non-nil in practice.
let onAddToNewPlaylist: ((Track) -> Void)?
// Opens "Get Info" for the resolved target set (full selection if the
// right-clicked row is part of it, else just the clicked row). nil hides it.
let onGetInfo: (([Track]) -> Void)?
// Explicit init so that onPlayNext, onAddToQueue and onGetInfo default to nil,
// allowing existing call sites that omit them to keep compiling unchanged.
init(
playlists: [Playlist],
lastUsedPlaylistName: String?,
selectedPlaylist: Playlist?,
onAddToPlaylist: @escaping (Track, Playlist) -> Void,
onAddToLastPlaylist: ((Track) -> Void)?,
onRemoveFromPlaylist: ((Track) -> Void)?,
onPlayNext: ((Track) -> Void)? = nil,
onAddToQueue: ((Track) -> Void)? = nil,
onAddToNewPlaylist: ((Track) -> Void)? = nil,
onGetInfo: (([Track]) -> Void)? = nil
) {
self.playlists = playlists
self.lastUsedPlaylistName = lastUsedPlaylistName
self.selectedPlaylist = selectedPlaylist
self.onAddToPlaylist = onAddToPlaylist
self.onAddToLastPlaylist = onAddToLastPlaylist
self.onRemoveFromPlaylist = onRemoveFromPlaylist
self.onPlayNext = onPlayNext
self.onAddToQueue = onAddToQueue
self.onAddToNewPlaylist = onAddToNewPlaylist
self.onGetInfo = onGetInfo
}
}
// A renderer-agnostic description of one context-menu entry. Both the AppKit
// table menu (TrackTableView) and the SwiftUI control-bar menu
// (TrackContextMenuModifier) render from the SAME list of these, so the two
// menus can never drift.
nonisolated enum TrackMenuEntry {
case button(title: String, action: () -> Void)
case submenu(title: String, items: [TrackMenuEntry])
case separator
}
nonisolated extension TrackContextMenuConfig {
/// The single source of truth for the track context menu.
/// - `primary`: the track that single-track actions operate on (e.g. the
/// right-clicked row, or the now-playing track in the control bar).
/// - `selection`: the full target set for multi-capable actions (Get Info).
/// Pass `[primary]` when there is no multi-selection.
func entries(primary track: Track, selection: [Track]) -> [TrackMenuEntry] {
var entries: [TrackMenuEntry] = []
if let onGetInfo {
let targets = selection.isEmpty ? [track] : selection
entries.append(.button(title: "Get Info") { onGetInfo(targets) })
entries.append(.separator)
}
if let onPlayNext {
entries.append(.button(title: "Play Next") { onPlayNext(track) })
}
if let onAddToQueue {
entries.append(.button(title: "Add to Queue") { onAddToQueue(track) })
}
entries.append(.separator)
if let lastUsedPlaylistName, let onAddToLastPlaylist {
entries.append(.button(title: "Add to \(lastUsedPlaylistName)") { onAddToLastPlaylist(track) })
entries.append(.separator)
}
if !playlists.isEmpty || onAddToNewPlaylist != nil {
var sub: [TrackMenuEntry] = []
if let onAddToNewPlaylist {
sub.append(.button(title: "New Playlist…") { onAddToNewPlaylist(track) })
if !playlists.isEmpty { sub.append(.separator) }
}
for playlist in playlists {
sub.append(.button(title: playlist.name) { onAddToPlaylist(track, playlist) })
}
entries.append(.submenu(title: "Add to Playlist", items: sub))
}
if selectedPlaylist != nil, let onRemoveFromPlaylist {
entries.append(.separator)
entries.append(.button(title: "Remove from Playlist") { onRemoveFromPlaylist(track) })
}
return Self.normalizeSeparators(entries)
}
/// Drops leading/trailing separators and collapses consecutive ones, so the
/// builder above can append separators freely without worrying about hygiene.
static func normalizeSeparators(_ entries: [TrackMenuEntry]) -> [TrackMenuEntry] {
var result: [TrackMenuEntry] = []
for entry in entries {
if case .separator = entry {
if result.isEmpty { continue }
if case .separator? = result.last { continue }
}
result.append(entry)
}
if case .separator? = result.last { result.removeLast() }
return result
}
}

@ -4,7 +4,7 @@
<dict>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.get-task-allow</key>
<true/>

@ -11,6 +11,7 @@ struct MusicApp: App {
@State private var shazamService = ShazamService()
@State private var playlistVM: PlaylistViewModel?
@State private var showNewPlaylistAlert = false
@State private var showSmartPlaylistBuilder = false
@State private var initError: String?
@State private var hostServer: HostServer?
@State private var remoteClient = RemoteClient()
@ -38,6 +39,7 @@ struct MusicApp: App {
shazam: shazamService,
db: db,
showNewPlaylistAlert: $showNewPlaylistAlert,
showSmartPlaylistBuilder: $showSmartPlaylistBuilder,
networkStatus: computeNetworkStatus()
)
} else if let error = initError {
@ -83,6 +85,12 @@ struct MusicApp: App {
.keyboardShortcut("n")
.disabled(remoteClient.connectionState.isConnected)
Button("New Smart Playlist...") {
showSmartPlaylistBuilder = true
}
.keyboardShortcut("n", modifiers: [.command, .shift])
.disabled(remoteClient.connectionState.isConnected)
Divider()
Toggle("Enable Host Mode", isOn: Binding(

@ -5,3 +5,19 @@ protocol PlaylistRepresentable: Identifiable, Hashable, Sendable {
var name: String { get }
var isSmartPlaylist: Bool { get }
}
extension PlaylistRepresentable {
/// Stable identity that stays unique across the merged regular + smart
/// playlist collection.
///
/// Regular playlists (`playlists`) and smart playlists (`smart_playlists`)
/// live in separate tables, each with its own `autoIncrementedPrimaryKey`,
/// so their `id` sequences overlap a regular playlist and a smart playlist
/// routinely share `id == 1`. Keying a SwiftUI `ForEach` off the bare `id`
/// collapses two distinct playlists into one identity, which renders a row
/// twice and leaks selection/updates between the colliding buttons.
/// Prefixing with the kind keeps the two namespaces apart.
var listIdentity: String {
"\(isSmartPlaylist ? "smart" : "regular")-\(id.map(String.init) ?? "new")"
}
}

@ -142,24 +142,46 @@ final class HostServer {
// MARK: - GET /db
/// Serve the SQLite database as an HTTP response.
/// Uses SQLite's backup API to produce a self-contained copy that includes
/// all WAL data, avoiding races with concurrent writers.
///
/// Produces a self-contained copy via `VACUUM INTO` (`DatabaseService.backup`),
/// then validates that copy with `PRAGMA quick_check` before sending. The host
/// must never put a malformed image on the wire: doing so surfaces on the client
/// as the opaque "database disk image is malformed" error with no way to tell
/// whether the corruption originated here or in transit. Validating here pins the
/// boundary a failure means the host's backup is bad, full stop.
private func handleDBRequest(on connection: NWConnection) {
guard let db else {
// Never serve the raw on-disk file. The live database runs in WAL mode,
// so its main file alone is an inconsistent (corrupt) image until the WAL
// is checkpointed exactly the malformed-DB symptom we're guarding against.
logger.error("DB request received but no database is configured")
sendHTTP(
status: "503 Service Unavailable",
body: Data("No database configured".utf8),
on: connection,
close: true
)
return
}
do {
let data: Data
if let db {
// Create a temporary copy via the backup API so the served file
// is self-contained (no WAL/SHM dependency) and consistent.
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".sqlite")
defer { try? FileManager.default.removeItem(at: tempURL) }
try db.backup(to: tempURL.path)
data = try Data(contentsOf: tempURL)
} else {
// Fallback: serve the raw file when no DatabaseService is configured
data = try Data(contentsOf: URL(fileURLWithPath: dbPath))
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".sqlite")
defer { try? FileManager.default.removeItem(at: tempURL) }
try db.backup(to: tempURL.path)
guard DatabaseService.isWellFormedDatabase(atPath: tempURL.path) else {
logger.error("Backup failed integrity check — refusing to serve a corrupt database")
sendHTTP(
status: "500 Internal Server Error",
body: Data("Database backup is corrupt".utf8),
on: connection,
close: true
)
return
}
logger.info("Serving database (\(data.count) bytes)")
let data = try Data(contentsOf: tempURL)
logger.info("Serving database (\(data.count) bytes, integrity ok)")
sendHTTP(
status: "200 OK",
body: data,

@ -189,20 +189,52 @@ final class RemoteClient: RemoteCommandSender {
return
}
let header = String(decoding: data[data.startIndex..<separatorRange.lowerBound], as: UTF8.self)
let body = data[separatorRange.upperBound...]
// The host returns a non-200 status (with a short text body) when it cannot
// produce a valid database. Don't write that text out as if it were a DB.
let statusLine = header.split(separator: "\r\n").first.map(String.init) ?? ""
guard statusLine.contains("200") else {
logger.error("DB download failed — host responded: \(statusLine)")
transition(to: .disconnected)
return
}
// Detect a truncated transfer here, where the error is actionable, rather than
// letting a short file surface later as "database disk image is malformed".
if let expected = Self.contentLength(from: header), body.count != expected {
logger.error("DB download truncated: received \(body.count) bytes, expected \(expected)")
transition(to: .disconnected)
return
}
guard !body.isEmpty else {
logger.error("DB response body is empty")
transition(to: .disconnected)
return
}
// Ensure the directory exists
// Ensure the directory exists, then clear any prior remote DB *and its stale
// -wal/-shm side files*. Writing a fresh main database next to a leftover WAL
// is a classic SQLite corruption trap.
let dirURL = URL(fileURLWithPath: Self.remoteDBPath).deletingLastPathComponent()
try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
deleteRemoteDB()
do {
try body.write(to: URL(fileURLWithPath: Self.remoteDBPath))
logger.info("Database saved (\(body.count) bytes) to \(Self.remoteDBPath)")
// Final gate: validate the written file before handing it to GRDB. A bad
// download must produce a clean disconnect, not a malformed-DB crash in the UI.
guard DatabaseService.isWellFormedDatabase(atPath: Self.remoteDBPath) else {
logger.error("Downloaded DB failed integrity check (\(body.count) bytes) — discarding")
deleteRemoteDB()
transition(to: .disconnected)
return
}
logger.info("Database saved (\(body.count) bytes, integrity ok) to \(Self.remoteDBPath)")
connectCommandChannel(hostName: hostName)
} catch {
logger.error("Failed to write DB: \(error.localizedDescription)")
@ -210,6 +242,18 @@ final class RemoteClient: RemoteCommandSender {
}
}
/// Parse the `Content-Length` header value from a raw HTTP header block, if present.
private static func contentLength(from header: String) -> Int? {
for line in header.split(separator: "\r\n") {
let parts = line.split(separator: ":", maxSplits: 1)
guard parts.count == 2,
parts[0].trimmingCharacters(in: .whitespaces).lowercased() == "content-length"
else { continue }
return Int(parts[1].trimmingCharacters(in: .whitespaces))
}
return nil
}
// MARK: - Command Channel
/// Open a second TCP connection to the same endpoint and upgrade it to the command channel.
@ -379,16 +423,24 @@ final class RemoteClient: RemoteCommandSender {
// MARK: - Helpers
/// Delete the local remote database file if it exists.
/// Delete the local remote database file, including its `-wal`/`-shm` side files.
/// Leaving a stale WAL behind would let SQLite apply foreign frames to the next
/// downloaded database and corrupt it.
private func deleteRemoteDB() {
let path = Self.remoteDBPath
if FileManager.default.fileExists(atPath: path) {
var deletedMain = false
for suffix in ["", "-wal", "-shm"] {
let sidecar = path + suffix
guard FileManager.default.fileExists(atPath: sidecar) else { continue }
do {
try FileManager.default.removeItem(atPath: path)
logger.info("Deleted remote DB at \(path)")
try FileManager.default.removeItem(atPath: sidecar)
if suffix.isEmpty { deletedMain = true }
} catch {
logger.error("Failed to delete remote DB: \(error.localizedDescription)")
logger.error("Failed to delete \(sidecar): \(error.localizedDescription)")
}
}
if deletedMain {
logger.info("Deleted remote DB at \(path)")
}
}
}

@ -116,16 +116,66 @@ nonisolated final class DatabaseService: Sendable {
}
}
migrator.registerMigration("v5-add-smart-playlist-conditions") { db in
try db.alter(table: "smart_playlists") { t in
t.add(column: "conditions", .text)
}
}
try migrator.migrate(db)
}
// MARK: - Maintenance
/// Create a self-contained copy of the database at the given path using
/// SQLite's online backup API. The copy includes all WAL data and is safe
/// to serve or transfer without additional files.
/// Create a self-contained copy of the database at the given path.
///
/// Uses SQLite's `VACUUM INTO` rather than the online backup API. The online
/// backup (`dbPool.backup(to:)`) produced copies whose FTS5 `tracks_ft` shadow
/// tables were not transferred, so any `ValueObservation` opened on the copy
/// failed with "no such table: tracks_ft" which left the remote client's
/// track list empty even though the row data copied fine. `VACUUM INTO` writes
/// a fresh, fully-rebuilt, self-contained database that includes a functional
/// FTS5 index and all committed data, with no WAL/SHM side files.
///
/// `VACUUM INTO` requires the destination file to not already exist, so any
/// stale file at `destinationPath` is removed first.
func backup(to destinationPath: String) throws {
try dbPool.backup(to: DatabaseQueue(path: destinationPath))
try? FileManager.default.removeItem(atPath: destinationPath)
let escaped = destinationPath.replacingOccurrences(of: "'", with: "''")
try dbPool.writeWithoutTransaction { db in
try db.execute(sql: "VACUUM INTO '\(escaped)'")
}
}
/// Returns `true` only if the SQLite file at `path` is a complete, well-formed
/// database. Opens a throwaway **read-only** connection (so it never flips the
/// file to WAL or creates side files) and runs `PRAGMA quick_check`, which walks
/// every b-tree page. This catches a truncated or inconsistent copy *before* it
/// reaches GRDB where it would otherwise blow up with the opaque
/// "database disk image is malformed" (`SQLITE_CORRUPT`) error mid-query.
static func isWellFormedDatabase(atPath path: String) -> Bool {
guard FileManager.default.fileExists(atPath: path) else { return false }
do {
var config = Configuration()
config.readonly = true
let queue = try DatabaseQueue(path: path, configuration: config)
return try queue.read { db in
// 1. quick_check walks every b-tree page catches truncated/corrupt images.
let check = try String.fetchOne(db, sql: "PRAGMA quick_check") ?? "unknown"
guard check == "ok" else { return false }
// 2. A 0-byte or schema-less file is *valid* but empty SQLite, which would
// yield an empty remote library. Require the core `tracks` table so an
// empty/wrong file is rejected too. (Existence, not row count an empty
// library with the table present is legitimate.)
let hasTracks = try Int.fetchOne(
db,
sql: "SELECT count(*) FROM sqlite_master WHERE type = 'table' AND name = 'tracks'"
) ?? 0
return hasTracks > 0
}
} catch {
return false
}
}
// MARK: - Write
@ -153,6 +203,15 @@ nonisolated final class DatabaseService: Sendable {
}
}
// Full-record update for metadata edits. The tracks_ft FTS5 index is kept in
// sync automatically by the triggers installed via synchronize(withTable:),
// so no manual FTS write is needed here.
func updateTrack(_ track: Track) throws {
try dbPool.write { db in
try track.update(db)
}
}
func deleteTracksWithURLs(_ urls: Set<String>) throws {
try dbPool.write { db in
let placeholders = databaseQuestionMarks(count: urls.count)
@ -170,6 +229,21 @@ nonisolated final class DatabaseService: Sendable {
"trackNumber", "dateAdded", "playCount", "rating", "bpm"
]
/// Builds the SQL `ORDER BY` expression (without the `ORDER BY` keyword) for a track
/// list. `column` is whitelisted against `validSortColumns`, so it is safe to
/// interpolate. When sorting by `album`, a secondary `discNumber, trackNumber`
/// ascending sort is appended so tracks within an album stay in playing order
/// always ascending, even when the album sort itself is descending.
private static func orderByClause(column: String, ascending: Bool) -> String {
let col = validSortColumns.contains(column) ? column : "title"
let order = ascending ? "ASC" : "DESC"
var clause = "\(col) COLLATE NOCASE \(order)"
if col == "album" {
clause += ", discNumber ASC, trackNumber ASC"
}
return clause
}
func fetchTracks(search: String, sortColumn: String, ascending: Bool) throws -> [Track] {
try dbPool.read { db in
try self.fetchTracks(db: db, search: search, sortColumn: sortColumn, ascending: ascending)
@ -178,13 +252,12 @@ nonisolated final class DatabaseService: Sendable {
// Used by ValueObservation which already holds a Database access
func fetchTracks(db: Database, search: String, sortColumn: String, ascending: Bool) throws -> [Track] {
let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title"
let order = ascending ? "ASC" : "DESC"
let orderBy = Self.orderByClause(column: sortColumn, ascending: ascending)
if search.trimmingCharacters(in: .whitespaces).isEmpty {
return try Track.fetchAll(
db,
sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)"
sql: "SELECT * FROM tracks ORDER BY \(orderBy)"
)
}
@ -197,12 +270,31 @@ nonisolated final class DatabaseService: Sendable {
SELECT tracks.* FROM tracks
JOIN tracks_ft ON tracks_ft.rowid = tracks.id
WHERE tracks_ft MATCH ?
ORDER BY \(col) COLLATE NOCASE \(order)
ORDER BY \(orderBy)
""",
arguments: [pattern]
)
}
func fetchTracks(conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] {
try dbPool.read { db in
try self.fetchTracks(db: db, conditions: conditions, sortColumn: sortColumn, ascending: ascending)
}
}
func fetchTracks(db: Database, conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] {
let orderBy = Self.orderByClause(column: sortColumn, ascending: ascending)
let (whereSQL, args) = buildWhereClause(conditions)
if whereSQL.isEmpty {
return try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY \(orderBy)")
}
return try Track.fetchAll(
db,
sql: "SELECT * FROM tracks WHERE \(whereSQL) ORDER BY \(orderBy)",
arguments: args
)
}
func allFileURLs() throws -> Set<String> {
try dbPool.read { db in
let urls = try String.fetchAll(db, sql: "SELECT fileURL FROM tracks")
@ -444,10 +536,67 @@ nonisolated final class DatabaseService: Sendable {
// MARK: - Smart Playlists
/// Builds a parameterized SQL WHERE clause from an array of conditions.
/// Column names come from `TrackField.rawValue` (an enum, not user input)
/// safe to interpolate. Values are always bound via `StatementArguments` (`?`)
/// to prevent SQL injection.
private func buildWhereClause(_ conditions: [SmartPlaylistCondition]) -> (sql: String, arguments: StatementArguments) {
guard !conditions.isEmpty else { return ("", StatementArguments()) }
var fragments: [String] = []
var args: [DatabaseValueConvertible?] = []
for condition in conditions {
let col = condition.field.rawValue
switch (condition.op, condition.value) {
case (.equals, .string(let s)):
fragments.append("LOWER(\(col)) = LOWER(?)")
args.append(s)
case (.startsWith, .string(let s)):
let escaped = s
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "%", with: "\\%")
.replacingOccurrences(of: "_", with: "\\_")
fragments.append("LOWER(\(col)) LIKE LOWER(?) || '%' ESCAPE '\\'")
args.append(escaped)
case (.equals, .int(let i)):
fragments.append("\(col) = ?"); args.append(i)
case (.greaterThan, .int(let i)):
fragments.append("\(col) > ?"); args.append(i)
case (.lessThan, .int(let i)):
fragments.append("\(col) < ?"); args.append(i)
case (.equals, .double(let d)):
fragments.append("\(col) = ?"); args.append(d)
case (.greaterThan, .double(let d)):
fragments.append("\(col) > ?"); args.append(d)
case (.lessThan, .double(let d)):
fragments.append("\(col) < ?"); args.append(d)
case (.equals, .date(let date)):
fragments.append("\(col) = ?"); args.append(date)
case (.greaterThan, .date(let date)):
fragments.append("\(col) > ?"); args.append(date)
case (.lessThan, .date(let date)):
fragments.append("\(col) < ?"); args.append(date)
default:
break
}
}
return (fragments.joined(separator: " AND "), StatementArguments(args))
}
func createSmartPlaylist(name: String, searchQuery: String) throws -> SmartPlaylist {
try dbPool.write { db in
var smartPlaylist = SmartPlaylist(
id: nil, name: name, searchQuery: searchQuery, createdAt: Date()
id: nil, name: name, searchQuery: searchQuery, createdAt: Date(), conditions: nil
)
try smartPlaylist.insert(db)
return smartPlaylist
}
}
func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws -> SmartPlaylist {
try dbPool.write { db in
var smartPlaylist = SmartPlaylist(
id: nil, name: name, searchQuery: "", createdAt: Date(), conditions: conditions
)
try smartPlaylist.insert(db)
return smartPlaylist
@ -472,6 +621,17 @@ nonisolated final class DatabaseService: Sendable {
}
}
func updateSmartPlaylistConditions(id: Int64, conditions: [SmartPlaylistCondition]) throws {
let data = try JSONEncoder().encode(conditions)
let json = String(data: data, encoding: .utf8)
try dbPool.write { db in
try db.execute(
sql: "UPDATE smart_playlists SET conditions = ? WHERE id = ?",
arguments: [json, id]
)
}
}
func deleteSmartPlaylist(id: Int64) throws {
try dbPool.write { db in
try db.execute(sql: "DELETE FROM smart_playlists WHERE id = ?", arguments: [id])

@ -34,6 +34,31 @@ final class ScannerService {
return results
}
/// Resolve a track's bitrate in kbps from the OS estimate, falling back to a
/// size/duration average. Returns nil when nothing can be derived never 0,
/// so the UI shows "" instead of a meaningless "0 kbps". A sub-kbps result in
/// either branch (rounds to 0) is treated as "no value": the estimate falls
/// through to the formula, and a sub-kbps formula result returns nil.
///
/// AVFoundation's `estimatedDataRate` returns 0 for some files (observed on
/// long/VBR MP3s); for those we compute the true average bitrate from the
/// file size and duration, which matches ffprobe to the kbps.
nonisolated static func resolveBitrate(estimatedDataRate: Double,
fileSizeBytes: Int64?,
durationSeconds: Double?) -> Int? {
if estimatedDataRate > 0 {
let kbps = Int((estimatedDataRate / 1000).rounded())
if kbps > 0 { return kbps }
}
// NaN-safe: `dur > 0` is false for .nan, so we return nil rather than 0.
if let size = fileSizeBytes, size > 0,
let dur = durationSeconds, dur > 0 {
let kbps = Int((Double(size) * 8 / dur / 1000).rounded())
if kbps > 0 { return kbps }
}
return nil
}
func scanFolder(_ folder: URL) async {
isScanning = true
defer { isScanning = false }
@ -146,23 +171,32 @@ final class ScannerService {
let duration = try await asset.load(.duration)
let durationSeconds = CMTimeGetSeconds(duration)
// Computed here (before the audio-track load) because resolveBitrate's
// size/duration fallback needs stats.fileSize. Still reused below for the
// Track's fileSize/dateModified/fileHash.
let stats = try TrackFileStats.compute(for: url)
var bitrate: Int?
var sampleRate: Int?
if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first {
let estimatedRate = try await audioTrack.load(.estimatedDataRate)
bitrate = Int(estimatedRate / 1000)
bitrate = Self.resolveBitrate(estimatedDataRate: Double(estimatedRate),
fileSizeBytes: stats.fileSize,
durationSeconds: durationSeconds)
let descriptions = try await audioTrack.load(.formatDescriptions)
if let desc = descriptions.first {
if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) {
sampleRate = Int(asbd.pointee.mSampleRate)
}
}
} else {
// No audio track loaded still attempt the size/duration fallback
// so we never silently lose the bitrate.
bitrate = Self.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: stats.fileSize,
durationSeconds: durationSeconds)
}
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
let fileSize = attrs[.size] as? Int64 ?? 0
let modDate = attrs[.modificationDate] as? Date ?? Date()
return Track(
fileURL: url.absoluteString,
title: title,
@ -179,13 +213,13 @@ final class ScannerService {
fileFormat: url.pathExtension.lowercased(),
bitrate: bitrate,
sampleRate: sampleRate,
fileSize: fileSize,
fileSize: stats.fileSize,
playCount: 0,
lastPlayedAt: nil,
rating: 0,
dateAdded: Date(),
dateModified: modDate,
fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate)
dateModified: stats.dateModified,
fileHash: stats.fileHash
)
} catch {
print("Failed to extract metadata from \(url.lastPathComponent): \(error)")

@ -0,0 +1,42 @@
import Foundation
import ID3TagEditor
// Writes ID3 string frames into mp3 files in place using ID3TagEditor 5.5.0.
// Builds a v2.3 tag with the managed frames; unmodeled frames (e.g. artwork) are
// not preserved in v1 acceptable; TagLib integration is a later task.
// rating is NOT written (DB-only in v1).
nonisolated struct ID3TagWriter: TagWriter {
func write(_ fields: EditableTrackFields, to url: URL) throws {
// Build a v2.3 tag. ID32v3TagBuilder is the correct class name in 5.5.0.
// All builder methods return Self so they can be chained, but we call them
// imperatively here because optional fields are conditionally added.
let builder = ID32v3TagBuilder()
_ = builder
.title(frame: ID3FrameWithStringContent(content: fields.title))
.artist(frame: ID3FrameWithStringContent(content: fields.artist))
.albumArtist(frame: ID3FrameWithStringContent(content: fields.albumArtist))
.album(frame: ID3FrameWithStringContent(content: fields.album))
.genre(frame: ID3FrameGenre(genre: nil, description: fields.genre))
.composer(frame: ID3FrameWithStringContent(content: fields.composer))
// recordingYear takes ID3FrameWithIntegerContent in 5.5.0 (TYER frame).
if let y = fields.year {
_ = builder.recordingYear(frame: ID3FrameWithIntegerContent(value: y))
}
// trackPosition / discPosition use ID3FramePartOfTotal.
if let n = fields.trackNumber {
_ = builder.trackPosition(frame: ID3FramePartOfTotal(part: n, total: nil))
}
if let d = fields.discNumber {
_ = builder.discPosition(frame: ID3FramePartOfTotal(part: d, total: nil))
}
// beatsPerMinute uses ID3FrameWithIntegerContent (TBPM frame).
if let b = fields.bpm {
_ = builder.beatsPerMinute(frame: ID3FrameWithIntegerContent(value: b))
}
let tag = builder.build()
// write(tag:to:andSaveTo:) overwrites in place when newPath is nil.
try ID3TagEditor().write(tag: tag, to: url.path)
}
}

@ -0,0 +1,53 @@
import Foundation
import AVFoundation
// Writes iTunes/common metadata into m4a-family files via a passthrough export
// to a temp file, then an atomic replace of the original. NOTE: passthrough
// export rewrites the metadata set, so unmodeled atoms may not survive fine for v1.
nonisolated struct MP4TagWriter: TagWriter {
func write(_ fields: EditableTrackFields, to url: URL) throws {
let asset = AVURLAsset(url: url)
guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else {
throw TagWriterError.exportUnavailable
}
let tmp = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString + ".m4a")
export.outputURL = tmp
export.outputFileType = .m4a
export.metadata = Self.items(from: fields)
let sema = DispatchSemaphore(value: 0)
var exportError: Error?
export.exportAsynchronously {
if export.status != .completed { exportError = export.error ?? TagWriterError.exportFailed }
sema.signal()
}
sema.wait()
if let exportError { try? FileManager.default.removeItem(at: tmp); throw exportError }
_ = try FileManager.default.replaceItemAt(url, withItemAt: tmp)
}
private static func items(from f: EditableTrackFields) -> [AVMetadataItem] {
func item(_ id: AVMetadataIdentifier, _ value: (any NSCopying & NSObjectProtocol)?) -> AVMetadataItem? {
guard let value else { return nil }
let m = AVMutableMetadataItem()
m.identifier = id
m.value = value
return m
}
var out: [AVMetadataItem?] = [
item(.commonIdentifierTitle, f.title as NSString),
item(.commonIdentifierArtist, f.artist as NSString),
item(.commonIdentifierAlbumName, f.album as NSString),
item(.iTunesMetadataAlbumArtist, f.albumArtist as NSString),
item(.iTunesMetadataUserGenre, f.genre as NSString),
item(.iTunesMetadataComposer, f.composer as NSString),
]
if let y = f.year { out.append(item(.iTunesMetadataReleaseDate, String(y) as NSString)) }
if let n = f.trackNumber { out.append(item(.iTunesMetadataTrackNumber, NSNumber(value: n))) }
if let d = f.discNumber { out.append(item(.iTunesMetadataDiscNumber, NSNumber(value: d))) }
if let b = f.bpm { out.append(item(.iTunesMetadataBeatsPerMin, NSNumber(value: b))) }
return out.compactMap { $0 }
}
}

@ -0,0 +1,20 @@
import Foundation
// Writes the editable, tag-mappable fields into an audio file. rating is
// intentionally NOT written (DB-only in v1). Implementations write atomically.
nonisolated protocol TagWriter: Sendable {
func write(_ fields: EditableTrackFields, to url: URL) throws
}
nonisolated enum TagWriterError: Error { case exportUnavailable, exportFailed }
nonisolated enum TagWriterFactory {
// Returns nil for formats with no v1 writer (flac/wav/aiff) DB-only.
static func writer(for url: URL) -> TagWriter? {
switch url.pathExtension.lowercased() {
case "mp3": return ID3TagWriter()
case "m4a", "alac", "aac": return MP4TagWriter()
default: return nil
}
}
}

@ -0,0 +1,57 @@
import Foundation
nonisolated struct TrackEditWarning: Sendable, Equatable {
enum Kind: Sendable, Equatable { case dbOnlyUnsupported, fileWriteFailed }
let trackId: Int64?
let fileURL: String
let kind: Kind
let reason: String
}
// Orchestrates a metadata save: apply edited fields best-effort file-tag write
// refresh file stats on success DB update. The DB is ALWAYS updated; file
// writeback failures are collected as warnings, never blocking the library edit.
nonisolated final class TrackEditService: Sendable {
private let database: DatabaseService
private let writerFactory: @Sendable (URL) -> TagWriter?
init(database: DatabaseService,
writerFactory: @escaping @Sendable (URL) -> TagWriter? = TagWriterFactory.writer) {
self.database = database
self.writerFactory = writerFactory
}
func save(_ values: EditableTrackFields,
editing edited: Set<EditableTrackField>,
to tracks: [Track]) -> [TrackEditWarning] {
var warnings: [TrackEditWarning] = []
for track in tracks {
var updated = values.apply(editing: edited, to: track)
// rating and dateAdded are DB-only (not file tags); only attempt a file
// write if some tag-mappable field actually changed.
let tagFieldsChanged = !edited.subtracting([.rating, .dateAdded]).isEmpty
if let url = URL(string: track.fileURL), tagFieldsChanged {
if let writer = writerFactory(url) {
do {
try writer.write(values, to: url)
if let stats = try? TrackFileStats.compute(for: url) {
updated.fileSize = stats.fileSize
updated.dateModified = stats.dateModified
updated.fileHash = stats.fileHash
}
} catch {
warnings.append(.init(trackId: track.id, fileURL: track.fileURL,
kind: .fileWriteFailed, reason: error.localizedDescription))
}
} else {
warnings.append(.init(trackId: track.id, fileURL: track.fileURL,
kind: .dbOnlyUnsupported,
reason: "Tag writing not supported for .\(url.pathExtension)"))
}
}
try? database.updateTrack(updated)
}
return warnings
}
}

@ -0,0 +1,22 @@
import Foundation
// Reads a file's size + modification date and derives the library fileHash.
// Centralizes the computation so ScannerService (import) and TrackEditService
// (post-writeback refresh) can never drift. Hash uses Track.computeHash so the
// format stays identical to import-time hashes.
nonisolated struct TrackFileStats: Sendable {
let fileSize: Int64
let dateModified: Date
let fileHash: String
static func compute(for url: URL) throws -> TrackFileStats {
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
let fileSize = attrs[.size] as? Int64 ?? 0
let modDate = attrs[.modificationDate] as? Date ?? Date()
return TrackFileStats(
fileSize: fileSize,
dateModified: modDate,
fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate)
)
}
}

@ -118,6 +118,14 @@ final class StreamingServer {
}
// GET /file?id=TRACKID&token=APIKEY direct file streaming (progressive download)
//
// Supports HTTP byte-range requests (RFC 7233). AVPlayer (plain AVURLAsset,
// no custom resource loader) only treats a progressive HTTP stream as
// *seekable* when the server answers `Range` requests with `206 Partial
// Content` + `Content-Range` + `Accept-Ranges: bytes`. We therefore always
// advertise `Accept-Ranges: bytes` and serve a single requested slice when a
// valid `Range: bytes=START-END` header is present, reading only those bytes
// off disk via a FileHandle (never loading the whole file for a partial read).
router.get("file") { [db] request, _ -> Response in
let hasBearer = request.headers[.authorization] == "Bearer \(apiKey)"
let hasToken = request.uri.queryParameters.get("token") == apiKey
@ -140,17 +148,68 @@ final class StreamingServer {
throw HTTPError(.notFound, message: "File not found on disk")
}
let data = try Data(contentsOf: fileURL)
let contentType = Self.audioContentType(for: fileURL.pathExtension)
return Response(
status: .ok,
headers: [
.contentType: contentType,
.contentLength: String(data.count),
],
body: .init(byteBuffer: ByteBuffer(bytes: data))
)
// Total size without loading the file into memory.
let attrs = try FileManager.default.attributesOfItem(atPath: fileURL.path)
let fileSize = (attrs[.size] as? NSNumber)?.int64Value ?? 0
// Serves the entire file with 200 OK + Accept-Ranges advertisement.
// Used when there's no Range header, or when the header is malformed
// or asks for multiple ranges (graceful fallback never crash).
func fullBodyResponse() throws -> Response {
let data = try Data(contentsOf: fileURL)
return Response(
status: .ok,
headers: [
.contentType: contentType,
.contentLength: String(data.count),
.acceptRanges: "bytes",
],
body: .init(byteBuffer: ByteBuffer(bytes: data))
)
}
// No Range header full body, but still advertise range support.
guard let rangeHeader = request.headers[.range] else {
return try fullBodyResponse()
}
// Multiple ranges (comma-separated) are unsupported here fall back.
guard !rangeHeader.contains(",") else {
return try fullBodyResponse()
}
switch Self.parseByteRange(rangeHeader, fileSize: fileSize) {
case .full:
// Malformed/unparseable Range graceful 200 full-body fallback.
return try fullBodyResponse()
case .unsatisfiable:
return Response(
status: .rangeNotSatisfiable,
headers: [.contentRange: "bytes */\(fileSize)"]
)
case let .range(start, end):
// Read ONLY the requested slice off disk.
let handle = try FileHandle(forReadingFrom: fileURL)
defer { try? handle.close() }
try handle.seek(toOffset: UInt64(start))
let length = Int(end - start + 1)
let slice = try handle.read(upToCount: length) ?? Data()
return Response(
status: .partialContent,
headers: [
.contentType: contentType,
.contentLength: String(slice.count),
.acceptRanges: "bytes",
.contentRange: "bytes \(start)-\(end)/\(fileSize)",
],
body: .init(byteBuffer: ByteBuffer(bytes: slice))
)
}
}
// GET /tracks/:trackId/stream.m3u8
@ -255,6 +314,57 @@ final class StreamingServer {
segmenterCache.clear()
}
/// Outcome of parsing a single-range `Range` header against a known file size.
private enum ByteRangeResult {
/// Serve the whole file (no range / unparseable header).
case full
/// Serve `start...end` inclusive (both already clamped to `0..<fileSize`).
case range(start: Int64, end: Int64)
/// Range is syntactically a range but lies outside the file 416.
case unsatisfiable
}
/// Parses a single-range HTTP `Range` header of the form `bytes=START-END`,
/// resolving the three accepted shapes against `fileSize`:
/// - `bytes=10-19` start=10, end=19 (inclusive)
/// - `bytes=10-` start=10, end=fileSize-1 (open-ended)
/// - `bytes=-500` last 500 bytes: start=fileSize-500, end=fileSize-1 (suffix)
/// `end` is clamped to `fileSize-1`. Any malformed/unparseable header returns
/// `.full` so the caller can fall back to a 200 full-body response.
nonisolated private static func parseByteRange(_ header: String, fileSize: Int64) -> ByteRangeResult {
let trimmed = header.trimmingCharacters(in: .whitespaces)
guard trimmed.hasPrefix("bytes=") else { return .full }
let spec = trimmed.dropFirst("bytes=".count)
guard let dashIndex = spec.firstIndex(of: "-") else { return .full }
let startPart = spec[spec.startIndex..<dashIndex].trimmingCharacters(in: .whitespaces)
let endPart = spec[spec.index(after: dashIndex)...].trimmingCharacters(in: .whitespaces)
if startPart.isEmpty {
// Suffix form: bytes=-N last N bytes.
guard let suffixLength = Int64(endPart), suffixLength > 0 else { return .full }
guard fileSize > 0 else { return .unsatisfiable }
let start = max(0, fileSize - suffixLength)
return .range(start: start, end: fileSize - 1)
}
guard let start = Int64(startPart) else { return .full }
// Out-of-bounds start is unsatisfiable per RFC 7233.
guard start < fileSize else { return .unsatisfiable }
let end: Int64
if endPart.isEmpty {
// Open-ended: bytes=N- through EOF.
end = fileSize - 1
} else {
guard let parsedEnd = Int64(endPart) else { return .full }
end = min(parsedEnd, fileSize - 1)
}
guard start <= end else { return .unsatisfiable }
return .range(start: start, end: end)
}
nonisolated private static func audioContentType(for ext: String) -> String {
switch ext.lowercased() {
case "mp3": return "audio/mpeg"

@ -11,14 +11,29 @@ final class LibraryViewModel {
var trackCount = 0
private let db: DatabaseService
private let editService: TrackEditService
private var cancellable: AnyDatabaseCancellable?
private var searchTask: Task<Void, Never>?
init(db: DatabaseService) {
self.db = db
self.editService = TrackEditService(database: db)
updateQuery()
}
// Applies metadata edits to one or more tracks. File-tag writes run off the
// main actor; the library list refreshes automatically via the DB observation
// (no manual reload). Returns per-track warnings (unsupported format / file
// write failure) for the caller to surface; the DB edit always lands.
func applyTrackEdits(
_ values: EditableTrackFields,
editing edited: Set<EditableTrackField>,
to tracks: [Track]
) async -> [TrackEditWarning] {
let service = editService
return await Task.detached { service.save(values, editing: edited, to: tracks) }.value
}
func search(_ text: String) {
searchText = text
searchTask?.cancel()

@ -18,6 +18,11 @@ final class PlayerViewModel {
private(set) var queue: [Track] = []
private var originalQueue: [Track] = []
/// The manual "Up Next" queue. Plays ahead of `queue` (the context) and survives
/// starting a new context. `queue`/`currentIndex` remain the CONTEXT position.
private(set) var manualQueue: [QueueEntry] = []
/// Display label for the panel's "Next from: <name>" section.
private(set) var contextName: String?
private var provider: PlaybackProvider
private let db: DatabaseService?
private var halfwayReported = false
@ -54,6 +59,8 @@ final class PlayerViewModel {
duration = 0
queue = []
originalQueue = []
manualQueue = []
contextName = nil
halfwayReported = false
bindProvider()
}
@ -86,7 +93,8 @@ final class PlayerViewModel {
// MARK: - Queue Management
func setQueue(_ tracks: [Track]) {
func setQueue(_ tracks: [Track], contextName: String? = nil) {
self.contextName = contextName
originalQueue = tracks
if isShuffled {
queue = buildShuffledQueue(from: tracks, startingWith: currentTrack)
@ -98,6 +106,64 @@ final class PlayerViewModel {
}
}
// MARK: - Manual Queue
func playNext(_ track: Track) {
guard remoteProvider == nil else { return }
manualQueue.insert(QueueEntry(track: track), at: 0)
startQueuedTrackIfIdle()
}
func addToQueue(_ track: Track) {
guard remoteProvider == nil else { return }
manualQueue.append(QueueEntry(track: track))
startQueuedTrackIfIdle()
}
func removeFromQueue(at offsets: IndexSet) {
// Remove indices in reverse order to keep remaining offsets valid.
for idx in offsets.sorted().reversed() {
manualQueue.remove(at: idx)
}
}
func moveInQueue(from source: IndexSet, to destination: Int) {
// Extract items, erase in reverse order, reinsert at adjusted destination.
let sorted = source.sorted()
let items = sorted.map { manualQueue[$0] }
for idx in sorted.reversed() { manualQueue.remove(at: idx) }
let shift = source.filter { $0 < destination }.count
manualQueue.insert(contentsOf: items, at: destination - shift)
}
/// Context tracks after the current context position the panel's "Next from"
/// section. Empty when there is no context or we are at its end.
var upcomingContext: [Track] {
guard let idx = currentIndex, idx + 1 < queue.count else { return [] }
return Array(queue[(idx + 1)...])
}
// If nothing is playing, start the just-queued track immediately rather than
// parking it matches Spotify's "queue while idle starts playback".
private func startQueuedTrackIfIdle() {
guard currentTrack == nil, !manualQueue.isEmpty else { return }
let entry = manualQueue.removeFirst()
playManual(entry.track)
}
// Plays a track pulled from the manual queue. Mirrors play(_:) but deliberately
// does NOT touch currentIndex, so the context position is preserved and resumes
// once the manual queue drains.
private func playManual(_ track: Track) {
currentTrack = track
halfwayReported = false
isPlaying = true
currentTime = 0
duration = track.duration
guard let url = provider.urlForTrack(track) else { return }
provider.play(url: url)
}
// MARK: - Playback Controls
func play(_ track: Track) {
@ -162,6 +228,12 @@ final class PlayerViewModel {
remote.sendNext()
return
}
// Drain the manual queue first "Up Next" tracks take priority over context.
if !manualQueue.isEmpty {
let entry = manualQueue.removeFirst()
playManual(entry.track)
return
}
guard let idx = currentIndex else { return }
let nextIdx = idx + 1
if nextIdx < queue.count {

@ -76,6 +76,13 @@ final class PlaylistViewModel {
lastUsedPlaylistId = playlistId
}
@discardableResult
func createPlaylistAndAddTrack(name: String, track: Track) throws -> Playlist {
let playlist = try db.createPlaylist(name: name)
try addTrack(track, to: playlist)
return playlist
}
func addTrackToLastUsedPlaylist(_ track: Track) throws {
guard let playlistId = lastUsedPlaylistId,
let playlist = playlists.first(where: { $0.id == playlistId }) else { return }
@ -102,6 +109,10 @@ final class PlaylistViewModel {
_ = try db.createSmartPlaylist(name: name, searchQuery: searchQuery)
}
func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws {
_ = try db.createSmartPlaylist(name: name, conditions: conditions)
}
func renameSmartPlaylist(_ smartPlaylist: SmartPlaylist, to name: String) throws {
guard let id = smartPlaylist.id else { return }
try db.renameSmartPlaylist(id: id, name: name)
@ -111,7 +122,20 @@ final class PlaylistViewModel {
guard let id = smartPlaylist.id else { return }
try db.updateSmartPlaylistQuery(id: id, searchQuery: query)
if selectedSmartPlaylist?.id == id {
observeSmartPlaylistTracks(searchQuery: query)
var updated = smartPlaylist
updated.searchQuery = query
updated.conditions = nil
observeSmartPlaylistTracks(for: updated)
}
}
func updateSmartPlaylistConditions(_ smartPlaylist: SmartPlaylist, to conditions: [SmartPlaylistCondition]) throws {
guard let id = smartPlaylist.id else { return }
try db.updateSmartPlaylistConditions(id: id, conditions: conditions)
if selectedSmartPlaylist?.id == id {
var updated = smartPlaylist
updated.conditions = conditions
observeSmartPlaylistTracks(for: updated)
}
}
@ -130,7 +154,7 @@ final class PlaylistViewModel {
if item is Playlist {
observePlaylistTracks()
} else if let smart = item as? SmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
observeSmartPlaylistTracks(for: smart)
}
}
@ -150,7 +174,7 @@ final class PlaylistViewModel {
sortAscending = true
}
if let smart = selectedSmartPlaylist {
observeSmartPlaylistTracks(searchQuery: smart.searchQuery)
observeSmartPlaylistTracks(for: smart)
}
}
@ -215,13 +239,21 @@ final class PlaylistViewModel {
)
}
private func observeSmartPlaylistTracks(searchQuery: String) {
private func observeSmartPlaylistTracks(for smartPlaylist: SmartPlaylist) {
tracksCancellable?.cancel()
let col = sortColumn
let asc = sortAscending
let observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc)
let observation: ValueObservation<ValueReducers.Fetch<[Track]>>
if let conditions = smartPlaylist.conditions {
observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchTracks(db: dbAccess, conditions: conditions, sortColumn: col, ascending: asc)
}
} else {
let searchQuery = smartPlaylist.searchQuery
observation = ValueObservation.tracking { [db] dbAccess in
try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc)
}
}
tracksCancellable = observation.start(
in: db.dbPool,

@ -20,17 +20,30 @@ struct PlayerControlsView: View {
let onVolumeChange: (Float) -> Void
let onShuffleToggle: () -> Void
let onNowPlayingTap: () -> Void
var contextMenuConfig: TrackContextMenuConfig? = nil
var isQueueVisible: Bool = false
var showQueueButton: Bool = true
var onToggleQueue: (() -> Void)? = nil
@State private var isDragging = false
@State private var dragValue: Double = 0
@State private var artworkImage: NSImage?
var body: some View {
VStack(spacing: 0) {
progressTrack
HStack(spacing: 0) {
nowPlayingSection
.frame(maxWidth: .infinity, alignment: .leading)
// `.equatable()` stops this subtree (which hosts the context menu)
// from re-rendering on every `currentTime` tick during playback
// that rebuild is what made the open "Add to Playlist" submenu blink.
NowPlayingSection(
track: currentTrack,
isBuffering: isBuffering,
streamingError: streamingError,
config: contextMenuConfig,
onTap: onNowPlayingTap
)
.equatable()
.frame(maxWidth: .infinity, alignment: .leading)
transportSection
.frame(maxWidth: .infinity)
@ -42,79 +55,6 @@ struct PlayerControlsView: View {
.padding(.vertical, 8)
}
.background(.bar)
.onChange(of: currentTrack?.id) {
loadArtwork()
}
.onAppear {
loadArtwork()
}
}
private func loadArtwork() {
guard let urlString = currentTrack?.fileURL,
let url = URL(string: urlString) else {
artworkImage = nil
return
}
Task.detached {
let asset = AVURLAsset(url: url)
let metadata = try? await asset.load(.metadata)
let data = try? await metadata?
.first { $0.commonKey == .commonKeyArtwork }?
.load(.dataValue)
let image = data.flatMap { NSImage(data: $0) }
await MainActor.run { artworkImage = image }
}
}
private var nowPlayingSection: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: 44, height: 44)
.overlay {
if let artworkImage {
Image(nsImage: artworkImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
if let track = currentTrack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(track.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
if isBuffering {
ProgressView()
.controlSize(.mini)
}
}
if let error = streamingError {
Text(error)
.font(.system(size: 11))
.foregroundStyle(.red)
.lineLimit(1)
} else {
Text("\(track.artist)\(track.album)")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
.contentShape(Rectangle())
.onTapGesture {
if currentTrack != nil {
onNowPlayingTap()
}
}
}
private var progressTrack: some View {
@ -211,6 +151,15 @@ struct PlayerControlsView: View {
private var volumeSection: some View {
HStack(spacing: 8) {
if showQueueButton {
Button(action: { onToggleQueue?() }) {
Image(systemName: "list.bullet")
.font(.system(size: 13))
.foregroundStyle(isQueueVisible ? .blue : .secondary)
}
.buttonStyle(.plain)
}
Image(systemName: volumeIconName)
.font(.system(size: 12))
.foregroundStyle(.secondary)
@ -242,3 +191,97 @@ struct PlayerControlsView: View {
return "\(mins):\(String(format: "%02d", secs))"
}
}
// The artwork + title/artist label in the control bar, plus its right-click menu.
// Conforms to `Equatable` (via `.equatable()`) so it only re-renders when something
// it actually shows or that the menu depends on changes. `currentTime` and the
// config's closures are intentionally excluded from `==`, so playback ticks don't
// rebuild the open context menu (which previously made the submenu blink).
private struct NowPlayingSection: View, Equatable {
let track: Track?
let isBuffering: Bool
let streamingError: String?
let config: TrackContextMenuConfig?
let onTap: () -> Void
@State private var artworkImage: NSImage?
static func == (lhs: NowPlayingSection, rhs: NowPlayingSection) -> Bool {
lhs.track?.id == rhs.track?.id
&& lhs.isBuffering == rhs.isBuffering
&& lhs.streamingError == rhs.streamingError
// Refresh when the playlist set the menu shows changes, but ignore the
// per-tick churn of `currentTime` and the (incomparable) closures.
&& lhs.config?.playlists.map(\.id) == rhs.config?.playlists.map(\.id)
&& lhs.config?.lastUsedPlaylistName == rhs.config?.lastUsedPlaylistName
&& lhs.config?.selectedPlaylist?.id == rhs.config?.selectedPlaylist?.id
}
var body: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: 44, height: 44)
.overlay {
if let artworkImage {
Image(nsImage: artworkImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
if let track {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(track.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
if isBuffering {
ProgressView()
.controlSize(.mini)
}
}
if let error = streamingError {
Text(error)
.font(.system(size: 11))
.foregroundStyle(.red)
.lineLimit(1)
} else {
Text("\(track.artist)\(track.album)")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
.contentShape(Rectangle())
.onTapGesture {
if track != nil { onTap() }
}
.trackContextMenu(track: track, config: config)
.onChange(of: track?.id) { loadArtwork() }
.onAppear { loadArtwork() }
}
private func loadArtwork() {
guard let urlString = track?.fileURL,
let url = URL(string: urlString) else {
artworkImage = nil
return
}
Task.detached {
let asset = AVURLAsset(url: url)
let metadata = try? await asset.load(.metadata)
let data = try? await metadata?
.first { $0.commonKey == .commonKeyArtwork }?
.load(.dataValue)
let image = data.flatMap { NSImage(data: $0) }
await MainActor.run { artworkImage = image }
}
}
}

@ -11,6 +11,7 @@ struct PlaylistBarView: View {
var onRename: (any PlaylistRepresentable) -> Void
var onDelete: (any PlaylistRepresentable) -> Void
var onEditQuery: (SmartPlaylist) -> Void
var onEditConditions: (SmartPlaylist) -> Void
var body: some View {
FlowLayout(spacing: 6) {
@ -22,13 +23,13 @@ struct PlaylistBarView: View {
action: onHomeSelect
)
ForEach(playlists, id: \.id) { item in
ForEach(playlists, id: \.listIdentity) { item in
PlaylistButton(
name: item.name,
isSelected: selectedItem?.id == item.id,
isSelected: selectedItem?.listIdentity == item.listIdentity,
isSmart: item.isSmartPlaylist,
action: {
if selectedItem?.id == item.id {
if selectedItem?.listIdentity == item.listIdentity {
onDeselect()
} else {
onSelect(item)
@ -39,7 +40,11 @@ struct PlaylistBarView: View {
if !isRemoteMode {
Button("Rename...") { onRename(item) }
if let smart = item as? SmartPlaylist {
Button("Edit Search Query...") { onEditQuery(smart) }
if smart.conditions != nil {
Button("Edit...") { onEditConditions(smart) }
} else {
Button("Edit Search Query...") { onEditQuery(smart) }
}
}
Button("Delete") { onDelete(item) }
}

@ -0,0 +1,67 @@
import SwiftUI
// The right-docked "Up Next" panel. The manual "Queue" section is reorderable and
// removable; the "Next from" section is the read-only upcoming context (double-click
// a row to jump to it).
struct QueueView: View {
var player: PlayerViewModel
var body: some View {
List {
if player.manualQueue.isEmpty && player.upcomingContext.isEmpty {
Text("Queue is empty.\nRight-click a track → Add to Queue.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
.listRowSeparator(.hidden)
}
if !player.manualQueue.isEmpty {
Section("Queue") {
ForEach(player.manualQueue) { entry in
HStack(spacing: 8) {
trackRow(entry.track)
Spacer()
Button {
if let idx = player.manualQueue.firstIndex(where: { $0.id == entry.id }) {
player.removeFromQueue(at: IndexSet(integer: idx))
}
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
}
.onMove(perform: player.moveInQueue)
}
}
if !player.upcomingContext.isEmpty {
Section("Next from: \(player.contextName ?? "Library")") {
ForEach(Array(player.upcomingContext.enumerated()), id: \.offset) { _, track in
trackRow(track)
.contentShape(Rectangle())
.onTapGesture(count: 2) { player.play(track) }
}
}
}
}
.listStyle(.inset)
.frame(width: 280)
}
private func trackRow(_ track: Track) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
Text(track.artist)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}

@ -0,0 +1,149 @@
import SwiftUI
struct SmartPlaylistBuilderSheet: View {
var editingPlaylist: SmartPlaylist?
var onSave: (String, [SmartPlaylistCondition]) -> Void
var onCancel: () -> Void
@State private var name: String
@State private var conditions: [SmartPlaylistCondition]
init(
editingPlaylist: SmartPlaylist? = nil,
onSave: @escaping (String, [SmartPlaylistCondition]) -> Void,
onCancel: @escaping () -> Void
) {
self.editingPlaylist = editingPlaylist
self.onSave = onSave
self.onCancel = onCancel
let defaultCondition = SmartPlaylistCondition(field: .artist, op: .equals, value: .string(""))
_name = State(initialValue: editingPlaylist?.name ?? "")
_conditions = State(initialValue: editingPlaylist?.conditions ?? [defaultCondition])
}
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespaces).isEmpty &&
conditions.allSatisfy { !$0.isEmpty }
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(editingPlaylist == nil ? "New Smart Playlist" : "Edit Smart Playlist")
.font(.headline)
VStack(alignment: .leading, spacing: 4) {
Text("Name")
.font(.caption)
.foregroundStyle(.secondary)
TextField("Playlist name", text: $name)
.textFieldStyle(.roundedBorder)
}
VStack(alignment: .leading, spacing: 6) {
Text("Conditions (all must match)")
.font(.caption)
.foregroundStyle(.secondary)
ForEach(conditions.indices, id: \.self) { index in
ConditionRowView(
condition: $conditions[index],
canRemove: conditions.count > 1,
onRemove: { conditions.remove(at: index) }
)
}
Button("+ Add Condition") {
conditions.append(SmartPlaylistCondition(field: .artist, op: .equals, value: .string("")))
}
.buttonStyle(.plain)
.foregroundStyle(Color.accentColor)
.font(.system(size: 12))
}
Divider()
HStack {
Spacer()
Button("Cancel", action: onCancel)
Button("Save") {
onSave(name.trimmingCharacters(in: .whitespaces), conditions)
}
.disabled(!canSave)
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: 540)
}
}
private struct ConditionRowView: View {
@Binding var condition: SmartPlaylistCondition
var canRemove: Bool
var onRemove: () -> Void
var body: some View {
HStack(spacing: 8) {
Picker("", selection: $condition.field) {
ForEach(TrackField.allCases) { field in
Text(field.displayName).tag(field)
}
}
.labelsHidden()
.frame(maxWidth: 130)
.onChange(of: condition.field) { _, newField in
condition.op = newField.validOperators[0]
condition.value = newField.defaultValue
}
Picker("", selection: $condition.op) {
ForEach(condition.field.validOperators) { op in
Text(op.displayName).tag(op)
}
}
.labelsHidden()
.frame(maxWidth: 130)
valueField
Button(action: onRemove) {
Image(systemName: "minus.circle.fill")
.foregroundStyle(Color.secondary.opacity(canRemove ? 1.0 : 0.3))
}
.buttonStyle(.plain)
.disabled(!canRemove)
}
}
@ViewBuilder
private var valueField: some View {
switch condition.field.fieldType {
case .string:
TextField("Value", text: Binding(
get: { if case .string(let s) = condition.value { return s } else { return "" } },
set: { condition.value = .string($0) }
))
.textFieldStyle(.roundedBorder)
case .int:
TextField("Value", text: Binding(
get: { if case .int(let i) = condition.value { return String(i) } else { return "0" } },
set: { condition.value = .int(Int($0) ?? 0) }
))
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 100)
case .double:
TextField("Value", text: Binding(
get: { if case .double(let d) = condition.value { return String(d) } else { return "0" } },
set: { condition.value = .double(Double($0) ?? 0) }
))
.textFieldStyle(.roundedBorder)
.frame(maxWidth: 100)
case .date:
DatePicker("", selection: Binding(
get: { if case .date(let d) = condition.value { return d } else { return Date() } },
set: { condition.value = .date($0) }
), displayedComponents: .date)
.labelsHidden()
}
}
}

@ -0,0 +1,44 @@
import SwiftUI
// Attaches a context menu matching the track table's right-click menu.
// No-ops silently when track or config is nil so callers can pass optionals freely.
// The menu is rendered from `config.entries(...)` the SAME source of truth the
// AppKit table menu uses so the two menus can never drift.
struct TrackContextMenuModifier: ViewModifier {
let track: Track?
let config: TrackContextMenuConfig?
func body(content: Content) -> some View {
content.contextMenu {
if let track, let config {
// No multi-selection in the control bar, so the target set is just
// the now-playing track.
TrackMenuEntryList(entries: config.entries(primary: track, selection: [track]))
}
}
}
}
// Recursively renders `[TrackMenuEntry]` as SwiftUI menu content.
struct TrackMenuEntryList: View {
let entries: [TrackMenuEntry]
var body: some View {
ForEach(Array(entries.enumerated()), id: \.offset) { _, entry in
switch entry {
case .separator:
Divider()
case .button(let title, let action):
Button(title, action: action)
case .submenu(let title, let items):
Menu(title) { TrackMenuEntryList(entries: items) }
}
}
}
}
extension View {
func trackContextMenu(track: Track?, config: TrackContextMenuConfig?) -> some View {
modifier(TrackContextMenuModifier(track: track, config: config))
}
}

@ -0,0 +1,156 @@
import SwiftUI
// Get Info dialog. Edits one or many tracks. For multi-edit, fields that differ
// across tracks show a "Mixed" placeholder and only fields the user touches are
// applied. onSave hands back the edited values + the set of edited fields.
struct TrackInfoSheet: View {
let tracks: [Track]
var onSave: (EditableTrackFields, Set<EditableTrackField>) -> Void
var onCancel: () -> Void
@State private var fields: EditableTrackFields
@State private var mixed: Set<EditableTrackField>
@State private var edited: Set<EditableTrackField> = []
@State private var tab = 0
init(tracks: [Track],
onSave: @escaping (EditableTrackFields, Set<EditableTrackField>) -> Void,
onCancel: @escaping () -> Void) {
self.tracks = tracks
self.onSave = onSave
self.onCancel = onCancel
let (values, mixed) = EditableTrackFields.shared(across: tracks)
_fields = State(initialValue: values)
_mixed = State(initialValue: mixed)
}
private var isMulti: Bool { tracks.count > 1 }
private var hasUnsupported: Bool {
tracks.contains { t in
["flac", "wav", "aiff"].contains((URL(string: t.fileURL)?.pathExtension ?? "").lowercased())
}
}
var body: some View {
VStack(alignment: .leading, spacing: 16) {
Text(isMulti ? "Get Info — \(tracks.count) tracks" : "Get Info")
.font(.headline)
if hasUnsupported {
Text("Edits save to your library only — tag writing isn't supported for some selected formats yet.")
.font(.caption).foregroundStyle(.secondary)
}
Picker("", selection: $tab) {
Text("Details").tag(0)
if !isMulti { Text("File").tag(1) }
}
.pickerStyle(.segmented)
.labelsHidden()
if tab == 0 { detailsTab } else { fileTab }
Divider()
HStack {
Spacer()
Button("Cancel", action: onCancel)
Button("Save") { onSave(fields, edited) }
.keyboardShortcut(.defaultAction)
}
}
.padding(20)
.frame(width: 460)
}
// Binding helper that marks a field edited whenever it changes.
private func text(_ field: EditableTrackField, _ keyPath: WritableKeyPath<EditableTrackFields, String>) -> Binding<String> {
Binding(
get: { mixed.contains(field) && !edited.contains(field) ? "" : fields[keyPath: keyPath] },
set: { fields[keyPath: keyPath] = $0; edited.insert(field) }
)
}
private func int(_ field: EditableTrackField, _ keyPath: WritableKeyPath<EditableTrackFields, Int?>) -> Binding<String> {
Binding(
get: { mixed.contains(field) && !edited.contains(field) ? "" : (fields[keyPath: keyPath].map(String.init) ?? "") },
set: { fields[keyPath: keyPath] = Int($0.filter(\.isNumber)); edited.insert(field) }
)
}
private func placeholder(_ field: EditableTrackField) -> String {
mixed.contains(field) && !edited.contains(field) ? "Mixed" : ""
}
private var detailsTab: some View {
detailsTextFields
}
@ViewBuilder private var detailsTextFields: some View {
VStack(alignment: .leading, spacing: 8) {
labeled("Title") { TextField(placeholder(.title), text: text(.title, \.title)) }
labeled("Artist") { TextField(placeholder(.artist), text: text(.artist, \.artist)) }
labeled("Album Artist") { TextField(placeholder(.albumArtist), text: text(.albumArtist, \.albumArtist)) }
labeled("Album") { TextField(placeholder(.album), text: text(.album, \.album)) }
labeled("Genre") { TextField(placeholder(.genre), text: text(.genre, \.genre)) }
labeled("Composer") { TextField(placeholder(.composer), text: text(.composer, \.composer)) }
detailsNumericRow
labeled("Rating") {
Stepper(value: Binding(
get: { fields.rating },
set: { fields.rating = max(0, min(5, $0)); edited.insert(.rating) }
), in: 0...5) { Text(String(repeating: "", count: fields.rating)) }
}
detailsDateRow
}
.textFieldStyle(.roundedBorder)
}
// Date Added is library-managed (not a file tag); editing it is DB-only.
// For multi-select, the label shows "(Mixed)" until the user touches it.
@ViewBuilder private var detailsDateRow: some View {
let isMixed = mixed.contains(.dateAdded) && !edited.contains(.dateAdded)
labeled(isMixed ? "Date Added (Mixed)" : "Date Added") {
DatePicker("", selection: Binding(
get: { fields.dateAdded },
set: { fields.dateAdded = $0; edited.insert(.dateAdded) }
), displayedComponents: [.date])
.labelsHidden()
}
}
@ViewBuilder private var detailsNumericRow: some View {
HStack(spacing: 12) {
labeled("Year") { TextField(placeholder(.year), text: int(.year, \.year)).frame(width: 70) }
labeled("Track") { TextField(placeholder(.trackNumber), text: int(.trackNumber, \.trackNumber)).frame(width: 50) }
labeled("Disc") { TextField(placeholder(.discNumber), text: int(.discNumber, \.discNumber)).frame(width: 50) }
labeled("BPM") { TextField(placeholder(.bpm), text: int(.bpm, \.bpm)).frame(width: 60) }
}
}
@ViewBuilder private var fileTab: some View {
if let t = tracks.first {
VStack(alignment: .leading, spacing: 6) {
row("Kind", t.fileFormat.uppercased())
row("Bit Rate", t.bitrate.map { "\($0) kbps" } ?? "")
row("Sample Rate", t.sampleRate.map { "\($0) Hz" } ?? "")
row("Size", ByteCountFormatter.string(fromByteCount: t.fileSize, countStyle: .file))
row("Duration", String(format: "%d:%02d", Int(t.duration) / 60, Int(t.duration) % 60))
row("Plays", "\(t.playCount)")
row("Added", t.dateAdded.formatted(date: .abbreviated, time: .omitted))
row("Where", URL(string: t.fileURL)?.path ?? t.fileURL)
}
.font(.system(size: 12))
}
}
private func labeled<C: View>(_ title: String, @ViewBuilder _ content: () -> C) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(title).font(.caption).foregroundStyle(.secondary)
content()
}
}
private func row(_ k: String, _ v: String) -> some View {
HStack(alignment: .top) {
Text(k).foregroundStyle(.secondary).frame(width: 90, alignment: .leading)
Text(v).textSelection(.enabled)
}
}
}

@ -42,12 +42,7 @@ struct TrackTableView: NSViewRepresentable {
let sortAscending: Bool
let onSort: (String) -> Void
let onDoubleClick: (Track) -> Void
var playlists: [Playlist]
var lastUsedPlaylistName: String?
var selectedPlaylist: Playlist?
var onAddToPlaylist: ((Track, Playlist) -> Void)?
var onAddToLastPlaylist: ((Track) -> Void)?
var onRemoveFromPlaylist: ((Track) -> Void)?
var contextMenuConfig: TrackContextMenuConfig?
var onReorder: ((Int, Int) -> Void)?
var scrollToPlayingTrigger: UUID = UUID()
@ -59,7 +54,7 @@ struct TrackTableView: NSViewRepresentable {
let tableView = PlayableTableView()
tableView.style = .plain
tableView.usesAlternatingRowBackgroundColors = true
tableView.allowsMultipleSelection = false
tableView.allowsMultipleSelection = true
tableView.rowHeight = 24
tableView.intercellSpacing = NSSize(width: 10, height: 0)
@ -183,6 +178,9 @@ struct TrackTableView: NSViewRepresentable {
var playingTrackId: Int64?
var lastScrollTrigger: UUID = UUID()
weak var tableView: NSTableView?
// NSMenuItem.target is weak, so we retain the per-build closure wrappers
// here for the lifetime of the open menu. Rebuilt on each menuNeedsUpdate.
private var menuActionTargets: [MenuActionTarget] = []
init(_ parent: TrackTableView) {
self.parent = parent
@ -332,65 +330,41 @@ struct TrackTableView: NSViewRepresentable {
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
menuActionTargets.removeAll()
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
if let lastPlaylistName = parent.lastUsedPlaylistName, parent.onAddToLastPlaylist != nil {
let lastItem = NSMenuItem(
title: "Add to \(lastPlaylistName)",
action: #selector(addToLastPlaylist(_:)),
keyEquivalent: ""
)
lastItem.target = self
menu.addItem(lastItem)
menu.addItem(.separator())
}
if !parent.playlists.isEmpty {
let submenu = NSMenu()
for (index, playlist) in parent.playlists.enumerated() {
let item = NSMenuItem(
title: playlist.name,
action: #selector(addToPlaylist(_:)),
keyEquivalent: ""
)
item.target = self
item.tag = index
submenu.addItem(item)
}
let submenuItem = NSMenuItem(title: "Add to Playlist", action: nil, keyEquivalent: "")
submenuItem.submenu = submenu
menu.addItem(submenuItem)
}
if parent.selectedPlaylist != nil, parent.onRemoveFromPlaylist != nil {
menu.addItem(.separator())
let removeItem = NSMenuItem(
title: "Remove from Playlist",
action: #selector(removeFromPlaylist(_:)),
keyEquivalent: ""
)
removeItem.target = self
menu.addItem(removeItem)
}
}
@objc func addToPlaylist(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
let track = tracks[tableView.clickedRow]
let playlist = parent.playlists[sender.tag]
parent.onAddToPlaylist?(track, playlist)
}
let clicked = tracks[tableView.clickedRow]
// macOS Music behavior: multi-capable actions operate on the full
// selection if the right-clicked row is part of it; otherwise just the
// clicked row.
let selection: [Track] = tableView.selectedRowIndexes.contains(tableView.clickedRow)
? tableView.selectedRowIndexes.sorted().compactMap { $0 < tracks.count ? tracks[$0] : nil }
: [clicked]
@objc func addToLastPlaylist(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
let track = tracks[tableView.clickedRow]
parent.onAddToLastPlaylist?(track)
populate(menu, with: config.entries(primary: clicked, selection: selection))
}
@objc func removeFromPlaylist(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
let track = tracks[tableView.clickedRow]
parent.onRemoveFromPlaylist?(track)
// Renders `[TrackMenuEntry]` (the shared menu model) into an NSMenu.
private func populate(_ menu: NSMenu, with entries: [TrackMenuEntry]) {
for entry in entries {
switch entry {
case .separator:
menu.addItem(.separator())
case .button(let title, let action):
let target = MenuActionTarget(action)
menuActionTargets.append(target)
let item = NSMenuItem(title: title, action: #selector(MenuActionTarget.invoke), keyEquivalent: "")
item.target = target
menu.addItem(item)
case .submenu(let title, let items):
let item = NSMenuItem(title: title, action: nil, keyEquivalent: "")
let submenu = NSMenu()
populate(submenu, with: items)
item.submenu = submenu
menu.addItem(item)
}
}
}
// MARK: - Drag and Drop
@ -466,3 +440,11 @@ private final class PlayableTableView: NSTableView {
}
}
}
// Bridges a Swift closure to an NSMenuItem action/target so the AppKit row menu can
// be built from the same closure-based TrackMenuEntry model as the SwiftUI menu.
private final class MenuActionTarget: NSObject {
private let perform: () -> Void
init(_ perform: @escaping () -> Void) { self.perform = perform }
@objc func invoke() { perform() }
}

@ -0,0 +1,42 @@
import Testing
import Foundation
import GRDB
@testable import Music
@MainActor
struct DBBackupFTS5Tests {
// Pins down the root cause: does DatabaseService.backup() produce a copy whose
// FTS5 `tracks_ft` table is functional? GRDB's ValueObservation introspects the
// whole schema on start, and that introspection throws "no such table: tracks_ft"
// if the FTS5 shadow tables didn't survive the copy.
@Test
func backupCopyHasFunctionalFTS5Table() throws {
// 1. Build a source DB (DatabasePool/WAL, like the running app) with 3 tracks.
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let srcPath = tempDir.appendingPathComponent("src.sqlite").path
let src = try DatabaseService(path: srcPath)
for i in 1...3 {
var t = Track.fixture(fileURL: "/s\(i).mp3", title: "Song \(i)")
try src.insert(&t)
}
// 2. Copy the DB exactly as the host serves it today.
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path
try src.backup(to: copyPath)
// 3. Open the copy and run the same schema-introspection query GRDB runs when
// a ValueObservation starts. If FTS5 didn't transfer, this throws.
let copy = try DatabaseService(path: copyPath)
try copy.dbPool.read { db in
_ = try Row.fetchAll(
db,
sql: "SELECT rootpage, sql FROM sqlite_master WHERE (type = 'table' OR type = 'view')"
)
// And an actual FTS5 query must work.
_ = try Row.fetchAll(db, sql: "SELECT * FROM tracks_ft LIMIT 1")
}
}
}

@ -31,6 +31,31 @@ struct DatabaseServiceTests {
#expect(descending[0].artist == "Zebra")
}
// Verifies that sorting by album orders tracks within an album by disc then track
// number ascending and that this secondary order stays ascending even when the
// album sort direction is descending (so an album always reads in playing order).
@Test func fetchTracksByAlbumOrdersByDiscAndTrackNumber() throws {
// 1. Insert one album's tracks out of order, spanning two discs, so only a
// secondary disc/track sort can restore playing order.
let db = try DatabaseService(inMemory: true)
let fixtures = [
Track.fixture(fileURL: "/3.mp3", title: "C", album: "Greatest Hits", trackNumber: 3, discNumber: 1),
Track.fixture(fileURL: "/1.mp3", title: "A", album: "Greatest Hits", trackNumber: 1, discNumber: 1),
Track.fixture(fileURL: "/4.mp3", title: "D", album: "Greatest Hits", trackNumber: 1, discNumber: 2),
Track.fixture(fileURL: "/2.mp3", title: "B", album: "Greatest Hits", trackNumber: 2, discNumber: 1),
]
for var t in fixtures { try db.insert(&t) }
// 2. Sort by album ascending disc1/1, disc1/2, disc1/3, disc2/1.
let asc = try db.fetchTracks(search: "", sortColumn: "album", ascending: true)
#expect(asc.map(\.title) == ["A", "B", "C", "D"])
// 3. Sort by album descending same within-album order, because the disc/track
// secondary sort is always ascending regardless of the album direction.
let desc = try db.fetchTracks(search: "", sortColumn: "album", ascending: false)
#expect(desc.map(\.title) == ["A", "B", "C", "D"])
}
// Searches using FTS5 and verifies only matching tracks are returned.
@Test func fts5Search() throws {
let db = try DatabaseService(inMemory: true)
@ -284,6 +309,25 @@ struct DatabaseServiceTests {
#expect(result[2].id == tracks[4].id)
}
// Verifies updateTrack persists edited fields and that the tracks_ft index
// stays in sync (the synchronize-installed triggers fire on UPDATE).
@Test func updateTrackPersistsFieldsAndSyncsFTS() throws {
// Step 1: insert a track.
let db = try DatabaseService(inMemory: true)
var t = Track.fixture(title: "Original Title", artist: "X")
try db.insert(&t)
// Step 2: edit fields and update.
t.title = "Renamed Title"; t.album = "New Album"
try db.updateTrack(t)
// Step 3: re-fetch and assert persisted.
let fetched = try #require(db.fetchTracksByIds([t.id!]).first)
#expect(fetched.title == "Renamed Title")
#expect(fetched.album == "New Album")
// Step 4: FTS reflects the new title and not the old (triggers keep it synced).
#expect(try db.fetchTracks(search: "Renamed", sortColumn: "title", ascending: true).count == 1)
#expect(try db.fetchTracks(search: "Original", sortColumn: "title", ascending: true).count == 0)
}
// Inserts tracks in different months and verifies fetchMonthlyAdditions returns
// the correct per-month counts covering the requested range including empty months.
// Uses a UTC calendar to match the implementation, which uses UTC month boundaries

@ -0,0 +1,82 @@
import Foundation
import Testing
@testable import Music
// Verifies the pure single/multi-track edit logic: extraction, change detection,
// shared-vs-mixed across many tracks, and applying only edited fields.
struct EditableTrackFieldsTests {
@Test func initCopiesEditableValues() {
// Step 1: build fields from a fixture track.
let t = Track.fixture(title: "A", artist: "B", album: "C", year: 2001, rating: 3)
let f = EditableTrackFields(from: t)
// Step 2: editable values match.
#expect(f.title == "A"); #expect(f.artist == "B")
#expect(f.album == "C"); #expect(f.year == 2001); #expect(f.rating == 3)
}
@Test func changedFieldsDetectsOnlyDifferences() {
// Step 1: two field sets differing only in genre + bpm.
let a = EditableTrackFields(from: .fixture(genre: "Rock", bpm: 120))
var b = a; b.genre = "Jazz"; b.bpm = 90
// Step 2: change set is exactly {genre, bpm}.
#expect(a.changedFields(to: b) == [.genre, .bpm])
}
@Test func sharedMarksDifferingFieldsMixed() {
// Step 1: two tracks share artist but differ in genre.
let t1 = Track.fixture(artist: "Same", genre: "Rock")
let t2 = Track.fixture(artist: "Same", genre: "Pop")
// Step 2: shared() returns common artist and flags genre as mixed.
let (values, mixed) = EditableTrackFields.shared(across: [t1, t2])
#expect(values.artist == "Same")
#expect(mixed.contains(.genre))
#expect(!mixed.contains(.artist))
}
@Test func sharedAcrossThreeTracksAccumulatesMixed() {
// Step 1: three tracks all share the same album, but title differs on the
// third track and genre differs on the second so both title and
// genre must end up "mixed", while album stays shared.
let t1 = Track.fixture(title: "Same", album: "One Album", genre: "Rock")
let t2 = Track.fixture(title: "Same", album: "One Album", genre: "Pop")
let t3 = Track.fixture(title: "Different", album: "One Album", genre: "Rock")
// Step 2: shared() over all three.
let (values, mixed) = EditableTrackFields.shared(across: [t1, t2, t3])
// Step 3: album is shared (not mixed); title + genre are mixed.
#expect(values.album == "One Album")
#expect(!mixed.contains(.album))
#expect(mixed.contains(.title))
#expect(mixed.contains(.genre))
}
@Test func applyOnlyWritesEditedFields() {
// Step 1: a track and a fields object that changes album only.
let t = Track.fixture(album: "Old", genre: "Rock")
var f = EditableTrackFields(from: t); f.album = "New"; f.genre = "IGNORED"
// Step 2: applying with editing={.album} changes album, leaves genre.
let out = f.apply(editing: [.album], to: t)
#expect(out.album == "New")
#expect(out.genre == "Rock")
}
@Test func applyEmptyEditSetReturnsUnchanged() {
let t = Track.fixture(title: "Keep")
let f = EditableTrackFields(from: t)
#expect(f.apply(editing: [], to: t) == t)
}
@Test func dateAddedIsEditable() {
// Step 1: a track with a known dateAdded; init copies it.
let original = Date(timeIntervalSince1970: 1_000_000)
let t = Track.fixture(dateAdded: original)
var f = EditableTrackFields(from: t)
#expect(f.dateAdded == original)
// Step 2: changing dateAdded is detected as the only changed field.
let newDate = Date(timeIntervalSince1970: 2_000_000)
f.dateAdded = newDate
#expect(EditableTrackFields(from: t).changedFields(to: f) == [.dateAdded])
// Step 3: applying with editing={.dateAdded} writes the new date onto the track.
let out = f.apply(editing: [.dateAdded], to: t)
#expect(out.dateAdded == newDate)
}
}

Binary file not shown.

Binary file not shown.

@ -103,6 +103,114 @@ struct PlayerViewModelTests {
#expect(vm.queue.map { $0.id } == tracks.map { $0.id })
}
// Step 1: context [1,2,3], track 1 playing.
// Step 2: addToQueue twice manual queue holds those tracks in arrival order.
// Step 3: playNext jumps a track to the FRONT of the manual queue.
@Test func addToQueueAppendsAndPlayNextInsertsFront() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[3]) // id 4
vm.addToQueue(tracks[4]) // id 5
#expect(vm.manualQueue.map { $0.track.id } == [4, 5])
vm.playNext(tracks[5]) // id 6 to the front
#expect(vm.manualQueue.map { $0.track.id } == [6, 4, 5])
}
// Step 1: a view model with nothing playing (idle).
// Step 2: addToQueue should start playback immediately (queue-while-idle) and
// leave the manual queue empty because the track was consumed to play.
@Test func queueWhileIdleStartsPlayback() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let track = Track.fixture(id: 1, fileURL: "/a.mp3", title: "A")
vm.addToQueue(track)
#expect(vm.currentTrack?.id == 1)
#expect(vm.manualQueue.isEmpty)
}
// Step 1: context [1,2,3], track 1 playing; two tracks added to manual queue.
// Step 2: next() is called must consume manualQueue before advancing context.
// Step 3: next() again drains second manual-queue entry.
// Step 4: next() with empty manualQueue falls through to context track 2.
@Test func nextDrainsManualQueueBeforeContext() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0]) // context index 0 (id 1)
vm.addToQueue(tracks[3]) // id 4
vm.addToQueue(tracks[4]) // id 5
vm.next()
#expect(vm.currentTrack?.id == 4)
#expect(vm.manualQueue.map { $0.track.id } == [5])
vm.next()
#expect(vm.currentTrack?.id == 5)
#expect(vm.manualQueue.isEmpty)
vm.next() // manual queue empty resume context at index 1
#expect(vm.currentTrack?.id == 2)
#expect(vm.currentIndex == 1)
}
// Step 1: context [1,2,3], track 1 playing; queue tracks 4,5,6.
// Step 2: removeFromQueue removes the middle entry [4,6].
// Step 3: moveInQueue moves the last entry to the front [6,4].
@Test func removeAndMoveMutateManualQueue() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[3]); vm.addToQueue(tracks[4]); vm.addToQueue(tracks[5])
#expect(vm.manualQueue.map { $0.track.id } == [4, 5, 6])
vm.removeFromQueue(at: IndexSet(integer: 1))
#expect(vm.manualQueue.map { $0.track.id } == [4, 6])
vm.moveInQueue(from: IndexSet(integer: 1), to: 0)
#expect(vm.manualQueue.map { $0.track.id } == [6, 4])
}
// Step 1: context [1,2,3,4], track 2 playing (currentIndex 1).
// Step 2: upcomingContext is the slice after the current position [3,4].
@Test func upcomingContextReturnsTracksAfterCurrent() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(4)
vm.setQueue(tracks)
vm.play(tracks[1])
#expect(vm.upcomingContext.map { $0.id } == [3, 4])
}
// Step 1: 10-track context, one playing; queue tracks 11 and 12 in order.
// Step 2: toggling shuffle reorders only the context the manual queue order
// must be left exactly as the user arranged it.
@Test func shuffleLeavesManualQueueIntact() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(12)
vm.setQueue(Array(tracks[0..<10]))
vm.play(tracks[0])
vm.addToQueue(tracks[10]) // id 11
vm.addToQueue(tracks[11]) // id 12
vm.toggleShuffle()
#expect(vm.manualQueue.map { $0.track.id } == [11, 12])
}
// Step 1: setQueue accepts an optional context label for the panel header.
@Test func setQueueStoresContextName() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
vm.setQueue(makeTracks(2), contextName: "Synthwave")
#expect(vm.contextName == "Synthwave")
}
// Reproduces the streaming "0:00" bug: when the player cannot determine a
// track's total duration (as with a progressive HTTP MP3 stream), the view
// model must fall back to the duration already known from the library database.

@ -0,0 +1,54 @@
import Foundation
import Testing
@testable import Music
// Reproduces the playlist-bar duplication bug.
//
// Regular playlists and smart playlists live in two separate SQLite tables, each
// with its own `autoIncrementedPrimaryKey("id")`. The two id sequences are
// independent, so a regular playlist and a smart playlist routinely share the
// same `id` value (both start at 1, 2, 3, ...). PlaylistViewModel.allPlaylists
// merges the two kinds into one [any PlaylistRepresentable] collection, and
// PlaylistBarView's `ForEach(playlists, id: \.id)` keyed off the bare `id`.
//
// When two items share an id, SwiftUI collapses them into a single identity:
// it renders one row twice and ties both buttons to the same view, so selecting
// or updating one leaks to the other (the reported "shown twice / name changes
// on both buttons" symptom). The fix is a type-disambiguated `listIdentity` that
// stays unique across the merged collection.
struct PlaylistBarIdentityTests {
// Step 1: build a regular playlist and a smart playlist that share id == 1,
// exactly as the two independent autoincrement tables would produce.
// Step 2: collect the identities the playlist bar uses to key its ForEach.
// Step 3: assert the two identities are distinct, so SwiftUI keeps two rows.
@Test func regularAndSmartPlaylistWithSameIdHaveDistinctListIdentity() {
let regular = Playlist.fixture(id: 1, name: "Rock")
let smart = SmartPlaylist.fixture(id: 1, name: "Recently Added")
let items: [any PlaylistRepresentable] = [regular, smart]
let identities = items.map(\.listIdentity)
#expect(Set(identities).count == items.count)
}
// Step 1: build the merged collection the way allPlaylists does, with regular
// and smart playlists whose ids overlap across the two tables.
// Step 2: map every item to its list identity.
// Step 3: assert all identities are unique across the whole collection the
// invariant SwiftUI ForEach needs to avoid duplicate/leaking rows.
@Test func mergedPlaylistsHaveUniqueListIdentities() {
let regulars: [any PlaylistRepresentable] = [
Playlist.fixture(id: 1, name: "Rock"),
Playlist.fixture(id: 2, name: "Jazz"),
]
let smarts: [any PlaylistRepresentable] = [
SmartPlaylist.fixture(id: 1, name: "Recently Added"),
SmartPlaylist.fixture(id: 2, name: "Top Rated"),
]
let all = regulars + smarts
let identities = all.map(\.listIdentity)
#expect(Set(identities).count == all.count)
}
}

@ -0,0 +1,36 @@
import Testing
import Foundation
@testable import Music
@MainActor
struct PlaylistViewModelTests {
// Verifies createPlaylistAndAddTrack does the full job in one call:
// 1. Seed a track into an in-memory DB and build a PlaylistViewModel over it.
// 2. Call createPlaylistAndAddTrack with a name and the seeded track.
// 3. The returned playlist has the given name and a real (non-nil) id.
// 4. The DB shows that playlist now contains exactly the seeded track.
// 5. The new playlist is recorded as the last-used playlist.
@Test func createPlaylistAndAddTrackCreatesPlaylistAndAddsTrack() throws {
// 1. Seed a track and build the view model.
let db = try DatabaseService(inMemory: true)
var track = Track.fixture(fileURL: "/song.mp3", title: "Song A")
try db.insert(&track)
let vm = PlaylistViewModel(db: db)
// 2. Create a new playlist and add the track in one step.
let created = try vm.createPlaylistAndAddTrack(name: "Road Trip", track: track)
// 3. The returned playlist is well-formed (a real id was assigned, name matches).
let createdId = try #require(created.id)
#expect(created.name == "Road Trip")
// 4. The playlist contains exactly the seeded track.
let tracks = try db.fetchPlaylistTracks(playlistId: createdId)
#expect(tracks.count == 1)
#expect(tracks[0].id == track.id)
// 5. The new playlist became the last-used playlist.
#expect(vm.lastUsedPlaylistId == createdId)
}
}

@ -0,0 +1,123 @@
import Testing
import Foundation
import GRDB
@testable import Music
/// Tests for the remote-DB transfer integrity guard. The bug under test: connecting to
/// a remote host downloaded a database that opened with "database disk image is malformed"
/// (SQLITE_CORRUPT) on `SELECT * FROM sqlite_master`. The fix validates the database with
/// `PRAGMA quick_check` on both ends (host before serving, client after writing) so a
/// malformed image is rejected with a clear error instead of crashing the UI.
@MainActor
struct RemoteDBIntegrityTests {
/// Build a real DatabaseService (DatabasePool/WAL, like the running app) at a fresh
/// temp path, populated with `count` tracks, and return (tempDir, dbPath).
private func makePopulatedDB(count: Int) throws -> (dir: URL, path: String) {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
let path = tempDir.appendingPathComponent("src.sqlite").path
let db = try DatabaseService(path: path)
for i in 1...count {
var t = Track.fixture(fileURL: "/track\(i).mp3", title: "Song number \(i) with several words")
try db.insert(&t)
}
return (tempDir, path)
}
@Test
func backupCopyIsWellFormedAndSurvivesHTTPFraming() throws {
// 1. Build a source DB large enough to span many pages, like the real ~5.8MB library.
let (tempDir, srcPath) = try makePopulatedDB(count: 2000)
defer { try? FileManager.default.removeItem(at: tempDir) }
// 2. Produce the served copy exactly as the host does (VACUUM INTO via backup()).
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path
try DatabaseService(path: srcPath).backup(to: copyPath)
// 3. The copy must pass the integrity gate the host now applies before serving.
#expect(DatabaseService.isWellFormedDatabase(atPath: copyPath))
// 4. Frame the copy into an HTTP response byte-for-byte like HostServer.sendHTTP,
// then parse it back like RemoteClient.handleDBData (split on the first \r\n\r\n).
let bodyBytes = try Data(contentsOf: URL(fileURLWithPath: copyPath))
var response = Data("HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\nContent-Length: \(bodyBytes.count)\r\nConnection: close\r\n\r\n".utf8)
response.append(bodyBytes)
let separator: [UInt8] = [0x0D, 0x0A, 0x0D, 0x0A]
let sepRange = try #require(response.range(of: Data(separator)))
let parsedBody = Data(response[sepRange.upperBound...])
// 5. The framed-then-parsed body must be byte-identical to the original copy.
#expect(parsedBody == bodyBytes)
// 6. Writing it out yields a DB that passes the gate, opens cleanly, and has all rows.
let recvPath = tempDir.appendingPathComponent("received.sqlite").path
try parsedBody.write(to: URL(fileURLWithPath: recvPath))
#expect(DatabaseService.isWellFormedDatabase(atPath: recvPath))
let reopened = try DatabaseService(path: recvPath)
let n = try reopened.dbPool.read { db in try Int.fetchOne(db, sql: "SELECT count(*) FROM tracks") }
#expect(n == 2000)
}
@Test
func truncatedDownloadIsRejected() throws {
// Reproduces the user's exact symptom: a page-aligned but incomplete SQLite image.
// 1. Build a valid served copy.
let (tempDir, srcPath) = try makePopulatedDB(count: 2000)
defer { try? FileManager.default.removeItem(at: tempDir) }
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path
try DatabaseService(path: srcPath).backup(to: copyPath)
let full = try Data(contentsOf: URL(fileURLWithPath: copyPath))
// 2. Keep the first half still a whole number of 4096-byte pages with a valid
// SQLite header, exactly the shape of the user's "5828608 bytes" malformed file.
let truncatedLen = (full.count / 2 / 4096) * 4096
let truncated = Data(full.prefix(truncatedLen))
let truncPath = tempDir.appendingPathComponent("truncated.sqlite").path
try truncated.write(to: URL(fileURLWithPath: truncPath))
// 3. The gate must reject it. Before the fix the client handed this straight to
// GRDB, which crashed with "database disk image is malformed".
#expect(DatabaseService.isWellFormedDatabase(atPath: truncPath) == false)
}
@Test
func corruptInteriorBytesAreRejected() throws {
// 1. Build a valid served copy.
let (tempDir, srcPath) = try makePopulatedDB(count: 2000)
defer { try? FileManager.default.removeItem(at: tempDir) }
let copyPath = tempDir.appendingPathComponent("copy.sqlite").path
try DatabaseService(path: srcPath).backup(to: copyPath)
var bytes = try Data(contentsOf: URL(fileURLWithPath: copyPath))
// 2. Leave the SQLite header intact (so it's still recognized as a database this
// yields SQLITE_CORRUPT/error 11, like the user saw, not "not a database"/error 26)
// but smash a stretch of an interior b-tree page.
for i in 4096..<4600 { bytes[i] = 0xFF }
let corruptPath = tempDir.appendingPathComponent("corrupt.sqlite").path
try bytes.write(to: URL(fileURLWithPath: corruptPath))
// 3. The gate must reject the malformed image.
#expect(DatabaseService.isWellFormedDatabase(atPath: corruptPath) == false)
}
@Test
func emptyMissingAndGarbageFilesAreRejected() throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
// 1. A missing file is not well-formed.
#expect(DatabaseService.isWellFormedDatabase(atPath: tempDir.appendingPathComponent("nope.sqlite").path) == false)
// 2. An empty file (0 bytes) is not well-formed.
let emptyPath = tempDir.appendingPathComponent("empty.sqlite").path
try Data().write(to: URL(fileURLWithPath: emptyPath))
#expect(DatabaseService.isWellFormedDatabase(atPath: emptyPath) == false)
// 3. A short text body (e.g. an HTTP error message written as if it were a DB) is rejected.
let garbagePath = tempDir.appendingPathComponent("garbage.sqlite").path
try Data("Failed to read database".utf8).write(to: URL(fileURLWithPath: garbagePath))
#expect(DatabaseService.isWellFormedDatabase(atPath: garbagePath) == false)
}
}

@ -0,0 +1,98 @@
import Testing
import Foundation
import MusicShared
import Network
@testable import Music
@MainActor
struct RemoteLibraryDisplayTests {
// Reproduces the reported remote-mode bug: "connect + DB download seem fine,
// but no tracks show up". This exercises the exact step enterRemoteMode performs
// after the download pointing a LibraryViewModel at the downloaded DB and
// asserts the view model's `tracks` array actually populates.
@Test(.timeLimit(.minutes(1)))
func remoteLibraryViewModelPopulatesTracksAfterDownload() async throws {
// 1. Create a host database and insert three tracks (the "host library").
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer { try? FileManager.default.removeItem(at: tempDir) }
let hostDBPath = tempDir.appendingPathComponent("host.sqlite").path
let hostDB = try DatabaseService(path: hostDBPath)
for i in 1...3 {
var t = Track.fixture(fileURL: "/song\(i).mp3", title: "Song \(i)")
try hostDB.insert(&t)
}
// 2. Start the HostServer configured with the DB so GET /db serves a backup copy.
let server = HostServer(dbPath: hostDBPath)
server.configure(player: nil, db: hostDB)
try server.start()
try await Task.sleep(for: .milliseconds(200))
let port = server.actualPort!
defer { server.stop() }
// 3. Download the DB over HTTP and write the body to disk, exactly as
// RemoteClient.handleDBData does when a remote connects to a host.
let body = try await httpGet(host: "127.0.0.1", port: port, path: "/db")
let downloadedPath = tempDir.appendingPathComponent("remote_db.sqlite").path
try body.write(to: URL(fileURLWithPath: downloadedPath))
// 4. Point a LibraryViewModel at the downloaded DB this is precisely what
// MusicApp.enterRemoteMode() does after a successful connect.
let remoteDB = try DatabaseService(path: downloadedPath)
let library = LibraryViewModel(db: remoteDB)
// 5. The LibraryViewModel loads via an async GRDB ValueObservation, so poll
// briefly for the observation to deliver its initial value.
try await waitUntil { library.tracks.count == 3 }
// 6. The remote library MUST display all three downloaded tracks. If this
// fails with 0 tracks, it reproduces the "no tracks after connect" bug.
#expect(library.tracks.count == 3)
}
// MARK: - Helpers
/// Polls `condition` on the main actor until it becomes true or the timeout elapses.
private func waitUntil(
timeout: Duration = .seconds(3),
_ condition: @MainActor () -> Bool
) async throws {
let deadline = ContinuousClock.now + timeout
while ContinuousClock.now < deadline {
if condition() { return }
try await Task.sleep(for: .milliseconds(50))
}
}
/// Performs a simple HTTP GET using NWConnection and returns the response body.
private func httpGet(host: String, port: UInt16, path: String) async throws -> Data {
try await withCheckedThrowingContinuation { continuation in
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: port)!,
using: .tcp
)
connection.stateUpdateHandler = { state in
if case .ready = state {
let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n"
connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in })
connection.receiveMessage { data, _, _, error in
if let error {
continuation.resume(throwing: error)
} else if let data, let range = data.range(of: Data("\r\n\r\n".utf8)) {
continuation.resume(returning: Data(data[range.upperBound...]))
} else {
continuation.resume(returning: data ?? Data())
}
connection.cancel()
}
} else if case .failed(let error) = state {
continuation.resume(throwing: error)
}
}
connection.start(queue: .main)
}
}
}

@ -32,4 +32,79 @@ struct ScannerServiceTests {
let extensions = Set(discovered.map { $0.pathExtension.lowercased() })
#expect(extensions.isSubset(of: ["mp3", "m4a", "flac", "wav", "aiff", "aac", "alac"]))
}
// Verifies resolveBitrate uses the OS estimate when it is positive.
// 1. Passes a positive estimatedDataRate in bits/sec (320450).
// 2. Expects it rounded to kbps (320450/1000 = 320.45, which rounds to 320), ignoring size/duration.
@Test func resolveBitrateUsesEstimateWhenPositive() {
let kbps = ScannerService.resolveBitrate(estimatedDataRate: 320_450,
fileSizeBytes: 5_000_000,
durationSeconds: 200)
#expect(kbps == 320)
}
// Verifies the size/duration fallback when the OS estimate is 0 (the AVFoundation bug).
// 1. Passes estimatedDataRate 0 with a real file size and duration.
// 2. Expects 230_358_479 * 8 / 7198.54 s / 1000 -> ~256.0 -> 256 kbps (matches ffprobe).
@Test func resolveBitrateFallsBackToSizeAndDuration() {
let kbps = ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 230_358_479,
durationSeconds: 7198.5371428571425)
#expect(kbps == 256)
}
// Verifies nil (never 0) when the estimate is 0 and duration is unusable.
// 1. Zero duration cannot yield a value -> nil.
// 2. A NaN duration (CMTimeGetSeconds can return NaN) is also nil, not 0.
@Test func resolveBitrateReturnsNilWhenNoDuration() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 230_358_479,
durationSeconds: 0) == nil)
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 230_358_479,
durationSeconds: .nan) == nil)
}
// Verifies nil when the estimate is 0 and there is no file size.
// 1. Missing fileSizeBytes with estimate 0 -> nil (never 0).
@Test func resolveBitrateReturnsNilWhenNoFileSize() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: nil,
durationSeconds: 200) == nil)
}
// Verifies the core invariant: no input combination ever yields 0.
// 1. All-zero inputs return nil so the UI renders "" instead of "0 kbps".
@Test func resolveBitrateNeverReturnsZero() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 0,
durationSeconds: 0) == nil)
}
// Verifies the fallback never returns 0 for sub-kbps inputs (corrupt/truncated file).
// 1. Estimate 0, a 1-byte file with a 1-hour duration -> 8/3600/1000 0 kbps.
// 2. Must return nil (never 0), upholding the "" display invariant.
@Test func resolveBitrateFallbackBelowOneKbpsReturnsNil() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 1,
durationSeconds: 3600) == nil)
}
// Verifies the estimate branch never returns 0 for a sub-kbps estimate.
// 1. A tiny positive estimate (400 bits/s -> 0.4 kbps) rounds to 0.
// 2. With no size/duration to fall back on, the result must be nil, not 0.
@Test func resolveBitrateSubKbpsEstimateReturnsNil() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 400,
fileSizeBytes: nil,
durationSeconds: nil) == nil)
}
// Verifies a (non-physical) negative estimate falls through to the formula.
// 1. estimatedDataRate -1 fails the `> 0` guard.
// 2. Falls back to size/duration -> 256 kbps, never a negative kbps.
@Test func resolveBitrateNegativeEstimateFallsBackToFormula() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: -1,
fileSizeBytes: 230_358_479,
durationSeconds: 7198.5371428571425) == 256)
}
}

@ -9,7 +9,8 @@ struct SmartPlaylistTests {
id: nil,
name: "Miles Davis",
searchQuery: "miles davis",
createdAt: Date()
createdAt: Date(),
conditions: nil
)
#expect(sp.name == "Miles Davis")
#expect(sp.searchQuery == "miles davis")
@ -76,4 +77,229 @@ struct SmartPlaylistTests {
#expect(results[0].title == "Bitches Brew")
#expect(results[1].title == "Kind of Blue")
}
// Creates a SmartPlaylist fixture with conditions and verifies the conditions
// field is preserved and the isSmartPlaylist flag is true.
@Test func smartPlaylistWithConditions() throws {
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))]
let sp = SmartPlaylist.fixture(conditions: conditions)
#expect(sp.conditions?.count == 1)
#expect(sp.conditions?[0].field == .artist)
#expect(sp.isSmartPlaylist == true)
}
// Encodes and decodes a SmartPlaylistCondition to/from JSON,
// verifying that all fields survive the round-trip.
@Test func conditionCodableRoundTrip() throws {
let condition = SmartPlaylistCondition(
field: .artist,
op: .equals,
value: .string("Miles Davis")
)
let data = try JSONEncoder().encode(condition)
let decoded = try JSONDecoder().decode(SmartPlaylistCondition.self, from: data)
#expect(decoded.field == .artist)
#expect(decoded.op == .equals)
if case .string(let s) = decoded.value {
#expect(s == "Miles Davis")
} else {
Issue.record("Expected string value")
}
}
// Encodes and decodes an array of conditions with mixed value types.
@Test func conditionsArrayCodableRoundTrip() throws {
let conditions: [SmartPlaylistCondition] = [
SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("Miles")),
SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)),
SmartPlaylistCondition(field: .dateAdded, op: .lessThan, value: .date(Date(timeIntervalSince1970: 0)))
]
let data = try JSONEncoder().encode(conditions)
let decoded = try JSONDecoder().decode([SmartPlaylistCondition].self, from: data)
#expect(decoded.count == 3)
#expect(decoded[0].field == .artist)
#expect(decoded[1].op == .greaterThan)
if case .int(let y) = decoded[1].value { #expect(y == 1960) } else { Issue.record("Expected int") }
if case .date(let d) = decoded[2].value { #expect(d == Date(timeIntervalSince1970: 0)) } else { Issue.record("Expected date") }
}
// Creates an in-memory DB (which runs all migrations including v5) and verifies
// that existing FTS smart playlists (conditions = nil) still load correctly.
@Test func existingFTSPlaylistSurvivesMigration() throws {
// Step 1: Create DB migration v5 runs automatically, adding the conditions column
// Step 2: Create a FTS smart playlist using the old searchQuery path
// Step 3: Fetch it back and verify conditions is nil, searchQuery is intact
let db = try DatabaseService(inMemory: true)
_ = try db.createSmartPlaylist(name: "Jazz", searchQuery: "jazz")
let all = try db.fetchSmartPlaylists()
#expect(all.count == 1)
#expect(all[0].searchQuery == "jazz")
#expect(all[0].conditions == nil)
}
// Inserts two tracks with different artists, fetches with an equals condition
// on artist, and verifies only the matching track is returned.
@Test func fetchTracksWithEqualsCondition() throws {
// Step 1: Insert two tracks with different artists
// Step 2: Build a condition: artist equals "Miles Davis"
// Step 3: Fetch tracks with that condition and verify only one returned
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis")
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles")
try db.insert(&t1)
try db.insert(&t2)
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect(results[0].artist == "Miles Davis")
}
// Verifies that string equals matching is case-insensitive.
@Test func fetchTracksEqualsIsCaseInsensitive() throws {
// Step 1: Insert a track with mixed-case artist "Miles Davis"
// Step 2: Fetch using lowercase "miles davis"
// Step 3: Verify the track is returned
let db = try DatabaseService(inMemory: true)
var t = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis")
try db.insert(&t)
let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("miles davis"))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
}
// Verifies that startsWith matches case-insensitively on the leading prefix.
@Test func fetchTracksWithStartsWithCondition() throws {
// Step 1: Insert a Miles Davis track and an Eagles track
// Step 2: Fetch with artist startsWith "miles" (lowercase)
// Step 3: Verify only Miles Davis is returned
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis")
var t2 = Track.fixture(fileURL: "/b.mp3", artist: "Eagles")
try db.insert(&t1)
try db.insert(&t2)
let conditions = [SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("miles"))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect(results[0].artist == "Miles Davis")
}
// Verifies that greaterThan on an integer field returns only tracks strictly
// above the threshold value.
@Test func fetchTracksWithGreaterThanCondition() throws {
// Step 1: Insert tracks with years 1990, 2010, 2020
// Step 2: Fetch with year > 2000
// Step 3: Verify 2010 and 2020 are returned; 1990 is not
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", year: 1990)
var t2 = Track.fixture(fileURL: "/b.mp3", year: 2010)
var t3 = Track.fixture(fileURL: "/c.mp3", year: 2020)
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let conditions = [SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(2000))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 2)
#expect(results.allSatisfy { ($0.year ?? 0) > 2000 })
}
// Verifies that multiple conditions are AND-ed: only tracks matching all
// conditions are returned.
@Test func fetchTracksWithMultipleAndConditions() throws {
// Step 1: Insert three tracks two Miles Davis (1959, 1970) and one Eagles (1975)
// Step 2: Fetch with artist = "Miles Davis" AND year > 1960
// Step 3: Verify only the 1970 Miles Davis track is returned
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis", year: 1959)
var t2 = Track.fixture(fileURL: "/b.mp3", title: "Bitches Brew", artist: "Miles Davis", year: 1970)
var t3 = Track.fixture(fileURL: "/c.mp3", title: "Hotel California", artist: "Eagles", year: 1975)
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let conditions = [
SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")),
SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960))
]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect(results[0].title == "Bitches Brew")
}
// Creates a conditions-based smart playlist, fetches it back, and verifies the
// conditions survive the JSON round-trip through GRDB's Codable synthesis.
@Test func createSmartPlaylistWithConditionsPersists() throws {
// Step 1: Create a conditions-based playlist with artist equals and year > conditions
// Step 2: Fetch all smart playlists
// Step 3: Verify both conditions survived the DB round-trip intact
let db = try DatabaseService(inMemory: true)
let conditions = [
SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")),
SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960))
]
_ = try db.createSmartPlaylist(name: "Late Miles", conditions: conditions)
let all = try db.fetchSmartPlaylists()
#expect(all.count == 1)
#expect(all[0].conditions?.count == 2)
#expect(all[0].conditions?[0].field == .artist)
#expect(all[0].conditions?[1].op == .greaterThan)
if case .int(let y) = all[0].conditions?[1].value {
#expect(y == 1960)
} else {
Issue.record("Expected int value")
}
}
// Verifies that lessThan on an integer field returns only tracks strictly
// below the threshold value.
@Test func fetchTracksWithLessThanCondition() throws {
// Step 1: Insert tracks with years 1990, 2010, 2020
// Step 2: Fetch with year < 2000
// Step 3: Verify only the 1990 track is returned
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", year: 1990)
var t2 = Track.fixture(fileURL: "/b.mp3", year: 2010)
var t3 = Track.fixture(fileURL: "/c.mp3", year: 2020)
try db.insert(&t1)
try db.insert(&t2)
try db.insert(&t3)
let conditions = [SmartPlaylistCondition(field: .year, op: .lessThan, value: .int(2000))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect((results[0].year ?? 0) < 2000)
}
// Verifies that the startsWith operator treats % and _ as literal characters,
// not LIKE wildcards confirming the ESCAPE clause in buildWhereClause works.
@Test func startsWithEscapesLIKEMetachars() throws {
// Step 1: Insert a track whose artist literally contains "%" and one whose
// artist matches the % wildcard pattern but not the literal prefix
// Step 2: Search with startsWith "A%B" only the literal match should return
// Step 3: Verify only the literal "A%B Band" track is returned, not "AXB Band"
let db = try DatabaseService(inMemory: true)
var t1 = Track.fixture(fileURL: "/a.mp3", artist: "A%B Band")
var t2 = Track.fixture(fileURL: "/b.mp3", artist: "AXB Band")
try db.insert(&t1)
try db.insert(&t2)
let conditions = [SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("A%B"))]
let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true)
#expect(results.count == 1)
#expect(results[0].artist == "A%B Band")
}
// Updates the conditions of a structured smart playlist and verifies the updated
// conditions are persisted and fetch back correctly.
@Test func updateSmartPlaylistConditionsPersists() throws {
// Step 1: Create a playlist with artist = "Eagles"
// Step 2: Update its conditions to genre startsWith "Jazz"
// Step 3: Fetch and verify the updated conditions are stored
let db = try DatabaseService(inMemory: true)
let sp = try db.createSmartPlaylist(
name: "Test",
conditions: [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Eagles"))]
)
let newConditions = [SmartPlaylistCondition(field: .genre, op: .startsWith, value: .string("Jazz"))]
try db.updateSmartPlaylistConditions(id: sp.id!, conditions: newConditions)
let all = try db.fetchSmartPlaylists()
#expect(all[0].conditions?.count == 1)
#expect(all[0].conditions?[0].field == .genre)
}
}

@ -201,4 +201,143 @@ struct StreamingIntegrationTests {
#expect(http.statusCode == 200)
#expect(data.count > 0)
}
// Reproduces the "seeking does not work in streaming mode" bug.
//
// The client plays the /file endpoint through a plain AVURLAsset/AVPlayer
// with no custom resource loader, so AVPlayer relies on the OS's default
// HTTP transport. AVPlayer only treats a progressive HTTP asset as
// *seekable* when the server honors HTTP byte-range requests: a `Range`
// request must be answered with `206 Partial Content`, a matching
// `Content-Range`, `Accept-Ranges: bytes`, a `Content-Length` equal to the
// slice length, and a body containing ONLY the requested slice. The /file
// endpoint currently ignores the Range header and always returns the whole
// file with `200 OK`, so AVPlayer concludes the stream has no random access
// and silently refuses to seek.
//
// Steps:
// 1. Insert a track pointing at a real audio fixture and start the server.
// 2. Download the full file once to learn its total byte length and exact
// bytes (the ground truth we slice against).
// 3. Request a middle byte range (Range: bytes=10-19) from /file.
// 4. Expect 206 Partial Content, Content-Range: bytes 10-19/<total>,
// Accept-Ranges: bytes, Content-Length: 10, and a body byte-for-byte
// equal to fullBytes[10...19].
@Test(.timeLimit(.minutes(1)))
func fileEndpointHonorsRangeRequests() async throws {
// 1. Insert a track + start the server.
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Range Test")
try db.insert(&track)
let trackId = try #require(track.id)
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
try await server.start()
defer { server.stop() }
let port = try #require(server.actualPort)
let baseURL = "http://127.0.0.1:\(port)"
let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
// 2. Download the full file to establish ground-truth length + bytes.
let (fullData, fullResp) = try await URLSession.shared.data(for: URLRequest(url: fileURL))
let fullHTTP = try #require(fullResp as? HTTPURLResponse)
#expect(fullHTTP.statusCode == 200)
let total = fullData.count
#expect(total > 20) // fixture must be large enough to slice a middle range
// 3. Request bytes 10-19 (10 bytes, inclusive range).
var rangeReq = URLRequest(url: fileURL)
rangeReq.setValue("bytes=10-19", forHTTPHeaderField: "Range")
let (rangeData, rangeResp) = try await URLSession.shared.data(for: rangeReq)
let rangeHTTP = try #require(rangeResp as? HTTPURLResponse)
// 4. Assert proper Partial Content semantics.
#expect(rangeHTTP.statusCode == 206)
#expect(rangeHTTP.value(forHTTPHeaderField: "Accept-Ranges") == "bytes")
#expect(rangeHTTP.value(forHTTPHeaderField: "Content-Range") == "bytes 10-19/\(total)")
#expect(rangeHTTP.value(forHTTPHeaderField: "Content-Length") == "10")
#expect(rangeData.count == 10)
#expect(rangeData == fullData.subdata(in: 10..<20))
}
// A plain GET with no Range header must still succeed AND advertise
// `Accept-Ranges: bytes` up front, so AVPlayer knows before scrubbing that
// the stream supports random access. Without this header AVPlayer never
// enables seeking even if it would have gotten 206s.
//
// Steps:
// 1. Insert a track + start the server.
// 2. GET /file with no Range header.
// 3. Expect 200 OK, Accept-Ranges: bytes, and the full body.
@Test(.timeLimit(.minutes(1)))
func fileEndpointAdvertisesByteRangesWithoutRangeHeader() async throws {
// 1. Insert a track + start the server.
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Accept-Ranges Test")
try db.insert(&track)
let trackId = try #require(track.id)
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
try await server.start()
defer { server.stop() }
let port = try #require(server.actualPort)
let baseURL = "http://127.0.0.1:\(port)"
// 2. Plain GET, no Range header.
let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: fileURL))
let http = try #require(resp as? HTTPURLResponse)
// 3. Full body returned, but server advertises range support.
#expect(http.statusCode == 200)
#expect(http.value(forHTTPHeaderField: "Accept-Ranges") == "bytes")
#expect(data.count > 0)
}
// An open-ended range (Range: bytes=N-) means "from byte N to the end"
// the form AVPlayer most commonly issues while streaming forward. The
// server must return 206 with the tail of the file and a Content-Range
// whose end is total-1.
//
// Steps:
// 1. Insert a track + start the server.
// 2. Download the full file for ground-truth length + bytes.
// 3. Request Range: bytes=10- (byte 10 through EOF).
// 4. Expect 206, Content-Range: bytes 10-<total-1>/<total>, and a body
// equal to fullBytes[10...].
@Test(.timeLimit(.minutes(1)))
func fileEndpointHonorsOpenEndedRange() async throws {
// 1. Insert a track + start the server.
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Open Range Test")
try db.insert(&track)
let trackId = try #require(track.id)
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
try await server.start()
defer { server.stop() }
let port = try #require(server.actualPort)
let baseURL = "http://127.0.0.1:\(port)"
let fileURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
// 2. Ground-truth full file.
let (fullData, _) = try await URLSession.shared.data(for: URLRequest(url: fileURL))
let total = fullData.count
#expect(total > 10)
// 3. Open-ended range from byte 10.
var rangeReq = URLRequest(url: fileURL)
rangeReq.setValue("bytes=10-", forHTTPHeaderField: "Range")
let (rangeData, rangeResp) = try await URLSession.shared.data(for: rangeReq)
let rangeHTTP = try #require(rangeResp as? HTTPURLResponse)
// 4. Tail of the file, correctly described.
#expect(rangeHTTP.statusCode == 206)
#expect(rangeHTTP.value(forHTTPHeaderField: "Content-Range") == "bytes 10-\(total - 1)/\(total)")
#expect(rangeData.count == total - 10)
#expect(rangeData == fullData.subdata(in: 10..<total))
}
}

@ -0,0 +1,82 @@
import Testing
import Foundation
import AVFoundation
import MusicShared
@testable import Music
// End-to-end reproduction of the reported bug:
// "After streaming a full track, AVPlayer fails with 'Operation Stopped'
// and the player does NOT auto-advance to the next track."
//
// These tests drive the *real* StreamingPlaybackProvider against the *real*
// StreamingServer, exactly as the app does, and assert that playing a track to
// its natural end results in a clean finish (onTrackFinished fires, no error).
@MainActor
struct StreamingPlaybackEndToEndTests {
static let testAPIKey = "e2e-test-key"
// Spins the main run loop (where AVPlayer delivers its callbacks) until
// `condition` is true or `timeout` seconds elapse. Returns true if the
// condition was met. Polls in small slices so an async @MainActor test
// lets AVPlayer's main-queue observers run.
private func waitUntil(timeout: Double, _ condition: () -> Bool) async -> Bool {
let slices = Int(timeout / 0.05)
for _ in 0..<slices {
if condition() { return true }
try? await Task.sleep(nanoseconds: 50_000_000) // 50 ms
}
return condition()
}
// Reproduces the auto-advance failure at end-of-track.
// Steps:
// 1. Create an in-memory DB and insert a track pointing at a real audio
// fixture, stored the way the production scanner stores it (absoluteString).
// 2. Start the real StreamingServer on an OS-assigned port.
// 3. Create the real StreamingPlaybackProvider pointed at that server.
// 4. Install an onTrackFinished callback that records the clean-finish signal
// (this is the callback PlayerViewModel uses to advance to the next track).
// 5. Begin playback of the track's /file URL and let it play to the end.
// 6. Wait until EITHER a clean finish fires OR a playback error appears.
// 7. Assert: no playback error occurred AND the clean-finish callback fired.
// On the current (buggy) server this fails playback ends in an error
// instead of a clean finish, so the next track never plays.
@Test(.timeLimit(.minutes(1)))
func playingTrackToEndFiresCleanFinish() async throws {
// 1. DB + fixture track (stored as absoluteString, like the real scanner).
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "E2E Track")
try db.insert(&track)
// 2. Start the real streaming server.
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
try await server.start()
defer { server.stop() }
let port = try #require(server.actualPort)
// 3. Real provider against the real server.
let provider = StreamingPlaybackProvider(hostURL: "http://127.0.0.1:\(port)", apiKey: Self.testAPIKey)
// 4. Record the clean-finish signal (drives PlayerViewModel.next()).
var finishedCleanly = false
provider.onTrackFinished = { finishedCleanly = true }
// 5. Start playback of the track.
let url = try #require(provider.urlForTrack(track))
provider.play(url: url)
// 6. Wait for a terminal outcome: clean finish or error. The fixture is a
// few seconds long; allow generous headroom for buffering.
_ = await waitUntil(timeout: 25) {
finishedCleanly || provider.playbackError != nil
}
// --- Diagnostics (printed regardless of pass/fail) ---
print("E2E DIAGNOSTIC -> finishedCleanly=\(finishedCleanly), playbackError=\(String(describing: provider.playbackError)), providerDuration=\(provider.duration), currentTime=\(provider.currentTime)")
// 7. A fully streamed track must end cleanly so the queue can advance.
#expect(provider.playbackError == nil)
#expect(finishedCleanly == true)
}
}

@ -0,0 +1,76 @@
import Foundation
import AVFoundation
import Testing
@testable import Music
// Locates the test bundle from a struct suite (struct suites don't have a Bundle.self,
// so we use a final class defined in the same file).
private final class BundleToken {}
// Verifies format routing and that writing tags round-trips through a real file
// without corrupting audio.
struct TagWriterTests {
// Step 1: Locate a resource file in the test bundle using BundleToken as the anchor.
private func fixtureURL(_ name: String, _ ext: String) -> URL? {
Bundle(for: BundleToken.self).url(forResource: name, withExtension: ext)
}
// Step 2: Copy the fixture to a temp path so the test can mutate it without
// modifying the bundle resource.
private func tempCopy(of url: URL) throws -> URL {
let dst = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString + "." + url.pathExtension)
try FileManager.default.copyItem(at: url, to: dst)
return dst
}
// Step 3: Read the common "title" key from an audio file using AVFoundation metadata.
private func readCommonTitle(_ url: URL) async throws -> String? {
let md = try await AVURLAsset(url: url).load(.metadata)
let items = AVMetadataItem.metadataItems(from: md, withKey: AVMetadataKey.commonKeyTitle, keySpace: .common)
return try await items.first?.load(.stringValue)
}
// Verifies that TagWriterFactory routes ".mp3" ID3TagWriter, ".m4a" MP4TagWriter,
// and returns nil for unsupported formats.
@Test func factoryRoutesByExtension() {
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.mp3")) is ID3TagWriter)
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.m4a")) is MP4TagWriter)
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.flac")) == nil)
#expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.wav")) == nil)
}
// Step 1: Locate the sample.m4a fixture in the test bundle.
// Step 2: Copy it to a temp file so the bundle resource is not mutated.
// Step 3: Build EditableTrackFields with a specific title and artist.
// Step 4: Write the fields via MP4TagWriter.
// Step 5: Read the common title back via AVFoundation and assert it matches.
// Step 6: Assert the audio track is still present (file not corrupted).
@Test func m4aRoundTrips() async throws {
let src = try #require(fixtureURL("sample", "m4a"), "missing sample.m4a fixture")
let url = try tempCopy(of: src)
defer { try? FileManager.default.removeItem(at: url) }
var f = EditableTrackFields(from: .fixture())
f.title = "Round Trip"; f.artist = "The Verifier"
try MP4TagWriter().write(f, to: url)
#expect(try await readCommonTitle(url) == "Round Trip")
let tracks = try await AVURLAsset(url: url).loadTracks(withMediaType: .audio)
#expect(!tracks.isEmpty) // audio track survived the write
}
// Step 1: Check if sample.mp3 fixture is available; skip trivially if absent.
// Step 2: Copy it to a temp file.
// Step 3: Build EditableTrackFields with a specific title.
// Step 4: Write the fields via ID3TagWriter.
// Step 5: Read the common title back via AVFoundation and assert it matches.
@Test func mp3RoundTrips() async throws {
guard let src = fixtureURL("sample", "mp3") else { return } // no fixture trivially pass
let url = try tempCopy(of: src)
defer { try? FileManager.default.removeItem(at: url) }
var f = EditableTrackFields(from: .fixture())
f.title = "ID3 Round Trip"; f.artist = "Tagger"
try ID3TagWriter().write(f, to: url)
#expect(try await readCommonTitle(url) == "ID3 Round Trip")
}
}

@ -0,0 +1,96 @@
import Testing
@testable import Music
struct TrackContextMenuConfigTests {
// Builds a config with all fields set and verifies:
// - stored playlists, lastUsedPlaylistName, selectedPlaylist match the inputs
// - onAddToPlaylist callback fires with the correct track and playlist
// - onAddToLastPlaylist callback fires with the correct track
// - onRemoveFromPlaylist callback fires with the correct track
// - when optional callbacks are nil, optionally calling them is safe
@Test func storesPropertiesAndFiresCallbacks() {
// 1. Create fixture data
let pl1 = Playlist.fixture(id: 1, name: "Favorites")
let pl2 = Playlist.fixture(id: 2, name: "Chill")
let track = Track.fixture(id: 42, title: "Test")
var addedTrack: Track? = nil
var addedPlaylist: Playlist? = nil
var lastTrack: Track? = nil
var removedTrack: Track? = nil
// 2. Build config with all callbacks
let config = TrackContextMenuConfig(
playlists: [pl1, pl2],
lastUsedPlaylistName: "Favorites",
selectedPlaylist: pl1,
onAddToPlaylist: { t, p in addedTrack = t; addedPlaylist = p },
onAddToLastPlaylist: { t in lastTrack = t },
onRemoveFromPlaylist: { t in removedTrack = t }
)
// 3. Verify stored properties
#expect(config.playlists.count == 2)
#expect(config.playlists[0].name == "Favorites")
#expect(config.lastUsedPlaylistName == "Favorites")
#expect(config.selectedPlaylist == pl1)
// 4. Invoke callbacks and verify they fire correctly
config.onAddToPlaylist(track, pl2)
config.onAddToLastPlaylist?(track)
config.onRemoveFromPlaylist?(track)
#expect(addedTrack?.id == track.id)
#expect(addedPlaylist?.id == pl2.id)
#expect(lastTrack?.id == track.id)
#expect(removedTrack?.id == track.id)
}
@Test func nilOptionalCallbacksAreSafe() {
// Verifies that a config with nil optional callbacks does not crash
// when you call them via optional chaining (the normal usage pattern)
let pl = Playlist.fixture(id: 1, name: "Rock")
let track = Track.fixture()
let config = TrackContextMenuConfig(
playlists: [pl],
lastUsedPlaylistName: nil,
selectedPlaylist: nil,
onAddToPlaylist: { _, _ in },
onAddToLastPlaylist: nil,
onRemoveFromPlaylist: nil
)
// These must not crash
config.onAddToLastPlaylist?(track)
config.onRemoveFromPlaylist?(track)
#expect(config.lastUsedPlaylistName == nil)
#expect(config.selectedPlaylist == nil)
}
// Verifies the queue callbacks fire with the right track.
@Test func queueCallbacksFire() {
let track = Track.fixture(id: 7, title: "Q")
var playNextTrack: Track? = nil
var addQueueTrack: Track? = nil
let config = TrackContextMenuConfig(
playlists: [],
lastUsedPlaylistName: nil,
selectedPlaylist: nil,
onAddToPlaylist: { _, _ in },
onAddToLastPlaylist: nil,
onRemoveFromPlaylist: nil,
onPlayNext: { t in playNextTrack = t },
onAddToQueue: { t in addQueueTrack = t }
)
config.onPlayNext?(track)
config.onAddToQueue?(track)
#expect(playNextTrack?.id == 7)
#expect(addQueueTrack?.id == 7)
}
}

@ -0,0 +1,101 @@
import Foundation
import Testing
@testable import Music
// Verifies the save orchestration: DB always updated; file writeback best-effort;
// stats refreshed on success; warnings on unsupported format / writer failure.
struct TrackEditServiceTests {
// A spy writer we can make succeed or throw.
struct SpyWriter: TagWriter {
let shouldThrow: Bool
func write(_ fields: EditableTrackFields, to url: URL) throws {
if shouldThrow { throw TagWriterError.exportFailed }
// simulate a real write by appending a byte so size/mtime change.
let h = try FileHandle(forWritingTo: url); try h.seekToEnd()
try h.write(contentsOf: Data([0])); try h.close()
}
}
private func tempTrack(ext: String) throws -> Track {
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString + "." + ext)
try Data(repeating: 1, count: 100).write(to: url)
return .fixture(fileURL: url.absoluteString, fileFormat: ext)
}
@Test func supportedFormatSuccessUpdatesDBAndRefreshesStats() throws {
// Step 1: DB + a real temp file + an edit changing the title.
let db = try DatabaseService(inMemory: true)
var t = try tempTrack(ext: "mp3"); try db.insert(&t)
let original = EditableTrackFields(from: t)
var edited = original; edited.title = "Edited"
// Step 2: save via a succeeding writer (injected).
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: false) })
let warnings = svc.save(edited, editing: original.changedFields(to: edited), to: [t])
// Step 3: no warnings; DB has new title and refreshed hash (file changed).
#expect(warnings.isEmpty)
let f = try #require(db.fetchTracksByIds([t.id!]).first)
#expect(f.title == "Edited")
#expect(f.fileHash != t.fileHash)
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!)
}
@Test func unsupportedFormatSavesDBOnlyWithWarning() throws {
let db = try DatabaseService(inMemory: true)
var t = try tempTrack(ext: "flac"); try db.insert(&t)
var edited = EditableTrackFields(from: t); edited.album = "DB Only"
let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) // nil for flac
let warnings = svc.save(edited, editing: [.album], to: [t])
#expect(warnings.count == 1)
#expect(warnings.first?.kind == .dbOnlyUnsupported)
#expect(try #require(db.fetchTracksByIds([t.id!]).first).album == "DB Only")
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!)
}
@Test func writerThrowsSavesDBOnlyWithFailureWarning() throws {
let db = try DatabaseService(inMemory: true)
var t = try tempTrack(ext: "mp3"); try db.insert(&t)
var edited = EditableTrackFields(from: t); edited.genre = "Still Saved"
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: true) })
let warnings = svc.save(edited, editing: [.genre], to: [t])
#expect(warnings.first?.kind == .fileWriteFailed)
#expect(try #require(db.fetchTracksByIds([t.id!]).first).genre == "Still Saved")
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!)
}
@Test func dateAddedOnlyEditIsDBOnlyNoFileWrite() throws {
// Step 1: insert an mp3 track and remember its file hash.
let db = try DatabaseService(inMemory: true)
var t = try tempTrack(ext: "mp3"); try db.insert(&t)
let originalHash = t.fileHash
// Step 2: edit ONLY dateAdded, with a writer that THROWS if ever called
// proving dateAdded is DB-only and triggers no file write.
var edited = EditableTrackFields(from: t)
let newDate = Date(timeIntervalSince1970: 1_000_000)
edited.dateAdded = newDate
let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: true) })
let warnings = svc.save(edited, editing: [.dateAdded], to: [t])
// Step 3: no warnings (no write attempted); DB has the new date; file untouched.
#expect(warnings.isEmpty)
let f = try #require(db.fetchTracksByIds([t.id!]).first)
#expect(f.dateAdded == newDate)
#expect(f.fileHash == originalHash)
try? FileManager.default.removeItem(at: URL(string: t.fileURL)!)
}
@Test func multiTrackAppliesOnlyEditedFields() throws {
let db = try DatabaseService(inMemory: true)
var a = try tempTrack(ext: "flac"); a.album = "OldA"; a.genre = "RockA"; try db.insert(&a)
var b = try tempTrack(ext: "flac"); b.album = "OldB"; b.genre = "RockB"; try db.insert(&b)
var edited = EditableTrackFields(from: a); edited.album = "Shared"
let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer)
_ = svc.save(edited, editing: [.album], to: [a, b])
// album applied to both; each genre untouched.
#expect(try #require(db.fetchTracksByIds([a.id!]).first).album == "Shared")
#expect(try #require(db.fetchTracksByIds([b.id!]).first).album == "Shared")
#expect(try #require(db.fetchTracksByIds([b.id!]).first).genre == "RockB")
try? FileManager.default.removeItem(at: URL(string: a.fileURL)!)
try? FileManager.default.removeItem(at: URL(string: b.fileURL)!)
}
}

@ -0,0 +1,26 @@
import Foundation
import Testing
@testable import Music
// Verifies the shared file-stat helper reads size/mod-date from disk and
// produces a fileHash identical to Track.computeHash (the existing canonical formula).
struct TrackFileStatsTests {
@Test func compute_matchesTrackComputeHash() throws {
// Step 1: write a temp file with known bytes.
let url = URL(fileURLWithPath: NSTemporaryDirectory())
.appendingPathComponent(UUID().uuidString + ".bin")
try Data(repeating: 0xAB, count: 1234).write(to: url)
defer { try? FileManager.default.removeItem(at: url) }
// Step 2: compute stats via the helper.
let stats = try TrackFileStats.compute(for: url)
// Step 3: independently read attrs and assert the helper agrees.
let attrs = try FileManager.default.attributesOfItem(atPath: url.path)
let size = attrs[.size] as? Int64 ?? -1
let mod = attrs[.modificationDate] as? Date ?? Date.distantPast
#expect(stats.fileSize == size)
#expect(stats.dateModified == mod)
#expect(stats.fileHash == Track.computeHash(fileSize: size, modificationDate: mod))
}
}

@ -0,0 +1,327 @@
# Add "New Playlist…" to the Add-to-Playlist menu — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Let a user create a new regular playlist from a track's "Add to Playlist" context submenu, name it via a prompt, and have the track added to it on save.
**Architecture:** A new "New Playlist…" item in the existing `Menu("Add to Playlist")` (in `TrackContextMenuModifier`) calls a new optional closure on `TrackContextMenuConfig`. `ContentView` owns that closure: it stashes the pending track and presents an `.alert` + `TextField` (mirroring the app's existing New Playlist alert). On save it calls a new `PlaylistViewModel.createPlaylistAndAddTrack(name:track:)`, which creates the regular playlist and adds the track in one step.
**Tech Stack:** Swift, SwiftUI, GRDB, Swift Testing (`@Test`), Xcode (`Music` scheme).
**Git note:** This project's owner never auto-commits — commits are made by the user via the `/commit` skill. The "Commit" steps below describe the *suggested grouping* of changes for when the user chooses to commit; do **not** run `git commit` yourself.
**Spec:** `docs/superpowers/specs/2026-05-30-add-to-new-playlist-design.md`
---
## File Structure
- `Music/ViewModels/PlaylistViewModel.swift`**Modify.** Add `createPlaylistAndAddTrack(name:track:)` orchestration method.
- `MusicTests/PlaylistViewModelTests.swift`**Create.** Unit test for the new method.
- `Music/Models/TrackContextMenuConfig.swift`**Modify.** Add `onAddToNewPlaylist` optional closure (+ init param, default `nil`).
- `Music/Views/TrackContextMenuModifier.swift`**Modify.** Add "New Playlist…" button + relax the submenu visibility guard.
- `Music/ContentView.swift`**Modify.** New `@State` for the pending track, wire `onAddToNewPlaylist`, present the name alert.
---
## Task 1: `PlaylistViewModel.createPlaylistAndAddTrack`
**Files:**
- Test: `MusicTests/PlaylistViewModelTests.swift` (create)
- Modify: `Music/ViewModels/PlaylistViewModel.swift` (add method after `addTrack`, ~line 77)
- [ ] **Step 1: Write the failing test**
Create `MusicTests/PlaylistViewModelTests.swift`:
```swift
import Testing
import Foundation
@testable import Music
@MainActor
struct PlaylistViewModelTests {
// Verifies createPlaylistAndAddTrack does the full job in one call:
// 1. Seed a track into an in-memory DB and build a PlaylistViewModel over it.
// 2. Call createPlaylistAndAddTrack with a name and the seeded track.
// 3. The returned playlist has the given name and a real (non-nil) id.
// 4. The DB shows that playlist now contains exactly the seeded track.
// 5. The new playlist is recorded as the last-used playlist.
@Test func createPlaylistAndAddTrackCreatesPlaylistAndAddsTrack() throws {
// 1. Seed a track and build the view model.
let db = try DatabaseService(inMemory: true)
var track = Track.fixture(fileURL: "/song.mp3", title: "Song A")
try db.insert(&track)
let vm = PlaylistViewModel(db: db)
// 2. Create a new playlist and add the track in one step.
let created = try vm.createPlaylistAndAddTrack(name: "Road Trip", track: track)
// 3. The returned playlist is well-formed.
#expect(created.id != nil)
#expect(created.name == "Road Trip")
// 4. The playlist contains exactly the seeded track.
let tracks = try db.fetchPlaylistTracks(playlistId: created.id!)
#expect(tracks.count == 1)
#expect(tracks[0].id == track.id)
// 5. The new playlist became the last-used playlist.
#expect(vm.lastUsedPlaylistId == created.id)
}
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run:
```bash
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlaylistViewModelTests
```
Expected: **build/compile failure**`value of type 'PlaylistViewModel' has no member 'createPlaylistAndAddTrack'`.
- [ ] **Step 3: Write the minimal implementation**
In `Music/ViewModels/PlaylistViewModel.swift`, add this method directly after `addTrack(_:to:)` (after line 77):
```swift
@discardableResult
func createPlaylistAndAddTrack(name: String, track: Track) throws -> Playlist {
let playlist = try db.createPlaylist(name: name)
try addTrack(track, to: playlist)
return playlist
}
```
`db.createPlaylist` returns a `Playlist` with its assigned `id`; the existing `addTrack` inserts the join row and sets `lastUsedPlaylistId`.
- [ ] **Step 4: Run the test to verify it passes**
Run:
```bash
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlaylistViewModelTests
```
Expected: **PASS** (`createPlaylistAndAddTrackCreatesPlaylistAndAddsTrack`).
- [ ] **Step 5: Commit (suggested grouping — leave to the user / `/commit`)**
Changed files: `MusicTests/PlaylistViewModelTests.swift`, `Music/ViewModels/PlaylistViewModel.swift`.
Suggested message: `feat: add PlaylistViewModel.createPlaylistAndAddTrack`
---
## Task 2: Add `onAddToNewPlaylist` to `TrackContextMenuConfig`
This is a pure data struct; the new field is optional with a `nil` default, so existing call sites and `TrackContextMenuConfigTests` keep compiling. No new unit test (the struct just stores a closure).
**Files:**
- Modify: `Music/Models/TrackContextMenuConfig.swift`
- [ ] **Step 1: Add the stored property**
In `TrackContextMenuConfig`, add the property after `onAddToQueue` (after line 15):
```swift
// nil hides the "New Playlist…" item (e.g. tests that don't supply it).
let onAddToNewPlaylist: ((Track) -> Void)?
```
- [ ] **Step 2: Add the init parameter (with default `nil`)**
In the explicit `init`, add the parameter after `onAddToQueue` (line 30):
```swift
onAddToQueue: ((Track) -> Void)? = nil,
onAddToNewPlaylist: ((Track) -> Void)? = nil,
onGetInfo: (([Track]) -> Void)? = nil
```
And add the assignment in the body, after `self.onAddToQueue = onAddToQueue` (line 40):
```swift
self.onAddToNewPlaylist = onAddToNewPlaylist
```
- [ ] **Step 3: Build to verify it compiles**
Run:
```bash
xcodebuild build -scheme Music -destination 'platform=macOS'
```
Expected: **BUILD SUCCEEDED** (existing call sites still compile because the new param defaults to `nil`).
- [ ] **Step 4: Run the existing config tests to confirm no regression**
Run:
```bash
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/TrackContextMenuConfigTests
```
Expected: **PASS** (all existing tests).
- [ ] **Step 5: Commit (suggested grouping — leave to the user / `/commit`)**
Changed file: `Music/Models/TrackContextMenuConfig.swift`.
Suggested message: `feat: add onAddToNewPlaylist to TrackContextMenuConfig`
---
## Task 3: Add "New Playlist…" to the submenu
SwiftUI view code; no unit test (consistent with the codebase). Verify by build.
**Files:**
- Modify: `Music/Views/TrackContextMenuModifier.swift:30-38`
- [ ] **Step 1: Replace the "Add to Playlist" submenu block**
Replace the current block (lines 30–38):
```swift
if !config.playlists.isEmpty {
Menu("Add to Playlist") {
ForEach(config.playlists) { playlist in
Button(playlist.name) {
config.onAddToPlaylist(track, playlist)
}
}
}
}
```
with:
```swift
if !config.playlists.isEmpty || config.onAddToNewPlaylist != nil {
Menu("Add to Playlist") {
if let onAddToNewPlaylist = config.onAddToNewPlaylist {
Button("New Playlist…") {
onAddToNewPlaylist(track)
}
if !config.playlists.isEmpty {
Divider()
}
}
ForEach(config.playlists) { playlist in
Button(playlist.name) {
config.onAddToPlaylist(track, playlist)
}
}
}
}
```
Notes: the button label uses a real ellipsis character `…` (macOS convention for "opens a prompt"). The submenu now appears even when the user has zero playlists, showing just "New Playlist…". The `Divider` only appears when there are existing playlists to separate from.
- [ ] **Step 2: Build to verify it compiles**
Run:
```bash
xcodebuild build -scheme Music -destination 'platform=macOS'
```
Expected: **BUILD SUCCEEDED**.
- [ ] **Step 3: Commit (suggested grouping — leave to the user / `/commit`)**
Changed file: `Music/Views/TrackContextMenuModifier.swift`.
Suggested message: `feat: add "New Playlist…" item to Add to Playlist submenu`
---
## Task 4: Wire the prompt in `ContentView`
SwiftUI view code; no unit test. Verify by build + manual check.
**Files:**
- Modify: `Music/ContentView.swift` — add `@State` (near the other playlist alert state, e.g. by `playlistNameInput`/`showNewPlaylistAlert`), wire the closure in `trackContextMenuConfig` (line 433–434 area), add the alert (by the existing New Playlist alert at line 273).
- [ ] **Step 1: Add state for the pending track**
Find the existing `@State` declarations for the playlist alerts (the same scope that declares `showNewPlaylistAlert` and `playlistNameInput`). Add nearby:
```swift
@State private var newPlaylistTrack: Track?
@State private var newPlaylistNameInput = ""
```
(`newPlaylistNameInput` is kept separate from the sidebar's `playlistNameInput` so the two flows can't clobber each other's text.)
- [ ] **Step 2: Wire the closure in `trackContextMenuConfig`**
In `trackContextMenuConfig` (line 412), add `onAddToNewPlaylist` to the `TrackContextMenuConfig(...)` call. Insert it after the `onAddToQueue:` argument (line 433), before `onGetInfo:`:
```swift
onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil,
onAddToNewPlaylist: { track in newPlaylistTrack = track },
onGetInfo: { tracks in
if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks) }
}
```
(Wired unconditionally — matches `onAddToPlaylist`, which is also not gated on `queueEnabled`.)
- [ ] **Step 3: Add the name-prompt alert**
Immediately after the existing New Playlist alert block (after line 283, the closing `}` of `.alert("New Playlist", isPresented: $showNewPlaylistAlert)`), add:
```swift
.alert("New Playlist", isPresented: Binding(
get: { newPlaylistTrack != nil },
set: { if !$0 { newPlaylistTrack = nil; newPlaylistNameInput = "" } }
)) {
TextField("Playlist name", text: $newPlaylistNameInput)
Button("Cancel", role: .cancel) {
newPlaylistNameInput = ""
newPlaylistTrack = nil
}
Button("Create") {
let name = newPlaylistNameInput.trimmingCharacters(in: .whitespaces)
if !name.isEmpty, let track = newPlaylistTrack {
try? playlist.createPlaylistAndAddTrack(name: name, track: track)
}
newPlaylistNameInput = ""
newPlaylistTrack = nil
}
}
```
- [ ] **Step 4: Build to verify it compiles**
Run:
```bash
xcodebuild build -scheme Music -destination 'platform=macOS'
```
Expected: **BUILD SUCCEEDED**.
- [ ] **Step 5: Manual verification**
Launch the app. Right-click a track → **Add to Playlist****New Playlist…**. Enter a name, click **Create**. Confirm:
- A new playlist with that name appears in the sidebar.
- Selecting it shows the track you added.
- The sidebar selection did **not** change automatically (stayed on the current view).
- Re-open the context menu: "Add to <new name>" now appears as the last-used playlist shortcut.
- Empty name → clicking Create does nothing (no empty playlist created).
- [ ] **Step 6: Commit (suggested grouping — leave to the user / `/commit`)**
Changed file: `Music/ContentView.swift`.
Suggested message: `feat: prompt for name and add track when creating playlist from menu`
---
## Final verification
- [ ] Run the full test target once:
```bash
xcodebuild test -scheme Music -destination 'platform=macOS'
```
Expected: **all tests PASS**, including the new `PlaylistViewModelTests`.
---
## Self-review (done while writing this plan)
- **Spec coverage:** view-model create+add (Task 1), config closure (Task 2), submenu item + empty-list handling (Task 3), prompt + wiring + behavior decisions: single clicked track, no navigation, empty-name no-op (Task 4). All spec sections map to a task.
- **Placeholder scan:** none — every code step shows the exact code.
- **Type/name consistency:** `createPlaylistAndAddTrack(name:track:)` and `onAddToNewPlaylist` are used identically across Tasks 1–4; `newPlaylistTrack` / `newPlaylistNameInput` are consistent within Task 4.

@ -0,0 +1,607 @@
# Fix `bitrate = 0` Tracks Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Stop the importer from ever storing a bitrate of `0`, and backfill the real bitrate onto existing tracks where it is `0` or `NULL`.
**Architecture:** Two independent pieces. (1) A pure, unit-tested Swift function `ScannerService.resolveBitrate` that derives kbps from AVFoundation's estimate with a file-size/duration fallback, returning `nil` (never `0`) when nothing is derivable; wired into `extractMetadata`. (2) A stdlib-only Python backfill script `scripts/backfill_bitrate.py` (modeled on the existing `backfill_itunes_dates.py`) that recomputes bitrate via `ffprobe`, falling back to the same formula, with dry-run default and `--apply` + timestamped backup.
**Tech Stack:** Swift (AVFoundation, swift-testing), Python 3 stdlib (`sqlite3`, `subprocess`), `ffprobe` (optional external tool).
**Project conventions to respect:**
- User's global rule: **never auto-commit.** Each "Commit checkpoint" step means *stop and run the `/commit` skill* — do not run `git commit` directly.
- Tests use swift-testing (`import Testing`, `@Test`, `#expect`), one struct per file, every test carries a step-by-step comment.
- Swift static utilities in `ScannerService` are `nonisolated static`.
**Spec:** `docs/superpowers/specs/2026-05-30-fix-zero-bitrate-design.md`
---
### Task 1: `ScannerService.resolveBitrate` pure function (Swift, TDD)
**Files:**
- Test: `MusicTests/ScannerServiceTests.swift` (add tests to the existing `ScannerServiceTests` struct)
- Modify: `Music/Services/ScannerService.swift` (add the static function)
- [ ] **Step 1: Write the failing tests**
Add these five tests inside the existing `struct ScannerServiceTests { ... }` in `MusicTests/ScannerServiceTests.swift`, just before the closing brace:
```swift
// Verifies resolveBitrate uses the OS estimate when it is positive.
// 1. Passes a positive estimatedDataRate in bits/sec (320450).
// 2. Expects it rounded to kbps (320450/1000 = 320.45 -> 320), ignoring size/duration.
@Test func resolveBitrateUsesEstimateWhenPositive() {
let kbps = ScannerService.resolveBitrate(estimatedDataRate: 320_450,
fileSizeBytes: 5_000_000,
durationSeconds: 200)
#expect(kbps == 320)
}
// Verifies the size/duration fallback when the OS estimate is 0 (the AVFoundation bug).
// 1. Passes estimatedDataRate 0 with a real file size and duration.
// 2. Expects 230_358_479 * 8 / 7198.54 / 1000 -> ~256.0 -> 256 kbps (matches ffprobe).
@Test func resolveBitrateFallsBackToSizeAndDuration() {
let kbps = ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 230_358_479,
durationSeconds: 7198.5371428571425)
#expect(kbps == 256)
}
// Verifies nil (never 0) when the estimate is 0 and duration is unusable.
// 1. Zero duration cannot yield a value -> nil.
// 2. A NaN duration (CMTimeGetSeconds can return NaN) is also nil, not 0.
@Test func resolveBitrateReturnsNilWhenNoDuration() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 230_358_479,
durationSeconds: 0) == nil)
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 230_358_479,
durationSeconds: .nan) == nil)
}
// Verifies nil when the estimate is 0 and there is no file size.
// 1. Missing fileSizeBytes with estimate 0 -> nil (never 0).
@Test func resolveBitrateReturnsNilWhenNoFileSize() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: nil,
durationSeconds: 200) == nil)
}
// Verifies the core invariant: no input combination ever yields 0.
// 1. All-zero inputs return nil so the UI renders "—" instead of "0 kbps".
@Test func resolveBitrateNeverReturnsZero() {
#expect(ScannerService.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: 0,
durationSeconds: 0) == nil)
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run:
```bash
xcodebuild test -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \
-scheme Music -destination 'platform=macOS' \
-only-testing:MusicTests/ScannerServiceTests 2>&1 | tail -30
```
Expected: **compile failure**`type 'ScannerService' has no member 'resolveBitrate'`.
(If the destination errors, list options with `xcodebuild -showdestinations -project Music.xcodeproj -scheme Music` and use the macOS one.)
- [ ] **Step 3: Write the minimal implementation**
In `Music/Services/ScannerService.swift`, add this method right after the `discoverAudioFiles` static function (after line 35, inside the class):
```swift
/// Resolve a track's bitrate in kbps from the OS estimate, falling back to a
/// size/duration average. Returns nil when nothing can be derived — never 0,
/// so the UI shows "—" instead of a meaningless "0 kbps".
///
/// AVFoundation's `estimatedDataRate` returns 0 for some files (observed on
/// long/VBR MP3s); for those we compute the true average bitrate from the
/// file size and duration, which matches ffprobe to the kbps.
nonisolated static func resolveBitrate(estimatedDataRate: Double,
fileSizeBytes: Int64?,
durationSeconds: Double?) -> Int? {
if estimatedDataRate > 0 {
return Int((estimatedDataRate / 1000).rounded())
}
// NaN-safe: `dur > 0` is false for .nan, so we return nil rather than 0.
if let size = fileSizeBytes, size > 0,
let dur = durationSeconds, dur > 0 {
return Int((Double(size) * 8 / dur / 1000).rounded())
}
return nil
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run:
```bash
xcodebuild test -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \
-scheme Music -destination 'platform=macOS' \
-only-testing:MusicTests/ScannerServiceTests 2>&1 | tail -30
```
Expected: **TEST SUCCEEDED**, all `ScannerServiceTests` pass (the new five plus the existing `discoverAudioFiles`).
- [ ] **Step 5: Commit checkpoint**
Stop and run the `/commit` skill (do not `git commit` directly). Suggested message: `feat: add ScannerService.resolveBitrate with size/duration fallback`.
---
### Task 2: Wire `resolveBitrate` into `extractMetadata`
**Files:**
- Modify: `Music/Services/ScannerService.swift:146-162` (the bitrate block inside `extractMetadata`)
- [ ] **Step 1: Move the file-stats computation above the bitrate block**
In `extractMetadata`, `TrackFileStats.compute` currently runs at line 162, *after* the bitrate block. Move it up so its `fileSize` is available to `resolveBitrate`. Replace the current block that spans from the duration load through the bitrate computation (lines 146-160):
```swift
let duration = try await asset.load(.duration)
let durationSeconds = CMTimeGetSeconds(duration)
var bitrate: Int?
var sampleRate: Int?
if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first {
let estimatedRate = try await audioTrack.load(.estimatedDataRate)
bitrate = Int(estimatedRate / 1000)
let descriptions = try await audioTrack.load(.formatDescriptions)
if let desc = descriptions.first {
if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) {
sampleRate = Int(asbd.pointee.mSampleRate)
}
}
}
```
with this:
```swift
let duration = try await asset.load(.duration)
let durationSeconds = CMTimeGetSeconds(duration)
let stats = try TrackFileStats.compute(for: url)
var bitrate: Int?
var sampleRate: Int?
if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first {
let estimatedRate = try await audioTrack.load(.estimatedDataRate)
bitrate = Self.resolveBitrate(estimatedDataRate: estimatedRate,
fileSizeBytes: stats.fileSize,
durationSeconds: durationSeconds)
let descriptions = try await audioTrack.load(.formatDescriptions)
if let desc = descriptions.first {
if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) {
sampleRate = Int(asbd.pointee.mSampleRate)
}
}
} else {
// No audio track loaded — still attempt the size/duration fallback
// so we never silently lose the bitrate.
bitrate = Self.resolveBitrate(estimatedDataRate: 0,
fileSizeBytes: stats.fileSize,
durationSeconds: durationSeconds)
}
```
- [ ] **Step 2: Remove the now-duplicate `TrackFileStats.compute` call**
The original line 162 `let stats = try TrackFileStats.compute(for: url)` (now appearing just above the `return Track(` call) is a duplicate — delete that single line. The `Track(...)` initializer still references `stats.fileSize`, `stats.dateModified`, and `stats.fileHash`, which now come from the moved-up computation. Confirm exactly one `let stats = try TrackFileStats.compute(for: url)` remains in the function.
- [ ] **Step 3: Build to verify it compiles**
Run:
```bash
xcodebuild build -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \
-scheme Music -destination 'platform=macOS' 2>&1 | tail -15
```
Expected: **BUILD SUCCEEDED**.
- [ ] **Step 4: Re-run the full test target to confirm no regressions**
Run:
```bash
xcodebuild test -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \
-scheme Music -destination 'platform=macOS' \
-only-testing:MusicTests 2>&1 | tail -20
```
Expected: **TEST SUCCEEDED** for the whole `MusicTests` target.
- [ ] **Step 5: Commit checkpoint**
Stop and run the `/commit` skill. Suggested message: `fix: importer derives bitrate via resolveBitrate instead of storing 0`.
---
### Task 3: Backfill script — pure helpers + self-test (Python, TDD)
**Files:**
- Create: `scripts/backfill_bitrate.py`
- [ ] **Step 1: Create the script with helpers and a failing self-test**
Create `scripts/backfill_bitrate.py` with exactly this content:
```python
#!/usr/bin/env python3
"""One-time backfill of real bitrate onto tracks stored with bitrate 0 or NULL.
ScannerService writes `bitrate = Int(estimatedDataRate / 1000)` at scan time.
AVFoundation's estimatedDataRate returns 0 for some files (long/VBR MP3s), so a
literal 0 gets stored; other tracks were imported before bitrate existed and are
NULL. This script recomputes bitrate for those rows using ffprobe, falling back
to fileSize*8/duration (the same average the app's importer now uses) when
ffprobe is unavailable or can't determine a value.
Dry-run by default. Pass --apply to write (a timestamped backup is made first).
Usage:
python3 backfill_bitrate.py [--db <path>] [--apply]
python3 backfill_bitrate.py --self-test
Stdlib only; uses ffprobe if present on PATH (optional).
"""
import argparse
import os
import shutil
import sqlite3
import subprocess
import sys
import unicodedata
from datetime import datetime
from urllib.parse import unquote
# Default DB path for the sandboxed app (bundle id com.staxriver.mu). Computed from
# $HOME so it resolves to the right user on whichever Mac the script runs on.
DEFAULT_DB = os.path.expanduser(
"~/Library/Containers/com.staxriver.mu/Data/Library/"
"Application Support/Music/db.sqlite"
)
def norm_path(u):
"""Reduce a file:// URL (or bare path) to a comparable, on-disk POSIX path.
The app stores `fileURL` as Foundation's url.absoluteString (a percent-encoded
file URL). Decode it, drop the file:// (or file://localhost) prefix, NFC-
normalize, and strip a trailing slash so it can be stat'd on APFS.
"""
s = u
if s.startswith("file://"):
s = s[len("file://"):]
if s.startswith("localhost/"):
s = s[len("localhost"):] # leaves the leading "/"
s = unquote(s)
s = unicodedata.normalize("NFC", s)
if len(s) > 1 and s.endswith("/"):
s = s[:-1]
return s
def parse_ffprobe_bitrate(stdout):
"""Parse ffprobe's bit_rate stdout (bits/sec) into integer kbps, or None.
Returns None for empty output, 'N/A', or any non-integer text so the caller
falls back to the formula.
"""
s = stdout.strip()
if not s or s == "N/A":
return None
try:
return round(int(s) / 1000)
except ValueError:
return None
def kbps_from_ffprobe(path):
"""Return integer kbps from ffprobe's format bit_rate, or None if unavailable.
None on: ffprobe not installed, ffprobe error, or N/A/empty/non-integer output.
"""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=bit_rate",
"-of", "default=nw=1:nk=1", path],
capture_output=True, text=True, timeout=30,
)
except (FileNotFoundError, subprocess.SubprocessError):
return None
return parse_ffprobe_bitrate(out.stdout)
def kbps_from_formula(file_size, duration):
"""Average kbps from size (bytes) and duration (seconds): size*8/duration/1000.
Returns None when inputs can't yield a meaningful value (missing size, or
non-positive/missing duration).
"""
if not file_size or not duration or duration <= 0:
return None
return round(file_size * 8 / duration / 1000)
def resolve_bitrate(path, duration):
"""Best available kbps for an on-disk file: ffprobe first, formula fallback.
`duration` is the DB's stored seconds; file size is read from disk. Returns
None if neither method can produce a positive value.
"""
kbps = kbps_from_ffprobe(path)
if kbps and kbps > 0:
return kbps
try:
size = os.path.getsize(path)
except OSError:
size = None
return kbps_from_formula(size, duration)
def ffprobe_available():
return shutil.which("ffprobe") is not None
def self_test():
"""Fast smoke check of the pure helpers (no DB, no ffprobe needed)."""
# ffprobe stdout parsing
assert parse_ffprobe_bitrate("256005\n") == 256
assert parse_ffprobe_bitrate("N/A") is None
assert parse_ffprobe_bitrate("") is None
assert parse_ffprobe_bitrate("garbage") is None
# formula: 230_358_479 bytes over 7198.54 s -> 256 kbps (matches ffprobe sample)
assert kbps_from_formula(230_358_479, 7198.5371428571425) == 256
assert kbps_from_formula(None, 100) is None
assert kbps_from_formula(1000, 0) is None
assert kbps_from_formula(1000, None) is None
# path normalization (NFD vs NFC accents, percent-encoding, localhost host)
nfc = norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3")
nfd = norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3")
assert nfc == nfd == "/Users/x/Música/Café.mp3", (nfc, nfd)
assert norm_path("file:///a/b%20c%23d.mp3") == "/a/b c#d.mp3"
print("self-test OK")
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--db", default=DEFAULT_DB, help=f"App DB path (default: {DEFAULT_DB})")
p.add_argument("--apply", action="store_true", help="Write changes (default: dry run).")
p.add_argument("--self-test", action="store_true", help="Run the built-in smoke test.")
args = p.parse_args(argv)
if args.self_test:
self_test()
return 0
if not os.path.exists(args.db):
p.error(f"DB not found: {args.db}")
run(args.db, args.apply)
return 0
if __name__ == "__main__":
sys.exit(main())
```
Note: `run(...)` is referenced by `main` but not yet defined — Task 4 adds it. The self-test does not call `run`, so `--self-test` works now.
- [ ] **Step 2: Run the self-test to verify the helpers pass**
Run:
```bash
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --self-test
```
Expected: `self-test OK`.
- [ ] **Step 3: Verify the dry-run path fails cleanly (run undefined)**
Run:
```bash
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --db /nonexistent.sqlite
```
Expected: argparse error `DB not found: /nonexistent.sqlite` (exit 2) — confirms arg handling before `run` is implemented.
- [ ] **Step 4: Commit checkpoint**
Stop and run the `/commit` skill. Suggested message: `feat: add backfill_bitrate.py helpers + self-test`.
---
### Task 4: Backfill script — DB wiring, dry-run report, `--apply`
**Files:**
- Modify: `scripts/backfill_bitrate.py` (add `fetch_rows`, `build_updates`, `backup_db`, `apply_updates`, `run`)
- [ ] **Step 1: Add the DB + reporting functions**
Insert these functions into `scripts/backfill_bitrate.py` immediately before `def main(`:
```python
def fetch_rows(db_path):
"""Return candidate rows: (id, fileURL, duration, bitrate) where bitrate is 0/NULL."""
con = sqlite3.connect(db_path)
try:
return con.execute(
"SELECT id, fileURL, duration, bitrate FROM tracks "
"WHERE bitrate = 0 OR bitrate IS NULL"
).fetchall()
finally:
con.close()
def build_updates(rows):
"""Resolve a new bitrate for each candidate row.
Returns (updates, missing, undeterminable):
- updates: list of {id, file_url, old, new} where new is a positive kbps
- missing: (id, path) for rows whose file is not on disk (left untouched)
- undeterminable: (id, path) for on-disk files whose bitrate couldn't be found
"""
updates, missing, undeterminable = [], [], []
for row_id, file_url, duration, old in rows:
path = norm_path(file_url)
if not os.path.exists(path):
missing.append((row_id, path))
continue
new = resolve_bitrate(path, duration)
if not new or new <= 0:
undeterminable.append((row_id, path))
continue
updates.append({"id": row_id, "file_url": file_url, "old": old, "new": new})
return updates, missing, undeterminable
def backup_db(db_path):
"""Copy db.sqlite (+ -wal, -shm) under backups/<timestamp>/ next to the DB."""
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_dir = os.path.join(os.path.dirname(db_path), "backups", stamp)
os.makedirs(backup_dir, exist_ok=True)
for suffix in ("", "-wal", "-shm"):
src = db_path + suffix
if os.path.exists(src):
shutil.copy2(src, os.path.join(backup_dir, os.path.basename(src)))
return backup_dir
def apply_updates(db_path, updates):
"""Write bitrate updates in a single transaction, then checkpoint the WAL."""
con = sqlite3.connect(db_path)
try:
con.execute("BEGIN")
con.executemany("UPDATE tracks SET bitrate=:new WHERE id=:id", updates)
con.commit()
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
finally:
con.close()
def run(db_path, apply):
rows = fetch_rows(db_path)
updates, missing, undeterminable = build_updates(rows)
print(f"Candidate rows (bitrate 0 or NULL): {len(rows)}")
print(f"Resolvable (will set): {len(updates)}")
print(f"Skipped — file missing on disk: {len(missing)}")
print(f"Skipped — could not determine: {len(undeterminable)}")
if not ffprobe_available():
print("NOTE: ffprobe not on PATH — used the filesize/duration formula for all rows.")
print()
for u in updates[:15]:
name = os.path.basename(norm_path(u["file_url"]))
old = "NULL" if u["old"] is None else u["old"]
print(f" • {name}")
print(f" bitrate {old} -> {u['new']} kbps")
if len(updates) > 15:
print(f" ... and {len(updates) - 15} more")
print()
if missing[:5]:
print("Sample of skipped (file missing on disk, left untouched):")
for row_id, path in missing[:5]:
print(f" - [{row_id}] {os.path.basename(path)}")
print()
if undeterminable[:5]:
print("Sample of skipped (could not determine bitrate, left untouched):")
for row_id, path in undeterminable[:5]:
print(f" - [{row_id}] {os.path.basename(path)}")
print()
if not apply:
print("DRY RUN — nothing written. Re-run with --apply to commit these changes.")
return
if not updates:
print("Nothing to apply.")
return
backup_dir = backup_db(db_path)
print(f"Backup written to: {backup_dir}")
apply_updates(db_path, updates)
print(f"Applied {len(updates)} bitrate updates to {db_path}")
```
- [ ] **Step 2: Re-run the self-test (ensure the new code didn't break helpers)**
Run:
```bash
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --self-test
```
Expected: `self-test OK`.
- [ ] **Step 3: Dry-run against a temp DB to verify end-to-end wiring**
This builds a tiny throwaway DB with one real bitrate=0 row, so the run path is exercised without touching the app DB:
```bash
python3 - <<'PY'
import sqlite3, os, tempfile
d = tempfile.mkdtemp()
db = os.path.join(d, "db.sqlite")
con = sqlite3.connect(db)
con.execute("CREATE TABLE tracks (id INTEGER PRIMARY KEY, fileURL TEXT, duration REAL, bitrate INTEGER)")
# A file that does not exist -> should be reported as 'missing', not crash.
con.execute("INSERT INTO tracks (fileURL, duration, bitrate) VALUES (?,?,?)",
("file:///no/such/file.mp3", 100.0, 0))
con.commit(); con.close()
print(db)
PY
```
Take the printed path and run:
```bash
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --db <printed-path>
```
Expected: a summary with `Candidate rows ... : 1`, `Skipped — file missing on disk: 1`, and `DRY RUN — nothing written.` (no traceback).
- [ ] **Step 4: Commit checkpoint**
Stop and run the `/commit` skill. Suggested message: `feat: backfill_bitrate.py DB wiring, dry-run report, --apply`.
---
### Task 5: Verify against the real library (manual, no code change)
**Files:** none.
- [ ] **Step 1: Dry-run against the real app DB**
Quit the Music app first (avoids WAL/lock contention). Then:
```bash
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py
```
Expected: a non-zero `Resolvable (will set)` count and a sample of `bitrate 0 -> NNN kbps` lines. Eyeball a few values for plausibility (typical 128–320 kbps).
- [ ] **Step 2: Apply**
```bash
python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --apply
```
Expected: `Backup written to: .../backups/<timestamp>` then `Applied N bitrate updates`.
- [ ] **Step 3: Confirm the DB no longer has 0/NULL bitrates (except undeterminable)**
```bash
DB="$HOME/Library/Containers/com.staxriver.mu/Data/Library/Application Support/Music/db.sqlite"
sqlite3 "$DB" "SELECT COUNT(*) AS still_zero_or_null FROM tracks WHERE bitrate = 0 OR bitrate IS NULL;"
```
Expected: `0`, or the small count the dry-run reported as "could not determine"/"file missing".
- [ ] **Step 4: Reopen the app and spot-check**
Open a previously-0 track's Get Info (or the Bit Rate column) and confirm it now shows a real value.
---
## Self-Review Notes
- **Spec coverage:** Root cause + invariant → Tasks 1–2 (`resolveBitrate`, never stores 0). Backfill (0 and NULL, ffprobe→formula, missing-file skip, dry-run/backup/apply) → Tasks 3–4. Manual verification → Task 5. All spec sections covered.
- **Type consistency:** `resolveBitrate(estimatedDataRate:fileSizeBytes:durationSeconds:)` is defined identically in Task 1 and called identically in Task 2; `stats.fileSize` is `Int64` (matches `fileSizeBytes: Int64?`). Python helper names (`parse_ffprobe_bitrate`, `kbps_from_ffprobe`, `kbps_from_formula`, `resolve_bitrate`, `fetch_rows`, `build_updates`, `backup_db`, `apply_updates`, `run`) are defined once and referenced consistently.
- **No placeholders:** every code step shows complete code; every run step shows the command and expected output.

@ -0,0 +1,780 @@
# Playing Queue Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add a Spotify-style priority "Up Next" queue: tracks can be pushed to the front ("Play Next") or end ("Add to Queue") via the track context menu, played before the playlist/album context resumes, and managed in a right-docked panel.
**Architecture:** `PlayerViewModel` keeps its existing `queue`/`currentIndex` as the playback **context** and gains a parallel `manualQueue` of `QueueEntry` (a track + stable UUID). `next()` drains the manual queue before advancing the context; a dedicated `playManual(_:)` plays a queued track without moving `currentIndex`, so the context resumes correctly. A new `QueueView` renders the panel; the context menu and `ContentView` are wired up. Local-only for v1 — queue actions are hidden when driving a remote device.
**Tech Stack:** Swift, SwiftUI + AppKit (`NSTableView`), Swift Testing (`import Testing`), Xcode 16 synchronized groups (no `pbxproj` edits for new files).
**Spec:** `docs/superpowers/specs/2026-05-30-playing-queue-design.md`
> **Project rule — commits:** This repo's CLAUDE.md says *never commit unless triggered by the `/commit` skill*. The "Commit" steps below are checkpoints: stage the listed files and ask the user to run `/commit` (or run it when they direct). Do **not** commit autonomously.
> **Test command:** `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:<TestTarget/Suite> 2>&1 | tail -30`. Full build: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -20`.
---
## Task 1: `QueueEntry` model + queue state and add actions
**Files:**
- Create: `Music/Models/QueueEntry.swift`
- Modify: `Music/ViewModels/PlayerViewModel.swift` (add state + methods)
- Test: `MusicTests/PlayerViewModelTests.swift`
- [ ] **Step 1: Write the failing tests**
Add these tests inside the `PlayerViewModelTests` struct in `MusicTests/PlayerViewModelTests.swift` (after the existing tests, before the closing `}` of the struct):
```swift
// Step 1: context [1,2,3], track 1 playing.
// Step 2: addToQueue twice → manual queue holds those tracks in arrival order.
// Step 3: playNext jumps a track to the FRONT of the manual queue.
@Test func addToQueueAppendsAndPlayNextInsertsFront() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[3]) // id 4
vm.addToQueue(tracks[4]) // id 5
#expect(vm.manualQueue.map { $0.track.id } == [4, 5])
vm.playNext(tracks[5]) // id 6 to the front
#expect(vm.manualQueue.map { $0.track.id } == [6, 4, 5])
}
// Step 1: a view model with nothing playing (idle).
// Step 2: addToQueue should start playback immediately (queue-while-idle) and
// leave the manual queue empty because the track was consumed to play.
@Test func queueWhileIdleStartsPlayback() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let track = Track.fixture(id: 1, fileURL: "/a.mp3", title: "A")
vm.addToQueue(track)
#expect(vm.currentTrack?.id == 1)
#expect(vm.manualQueue.isEmpty)
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30`
Expected: FAIL to compile — `value of type 'PlayerViewModel' has no member 'manualQueue' / 'addToQueue' / 'playNext'`.
- [ ] **Step 3: Create the `QueueEntry` model**
Create `Music/Models/QueueEntry.swift`:
```swift
import Foundation
// A single slot in the manual "Up Next" queue. Carries its own stable identity so
// the same track can be queued more than once without SwiftUI confusing the rows —
// Track.id alone is not unique across duplicate queue entries.
nonisolated struct QueueEntry: Identifiable {
let id = UUID()
let track: Track
}
```
- [ ] **Step 4: Add queue state to `PlayerViewModel`**
In `Music/ViewModels/PlayerViewModel.swift`, add the new stored properties immediately after the `originalQueue` line (currently line 20):
```swift
private var originalQueue: [Track] = []
/// The manual "Up Next" queue. Plays ahead of `queue` (the context) and survives
/// starting a new context. `queue`/`currentIndex` remain the CONTEXT position.
private(set) var manualQueue: [QueueEntry] = []
/// Display label for the panel's "Next from: <name>" section.
private(set) var contextName: String?
```
- [ ] **Step 5: Add the manual-queue methods and `playManual`**
In the same file, add a new section just after the `// MARK: - Queue Management` block (after `setQueue`'s closing brace, currently line 99). Note the `remoteProvider == nil` guard — the manual queue is local-only for v1:
```swift
// MARK: - Manual Queue
func playNext(_ track: Track) {
guard remoteProvider == nil else { return }
manualQueue.insert(QueueEntry(track: track), at: 0)
startQueuedTrackIfIdle()
}
func addToQueue(_ track: Track) {
guard remoteProvider == nil else { return }
manualQueue.append(QueueEntry(track: track))
startQueuedTrackIfIdle()
}
func removeFromQueue(at offsets: IndexSet) {
manualQueue.remove(atOffsets: offsets)
}
func moveInQueue(from source: IndexSet, to destination: Int) {
manualQueue.move(fromOffsets: source, toOffset: destination)
}
/// Context tracks after the current context position — the panel's "Next from"
/// section. Empty when there is no context or we are at its end.
var upcomingContext: [Track] {
guard let idx = currentIndex, idx + 1 < queue.count else { return [] }
return Array(queue[(idx + 1)...])
}
// If nothing is playing, start the just-queued track immediately rather than
// parking it — matches Spotify's "queue while idle starts playback".
private func startQueuedTrackIfIdle() {
guard currentTrack == nil, !manualQueue.isEmpty else { return }
let entry = manualQueue.removeFirst()
playManual(entry.track)
}
// Plays a track pulled from the manual queue. Mirrors play(_:) but deliberately
// does NOT touch currentIndex, so the context position is preserved and resumes
// once the manual queue drains.
private func playManual(_ track: Track) {
currentTrack = track
halfwayReported = false
isPlaying = true
currentTime = 0
duration = track.duration
guard let url = provider.urlForTrack(track) else { return }
provider.play(url: url)
}
```
- [ ] **Step 6: Run the tests to verify they pass**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30`
Expected: PASS (the two new tests plus all existing PlayerViewModel tests).
- [ ] **Step 7: Commit (checkpoint — via `/commit`)**
Stage and request a commit:
```bash
git add Music/Models/QueueEntry.swift Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift
# Then ask the user to run /commit (suggested message: "feat: add manual queue state and Play Next / Add to Queue to PlayerViewModel")
```
---
## Task 2: `next()` drains the manual queue, then resumes the context
**Files:**
- Modify: `Music/ViewModels/PlayerViewModel.swift:160-172` (the `next()` method)
- Test: `MusicTests/PlayerViewModelTests.swift`
- [ ] **Step 1: Write the failing tests**
Add to `PlayerViewModelTests`:
```swift
// Step 1: context [1,2,3], track 1 playing (currentIndex 0).
// Step 2: queue track 5. next() must play the QUEUED track, not context track 2,
// consume it from the queue, and leave currentIndex at 0 (context held).
@Test func nextConsumesManualQueueBeforeContext() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[4]) // id 5
vm.next()
#expect(vm.currentTrack?.id == 5)
#expect(vm.manualQueue.isEmpty)
#expect(vm.currentIndex == 0)
}
// Step 1: context [1,2,3], track 1 playing; queue track 5.
// Step 2: first next() plays the queued track 5 (context still at index 0).
// Step 3: second next() finds the queue empty and resumes the context at index 1
// → track 2.
@Test func contextResumesAfterManualQueueDrains() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[4]) // id 5
vm.next() // plays queued track 5
vm.next() // resumes context
#expect(vm.currentTrack?.id == 2)
#expect(vm.currentIndex == 1)
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests/nextConsumesManualQueueBeforeContext 2>&1 | tail -30`
Expected: FAIL — `next()` currently advances the context, so `currentTrack?.id` is `2`, not `5`.
- [ ] **Step 3: Update `next()`**
In `Music/ViewModels/PlayerViewModel.swift`, replace the existing `next()` method:
```swift
func next() {
if let remote = remoteProvider {
remote.sendNext()
return
}
guard let idx = currentIndex else { return }
let nextIdx = idx + 1
if nextIdx < queue.count {
play(queue[nextIdx])
} else {
stop()
}
}
```
with:
```swift
func next() {
if let remote = remoteProvider {
remote.sendNext()
return
}
// Manual queue takes priority and is consumed as it plays.
if !manualQueue.isEmpty {
let entry = manualQueue.removeFirst()
playManual(entry.track)
return
}
// Otherwise advance the context from the preserved position.
guard let idx = currentIndex else { return }
let nextIdx = idx + 1
if nextIdx < queue.count {
play(queue[nextIdx])
} else {
stop()
}
}
```
- [ ] **Step 4: Run the tests to verify they pass**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30`
Expected: PASS (new tests + existing tests, including `nextAtEndStops` and `nextAdvancesToNextTrack`, still green).
- [ ] **Step 5: Commit (checkpoint — via `/commit`)**
```bash
git add Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift
# Suggested message: "feat: next() drains the manual queue before advancing the context"
```
---
## Task 3: Edit ops, `upcomingContext`, `setQueue(contextName:)`, shuffle isolation
**Files:**
- Modify: `Music/ViewModels/PlayerViewModel.swift` (`setQueue`, `setProvider`)
- Test: `MusicTests/PlayerViewModelTests.swift`
- [ ] **Step 1: Write the failing tests**
Add to `PlayerViewModelTests`:
```swift
// Step 1: context [1,2,3], track 1 playing; queue tracks 4,5,6.
// Step 2: removeFromQueue removes the middle entry → [4,6].
// Step 3: moveInQueue moves the last entry to the front → [6,4].
@Test func removeAndMoveMutateManualQueue() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(6)
vm.setQueue(Array(tracks[0..<3]))
vm.play(tracks[0])
vm.addToQueue(tracks[3]); vm.addToQueue(tracks[4]); vm.addToQueue(tracks[5])
#expect(vm.manualQueue.map { $0.track.id } == [4, 5, 6])
vm.removeFromQueue(at: IndexSet(integer: 1))
#expect(vm.manualQueue.map { $0.track.id } == [4, 6])
vm.moveInQueue(from: IndexSet(integer: 1), to: 0)
#expect(vm.manualQueue.map { $0.track.id } == [6, 4])
}
// Step 1: context [1,2,3,4], track 2 playing (currentIndex 1).
// Step 2: upcomingContext is the slice after the current position → [3,4].
@Test func upcomingContextReturnsTracksAfterCurrent() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(4)
vm.setQueue(tracks)
vm.play(tracks[1])
#expect(vm.upcomingContext.map { $0.id } == [3, 4])
}
// Step 1: 10-track context, one playing; queue tracks 11 and 12 in order.
// Step 2: toggling shuffle reorders only the context — the manual queue order
// must be left exactly as the user arranged it.
@Test func shuffleLeavesManualQueueIntact() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(12)
vm.setQueue(Array(tracks[0..<10]))
vm.play(tracks[0])
vm.addToQueue(tracks[10]) // id 11
vm.addToQueue(tracks[11]) // id 12
vm.toggleShuffle()
#expect(vm.manualQueue.map { $0.track.id } == [11, 12])
}
// Step 1: setQueue accepts an optional context label for the panel header.
@Test func setQueueStoresContextName() {
let vm = PlayerViewModel(provider: AudioService(), db: nil)
vm.setQueue(makeTracks(2), contextName: "Synthwave")
#expect(vm.contextName == "Synthwave")
}
```
- [ ] **Step 2: Run the tests to verify they fail**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests/setQueueStoresContextName 2>&1 | tail -30`
Expected: FAIL to compile — `setQueue` has no `contextName:` parameter. (`removeAndMoveMutateManualQueue`, `upcomingContext...`, and `shuffleLeaves...` already pass from Task 1's methods, except the `contextName` compile error blocks the whole suite.)
- [ ] **Step 3: Add the `contextName` parameter to `setQueue`**
In `Music/ViewModels/PlayerViewModel.swift`, replace the `setQueue` signature line:
```swift
func setQueue(_ tracks: [Track]) {
originalQueue = tracks
```
with:
```swift
func setQueue(_ tracks: [Track], contextName: String? = nil) {
self.contextName = contextName
originalQueue = tracks
```
(The default `nil` keeps existing callers and tests compiling.)
- [ ] **Step 4: Reset the new state in `setProvider`**
In the `setProvider(_:)` method, add the two resets next to the existing `queue = []` / `originalQueue = []` lines (currently lines 55-56):
```swift
queue = []
originalQueue = []
manualQueue = []
contextName = nil
```
- [ ] **Step 5: Run the tests to verify they pass**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30`
Expected: PASS (all PlayerViewModel tests, old and new).
- [ ] **Step 6: Commit (checkpoint — via `/commit`)**
```bash
git add Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift
# Suggested message: "feat: queue edit ops, upcomingContext, and contextName label"
```
---
## Task 4: Context-menu config — `onPlayNext` / `onAddToQueue` + both menu builders
**Files:**
- Modify: `Music/Models/TrackContextMenuConfig.swift`
- Modify: `Music/Views/TrackContextMenuModifier.swift` (SwiftUI menu)
- Modify: `Music/Views/TrackTableView.swift:328-394` (AppKit menu + actions)
- Test: `MusicTests/TrackContextMenuConfigTests.swift`
- [ ] **Step 1: Write the failing test**
The two new config fields get `= nil` defaults (Step 3), so the existing
`TrackContextMenuConfig(...)` constructions in this file and in `ContentView`
keep compiling untouched. Just add a new test to `TrackContextMenuConfigTests`:
```swift
// Verifies the queue callbacks fire with the right track.
@Test func queueCallbacksFire() {
let track = Track.fixture(id: 7, title: "Q")
var playNextTrack: Track? = nil
var addQueueTrack: Track? = nil
let config = TrackContextMenuConfig(
playlists: [],
lastUsedPlaylistName: nil,
selectedPlaylist: nil,
onAddToPlaylist: { _, _ in },
onAddToLastPlaylist: nil,
onRemoveFromPlaylist: nil,
onPlayNext: { t in playNextTrack = t },
onAddToQueue: { t in addQueueTrack = t }
)
config.onPlayNext?(track)
config.onAddToQueue?(track)
#expect(playNextTrack?.id == 7)
#expect(addQueueTrack?.id == 7)
}
```
- [ ] **Step 2: Run the test to verify it fails**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/TrackContextMenuConfigTests 2>&1 | tail -30`
Expected: FAIL to compile — `extra arguments 'onPlayNext', 'onAddToQueue'` (the struct has no such members yet).
- [ ] **Step 3: Add the closures to the config struct**
In `Music/Models/TrackContextMenuConfig.swift`, add the two fields after `onRemoveFromPlaylist`. The `= nil` defaults flow into the synthesized memberwise initializer, so existing callers (ContentView + the other config test) keep compiling and the items stay hidden until wired:
```swift
let onRemoveFromPlaylist: ((Track) -> Void)?
// nil hides the corresponding item (e.g. when driving a remote device).
let onPlayNext: ((Track) -> Void)? = nil
let onAddToQueue: ((Track) -> Void)? = nil
```
- [ ] **Step 4: Run the config test to verify it passes**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/TrackContextMenuConfigTests 2>&1 | tail -30`
Expected: PASS, and the app target still builds (existing callers use the `nil` defaults).
> Note: the menu items are wired with real closures in Task 6. Until then `ContentView` passes the default `nil`, so the items stay hidden — expected interim state.
- [ ] **Step 5: Render the items in the SwiftUI menu**
In `Music/Views/TrackContextMenuModifier.swift`, inside `if let track, let config {`, insert this block **before** the existing `lastUsedPlaylistName` block (so Play Next / Add to Queue appear at the top):
```swift
if let onPlayNext = config.onPlayNext {
Button("Play Next") { onPlayNext(track) }
}
if let onAddToQueue = config.onAddToQueue {
Button("Add to Queue") { onAddToQueue(track) }
}
if config.onPlayNext != nil || config.onAddToQueue != nil {
Divider()
}
```
- [ ] **Step 6: Render the items in the AppKit menu**
In `Music/Views/TrackTableView.swift`, in `menuNeedsUpdate(_:)`, insert this block immediately after the two `guard` lines (after `guard let config = parent.contextMenuConfig else { return }`) and **before** the `if let lastPlaylistName` block:
```swift
if config.onPlayNext != nil {
let item = NSMenuItem(title: "Play Next", action: #selector(playNext(_:)), keyEquivalent: "")
item.target = self
menu.addItem(item)
}
if config.onAddToQueue != nil {
let item = NSMenuItem(title: "Add to Queue", action: #selector(addToQueue(_:)), keyEquivalent: "")
item.target = self
menu.addItem(item)
}
if config.onPlayNext != nil || config.onAddToQueue != nil {
menu.addItem(.separator())
}
```
Then add the two action handlers next to the existing `addToLastPlaylist` / `removeFromPlaylist` handlers (after `removeFromPlaylist(_:)`'s closing brace, currently line 394):
```swift
@objc func playNext(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
config.onPlayNext?(tracks[tableView.clickedRow])
}
@objc func addToQueue(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
config.onAddToQueue?(tracks[tableView.clickedRow])
}
```
- [ ] **Step 7: Commit (checkpoint — via `/commit`)**
```bash
git add Music/Models/TrackContextMenuConfig.swift Music/Views/TrackContextMenuModifier.swift Music/Views/TrackTableView.swift MusicTests/TrackContextMenuConfigTests.swift
# Suggested message: "feat: add Play Next / Add to Queue context-menu items"
```
---
## Task 5: `QueueView` panel + `PlayerControlsView` toggle button
**Files:**
- Create: `Music/Views/QueueView.swift`
- Modify: `Music/Views/PlayerControlsView.swift`
This task is UI; it is verified by a clean build and (optionally) by running the app, not by unit tests.
- [ ] **Step 1: Create `QueueView`**
Create `Music/Views/QueueView.swift`:
```swift
import SwiftUI
// The right-docked "Up Next" panel. The manual "Queue" section is reorderable and
// removable; the "Next from" section is the read-only upcoming context (double-click
// a row to jump to it).
struct QueueView: View {
var player: PlayerViewModel
var body: some View {
List {
if player.manualQueue.isEmpty && player.upcomingContext.isEmpty {
Text("Queue is empty.\nRight-click a track → Add to Queue.")
.font(.system(size: 12))
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: .infinity, alignment: .center)
.padding(.vertical, 24)
.listRowSeparator(.hidden)
}
if !player.manualQueue.isEmpty {
Section("Queue") {
ForEach(player.manualQueue) { entry in
HStack(spacing: 8) {
trackRow(entry.track)
Spacer()
Button {
if let idx = player.manualQueue.firstIndex(where: { $0.id == entry.id }) {
player.removeFromQueue(at: IndexSet(integer: idx))
}
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
}
}
.onMove(perform: player.moveInQueue)
}
}
if !player.upcomingContext.isEmpty {
Section("Next from: \(player.contextName ?? "Library")") {
ForEach(Array(player.upcomingContext.enumerated()), id: \.offset) { _, track in
trackRow(track)
.contentShape(Rectangle())
.onTapGesture(count: 2) { player.play(track) }
}
}
}
}
.listStyle(.inset)
.frame(width: 280)
}
private func trackRow(_ track: Track) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.font(.system(size: 12, weight: .medium))
.lineLimit(1)
Text(track.artist)
.font(.system(size: 10))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
```
- [ ] **Step 2: Add toggle inputs to `PlayerControlsView`**
In `Music/Views/PlayerControlsView.swift`, add three properties immediately after `var contextMenuConfig: TrackContextMenuConfig? = nil` (line 23):
```swift
var isQueueVisible: Bool = false
var showQueueButton: Bool = true
var onToggleQueue: (() -> Void)? = nil
```
- [ ] **Step 3: Render the queue button**
In the same file, replace the start of `volumeSection`:
```swift
private var volumeSection: some View {
HStack(spacing: 8) {
Image(systemName: volumeIconName)
```
with:
```swift
private var volumeSection: some View {
HStack(spacing: 8) {
if showQueueButton {
Button(action: { onToggleQueue?() }) {
Image(systemName: "list.bullet")
.font(.system(size: 13))
.foregroundStyle(isQueueVisible ? .blue : .secondary)
}
.buttonStyle(.plain)
}
Image(systemName: volumeIconName)
```
- [ ] **Step 4: Build to verify it compiles**
Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -20`
Expected: `** BUILD SUCCEEDED **` (the new toggle props are unused until Task 6 wires them — defaults keep `ContentView` compiling).
- [ ] **Step 5: Commit (checkpoint — via `/commit`)**
```bash
git add Music/Views/QueueView.swift Music/Views/PlayerControlsView.swift
# Suggested message: "feat: add Up Next QueueView panel and transport queue toggle"
```
---
## Task 6: Wire everything into `ContentView`
**Files:**
- Modify: `Music/ContentView.swift`
UI integration — verified by a full build and the complete test suite.
- [ ] **Step 1: Add panel visibility state**
In `Music/ContentView.swift`, add to the `@State` block (e.g. after `@State private var showHome = false`, line 24):
```swift
@State private var showQueue = false
```
- [ ] **Step 2: Wire the queue closures into the context-menu config**
Replace the whole `trackContextMenuConfig` computed property (lines 358-377) with:
```swift
private var trackContextMenuConfig: TrackContextMenuConfig {
// Queue actions are local-only for v1: hidden when driving a remote device.
let queueEnabled = !(networkStatus?.isRemoteMode ?? false)
return TrackContextMenuConfig(
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
// Outer nil hides the "Remove from Playlist" menu item when not in a playlist view.
// Inner re-check defends against the playlist being deselected between menu display and action.
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil,
onPlayNext: queueEnabled ? { track in player.playNext(track) } : nil,
onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil
)
}
```
- [ ] **Step 3: Dock the panel beside the main content**
In `body`, wrap the main-content region in an `HStack` and append the panel. Replace the opening of that region (currently lines 118-119):
```swift
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil {
```
with:
```swift
HStack(spacing: 0) {
VStack(spacing: 0) {
if showHome || playlist.selectedItem != nil {
```
Then replace its closing `.frame(maxHeight: .infinity)` (currently line 191) with:
```swift
}
.frame(maxHeight: .infinity)
if showQueue {
Divider()
QueueView(player: player)
}
}
```
(The first `}` closes the existing inner `VStack`; the new outer `}` closes the added `HStack`.)
- [ ] **Step 4: Pass context labels at every `setQueue` call site**
Make these four edits in `ContentView.swift`:
1. HomeView `onTrackDoubleClick` (line 155): `player.setQueue(recentTracks)``player.setQueue(recentTracks, contextName: "Recently Added")`
2. TrackTableView `onDoubleClick` (line 178): `player.setQueue(trackList)``player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")`
3. `onPlayPause` empty-state (line 393): `player.setQueue(trackList)``player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")`
4. Keyboard space handler (line 426): `player.setQueue(trackList)``player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")`
- [ ] **Step 5: Pass toggle props to `PlayerControlsView`**
In the `playerControls` computed property, add these arguments after `onNowPlayingTap:` and before `contextMenuConfig:` (line 408-409):
```swift
onNowPlayingTap: { scrollToPlayingTrigger = UUID() },
isQueueVisible: showQueue,
showQueueButton: !(networkStatus?.isRemoteMode ?? false),
onToggleQueue: { showQueue.toggle() },
contextMenuConfig: trackContextMenuConfig
```
- [ ] **Step 6: Build to verify it compiles**
Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -20`
Expected: `** BUILD SUCCEEDED **`.
- [ ] **Step 7: Run the full test suite**
Run: `xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | tail -30`
Expected: all tests pass, including the new queue tests and all pre-existing suites.
- [ ] **Step 8: Manual verification (optional but recommended)**
Use the `/run` or `/verify` skill to launch the app and confirm:
- Right-click a track → "Play Next" and "Add to Queue" appear and work.
- The transport `list.bullet` button toggles the right panel.
- Queued tracks show under "Queue", reorder by drag, and remove via the × button.
- Playing a queued track removes it from the panel; after the queue drains, the original playlist resumes at the right spot.
- [ ] **Step 9: Commit (checkpoint — via `/commit`)**
```bash
git add Music/ContentView.swift
# Suggested message: "feat: wire Up Next panel, queue toggle, and queue actions into ContentView"
```
---
## Self-Review Notes (for the implementer)
- **Backward compatibility:** `queue`/`currentIndex` keep meaning the *context*; every pre-existing `PlayerViewModelTests` case must stay green at each task. If one breaks, the new logic touched the context path incorrectly.
- **Remote gate:** queue methods early-return when `remoteProvider != nil`, and `ContentView` passes `nil` queue closures + hides the button when `networkStatus.isRemoteMode`. Streaming-client mode is *not* gated (it plays locally).
- **Duplicates:** `QueueEntry.id` (UUID) is the SwiftUI identity, so the same track can be queued multiple times without row glitches; removal looks up the entry by `id`, never by track.

@ -0,0 +1,525 @@
# Track Context Menu on Bottom Controls — Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Right-clicking the now-playing area at bottom-left shows the same Add/Remove playlist context menu as right-clicking a track row in the track table.
**Architecture:** Introduce a `TrackContextMenuConfig` value type that bundles all menu data + callbacks. A new `TrackContextMenuModifier` SwiftUI view modifier applies `.contextMenu` using that config. `TrackTableView` is refactored to accept a single `contextMenuConfig` parameter (replacing six individual playlist params), and `PlayerControlsView` gains the same optional parameter with the modifier applied to `nowPlayingSection`. `ContentView` constructs one config and passes it to both views.
**Tech Stack:** Swift 5.9+, SwiftUI `.contextMenu`, AppKit `NSMenu` (table view keeps existing AppKit path), Swift Testing framework.
---
## File Map
| File | Action |
|------|--------|
| `Music/Models/TrackContextMenuConfig.swift` | **Create** — value type holding playlists + callbacks |
| `Music/Views/TrackContextMenuModifier.swift` | **Create** — SwiftUI ViewModifier + View extension |
| `MusicTests/TrackContextMenuConfigTests.swift` | **Create** — unit tests for config struct |
| `Music/Views/TrackTableView.swift` | **Modify** — replace 6 playlist params with `contextMenuConfig`, update `menuNeedsUpdate` |
| `Music/Views/PlayerControlsView.swift` | **Modify** — add `contextMenuConfig` param, apply modifier to `nowPlayingSection` |
| `Music/ContentView.swift` | **Modify** — construct and pass `TrackContextMenuConfig` to both views |
---
### Task 1: Create `TrackContextMenuConfig`
**Files:**
- Create: `Music/Models/TrackContextMenuConfig.swift`
- Test: `MusicTests/TrackContextMenuConfigTests.swift`
- [ ] **Step 1: Write the failing test**
Create `MusicTests/TrackContextMenuConfigTests.swift`:
```swift
import Testing
@testable import Music
struct TrackContextMenuConfigTests {
// Builds a config with all fields set and verifies:
// - stored playlists, lastUsedPlaylistName, selectedPlaylist match the inputs
// - onAddToPlaylist callback fires with the correct track and playlist
// - onAddToLastPlaylist callback fires with the correct track
// - onRemoveFromPlaylist callback fires with the correct track
// - when optional callbacks are nil, optionally calling them is safe
@Test func storesPropertiesAndFiresCallbacks() {
// 1. Create fixture data
let pl1 = Playlist.fixture(id: 1, name: "Favorites")
let pl2 = Playlist.fixture(id: 2, name: "Chill")
let track = Track.fixture(id: 42, title: "Test")
var addedTrack: Track? = nil
var addedPlaylist: Playlist? = nil
var lastTrack: Track? = nil
var removedTrack: Track? = nil
// 2. Build config with all callbacks
let config = TrackContextMenuConfig(
playlists: [pl1, pl2],
lastUsedPlaylistName: "Favorites",
selectedPlaylist: pl1,
onAddToPlaylist: { t, p in addedTrack = t; addedPlaylist = p },
onAddToLastPlaylist: { t in lastTrack = t },
onRemoveFromPlaylist: { t in removedTrack = t }
)
// 3. Verify stored properties
#expect(config.playlists.count == 2)
#expect(config.playlists[0].name == "Favorites")
#expect(config.lastUsedPlaylistName == "Favorites")
#expect(config.selectedPlaylist == pl1)
// 4. Invoke callbacks and verify they fire correctly
config.onAddToPlaylist(track, pl2)
config.onAddToLastPlaylist?(track)
config.onRemoveFromPlaylist?(track)
#expect(addedTrack?.id == track.id)
#expect(addedPlaylist?.id == pl2.id)
#expect(lastTrack?.id == track.id)
#expect(removedTrack?.id == track.id)
}
@Test func nilOptionalCallbacksAreSafe() {
// Verifies that a config with nil optional callbacks does not crash
// when you call them via optional chaining (the normal usage pattern)
let pl = Playlist.fixture(id: 1, name: "Rock")
let track = Track.fixture()
let config = TrackContextMenuConfig(
playlists: [pl],
lastUsedPlaylistName: nil,
selectedPlaylist: nil,
onAddToPlaylist: { _, _ in },
onAddToLastPlaylist: nil,
onRemoveFromPlaylist: nil
)
// These must not crash
config.onAddToLastPlaylist?(track)
config.onRemoveFromPlaylist?(track)
#expect(config.lastUsedPlaylistName == nil)
#expect(config.selectedPlaylist == nil)
}
}
```
- [ ] **Step 2: Run tests to confirm they fail (type not found)**
```
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/TrackContextMenuConfigTests 2>&1 | grep -E "error:|FAIL|PASS|warning: cannot"
```
Expected: compile error — `TrackContextMenuConfig` not found.
- [ ] **Step 3: Create `Music/Models/TrackContextMenuConfig.swift`**
```swift
import Foundation
struct TrackContextMenuConfig {
let playlists: [Playlist]
let lastUsedPlaylistName: String?
let selectedPlaylist: Playlist?
let onAddToPlaylist: (Track, Playlist) -> Void
let onAddToLastPlaylist: ((Track) -> Void)?
let onRemoveFromPlaylist: ((Track) -> Void)?
}
```
- [ ] **Step 4: Add the new file to the Xcode project**
In Xcode, right-click the `Models` group in the project navigator → **Add Files to "Music"** → select `TrackContextMenuConfig.swift`. Make sure "Add to targets: Music" is checked.
- [ ] **Step 5: Run tests to confirm they pass**
```
xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/TrackContextMenuConfigTests 2>&1 | grep -E "Test.*passed|Test.*failed|error:"
```
Expected: both tests pass.
- [ ] **Step 6: Commit**
```bash
git add Music/Models/TrackContextMenuConfig.swift MusicTests/TrackContextMenuConfigTests.swift
git commit -m "feat: add TrackContextMenuConfig value type"
```
---
### Task 2: Create `TrackContextMenuModifier`
**Files:**
- Create: `Music/Views/TrackContextMenuModifier.swift`
> No unit test for this task — SwiftUI view modifier behaviour requires UI/snapshot testing not set up in this project. Manual verification is in Task 5.
- [ ] **Step 1: Create `Music/Views/TrackContextMenuModifier.swift`**
```swift
import SwiftUI
// Attaches a context menu matching the track table's right-click menu.
// No-ops silently when track or config is nil so callers can pass optionals freely.
struct TrackContextMenuModifier: ViewModifier {
let track: Track?
let config: TrackContextMenuConfig?
func body(content: Content) -> some View {
if let track, let config {
content.contextMenu {
if let lastPlaylistName = config.lastUsedPlaylistName,
let onAddToLastPlaylist = config.onAddToLastPlaylist {
Button("Add to \(lastPlaylistName)") {
onAddToLastPlaylist(track)
}
Divider()
}
if !config.playlists.isEmpty {
Menu("Add to Playlist") {
ForEach(config.playlists) { playlist in
Button(playlist.name) {
config.onAddToPlaylist(track, playlist)
}
}
}
}
if config.selectedPlaylist != nil,
let onRemoveFromPlaylist = config.onRemoveFromPlaylist {
Divider()
Button("Remove from Playlist") {
onRemoveFromPlaylist(track)
}
}
}
} else {
content
}
}
}
extension View {
func trackContextMenu(track: Track?, config: TrackContextMenuConfig?) -> some View {
modifier(TrackContextMenuModifier(track: track, config: config))
}
}
```
- [ ] **Step 2: Add the new file to the Xcode project**
In Xcode, right-click the `Views` group → **Add Files to "Music"** → select `TrackContextMenuModifier.swift`. Make sure "Add to targets: Music" is checked.
- [ ] **Step 3: Build to confirm it compiles**
```
xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"
```
Expected: `BUILD SUCCEEDED`.
- [ ] **Step 4: Commit**
```bash
git add Music/Views/TrackContextMenuModifier.swift
git commit -m "feat: add TrackContextMenuModifier SwiftUI view modifier"
```
---
### Task 3: Refactor `TrackTableView` to use `contextMenuConfig`
**Files:**
- Modify: `Music/Views/TrackTableView.swift:45-50` (replace 6 playlist params)
- Modify: `Music/Views/TrackTableView.swift:333-394` (update `menuNeedsUpdate` + action handlers)
- [ ] **Step 1: Replace the 6 individual playlist properties with `contextMenuConfig`**
In `Music/Views/TrackTableView.swift`, find lines 45–51:
```swift
var playlists: [Playlist]
var lastUsedPlaylistName: String?
var selectedPlaylist: Playlist?
var onAddToPlaylist: ((Track, Playlist) -> Void)?
var onAddToLastPlaylist: ((Track) -> Void)?
var onRemoveFromPlaylist: ((Track) -> Void)?
var onReorder: ((Int, Int) -> Void)?
```
Replace with:
```swift
var contextMenuConfig: TrackContextMenuConfig?
var onReorder: ((Int, Int) -> Void)?
```
- [ ] **Step 2: Update `menuNeedsUpdate` to read from `contextMenuConfig`**
Find the entire `menuNeedsUpdate` method (lines ~333–375) and replace it:
```swift
func menuNeedsUpdate(_ menu: NSMenu) {
menu.removeAllItems()
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
if let lastPlaylistName = config.lastUsedPlaylistName, config.onAddToLastPlaylist != nil {
let lastItem = NSMenuItem(
title: "Add to \(lastPlaylistName)",
action: #selector(addToLastPlaylist(_:)),
keyEquivalent: ""
)
lastItem.target = self
menu.addItem(lastItem)
menu.addItem(.separator())
}
if !config.playlists.isEmpty {
let submenu = NSMenu()
for (index, playlist) in config.playlists.enumerated() {
let item = NSMenuItem(
title: playlist.name,
action: #selector(addToPlaylist(_:)),
keyEquivalent: ""
)
item.target = self
item.tag = index
submenu.addItem(item)
}
let submenuItem = NSMenuItem(title: "Add to Playlist", action: nil, keyEquivalent: "")
submenuItem.submenu = submenu
menu.addItem(submenuItem)
}
if config.selectedPlaylist != nil, config.onRemoveFromPlaylist != nil {
menu.addItem(.separator())
let removeItem = NSMenuItem(
title: "Remove from Playlist",
action: #selector(removeFromPlaylist(_:)),
keyEquivalent: ""
)
removeItem.target = self
menu.addItem(removeItem)
}
}
```
- [ ] **Step 3: Update the three `@objc` menu action methods to use `contextMenuConfig`**
Find `addToPlaylist`, `addToLastPlaylist`, and `removeFromPlaylist` (lines ~377–394) and replace all three:
```swift
@objc func addToPlaylist(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
let track = tracks[tableView.clickedRow]
let playlist = config.playlists[sender.tag]
config.onAddToPlaylist(track, playlist)
}
@objc func addToLastPlaylist(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
let track = tracks[tableView.clickedRow]
config.onAddToLastPlaylist?(track)
}
@objc func removeFromPlaylist(_ sender: NSMenuItem) {
guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return }
guard let config = parent.contextMenuConfig else { return }
let track = tracks[tableView.clickedRow]
config.onRemoveFromPlaylist?(track)
}
```
- [ ] **Step 4: Build — expect ContentView compile errors (call site not yet updated)**
```
xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep "error:"
```
Expected: errors in `ContentView.swift` about removed parameters. `TrackTableView.swift` itself should be clean.
- [ ] **Step 5: Commit**
```bash
git add Music/Views/TrackTableView.swift
git commit -m "refactor: replace TrackTableView playlist params with TrackContextMenuConfig"
```
---
### Task 4: Add `contextMenuConfig` to `PlayerControlsView`
**Files:**
- Modify: `Music/Views/PlayerControlsView.swift:22` (add param after `onNowPlayingTap`)
- Modify: `Music/Views/PlayerControlsView.swift:112-118` (apply modifier to `nowPlayingSection`)
- [ ] **Step 1: Add the new parameter to `PlayerControlsView`**
In `Music/Views/PlayerControlsView.swift`, find line 22:
```swift
let onNowPlayingTap: () -> Void
```
Add after it:
```swift
let onNowPlayingTap: () -> Void
var contextMenuConfig: TrackContextMenuConfig? = nil
```
- [ ] **Step 2: Apply the modifier to `nowPlayingSection`**
In `PlayerControlsView.swift`, find the closing of `nowPlayingSection` (lines ~112–118):
```swift
.contentShape(Rectangle())
.onTapGesture {
if currentTrack != nil {
onNowPlayingTap()
}
}
}
```
Replace with:
```swift
.contentShape(Rectangle())
.onTapGesture {
if currentTrack != nil {
onNowPlayingTap()
}
}
.trackContextMenu(track: currentTrack, config: contextMenuConfig)
}
```
- [ ] **Step 3: Build — expect ContentView errors only**
```
xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep "error:"
```
Expected: only `ContentView.swift` errors remain (old params still passed there).
- [ ] **Step 4: Commit**
```bash
git add Music/Views/PlayerControlsView.swift
git commit -m "feat: add contextMenuConfig param to PlayerControlsView"
```
---
### Task 5: Update `ContentView` — wire both call sites
**Files:**
- Modify: `Music/ContentView.swift:181-194` (TrackTableView call site)
- Modify: `Music/ContentView.swift:333-362` (PlayerControlsView call site)
- [ ] **Step 1: Replace the TrackTableView playlist params with `contextMenuConfig`**
In `Music/ContentView.swift`, find lines 181–200 (inside `TrackTableView(...)`):
```swift
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil,
```
Replace with:
```swift
contextMenuConfig: TrackContextMenuConfig(
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil
),
```
- [ ] **Step 2: Pass `contextMenuConfig` to `PlayerControlsView`**
In `Music/ContentView.swift`, find the `PlayerControlsView(...)` block (lines ~333–362). Add `contextMenuConfig` after `onNowPlayingTap`:
```swift
onNowPlayingTap: { scrollToPlayingTrigger = UUID() },
contextMenuConfig: TrackContextMenuConfig(
playlists: playlist.playlists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, targetPlaylist in
try? playlist.addTrack(track, to: targetPlaylist)
},
onAddToLastPlaylist: { track in
try? playlist.addTrackToLastUsedPlaylist(track)
},
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil
)
```
- [ ] **Step 3: Build cleanly**
```
xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"
```
Expected: `BUILD SUCCEEDED` with no errors.
- [ ] **Step 4: Run the full test suite**
```
xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "Test.*passed|Test.*failed|error:|BUILD"
```
Expected: all tests pass, including `TrackContextMenuConfigTests`.
- [ ] **Step 5: Manual verification**
Run the app. With at least one playlist created:
1. **Track table:** right-click any track row → confirm the menu shows "Add to [last]" / "Add to Playlist" submenu / "Remove from Playlist" (when in a playlist view). Behaviour must be unchanged.
2. **Bottom controls:** play a track, then right-click anywhere on the now-playing area (album art + title + artist) → confirm the same menu appears.
3. **No track playing:** right-click the empty now-playing area → confirm no menu appears (the modifier is a no-op when `currentTrack` is nil).
- [ ] **Step 6: Commit**
```bash
git add Music/ContentView.swift
git commit -m "feat: wire TrackContextMenuConfig to bottom controls and track table"
```

File diff suppressed because it is too large Load Diff

@ -0,0 +1,110 @@
# Add "New Playlist…" to the Add-to-Playlist menu
**Date:** 2026-05-30
**Status:** Approved design
## Goal
In a track's right-click "Add to Playlist" submenu, let the user create a brand-new
regular playlist on the fly: pick **New Playlist…**, enter a name, and on save the
playlist is created and the track is added to it.
## Background
The "Add to Playlist" submenu lives in `TrackContextMenuModifier.swift` and is driven by
the data-only `TrackContextMenuConfig` struct (a `playlists` array plus action closures),
built in `ContentView.trackContextMenuConfig` (`ContentView.swift:415`).
The app already creates playlists from the sidebar via an `.alert` + `TextField`
(`ContentView.swift:273`), calling `PlaylistViewModel.createPlaylist(name:)`
`DatabaseService.createPlaylist(name:) -> Playlist`. Adding a track is
`PlaylistViewModel.addTrack(_:to:)`, which also records the playlist as last-used.
Regular and smart playlists are separate tables; this feature only creates **regular**
playlists (`Playlist`).
## Approach
Route the name prompt up to `ContentView`, which already owns the new-playlist alert.
SwiftUI alerts do not present reliably when attached *inside* a context menu's content
(the menu dismisses, and the per-row modifier is re-instantiated). So the menu item only
signals intent — "create a new playlist for this track" — and `ContentView` owns the
prompt and the orchestration. This reuses the existing alert pattern rather than
duplicating it inside the modifier (the rejected alternative, which would also need the
modifier to reach into `PlaylistViewModel`).
## Behavior decisions
- Adds **only the clicked track** (matches the current single-track "Add to Playlist").
- **No navigation** — after create+add the sidebar selection is unchanged.
- Empty / whitespace-only name → no-op (matches the existing create flow).
- When the user has **no** existing playlists, the submenu still appears showing just
"New Playlist…" (today the whole submenu is hidden when `playlists` is empty).
- Remote mode → wired the same way as the existing "Add to Playlist" action
(unconditionally), keeping parity with current behavior.
## Changes
### 1. `PlaylistViewModel` (`Music/ViewModels/PlaylistViewModel.swift`)
Add one orchestration method — the unit under test:
```swift
@discardableResult
func createPlaylistAndAddTrack(name: String, track: Track) throws -> Playlist {
let playlist = try db.createPlaylist(name: name)
try addTrack(track, to: playlist)
return playlist
}
```
`db.createPlaylist` returns a `Playlist` with its assigned `id`; `addTrack` adds the track
and sets `lastUsedPlaylistId` to the new playlist (so the "Add to <last>" item updates too).
### 2. `TrackContextMenuConfig` (`Music/Models/TrackContextMenuConfig.swift`)
Add a new optional closure, defaulting to `nil` (so all existing call sites and tests
compile unchanged):
```swift
let onAddToNewPlaylist: ((Track) -> Void)?
```
Add it to the explicit `init` with a `= nil` default, alongside the other optionals.
### 3. `TrackContextMenuModifier` (`Music/Views/TrackContextMenuModifier.swift`)
Inside the "Add to Playlist" submenu:
- Add a **New Playlist…** button at the top (ellipsis = opens a prompt) when
`onAddToNewPlaylist != nil`, followed by a `Divider` before the list of existing
playlists.
- Relax the visibility guard so the submenu shows when
`!config.playlists.isEmpty || config.onAddToNewPlaylist != nil` (previously hidden
whenever `playlists` was empty).
### 4. `ContentView` (`Music/ContentView.swift`)
- Add state: `@State private var newPlaylistTrack: Track?` and a name field
(reuse/parallel the existing `playlistNameInput` style; a dedicated field is fine).
- In `trackContextMenuConfig`, wire `onAddToNewPlaylist: { track in newPlaylistTrack = track }`.
- Present an alert (mirroring the existing New Playlist alert) gated on
`newPlaylistTrack != nil`. **Create** trims whitespace, and if non-empty calls
`playlist.createPlaylistAndAddTrack(name:track:)` with the pending track; **Cancel** and
completion both clear `newPlaylistTrack` and the name field.
## Testing (TDD)
Unit-test `PlaylistViewModel.createPlaylistAndAddTrack(name:track:)` against an in-memory
database:
1. Seed a track in the DB.
2. Call `createPlaylistAndAddTrack(name:track:)`.
3. Assert a regular playlist with that name now exists.
4. Assert the playlist's tracks contain the seeded track.
5. Assert `lastUsedPlaylistId` equals the new playlist's id.
The SwiftUI menu/alert rendering is not unit-tested, consistent with the rest of the
codebase. `TrackContextMenuConfig`'s new optional defaults to `nil`, so existing
`TrackContextMenuConfigTests` are unaffected.

@ -0,0 +1,166 @@
# Fix `bitrate = 0` Tracks — Design
**Date:** 2026-05-30
**Status:** Approved (design)
## Problem
Many tracks in the library have a stored `bitrate` of `0`. Bitrate of `0` is
displayed literally and is meaningless. Users want these tracks to show their
real bitrate, and want new imports to stop producing the problem.
## Root Cause
`ScannerService.extractMetadata()` extracts bitrate via AVFoundation:
```swift
// Music/Services/ScannerService.swift (~line 151)
let estimatedRate = try await audioTrack.load(.estimatedDataRate)
bitrate = Int(estimatedRate / 1000) // bits/sec -> kbps
```
For some files (observed: long / VBR MP3s such as 2-hour DJ "Essential Mix"
recordings), `AVAsset.estimatedDataRate` returns `0`. `Int(0 / 1000)` is `0`, so
a literal `0` is written to the DB. The `duration` field is extracted correctly
for the same files, which is what makes recovery reliable.
Tracks with `bitrate IS NULL` exist as well — the importer never produced any
value. These display as "—" and are treated as equally "missing" for this work.
### Evidence
Validated against a real `bitrate = 0` row (a ~120 min MP3):
| Method | Result |
|------------------------------------------|---------------|
| `fileSize × 8 ÷ duration ÷ 1000` | 256.0 kbps |
| `ffprobe -show_entries format=bit_rate` | 256005 → 256.0 kbps |
The two agree to the kbps. For VBR, both methods yield the true **average**
bitrate, which is the meaningful value.
## Scope
Both halves of the problem:
1. **Backfill** existing rows where `bitrate = 0 OR bitrate IS NULL`.
2. **Fix the importer** so future imports never store `0` again.
No mass rescan is required: the script repairs today's rows; the importer fix
only needs to guarantee correctness for new imports.
## Component 1 — Importer Fix (`ScannerService`)
Extract the bitrate decision into a pure, unit-testable function:
```swift
/// Resolve a track's bitrate (kbps) from the OS estimate, with a
/// file-size/duration fallback. Returns nil when no value can be derived —
/// never 0.
static func resolveBitrate(estimatedDataRate: Double,
fileSizeBytes: Int64?,
durationSeconds: Double?) -> Int?
```
Logic:
- `estimatedDataRate > 0`
`Int((estimatedDataRate / 1000).rounded())` (current behaviour, now rounded
rather than truncated).
- else if `fileSizeBytes != nil` **and** `durationSeconds != nil && > 0`
`Int((Double(fileSizeBytes) * 8 / durationSeconds / 1000).rounded())`.
- else → `nil`.
**Key invariant: the importer never stores `0`.** When nothing can be derived it
stores `nil`, which the UI already renders as "—".
`extractMetadata()` is updated to:
- read the file size it can already obtain via `FileManager.attributesOfItem(atPath:)`,
- pass `estimatedDataRate`, file size, and the loaded `duration` into
`resolveBitrate`,
- assign the result to `bitrate`.
This keeps the AVFoundation I/O in `extractMetadata` and the arithmetic in a pure
function that tests can drive directly.
## Component 2 — Backfill Script (`scripts/backfill_bitrate.py`)
Mirrors the conventions of the existing `scripts/backfill_itunes_dates.py`:
- Same `DEFAULT_DB` resolution (`~/Library/Containers/com.staxriver.mu/Data/
Library/Application Support/Music/db.sqlite`), with `--db` override.
- Reuses the `norm_path` / percent-decoding approach to turn a stored `fileURL`
into a POSIX path.
- **Dry-run by default**; `--apply` writes after a timestamped backup of the DB.
- `--self-test` runs offline unit checks and exits.
- Stdlib only (`sqlite3`, `subprocess`, `os`, `urllib`, …), plus `ffprobe` as an
optional external tool.
### Selection
```sql
SELECT id, fileURL, duration, bitrate
FROM tracks
WHERE bitrate = 0 OR bitrate IS NULL;
```
### Per-row bitrate determination
1. Resolve `fileURL` → POSIX path. If the file does not exist on disk →
report as **skipped (missing file)**, do not update.
2. Run `ffprobe -v error -show_entries format=bit_rate -of default=nw=1:nk=1 <path>`.
- Parse an integer bps → `round(bps / 1000)` kbps.
3. If ffprobe is **absent**, **errors**, or returns **`N/A`/empty**, fall back to
the formula: `round(fileSizeBytes * 8 / durationSeconds / 1000)`.
- If there is also no usable duration → report as
**skipped (undeterminable)**, do not update.
### Output
- **Dry-run:** a table of `path · old → new` plus a summary.
- **`--apply`:** create `db.sqlite.bak-<timestamp>`, then `UPDATE tracks SET
bitrate = ? WHERE id = ?` per resolved row, in a single transaction.
- **Summary (both modes):** counts for updated, skipped-missing-file,
skipped-undeterminable, and whether ffprobe was available.
## Testing (TDD — tests written before implementation)
### Swift (`MusicTests`)
Unit tests for `ScannerService.resolveBitrate`:
1. Positive `estimatedDataRate` → rounded kbps (e.g. `320450.0``320`).
2. `estimatedDataRate == 0` with valid size + duration → formula result
(e.g. 230_358_479 bytes, 7198.54 s → `256`).
3. `estimatedDataRate == 0`, valid size, **no/zero duration**`nil`.
4. `estimatedDataRate == 0`, **no file size**`nil`.
5. Confirms the function never returns `0`.
Each test carries a step-by-step comment describing what it exercises.
### Python (`--self-test`)
1. ffprobe-output parsing: `"256005\n"``256`.
2. `N/A`/empty ffprobe output → triggers formula fallback.
3. Formula math: `(230_358_479 * 8 / 7198.54 / 1000)` rounds to `256`.
4. `norm_path` edge cases (percent-encoding, `file://localhost/`, NFC, trailing
slash) — mirrored from the existing script's expectations.
### Manual verification
Run the script in dry-run against the real library DB and eyeball a sample
(ffprobe vs formula agreement) before `--apply`.
## Operational Notes
- `--apply` writes directly to the SQLite file. **Quit the app first** to avoid
WAL/lock contention — same caveat as `backfill_itunes_dates.py`.
- A timestamped backup is created before any write; restore by copying it back.
## Out of Scope
- No new UI (no in-app "repair bitrates" command); the script covers existing
rows and the importer covers future ones.
- No change to how bitrate is displayed.
- No re-encoding or modification of audio files — read-only analysis only.

@ -0,0 +1,97 @@
# iTunes/Music → app DB date & stats backfill (one-time)
Date: 2026-05-30
## Problem
`ScannerService.extractMetadata` sets `dateAdded: Date()` at scan time
(`Music/Services/ScannerService.swift:186`), so every track's "added date" in the
app DB is really its *scan* date, not the date the user originally added it in
Apple Music. The user wants the **true `Date Added`** (and, since Music.app tracks
them too, `Play Count`, `Rating`, and last-played) copied from their Apple Music
library into the app's SQLite database.
## Context (verified)
- The app is **sandboxed**. Current `PRODUCT_BUNDLE_IDENTIFIER` is `com.staxriver.mu`
(HEAD and working tree; not part of the uncommitted diff). The live DB is therefore at:
`~/Library/Containers/com.staxriver.mu/Data/Library/Application Support/Music/db.sqlite`
- **The real app and real library are on a different computer.** This machine only has a
3-track dev DB. The script must be portable and is intended to run on the other Mac.
- The user confirmed the audio files are the **same files** Apple Music references (they
live inside Apple Music's media folder, e.g.
`~/Music/Music/Media.localized/Music/...`). So the join key is the **file path**.
- `tracks.dateAdded` is a GRDB `.datetime` column, stored as the string
`YYYY-MM-DD HH:MM:SS.SSS` in UTC (confirmed from existing rows, e.g.
`2026-05-24 06:46:01.713`). GRDB is lenient on read, so `....000` round-trips.
- App rating scale is **0–5 stars** (`TrackTableView.swift:284` renders
`String(repeating: "★", count: track.rating)`). Music.app stores 0–100, so map `// 20`.
## Approach
A single **stdlib-only Python 3 script**, run once. Source of truth is the Music.app
**File ▸ Library ▸ Export Library…** XML plist (chosen over live AppleScript: no
Automation prompt, no timeouts, trivially parseable with `plistlib`). On matched tracks
it does a **blunt overwrite** of all four fields, with Music.app as the source of truth.
## Matching (the bug-prone part)
Join Music.app `Location` to `tracks.fileURL` on a **normalized decoded POSIX path**,
not raw URL strings. `norm_path()`:
1. strip leading `file://`, then optional `localhost` host segment,
2. percent-decode (`urllib.parse.unquote`),
3. `unicodedata.normalize("NFC", …)` — neutralizes the accented-filename NFC/NFD mismatch
between APFS storage and the two URL string sources,
4. strip a trailing slash.
Music tracks with no `Location` (Apple Music streaming entries) are skipped.
## Field mapping (matched rows only; blunt overwrite)
| Column | XML key | Rule |
|----------------|------------------|-------------------------------------------------------------|
| `dateAdded` | `Date Added` | `%Y-%m-%d %H:%M:%S.000` UTC. If absent, keep existing (col is NOT NULL). |
| `playCount` | `Play Count` | integer, `0` if absent. |
| `rating` | `Rating` (0–100) | `// 20` → 0–5, `0` if absent. |
| `lastPlayedAt` | `Play Date UTC` | same date format, or `NULL` if absent. |
## Safety
- **Dry-run by default**: prints match rate, a sample of before→after changes, and the
counts + samples of unmatched-in-DB and unmatched-in-XML. Writes nothing.
- `--apply`: first copies `db.sqlite` + `-wal` + `-shm` to a timestamped backup, then
performs all writes in a single transaction, then `PRAGMA wal_checkpoint(TRUNCATE)`.
Reversible by restoring the backup.
- The app must be **quit** before running so the sandbox DB isn't mid-write.
## CLI
```
python3 scripts/backfill_itunes_dates.py --xml <Library.xml> [--db <path>] [--apply] [--self-test]
```
- Default `--db`: `~/Library/Containers/com.staxriver.mu/Data/Library/Application Support/Music/db.sqlite`
computed from `$HOME`, so it resolves on the other Mac too.
## Testing
`scripts/test_backfill_itunes_dates.py` (stdlib `unittest`):
- `norm_path`: NFC/NFD equivalence, `file://localhost/` form, percent-encoding,
filenames with spaces/`#`/parentheses/apostrophes.
- `build_updates`: date formatting, rating `// 20`, playCount & lastPlayed present/absent,
unmatched-row handling.
- Integration: a temp SQLite DB with the real `tracks` schema seeded with the user's actual
3 track paths + scan dates and a synthetic Library.xml → `apply` → assert rows updated.
## Delivery
Script + test live in the repo under `scripts/`. The user commits (via `/commit`), pushes,
pulls on the real machine, runs **File ▸ Library ▸ Export Library…** there, then runs the
dry-run, eyeballs the match rate, and re-runs with `--apply`.
## Out of scope
Scanning the library into the app (the user does that in-app first), ongoing/automatic sync,
and non-file (streaming) tracks.

@ -0,0 +1,243 @@
# Playing Queue — Design
**Date:** 2026-05-30
**Status:** Approved (pending spec review)
## Overview
Add a Spotify-style **priority "Up Next" queue** to the Music app. Users can push
tracks to the front ("Play Next") or end ("Add to Queue") of a manual queue via the
track context menu. The manual queue plays before the current playback context
(playlist/album) resumes, survives starting a new context, and is visible and
editable in a right-docked "Up Next" panel.
## Goals
- "Play Next" and "Add to Queue" actions on the track context menu.
- A manual queue that takes priority over the playback context and persists when a
new context (playlist/album/library) starts playing.
- A visible, right-docked "Up Next" panel showing the manual queue and the upcoming
context tracks.
- Drag-to-reorder and remove **within the manual queue**.
## Non-Goals (v1)
- **Remote/streaming support.** When this app is driving a remote device, queue
actions are hidden and `next()` continues to delegate over the wire. No
`RemoteProtocol` changes. (Possible follow-up.)
- **Persistence across app restart.** The queue is in-memory in `PlayerViewModel`.
- **Reordering the context** from the panel (that is playlist editing, already
handled elsewhere). The "Next from" section is read-only.
- **Clear-all-queue** action (not requested).
- **Multi-select queueing.** Actions operate on the single right-clicked track,
consistent with the existing "Add to Playlist".
## Resolved Decisions
| Decision | Choice | Reason |
|---|---|---|
| Queue model | Spotify-style priority queue (manual queue distinct from context) | User selection |
| Panel placement | Right-docked slide-out, toggled from transport bar | User selection (mockup A) |
| Remote scope | Local-only for v1; hide actions in remote mode | User selection |
| Persistence | In-memory only | User selection |
| `queue`/`currentIndex` naming | Keep as the **context**; add doc comments | Backward-compatible; avoids touching remote-sync + existing tests |
| "Next from" section | Read-only, double-click to jump | Context reordering is out of scope |
## Data Model (`PlayerViewModel`)
Existing `queue` / `originalQueue` / `currentIndex` are **retained and keep their
current meaning: the playback CONTEXT** (playlist/album, with shuffle applied). New
state is added alongside:
```swift
private(set) var queue: [Track] // UNCHANGED — the context (shuffled view)
private var originalQueue: [Track] // UNCHANGED — context, original order
var currentIndex: Int? // UNCHANGED — index into `queue` (the CONTEXT
// position), held even while a manual track plays
private(set) var manualQueue: [QueueEntry] = [] // NEW — priority "Up Next" entries
private(set) var contextName: String? // NEW — label for "Next from: <name>"
```
Each manual entry carries its own identity so the same track can be queued twice
without SwiftUI confusing the rows (the codebase has prior id-collision bugs):
```swift
nonisolated struct QueueEntry: Identifiable {
let id = UUID()
let track: Track
}
```
A dedicated `playManual(_:)` path plays a queued track **without** touching
`currentIndex`, so the context position is preserved automatically — no extra "am I
playing from the queue?" flag is needed.
Computed for the panel:
```swift
// Context tracks after the current context position; the "Next from" section.
var upcomingContext: [Track] {
guard let idx = currentIndex, idx + 1 < queue.count else { return [] }
return Array(queue[(idx + 1)...])
}
```
`currentIndex` deliberately tracks the **context** position, not "what is playing."
While a manual-queue track plays, `currentIndex` stays put so the context resumes at
the correct spot once the manual queue drains.
## Behavior
### Adding to the queue
```swift
func playNext(_ track: Track) // insert at front of manualQueue
func addToQueue(_ track: Track) // append to end of manualQueue
```
Both: if **nothing is currently playing** (`currentTrack == nil`), immediately pop
and play the just-queued track instead of leaving it parked (queue-while-idle starts
playback).
### Advancing
```swift
func next() {
if remoteProvider != nil { remote.sendNext(); return } // UNCHANGED remote path
if !manualQueue.isEmpty {
let entry = manualQueue.removeFirst() // consume-on-play
playManual(entry.track) // currentIndex unchanged
} else {
// existing context-advance logic (currentIndex + 1, stop at end)
}
}
```
- **Consume-on-play:** `removeFirst()` removes the track from "Up Next" the instant
it starts. The Queue section only ever shows not-yet-played tracks.
- **Resume point:** when `manualQueue` empties, `next()` advances the context from
the preserved `currentIndex`.
- Triggered identically by user-pressed Next and by auto-advance (`trackDidFinish`).
### Previous
Unchanged: steps back through the **context** (`currentIndex − 1`, clamped at 0).
It never re-adds consumed queue items and does not consult `manualQueue` (accepted
v1 simplification).
### Shuffle
Unchanged: only the context (`queue`) is shuffled. `manualQueue` order is always
preserved.
### Explicit play / new context
`play(_:)` (double-click a row, space-bar, Home) sets a new **context** via
`setQueue(_:contextName:)` and plays from it (setting `currentIndex`). The manual
queue is **not** cleared — it survives the new context, matching the chosen Spotify
model.
### Queue editing
```swift
func moveInQueue(from: IndexSet, to: Int) // reorder manualQueue (panel drag)
func removeFromQueue(at: IndexSet) // remove from manualQueue (panel × / swipe)
```
## UI
### `setQueue` signature change
```swift
func setQueue(_ tracks: [Track], contextName: String? = nil)
```
Call sites in `ContentView` pass the context label: playlist/smart-playlist name,
`"Library"`, or `"Recently Added"` (Home). Default `nil` keeps existing tests and
any unlabeled callers compiling.
### Up Next panel — new `QueueView`
A SwiftUI `List`, docked on the right of the main content:
- **Section "Queue"**`player.manualQueue`. `.onMove``moveInQueue`; row × /
`.swipeActions``removeFromQueue`.
- **Section "Next from: \<contextName\>"**`player.upcomingContext`, read-only.
Double-click a row to jump to it in the context (sets `currentIndex` and plays).
- **Empty state** — "Queue is empty. Right-click a track → Add to Queue."
### Integration into `ContentView`
- New `@State private var showQueue = false`.
- Wrap the main-content region (the `VStack` holding `HomeView`/`TrackTableView`) in
`HStack(spacing: 0) { mainContent; if showQueue { QueueView(player: player) } }`.
- `PlayerControlsView` gains a queue-toggle button (bottom-right of the transport
bar) bound to `showQueue`. The button is **hidden when `networkStatus` indicates
remote-drive mode**.
### Context menu — `TrackContextMenuConfig`
Add two optional closures:
```swift
let onPlayNext: ((Track) -> Void)?
let onAddToQueue: ((Track) -> Void)?
```
Rendered in **both** menu builders, as a group above "Add to Playlist":
- `TrackTableView.Coordinator.menuNeedsUpdate(_:)` (AppKit `NSMenuItem`s + actions).
- `TrackContextMenuModifier` (SwiftUI `Button`s).
Each is shown only when its closure is non-nil. In `ContentView.trackContextMenuConfig`
they are wired to `player.playNext` / `player.addToQueue`, and passed as **`nil`
when driving a remote device** so the items are hidden.
## Remote / Edge Handling (local-only v1)
- The disable gate is specifically the **`RemotePlaybackProvider`** case (driving a
separate remote device, `networkStatus.mode == .remote`): `next()` delegates over
the wire (unchanged) and never consults `manualQueue`; queue menu items hidden (nil
closures); queue-toggle button hidden. No protocol changes.
- **Streaming client** mode plays locally through `StreamingPlaybackProvider`, so it
is **not** gated — the queue works there, as it does for local and streaming-host
playback.
- Empty manual queue + empty upcoming context: panel shows empty state; `next()`
at the end of the context stops, as today.
## Testing (TDD)
New `PlayerViewModelTests` cases (reusing the `AudioService()` / `FakeStreamingProvider`
pattern already in the file). Each test carries a step-by-step comment:
1. `addToQueue` appends to `manualQueue`; `playNext` inserts at the front.
2. `next()` plays the front of `manualQueue` before advancing the context, and
`removeFirst` consumes it.
3. After `manualQueue` drains, `next()` resumes the context at `currentIndex + 1`.
4. Queue-while-idle (`currentTrack == nil`) starts playback immediately.
5. `toggleShuffle()` leaves `manualQueue` order unchanged.
6. `removeFromQueue` / `moveInQueue` mutate `manualQueue` correctly.
7. `upcomingContext` returns the correct slice of the context.
8. Existing PlayerViewModel tests remain green (backward-compatible model).
New `TrackContextMenuConfigTests` case: the `onPlayNext` / `onAddToQueue` closures
fire with the expected track.
## Files Touched
- `Music/ViewModels/PlayerViewModel.swift` — state, `playNext`/`addToQueue`/
`moveInQueue`/`removeFromQueue`, `next()` priority logic, `upcomingContext`,
`setQueue(contextName:)`.
- `Music/Models/QueueEntry.swift`**new** identity wrapper for queued tracks.
- `Music/Models/TrackContextMenuConfig.swift` — two new closures.
- `Music/Views/TrackTableView.swift` — AppKit menu items + actions.
- `Music/Views/TrackContextMenuModifier.swift` — SwiftUI menu buttons.
- `Music/Views/PlayerControlsView.swift` — queue-toggle button.
- `Music/Views/QueueView.swift`**new** Up Next panel.
- `Music/ContentView.swift``showQueue` state, panel layout, wiring, `contextName`
at `setQueue` call sites.
- `MusicTests/PlayerViewModelTests.swift`, `MusicTests/TrackContextMenuConfigTests.swift`
— new tests.
No `project.pbxproj` edit is needed: the project uses Xcode 16 file-system
synchronized groups, so new files under `Music/` are picked up automatically.

@ -0,0 +1,166 @@
# Smart Playlist Conditions — Design Spec
**Date:** 2026-05-30
**Status:** Approved
## Overview
Extend smart playlists to support structured metadata-based conditions (e.g. artist = "Alicia Keys", year > 2015). Multiple conditions combine with AND. The existing FTS free-text smart playlist flow is preserved unchanged.
## Data Model
### SmartPlaylist (extended)
Add one new optional field to the existing `SmartPlaylist` struct:
```swift
var conditions: [SmartPlaylistCondition]?
```
- `nil` → FTS mode (existing behavior, no change)
- non-nil → structured SQL WHERE mode (new behavior)
### SmartPlaylistCondition
```swift
struct SmartPlaylistCondition: Codable, Equatable, Sendable {
var field: TrackField
var op: ConditionOperator
var value: ConditionValue
}
```
### TrackField
`String` raw-value enum. Raw value matches the SQLite column name exactly (used directly in query building):
```
title, artist, albumArtist, album, genre, composer,
year, bpm, rating, playCount, trackNumber, discNumber,
duration, bitrate, sampleRate, fileSize,
dateAdded, dateModified, lastPlayedAt, fileFormat
```
Each field has an associated `FieldType`: `.string`, `.int`, `.double`, `.date`.
### ConditionOperator
```swift
enum ConditionOperator: String, Codable {
case equals
case startsWith // strings only
case greaterThan // numbers and dates only
case lessThan // numbers and dates only
}
```
Valid operators per field type:
- String: `equals`, `startsWith`
- Number (Int, Double): `equals`, `greaterThan`, `lessThan`
- Date: `equals`, `greaterThan`, `lessThan`
### ConditionValue
Tagged Codable union:
```swift
enum ConditionValue: Codable, Equatable, Sendable {
case string(String)
case int(Int)
case double(Double)
case date(Date)
}
```
## Persistence
### DB Migration (v5)
```sql
ALTER TABLE smart_playlists ADD COLUMN conditions TEXT;
```
Nullable, no default. Existing rows stay `NULL` (FTS mode).
### Encoding
`SmartPlaylist.conditions` is encoded as a JSON string when writing to the `conditions` column and decoded on read. GRDB's `Codable` conformance handles this automatically via a custom `columnEncodingStrategy` or manual encode/decode.
## Query Evaluation
### SQL Generation
New private method in `DatabaseService`:
```swift
private func buildWhereClause(_ conditions: [SmartPlaylistCondition]) -> (sql: String, args: StatementArguments)
```
Mapping:
| Field type | Operator | SQL fragment |
|------------|--------------|-------------------------------------------|
| String | equals | `LOWER({col}) = LOWER(?)` |
| String | startsWith | `LOWER({col}) LIKE LOWER(?) || '%'` |
| Number | equals | `{col} = ?` |
| Number | greaterThan | `{col} > ?` |
| Number | lessThan | `{col} < ?` |
| Date | equals | `{col} = ?` |
| Date | greaterThan | `{col} > ?` |
| Date | lessThan | `{col} < ?` |
Fragments joined with ` AND `. Final query:
```sql
SELECT * FROM tracks WHERE <clause> ORDER BY <sortCol> COLLATE NOCASE <asc|desc>
```
### fetchTracks branch
`PlaylistViewModel.observeSmartPlaylistTracks` branches on `smartPlaylist.conditions`:
- `nil` → existing FTS `ValueObservation` (unchanged)
- non-nil → new SQL WHERE `ValueObservation` using `buildWhereClause`
## UI
### Entry Point
New "New Smart Playlist…" item in the app menu (alongside existing "New Playlist…"). Triggers a sheet (not an alert) since the form has multiple fields.
### Condition Builder Sheet
```
┌─────────────────────────────────────────┐
│ New Smart Playlist │
│ │
│ NAME │
│ [________________________] │
│ │
│ CONDITIONS (all must match) │
│ [Field ▾] [Operator ▾] [Value ] [−] │
│ [Field ▾] [Operator ▾] [Value ] [−] │
│ │
│ [+ Add Condition ] │
│ │
│ [Cancel] [Save] │
└─────────────────────────────────────────┘
```
- One condition row shown by default
- Operator picker options update when field changes (string → is/starts with; number/date → is/greater than/less than)
- Value input adapts to field type: `TextField` for strings and numbers, `DatePicker` for date fields
- Remove (−) button disabled when only one condition remains
- Save disabled until name is non-empty and all condition values are non-empty
### Edit Flow
- **Structured smart playlists:** context menu shows "Edit…" → reopens the builder sheet pre-populated
- **FTS smart playlists:** context menu keeps existing "Edit Search Query…" → existing text alert (no change)
## Out of Scope
- OR logic between conditions
- Nested condition groups
- Track limit per playlist ("limit to N songs")
- Migrating existing FTS playlists to structured format
- Live-updating toggle (not needed — `ValueObservation` already handles this automatically)

@ -0,0 +1,86 @@
---
title: Track Context Menu on Bottom Controls
date: 2026-05-30
status: approved
---
## Goal
Right-clicking the now-playing area (bottom-left of the window) shows the same context menu as right-clicking a track in the track table: add to last playlist, add to any playlist via submenu, and remove from the current playlist when one is open.
## Shared Config Struct
A new `TrackContextMenuConfig` struct captures everything the menu needs:
```swift
struct TrackContextMenuConfig {
let playlists: [Playlist]
let lastUsedPlaylistName: String?
let selectedPlaylist: Playlist?
let onAddToPlaylist: (Track, Playlist) -> Void
let onAddToLastPlaylist: ((Track) -> Void)?
let onRemoveFromPlaylist: ((Track) -> Void)?
}
```
This is the single source of truth for menu data. Both `TrackTableView` and `PlayerControlsView` receive one instance, constructed by `ContentView`.
## Shared ViewModifier
`TrackContextMenuModifier` is a SwiftUI `ViewModifier` that takes a `Track?` and `TrackContextMenuConfig?` and applies `.contextMenu` when both are non-nil:
- **"Add to [last]"** button — shown only when `lastUsedPlaylistName` is set and `onAddToLastPlaylist` is non-nil.
- **"Add to Playlist"** submenu — one `Button` per playlist in `playlists`. Calls `onAddToPlaylist(track, playlist)`.
- **Divider + "Remove from Playlist"** — shown only when `selectedPlaylist != nil` and `onRemoveFromPlaylist` is non-nil.
Menu is omitted entirely (no empty menu flicker) when `track` or `config` is nil.
## PlayerControlsView Changes
`PlayerControlsView` gains one new optional parameter:
```swift
let contextMenuConfig: TrackContextMenuConfig?
```
The `nowPlayingSection` view applies `.trackContextMenu(track: currentTrack, config: contextMenuConfig)` (a convenience extension wrapping `TrackContextMenuModifier`).
## TrackTableView Refactor
`TrackTableView`'s existing four playlist-related parameters (`playlists`, `lastUsedPlaylistName`, `selectedPlaylist`, `onAddToLastPlaylist`, `onRemoveFromPlaylist`) are replaced by a single `contextMenuConfig: TrackContextMenuConfig?`. The `Coordinator.menuNeedsUpdate` builds its `NSMenu` from this config. This makes both call sites symmetric.
> The AppKit `NSMenu` path in `TrackTableView` is kept — SwiftUI `.contextMenu` does not attach per-row in an `NSTableView`, so the table continues using `menuNeedsUpdate`.
## ContentView Changes
`ContentView` constructs one `TrackContextMenuConfig` and passes it to both views:
```swift
let menuConfig = TrackContextMenuConfig(
playlists: playlist.allPlaylists,
lastUsedPlaylistName: playlist.lastUsedPlaylistName,
selectedPlaylist: playlist.selectedPlaylist,
onAddToPlaylist: { track, pl in try? playlist.addTrack(track, to: pl) },
onAddToLastPlaylist: { track in try? playlist.addTrackToLastUsedPlaylist(track) },
onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in
if let selected = playlist.selectedPlaylist {
try? playlist.removeTrack(track, from: selected)
}
} : nil
)
```
## Files Affected
| File | Change |
|------|--------|
| `Music/Models/TrackContextMenuConfig.swift` | New file — struct definition |
| `Music/Views/TrackContextMenuModifier.swift` | New file — SwiftUI ViewModifier |
| `Music/Views/PlayerControlsView.swift` | Add `contextMenuConfig` param, apply modifier to `nowPlayingSection` |
| `Music/Views/TrackTableView.swift` | Replace individual playlist params with `contextMenuConfig`, adapt `menuNeedsUpdate` |
| `Music/ContentView.swift` | Construct and pass `TrackContextMenuConfig` to both views |
## Out of Scope
- Keyboard shortcut for the menu
- Any new menu items not already in the track table menu

@ -0,0 +1,206 @@
# Track "Get Info" — Design Spec
**Date:** 2026-05-30
**Status:** Approved (design); pending implementation plan
**Branch:** feat/music-streaming
## Goal
Replicate the macOS Music app's right-click **Get Info** experience: a dialog that
shows all of a track's metadata and lets the user edit it. Edits persist to the
app's library (SQLite DB) **and** are written back into the audio file's embedded
tags, mirroring how the real Music app mutates files.
## Decisions (locked)
| Question | Decision |
|---|---|
| Where edits are saved | **DB + write file tags** (best-effort file writeback) |
| Single vs multi-track | **Both** — single track shows all fields; multiple selected tracks use mixed-value handling |
| Field scope | **Existing model fields only** — no schema migration; read-only File info section |
| Writeback formats (v1) | **mp3** (ID3TagEditor) + **m4a / alac / aac** (AVFoundation). flac/wav/aiff → DB only with a UI note |
| Tag library strategy | **Lighter path, TagLib-ready** — abstract behind a `TagWriter` protocol so a TagLib writer can be added later for flac/wav/aiff with no rework |
| Layout | **Tabbed** (Details / File), like macOS Music |
| Failure model | **DB is always saved; file writeback is best-effort** with a non-blocking warning on failure |
## Non-goals (v1)
- No new metadata fields (no comments, lyrics, sorting, artwork). Editing is limited to
fields already present on the `Track` model.
- No album-artwork display or editing.
- No flac/wav/aiff **file-tag** writeback (those edits save to the DB only for now).
The architecture leaves a clean seam to add TagLib later.
## Data model context
`Track` (Music/Models/Track.swift) already holds every field we need. No migration.
**Editable fields** (surfaced on the Details tab):
`title`, `artist`, `albumArtist`, `album`, `genre`, `composer` (String);
`year`, `trackNumber`, `discNumber`, `bpm` (Int?); `rating` (Int 0–5).
> **rating is DB-only in v1.** It's an app/iTunes concept with format-specific
> scales (ID3 POPM 0–255, iTunes atom 0–100), so writing it to file tags is
> deferred. `EditableTrackFields` includes `rating`, but the `TagWriter`s ignore it
> — rating persists only via `updateTrack`. All other editable fields are written to
> both file and DB (where a writer exists).
**Read-only fields** (File tab): `fileURL` (path), `fileFormat`, `bitrate`,
`sampleRate`, `fileSize`, `duration`, `playCount`, `lastPlayedAt`, `dateAdded`,
`dateModified`. (`playCount`/`lastPlayedAt`/`rating` are app-managed, not file tags.)
> Metadata source today: tracks are read from file tags via AVFoundation **once at
> import** (ScannerService), then the DB is the source of truth. `insertBatch` uses
> `.ignore` on conflict, so re-scans do not clobber DB edits.
## Architecture & components
Each unit has one responsibility and a well-defined interface.
### `EditableTrackFields` (new value type)
A plain struct of the ~11 editable fields. The unit of "what the user can change"
and "what changed." Decouples the sheet and the writer from the full `Track`.
### `TagWriter` protocol + factory (new)
```
protocol TagWriter {
func write(_ fields: EditableTrackFields, to fileURL: URL) throws
}
```
- `TagWriterFactory.writer(for: URL) -> TagWriter?` selects by file extension;
returns `nil` for unsupported formats (flac/wav/aiff in v1).
- Implementations:
- `ID3TagWriter` — mp3, via the **ID3TagEditor** SPM package.
- `MP4TagWriter` — m4a / alac / aac, via **AVFoundation** `AVAssetExportSession`
(`AVAssetExportPresetPassthrough`) writing iTunes/common metadata items.
- **No code outside the writers knows how tags are encoded per format.**
### `TrackEditService` (new)
Orchestrates one save. For each target track:
1. Diff `EditableTrackFields` against the original → set of changed fields.
2. If a `TagWriter` exists for the format: write tags to a **temp copy**, then
`FileManager.replaceItemAt` to atomically swap the original (never leaves a
half-written file).
3. Recompute `fileSize`, `dateModified`, `fileHash` from the new file.
4. Build the updated `Track` (changed fields + refreshed stats) and persist via
`DatabaseService.updateTrack`.
5. On file-write failure (or unsupported format): still persist DB changes; collect
a warning for that track.
Owns the **single- and multi-track diff logic** as pure, testable functions
(no UI, no I/O in the diff step).
### `TrackInfoSheet` (new SwiftUI view)
The Get Info dialog (see Layout). Holds local `@State` for the edited fields,
prefilled from the target(s). On Save, hands `EditableTrackFields` + the target
track set to `TrackEditService`. Models the `.sheet` pattern already used by
`SmartPlaylistBuilderSheet`.
### `DatabaseService.updateTrack(_ track: Track) throws` (new method)
GRDB `track.update(db)` inside `dbPool.write`. **Implementation plan must verify
whether the `tracks_ft` FTS5 table is kept in sync automatically (triggers /
external-content) and, if not, update it here.**
### Context-menu integration (edits)
- Add **Get Info** (⌘I) to `TrackContextMenuConfig`, `TrackTableView`'s `NSMenu`
(`menuNeedsUpdate`), and `TrackContextMenuModifier`.
- Target resolution: the menu operates on the **current selection if the
right-clicked row is part of it**, otherwise just the clicked row (matches macOS
Music). This requires the config to expose the current multi-selection (or a
callback that returns the target set), not just a single `Track`.
- `ContentView` holds the presented-target state and shows the `.sheet`.
## Save sequence (per track)
```
edit fields ─▶ diff vs original ─▶ writer for format?
│ yes │ no / unsupported
▼ ▼
write tags to temp copy (skip file write,
─▶ atomic replace original collect "DB-only" note)
success? │ fail ─────────┐
▼ ▼
recompute size/mod/hash keep old stats + warning
│ │
└──────┬────────┘
updateTrack(...) in DB
```
Result: the **DB edit always lands**; file writeback is best-effort. Failures
surface as a single non-blocking summary alert ("Saved to library. Couldn't write
tags to N file(s): <reason>").
## Multi-track behavior
- Prefill: fields with one shared value across all targets prefill normally;
fields that differ show a **"Mixed"** placeholder and start empty.
- Apply: **only fields the user actually edits** are applied — to all targets.
Untouched "Mixed" fields are left per-track unchanged.
- Saving N tracks runs in a background `Task`, sequentially, with a small progress
indicator when N is large. Per-track failures aggregate into one summary.
## UI layout (tabbed)
```
┌─ Get Info ──────────────────────────────┐
│ [ Details ] [ File ] │
├──────────────────────────────────────────┤
│ Title [_______________________] │
│ Artist [_______________________] │
│ Album Artist[_______________________] │
│ Album [_______________________] │
│ Genre [_______________________] │
│ Composer [_______________________] │
│ Year [____] Track [__]/[__] Disc [__]/[__]
│ BPM [____] Rating ★★★☆☆ │
├──────────────────────────────────────────┤
│ File tab: format, bitrate, sample rate, │
│ size, duration, path, date added, │
│ play count, last played — read-only │
├──────────────────────────────────────────┤
│ [ Cancel ] [ Save ] │
└──────────────────────────────────────────┘
```
- Numeric fields (year / track / disc / bpm) validate on input.
- flac/wav/aiff targets show a subtle note under the tabs: *"Edits save to your
library only — tag writing isn't supported for .flac yet."*
- Cancel = `.cancelAction`, Save = `.defaultAction`.
## Error handling & risks
- **Atomic replace** (temp file + `replaceItemAt`) prevents audio-file corruption on
a failed/interrupted write.
- **File-write failure** → DB still saved + non-blocking warning with the reason.
- **Sandbox / permissions (must verify early):** if the app is sandboxed, writing to
user audio files requires the appropriate entitlement and/or a security-scoped
bookmark for the music folder. If writing is blocked, file writeback cannot work
regardless of library — DB edits still work. Verify before building the writers.
- **New dependency:** ID3TagEditor added via SPM to the Xcode project.
- **Hash drift:** writing tags changes file size/mod-date → `fileHash`. Step 3
refreshes these so the next scan doesn't treat the file as changed.
## Testing (TDD)
- **TagWriter round-trip:** write `EditableTrackFields` to bundled mp3 and m4a
fixtures, re-read via AVFoundation, assert each field; assert the file remains a
valid, playable asset after atomic replace.
- **Diff / multi-track logic:** pure-function, table-driven tests for "which fields
changed," "shared vs Mixed across N tracks," and "apply only edited fields to all."
- **Stat refresh:** `fileSize` / `dateModified` / `fileHash` recomputed after
writeback.
- **Factory:** correct writer per extension; `nil` for flac/wav/aiff.
- **`updateTrack`:** persists edited fields and keeps `tracks_ft` in sync.
## Implementation outline
1. Add ID3TagEditor SPM dependency; confirm sandbox/file-write permissions.
2. `EditableTrackFields` + diff/multi-track pure logic (+ tests).
3. `TagWriter` protocol, factory, `ID3TagWriter`, `MP4TagWriter` (+ round-trip tests).
4. `DatabaseService.updateTrack` (+ FTS sync) (+ test).
5. `TrackEditService` wiring the save sequence (+ tests).
6. `TrackInfoSheet` UI (tabs, validation, Mixed handling).
7. Context-menu "Get Info" (⌘I) + target resolution + `ContentView` sheet presentation.
8. Manual verification against real mp3/m4a/flac files.

@ -0,0 +1,272 @@
#!/usr/bin/env python3
"""One-time backfill of real bitrate onto tracks stored with bitrate 0 or NULL.
ScannerService writes `bitrate = Int(estimatedDataRate / 1000)` at scan time.
AVFoundation's estimatedDataRate returns 0 for some files (long/VBR MP3s), so a
literal 0 gets stored; other tracks were imported before bitrate existed and are
NULL. This script recomputes bitrate for those rows using ffprobe, falling back
to fileSize*8/duration (the same average the app's importer now uses) when
ffprobe is unavailable or can't determine a value.
Dry-run by default. Pass --apply to write (a timestamped backup is made first).
Usage:
python3 backfill_bitrate.py [--db <path>] [--apply]
python3 backfill_bitrate.py --self-test
Stdlib only; uses ffprobe if present on PATH (optional).
"""
import argparse
import os
import shutil
import sqlite3
import subprocess
import sys
import unicodedata
from datetime import datetime
from urllib.parse import unquote
# Default DB path for the sandboxed app (bundle id com.staxriver.mu). Computed from
# $HOME so it resolves to the right user on whichever Mac the script runs on.
DEFAULT_DB = os.path.expanduser(
"~/Library/Containers/com.staxriver.mu/Data/Library/"
"Application Support/Music/db.sqlite"
)
def norm_path(u):
"""Reduce a file:// URL (or bare path) to a comparable, on-disk POSIX path.
The app stores `fileURL` as Foundation's url.absoluteString (a percent-encoded
file URL). Decode it, drop the file:// (or file://localhost) prefix, NFC-
normalize, and strip a trailing slash so it can be stat'd on APFS.
"""
s = u
if s.startswith("file://"):
s = s[len("file://"):]
if s.startswith("localhost/"):
s = s[len("localhost"):] # leaves the leading "/"
s = unquote(s)
s = unicodedata.normalize("NFC", s)
if len(s) > 1 and s.endswith("/"):
s = s[:-1]
return s
def parse_ffprobe_bitrate(stdout):
"""Parse ffprobe's bit_rate stdout (bits/sec) into integer kbps, or None.
Returns None for empty output, 'N/A', or any non-integer text so the caller
falls back to the formula.
"""
s = stdout.strip()
if not s or s == "N/A":
return None
try:
return round(int(s) / 1000)
except ValueError:
return None
def kbps_from_ffprobe(path):
"""Return integer kbps from ffprobe's format bit_rate, or None if unavailable.
None on: ffprobe not installed, ffprobe error, or N/A/empty/non-integer output.
"""
try:
out = subprocess.run(
["ffprobe", "-v", "error", "-show_entries", "format=bit_rate",
"-of", "default=nw=1:nk=1", path],
capture_output=True, text=True, timeout=30,
)
except (FileNotFoundError, subprocess.SubprocessError):
return None
return parse_ffprobe_bitrate(out.stdout)
def kbps_from_formula(file_size, duration):
"""Average kbps from size (bytes) and duration (seconds): size*8/duration/1000.
Returns None when inputs can't yield a meaningful value (missing size, or
non-positive/missing duration).
"""
if file_size is None or file_size <= 0 or duration is None or duration <= 0:
return None
return round(file_size * 8 / duration / 1000)
def resolve_bitrate(path, duration):
"""Best available kbps for an on-disk file: ffprobe first, formula fallback.
`duration` is the DB's stored seconds; file size is read from disk. Returns
None if neither method can produce a positive value.
"""
kbps = kbps_from_ffprobe(path)
if kbps is not None and kbps > 0:
return kbps
try:
size = os.path.getsize(path)
except OSError:
size = None
return kbps_from_formula(size, duration)
def ffprobe_available():
"""Return True if ffprobe is on PATH."""
return shutil.which("ffprobe") is not None
def self_test():
"""Fast smoke check of the pure helpers (no DB, no ffprobe needed)."""
# ffprobe stdout parsing
assert parse_ffprobe_bitrate("256005\n") == 256
assert parse_ffprobe_bitrate("N/A") is None
assert parse_ffprobe_bitrate("") is None
assert parse_ffprobe_bitrate("garbage") is None
# formula: 230_358_479 bytes over 7198.54 s -> 256 kbps (matches ffprobe sample)
assert kbps_from_formula(230_358_479, 7198.5371428571425) == 256
assert kbps_from_formula(None, 100) is None
assert kbps_from_formula(1000, 0) is None
assert kbps_from_formula(1000, None) is None
# path normalization (NFD vs NFC accents, percent-encoding, localhost host)
nfc = norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3")
nfd = norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3")
assert nfc == nfd == "/Users/x/Música/Café.mp3", (nfc, nfd)
assert norm_path("file:///a/b%20c%23d.mp3") == "/a/b c#d.mp3"
# resolve_bitrate composition: a missing file yields None regardless of whether
# ffprobe is installed (ffprobe errors on the path -> None; getsize raises
# OSError -> formula gets size=None -> None).
assert resolve_bitrate("/nonexistent/file.mp3", 100) is None
print("self-test OK")
def fetch_rows(db_path):
"""Return candidate rows: (id, fileURL, duration, bitrate) where bitrate is 0/NULL."""
con = sqlite3.connect(db_path)
try:
return con.execute(
"SELECT id, fileURL, duration, bitrate FROM tracks "
"WHERE bitrate = 0 OR bitrate IS NULL"
).fetchall()
finally:
con.close()
def build_updates(rows):
"""Resolve a new bitrate for each candidate row.
Returns (updates, missing, undeterminable):
- updates: list of {id, file_url, old, new} where new is a positive kbps
- missing: (id, path) for rows whose file is not on disk (left untouched)
- undeterminable: (id, path) for on-disk files whose bitrate couldn't be found
"""
updates, missing, undeterminable = [], [], []
for row_id, file_url, duration, old in rows:
path = norm_path(file_url)
if not os.path.exists(path):
missing.append((row_id, path))
continue
new = resolve_bitrate(path, duration)
if new is None or new <= 0:
undeterminable.append((row_id, path))
continue
updates.append({"id": row_id, "file_url": file_url, "old": old, "new": new})
return updates, missing, undeterminable
def backup_db(db_path):
"""Copy db.sqlite (+ -wal, -shm) under backups/<timestamp>/ next to the DB."""
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_dir = os.path.join(os.path.dirname(db_path), "backups", stamp)
os.makedirs(backup_dir, exist_ok=True)
for suffix in ("", "-wal", "-shm"):
src = db_path + suffix
if os.path.exists(src):
shutil.copy2(src, os.path.join(backup_dir, os.path.basename(src)))
return backup_dir
def apply_updates(db_path, updates):
"""Write bitrate updates in a single transaction, then checkpoint the WAL."""
con = sqlite3.connect(db_path)
try:
con.execute("BEGIN")
con.executemany("UPDATE tracks SET bitrate=:new WHERE id=:id", updates)
con.commit()
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
finally:
con.close()
def run(db_path, apply):
rows = fetch_rows(db_path)
updates, missing, undeterminable = build_updates(rows)
print(f"Candidate rows (bitrate 0 or NULL): {len(rows)}")
print(f"Resolvable (will set): {len(updates)}")
print(f"Skipped — file missing on disk: {len(missing)}")
print(f"Skipped — could not determine: {len(undeterminable)}")
if not ffprobe_available():
print("NOTE: ffprobe not on PATH — used the filesize/duration formula for all rows.")
print()
for u in updates[:15]:
name = os.path.basename(norm_path(u["file_url"]))
old = "NULL" if u["old"] is None else u["old"]
print(f"{name}")
print(f" bitrate {old} -> {u['new']} kbps")
if len(updates) > 15:
print(f" ... and {len(updates) - 15} more")
print()
if missing[:5]:
print("Sample of skipped (file missing on disk, left untouched):")
for row_id, path in missing[:5]:
print(f" - [{row_id}] {os.path.basename(path)}")
print()
if undeterminable[:5]:
print("Sample of skipped (could not determine bitrate, left untouched):")
for row_id, path in undeterminable[:5]:
print(f" - [{row_id}] {os.path.basename(path)}")
print()
if not apply:
print("DRY RUN — nothing written. Re-run with --apply to commit these changes.")
return
if not updates:
print("Nothing to apply.")
return
backup_dir = backup_db(db_path)
print(f"Backup written to: {backup_dir}")
apply_updates(db_path, updates)
print(f"Applied {len(updates)} bitrate updates to {db_path}")
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--db", default=DEFAULT_DB, help=f"App DB path (default: {DEFAULT_DB})")
p.add_argument("--apply", action="store_true", help="Write changes (default: dry run).")
p.add_argument("--self-test", action="store_true", help="Run the built-in smoke test.")
args = p.parse_args(argv)
if args.self_test:
self_test()
return 0
if not os.path.exists(args.db):
p.error(f"DB not found: {args.db}")
run(args.db, args.apply)
return 0
if __name__ == "__main__":
sys.exit(main())

@ -0,0 +1,253 @@
#!/usr/bin/env python3
"""One-time backfill of Date Added / play stats from Apple Music into the app DB.
ScannerService stamps `dateAdded = Date()` at scan time, so the app DB holds scan
dates rather than the real "date added" from Apple Music. This script reads the
ground truth from a Music.app library export (File > Library > Export Library...)
and overwrites dateAdded, playCount, rating and lastPlayedAt on tracks it can match
by file path.
Dry-run by default. Pass --apply to write (a timestamped backup is made first).
Usage:
python3 backfill_itunes_dates.py --xml <Library.xml> [--db <path>] [--apply]
python3 backfill_itunes_dates.py --self-test
Stdlib only; needs python3 (ships with Xcode Command Line Tools).
"""
import argparse
import os
import plistlib
import shutil
import sqlite3
import sys
import unicodedata
from datetime import datetime
from urllib.parse import unquote
# Default DB path for the sandboxed app (bundle id com.staxriver.mu). Computed from
# $HOME so it resolves to the right user on whichever Mac the script runs on.
DEFAULT_DB = os.path.expanduser(
"~/Library/Containers/com.staxriver.mu/Data/Library/"
"Application Support/Music/db.sqlite"
)
def norm_path(u):
"""Reduce a file:// URL (or bare path) to a comparable POSIX path.
Both the app's stored `fileURL` (Foundation's url.absoluteString) and Music.app's
`Location` are percent-encoded file URLs, but they can differ in host form
(file:/// vs file://localhost/) and Unicode normalization (APFS keeps filenames
in one form, the URL encoders may emit another). Normalizing both to a decoded,
NFC, trailing-slash-free path makes accented filenames compare equal.
"""
s = u
if s.startswith("file://"):
s = s[len("file://"):]
if s.startswith("localhost/"):
s = s[len("localhost"):] # leaves the leading "/"
s = unquote(s)
s = unicodedata.normalize("NFC", s)
if len(s) > 1 and s.endswith("/"):
s = s[:-1]
return s
def fmt_dt(dt):
"""Format a datetime as GRDB's .datetime string (UTC), or None.
plistlib parses <date> values into naive datetimes already expressed in UTC,
which is exactly what GRDB stores (e.g. '2026-05-24 06:46:01.713'). We emit
millisecond precision (.000) to match the column's existing shape; GRDB reads
both with and without millis, so this round-trips.
"""
if dt is None:
return None
return dt.strftime("%Y-%m-%d %H:%M:%S") + ".000"
def parse_library(xml_path):
"""Parse a Music.app library export into {norm_path: fields}.
Only tracks with a Location (i.e. real local files) are included; Apple Music
streaming entries have no Location and are skipped.
"""
with open(xml_path, "rb") as fp:
plist = plistlib.load(fp)
music = {}
for track in plist.get("Tracks", {}).values():
location = track.get("Location")
if not location:
continue
music[norm_path(location)] = {
"date_added": track.get("Date Added"),
"play_count": track.get("Play Count"),
"rating": track.get("Rating"),
"play_date_utc": track.get("Play Date UTC"),
}
return music
def build_updates(db_rows, music_map):
"""Compute UPDATE tuples for matched tracks (blunt overwrite, Music is truth).
db_rows: iterable of (id, fileURL, current_dateAdded).
Returns (updates, unmatched) where:
- updates is a list of dicts: id, file_url, old_date, dateAdded, playCount,
rating, lastPlayedAt
- unmatched is a list of (id, fileURL) present in the DB but not the export.
"""
updates = []
unmatched = []
for row_id, file_url, current_date in db_rows:
m = music_map.get(norm_path(file_url))
if m is None:
unmatched.append((row_id, file_url))
continue
# dateAdded is NOT NULL: if the export somehow lacks it, keep what's there.
new_date = fmt_dt(m["date_added"]) if m["date_added"] else current_date
rating = min(5, int(m["rating"] or 0) // 20) # Music 0-100 -> 0-5 stars
updates.append({
"id": row_id,
"file_url": file_url,
"old_date": current_date,
"dateAdded": new_date,
"playCount": int(m["play_count"] or 0),
"rating": rating,
"lastPlayedAt": fmt_dt(m["play_date_utc"]),
})
return updates, unmatched
def backup_db(db_path):
"""Copy db.sqlite (+ -wal, -shm) next to it under backups/<timestamp>/."""
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
backup_dir = os.path.join(os.path.dirname(db_path), "backups", stamp)
os.makedirs(backup_dir, exist_ok=True)
for suffix in ("", "-wal", "-shm"):
src = db_path + suffix
if os.path.exists(src):
shutil.copy2(src, os.path.join(backup_dir, os.path.basename(src)))
return backup_dir
def apply_updates(db_path, updates):
"""Write updates in a single transaction, then checkpoint the WAL."""
con = sqlite3.connect(db_path)
try:
con.execute("BEGIN")
con.executemany(
"UPDATE tracks SET dateAdded=:dateAdded, playCount=:playCount, "
"rating=:rating, lastPlayedAt=:lastPlayedAt WHERE id=:id",
updates,
)
con.commit()
con.execute("PRAGMA wal_checkpoint(TRUNCATE)")
finally:
con.close()
def fetch_db_rows(db_path):
con = sqlite3.connect(db_path)
try:
return con.execute("SELECT id, fileURL, dateAdded FROM tracks").fetchall()
finally:
con.close()
def run(xml_path, db_path, apply):
music_map = parse_library(xml_path)
db_rows = fetch_db_rows(db_path)
updates, unmatched = build_updates(db_rows, music_map)
matched_paths = {norm_path(u["file_url"]) for u in updates}
unmatched_xml = [p for p in music_map if p not in matched_paths]
print(f"DB tracks: {len(db_rows)}")
print(f"Local files in XML: {len(music_map)}")
print(f"Matched (will set): {len(updates)}")
print(f"In DB, not in XML: {len(unmatched)}")
print(f"In XML, not in DB: {len(unmatched_xml)}")
print()
for u in updates[:10]:
name = os.path.basename(norm_path(u["file_url"]))
print(f"{name}")
print(f" dateAdded {u['old_date']} -> {u['dateAdded']}")
print(f" playCount={u['playCount']} rating={u['rating']} "
f"lastPlayedAt={u['lastPlayedAt']}")
if len(updates) > 10:
print(f" ... and {len(updates) - 10} more")
print()
if unmatched[:5]:
print("Sample of DB tracks with no XML match (left untouched):")
for row_id, file_url in unmatched[:5]:
print(f" - [{row_id}] {os.path.basename(norm_path(file_url))}")
print()
if not apply:
print("DRY RUN — nothing written. Re-run with --apply to commit these changes.")
return
if not updates:
print("Nothing to apply.")
return
backup_dir = backup_db(db_path)
print(f"Backup written to: {backup_dir}")
apply_updates(db_path, updates)
print(f"Applied {len(updates)} updates to {db_path}")
def self_test():
"""Fast smoke check of the matching + formatting core."""
nfc = norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3") # NFD source
nfd = norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3") # NFC source
assert nfc == nfd == "/Users/x/Música/Café.mp3", (nfc, nfd)
assert norm_path("file:///a/b%20c%23d.mp3") == "/a/b c#d.mp3"
music = {"/a/song.mp3": {
"date_added": datetime(2021, 3, 14, 9, 26, 53),
"play_count": 7, "rating": 80,
"play_date_utc": datetime(2024, 1, 2, 3, 4, 5),
}}
rows = [(1, "file:///a/song.mp3", "2026-05-24 06:46:01.713"),
(2, "file:///a/missing.mp3", "2026-05-24 06:46:01.999")]
updates, unmatched = build_updates(rows, music)
assert len(updates) == 1 and len(unmatched) == 1
u = updates[0]
assert u["dateAdded"] == "2021-03-14 09:26:53.000", u["dateAdded"]
assert u["playCount"] == 7 and u["rating"] == 4
assert u["lastPlayedAt"] == "2024-01-02 03:04:05.000"
print("self-test OK")
def main(argv=None):
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--xml", help="Path to Music.app Library export (XML plist).")
p.add_argument("--db", default=DEFAULT_DB, help=f"App DB path (default: {DEFAULT_DB})")
p.add_argument("--apply", action="store_true", help="Write changes (default: dry run).")
p.add_argument("--self-test", action="store_true", help="Run the built-in smoke test.")
args = p.parse_args(argv)
if args.self_test:
self_test()
return 0
if not args.xml:
p.error("--xml is required (export it via Music.app: File > Library > Export Library...)")
if not os.path.exists(args.xml):
p.error(f"XML not found: {args.xml}")
if not os.path.exists(args.db):
p.error(f"DB not found: {args.db}")
run(args.xml, args.db, args.apply)
return 0
if __name__ == "__main__":
sys.exit(main())

@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Tests for backfill_itunes_dates. Run: python3 -m unittest test_backfill_itunes_dates"""
import io
import os
import plistlib
import sqlite3
import sys
import tempfile
import unittest
from contextlib import redirect_stdout
from datetime import datetime
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import backfill_itunes_dates as bf # noqa: E402
# The three real file paths from the dev DB. The Kevin track exercises spaces (%20),
# a literal apostrophe, and parentheses; the others share a directory.
KEVIN = ("file:///Users/laurentmorvillier/Music/Music/Media.localized/Music/"
"Kevin%20Saunderson%20as%20E-Dancer/GlobalDJMix.com/"
"Radio%201's%20Essential%20Mix%20(2025-02-08).mp3")
T1130 = ("file:///Users/laurentmorvillier/Music/Music/Media.localized/Music/"
"Unknown%20Artist/Unknown%20Album/1130.mp3")
T1486 = ("file:///Users/laurentmorvillier/Music/Music/Media.localized/Music/"
"Unknown%20Artist/Unknown%20Album/1486.mp3")
class NormPathTests(unittest.TestCase):
# Step: an NFD-encoded source and an NFC-encoded source for the same accented
# filename must normalize to the identical path, regardless of file:// host form.
def test_nfc_nfd_and_host_form_converge(self):
nfd = bf.norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3")
nfc = bf.norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3")
self.assertEqual(nfd, "/Users/x/Música/Café.mp3")
self.assertEqual(nfd, nfc)
# Step: percent-encoded space and hash decode to literal characters.
def test_special_chars_decoded(self):
self.assertEqual(bf.norm_path("file:///a/b%20c%23d.mp3"), "/a/b c#d.mp3")
# Step: a trailing slash is stripped so dir-ish strings compare cleanly.
def test_trailing_slash_stripped(self):
self.assertEqual(bf.norm_path("file:///a/b/"), "/a/b")
# Step: the real Kevin path with apostrophe + parens round-trips to a clean path.
def test_real_kevin_path(self):
self.assertTrue(
bf.norm_path(KEVIN).endswith("/Radio 1's Essential Mix (2025-02-08).mp3"))
class BuildUpdatesTests(unittest.TestCase):
# Step: a matched track gets dateAdded reformatted to GRDB shape, rating mapped
# 0-100 -> 0-5, playCount copied, and lastPlayedAt formatted; an unmatched DB
# track is reported separately and produces no update.
def test_mapping_and_unmatched(self):
music = {bf.norm_path("file:///a/song.mp3"): {
"date_added": datetime(2021, 3, 14, 9, 26, 53),
"play_count": 7, "rating": 80,
"play_date_utc": datetime(2024, 1, 2, 3, 4, 5),
}}
rows = [(1, "file:///a/song.mp3", "2026-05-24 06:46:01.713"),
(2, "file:///a/missing.mp3", "2026-05-24 06:46:01.999")]
updates, unmatched = bf.build_updates(rows, music)
self.assertEqual([u["id"] for u in updates], [1])
self.assertEqual([r[0] for r in unmatched], [2])
u = updates[0]
self.assertEqual(u["dateAdded"], "2021-03-14 09:26:53.000")
self.assertEqual(u["playCount"], 7)
self.assertEqual(u["rating"], 4)
self.assertEqual(u["lastPlayedAt"], "2024-01-02 03:04:05.000")
# Step: missing optional fields default safely — playCount 0, rating 0,
# lastPlayedAt None — while dateAdded still applies.
def test_absent_optionals(self):
music = {bf.norm_path("file:///a/x.mp3"): {
"date_added": datetime(2020, 1, 1, 0, 0, 0),
"play_count": None, "rating": None, "play_date_utc": None,
}}
updates, _ = bf.build_updates([(1, "file:///a/x.mp3", "old")], music)
u = updates[0]
self.assertEqual(u["playCount"], 0)
self.assertEqual(u["rating"], 0)
self.assertIsNone(u["lastPlayedAt"])
# Step: if the export lacks Date Added, keep the existing value (column is NOT NULL).
def test_missing_date_keeps_existing(self):
music = {bf.norm_path("file:///a/x.mp3"): {
"date_added": None, "play_count": 1, "rating": 0, "play_date_utc": None}}
updates, _ = bf.build_updates([(1, "file:///a/x.mp3", "2026-05-24 06:46:01.713")], music)
self.assertEqual(updates[0]["dateAdded"], "2026-05-24 06:46:01.713")
class IntegrationTest(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.mkdtemp()
self.db = os.path.join(self.tmp, "db.sqlite")
self.xml = os.path.join(self.tmp, "Library.xml")
# Step: build a temp DB mirroring the real tracks columns the script touches,
# seeded with the three real paths carrying placeholder scan dates.
con = sqlite3.connect(self.db)
con.execute(
"CREATE TABLE tracks ("
" id INTEGER PRIMARY KEY,"
" fileURL TEXT NOT NULL,"
" dateAdded TEXT NOT NULL,"
" playCount INTEGER NOT NULL DEFAULT 0,"
" rating INTEGER NOT NULL DEFAULT 0,"
" lastPlayedAt TEXT)")
con.executemany(
"INSERT INTO tracks (id, fileURL, dateAdded, playCount, rating) VALUES (?,?,?,0,0)",
[(1, KEVIN, "2026-05-24 06:46:01.713"),
(2, T1130, "2026-05-24 06:46:01.715"),
(3, T1486, "2026-05-24 06:46:01.718")])
con.commit()
con.close()
# Step: write a synthetic library export. Kevin is matched via the localhost
# host form (proving normalization), 1130 matched with no optional stats, 1486
# deliberately omitted (stays unmatched), plus a streaming entry with no
# Location (must be skipped) and an XML-only local file (unmatched-in-DB).
kevin_localhost = KEVIN.replace("file:///", "file://localhost/")
plist = {"Tracks": {
"10": {"Location": kevin_localhost,
"Date Added": datetime(2025, 2, 9, 12, 0, 0),
"Play Count": 5, "Rating": 100,
"Play Date UTC": datetime(2025, 3, 1, 8, 0, 0)},
"11": {"Location": T1130,
"Date Added": datetime(2024, 11, 30, 10, 0, 0)},
"12": {"Date Added": datetime(2023, 1, 1, 0, 0, 0)}, # streaming, no Location
"13": {"Location": "file:///Users/x/only-in-xml.mp3",
"Date Added": datetime(2022, 6, 6, 6, 6, 6)},
}}
with open(self.xml, "wb") as fp:
plistlib.dump(plist, fp)
def tearDown(self):
import shutil
shutil.rmtree(self.tmp, ignore_errors=True)
# Step: a full --apply run rewrites the two matched rows with Music.app's values
# (Kevin: 5 plays / 5 stars / real dates; 1130: date only), leaves the unmatched
# 1486 row untouched, and ignores the streaming + xml-only entries.
def test_apply_end_to_end(self):
with redirect_stdout(io.StringIO()):
bf.run(self.xml, self.db, apply=True)
con = sqlite3.connect(self.db)
rows = {r[0]: r for r in con.execute(
"SELECT id, dateAdded, playCount, rating, lastPlayedAt FROM tracks")}
con.close()
self.assertEqual(rows[1], (1, "2025-02-09 12:00:00.000", 5, 5, "2025-03-01 08:00:00.000"))
self.assertEqual(rows[2], (2, "2024-11-30 10:00:00.000", 0, 0, None))
self.assertEqual(rows[3], (3, "2026-05-24 06:46:01.718", 0, 0, None)) # untouched
# Step: a backup directory containing the db copy is created on apply.
def test_apply_makes_backup(self):
with redirect_stdout(io.StringIO()):
bf.run(self.xml, self.db, apply=True)
backups_root = os.path.join(self.tmp, "backups")
self.assertTrue(os.path.isdir(backups_root))
stamps = os.listdir(backups_root)
self.assertEqual(len(stamps), 1)
self.assertIn("db.sqlite", os.listdir(os.path.join(backups_root, stamps[0])))
# Step: a dry run reports matches but writes nothing to the DB.
def test_dry_run_writes_nothing(self):
with redirect_stdout(io.StringIO()):
bf.run(self.xml, self.db, apply=False)
con = sqlite3.connect(self.db)
unchanged = con.execute("SELECT dateAdded FROM tracks WHERE id=1").fetchone()[0]
con.close()
self.assertEqual(unchanged, "2026-05-24 06:46:01.713")
if __name__ == "__main__":
unittest.main()
Loading…
Cancel
Save