parent
0829dba09a
commit
98f11658ad
@ -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,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) |
|
||||||
} |
|
||||||
} |
|
||||||
} |
|
||||||
@ -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)") |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,221 @@ |
|||||||
|
{ |
||||||
|
"pins" : [ |
||||||
|
{ |
||||||
|
"identity" : "async-http-client", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/swift-server/async-http-client.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "3a5b74a58782c3b4c1f0bc75e9b67b10c2494e8f", |
||||||
|
"version" : "1.33.1" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "hummingbird", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/hummingbird-project/hummingbird.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "2f407402799c2217df69b01582f3a44856fef012", |
||||||
|
"version" : "2.24.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-algorithms", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-algorithms.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", |
||||||
|
"version" : "1.2.1" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-asn1", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-asn1.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "eb50cbd14606a9161cbc5d452f18797c90ef0bab", |
||||||
|
"version" : "1.7.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-async-algorithms", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-async-algorithms.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", |
||||||
|
"version" : "1.1.4" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-atomics", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-atomics.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", |
||||||
|
"version" : "1.3.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-certificates", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-certificates.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "bde8ca32a096825dfce37467137c903418c1893d", |
||||||
|
"version" : "1.19.1" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-collections", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-collections.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", |
||||||
|
"version" : "1.5.1" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-configuration", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-configuration.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", |
||||||
|
"version" : "1.2.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-crypto", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-crypto.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "1b6b2e274e85105bfa155183145a1dcfd63331f1", |
||||||
|
"version" : "4.5.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-distributed-tracing", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-distributed-tracing.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "dc4030184203ffafbb2ec614352487235d747fe0", |
||||||
|
"version" : "1.4.1" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-http-structured-headers", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-http-structured-headers.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", |
||||||
|
"version" : "1.7.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-http-types", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-http-types.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", |
||||||
|
"version" : "1.5.1" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-log", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-log.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "7dc6101ae4dbe95cd3bc9cebad3b7cf8e49a7a63", |
||||||
|
"version" : "1.13.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-metrics", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-metrics.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "087e8074afa97040c3b870c8664fe5482fb87cc4", |
||||||
|
"version" : "2.11.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-nio", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-nio.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "57c0a08a331aaea9f5d7a932ad94ef43be942a95", |
||||||
|
"version" : "2.100.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-nio-extras", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-nio-extras.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506", |
||||||
|
"version" : "1.34.1" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-nio-http2", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-nio-http2.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", |
||||||
|
"version" : "1.44.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-nio-ssl", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-nio-ssl.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "3f337058ccd7243c4cac7911477d8ad4c598d4da", |
||||||
|
"version" : "2.37.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-nio-transport-services", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-nio-transport-services.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", |
||||||
|
"version" : "1.28.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-numerics", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-numerics.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", |
||||||
|
"version" : "1.1.1" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-service-context", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-service-context.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", |
||||||
|
"version" : "1.3.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-service-lifecycle", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/swift-server/swift-service-lifecycle.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", |
||||||
|
"version" : "2.11.0" |
||||||
|
} |
||||||
|
}, |
||||||
|
{ |
||||||
|
"identity" : "swift-system", |
||||||
|
"kind" : "remoteSourceControl", |
||||||
|
"location" : "https://github.com/apple/swift-system.git", |
||||||
|
"state" : { |
||||||
|
"revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", |
||||||
|
"version" : "1.6.4" |
||||||
|
} |
||||||
|
} |
||||||
|
], |
||||||
|
"version" : 2 |
||||||
|
} |
||||||
@ -0,0 +1,3 @@ |
|||||||
|
// Re-export Hummingbird so the app target can use it |
||||||
|
// without adding a separate package dependency. |
||||||
|
@_exported import Hummingbird |
||||||
@ -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 |
||||||
|
} |
||||||
|
} |
||||||
@ -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) |
||||||
|
} |
||||||
|
} |
||||||
@ -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…
Reference in new issue