first working instance

feat/music-streaming
Laurent 1 month ago
parent 0829dba09a
commit 98f11658ad
  1. 44
      Music.xcodeproj/project.pbxproj
  2. 218
      Music.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  3. 7
      Music.xcodeproj/xcshareddata/xcschemes/Music.xcscheme
  4. 18
      Music/ContentView.swift
  5. 12
      Music/Music.entitlements
  6. 146
      Music/MusicApp.swift
  7. 25
      Music/Protocols/PlaybackProvider.swift
  8. 100
      Music/Providers/RemotePlaybackProvider.swift
  9. 303
      Music/Providers/StreamingPlaybackProvider.swift
  10. 1
      Music/Remote/HostServer.swift
  11. 30
      Music/Remote/NetworkStatus.swift
  12. 1
      Music/Remote/RemoteClient.swift
  13. 172
      Music/Remote/RemoteProtocol.swift
  14. 17
      Music/Services/AudioService.swift
  15. 80
      Music/Streaming/HLSSegmenter.swift
  16. 219
      Music/Streaming/StreamingClient.swift
  17. 83
      Music/Streaming/StreamingConnectionSheet.swift
  18. 308
      Music/Streaming/StreamingServer.swift
  19. 125
      Music/Streaming/TunnelManager.swift
  20. 134
      Music/ViewModels/PlayerViewModel.swift
  21. 29
      Music/Views/PlayerControlsView.swift
  22. 221
      MusicShared/Package.resolved
  23. 17
      MusicShared/Package.swift
  24. 8
      MusicShared/Sources/MusicShared/APIModels.swift
  25. 3
      MusicShared/Sources/MusicShared/Exports.swift
  26. 5
      MusicShared/Sources/MusicShared/HLSManifestGenerator.swift
  27. 2
      MusicShared/Sources/MusicShared/StreamingConstants.swift
  28. 4
      MusicShared/Sources/MusicShared/StreamingRoutes.swift
  29. 89
      MusicTests/HLSSegmenterTests.swift
  30. 3
      MusicTests/HostServerIntegrationTests.swift
  31. 71
      MusicTests/PlaybackPipelineTeardownTests.swift
  32. 71
      MusicTests/PlayerViewModelTests.swift
  33. 132
      MusicTests/RemoteProtocolTests.swift
  34. 204
      MusicTests/StreamingIntegrationTests.swift
  35. 147
      MusicTests/StreamingServerTests.swift
  36. 2263
      docs/superpowers/plans/2026-05-26-remote-mode.md
  37. 3010
      docs/superpowers/plans/2026-05-27-music-streaming.md
  38. 276
      docs/superpowers/specs/2026-05-26-music-streaming-design.md
  39. 270
      docs/superpowers/specs/2026-05-26-remote-mode-design.md

@ -9,6 +9,7 @@
/* 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 */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@ -74,6 +75,7 @@
buildActionMask = 2147483647;
files = (
C46B2CC02FC2449900F95A24 /* GRDB in Frameworks */,
C46CC4692FC6ED47000BD495 /* MusicShared in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@ -101,6 +103,7 @@
C46B2C8F2FC2448700F95A24 /* Music */,
C46B2C9D2FC2448800F95A24 /* MusicTests */,
C46B2CA72FC2448800F95A24 /* MusicUITests */,
C46C8F952FC6D951000BD495 /* Frameworks */,
C46B2C8E2FC2448700F95A24 /* Products */,
);
sourceTree = "<group>";
@ -115,6 +118,13 @@
name = Products;
sourceTree = "<group>";
};
C46C8F952FC6D951000BD495 /* Frameworks */ = {
isa = PBXGroup;
children = (
);
name = Frameworks;
sourceTree = "<group>";
};
/* End PBXGroup section */
/* Begin PBXNativeTarget section */
@ -136,6 +146,7 @@
name = Music;
packageProductDependencies = (
C46B2CBF2FC2449900F95A24 /* GRDB */,
C46CC4682FC6ED47000BD495 /* MusicShared */,
);
productName = Music;
productReference = C46B2C8D2FC2448700F95A24 /* Mumu.app */;
@ -222,6 +233,7 @@
minimizedProjectReferenceProxies = 1;
packageReferences = (
C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */,
C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = C46B2C8E2FC2448700F95A24 /* Products */;
@ -331,6 +343,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 +369,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 +409,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 +428,7 @@
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
SWIFT_COMPILATION_MODE = wholemodule;
};
name = Release;
@ -427,11 +443,15 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 21;
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_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Mumu;
@ -468,11 +488,15 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 12;
CURRENT_PROJECT_VERSION = 21;
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_RESOURCE_ACCESS_AUDIO_INPUT = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = YES;
INFOPLIST_KEY_CFBundleDisplayName = Mumu;
@ -505,6 +529,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 +551,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 +572,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 +592,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 +648,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;
@ -642,6 +677,11 @@
package = C46B2CBE2FC2449900F95A24 /* XCRemoteSwiftPackageReference "GRDB" */;
productName = GRDB;
};
C46CC4682FC6ED47000BD495 /* MusicShared */ = {
isa = XCSwiftPackageProductDependency;
package = C46CC4672FC6ECB9000BD495 /* XCLocalSwiftPackageReference "MusicShared" */;
productName = MusicShared;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = C46B2C852FC2448700F95A24 /* Project object */;

@ -1,6 +1,15 @@
{
"originHash" : "d77223ea3cadaebd2154378ec5005b6ebefcef3b34a4dafa368b0c4f16c0561c",
"originHash" : "b77ffc6518242a68c7f6b38dd3ba60b2719cc56c35b27391784d4b0d94e08b32",
"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,213 @@
"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" : "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"

@ -52,6 +52,22 @@ struct ContentView: View {
}
.padding(.horizontal, 12).padding(.vertical, 4)
.background(Color.green.opacity(0.08))
case .streamHosting, .streamClient:
HStack(spacing: 8) {
Image(systemName: "cloud")
.font(.system(size: 10)).foregroundStyle(.purple)
Text(status.statusMessage)
.font(.system(size: 11, weight: .medium)).foregroundStyle(.purple)
Spacer()
if case .streamClient = status.mode {
Button("Refresh") { status.onRefreshLibrary?() }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary)
}
Button("Disconnect") { status.onDisconnect?() }
.font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red)
}
.padding(.horizontal, 12).padding(.vertical, 4)
.background(Color.purple.opacity(0.08))
}
}
@ -321,6 +337,8 @@ 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

@ -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-only</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
@ -14,6 +15,12 @@ struct MusicApp: App {
@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 {
@ -52,6 +59,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) {
@ -80,6 +96,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 +136,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 +242,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 +258,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 +266,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 +367,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)
}

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

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

@ -1,172 +0,0 @@
import Foundation
// MARK: - Protocol Version
/// Current version of the remote control wire protocol.
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
}
/// Exchanged during connection setup to agree on protocol version.
nonisolated struct HandshakeMessage: Codable, Equatable, Sendable {
var protocolVersion: Int
var appVersion: String
}
// 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 {
case play(trackId: Int64, queueIds: [Int64])
case pause
case resume
case next
case previous
case seek(position: Double)
case setVolume(level: Float)
case toggleShuffle
case refreshDB
}
extension RemoteCommand: Codable {
private enum TypeKey: String, Codable {
case play, pause, resume, next, previous, seek, setVolume, toggleShuffle, refreshDB
}
private enum CodingKeys: String, CodingKey {
case type, payload
}
// Payload structs for cases with associated values
private struct PlayPayload: Codable {
var trackId: Int64
var queueIds: [Int64]
}
private struct SeekPayload: Codable {
var position: Double
}
private struct VolumePayload: Codable {
var level: Float
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .play(let trackId, let queueIds):
try container.encode(TypeKey.play, forKey: .type)
try container.encode(PlayPayload(trackId: trackId, queueIds: queueIds), forKey: .payload)
case .pause:
try container.encode(TypeKey.pause, forKey: .type)
case .resume:
try container.encode(TypeKey.resume, forKey: .type)
case .next:
try container.encode(TypeKey.next, forKey: .type)
case .previous:
try container.encode(TypeKey.previous, forKey: .type)
case .seek(let position):
try container.encode(TypeKey.seek, forKey: .type)
try container.encode(SeekPayload(position: position), forKey: .payload)
case .setVolume(let level):
try container.encode(TypeKey.setVolume, forKey: .type)
try container.encode(VolumePayload(level: level), forKey: .payload)
case .toggleShuffle:
try container.encode(TypeKey.toggleShuffle, forKey: .type)
case .refreshDB:
try container.encode(TypeKey.refreshDB, forKey: .type)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(TypeKey.self, forKey: .type)
switch type {
case .play:
let payload = try container.decode(PlayPayload.self, forKey: .payload)
self = .play(trackId: payload.trackId, queueIds: payload.queueIds)
case .pause:
self = .pause
case .resume:
self = .resume
case .next:
self = .next
case .previous:
self = .previous
case .seek:
let payload = try container.decode(SeekPayload.self, forKey: .payload)
self = .seek(position: payload.position)
case .setVolume:
let payload = try container.decode(VolumePayload.self, forKey: .payload)
self = .setVolume(level: payload.level)
case .toggleShuffle:
self = .toggleShuffle
case .refreshDB:
self = .refreshDB
}
}
}
// MARK: - HostEvent
/// 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 {
case playbackState(PlaybackStatePayload)
case dbReady
case error(message: String)
}
extension HostEvent: Codable {
private enum TypeKey: String, Codable {
case playbackState, dbReady, error
}
private enum CodingKeys: String, CodingKey {
case type, payload
}
private struct ErrorPayload: Codable {
var message: String
}
func encode(to encoder: Encoder) throws {
var container = encoder.container(keyedBy: CodingKeys.self)
switch self {
case .playbackState(let payload):
try container.encode(TypeKey.playbackState, forKey: .type)
try container.encode(payload, forKey: .payload)
case .dbReady:
try container.encode(TypeKey.dbReady, forKey: .type)
case .error(let message):
try container.encode(TypeKey.error, forKey: .type)
try container.encode(ErrorPayload(message: message), forKey: .payload)
}
}
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
let type = try container.decode(TypeKey.self, forKey: .type)
switch type {
case .playbackState:
let payload = try container.decode(PlaybackStatePayload.self, forKey: .payload)
self = .playbackState(payload)
case .dbReady:
self = .dbReady
case .error:
let payload = try container.decode(ErrorPayload.self, forKey: .payload)
self = .error(message: payload.message)
}
}
}

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

@ -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,308 @@
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)
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 data = try Data(contentsOf: fileURL)
let contentType = Self.audioContentType(for: fileURL.pathExtension)
return Response(
status: .ok,
headers: [
.contentType: contentType,
.contentLength: String(data.count),
],
body: .init(byteBuffer: ByteBuffer(bytes: data))
)
}
// 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()
}
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)")
}
}
}
}

@ -1,5 +1,6 @@
import Foundation
import Observation
import MusicShared
protocol RemoteCommandSender: AnyObject {
func sendCommand(_ command: RemoteCommand)
@ -17,37 +18,69 @@ final class PlayerViewModel {
private(set) var queue: [Track] = []
private var originalQueue: [Track] = []
private let audio: AudioService
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
audio.onTrackFinished = { [weak self] in
func setProvider(_ newProvider: PlaybackProvider) {
provider.stop()
provider = newProvider
currentTrack = nil
currentIndex = nil
isPlaying = false
currentTime = 0
duration = 0
queue = []
originalQueue = []
halfwayReported = false
bindProvider()
}
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()
}
@ -73,13 +106,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 +125,41 @@ 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
}
guard let idx = currentIndex else { return }
@ -136,8 +172,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 +183,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 +203,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 +239,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())

@ -8,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
@ -84,13 +86,26 @@ struct PlayerControlsView: View {
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)
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)
}
}
}
}

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

@ -13,9 +13,26 @@ let package = Package(
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(

@ -3,10 +3,16 @@ 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) {
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
}
}

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

@ -6,7 +6,7 @@ public enum HLSManifestGenerator: Sendable {
public var duration: Double
}
public static func manifest(trackId: Int64, duration: Double, segmentDuration: Double) -> String {
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))
@ -17,10 +17,11 @@ public enum HLSManifestGenerator: Sendable {
"#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")
lines.append("segments/\(i).mp3\(tokenQuery)")
}
lines.append("#EXT-X-ENDLIST")

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

@ -5,6 +5,10 @@ public enum StreamingRoutes: Sendable {
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"
}

@ -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,61 @@ struct PlayerViewModelTests {
#expect(vm.currentTrack?.id == 4)
#expect(vm.queue.map { $0.id } == tracks.map { $0.id })
}
// 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 }
}

@ -1,132 +0,0 @@
import Foundation
import Testing
@testable import Music
struct RemoteProtocolTests {
private let encoder: JSONEncoder = {
let e = JSONEncoder()
e.outputFormatting = [.sortedKeys]
return e
}()
private let decoder = JSONDecoder()
// MARK: - Helpers
/// Encode then decode a value, returning the decoded copy.
private func roundTrip<T: Codable>(_ value: T) throws -> T {
let data = try encoder.encode(value)
return try decoder.decode(T.self, from: data)
}
// MARK: - RemoteCommand round-trip tests
// Each test encodes a RemoteCommand case to JSON and decodes it back,
// verifying the decoded value equals the original.
@Test func remoteCommandRoundTrip_play() throws {
let cmd = RemoteCommand.play(trackId: 42, queueIds: [42, 43, 44, 45])
#expect(try roundTrip(cmd) == cmd)
}
@Test func remoteCommandRoundTrip_pause() throws {
let cmd = RemoteCommand.pause
#expect(try roundTrip(cmd) == cmd)
}
@Test func remoteCommandRoundTrip_resume() throws {
let cmd = RemoteCommand.resume
#expect(try roundTrip(cmd) == cmd)
}
@Test func remoteCommandRoundTrip_next() throws {
let cmd = RemoteCommand.next
#expect(try roundTrip(cmd) == cmd)
}
@Test func remoteCommandRoundTrip_previous() throws {
let cmd = RemoteCommand.previous
#expect(try roundTrip(cmd) == cmd)
}
@Test func remoteCommandRoundTrip_seek() throws {
let cmd = RemoteCommand.seek(position: 123.456)
#expect(try roundTrip(cmd) == cmd)
}
@Test func remoteCommandRoundTrip_setVolume() throws {
let cmd = RemoteCommand.setVolume(level: 0.75)
#expect(try roundTrip(cmd) == cmd)
}
@Test func remoteCommandRoundTrip_toggleShuffle() throws {
let cmd = RemoteCommand.toggleShuffle
#expect(try roundTrip(cmd) == cmd)
}
@Test func remoteCommandRoundTrip_refreshDB() throws {
let cmd = RemoteCommand.refreshDB
#expect(try roundTrip(cmd) == cmd)
}
// MARK: - HostEvent round-trip tests
// Each test encodes a HostEvent case to JSON and decodes it back,
// verifying the decoded value equals the original.
@Test func hostEventRoundTrip_playbackState() throws {
let payload = PlaybackStatePayload(
trackId: 7,
isPlaying: true,
currentTime: 42.5,
duration: 210.0,
volume: 0.8,
isShuffled: false
)
let event = HostEvent.playbackState(payload)
#expect(try roundTrip(event) == event)
}
@Test func hostEventRoundTrip_dbReady() throws {
let event = HostEvent.dbReady
#expect(try roundTrip(event) == event)
}
@Test func hostEventRoundTrip_error() throws {
let event = HostEvent.error(message: "Something went wrong")
#expect(try roundTrip(event) == event)
}
// MARK: - HandshakeMessage round-trip test
// Verifies HandshakeMessage survives JSON encoding and decoding.
@Test func handshakeMessageRoundTrip() throws {
let msg = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: "1.2.3")
#expect(try roundTrip(msg) == msg)
}
// MARK: - Wire format decode tests
// Verify that hand-crafted JSON strings matching the expected wire format
// decode correctly, ensuring the Codable implementation matches the spec.
@Test func wireFormatDecode_playCommand() throws {
let json = """
{"type":"play","payload":{"trackId":42,"queueIds":[42,43,44,45]}}
"""
let decoded = try decoder.decode(RemoteCommand.self, from: Data(json.utf8))
#expect(decoded == .play(trackId: 42, queueIds: [42, 43, 44, 45]))
}
@Test func wireFormatDecode_playbackStateEvent() throws {
let json = """
{"type":"playbackState","payload":{"trackId":7,"isPlaying":true,"currentTime":42.5,"duration":210.0,"volume":0.8,"isShuffled":false}}
"""
let decoded = try decoder.decode(HostEvent.self, from: Data(json.utf8))
let expected = HostEvent.playbackState(PlaybackStatePayload(
trackId: 7,
isPlaying: true,
currentTime: 42.5,
duration: 210.0,
volume: 0.8,
isShuffled: false
))
#expect(decoded == expected)
}
}

@ -0,0 +1,204 @@
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)
}
}

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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -0,0 +1,276 @@
# Music Streaming — Design Spec
## Overview
Add internet-based music streaming to the Music app. A **host** serves its MP3 library as HLS streams over HTTPS. A **client** downloads the host's database for local browsing and plays audio by streaming from the host. Audio plays on the client, not the host.
This is distinct from the existing remote-mode design (LAN, audio on host). Here the client is an independent player that happens to source its audio and library from a remote host.
## Architecture
```
┌──────────────────────────┐ ┌──────────────────────────┐
│ HOST (Mac) │ │ CLIENT (Mac/iOS) │
│ │ │ │
│ Existing App │ │ Existing App │
│ ┌────────────────────┐ │ │ ┌────────────────────┐ │
│ │ DatabaseService │──┼── GET /db ──► │ Local DB copy │ │
│ │ (source of truth) │ │ │ │ (read-only browse) │ │
│ └────────────────────┘ │ │ └────────────────────┘ │
│ ┌────────────────────┐ │ │ ┌────────────────────┐ │
│ │ StreamingServer │ │◄── HLS ─────│ │ AVPlayer │ │
│ │ - Hummingbird HTTP │ │ requests │ │ (buffered playback)│ │
│ │ - HLS segmenter │ │ │ └────────────────────┘ │
│ │ - WebSocket │ │ │ ┌────────────────────┐ │
│ └────────────────────┘ │ │ │ WebSocket client │ │
│ ┌────────────────────┐ │◄── cmds ────│ │ (RemoteCommand / │ │
│ │ Cloudflare Tunnel │ │── events ──►│ │ HostEvent) │ │
│ │ (cloudflared) │ │ │ └────────────────────┘ │
│ └────────────────────┘ │ │ │
└──────────────────────────┘ └──────────────────────────┘
https://music.yourdomain.com
(Cloudflare edge)
```
### Key Difference from Remote Mode
In remote mode, the client is a remote control — audio plays on the host. In streaming mode, the client is an independent player — it streams audio from the host and plays it locally. The host doesn't play anything when serving a streaming client.
## MusicShared Swift Package
A local Swift package inside the repo, holding code shared between host and client (and later, an iOS target).
Contents:
- **`RemoteProtocol.swift`** — moved from `Music/Remote/`. Contains `RemoteCommand`, `HostEvent`, `PlaybackStatePayload`, `HandshakeMessage`, `RemoteProtocolVersion`.
- **`HLSManifestGenerator.swift`** — pure function: given track duration and segment size, produces `.m3u8` playlist text. No I/O.
- **`APIModels.swift`** — shared DTOs: `AuthResponse` (host info, protocol version), `DBMetadata` (version, checksum for conditional re-download).
- **`Routes.swift`** — route path constants so host and client stay in sync.
- **`StreamingConstants.swift`** — segment duration (6s), default port (8420), protocol version.
## HTTP Endpoints
All endpoints require the `Authorization: Bearer <api-key>` header. Served by Hummingbird on the host.
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/auth` | Validate API key, return host name + protocol version |
| `GET` | `/db` | Download the full SQLite database file |
| `GET` | `/tracks/:id/stream.m3u8` | HLS manifest for a track |
| `GET` | `/tracks/:id/segments/:index.mp3` | Individual MP3 audio segment |
| `GET` | `/ws` | WebSocket upgrade for real-time command/event channel |
No REST API for browsing — the client downloads the full database and queries it locally using existing `DatabaseService` code.
## HLS Streaming
### On-the-Fly Segmentation
When a client requests a track's manifest:
1. Look up the track's file path in the database.
2. Read MP3 duration from file metadata (cached after first read).
3. Generate a `.m3u8` playlist with N segments of 6 seconds each.
When a client requests a segment:
1. Use `AVAssetReader` with a time range (`CMTimeRange`) for the requested segment (e.g., segment 2 = 12s–18s).
2. `AVAssetReader` handles frame-boundary alignment and VBR files correctly.
3. Return the extracted audio bytes.
This avoids raw byte slicing, which breaks on VBR files and frame-boundary misalignment.
### Manifest Format
```
#EXTM3U
#EXT-X-VERSION:3
#EXT-X-TARGETDURATION:6
#EXT-X-MEDIA-SEQUENCE:0
#EXTINF:6.0,
segments/0.mp3
#EXTINF:6.0,
segments/1.mp3
#EXTINF:4.2,
segments/2.mp3
#EXT-X-ENDLIST
```
### Design Decisions
- **MP3 byte-range slicing** instead of transcoding to AAC. Avoids CPU overhead; `AVPlayer` handles MP3 segments without issues.
- **6-second segments**: HLS standard. Short enough for responsive seeking, long enough to avoid excessive HTTP requests for a single listener.
- **No adaptive bitrate**: the source files are fixed-bitrate MP3s. No need for multiple quality renditions.
## Cloudflare Tunnel
The host uses `cloudflared` to expose its local Hummingbird server to the internet.
### Quick Tunnel (Development)
```
cloudflared tunnel --url http://localhost:8420
```
- Zero config, no account required.
- Generates a random `https://xxx-yyy-zzz.trycloudflare.com` URL.
- URL changes on every restart — must be copied to the client each time.
### Named Tunnel (Recommended for Daily Use)
One-time setup with a Cloudflare account and domain:
```
cloudflared tunnel create music
cloudflared tunnel route dns music music.yourdomain.com
```
Then the app launches:
```
cloudflared tunnel run --url http://localhost:8420 music
```
- Stable URL: `https://music.yourdomain.com`.
- Configure the client once, never touch it again.
### App Integration
- The host manages the `cloudflared` process as a child process (`Process` in Swift).
- On host start: launch `cloudflared`, parse the tunnel URL from stdout.
- On host stop: terminate the `cloudflared` process.
- The host UI displays the current tunnel URL for the user to share with the client.
- The app supports both modes: a toggle or setting to choose quick vs named tunnel.
### Prerequisite
`cloudflared` must be installed separately (`brew install cloudflared`). The app checks for its presence on host startup and shows a clear error with install instructions if missing.
## Authentication
Static API key for personal use.
- The host generates a random API key on first setup (or the user sets one manually).
- The key is stored in the host's Keychain.
- The client stores the host URL + API key in its Keychain.
- Every HTTP request and WebSocket upgrade includes `Authorization: Bearer <api-key>`.
- Invalid keys receive HTTP 401. No retry, no session tokens, no expiry — a static secret over HTTPS is sufficient for single-user.
## Client-Side Playback
### Connection Flow
1. User enters host URL + API key in the connection settings (one-time).
2. Client calls `GET /auth` to validate credentials and check protocol version.
3. Client calls `GET /db` to download the SQLite database, saved to `Application Support/Music/streaming_db.sqlite`.
4. Client opens the DB with `DatabaseService` in read-only mode.
5. Client establishes WebSocket connection to `/ws`.
6. App transitions to streaming mode — existing views reload from the downloaded DB.
### Playback
When the user picks a track:
1. Client constructs the HLS URL: `https://<host>/tracks/<id>/stream.m3u8`.
2. Creates an `AVPlayer` with an `AVURLAsset`, injecting the API key via a custom `AVAssetResourceLoaderDelegate` or URL request headers.
3. `AVPlayer` fetches the manifest, then segments on demand. Buffering, seeking, and playback are handled natively.
4. Client sends `RemoteCommand` over WebSocket to keep the host aware of what's playing (for state sync if multiple clients in the future).
### AudioService Abstraction
The existing `AudioService` plays local files. For streaming, a parallel `StreamingAudioService` wraps `AVPlayer` with HLS URLs. Both conform to a shared protocol so `PlayerViewModel` works with either.
### Database Refresh
Same as remote mode: send `RemoteCommand.refreshDB` over WebSocket → host signals `HostEvent.dbReady` → client re-downloads the DB and reloads views.
## WebSocket Channel
Reuses the existing `RemoteCommand` / `HostEvent` protocol (JSON over WebSocket).
### Client → Host
| Command | Purpose |
|---------|---------|
| `play(trackId, queueIds)` | Inform host what the client is playing |
| `pause` | Client paused |
| `resume` | Client resumed |
| `next` / `previous` | Client changed track |
| `seek(position)` | Client seeked |
| `setVolume(level)` | Client volume changed |
| `toggleShuffle` | Client toggled shuffle |
| `refreshDB` | Request fresh database |
In streaming mode, these commands are informational (the client controls its own playback). They keep the host aware of client state for logging and potential future multi-client coordination.
### Host → Client
| Event | Purpose |
|-------|---------|
| `dbReady` | New database available for download |
| `error(message)` | Server-side error (track file missing, etc.) |
`playbackState` events are less critical in streaming mode since the client drives its own playback, but can be used for sync verification.
### Handshake & Keep-Alive
- On WebSocket connect: exchange `HandshakeMessage` with protocol version and app version.
- Ping/pong every 5 seconds. Connection declared lost after 3 missed pings (15s).
## UI Changes
### Host Mode
- **"Start Streaming Server"** menu toggle — starts Hummingbird + `cloudflared`.
- Status indicator: "Streaming server running · `https://music.yourdomain.com`".
- Settings panel: API key display/regenerate, tunnel mode (quick/named), named tunnel config.
### Client Mode
- **Connection settings**: host URL + API key fields, "Connect" / "Disconnect" button.
- Status indicator: "Connected to [host]" or "Disconnected".
- "Refresh Library" action to re-download the database.
- All existing views (HomeView, TrackTableView, playlists, search, player controls) work unchanged against the local DB copy.
- Playlist creation/editing disabled (read-only snapshot).
### Mode Selection
A setting to choose the app's role: **Local** (default, current behavior), **Host** (serves library), or **Client** (streams from host). Persisted in UserDefaults.
## Testing
### Unit Tests
- `HLSManifestGenerator`: correct `.m3u8` output for various track durations, edge cases (very short tracks, exact multiples of segment duration).
- `RemoteCommand` / `HostEvent` Codable round-trip (already partially covered in `RemoteProtocolTests.swift`).
- API key validation logic.
- Segment extraction: `AVAssetReader` produces valid audio for each segment time range, including edge cases (VBR files, last segment shorter than 6s).
### Integration Tests
- Loopback streaming: start Hummingbird server in-process, request manifest + segments over localhost, verify valid HLS output.
- Database download: verify downloaded DB matches source schema and row counts.
- Auth rejection: requests without or with wrong API key receive 401.
- WebSocket handshake: version mismatch is caught and reported.
### Manual Test Scenarios
- Happy path: start host → connect client → browse library → play track → audio streams and plays on client.
- Seek mid-track → playback resumes from correct position.
- Network interruption → client buffers, resumes when connection returns.
- Kill host mid-playback → client shows error cleanly.
- Add tracks on host → client refreshes DB → new tracks appear.
- Wrong API key → client shows auth error.
- `cloudflared` not installed → host shows clear install instructions.
## Scope & Constraints
- **Single client** for v1. No concurrent listener handling.
- **Read-only client**: no playlist or library modifications from the client.
- **MP3 only**: HLS segmentation assumes MP3 source files (matches current library).
- **`cloudflared` required**: not bundled, must be installed separately.
- **No offline mode**: client requires active connection to stream. Downloaded DB enables browsing but not playback without the host.
- **No transcoding**: segments served as raw MP3 byte ranges.
- **Hummingbird dependency**: added via Swift Package Manager for the embedded HTTP server.

@ -0,0 +1,270 @@
# Remote Mode — Design Spec
## Overview
Add a Host/Remote mode to the Music app so a MacBook can control playback on a Mac Mini over the local network. The remote sees the full library and controls playback, but audio plays on the host. The remote is read-only — no playlist or library modifications for v1.
## Architecture
Two roles the app can operate in, one at a time:
- **Host:** Runs a network server, advertises via Bonjour, serves its database, accepts playback commands, streams playback state.
- **Remote:** Discovers hosts via Bonjour, downloads the host's database, sends playback commands, displays synced playback state.
```
┌──────────────────────┐ ┌──────────────────────┐
│ MAC MINI │ │ MACBOOK │
│ (Host) │ │ (Remote) │
│ │ │ │
│ Existing App │ │ Existing App │
│ ┌────────────────┐ │ │ ┌────────────────┐ │
│ │ AudioService │ │◄────────│ │ RemoteClient │ │
│ │ PlayerViewModel│ │ WebSocket│ │ │ │
│ │ DatabaseService│──┼────────►│ │ Local DB copy │ │
│ └────────────────┘ │ HTTP │ └────────────────┘ │
│ ┌────────────────┐ │ │ │
│ │ HostServer │ │ │ Reuses all existing │
│ │ - HTTP (DB) │ │ │ ViewModels & Views │
│ │ - WebSocket │ │ │ for browsing │
│ │ - Bonjour │ │ │ │
│ └────────────────┘ │ │ │
└──────────────────────┘ └──────────────────────┘
```
### Playback Abstraction
A `PlaybackController` protocol abstracts where playback happens:
- `LocalPlaybackController` — wraps the existing `AudioService` + `PlayerViewModel` logic. This is what the app uses today.
- `RemotePlaybackController` — sends commands over WebSocket to the host, receives state updates, and updates the `PlayerViewModel` accordingly.
Views and ViewModels call the same methods (`play`, `pause`, `next`, `seek`, etc.) regardless of which controller is active. The active controller decides whether that's local audio or a network command.
## Host Server
New service: `HostServer`.
### Bonjour Advertisement
- Uses `NWListener` with service type `_musicremote._tcp`.
- Service name is the computer's local name.
- Automatically discoverable on the local network when hosting is enabled.
### HTTP — Database Download
- When a remote connects, its first request is `GET /db`.
- The host reads `db.sqlite` from its Application Support directory and streams it as a binary response.
- Typically a few MB, under a second on WiFi.
### WebSocket — Command & State Channel
After the DB download, the remote establishes a WebSocket connection for bidirectional communication.
**Remote → Host (commands):**
| Command | Payload |
|---------|---------|
| `play` | `trackId`, `queueIds` (array of track IDs) |
| `pause` | — |
| `resume` | — |
| `next` | — |
| `previous` | — |
| `seek` | `position` (seconds) |
| `setVolume` | `level` (0.0–1.0) |
| `toggleShuffle` | — |
| `refreshDB` | — |
**Host → Remote (events):**
| Event | Payload |
|-------|---------|
| `playbackState` | `trackId`, `isPlaying`, `currentTime`, `duration`, `volume`, `isShuffled` |
| `dbReady` | — (sent after refreshDB, signals new DB is available for download) |
| `error` | `message` (human-readable) |
### State Update Frequency
- Immediate on discrete events: play, pause, track change, volume change, shuffle toggle.
- Every ~1 second while playing for progress bar position.
- The remote interpolates locally between updates for smooth scrubber movement.
### Connection Limits
Single remote connection at a time for v1. A second connection attempt is rejected with a clear error.
## Remote Client
New service: `RemoteClient`.
### Discovery
- Uses `NWBrowser` to scan for `_musicremote._tcp` services.
- Presents discovered hosts by computer name in the connection sheet.
- Resolves the selected endpoint to get IP/port.
### Connection Flow
1. Connect to the host's HTTP endpoint.
2. Download `db.sqlite`, save to `Application Support/Music/remote_db.sqlite`.
3. Open the downloaded DB with `DatabaseService` in read-only mode.
4. Establish the WebSocket connection.
5. App transitions to remote mode — existing ViewModels reload from the downloaded DB.
### Command Forwarding
In remote mode, the `RemotePlaybackController` intercepts all playback calls and sends them as WebSocket commands instead of calling the local `AudioService`.
### State Sync
The remote listens for `playbackState` messages and updates the `PlayerViewModel`:
- Current track is looked up by ID from the local DB copy.
- `isPlaying`, `currentTime`, `duration`, `volume`, `isShuffled` are set directly.
- SwiftUI observation triggers UI updates automatically.
### DB Refresh
A "Refresh Library" action sends `refreshDB`, the host signals `dbReady`, the remote re-downloads the DB and reloads the ViewModels.
### Disconnection
On disconnect (user-initiated or connection drop), the app returns to local mode. The temporary remote DB file is deleted.
## Message Protocol
JSON over WebSocket. Swift `Codable` enums for type safety.
```json
// Remote → Host
{"type": "play", "payload": {"trackId": 42, "queueIds": [42, 43, 44, 45]}}
{"type": "pause"}
{"type": "resume"}
{"type": "next"}
{"type": "previous"}
{"type": "seek", "payload": {"position": 65.3}}
{"type": "setVolume", "payload": {"level": 0.75}}
{"type": "toggleShuffle"}
{"type": "refreshDB"}
// Host → Remote
{"type": "playbackState", "payload": {
"trackId": 42,
"isPlaying": true,
"currentTime": 65.3,
"duration": 210.0,
"volume": 0.75,
"isShuffled": false
}}
{"type": "dbReady"}
{"type": "error", "payload": {"message": "Track file not found"}}
```
### Handshake
On WebSocket connect, host and client exchange a handshake message with app version. Version mismatches are caught early and logged.
### Keep-Alive
WebSocket ping/pong at 5-second intervals. If 3 consecutive pings go unanswered, the connection is declared lost.
## UI Changes
### Menu Bar
- **"Enable Host Mode"** — toggle menu item. Starts/stops the `HostServer`.
- **"Connect to Remote..."** — opens the connection sheet.
### Connection Sheet (Remote Side)
Modal sheet showing:
- List of discovered Bonjour hosts (computer name + connectivity indicator).
- "Connect" button for the selected host.
- Progress indicator during DB download.
- Error state with retry if connection fails.
### Remote Mode Indicators
When connected as a remote:
- A persistent banner/badge showing "Connected to [host name]" with a disconnect button.
- Playlist creation/editing UI disabled (greyed out context menus, hidden "New Playlist").
- "Open Music Folder..." menu item disabled.
- "Refresh Library" action available (triggers DB re-download).
### Host Mode Indicators
When hosting:
- Status indicator showing "Hosting" or "Hosting · [remote name] connected".
### Unchanged
Track table, player controls, search bar, home view, playlist bar — all work as-is against the local DB copy and the `PlaybackController` abstraction.
## Observability
### Structured Logging
`os.Logger` with subsystem `com.music.remote` and two categories: `host` and `client`. Logs are filterable in Console.app.
| Level | Examples |
|-------|---------|
| Info | "Host started on port 8432", "Remote connected: Laurent's MacBook", "DB download complete (2.4 MB, 340ms)" |
| Debug | Command send/receive, state update cycle, Bonjour browse events, connection lifecycle transitions |
| Error | Connection refused, DB read failure, WebSocket decode failure, unexpected disconnect with reason |
### Connection State Machine
Every state transition is logged and drives user-visible status:
```
Disconnected → Discovering → Found Host → Downloading DB → Connecting WebSocket → Connected
↑ │
└──── Connection Lost ◄───────────────────────────────────────────────────────────┘
```
User-visible status messages: "Searching for hosts...", "Connecting to [name]...", "Downloading library...", "Connected to [name]", "Connection lost — Reconnect?"
### Error Messages
Every error includes:
- A clean, human-readable summary for the user (shown in UI).
- The underlying `NWError` description in the log for debugging.
Examples: "Host refused connection", "Download timed out after 10s", "Network changed", "Host stopped hosting".
### Diagnostics
- Version handshake on connect catches protocol mismatches early.
- WebSocket keep-alive detects stale connections within 15 seconds.
- All incoming commands logged at debug level on the host for traceability.
## Testing
### Unit Tests
- `RemoteCommand` / `HostEvent` Codable round-trip for every message type.
- `RemotePlaybackController` sends correct WebSocket messages for each action.
- Connection state machine: valid transitions succeed, invalid transitions are rejected.
- `HostServer` command dispatch: incoming commands map to correct `PlayerViewModel` calls.
### Integration Tests
- Loopback connection: `HostServer` + `RemoteClient` in the same process over localhost — full flow from DB download through command/response round-trip.
- DB download integrity: downloaded DB matches source schema and row counts.
- State sync accuracy: play a track on host, verify remote receives correct `playbackState` values.
Real network connections in integration tests — no mocks for the networking layer.
### Manual Test Scenarios
- Happy path: enable host → connect remote → browse → play → verify audio on host, UI synced on remote.
- Kill host app mid-playback → remote shows "Connection lost" cleanly.
- Disconnect WiFi on remote → reconnect flow works.
- Scan new folder on host while remote connected → remote can refresh and see new tracks.
- Attempt playlist creation on remote → properly disabled.
## Scope & Constraints
- **v1 only:** Single remote, read-only, no authentication, local network only.
- **No changes to existing playback logic:** The `HostServer` wraps `PlayerViewModel` and `AudioService`, it doesn't modify them.
- **No dependencies added:** All networking uses Apple's Network.framework.
- **Existing UI untouched:** Only additions are menu items, connection sheet, and status indicators.
- **Play counts track on the host:** Since the host is playing the audio, play count increments happen on the host's database. The remote's local DB copy is a read-only snapshot and is not written to.
Loading…
Cancel
Save