Compare commits

...

37 Commits

Author SHA1 Message Date
Laurent 7f31945b84 updates 1 month ago
Laurent 9b6bd8929e cleanup 1 month ago
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
Laurent 98f11658ad first working instance 1 month ago
Laurent 0829dba09a feat: add HLSManifestGenerator with TDD tests 1 month ago
Laurent 981c9123a1 feat: add AppRole, StreamingConstants, Routes, APIModels to MusicShared 1 month ago
Laurent d8863299ff chore(MusicShared): add .gitignore to exclude .build artifacts 1 month ago
Laurent aae1c8a957 feat: create MusicShared Swift package with public RemoteProtocol types 1 month ago
Laurent d7e81b1d31 refactor: remove artworkData BLOB from database, load artwork from files on demand 1 month ago
  1. 69
      Music.xcodeproj/project.pbxproj
  2. 227
      Music.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  3. 7
      Music.xcodeproj/xcshareddata/xcschemes/Music.xcscheme
  4. 2
      Music/Assets.xcassets/AppIcon.appiconset/Contents.json
  5. BIN
      Music/Assets.xcassets/AppIcon.appiconset/icon_mu.png
  6. BIN
      Music/Assets.xcassets/AppIcon.appiconset/mumu_icon.png
  7. 352
      Music/ContentView.swift
  8. 21
      Music/Info.plist
  9. 84
      Music/Models/EditableTrackFields.swift
  10. 9
      Music/Models/QueueEntry.swift
  11. 9
      Music/Models/SmartPlaylist.swift
  12. 152
      Music/Models/SmartPlaylistCondition.swift
  13. 4
      Music/Models/Track.swift
  14. 130
      Music/Models/TrackContextMenuConfig.swift
  15. 12
      Music/Music.entitlements
  16. 154
      Music/MusicApp.swift
  17. 25
      Music/Protocols/PlaybackProvider.swift
  18. 16
      Music/Protocols/PlaylistRepresentable.swift
  19. 100
      Music/Providers/RemotePlaybackProvider.swift
  20. 303
      Music/Providers/StreamingPlaybackProvider.swift
  21. 53
      Music/Remote/HostServer.swift
  22. 30
      Music/Remote/NetworkStatus.swift
  23. 67
      Music/Remote/RemoteClient.swift
  24. 17
      Music/Services/AudioService.swift
  25. 184
      Music/Services/DatabaseService.swift
  26. 55
      Music/Services/ScannerService.swift
  27. 42
      Music/Services/TagWriting/ID3TagWriter.swift
  28. 53
      Music/Services/TagWriting/MP4TagWriter.swift
  29. 20
      Music/Services/TagWriting/TagWriter.swift
  30. 57
      Music/Services/TrackEditService.swift
  31. 22
      Music/Services/TrackFileStats.swift
  32. 80
      Music/Streaming/HLSSegmenter.swift
  33. 219
      Music/Streaming/StreamingClient.swift
  34. 83
      Music/Streaming/StreamingConnectionSheet.swift
  35. 418
      Music/Streaming/StreamingServer.swift
  36. 125
      Music/Streaming/TunnelManager.swift
  37. 26
      Music/ViewModels/LibraryViewModel.swift
  38. 208
      Music/ViewModels/PlayerViewModel.swift
  39. 44
      Music/ViewModels/PlaylistViewModel.swift
  40. 162
      Music/Views/PlayerControlsView.swift
  41. 112
      Music/Views/PlaylistBarView.swift
  42. 67
      Music/Views/QueueView.swift
  43. 149
      Music/Views/SmartPlaylistBuilderSheet.swift
  44. 44
      Music/Views/TrackContextMenuModifier.swift
  45. 156
      Music/Views/TrackInfoSheet.swift
  46. 130
      Music/Views/TrackTableView.swift
  47. 1
      MusicShared/.gitignore
  48. 221
      MusicShared/Package.resolved
  49. 44
      MusicShared/Package.swift
  50. 27
      MusicShared/Sources/MusicShared/APIModels.swift
  51. 19
      MusicShared/Sources/MusicShared/AppRole.swift
  52. 3
      MusicShared/Sources/MusicShared/Exports.swift
  53. 43
      MusicShared/Sources/MusicShared/HLSManifestGenerator.swift
  54. 55
      MusicShared/Sources/MusicShared/RemoteProtocol.swift
  55. 7
      MusicShared/Sources/MusicShared/StreamingConstants.swift
  56. 27
      MusicShared/Sources/MusicShared/StreamingRoutes.swift
  57. 78
      MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift
  58. 2
      MusicShared/Tests/MusicSharedTests/RemoteProtocolTests.swift
  59. 42
      MusicTests/DBBackupFTS5Tests.swift
  60. 44
      MusicTests/DatabaseServiceTests.swift
  61. 82
      MusicTests/EditableTrackFieldsTests.swift
  62. BIN
      MusicTests/Fixtures/sample.m4a
  63. BIN
      MusicTests/Fixtures/sample.mp3
  64. 89
      MusicTests/HLSSegmenterTests.swift
  65. 3
      MusicTests/HostServerIntegrationTests.swift
  66. 71
      MusicTests/PlaybackPipelineTeardownTests.swift
  67. 179
      MusicTests/PlayerViewModelTests.swift
  68. 54
      MusicTests/PlaylistBarIdentityTests.swift
  69. 36
      MusicTests/PlaylistViewModelTests.swift
  70. 123
      MusicTests/RemoteDBIntegrityTests.swift
  71. 98
      MusicTests/RemoteLibraryDisplayTests.swift
  72. 75
      MusicTests/ScannerServiceTests.swift
  73. 228
      MusicTests/SmartPlaylistTests.swift
  74. 343
      MusicTests/StreamingIntegrationTests.swift
  75. 82
      MusicTests/StreamingPlaybackEndToEndTests.swift
  76. 147
      MusicTests/StreamingServerTests.swift
  77. 76
      MusicTests/TagWriterTests.swift
  78. 96
      MusicTests/TrackContextMenuConfigTests.swift
  79. 101
      MusicTests/TrackEditServiceTests.swift
  80. 26
      MusicTests/TrackFileStatsTests.swift
  81. 1
      MusicTests/TrackTests.swift
  82. 343
      docs/superpowers/plans/2026-05-31-track-drag-to-playlist.md
  83. 85
      docs/superpowers/specs/2026-05-31-track-drag-to-playlist-design.md
  84. BIN
      scripts/__pycache__/backfill_itunes_dates.cpython-312.pyc
  85. BIN
      scripts/__pycache__/test_backfill_itunes_dates.cpython-312.pyc
  86. 272
      scripts/backfill_bitrate.py
  87. 253
      scripts/backfill_itunes_dates.py
  88. 178
      scripts/test_backfill_itunes_dates.py

@ -9,6 +9,8 @@
/* Begin PBXBuildFile section */
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 */
@ -73,7 +75,9 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
C4BA36A52FCB3F7600DF615F /* ID3TagEditor in Frameworks */,
C46B2CC02FC2449900F95A24 /* GRDB in Frameworks */,
C46CC4692FC6ED47000BD495 /* MusicShared in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -101,6 +105,7 @@
C46B2C8F2FC2448700F95A24 /* Music */,
C46B2C9D2FC2448800F95A24 /* MusicTests */,
C46B2CA72FC2448800F95A24 /* MusicUITests */,
C46C8F952FC6D951000BD495 /* Frameworks */,
C46B2C8E2FC2448700F95A24 /* Products */,
);
sourceTree = "<group>";
@ -115,6 +120,13 @@
name = Products;
sourceTree = "<group>";
};
C46C8F952FC6D951000BD495 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -136,6 +148,8 @@
name = Music;
packageProductDependencies = (
C46B2CBF2FC2449900F95A24 /* GRDB */,
C46CC4682FC6ED47000BD495 /* MusicShared */,
C4BA35472FCB3B0C00DF615F /* ID3TagEditor */,
);
productName = Music;
productReference = C46B2C8D2FC2448700F95A24 /* Mumu.app */;
@ -222,6 +236,8 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */,
C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */,
C4BA35462FCB3B0C00DF615F /* XCRemoteSwiftPackageReference "ID3TagEditor" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = C46B2C8E2FC2448700F95A24 /* Products */;
@ -331,6 +347,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = dwarf;
DEVELOPMENT_TEAM = 895UN7FKH2;
ENABLE_STRICT_OBJC_MSGSEND = YES;
@ -356,6 +373,7 @@
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
};
@ -395,6 +413,7 @@
CLANG_WARN_UNREACHABLE_CODE = YES;
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
COPY_PHASE_STRIP = NO;
DEAD_CODE_STRIPPING = YES;
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
DEVELOPMENT_TEAM = 895UN7FKH2;
ENABLE_NS_ASSERTIONS = NO;
@ -413,6 +432,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
@ -427,18 +447,24 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 27;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Music/Info.plist;
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",
@ -468,18 +494,24 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 27;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 526E96RFNP;
ENABLE_APP_SANDBOX = YES;
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_INCOMING_NETWORK_CONNECTIONS = YES;
ENABLE_OUTGOING_NETWORK_CONNECTIONS = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
ENABLE_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_USER_SELECTED_FILES = readwrite;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_FILE = Music/Info.plist;
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",
@ -505,6 +537,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 895UN7FKH2;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.6;
@ -526,6 +559,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 895UN7FKH2;
GENERATE_INFOPLIST_FILE = YES;
MACOSX_DEPLOYMENT_TARGET = 15.6;
@ -546,6 +580,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 895UN7FKH2;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@ -565,6 +600,7 @@
buildSettings = {
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = 895UN7FKH2;
GENERATE_INFOPLIST_FILE = YES;
MARKETING_VERSION = 1.0;
@ -620,6 +656,13 @@
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = MusicShared;
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */ = {
isa = XCRemoteSwiftPackageReference;
@ -629,6 +672,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 */
@ -642,6 +693,16 @@
package = C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */;
productName = GRDB;
};
C46CC4682FC6ED47000BD495 /* MusicShared */ = {
isa = XCSwiftPackageProductDependency;
package = C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */;
productName = MusicShared;
};
C4BA35472FCB3B0C00DF615F /* ID3TagEditor */ = {
isa = XCSwiftPackageProductDependency;
package = C4BA35462FCB3B0C00DF615F /* XCRemoteSwiftPackageReference "ID3TagEditor" */;
productName = ID3TagEditor;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = C46B2C852FC2448700F95A24 /* Project object */;

@ -1,6 +1,15 @@
{
"originHash" : "d77223ea3cadaebd2154378ec5005b6ebefcef3b34a4dafa368b0c4f16c0561c",
"originHash" : "fd0f5ecf1cad35fa15b2a92be5f094df9927b358dc72b36a269e799e8cfb64a9",
"pins" : [
{
"identity" : "async-http-client",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client.git",
"state" : {
"revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f",
"version" : "1.33.1"
}
},
{
"identity" : "grdb.swift",
"kind" : "remoteSourceControl",
@ -9,6 +18,222 @@
"revision" : "36e30a6f1ef10e4194f6af0cff90888526f0c115",
"version" : "7.10.0"
}
},
{
"identity" : "hummingbird",
"kind" : "remoteSourceControl",
"location" : "https://github.com/hummingbird-project/hummingbird.git",
"state" : {
"revision" : "a2ed0a0294de56e18ba55344eafc801a7a385a90",
"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",
"location" : "https://github.com/apple/swift-algorithms.git",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
"version" : "1.7.0"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440",
"version" : "1.1.4"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-certificates",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-certificates.git",
"state" : {
"revision" : "bde8ca32a096825dfce37467137c903418c1893d",
"version" : "1.19.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a",
"version" : "1.5.1"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration.git",
"state" : {
"revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9",
"version" : "1.2.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
"version" : "4.5.0"
}
},
{
"identity" : "swift-distributed-tracing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-distributed-tracing.git",
"state" : {
"revision" : "dc4030184203ffafbb2ec614352487235d747fe0",
"version" : "1.4.1"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47",
"version" : "1.7.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
"version" : "1.5.1"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "7dc6101ae4dbe95cd3bc9cebad3b7cf8e49a7a63",
"version" : "1.13.0"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4",
"version" : "2.11.0"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95",
"version" : "2.100.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506",
"version" : "1.34.1"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a",
"version" : "1.44.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da",
"version" : "2.37.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5",
"version" : "1.28.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-service-context",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-service-context.git",
"state" : {
"revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29",
"version" : "1.3.0"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
"state" : {
"revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a",
"version" : "2.11.0"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
"version" : "1.6.4"
}
}
],
"version" : 3

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1600"
version = "1.7">
LastUpgradeVersion = "2630"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
buildImplicitDependencies = "YES">
@ -68,8 +68,7 @@
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "NO">
skipped = "NO">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "C46B2CA32FC2448800F95A24"

@ -46,7 +46,7 @@
"size" : "512x512"
},
{
"filename" : "icon_mu.png",
"filename" : "mumu_icon.png",
"idiom" : "mac",
"scale" : "2x",
"size" : "512x512"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 575 KiB

@ -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,15 @@ 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 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,40 +32,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))
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,
@ -89,93 +126,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,
@ -223,6 +254,13 @@ struct ContentView: View {
smartPlaylistToEdit = smart
editQueryInput = smart.searchQuery
showEditQueryAlert = true
},
onEditConditions: { smart in
smartPlaylistBuilderEditing = smart
},
onDropTrack: { trackId, targetPlaylist in
guard let track = library.tracks.first(where: { $0.id == trackId }) else { return }
try? playlist.addTrack(track, to: targetPlaylist)
}
)
@ -248,6 +286,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 = "" }
@ -311,6 +367,82 @@ 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 {
_ = await library.applyTrackEdits(values, editing: edited, to: targets)
}
},
onCancel: { infoRequest = nil }
)
}
}
/// 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 {
@ -321,11 +453,13 @@ struct ContentView: View {
duration: player.duration,
volume: player.volume,
isShuffled: player.isShuffled,
isBuffering: player.isBuffering,
streamingError: player.streamingError,
onPlayPause: {
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 {
@ -340,7 +474,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() }
)
}
@ -358,7 +496,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,21 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>UTExportedTypeDeclarations</key>
<array>
<dict>
<key>UTTypeConformsTo</key>
<array>
<string>public.data</string>
</array>
<key>UTTypeDescription</key>
<string>Music Track ID</string>
<key>UTTypeIdentifier</key>
<string>com.music.trackID</string>
<key>UTTypeTagSpecification</key>
<dict/>
</dict>
</array>
</dict>
</plist>

@ -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 }
}

@ -22,7 +22,6 @@ nonisolated struct Track: Codable, Identifiable, Equatable, Hashable, Sendable {
var bitrate: Int?
var sampleRate: Int?
var fileSize: Int64
var artworkData: Data?
var playCount: Int
var lastPlayedAt: Date?
var rating: Int
@ -66,7 +65,6 @@ extension Track {
bitrate: Int? = 320,
sampleRate: Int? = 44100,
fileSize: Int64 = 5_000_000,
artworkData: Data? = nil,
playCount: Int = 0,
lastPlayedAt: Date? = nil,
rating: Int = 0,
@ -79,7 +77,7 @@ extension Track {
albumArtist: albumArtist, album: album, genre: genre, year: year,
trackNumber: trackNumber, discNumber: discNumber, duration: duration,
bpm: bpm, composer: composer, fileFormat: fileFormat, bitrate: bitrate,
sampleRate: sampleRate, fileSize: fileSize, artworkData: artworkData,
sampleRate: sampleRate, fileSize: fileSize,
playCount: playCount, lastPlayedAt: lastPlayedAt, rating: rating,
dateAdded: dateAdded, dateModified: dateModified, fileHash: fileHash
)

@ -0,0 +1,130 @@
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)?
let onDelete: (([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,
onDelete: (([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
self.onDelete = onDelete
}
}
// 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) })
}
if let onDelete {
let targets = selection.isEmpty ? [track] : selection
entries.append(.separator)
entries.append(.button(title: "Delete") { onDelete(targets) })
}
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
}
}

@ -2,19 +2,11 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.app-sandbox</key>
<true/>
<key>com.apple.security.get-task-allow</key>
<true/>
<key>com.apple.security.files.user-selected.read-only</key>
<true/>
<key>com.apple.security.files.bookmarks.app-scope</key>
<true/>
<key>com.apple.security.network.client</key>
<true/>
<key>com.apple.security.network.server</key>
<key>com.apple.security.files.user-selected.read-write</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<key>com.apple.security.get-task-allow</key>
<true/>
</dict>
</plist>

@ -1,3 +1,4 @@
import MusicShared
import SwiftUI
@main
@ -10,10 +11,17 @@ 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()
@State private var showConnectionSheet = false
@State private var streamingServer: StreamingServer?
@State private var tunnelManager = TunnelManager()
@State private var streamingClient = StreamingClient()
@State private var showStreamingSettings = false
@State private var streamHostURL = UserDefaults.standard.string(forKey: "streamHostURL") ?? ""
@State private var streamAPIKey = UserDefaults.standard.string(forKey: "streamAPIKey") ?? ""
var body: some Scene {
WindowGroup {
@ -31,6 +39,7 @@ struct MusicApp: App {
shazam: shazamService,
db: db,
showNewPlaylistAlert: $showNewPlaylistAlert,
showSmartPlaylistBuilder: $showSmartPlaylistBuilder,
networkStatus: computeNetworkStatus()
)
} else if let error = initError {
@ -52,6 +61,15 @@ struct MusicApp: App {
.sheet(isPresented: $showConnectionSheet) {
ConnectionSheet(remoteClient: remoteClient, isPresented: $showConnectionSheet)
}
.sheet(isPresented: $showStreamingSettings) {
StreamingConnectionSheet(
hostURL: $streamHostURL,
apiKey: $streamAPIKey,
client: streamingClient,
isPresented: $showStreamingSettings,
onConnect: { enterStreamingClientMode() }
)
}
}
.commands {
CommandGroup(after: .newItem) {
@ -67,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(
@ -80,6 +104,31 @@ struct MusicApp: App {
remoteClient.startDiscovery()
}
.disabled(hostServer?.isHosting ?? false)
Divider()
Button("Start Streaming Server...") {
startStreamingServer()
}
.disabled(streamingServer?.isRunning ?? false || remoteClient.connectionState.isConnected)
Button("Stop Streaming Server") {
stopStreamingServer()
}
.disabled(!(streamingServer?.isRunning ?? false))
Divider()
Button("Connect to Stream Host...") {
showStreamingSettings = true
}
.disabled(streamingClient.state.isConnected || hostServer?.isHosting ?? false)
if streamingClient.state.isConnected {
Button("Disconnect from Stream") {
exitStreamingClientMode()
}
}
}
}
}
@ -95,7 +144,7 @@ struct MusicApp: App {
let db = try DatabaseService(path: dbPath)
let scanner = ScannerService(db: db)
let library = LibraryViewModel(db: db)
let player = PlayerViewModel(audio: audioService, db: db)
let player = PlayerViewModel(provider: audioService, db: db)
let playlist = PlaylistViewModel(db: db)
self.dbService = db
@ -201,7 +250,8 @@ struct MusicApp: App {
self.libraryVM = LibraryViewModel(db: remoteDb)
self.playlistVM = PlaylistViewModel(db: remoteDb)
player.enterRemoteMode(client: remoteClient)
let remoteProvider = RemotePlaybackProvider(commandSender: remoteClient)
player.setProvider(remoteProvider)
player.trackResolver = { trackId in
self.libraryVM?.tracks.first(where: { $0.id == trackId })
}
@ -216,7 +266,7 @@ struct MusicApp: App {
}
private func exitRemoteMode() {
playerVM?.exitRemoteMode()
playerVM?.setProvider(audioService)
remoteClient.onPlaybackState = nil
guard let db = dbService else { return }
self.libraryVM = LibraryViewModel(db: db)
@ -224,6 +274,94 @@ struct MusicApp: App {
try? FileManager.default.removeItem(atPath: RemoteClient.remoteDBPath)
}
// MARK: - Streaming Host
private func startStreamingServer() {
guard let db = dbService else { return }
var key = UserDefaults.standard.string(forKey: "streamServerAPIKey") ?? ""
if key.isEmpty {
key = UUID().uuidString
UserDefaults.standard.set(key, forKey: "streamServerAPIKey")
}
let server = StreamingServer(db: db, apiKey: key)
Task {
do {
try await server.start()
self.streamingServer = server
if TunnelManager.isCloudflaredInstalled() {
try tunnelManager.startQuickTunnel(localPort: server.actualPort ?? StreamingConstants.defaultPort)
}
} catch {
print("Failed to start streaming server: \(error)")
}
}
}
private func stopStreamingServer() {
streamingServer?.stop()
streamingServer = nil
tunnelManager.stop()
}
// MARK: - Streaming Client
private func enterStreamingClientMode() {
guard !streamHostURL.isEmpty, !streamAPIKey.isEmpty else { return }
UserDefaults.standard.set(streamHostURL, forKey: "streamHostURL")
UserDefaults.standard.set(streamAPIKey, forKey: "streamAPIKey")
Task {
await streamingClient.connect(hostURL: streamHostURL, apiKey: streamAPIKey)
if streamingClient.state.isConnected {
do {
let streamingDb = try DatabaseService(path: StreamingClient.streamingDBPath)
self.libraryVM = LibraryViewModel(db: streamingDb)
self.playlistVM = PlaylistViewModel(db: streamingDb)
let streamProvider = StreamingPlaybackProvider(
hostURL: streamHostURL,
apiKey: streamAPIKey
)
playerVM?.setProvider(streamProvider)
playerVM?.trackResolver = { trackId in
self.libraryVM?.tracks.first(where: { $0.id == trackId })
}
streamingClient.onDBReady = {
Task {
try? await self.refreshStreamingDB()
}
}
} catch {
print("Failed to load streaming DB: \(error)")
streamingClient.disconnect()
}
}
}
}
private func exitStreamingClientMode() {
streamingClient.disconnect()
playerVM?.setProvider(audioService)
playerVM?.trackResolver = nil
guard let db = dbService else { return }
self.libraryVM = LibraryViewModel(db: db)
self.playlistVM = PlaylistViewModel(db: db)
}
private func refreshStreamingDB() async throws {
await streamingClient.connect(hostURL: streamHostURL, apiKey: streamAPIKey)
if streamingClient.state.isConnected {
let streamingDb = try DatabaseService(path: StreamingClient.streamingDBPath)
self.libraryVM = LibraryViewModel(db: streamingDb)
self.playlistVM = PlaylistViewModel(db: streamingDb)
}
}
private func computeNetworkStatus() -> NetworkStatus? {
if remoteClient.connectionState.isConnected {
let hostName: String
@ -237,6 +375,16 @@ struct MusicApp: App {
if let server = hostServer, server.isHosting {
return NetworkStatus(mode: .hosting(connectedRemote: server.connectedRemoteName))
}
if let server = streamingServer, server.isRunning {
return NetworkStatus(mode: .streamHosting(tunnelURL: tunnelManager.tunnelURL))
}
if case .connected(let hostName) = streamingClient.state {
return NetworkStatus(
mode: .streamClient(hostName: hostName),
onDisconnect: { self.exitStreamingClientMode() },
onRefreshLibrary: { [streamingClient] in streamingClient.requestDBRefresh() }
)
}
return nil
}
}

@ -0,0 +1,25 @@
import Foundation
@MainActor
protocol PlaybackProvider: AnyObject {
var isPlaying: Bool { get }
var currentTime: Double { get }
var duration: Double { get }
var volume: Float { get }
var isScrubbing: Bool { get }
var onTrackFinished: (() -> Void)? { get set }
var onPlaybackStateChanged: (() -> Void)? { get set }
func urlForTrack(_ track: Track) -> URL?
func play(url: URL)
func pause()
func resume()
func togglePlayPause()
func seek(to position: Double)
func setVolume(_ level: Float)
func stop()
func beginScrubbing()
func scrub(to position: Double)
func endScrubbing(at position: Double)
}

@ -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")"
}
}

@ -0,0 +1,100 @@
import Foundation
import Observation
import MusicShared
@Observable
final class RemotePlaybackProvider: PlaybackProvider {
var isPlaying = false
var currentTime: Double = 0
var duration: Double = 0
var volume: Float = 0.65
private(set) var isScrubbing = false
var onTrackFinished: (() -> Void)?
var onPlaybackStateChanged: (() -> Void)?
private weak var commandSender: RemoteCommandSender?
init(commandSender: RemoteCommandSender) {
self.commandSender = commandSender
}
func urlForTrack(_ track: Track) -> URL? {
nil
}
func play(url: URL) {
// Remote mode uses sendPlayCommand(trackId:queueIds:) instead
}
func sendPlayCommand(trackId: Int64, queueIds: [Int64]) {
commandSender?.sendCommand(.play(trackId: trackId, queueIds: queueIds))
}
func pause() {
isPlaying = false
commandSender?.sendCommand(.pause)
onPlaybackStateChanged?()
}
func resume() {
isPlaying = true
commandSender?.sendCommand(.resume)
onPlaybackStateChanged?()
}
func togglePlayPause() {
if isPlaying { pause() } else { resume() }
}
func seek(to position: Double) {
currentTime = position
commandSender?.sendCommand(.seek(position: position))
}
func setVolume(_ level: Float) {
volume = level
commandSender?.sendCommand(.setVolume(level: level))
}
func stop() {
isPlaying = false
currentTime = 0
duration = 0
onPlaybackStateChanged?()
}
func beginScrubbing() {
isScrubbing = true
}
func scrub(to position: Double) {
currentTime = position
}
func endScrubbing(at position: Double) {
currentTime = position
isScrubbing = false
commandSender?.sendCommand(.seek(position: position))
}
func sendNext() {
commandSender?.sendCommand(.next)
}
func sendPrevious() {
commandSender?.sendCommand(.previous)
}
func sendToggleShuffle() {
commandSender?.sendCommand(.toggleShuffle)
}
func applyRemoteState(_ state: PlaybackStatePayload) {
isPlaying = state.isPlaying
currentTime = state.currentTime
duration = state.duration
volume = state.volume
onPlaybackStateChanged?()
}
}

@ -0,0 +1,303 @@
import AVFoundation
import Foundation
import Observation
import MusicShared
import os
@Observable
final class StreamingPlaybackProvider: PlaybackProvider {
var isPlaying = false
var currentTime: Double = 0
var duration: Double = 0
var volume: Float = 0.65 {
didSet { player?.volume = volume }
}
private(set) var isScrubbing = false
var playbackError: String?
var isBuffering = false
var onTrackFinished: (() -> Void)?
var onPlaybackStateChanged: (() -> Void)?
private(set) var player: AVPlayer?
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
private var failedObserver: NSObjectProtocol?
private var statusObservation: NSKeyValueObservation?
private var timeControlObservation: NSKeyValueObservation?
private var seekInProgress = false
private var pendingSeekTime: Double?
private var playTask: Task<Void, Never>?
private let hostURL: String
private let apiKey: String
private let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingPlayback")
init(hostURL: String, apiKey: String) {
self.hostURL = hostURL.hasSuffix("/") ? String(hostURL.dropLast()) : hostURL
self.apiKey = apiKey
}
func urlForTrack(_ track: Track) -> URL? {
guard let trackId = track.id else { return nil }
// StreamingRoutes.trackFile already includes ?id=TRACKID
return URL(string: "\(hostURL)\(StreamingRoutes.trackFile(trackId: trackId))&token=\(apiKey)")
}
func play(url: URL) {
cleanup()
playbackError = nil
isBuffering = true
isPlaying = true
onPlaybackStateChanged?()
playTask = Task { [weak self] in
guard let self else { return }
// Pre-flight: verify the URL is reachable before handing to AVPlayer
do {
var request = URLRequest(url: url)
request.httpMethod = "GET"
// Only fetch first byte to avoid downloading the whole file
request.setValue("bytes=0-0", forHTTPHeaderField: "Range")
let (data, response) = try await URLSession.shared.data(for: request)
guard !Task.isCancelled else { return }
if let http = response as? HTTPURLResponse, http.statusCode != 200 && http.statusCode != 206 {
let body = String(data: data.prefix(500), encoding: .utf8) ?? ""
let msg: String
switch http.statusCode {
case 401: msg = "Server rejected authentication"
case 404: msg = "Route not found (HTTP 404)"
case 500...599: msg = "Server error (\(http.statusCode))"
default: msg = "HTTP \(http.statusCode)"
}
self.logger.error("\(msg, privacy: .public) — body: \(body, privacy: .public)")
self.playbackError = msg
self.isPlaying = false
self.isBuffering = false
self.onPlaybackStateChanged?()
return
}
} catch {
guard !Task.isCancelled else { return }
self.logger.error("Network error: \(error.localizedDescription, privacy: .public)")
self.playbackError = "Network: \(error.localizedDescription)"
self.isPlaying = false
self.isBuffering = false
self.onPlaybackStateChanged?()
return
}
guard !Task.isCancelled else { return }
self.logger.info("Pre-flight OK, starting AVPlayer for \(url.absoluteString, privacy: .public)")
self.startAVPlayer(url: url)
}
}
func startAVPlayer(url: URL) {
let asset = AVURLAsset(url: url)
let item = AVPlayerItem(asset: asset)
player = AVPlayer(playerItem: item)
player?.volume = volume
statusObservation = item.observe(\.status, options: [.new]) { [weak self] (playerItem: AVPlayerItem, _) in
DispatchQueue.main.async {
guard let self else { return }
switch playerItem.status {
case .failed:
let msg = playerItem.error?.localizedDescription ?? "Unknown playback error"
self.logger.error("AVPlayer failed: \(msg, privacy: .public)")
self.playbackError = msg
self.isPlaying = false
self.isBuffering = false
self.onPlaybackStateChanged?()
case .readyToPlay:
self.logger.info("Stream ready")
self.playbackError = nil
self.isBuffering = false
self.onPlaybackStateChanged?()
default:
break
}
}
}
timeControlObservation = player?.observe(\.timeControlStatus, options: [.new]) { [weak self] (avPlayer: AVPlayer, _) in
DispatchQueue.main.async {
guard let self else { return }
switch avPlayer.timeControlStatus {
case .waitingToPlayAtSpecifiedRate:
self.isBuffering = true
case .playing:
self.isBuffering = false
case .paused:
self.isBuffering = false
@unknown default:
break
}
self.onPlaybackStateChanged?()
}
}
timeObserver = player?.addPeriodicTimeObserver(
forInterval: CMTime(seconds: 0.5, preferredTimescale: 600),
queue: .main
) { [weak self] time in
guard let self, !self.isScrubbing else { return }
self.currentTime = time.seconds
if let dur = self.player?.currentItem?.duration, dur.isValid, !dur.isIndefinite {
self.duration = dur.seconds
}
self.onPlaybackStateChanged?()
}
endObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { [weak self] _ in
self?.isPlaying = false
self?.currentTime = 0
self?.onPlaybackStateChanged?()
self?.onTrackFinished?()
}
failedObserver = NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: item,
queue: .main
) { [weak self] notification in
let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
let msg = error?.localizedDescription ?? "Playback interrupted"
self?.playbackError = msg
self?.isPlaying = false
self?.isBuffering = false
self?.onPlaybackStateChanged?()
}
player?.play()
}
func pause() {
player?.pause()
isPlaying = false
onPlaybackStateChanged?()
}
func resume() {
player?.play()
isPlaying = true
onPlaybackStateChanged?()
}
func togglePlayPause() {
if isPlaying { pause() } else { resume() }
}
func seek(to time: Double) {
let clamped = max(0, min(time, duration))
currentTime = clamped
player?.seek(
to: CMTime(seconds: clamped, preferredTimescale: 600),
toleranceBefore: .zero,
toleranceAfter: .zero
)
}
func setVolume(_ level: Float) {
volume = level
}
func beginScrubbing() {
isScrubbing = true
}
func scrub(to time: Double) {
let clamped = max(0, min(time, duration))
currentTime = clamped
pendingSeekTime = clamped
guard !seekInProgress else { return }
performPendingSeek()
}
func endScrubbing(at time: Double) {
let clamped = max(0, min(time, duration))
currentTime = clamped
pendingSeekTime = nil
seekInProgress = false
player?.seek(
to: CMTime(seconds: clamped, preferredTimescale: 600),
toleranceBefore: .zero,
toleranceAfter: .zero
) { [weak self] _ in
DispatchQueue.main.async {
self?.isScrubbing = false
}
}
}
func stop() {
cleanup()
isPlaying = false
isBuffering = false
playbackError = nil
currentTime = 0
duration = 0
onPlaybackStateChanged?()
}
private func performPendingSeek() {
guard let time = pendingSeekTime else { return }
pendingSeekTime = nil
seekInProgress = true
player?.seek(
to: CMTime(seconds: time, preferredTimescale: 600),
toleranceBefore: CMTime(seconds: 0.1, preferredTimescale: 600),
toleranceAfter: CMTime(seconds: 0.1, preferredTimescale: 600)
) { [weak self] _ in
DispatchQueue.main.async {
guard let self else { return }
self.seekInProgress = false
if self.pendingSeekTime != nil {
self.performPendingSeek()
}
}
}
}
private func cleanup() {
playTask?.cancel()
playTask = nil
statusObservation?.invalidate()
statusObservation = nil
timeControlObservation?.invalidate()
timeControlObservation = nil
if let obs = timeObserver {
player?.removeTimeObserver(obs)
timeObserver = nil
}
if let obs = endObserver {
NotificationCenter.default.removeObserver(obs)
endObserver = nil
}
if let obs = failedObserver {
NotificationCenter.default.removeObserver(obs)
failedObserver = nil
}
player?.pause()
// Dissociate the item from the player to release its decode/render pipeline.
// Setting `player = nil` alone does NOT free the pipeline (ARC tears it down
// asynchronously); without this, pipelines accumulate across track switches
// until a new player can't acquire a decode session and fails with -16044.
player?.replaceCurrentItem(with: nil)
player = nil
}
nonisolated deinit {}
}

@ -1,4 +1,5 @@
import Foundation
import MusicShared
import Network
import os
@ -141,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,

@ -4,6 +4,8 @@ struct NetworkStatus {
enum Mode {
case hosting(connectedRemote: String?)
case remote(hostName: String)
case streamHosting(tunnelURL: String?)
case streamClient(hostName: String)
}
var mode: Mode
@ -11,7 +13,31 @@ struct NetworkStatus {
var onRefreshLibrary: (() -> Void)?
var isRemoteMode: Bool {
if case .remote = mode { return true }
return false
switch mode {
case .remote, .streamClient: return true
default: return false
}
}
var isHostMode: Bool {
switch mode {
case .hosting, .streamHosting: return true
default: return false
}
}
var statusMessage: String {
switch mode {
case .hosting(let remote):
if let remote { return "Hosting · \(remote) connected" }
return "Hosting"
case .remote(let host):
return "Connected to \(host)"
case .streamHosting(let url):
if let url { return "Streaming · \(url)" }
return "Streaming server starting..."
case .streamClient(let host):
return "Streaming from \(host)"
}
}
}

@ -1,4 +1,5 @@
import Foundation
import MusicShared
import Network
import os
@ -188,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)")
@ -209,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.
@ -378,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)")
}
}
}

@ -8,7 +8,7 @@ import Observation
// actor-isolated deinits it simply nils out the player reference so ARC handles
// cleanup safely without crossing actor boundaries.
@Observable
final class AudioService {
final class AudioService: PlaybackProvider {
var isPlaying = false
var currentTime: Double = 0
var duration: Double = 0
@ -16,7 +16,7 @@ final class AudioService {
didSet { player?.volume = volume }
}
private var player: AVPlayer?
private(set) var player: AVPlayer?
private var timeObserver: Any?
private var endObserver: NSObjectProtocol?
@ -145,6 +145,14 @@ final class AudioService {
max(0, min(time, duration))
}
func urlForTrack(_ track: Track) -> URL? {
URL(string: track.fileURL)
}
func setVolume(_ level: Float) {
volume = level
}
func stop() {
cleanup()
isPlaying = false
@ -163,6 +171,11 @@ final class AudioService {
endObserver = nil
}
player?.pause()
// Dissociate the item from the player to release its decode/render pipeline.
// Setting `player = nil` alone does NOT free the pipeline (ARC tears it down
// asynchronously); without this, pipelines accumulate across track switches
// until a new player can't acquire a decode session and fails with -16044.
player?.replaceCurrentItem(with: nil)
player = nil
}

@ -110,16 +110,72 @@ nonisolated final class DatabaseService: Sendable {
}
}
migrator.registerMigration("v4-drop-artworkData") { db in
try db.alter(table: "tracks") { t in
t.drop(column: "artworkData")
}
}
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
@ -147,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)
@ -164,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)
@ -172,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)"
)
}
@ -191,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")
@ -438,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
@ -466,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 }
@ -90,8 +115,6 @@ final class ScannerService {
var discNumber: Int?
var bpm: Int?
var composer = ""
var artworkData: Data?
do {
let metadata = try await asset.load(.metadata)
@ -106,8 +129,6 @@ final class ScannerService {
if albumArtist == "Unknown" { albumArtist = val }
case .commonKeyAlbumName:
album = (try? await item.load(.stringValue)) ?? "Unknown"
case .commonKeyArtwork:
artworkData = try? await item.load(.dataValue)
case .commonKeyCreator:
composer = (try? await item.load(.stringValue)) ?? ""
default:
@ -150,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,
@ -183,14 +213,13 @@ final class ScannerService {
fileFormat: url.pathExtension.lowercased(),
bitrate: bitrate,
sampleRate: sampleRate,
fileSize: fileSize,
artworkData: artworkData,
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)
)
}
}

@ -0,0 +1,80 @@
import AVFoundation
import Foundation
final class HLSSegmenter: Sendable {
let fileURL: URL
let duration: Double
init(fileURL: URL) throws {
self.fileURL = fileURL
let asset = AVURLAsset(url: fileURL)
let durationCM = asset.duration
guard durationCM.isValid, !durationCM.isIndefinite else {
throw HLSSegmenterError.invalidDuration
}
self.duration = durationCM.seconds
}
func segment(at index: Int, segmentDuration: Double) async throws -> Data? {
let startTime = Double(index) * segmentDuration
guard startTime < duration else { return nil }
let endTime = min(startTime + segmentDuration, duration)
let timeRange = CMTimeRange(
start: CMTime(seconds: startTime, preferredTimescale: 600),
end: CMTime(seconds: endTime, preferredTimescale: 600)
)
let asset = AVURLAsset(url: fileURL)
guard let track = try await asset.loadTracks(withMediaType: .audio).first else {
throw HLSSegmenterError.noAudioTrack
}
let reader = try AVAssetReader(asset: asset)
reader.timeRange = timeRange
// Try passthrough first (for MP3 sources), fall back to PCM if needed
let output: AVAssetReaderOutput
let trackOutput = AVAssetReaderTrackOutput(track: track, outputSettings: nil)
if reader.canAdd(trackOutput) {
reader.add(trackOutput)
output = trackOutput
} else {
let pcmOutput = AVAssetReaderTrackOutput(track: track, outputSettings: [
AVFormatIDKey: kAudioFormatLinearPCM,
AVSampleRateKey: 44100,
AVNumberOfChannelsKey: 2,
AVLinearPCMBitDepthKey: 16,
AVLinearPCMIsFloatKey: false,
AVLinearPCMIsBigEndianKey: false,
])
reader.add(pcmOutput)
output = pcmOutput
}
reader.startReading()
var segmentData = Data()
while let sampleBuffer = output.copyNextSampleBuffer() {
if let blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer) {
let length = CMBlockBufferGetDataLength(blockBuffer)
var bytes = [UInt8](repeating: 0, count: length)
CMBlockBufferCopyDataBytes(blockBuffer, atOffset: 0, dataLength: length, destination: &bytes)
segmentData.append(contentsOf: bytes)
}
}
guard reader.status == .completed else {
throw HLSSegmenterError.readFailed(reader.error)
}
return segmentData
}
}
enum HLSSegmenterError: Error {
case invalidDuration
case noAudioTrack
case readFailed(Error?)
}

@ -0,0 +1,219 @@
import Foundation
import Observation
import MusicShared
import os
@MainActor
@Observable
final class StreamingClient {
enum State: Equatable {
case disconnected
case connecting
case downloadingDB
case connected(hostName: String)
case error(message: String)
var isConnected: Bool {
if case .connected = self { return true }
return false
}
}
var state: State = .disconnected
var onDBReady: (() -> Void)?
private(set) var serverCapabilities: [String] = []
private var hostURL: String = ""
private var apiKey: String = ""
private var webSocketTask: URLSessionWebSocketTask?
private let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingClient")
static var streamingDBPath: String {
let appSupport = FileManager.default.urls(
for: .applicationSupportDirectory, in: .userDomainMask
).first!.appendingPathComponent("Music", isDirectory: true)
return appSupport.appendingPathComponent("streaming_db.sqlite").path
}
func connect(hostURL: String, apiKey: String) async {
self.hostURL = hostURL.hasSuffix("/") ? String(hostURL.dropLast()) : hostURL
self.apiKey = apiKey
state = .connecting
do {
let authResponse = try await authenticate()
logger.info("Authenticated with host: \(authResponse.hostName) (protocol v\(authResponse.protocolVersion), capabilities: \(authResponse.capabilities ?? []))")
serverCapabilities = authResponse.capabilities ?? []
state = .downloadingDB
try await downloadDatabase()
logger.info("Database downloaded")
state = .connected(hostName: authResponse.hostName)
} catch {
logger.error("Connection failed: \(error.localizedDescription)")
state = .error(message: error.localizedDescription)
}
}
func disconnect() {
webSocketTask?.cancel(with: .normalClosure, reason: nil)
webSocketTask = nil
deleteStreamingDB()
state = .disconnected
}
func requestDBRefresh() {
sendCommand(.refreshDB)
}
// MARK: - Auth
private func authenticate() async throws -> AuthResponse {
let url = URL(string: "\(hostURL)\(StreamingRoutes.auth)")!
var request = URLRequest(url: url)
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
throw StreamingClientError.invalidResponse
}
if httpResponse.statusCode == 401 {
throw StreamingClientError.unauthorized
}
guard httpResponse.statusCode == 200 else {
throw StreamingClientError.serverError(httpResponse.statusCode)
}
return try JSONDecoder().decode(AuthResponse.self, from: data)
}
// MARK: - DB Download
private func downloadDatabase() async throws {
let url = URL(string: "\(hostURL)\(StreamingRoutes.db)")!
var request = URLRequest(url: url)
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw StreamingClientError.dbDownloadFailed
}
let dirURL = URL(fileURLWithPath: Self.streamingDBPath).deletingLastPathComponent()
try FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true)
try data.write(to: URL(fileURLWithPath: Self.streamingDBPath))
logger.info("Database saved (\(data.count) bytes)")
}
// MARK: - WebSocket
private func connectWebSocket() {
let wsURLString = hostURL
.replacingOccurrences(of: "https://", with: "wss://")
.replacingOccurrences(of: "http://", with: "ws://")
guard let url = URL(string: "\(wsURLString)\(StreamingRoutes.ws)") else { return }
var request = URLRequest(url: url)
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
let task = URLSession.shared.webSocketTask(with: request)
task.resume()
self.webSocketTask = task
let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "unknown"
let handshake = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: appVersion)
if let data = try? JSONEncoder().encode(handshake),
let string = String(data: data, encoding: .utf8) {
task.send(.string(string)) { [weak self] error in
if let error {
self?.logger.error("Failed to send handshake: \(error.localizedDescription)")
}
}
}
receiveWebSocketMessages()
}
private func receiveWebSocketMessages() {
webSocketTask?.receive { [weak self] result in
Task { @MainActor [weak self] in
guard let self else { return }
switch result {
case .success(let message):
self.handleWebSocketMessage(message)
self.receiveWebSocketMessages()
case .failure(let error):
self.logger.error("WebSocket error: \(error.localizedDescription)")
if self.state.isConnected {
self.state = .error(message: "Connection lost")
}
}
}
}
}
private func handleWebSocketMessage(_ message: URLSessionWebSocketTask.Message) {
let data: Data
switch message {
case .string(let text):
guard let d = text.data(using: .utf8) else { return }
data = d
case .data(let d):
data = d
@unknown default:
return
}
do {
let event = try JSONDecoder().decode(HostEvent.self, from: data)
switch event {
case .playbackState:
break
case .dbReady:
onDBReady?()
case .error(let message):
logger.error("Host error: \(message)")
}
} catch {
logger.error("Failed to decode event: \(error.localizedDescription)")
}
}
private func sendCommand(_ command: RemoteCommand) {
guard let data = try? JSONEncoder().encode(command),
let string = String(data: data, encoding: .utf8) else { return }
webSocketTask?.send(.string(string)) { [weak self] error in
if let error {
self?.logger.error("Failed to send command: \(error.localizedDescription)")
}
}
}
private func deleteStreamingDB() {
let path = Self.streamingDBPath
if FileManager.default.fileExists(atPath: path) {
try? FileManager.default.removeItem(atPath: path)
logger.info("Deleted streaming DB")
}
}
}
enum StreamingClientError: LocalizedError {
case invalidResponse
case unauthorized
case serverError(Int)
case dbDownloadFailed
var errorDescription: String? {
switch self {
case .invalidResponse: return "Invalid server response"
case .unauthorized: return "Invalid API key"
case .serverError(let code): return "Server error (\(code))"
case .dbDownloadFailed: return "Failed to download library"
}
}
}

@ -0,0 +1,83 @@
import SwiftUI
struct StreamingConnectionSheet: View {
@Binding var hostURL: String
@Binding var apiKey: String
@Bindable var client: StreamingClient
@Binding var isPresented: Bool
var onConnect: () -> Void
private var isConnecting: Bool {
switch client.state {
case .connecting, .downloadingDB: return true
default: return false
}
}
var body: some View {
VStack(spacing: 16) {
Text("Connect to Streaming Host")
.font(.headline)
TextField("Host URL (e.g. https://music.example.com)", text: $hostURL)
.textFieldStyle(.roundedBorder)
.disabled(isConnecting)
SecureField("API Key", text: $apiKey)
.textFieldStyle(.roundedBorder)
.disabled(isConnecting)
statusView
HStack {
Button("Cancel") {
if isConnecting {
client.disconnect()
}
isPresented = false
}
Button(isConnecting ? "Connecting..." : "Connect") {
onConnect()
}
.disabled(hostURL.isEmpty || apiKey.isEmpty || isConnecting)
.keyboardShortcut(.defaultAction)
}
}
.padding(24)
.frame(width: 420)
.onChange(of: client.state) { _, newState in
if newState.isConnected {
isPresented = false
}
}
}
@ViewBuilder
private var statusView: some View {
switch client.state {
case .connecting:
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Authenticating...").foregroundStyle(.secondary)
}
case .downloadingDB:
HStack(spacing: 8) {
ProgressView().controlSize(.small)
Text("Downloading library...").foregroundStyle(.secondary)
}
case .error(let message):
Text(message)
.foregroundStyle(.red)
.font(.system(size: 12))
case .connected:
if !client.serverCapabilities.contains("file-streaming") {
Label("Host needs update — streaming may not work", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.system(size: 12))
}
default:
EmptyView()
}
}
}

@ -0,0 +1,418 @@
import Foundation
import MusicShared
import os
// MARK: - StreamingServer
/// Hummingbird-based HTTP server that exposes the music library for streaming.
///
/// Endpoints:
/// - `GET /auth` validate API key, return `AuthResponse` JSON
/// - `GET /db` backup SQLite and serve the file
/// - `GET /tracks/:trackId/stream.m3u8` HLS manifest
/// - `GET /tracks/:trackId/segments/:index.mp3` audio segment data
///
/// WebSocket at `/ws` is planned but deferred until the upstream
/// swift-websocket NIOSSL dependency issue is resolved.
@MainActor
@Observable
final class StreamingServer {
// MARK: - Observable State
var isRunning = false
private(set) var actualPort: Int?
// MARK: - Dependencies
private let db: DatabaseService
private let apiKey: String
private let requestedPort: Int
/// Cache of HLSSegmenter instances keyed by track ID to avoid re-parsing AVAsset metadata.
private let segmenterCache = SegmenterCache()
/// Continuation-based hook so `start()` can wait for the OS-assigned port.
private var portContinuation: CheckedContinuation<Int, Never>?
/// Task running the Hummingbird application; cancelled on `stop()`.
private var serverTask: Task<Void, any Error>?
// MARK: - Init
init(db: DatabaseService, apiKey: String, port: Int = StreamingConstants.defaultPort) {
self.db = db
self.apiKey = apiKey
self.requestedPort = port
}
// MARK: - Lifecycle
func start() async throws {
guard !isRunning else { return }
// Capture immutable/sendable values for use in Sendable closures
let db = self.db
let apiKey = self.apiKey
let port = self.requestedPort
let segmenterCache = self.segmenterCache
let logger = Logger(subsystem: "com.staxriver.mu", category: "StreamingServer")
// Build the HTTP router
let router = Router()
// GET /ping diagnostic endpoint
router.get("ping") { _, _ -> Response in
Response(status: .ok, body: .init(byteBuffer: ByteBuffer(string: "pong")))
}
// GET /ping/:value diagnostic for parameterized routes
router.get("ping/:value") { _, context -> Response in
let value = context.parameters.get("value") ?? "nil"
return Response(status: .ok, body: .init(byteBuffer: ByteBuffer(string: "echo: \(value)")))
}
// GET /auth
router.get("auth") { request, _ -> Response in
// Validate auth
guard let authHeader = request.headers[.authorization],
authHeader == "Bearer \(apiKey)" else {
return Response(status: .unauthorized)
}
let hostName = Host.current().localizedName ?? "Music Server"
let authResponse = AuthResponse(
hostName: hostName,
protocolVersion: StreamingConstants.protocolVersion,
capabilities: ["file-streaming"]
)
let data = try JSONEncoder().encode(authResponse)
return Response(
status: .ok,
headers: [.contentType: "application/json"],
body: .init(byteBuffer: ByteBuffer(bytes: data))
)
}
// GET /db
router.get("db") { [db] request, _ -> Response in
// Validate auth
guard let authHeader = request.headers[.authorization],
authHeader == "Bearer \(apiKey)" else {
return Response(status: .unauthorized)
}
let tempURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".sqlite")
defer { try? FileManager.default.removeItem(at: tempURL) }
try db.backup(to: tempURL.path)
let data = try Data(contentsOf: tempURL)
return Response(
status: .ok,
headers: [.contentType: "application/octet-stream"],
body: .init(byteBuffer: ByteBuffer(bytes: data))
)
}
// 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
guard hasBearer || hasToken else {
return Response(status: .unauthorized)
}
guard let idString = request.uri.queryParameters.get("id"),
let trackId = Int64(idString) else {
throw HTTPError(.badRequest, message: "Missing or invalid 'id' parameter")
}
let tracks = try db.fetchTracksByIds([trackId])
guard let track = tracks.first else {
throw HTTPError(.notFound, message: "Track \(trackId) not found")
}
let fileURL = resolveStoredFileURL(track.fileURL)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
throw HTTPError(.notFound, message: "File not found on disk")
}
let contentType = Self.audioContentType(for: fileURL.pathExtension)
// 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
router.get("tracks/:trackId/stream.m3u8") { [db, segmenterCache] request, context -> Response in
// Validate auth
guard let authHeader = request.headers[.authorization],
authHeader == "Bearer \(apiKey)" else {
return Response(status: .unauthorized)
}
let trackId = try context.parameters.require("trackId", as: Int64.self)
let segmenter = try await segmenterCache.segmenter(for: trackId, db: db)
let manifest = HLSManifestGenerator.manifest(
trackId: trackId,
duration: segmenter.duration,
segmentDuration: StreamingConstants.segmentDuration,
token: apiKey
)
return Response(
status: .ok,
headers: [.contentType: "application/vnd.apple.mpegurl"],
body: .init(byteBuffer: ByteBuffer(string: manifest))
)
}
// GET /tracks/:trackId/segments/:index
router.get("tracks/:trackId/segments/:index") { [db, segmenterCache] request, context -> Response in
let hasBearer = request.headers[.authorization] == "Bearer \(apiKey)"
let hasToken = request.uri.queryParameters.get("token") == apiKey
guard hasBearer || hasToken else {
return Response(status: .unauthorized)
}
let trackId = try context.parameters.require("trackId", as: Int64.self)
// The index parameter may include ".mp3" suffix from the URL; strip it
guard var indexString = context.parameters.get("index") else {
throw HTTPError(.badRequest)
}
if indexString.hasSuffix(".mp3") {
indexString = String(indexString.dropLast(4))
}
guard let index = Int(indexString) else {
throw HTTPError(.badRequest, message: "Invalid segment index")
}
let segmenter = try await segmenterCache.segmenter(for: trackId, db: db)
guard let data = try await segmenter.segment(
at: index,
segmentDuration: StreamingConstants.segmentDuration
) else {
throw HTTPError(.notFound)
}
return Response(
status: .ok,
headers: [.contentType: "audio/mpeg"],
body: .init(byteBuffer: ByteBuffer(bytes: data))
)
}
let app = Application(
router: router,
configuration: .init(address: .hostname("127.0.0.1", port: port)),
onServerRunning: { @Sendable [weak self] channel in
let boundPort = channel.localAddress?.port ?? port
await MainActor.run {
self?.actualPort = boundPort
self?.isRunning = true
self?.portContinuation?.resume(returning: boundPort)
self?.portContinuation = nil
}
}
)
// Start in a detached task so start() can return after the port is known
serverTask = Task.detached {
try await app.run()
}
// Wait for the port to be assigned (important for port: 0 in tests)
let assignedPort = await withCheckedContinuation { (continuation: CheckedContinuation<Int, Never>) in
// If the port was already set by onServerRunning (unlikely but possible),
// resume immediately
if let port = self.actualPort {
continuation.resume(returning: port)
} else {
self.portContinuation = continuation
}
}
self.actualPort = assignedPort
self.isRunning = true
}
func stop() {
serverTask?.cancel()
serverTask = nil
isRunning = false
actualPort = nil
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"
case "m4a", "aac": return "audio/mp4"
case "flac": return "audio/flac"
case "wav": return "audio/wav"
case "ogg": return "audio/ogg"
case "aiff", "aif": return "audio/aiff"
default: return "application/octet-stream"
}
}
}
// MARK: - SegmenterCache
/// Thread-safe cache of `HLSSegmenter` instances keyed by track ID.
private final class SegmenterCache: Sendable {
private let storage = OSAllocatedUnfairLock(initialState: [Int64: HLSSegmenter]())
func segmenter(for trackId: Int64, db: DatabaseService) throws -> HLSSegmenter {
// Check cache first
if let cached = storage.withLock({ $0[trackId] }) {
return cached
}
// Resolve the track's file URL from the database
let tracks = try db.fetchTracksByIds([trackId])
guard let track = tracks.first else {
throw HTTPError(.notFound, message: "Track \(trackId) not found")
}
let fileURL = resolveStoredFileURL(track.fileURL)
let segmenter = try HLSSegmenter(fileURL: fileURL)
storage.withLock { $0[trackId] = segmenter }
return segmenter
}
func clear() {
storage.withLock { $0.removeAll() }
}
}
/// Reconstructs a filesystem `URL` from the `fileURL` string stored in the
/// database. The scanner persists `url.absoluteString` (e.g. "file:///"), so it
/// must be parsed as a URL; `URL(fileURLWithPath:)` would treat the whole
/// "file://" string as a relative path (prepending the CWD) and never resolve
/// the file. Bare/legacy path strings fall back to `URL(fileURLWithPath:)`.
fileprivate func resolveStoredFileURL(_ stored: String) -> URL {
if let url = URL(string: stored), url.isFileURL {
return url
}
return URL(fileURLWithPath: stored)
}

@ -0,0 +1,125 @@
import Foundation
import os
import Observation
@MainActor
@Observable
final class TunnelManager {
enum TunnelMode: String, Codable {
case quick
case named
}
enum TunnelState: Equatable {
case stopped
case starting
case running(url: String)
case failed(message: String)
}
var state: TunnelState = .stopped
var tunnelURL: String? {
if case .running(let url) = state { return url }
return nil
}
private var process: Process?
private var outputPipe: Pipe?
private let logger = Logger(subsystem: "com.music.streaming", category: "tunnel")
static func isCloudflaredInstalled() -> Bool {
cloudflaredPath != nil
}
static var cloudflaredPath: String? {
if FileManager.default.fileExists(atPath: "/opt/homebrew/bin/cloudflared") {
return "/opt/homebrew/bin/cloudflared"
}
if FileManager.default.fileExists(atPath: "/usr/local/bin/cloudflared") {
return "/usr/local/bin/cloudflared"
}
return nil
}
func startQuickTunnel(localPort: Int) throws {
try startTunnel(
arguments: ["tunnel", "--url", "http://localhost:\(localPort)"],
logMessage: "Started cloudflared quick tunnel on port \(localPort)",
preserveFailedState: true
)
}
func startNamedTunnel(tunnelName: String, localPort: Int) throws {
try startTunnel(
arguments: ["tunnel", "run", "--url", "http://localhost:\(localPort)", tunnelName],
logMessage: "Started cloudflared named tunnel '\(tunnelName)' on port \(localPort)",
preserveFailedState: false
)
}
func stop() {
process?.terminate()
process = nil
outputPipe?.fileHandleForReading.readabilityHandler = nil
outputPipe = nil
state = .stopped
logger.info("Stopped cloudflared tunnel")
}
// MARK: - Private
private func startTunnel(arguments: [String], logMessage: String, preserveFailedState: Bool) throws {
guard let path = Self.cloudflaredPath else {
state = .failed(message: "cloudflared not found. Install with: brew install cloudflared")
return
}
state = .starting
let process = Process()
process.executableURL = URL(fileURLWithPath: path)
process.arguments = arguments
let pipe = Pipe()
process.standardError = pipe
pipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
let data = handle.availableData
guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { return }
Task { @MainActor [weak self] in
self?.parseOutput(line)
}
}
process.terminationHandler = { [weak self] proc in
Task { @MainActor [weak self] in
guard let self else { return }
if case .running = self.state {
// Was running normally just mark stopped
self.state = .stopped
} else if preserveFailedState {
// Never reached .running report the exit code as a failure
self.state = .failed(message: "cloudflared exited with code \(proc.terminationStatus)")
} else {
self.state = .stopped
}
}
}
try process.run()
self.process = process
self.outputPipe = pipe
logger.info("\(logMessage)")
}
private func parseOutput(_ output: String) {
let lines = output.components(separatedBy: .newlines)
for line in lines {
if let range = line.range(of: "https://[^ ]+", options: .regularExpression) {
let url = String(line[range])
state = .running(url: url)
logger.info("Tunnel URL: \(url)")
}
}
}
}

@ -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()
@ -39,6 +54,17 @@ final class LibraryViewModel {
updateQuery()
}
func deleteTracks(_ tracks: [Track], moveToTrash: Bool) throws {
let urls = Set(tracks.map(\.fileURL))
try db.deleteTracksWithURLs(urls)
if moveToTrash {
for track in tracks {
let url = URL(fileURLWithPath: track.fileURL)
try? FileManager.default.trashItem(at: url, resultingItemURL: nil)
}
}
}
private func updateQuery() {
cancellable?.cancel()
let search = searchText

@ -1,5 +1,6 @@
import Foundation
import Observation
import MusicShared
protocol RemoteCommandSender: AnyObject {
func sendCommand(_ command: RemoteCommand)
@ -17,43 +18,83 @@ final class PlayerViewModel {
private(set) var queue: [Track] = []
private var originalQueue: [Track] = []
private let audio: AudioService
/// 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
private var remoteClient: RemoteCommandSender?
var trackResolver: ((Int64) -> Track?)?
private var isRemote: Bool { remoteClient != nil }
var streamingError: String? {
(provider as? StreamingPlaybackProvider)?.playbackError
}
var isBuffering: Bool {
(provider as? StreamingPlaybackProvider)?.isBuffering ?? false
}
private var remoteProvider: RemotePlaybackProvider? {
provider as? RemotePlaybackProvider
}
init(audio: AudioService, db: DatabaseService?) {
self.audio = audio
init(provider: PlaybackProvider, db: DatabaseService?) {
self.provider = provider
self.db = db
bindProvider()
}
// MARK: - Provider Management
func setProvider(_ newProvider: PlaybackProvider) {
provider.stop()
provider = newProvider
currentTrack = nil
currentIndex = nil
isPlaying = false
currentTime = 0
duration = 0
queue = []
originalQueue = []
manualQueue = []
contextName = nil
halfwayReported = false
bindProvider()
}
audio.onTrackFinished = { [weak self] in
private func bindProvider() {
provider.onTrackFinished = { [weak self] in
self?.trackDidFinish()
}
audio.onPlaybackStateChanged = { [weak self] in
self?.syncFromAudio()
provider.onPlaybackStateChanged = { [weak self] in
self?.syncFromProvider()
}
}
// MARK: - Audio Sync
// MARK: - Provider Sync
private func syncFromAudio() {
guard !isRemote else { return }
isPlaying = audio.isPlaying
if !audio.isScrubbing {
currentTime = audio.currentTime
private func syncFromProvider() {
isPlaying = provider.isPlaying
if !provider.isScrubbing {
currentTime = provider.currentTime
}
// Only adopt the player's duration once it has actually resolved one.
// Progressive HTTP streams never report a valid duration, so keep the
// value seeded from the library database in play(_:).
if provider.duration > 0 {
duration = provider.duration
}
duration = audio.duration
volume = provider.volume
checkHalfway()
}
// 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)
@ -65,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) {
@ -73,13 +172,16 @@ final class PlayerViewModel {
halfwayReported = false
isPlaying = true
currentTime = 0
// The player can't determine duration for a progressive HTTP stream,
// so seed the value the library database already knows up front.
duration = track.duration
if let client = remoteClient {
if let remote = remoteProvider {
guard let trackId = track.id else { return }
client.sendCommand(.play(trackId: trackId, queueIds: queue.compactMap(\.id)))
remote.sendPlayCommand(trackId: trackId, queueIds: queue.compactMap(\.id))
} else {
guard let url = URL(string: track.fileURL) else { return }
audio.play(url: url)
guard let url = provider.urlForTrack(track) else { return }
provider.play(url: url)
}
}
@ -89,41 +191,47 @@ final class PlayerViewModel {
func pause() {
isPlaying = false
if let client = remoteClient { client.sendCommand(.pause) } else { audio.pause() }
provider.pause()
}
func resume() {
isPlaying = true
if let client = remoteClient { client.sendCommand(.resume) } else { audio.resume() }
provider.resume()
}
func seek(to position: Double) {
currentTime = position
if let client = remoteClient { client.sendCommand(.seek(position: position)) } else { audio.seek(to: position) }
provider.seek(to: position)
}
func setVolume(_ level: Float) {
volume = level
if let client = remoteClient { client.sendCommand(.setVolume(level: level)) } else { audio.volume = level }
provider.setVolume(level)
}
func beginScrubbing() {
if !isRemote { audio.beginScrubbing() }
provider.beginScrubbing()
}
func scrub(to position: Double) {
currentTime = position
if !isRemote { audio.scrub(to: position) }
provider.scrub(to: position)
}
func endScrubbing(at position: Double) {
currentTime = position
if let client = remoteClient { client.sendCommand(.seek(position: position)) } else { audio.endScrubbing(at: position) }
provider.endScrubbing(at: position)
}
func next() {
if let client = remoteClient {
client.sendCommand(.next)
if let remote = remoteProvider {
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 }
@ -136,8 +244,8 @@ final class PlayerViewModel {
}
func previous() {
if let client = remoteClient {
client.sendCommand(.previous)
if let remote = remoteProvider {
remote.sendPrevious()
return
}
guard let idx = currentIndex else { return }
@ -147,8 +255,8 @@ final class PlayerViewModel {
func toggleShuffle() {
isShuffled.toggle()
if let client = remoteClient {
client.sendCommand(.toggleShuffle)
if let remote = remoteProvider {
remote.sendToggleShuffle()
return
}
if isShuffled {
@ -167,37 +275,14 @@ final class PlayerViewModel {
duration = 0
currentTrack = nil
currentIndex = nil
if !isRemote { audio.stop() }
provider.stop()
}
// MARK: - Remote Mode
func enterRemoteMode(client: RemoteCommandSender) {
audio.stop()
remoteClient = client
currentTrack = nil
currentIndex = nil
isPlaying = false
currentTime = 0
duration = 0
queue = []
originalQueue = []
}
func exitRemoteMode() {
remoteClient = nil
trackResolver = nil
currentTrack = nil
currentIndex = nil
isPlaying = false
currentTime = 0
duration = 0
queue = []
originalQueue = []
}
// MARK: - Remote State
func applyRemoteState(_ state: PlaybackStatePayload) {
guard isRemote else { return }
guard let remote = remoteProvider else { return }
remote.applyRemoteState(state)
isPlaying = state.isPlaying
currentTime = state.currentTime
duration = state.duration
@ -226,7 +311,6 @@ final class PlayerViewModel {
}
private func trackDidFinish() {
guard !isRemote else { return }
if let track = currentTrack, let trackId = track.id, !halfwayReported {
let newCount = track.playCount + 1
try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date())

@ -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,

@ -1,4 +1,5 @@
import SwiftUI
import AVFoundation
struct PlayerControlsView: View {
let currentTrack: Track?
@ -7,6 +8,8 @@ struct PlayerControlsView: View {
let duration: Double
let volume: Float
let isShuffled: Bool
var isBuffering: Bool = false
var streamingError: String? = nil
let onPlayPause: () -> Void
let onNext: () -> Void
let onPrevious: () -> Void
@ -17,6 +20,10 @@ 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
@ -25,8 +32,18 @@ struct PlayerControlsView: 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)
@ -40,44 +57,6 @@ struct PlayerControlsView: View {
.background(.bar)
}
private var nowPlayingSection: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: 44, height: 44)
.overlay {
if let data = currentTrack?.artworkData,
let nsImage = NSImage(data: data) {
Image(nsImage: nsImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
if let track = currentTrack {
VStack(alignment: .leading, spacing: 2) {
Text(track.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
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 {
let trackHeight: CGFloat = 4
let thumbWidth: CGFloat = 4
@ -172,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)
@ -203,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 }
}
}
}

@ -1,4 +1,7 @@
import SwiftUI
import UniformTypeIdentifiers
private let trackIdUTType = UTType(exportedAs: "com.music.trackID")
struct PlaylistBarView: View {
var playlists: [any PlaylistRepresentable]
@ -11,6 +14,8 @@ struct PlaylistBarView: View {
var onRename: (any PlaylistRepresentable) -> Void
var onDelete: (any PlaylistRepresentable) -> Void
var onEditQuery: (SmartPlaylist) -> Void
var onEditConditions: (SmartPlaylist) -> Void
var onDropTrack: ((Int64, Playlist) -> Void)?
var body: some View {
FlowLayout(spacing: 6) {
@ -22,28 +27,29 @@ struct PlaylistBarView: View {
action: onHomeSelect
)
ForEach(playlists, id: \.id) { item in
PlaylistButton(
name: item.name,
isSelected: selectedItem?.id == item.id,
isSmart: item.isSmartPlaylist,
action: {
if selectedItem?.id == item.id {
ForEach(playlists, id: \.listIdentity) { item in
let isRegular = item is Playlist
PlaylistChip(
item: item,
isSelected: selectedItem?.listIdentity == item.listIdentity,
isRemoteMode: isRemoteMode,
acceptsDrop: isRegular,
trackIdUTType: trackIdUTType,
onTap: {
if selectedItem?.listIdentity == item.listIdentity {
onDeselect()
} else {
onSelect(item)
}
}
},
onDropTrack: isRegular ? { trackId in
onDropTrack?(trackId, item as! Playlist)
} : nil,
onRename: { onRename(item) },
onDelete: { onDelete(item) },
onEditQuery: (item as? SmartPlaylist).flatMap { smart in smart.conditions == nil ? { onEditQuery(smart) } : nil },
onEditConditions: (item as? SmartPlaylist).flatMap { smart in smart.conditions != nil ? { onEditConditions(smart) } : nil }
)
.contextMenu {
if !isRemoteMode {
Button("Rename...") { onRename(item) }
if let smart = item as? SmartPlaylist {
Button("Edit Search Query...") { onEditQuery(smart) }
}
Button("Delete") { onDelete(item) }
}
}
}
}
.padding(.horizontal, 12)
@ -51,11 +57,63 @@ struct PlaylistBarView: View {
}
}
private struct PlaylistChip: View {
let item: any PlaylistRepresentable
let isSelected: Bool
let isRemoteMode: Bool
let acceptsDrop: Bool
let trackIdUTType: UTType
let onTap: () -> Void
var onDropTrack: ((Int64) -> Void)?
let onRename: () -> Void
let onDelete: () -> Void
var onEditQuery: (() -> Void)?
var onEditConditions: (() -> Void)?
@State private var isDropTargeted = false
var body: some View {
PlaylistButton(
name: item.name,
isSelected: isSelected,
isSmart: item.isSmartPlaylist,
isDropTarget: isDropTargeted,
action: onTap
)
.if(acceptsDrop) { view in
view.onDrop(of: [trackIdUTType], isTargeted: $isDropTargeted) { providers in
guard let provider = providers.first else { return false }
provider.loadItem(forTypeIdentifier: trackIdUTType.identifier) { data, _ in
guard let data = data as? Data,
let str = String(data: data, encoding: .utf8),
let trackId = Int64(str) else { return }
DispatchQueue.main.async {
onDropTrack?(trackId)
}
}
return true
}
}
.contextMenu {
if !isRemoteMode {
Button("Rename...") { onRename() }
if let onEditConditions {
Button("Edit...") { onEditConditions() }
} else if let onEditQuery {
Button("Edit Search Query...") { onEditQuery() }
}
Button("Delete") { onDelete() }
}
}
}
}
private struct PlaylistButton: View {
let name: String
let isSelected: Bool
let isSmart: Bool
var icon: String? = nil
var isDropTarget: Bool = false
let action: () -> Void
private var tintColor: Color {
@ -78,14 +136,30 @@ private struct PlaylistButton: View {
.font(.system(size: 11))
.padding(.horizontal, 10)
.padding(.vertical, 5)
.background(isSelected ? tintColor.opacity(0.2) : Color.secondary.opacity(0.1))
.background(
isDropTarget ? tintColor.opacity(0.3) :
isSelected ? tintColor.opacity(0.2) :
Color.secondary.opacity(0.1)
)
.foregroundStyle(isSelected ? tintColor : inactiveColor)
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(isSelected ? tintColor : Color.secondary.opacity(0.3), lineWidth: 1)
.stroke(
isDropTarget ? tintColor :
isSelected ? tintColor :
Color.secondary.opacity(0.3),
lineWidth: isDropTarget ? 2 : 1
)
)
.cornerRadius(4)
}
.buttonStyle(.plain)
}
}
private extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition { transform(self) } else { self }
}
}

@ -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)
}
}
}

@ -5,6 +5,8 @@ private let visibleColumnsKey = "visibleTrackColumns"
private let defaultVisibleColumnIds: Set<String> = ["title", "artist", "album", "genre", "duration"]
private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID")
private let columnDefinitions: [(id: String, title: String, width: CGFloat, rightAlign: Bool)] = [
("title", "Title", 300, false),
("artist", "Artist", 200, false),
@ -42,12 +44,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 +56,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)
@ -135,13 +132,13 @@ struct TrackTableView: NSViewRepresentable {
tableView.sortDescriptors = [expectedDescriptor]
}
if context.coordinator.parent.onReorder != nil {
if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) {
tableView.registerForDraggedTypes([.string])
tableView.draggingDestinationFeedbackStyle = .gap
}
} else {
tableView.unregisterDraggedTypes()
let needsReorder = context.coordinator.parent.onReorder != nil
let wantedTypes: [NSPasteboard.PasteboardType] = needsReorder
? [trackIdPasteboardType, .string]
: [trackIdPasteboardType]
if Set(tableView.registeredDraggedTypes) != Set(wantedTypes) {
tableView.registerForDraggedTypes(wantedTypes)
tableView.draggingDestinationFeedbackStyle = needsReorder ? .gap : .none
}
if scrollTriggered {
@ -183,6 +180,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,72 +332,54 @@ 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())
}
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]
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)
populate(menu, with: config.entries(primary: clicked, selection: selection))
}
@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)
}
@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
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
guard parent.onReorder != nil else { return nil }
return "\(row)" as NSString
let item = NSPasteboardItem()
if let trackId = tracks[row].id {
item.setString(String(trackId), forType: trackIdPasteboardType)
}
if parent.onReorder != nil {
item.setString(String(row), forType: .string)
}
return item
}
func tableView(_ tableView: NSTableView, validateDrop info: any NSDraggingInfo, proposedRow row: Int, proposedDropOperation dropOperation: NSTableView.DropOperation) -> NSDragOperation {
@ -466,3 +448,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 @@
.build/

@ -0,0 +1,221 @@
{
"pins" : [
{
"identity" : "async-http-client",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client.git",
"state" : {
"revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f",
"version" : "1.33.1"
}
},
{
"identity" : "hummingbird",
"kind" : "remoteSourceControl",
"location" : "https://github.com/hummingbird-project/hummingbird.git",
"state" : {
"revision" : "2f407402799c2217df69b01582f3a44856fef012",
"version" : "2.24.0"
}
},
{
"identity" : "swift-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-algorithms.git",
"state" : {
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023",
"version" : "1.2.1"
}
},
{
"identity" : "swift-asn1",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-asn1.git",
"state" : {
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab",
"version" : "1.7.0"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440",
"version" : "1.1.4"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-certificates",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-certificates.git",
"state" : {
"revision" : "bde8ca32a096825dfce37467137c903418c1893d",
"version" : "1.19.1"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a",
"version" : "1.5.1"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration.git",
"state" : {
"revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9",
"version" : "1.2.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1",
"version" : "4.5.0"
}
},
{
"identity" : "swift-distributed-tracing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-distributed-tracing.git",
"state" : {
"revision" : "dc4030184203ffafbb2ec614352487235d747fe0",
"version" : "1.4.1"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47",
"version" : "1.7.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
"version" : "1.5.1"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "7dc6101ae4dbe95cd3bc9cebad3b7cf8e49a7a63",
"version" : "1.13.0"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics.git",
"state" : {
"revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4",
"version" : "2.11.0"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95",
"version" : "2.100.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506",
"version" : "1.34.1"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a",
"version" : "1.44.0"
}
},
{
"identity" : "swift-nio-ssl",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-ssl.git",
"state" : {
"revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da",
"version" : "2.37.0"
}
},
{
"identity" : "swift-nio-transport-services",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-transport-services.git",
"state" : {
"revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5",
"version" : "1.28.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-service-context",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-service-context.git",
"state" : {
"revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29",
"version" : "1.3.0"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
"state" : {
"revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a",
"version" : "2.11.0"
}
},
{
"identity" : "swift-system",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-system.git",
"state" : {
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df",
"version" : "1.6.4"
}
}
],
"version" : 2
}

@ -0,0 +1,44 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "MusicShared",
platforms: [
.macOS(.v14),
.iOS(.v17),
],
products: [
.library(
name: "MusicShared",
targets: ["MusicShared"]
),
],
dependencies: [
.package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"),
// Explicit transitive dependencies to work around Xcode SPM linker bug
// where product frameworks fail to link their own transitive deps in
// the build-for-testing action.
.package(url: "https://github.com/apple/swift-nio.git", from: "2.0.0"),
.package(url: "https://github.com/apple/swift-nio-transport-services.git", from: "1.20.0"),
.package(url: "https://github.com/apple/swift-service-context.git", from: "1.0.0"),
],
targets: [
.target(
name: "MusicShared",
dependencies: [
.product(name: "Hummingbird", package: "hummingbird"),
// Explicit transitive deps see comment on package dependencies above.
.product(name: "NIOFoundationCompat", package: "swift-nio"),
.product(name: "NIOHTTP1", package: "swift-nio"),
.product(name: "NIOTransportServices", package: "swift-nio-transport-services"),
.product(name: "ServiceContextModule", package: "swift-service-context"),
],
path: "Sources/MusicShared"
),
.testTarget(
name: "MusicSharedTests",
dependencies: ["MusicShared"],
path: "Tests/MusicSharedTests"
),
]
)

@ -0,0 +1,27 @@
import Foundation
public struct AuthResponse: Codable, Equatable, Sendable {
public var hostName: String
public var protocolVersion: Int
public var capabilities: [String]?
public init(hostName: String, protocolVersion: Int, capabilities: [String]? = nil) {
self.hostName = hostName
self.protocolVersion = protocolVersion
self.capabilities = capabilities
}
public var supportsDirectFileStreaming: Bool {
capabilities?.contains("file-streaming") ?? false
}
}
public struct DBMetadata: Codable, Equatable, Sendable {
public var checksum: String
public var trackCount: Int
public init(checksum: String, trackCount: Int) {
self.checksum = checksum
self.trackCount = trackCount
}
}

@ -0,0 +1,19 @@
import Foundation
public enum AppRole: String, Codable, CaseIterable, Sendable {
case local
case remoteHost
case remoteClient
case streamHost
case streamClient
}
extension AppRole {
public var isHost: Bool { self == .remoteHost || self == .streamHost }
public var isClient: Bool { self == .remoteClient || self == .streamClient }
public var isLocal: Bool { self == .local }
public var usesLocalAudio: Bool { self == .local || self == .remoteHost || self == .streamClient }
public var isReadOnlyLibrary: Bool { self == .remoteClient || self == .streamClient }
public var needsNetworkServer: Bool { self == .remoteHost || self == .streamHost }
public var isStreaming: Bool { self == .streamHost || self == .streamClient }
}

@ -0,0 +1,3 @@
// Re-export Hummingbird so the app target can use it
// without adding a separate package dependency.
@_exported import Hummingbird

@ -0,0 +1,43 @@
import Foundation
public enum HLSManifestGenerator: Sendable {
public struct TimeRange: Equatable, Sendable {
public var start: Double
public var duration: Double
}
public static func manifest(trackId: Int64, duration: Double, segmentDuration: Double, token: String? = nil) -> String {
let count = segmentCount(duration: duration, segmentDuration: segmentDuration)
let targetDuration = Int(segmentDuration.rounded(.up))
var lines: [String] = [
"#EXTM3U",
"#EXT-X-VERSION:3",
"#EXT-X-TARGETDURATION:\(targetDuration)",
"#EXT-X-MEDIA-SEQUENCE:0",
]
let tokenQuery = token.map { "?token=\($0)" } ?? ""
for i in 0..<count {
let range = segmentTimeRange(index: i, trackDuration: duration, segmentDuration: segmentDuration)
lines.append(String(format: "#EXTINF:%.3f,", range.duration))
lines.append("segments/\(i).mp3\(tokenQuery)")
}
lines.append("#EXT-X-ENDLIST")
lines.append("")
return lines.joined(separator: "\n")
}
public static func segmentCount(duration: Double, segmentDuration: Double) -> Int {
guard duration > 0, segmentDuration > 0 else { return 0 }
return Int((duration / segmentDuration).rounded(.up))
}
public static func segmentTimeRange(index: Int, trackDuration: Double, segmentDuration: Double) -> TimeRange {
let start = Double(index) * segmentDuration
let remaining = trackDuration - start
let duration = min(segmentDuration, remaining)
return TimeRange(start: start, duration: duration)
}
}

@ -3,31 +3,52 @@ import Foundation
// MARK: - Protocol Version
/// Current version of the remote control wire protocol.
nonisolated let RemoteProtocolVersion: Int = 1
public nonisolated let RemoteProtocolVersion: Int = 1
// MARK: - Supporting Types
/// Snapshot of the host's playback state, sent to remote clients.
nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable {
var trackId: Int64?
var isPlaying: Bool
var currentTime: Double
var duration: Double
var volume: Float
var isShuffled: Bool
public nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable {
public var trackId: Int64?
public var isPlaying: Bool
public var currentTime: Double
public var duration: Double
public var volume: Float
public var isShuffled: Bool
public init(
trackId: Int64? = nil,
isPlaying: Bool,
currentTime: Double,
duration: Double,
volume: Float,
isShuffled: Bool
) {
self.trackId = trackId
self.isPlaying = isPlaying
self.currentTime = currentTime
self.duration = duration
self.volume = volume
self.isShuffled = isShuffled
}
}
/// Exchanged during connection setup to agree on protocol version.
nonisolated struct HandshakeMessage: Codable, Equatable, Sendable {
var protocolVersion: Int
var appVersion: String
public nonisolated struct HandshakeMessage: Codable, Equatable, Sendable {
public var protocolVersion: Int
public var appVersion: String
public init(protocolVersion: Int, appVersion: String) {
self.protocolVersion = protocolVersion
self.appVersion = appVersion
}
}
// MARK: - RemoteCommand
/// Commands sent from a remote client to the host.
/// Wire format: `{"type":"<case>","payload":{...}}` (payload omitted for cases with no associated values).
nonisolated enum RemoteCommand: Equatable, Sendable {
public nonisolated enum RemoteCommand: Equatable, Sendable {
case play(trackId: Int64, queueIds: [Int64])
case pause
case resume
@ -62,7 +83,7 @@ extension RemoteCommand: Codable {
var level: Float
}
func encode(to encoder: Encoder) throws {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .play(let trackId, let queueIds):
@ -89,7 +110,7 @@ extension RemoteCommand: Codable {
}
}
init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(TypeKey.self, forKey: .type)
switch type {
@ -122,7 +143,7 @@ extension RemoteCommand: Codable {
/// Events sent from the host to remote clients.
/// Wire format: `{"type":"<case>","payload":{...}}` (payload omitted for cases with no associated values).
nonisolated enum HostEvent: Equatable, Sendable {
public nonisolated enum HostEvent: Equatable, Sendable {
case playbackState(PlaybackStatePayload)
case dbReady
case error(message: String)
@ -141,7 +162,7 @@ extension HostEvent: Codable {
var message: String
}
func encode(to encoder: Encoder) throws {
public func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .playbackState(let payload):
@ -155,7 +176,7 @@ extension HostEvent: Codable {
}
}
init(from decoder: Decoder) throws {
public init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(TypeKey.self, forKey: .type)
switch type {

@ -0,0 +1,7 @@
import Foundation
public enum StreamingConstants: Sendable {
public static let defaultPort: Int = 8420
public static let segmentDuration: Double = 6.0
public static let protocolVersion: Int = 2
}

@ -0,0 +1,27 @@
import Foundation
public enum StreamingRoutes: Sendable {
public static let auth = "/auth"
public static let db = "/db"
public static let ws = "/ws"
public static func trackFile(trackId: Int64) -> String {
"/file?id=\(trackId)"
}
public static func trackManifest(trackId: Int64) -> String {
"/tracks/\(trackId)/stream.m3u8"
}
public static func trackSegment(trackId: Int64, index: Int) -> String {
"/tracks/\(trackId)/segments/\(index).mp3"
}
public static func trackManifestPattern() -> String {
"/tracks/:trackId/stream.m3u8"
}
public static func trackSegmentPattern() -> String {
"/tracks/:trackId/segments/:index"
}
}

@ -0,0 +1,78 @@
import Testing
@testable import MusicShared
struct HLSManifestGeneratorTests {
// Generates a manifest for a 16-second track with 6s segments.
// Expects 3 segments: 6s, 6s, 4s (remainder).
@Test func generatesCorrectManifestForTypicalTrack() {
let manifest = HLSManifestGenerator.manifest(
trackId: 42,
duration: 16.0,
segmentDuration: 6.0
)
#expect(manifest.contains("#EXTM3U"))
#expect(manifest.contains("#EXT-X-VERSION:3"))
#expect(manifest.contains("#EXT-X-TARGETDURATION:6"))
#expect(manifest.contains("#EXT-X-MEDIA-SEQUENCE:0"))
#expect(manifest.contains("#EXTINF:6.000,"))
#expect(manifest.contains("#EXTINF:4.000,"))
#expect(manifest.contains("segments/0.mp3"))
#expect(manifest.contains("segments/1.mp3"))
#expect(manifest.contains("segments/2.mp3"))
#expect(!manifest.contains("segments/3.mp3"))
#expect(manifest.contains("#EXT-X-ENDLIST"))
}
// A track whose duration is an exact multiple of the segment duration.
// Expects no short final segment.
@Test func exactMultipleOfSegmentDuration() {
let manifest = HLSManifestGenerator.manifest(
trackId: 1,
duration: 12.0,
segmentDuration: 6.0
)
let segmentCount = manifest.components(separatedBy: "#EXTINF:6.000,").count - 1
#expect(segmentCount == 2)
#expect(!manifest.contains("segments/2.mp3"))
}
// A very short track (shorter than one segment).
// Expects a single segment with the track's full duration.
@Test func veryShortTrack() {
let manifest = HLSManifestGenerator.manifest(
trackId: 7,
duration: 2.5,
segmentDuration: 6.0
)
#expect(manifest.contains("#EXTINF:2.500,"))
#expect(manifest.contains("segments/0.mp3"))
#expect(!manifest.contains("segments/1.mp3"))
}
// Segment count helper returns the correct number of segments.
@Test func segmentCountCalculation() {
#expect(HLSManifestGenerator.segmentCount(duration: 16.0, segmentDuration: 6.0) == 3)
#expect(HLSManifestGenerator.segmentCount(duration: 12.0, segmentDuration: 6.0) == 2)
#expect(HLSManifestGenerator.segmentCount(duration: 2.5, segmentDuration: 6.0) == 1)
#expect(HLSManifestGenerator.segmentCount(duration: 6.0, segmentDuration: 6.0) == 1)
}
// Time range for a given segment index returns correct start and duration.
@Test func segmentTimeRange() {
// Track: 16s, segment: 6s segments at 0-6, 6-12, 12-16
let range0 = HLSManifestGenerator.segmentTimeRange(
index: 0, trackDuration: 16.0, segmentDuration: 6.0
)
#expect(range0.start == 0.0)
#expect(range0.duration == 6.0)
let range2 = HLSManifestGenerator.segmentTimeRange(
index: 2, trackDuration: 16.0, segmentDuration: 6.0
)
#expect(range2.start == 12.0)
#expect(range2.duration == 4.0)
}
}

@ -1,6 +1,6 @@
import Foundation
import Testing
@testable import Music
@testable import MusicShared
struct RemoteProtocolTests {
private let encoder: JSONEncoder = {

@ -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.

@ -0,0 +1,89 @@
import Testing
import Foundation
@testable import Music
@MainActor
struct HLSSegmenterTests {
// Creates a segmenter for a test MP3 file and verifies it reports the correct duration.
@Test func readsDurationFromFile() async throws {
let url = try TestFixtures.shortMP3URL()
let segmenter = try HLSSegmenter(fileURL: url)
// The test fixture is ~3 seconds long
#expect(segmenter.duration > 2.0)
#expect(segmenter.duration < 5.0)
}
// Extracts the first segment and verifies it returns non-empty data.
@Test func extractsFirstSegment() async throws {
let url = try TestFixtures.shortMP3URL()
let segmenter = try HLSSegmenter(fileURL: url)
let data = try #require(await segmenter.segment(at: 0, segmentDuration: 6.0))
#expect(!data.isEmpty)
}
// Requesting a segment index beyond the track duration returns nil.
@Test func outOfRangeSegmentReturnsNil() async throws {
let url = try TestFixtures.shortMP3URL()
let segmenter = try HLSSegmenter(fileURL: url)
let data = try await segmenter.segment(at: 999, segmentDuration: 6.0)
#expect(data == nil)
}
}
enum TestFixtures {
// Returns the URL of a short test audio file (M4A/AAC).
// macOS cannot encode MP3, so we use AAC which AVAssetReader handles identically.
static func shortMP3URL() throws -> URL {
let tempDir = FileManager.default.temporaryDirectory
let url = tempDir.appendingPathComponent("test_fixture.m4a")
if !FileManager.default.fileExists(atPath: url.path) {
let wavURL = tempDir.appendingPathComponent("test_fixture.wav")
let sampleRate = 44100
let channels = 1
let durationSamples = sampleRate * 3
let bytesPerSample = 2
let dataSize = durationSamples * channels * bytesPerSample
var wavData = Data()
func appendString(_ s: String) { wavData.append(contentsOf: s.utf8) }
func appendUInt32(_ v: UInt32) { withUnsafeBytes(of: v.littleEndian) { wavData.append(contentsOf: $0) } }
func appendUInt16(_ v: UInt16) { withUnsafeBytes(of: v.littleEndian) { wavData.append(contentsOf: $0) } }
appendString("RIFF")
appendUInt32(UInt32(36 + dataSize))
appendString("WAVE")
appendString("fmt ")
appendUInt32(16)
appendUInt16(1)
appendUInt16(UInt16(channels))
appendUInt32(UInt32(sampleRate))
appendUInt32(UInt32(sampleRate * channels * bytesPerSample))
appendUInt16(UInt16(channels * bytesPerSample))
appendUInt16(UInt16(bytesPerSample * 8))
appendString("data")
appendUInt32(UInt32(dataSize))
wavData.append(Data(count: dataSize))
try wavData.write(to: wavURL)
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert")
process.arguments = [wavURL.path, url.path, "-f", "m4af", "-d", "aac"]
try process.run()
process.waitUntilExit()
try? FileManager.default.removeItem(at: wavURL)
guard FileManager.default.fileExists(atPath: url.path) else {
throw NSError(domain: "TestFixtures", code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to create test audio fixture"])
}
}
return url
}
}

@ -1,5 +1,6 @@
import Testing
import Foundation
import MusicShared
import Network
@testable import Music
@ -54,7 +55,7 @@ struct HostServerIntegrationTests {
// 2. Set up the player and server with command dispatch configured
let audio = AudioService()
let player = PlayerViewModel(audio: audio, db: nil)
let player = PlayerViewModel(provider: audio, db: nil)
let server = HostServer(dbPath: dbPath)
server.configure(player: player, db: nil)
try server.start()

@ -0,0 +1,71 @@
import Testing
import Foundation
import AVFoundation
@testable import Music
// Reproduces the "CoreMediaErrorDomain error -16044 after streaming a few tracks"
// bug (plus the accompanying HALC_ProxyIOContext overload / out-of-order log spew).
//
// Root cause: setting `player = nil` does NOT release an AVPlayer's decode/render
// pipeline it is the *association* of an AVPlayerItem with an AVPlayer that owns
// the pipeline, and ARC tears it down asynchronously. The teardown path paused and
// nilled the player but never called `replaceCurrentItem(with: nil)`, so each track
// switch leaked a still-associated pipeline. After a handful of tracks they exceed
// CoreMedia's small concurrent-pipeline limit and a new player can't acquire a
// decode session, failing with -16044.
//
// The invariant proven here: tearing down the player must dissociate its item so
// the pipeline is released immediately.
@MainActor
struct PlaybackPipelineTeardownTests {
// Verifies the streaming provider releases the previous player's pipeline on teardown.
// Steps:
// 1. Create a StreamingPlaybackProvider (host/key unused we drive AVPlayer
// directly with a local file URL to bypass the network pre-flight).
// 2. Start an AVPlayer on a real local audio fixture and capture a strong
// reference to it, then confirm the pipeline is established (currentItem set).
// 3. Tear down via stop() the same cleanup path a queue advance runs.
// 4. The captured player must have been dissociated from its item (currentItem
// == nil). Before the fix it is still set, so pipelines accumulate.
@Test func streamingProviderReleasesPipelineOnTeardown() throws {
// 1. Provider with throwaway connection details.
let provider = StreamingPlaybackProvider(hostURL: "http://unused.invalid", apiKey: "unused")
// 2. Start playback on a real local file and grab the AVPlayer it created.
let fixture = try TestFixtures.shortMP3URL()
provider.startAVPlayer(url: fixture)
let firstPlayer = try #require(provider.player)
#expect(firstPlayer.currentItem != nil)
// 3. Tear down (queue advance / stop runs this path).
provider.stop()
// 4. The pipeline must be released: the item is dissociated from the player.
#expect(firstPlayer.currentItem == nil)
}
// Verifies the local-playback provider (AudioService) has the same fix.
// Steps:
// 1. Create an AudioService.
// 2. Play a real local audio fixture and capture the AVPlayer; confirm the
// pipeline is established (currentItem set).
// 3. Tear down via stop().
// 4. The captured player must have been dissociated from its item.
@Test func audioServiceReleasesPipelineOnTeardown() throws {
// 1. Local playback provider.
let provider = AudioService()
// 2. Play a real local file and grab the AVPlayer it created.
let fixture = try TestFixtures.shortMP3URL()
provider.play(url: fixture)
let firstPlayer = try #require(provider.player)
#expect(firstPlayer.currentItem != nil)
// 3. Tear down.
provider.stop()
// 4. The pipeline must be released: the item is dissociated from the player.
#expect(firstPlayer.currentItem == nil)
}
}

@ -16,7 +16,7 @@ struct PlayerViewModelTests {
// Sets the queue and plays a track, verifies current track and index are set.
@Test func playTrackSetsCurrentTrackAndIndex() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(tracks)
vm.play(tracks[2])
@ -27,7 +27,7 @@ struct PlayerViewModelTests {
// Calls next() and verifies it advances to the next track.
@Test func nextAdvancesToNextTrack() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(tracks)
vm.play(tracks[0])
@ -39,7 +39,7 @@ struct PlayerViewModelTests {
// Calls next() on the last track and verifies it stops (no wrap for v1).
@Test func nextAtEndStops() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(3)
vm.setQueue(tracks)
vm.play(tracks[2])
@ -50,7 +50,7 @@ struct PlayerViewModelTests {
// Calls previous() and verifies it goes to the previous track.
@Test func previousGoesToPreviousTrack() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(tracks)
vm.play(tracks[3])
@ -61,7 +61,7 @@ struct PlayerViewModelTests {
// Calls previous() on the first track and verifies it stays at the first track.
@Test func previousAtStartStaysAtFirst() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(3)
vm.setQueue(tracks)
vm.play(tracks[0])
@ -74,7 +74,7 @@ struct PlayerViewModelTests {
// Enables shuffle and verifies the shuffled queue contains all tracks
// and starts with the current track.
@Test func shuffleContainsAllTracksStartingWithCurrent() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(20)
vm.setQueue(tracks)
vm.play(tracks[5])
@ -91,7 +91,7 @@ struct PlayerViewModelTests {
// Disables shuffle and verifies the queue returns to original order
// and current track is preserved.
@Test func unshuffleRestoresOriginalOrder() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let vm = PlayerViewModel(provider: AudioService(), db: nil)
let tracks = makeTracks(10)
vm.setQueue(tracks)
vm.play(tracks[3])
@ -102,4 +102,169 @@ struct PlayerViewModelTests {
#expect(vm.currentTrack?.id == 4)
#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.
@Test func streamingTrackUsesDatabaseDurationWhenPlayerCannotDetermineIt() {
// Step 1: Back the view model with a provider that mimics streaming
// it reports playback state but never a valid duration (stays 0).
let provider = FakeStreamingProvider()
let vm = PlayerViewModel(provider: provider, db: nil)
// Step 2: Queue a track whose duration is known from the database (215s).
let track = Track.fixture(id: 1, title: "Streamed", duration: 215)
vm.setQueue([track])
// Step 3: Play it. The provider fires a playback-state change with
// duration 0, exactly as AVPlayer does for an unmeasurable
// progressive stream.
vm.play(track)
// Step 4: The displayed total duration must be the database value (215s),
// not 0 otherwise the UI shows "0:00".
#expect(vm.duration == 215)
}
}
// A PlaybackProvider that mimics the streaming case: it "plays" from an HTTP URL
// but like AVPlayer with a progressive MP3 stream can never determine the
// total duration, so `duration` stays 0. Used to reproduce the "0:00" bug.
@MainActor
private final class FakeStreamingProvider: PlaybackProvider {
var isPlaying = false
var currentTime: Double = 0
var duration: Double = 0 // never resolves the crux of the bug
var volume: Float = 0.65
private(set) var isScrubbing = false
var onTrackFinished: (() -> Void)?
var onPlaybackStateChanged: (() -> Void)?
func urlForTrack(_ track: Track) -> URL? {
URL(string: "http://host/file?id=\(track.id ?? 0)")
}
func play(url: URL) {
isPlaying = true
// Simulate the periodic time observer firing with no determinable duration.
onPlaybackStateChanged?()
}
func pause() { isPlaying = false; onPlaybackStateChanged?() }
func resume() { isPlaying = true; onPlaybackStateChanged?() }
func togglePlayPause() { if isPlaying { pause() } else { resume() } }
func seek(to position: Double) { currentTime = position }
func setVolume(_ level: Float) { volume = level }
func stop() { isPlaying = false; currentTime = 0; duration = 0 }
func beginScrubbing() { isScrubbing = true }
func scrub(to position: Double) { currentTime = position }
func endScrubbing(at position: Double) { currentTime = position; isScrubbing = false }
}

@ -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)
}
}

@ -0,0 +1,343 @@
import Testing
import Foundation
import MusicShared
@testable import Music
@MainActor
struct StreamingIntegrationTests {
static let testAPIKey = "integration-test-key"
// Full flow: start server, authenticate, download DB, verify track is present.
// Steps:
// 1. Create an in-memory DB and insert a test track
// 2. Start StreamingServer on a random port
// 3. Authenticate via GET /auth
// 4. Download DB via GET /db
// 5. Save downloaded DB to disk and verify the track is present
@Test(.timeLimit(.minutes(1)))
func fullConnectionFlow() async throws {
let db = try DatabaseService(inMemory: true)
var track = Track.fixture(id: nil, fileURL: "/tmp/test.mp3", title: "Test Song")
try db.insert(&track)
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)"
// 3. Authenticate
var authReq = URLRequest(url: URL(string: "\(baseURL)/auth")!)
authReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization")
let (authData, authResp) = try await URLSession.shared.data(for: authReq)
let authHTTP = try #require(authResp as? HTTPURLResponse)
#expect(authHTTP.statusCode == 200)
let authResponse = try JSONDecoder().decode(AuthResponse.self, from: authData)
#expect(authResponse.protocolVersion == StreamingConstants.protocolVersion)
// 4. Download DB
var dbReq = URLRequest(url: URL(string: "\(baseURL)/db")!)
dbReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization")
let (dbData, dbResp) = try await URLSession.shared.data(for: dbReq)
let dbHTTP = try #require(dbResp as? HTTPURLResponse)
#expect(dbHTTP.statusCode == 200)
#expect(dbData.count > 0)
// 5. Verify downloaded DB contains the track
let tempPath = FileManager.default.temporaryDirectory
.appendingPathComponent("integration_test_\(UUID().uuidString).sqlite").path
defer { try? FileManager.default.removeItem(atPath: tempPath) }
try dbData.write(to: URL(fileURLWithPath: tempPath))
let downloadedDb = try DatabaseService(path: tempPath)
let tracks = try downloadedDb.fetchTracks(search: "", sortColumn: "title", ascending: true)
#expect(tracks.count == 1)
#expect(tracks[0].title == "Test Song")
}
// Verifies that requests without auth get 401.
@Test(.timeLimit(.minutes(1)))
func unauthenticatedRequestsRejected() async throws {
let db = try DatabaseService(inMemory: true)
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
try await server.start()
defer { server.stop() }
let port = try #require(server.actualPort)
let request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/auth")!)
let (_, response) = try await URLSession.shared.data(for: request)
let httpResponse = try #require(response as? HTTPURLResponse)
#expect(httpResponse.statusCode == 401)
}
// Verifies the /tracks/:trackId/file endpoint serves audio data.
@Test(.timeLimit(.minutes(1)))
func fileEndpointServesTrack() async throws {
// 1. Create DB with a test track pointing to a real audio file
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.path, title: "Stream Test")
try db.insert(&track)
let trackId = try #require(track.id)
// 2. Start server
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)"
// 3. Request via Bearer auth
var bearerReq = URLRequest(url: URL(string: "\(baseURL)/file?id=\(trackId)")!)
bearerReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization")
let (bearerData, bearerResp) = try await URLSession.shared.data(for: bearerReq)
let bearerHTTP = try #require(bearerResp as? HTTPURLResponse)
#expect(bearerHTTP.statusCode == 200)
#expect(bearerData.count > 0)
// 4. Request via token query param
let tokenURL = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
let (tokenData, tokenResp) = try await URLSession.shared.data(for: URLRequest(url: tokenURL))
let tokenHTTP = try #require(tokenResp as? HTTPURLResponse)
#expect(tokenHTTP.statusCode == 200)
#expect(tokenData.count == bearerData.count)
// 5. Unauthenticated request should be 401
let noAuthURL = URL(string: "\(baseURL)/file?id=\(trackId)")!
let (_, noAuthResp) = try await URLSession.shared.data(for: URLRequest(url: noAuthURL))
let noAuthHTTP = try #require(noAuthResp as? HTTPURLResponse)
#expect(noAuthHTTP.statusCode == 401)
}
// Verifies that wrong API key gets 401.
@Test(.timeLimit(.minutes(1)))
func wrongApiKeyRejected() async throws {
let db = try DatabaseService(inMemory: true)
let server = StreamingServer(db: db, apiKey: Self.testAPIKey, port: 0)
try await server.start()
defer { server.stop() }
let port = try #require(server.actualPort)
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/auth")!)
request.setValue("Bearer wrong-key", forHTTPHeaderField: "Authorization")
let (_, response) = try await URLSession.shared.data(for: request)
let httpResponse = try #require(response as? HTTPURLResponse)
#expect(httpResponse.statusCode == 401)
}
// Reproduces the real-world "File not found on disk" (HTTP 404) bug.
//
// The production scanner (ScannerService) stores `fileURL` as
// `url.absoluteString` e.g. "file:///Users/.../song.m4a" WITH the
// "file://" scheme and percent-encoding. StreamingServer reconstructed it
// with `URL(fileURLWithPath:)`, which treats that whole string as a raw
// (relative) path, prepends the CWD, and skips percent-decoding, so the file
// is never found on disk. The earlier `fileEndpointServesTrack` test masked
// this because it stored `fixtureURL.path` (a bare path) instead.
//
// Steps:
// 1. Create a DB with a track whose fileURL is stored EXACTLY as the scanner
// stores it: `fixtureURL.absoluteString` (not `.path`).
// 2. Start the streaming server.
// 3. Request GET /file?id=<trackId> with a valid token.
// 4. Expect HTTP 200 with the file's bytes. Before the fix this returns 404
// with body {"error":{"message":"File not found on disk"}}.
@Test(.timeLimit(.minutes(1)))
func fileEndpointServesTrackStoredAsAbsoluteString() async throws {
// 1. Insert a track using the production storage format (absoluteString).
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Abs Stream Test")
try db.insert(&track)
let trackId = try #require(track.id)
// 2. Start the server.
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)"
// 3. Request the file via the token query parameter.
let url = URL(string: "\(baseURL)/file?id=\(trackId)&token=\(Self.testAPIKey)")!
let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: url))
let http = try #require(resp as? HTTPURLResponse)
// 4. Must serve the bytes, not 404.
#expect(http.statusCode == 200)
#expect(data.count > 0)
}
// Same root cause as above, exercised through the HLS path (SegmenterCache
// also used URL(fileURLWithPath:) on the stored fileURL).
//
// Steps:
// 1. Insert a track whose fileURL is stored as `absoluteString`.
// 2. Start the server.
// 3. Request GET /tracks/<id>/segments/0.mp3 with a valid token.
// 4. Expect HTTP 200 with segment bytes (before the fix the segmenter cannot
// open the file, so this returns a 404/error).
@Test(.timeLimit(.minutes(1)))
func segmentEndpointServesTrackStoredAsAbsoluteString() async throws {
// 1. Insert a track using the production storage format (absoluteString).
let db = try DatabaseService(inMemory: true)
let fixtureURL = try TestFixtures.shortMP3URL()
var track = Track.fixture(id: nil, fileURL: fixtureURL.absoluteString, title: "Abs HLS Test")
try db.insert(&track)
let trackId = try #require(track.id)
// 2. Start the server.
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)"
// 3. Request the first HLS segment via the token query parameter.
let url = URL(string: "\(baseURL)/tracks/\(trackId)/segments/0.mp3?token=\(Self.testAPIKey)")!
let (data, resp) = try await URLSession.shared.data(for: URLRequest(url: url))
let http = try #require(resp as? HTTPURLResponse)
// 4. Must serve segment bytes, not 404.
#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,147 @@
import Foundation
import MusicShared
import Network
import Testing
@testable import Music
@MainActor
struct StreamingServerTests {
// MARK: - Helpers
/// Creates a StreamingServer backed by an in-memory database on port 0
/// (OS-assigned) with a known API key for testing.
private func makeServer() throws -> StreamingServer {
let db = try DatabaseService(inMemory: true)
return StreamingServer(db: db, apiKey: "test-key-12345", port: 0)
}
/// Performs a simple HTTP GET using NWConnection and returns the full
/// response (headers + body) as raw Data, then splits out just the body.
private func httpGet(
host: String,
port: Int,
path: String,
headers: [String: String] = [:]
) async throws -> (statusCode: Int, body: Data) {
try await withCheckedThrowingContinuation { continuation in
let connection = NWConnection(
host: NWEndpoint.Host(host),
port: NWEndpoint.Port(rawValue: UInt16(port))!,
using: .tcp
)
connection.stateUpdateHandler = { state in
if case .ready = state {
// Build the HTTP request
var request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n"
for (key, value) in headers {
request += "\(key): \(value)\r\n"
}
request += "\r\n"
connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in })
// Receive the full response (Connection: close ensures everything arrives)
connection.receiveMessage { data, _, _, error in
defer { connection.cancel() }
if let error {
continuation.resume(throwing: error)
return
}
guard let data else {
continuation.resume(returning: (statusCode: 0, body: Data()))
return
}
// Parse the status code from the first line
let responseString = String(data: data, encoding: .utf8) ?? ""
let firstLine = responseString.split(separator: "\r\n").first ?? ""
let parts = firstLine.split(separator: " ")
let statusCode = parts.count >= 2 ? Int(parts[1]) ?? 0 : 0
// Extract body after the header/body separator
if let range = data.range(of: Data("\r\n\r\n".utf8)) {
continuation.resume(returning: (statusCode: statusCode, body: Data(data[range.upperBound...])))
} else {
continuation.resume(returning: (statusCode: statusCode, body: Data()))
}
}
} else if case .failed(let error) = state {
continuation.resume(throwing: error)
}
}
connection.start(queue: .main)
}
}
// MARK: - Tests
// 1. Sends GET /auth with a valid Bearer key.
// 2. Expects a 200 status code.
// 3. Decodes the body as AuthResponse and verifies protocolVersion matches.
@Test(.timeLimit(.minutes(1)))
func authEndpointAcceptsValidKey() async throws {
let server = try makeServer()
try await server.start()
defer { server.stop() }
let port = server.actualPort!
let (statusCode, body) = try await httpGet(
host: "127.0.0.1",
port: port,
path: "/auth",
headers: ["Authorization": "Bearer test-key-12345"]
)
#expect(statusCode == 200)
let authResponse = try JSONDecoder().decode(AuthResponse.self, from: body)
#expect(authResponse.protocolVersion == StreamingConstants.protocolVersion)
#expect(!authResponse.hostName.isEmpty)
}
// 1. Sends GET /auth WITHOUT an Authorization header.
// 2. Expects a 401 Unauthorized status code.
@Test(.timeLimit(.minutes(1)))
func authEndpointRejectsNoKey() async throws {
let server = try makeServer()
try await server.start()
defer { server.stop() }
let port = server.actualPort!
let (statusCode, _) = try await httpGet(
host: "127.0.0.1",
port: port,
path: "/auth"
// No Authorization header
)
#expect(statusCode == 401)
}
// 1. Sends GET /db with a valid key using URLSession (binary response
// requires proper HTTP framing that NWConnection.receiveMessage lacks).
// 2. Expects a 200 status code.
// 3. Verifies the response body starts with the SQLite magic header "SQLite format 3".
@Test(.timeLimit(.minutes(1)))
func dbEndpointReturnsDatabaseFile() async throws {
let server = try makeServer()
try await server.start()
defer { server.stop() }
let port = server.actualPort!
var request = URLRequest(url: URL(string: "http://127.0.0.1:\(port)/db")!)
request.setValue("Bearer test-key-12345", forHTTPHeaderField: "Authorization")
let (data, response) = try await URLSession.shared.data(for: request)
let httpResponse = try #require(response as? HTTPURLResponse)
#expect(httpResponse.statusCode == 200)
// SQLite files start with "SQLite format 3\0" (16 bytes)
let header = String(data: data.prefix(16), encoding: .utf8) ?? ""
#expect(header.hasPrefix("SQLite format 3"))
}
}

@ -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))
}
}

@ -27,7 +27,6 @@ struct TrackTests {
t.column("bitrate", .integer)
t.column("sampleRate", .integer)
t.column("fileSize", .integer).notNull()
t.column("artworkData", .blob)
t.column("playCount", .integer).notNull().defaults(to: 0)
t.column("lastPlayedAt", .datetime)
t.column("rating", .integer).notNull().defaults(to: 0)

@ -0,0 +1,343 @@
# Track Drag-to-Playlist 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:** Drag a single track from the track table onto a regular playlist chip to add it.
**Architecture:** The NSTableView drag source writes the track's `Int64` ID to the pasteboard under a custom type. Each regular playlist chip in `PlaylistBarView` becomes a SwiftUI drop target that reads the ID and calls the existing `PlaylistViewModel.addTrack` method. A highlight shows when a valid drag hovers over a chip.
**Tech Stack:** SwiftUI, AppKit (NSTableView/NSPasteboard), GRDB
---
### Task 1: Add custom pasteboard type and update drag source
**Files:**
- Modify: `Music/Views/TrackTableView.swift:4` (add constant)
- Modify: `Music/Views/TrackTableView.swift:133-140` (register drag types)
- Modify: `Music/Views/TrackTableView.swift:372-375` (pasteboardWriterForRow)
- [ ] **Step 1: Add the custom pasteboard type constant**
At the top of `TrackTableView.swift`, after line 6 (`private let defaultVisibleColumnIds`), add:
```swift
private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID")
```
- [ ] **Step 2: Update `pasteboardWriterForRow` to always write track ID**
Replace the current implementation (lines 372-375):
```swift
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
guard parent.onReorder != nil else { return nil }
return "\(row)" as NSString
}
```
With:
```swift
func tableView(_ tableView: NSTableView, pasteboardWriterForRow row: Int) -> (any NSPasteboardWriting)? {
let item = NSPasteboardItem()
if let trackId = parent.tracks[row].id {
item.setString(String(trackId), forType: trackIdPasteboardType)
}
if parent.onReorder != nil {
item.setString(String(row), forType: .string)
}
return item
}
```
- [ ] **Step 3: Update `validateDrop` and `acceptDrop` to read `.string` type explicitly**
The current `acceptDrop` reads from the first pasteboard item's generic string. Now that we write two types, it must read `.string` specifically.
Replace `acceptDrop` (lines 382-393):
```swift
func tableView(_ tableView: NSTableView, acceptDrop info: any NSDraggingInfo, row: Int, dropOperation: NSTableView.DropOperation) -> Bool {
guard let onReorder = parent.onReorder else { return false }
guard let item = info.draggingPasteboard.pasteboardItems?.first,
let rowString = item.string(forType: .string),
let sourceRow = Int(rowString) else { return false }
let destination = sourceRow < row ? row - 1 : row
guard sourceRow != destination else { return false }
onReorder(sourceRow, destination)
return true
}
```
- [ ] **Step 4: Update drag type registration in `updateNSView`**
Replace the drag registration block (lines 133-140):
```swift
if context.coordinator.parent.onReorder != nil {
if tableView.registeredDraggedTypes.isEmpty || !tableView.registeredDraggedTypes.contains(.string) {
tableView.registerForDraggedTypes([.string])
tableView.draggingDestinationFeedbackStyle = .gap
}
} else {
tableView.unregisterDraggedTypes()
}
```
With:
```swift
let needsReorder = context.coordinator.parent.onReorder != nil
let wantedTypes: [NSPasteboard.PasteboardType] = needsReorder
? [trackIdPasteboardType, .string]
: [trackIdPasteboardType]
if Set(tableView.registeredDraggedTypes) != Set(wantedTypes) {
tableView.registerForDraggedTypes(wantedTypes)
tableView.draggingDestinationFeedbackStyle = needsReorder ? .gap : .none
}
```
- [ ] **Step 5: Build and verify**
Run: `cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5`
Expected: BUILD SUCCEEDED
- [ ] **Step 6: Commit**
```bash
git add Music/Views/TrackTableView.swift
git commit -m "feat: write track ID to pasteboard on drag start"
```
---
### Task 2: Add drop target to PlaylistBarView
**Files:**
- Modify: `Music/Views/PlaylistBarView.swift:3-14` (add callback prop)
- Modify: `Music/Views/PlaylistBarView.swift:26-51` (add `.onDrop` to playlist chips)
- Modify: `Music/Views/PlaylistBarView.swift:59-96` (add `isDropTarget` param to `PlaylistButton`)
- [ ] **Step 1: Add `onDropTrack` callback and `UniformTypeIdentifiers` import**
Add at the top of the file:
```swift
import UniformTypeIdentifiers
```
Add a new property to `PlaylistBarView` after the existing callbacks (after line 14):
```swift
var onDropTrack: ((Int64, Playlist) -> Void)?
```
Also add a private constant for the UTType (inside the file, outside the struct — near the import):
```swift
private let trackIdUTType = UTType("com.music.trackID")!
```
- [ ] **Step 2: Add `.onDrop` modifier to regular playlist chips**
Replace the `ForEach` block (lines 26-52) with:
```swift
ForEach(playlists, id: \.listIdentity) { item in
let isRegular = item is Playlist
PlaylistChip(
item: item,
isSelected: selectedItem?.listIdentity == item.listIdentity,
isRemoteMode: isRemoteMode,
acceptsDrop: isRegular,
trackIdUTType: trackIdUTType,
onTap: {
if selectedItem?.listIdentity == item.listIdentity {
onDeselect()
} else {
onSelect(item)
}
},
onDropTrack: isRegular ? { trackId in
onDropTrack?(trackId, item as! Playlist)
} : nil,
onRename: { onRename(item) },
onDelete: { onDelete(item) },
onEditQuery: (item as? SmartPlaylist).map { smart in { onEditQuery(smart) } },
onEditConditions: (item as? SmartPlaylist).map { smart in { onEditConditions(smart) } }
)
}
```
- [ ] **Step 3: Create a `PlaylistChip` wrapper view to manage drop state**
Add this view between `PlaylistBarView` and `PlaylistButton` (it owns the `@State` for `isTargeted`):
```swift
private struct PlaylistChip: View {
let item: any PlaylistRepresentable
let isSelected: Bool
let isRemoteMode: Bool
let acceptsDrop: Bool
let trackIdUTType: UTType
let onTap: () -> Void
var onDropTrack: ((Int64) -> Void)?
let onRename: () -> Void
let onDelete: () -> Void
var onEditQuery: (() -> Void)?
var onEditConditions: (() -> Void)?
@State private var isDropTargeted = false
var body: some View {
PlaylistButton(
name: item.name,
isSelected: isSelected,
isSmart: item.isSmartPlaylist,
isDropTarget: isDropTargeted,
action: onTap
)
.if(acceptsDrop) { view in
view.onDrop(of: [trackIdUTType], isTargeted: $isDropTargeted) { providers in
guard let provider = providers.first else { return false }
provider.loadItem(forTypeIdentifier: trackIdUTType.identifier) { data, _ in
guard let data = data as? Data,
let str = String(data: data, encoding: .utf8),
let trackId = Int64(str) else { return }
DispatchQueue.main.async {
onDropTrack?(trackId)
}
}
return true
}
}
.contextMenu {
if !isRemoteMode {
Button("Rename...") { onRename() }
if let onEditConditions {
Button("Edit...") { onEditConditions() }
} else if let onEditQuery {
Button("Edit Search Query...") { onEditQuery() }
}
Button("Delete") { onDelete() }
}
}
}
}
```
- [ ] **Step 4: Add the `.if` view extension if it doesn't already exist**
Check if `View+if` already exists in the project. If not, add it at the bottom of `PlaylistBarView.swift`:
```swift
private extension View {
@ViewBuilder
func `if`<Content: View>(_ condition: Bool, transform: (Self) -> Content) -> some View {
if condition { transform(self) } else { self }
}
}
```
- [ ] **Step 5: Add `isDropTarget` parameter to PlaylistButton**
Update `PlaylistButton` to accept and use the highlight state. Add the parameter:
```swift
var isDropTarget: Bool = false
```
Update the `.background` and `.overlay` in the button body to respond to `isDropTarget`:
```swift
.background(
isDropTarget ? tintColor.opacity(0.3) :
isSelected ? tintColor.opacity(0.2) :
Color.secondary.opacity(0.1)
)
```
```swift
.overlay(
RoundedRectangle(cornerRadius: 4)
.stroke(
isDropTarget ? tintColor :
isSelected ? tintColor :
Color.secondary.opacity(0.3),
lineWidth: isDropTarget ? 2 : 1
)
)
```
- [ ] **Step 6: Build and verify**
Run: `cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5`
Expected: BUILD SUCCEEDED
- [ ] **Step 7: Commit**
```bash
git add Music/Views/PlaylistBarView.swift
git commit -m "feat: add drop target on playlist chips for track drag"
```
---
### Task 3: Wire up in ContentView
**Files:**
- Modify: `Music/ContentView.swift:211-261` (add `onDropTrack` to PlaylistBarView call)
- [ ] **Step 1: Add `onDropTrack` closure to PlaylistBarView instantiation**
In `ContentView.swift`, add the `onDropTrack` parameter to the `PlaylistBarView(...)` call, after the `onEditConditions` closure (after line 259):
```swift
onDropTrack: { trackId, targetPlaylist in
guard let track = library.tracks.first(where: { $0.id == trackId }) else { return }
try? playlist.addTrack(track, to: targetPlaylist)
}
```
- [ ] **Step 2: Build and verify**
Run: `cd /Users/laurentmorvillier/code/Music && xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -5`
Expected: BUILD SUCCEEDED
- [ ] **Step 3: Commit**
```bash
git add Music/ContentView.swift
git commit -m "feat: wire track drag-to-playlist in ContentView"
```
---
### Task 4: Manual test and fix any issues
- [ ] **Step 1: Launch the app**
Run: `cd /Users/laurentmorvillier/code/Music && open -a Xcode Music.xcodeproj` and run from Xcode (Cmd+R), or build and run via command line.
- [ ] **Step 2: Test the happy path**
1. Ensure at least one regular playlist exists
2. Drag a track from the track table
3. Hover over a regular playlist chip — verify it highlights
4. Drop on the chip — verify the track appears in the playlist (click the playlist to check)
- [ ] **Step 3: Test edge cases**
1. Drag a track over a **smart** playlist chip — verify no highlight, drop rejected
2. Drag a track that's **already** in a playlist onto that playlist — verify silent no-op
3. Drag a track over the **Home** chip — verify no highlight
4. After a drag-to-playlist, try **reordering** tracks within a playlist — verify still works
5. **Drop a file from Finder** onto the app — verify library scan still works
- [ ] **Step 4: Fix any issues found, commit**

@ -0,0 +1,85 @@
# Track Drag-to-Playlist Design
## Goal
Drag a single track from the track table onto a playlist chip in the bottom bar to add that track to the playlist.
## Scope
- Single track only (no multi-select drag)
- Regular playlists only (smart playlists are read-only)
- Duplicate adds silently ignored (DB `UNIQUE(playlistId, trackId)` constraint)
## Implementation
### 1. Custom Pasteboard Type
Define a custom `NSPasteboard.PasteboardType` for track IDs to avoid collisions with the existing `.string` type used for row-reorder and the `.fileURL` type used for Finder file drops.
Location: top of `TrackTableView.swift` (alongside existing private constants).
```swift
private let trackIdPasteboardType = NSPasteboard.PasteboardType("com.music.trackID")
```
### 2. Drag Source — TrackTableView Coordinator
**File:** `TrackTableView.swift`
**Change `pasteboardWriterForRow`:** Currently only writes when `onReorder != nil`. Change to always write the track ID under the custom type. When `onReorder` is also set, additionally write the row index under `.string` (preserving existing reorder behavior).
```
func pasteboardWriterForRow(row) -> NSPasteboardWriting?
let item = NSPasteboardItem()
// Always write track ID for cross-view drag
if let trackId = parent.tracks[row].id {
item.setString(String(trackId), forType: trackIdPasteboardType)
}
// Also write row index if reorder is enabled
if parent.onReorder != nil {
item.setString(String(row), forType: .string)
}
return item
```
**Register for drag types:** Add `trackIdPasteboardType` to `registerForDraggedTypes` alongside `.string`.
**`validateDrop` / `acceptDrop`:** Update to read from `.string` type specifically (not just first pasteboard item string), so they continue to work for reorder and ignore the track-ID type.
### 3. Drop Target — PlaylistBarView
**File:** `PlaylistBarView.swift`
**New callback prop:**
```swift
var onDropTrack: ((Int64, Playlist) -> Void)?
```
**Drop modifier on each regular playlist chip:** Add `.onDrop(of:isTargeted:perform:)` to each `PlaylistButton` for regular playlists only. The `isTargeted` binding drives a visual highlight (accent-colored border/background).
Since SwiftUI's `.onDrop` works with UTTypes, we register the custom type as a UTType and read the track ID from the `NSItemProvider`.
**Visual feedback:** When `isTargeted` is true, show a highlighted border/background on the chip (e.g. `tintColor.opacity(0.3)` background + `tintColor` border at 2pt).
**PlaylistButton changes:** Add an `isDropTarget: Bool` parameter to `PlaylistButton` that controls the highlight styling. Default `false`.
### 4. Wiring — ContentView
**File:** `ContentView.swift`
Pass the `onDropTrack` closure to `PlaylistBarView`. The closure calls `PlaylistViewModel.addTrack(_:to:)`, wrapping in try/catch to silently handle duplicates.
## Files Changed
| File | Change |
|------|--------|
| `TrackTableView.swift` | Custom pasteboard type constant; `pasteboardWriterForRow` writes track ID always + row index when reordering; register custom drag type; update `validateDrop`/`acceptDrop` to read `.string` explicitly |
| `PlaylistBarView.swift` | `onDropTrack` callback; `.onDrop` modifier on regular playlist chips; `isDropTarget` highlight state on `PlaylistButton` |
| `ContentView.swift` | Wire `onDropTrack` closure through to `PlaylistBarView` |
## Behaviors Preserved
- Right-click "Add to Playlist" context menu unchanged
- Drag reorder within a playlist unchanged
- File drops from Finder unchanged
- Smart playlists don't accept drops

@ -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