diff --git a/docs/superpowers/plans/2026-05-26-remote-mode.md b/docs/superpowers/plans/2026-05-26-remote-mode.md deleted file mode 100644 index a2d511c..0000000 --- a/docs/superpowers/plans/2026-05-26-remote-mode.md +++ /dev/null @@ -1,2263 +0,0 @@ -# Remote Mode Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add Host/Remote mode so the app on a MacBook can control playback on a Mac Mini over the local network. - -**Architecture:** PlayerViewModel becomes the single source of truth for all playback state — Views never read from AudioService directly. In local mode, PlayerViewModel syncs state from AudioService via a callback. In remote mode, it syncs from the network. This eliminates all `if isRemoteMode` branching from the View layer. Networking uses Network.framework with Bonjour discovery, HTTP for one-shot DB download, and NDJSON (newline-delimited JSON) over TCP for the bidirectional command channel. - -**Tech Stack:** Network.framework (NWListener, NWConnection, NWBrowser), Bonjour/mDNS, os.Logger, GRDB (existing), Swift Codable for JSON protocol. - -**Architectural note:** The spec describes a `PlaybackController` protocol with separate implementations. This plan achieves the same goal (Views unchanged between modes) more simply: PlayerViewModel handles both modes internally with clear `if/else` routing. The protocol added a layer of indirection that obscured what was happening, and ContentView still ended up needing `isRemoteMode` checks for reading state. This approach eliminates all of that. - ---- - -## File Structure - -### New Files - -| File | Responsibility | -|------|---------------| -| `Music/Remote/RemoteProtocol.swift` | `RemoteCommand` and `HostEvent` Codable enums, protocol version constant, `PlaybackStatePayload`, `HandshakeMessage` | -| `Music/Remote/RemoteLogger.swift` | `os.Logger` wrappers with `host` and `client` categories under `com.music.remote` subsystem | -| `Music/Remote/ConnectionState.swift` | Connection state machine enum with transition validation and user-visible messages | -| `Music/Remote/NDJSONTransport.swift` | Newline-delimited JSON framing over `NWConnection` — line-buffered reads, typed sends. Shared by host and client. | -| `Music/Remote/HostServer.swift` | `NWListener`-based server: Bonjour advertisement, HTTP DB serving, command channel via `NDJSONTransport`, command dispatch to `PlayerViewModel` | -| `Music/Remote/RemoteClient.swift` | `NWBrowser`-based client: Bonjour discovery, DB download, command channel via `NDJSONTransport`, state receiving | -| `Music/Views/ConnectionSheet.swift` | SwiftUI sheet for discovering and connecting to hosts | -| `MusicTests/RemoteProtocolTests.swift` | Codable round-trip tests for all message types | -| `MusicTests/ConnectionStateTests.swift` | State machine transition tests | -| `MusicTests/NDJSONTransportTests.swift` | Line-buffered framing tests | -| `MusicTests/PlayerViewModelRemoteTests.swift` | PlayerViewModel remote mode tests: command forwarding, state syncing | -| `MusicTests/HostServerIntegrationTests.swift` | Loopback integration: DB download, command/state round-trip | - -### Modified Files - -| File | Changes | -|------|---------| -| `Music/Services/AudioService.swift` | Add `onPlaybackStateChanged` callback, fire on time updates and discrete state changes | -| `Music/Services/DatabaseService.swift` | Add `fetchTracksByIds(_:)` method for efficient ID-based lookups | -| `Music/ViewModels/PlayerViewModel.swift` | Add `isPlaying`, `currentTime`, `duration`, `volume` properties. Add `enterRemoteMode`/`exitRemoteMode`. Route all playback actions through local audio or remote commands. | -| `Music/ContentView.swift` | Replace all `audio.*` reads with `player.*`. Remove `audio` property. Add `networkStatus` for banner/indicators. | -| `Music/Views/PlaylistBarView.swift` | Add `isRemoteMode` to disable context menus | -| `Music/MusicApp.swift` | Add `HostServer`, `RemoteClient`, menu items, DB swap on connect/disconnect | - ---- - -### Task 1: Message Protocol — `RemoteCommand` and `HostEvent` - -**Files:** -- Create: `Music/Remote/RemoteProtocol.swift` -- Test: `MusicTests/RemoteProtocolTests.swift` - -- [ ] **Step 1: Write failing tests for RemoteCommand encoding/decoding** - -```swift -// MusicTests/RemoteProtocolTests.swift -import Testing -import Foundation -@testable import Music - -struct RemoteProtocolTests { - - // Encodes each RemoteCommand to JSON and decodes it back, - // verifying the round-trip preserves the value exactly. - - @Test func playCommandRoundTrip() throws { - let cmd = RemoteCommand.play(trackId: 42, queueIds: [42, 43, 44, 45]) - let data = try JSONEncoder().encode(cmd) - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) - #expect(decoded == cmd) - } - - @Test func pauseCommandRoundTrip() throws { - let cmd = RemoteCommand.pause - let data = try JSONEncoder().encode(cmd) - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) - #expect(decoded == cmd) - } - - @Test func resumeCommandRoundTrip() throws { - let cmd = RemoteCommand.resume - let data = try JSONEncoder().encode(cmd) - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) - #expect(decoded == cmd) - } - - @Test func nextCommandRoundTrip() throws { - let cmd = RemoteCommand.next - let data = try JSONEncoder().encode(cmd) - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) - #expect(decoded == cmd) - } - - @Test func previousCommandRoundTrip() throws { - let cmd = RemoteCommand.previous - let data = try JSONEncoder().encode(cmd) - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) - #expect(decoded == cmd) - } - - @Test func seekCommandRoundTrip() throws { - let cmd = RemoteCommand.seek(position: 65.3) - let data = try JSONEncoder().encode(cmd) - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) - #expect(decoded == cmd) - } - - @Test func setVolumeCommandRoundTrip() throws { - let cmd = RemoteCommand.setVolume(level: 0.75) - let data = try JSONEncoder().encode(cmd) - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) - #expect(decoded == cmd) - } - - @Test func toggleShuffleCommandRoundTrip() throws { - let cmd = RemoteCommand.toggleShuffle - let data = try JSONEncoder().encode(cmd) - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) - #expect(decoded == cmd) - } - - @Test func refreshDBCommandRoundTrip() throws { - let cmd = RemoteCommand.refreshDB - let data = try JSONEncoder().encode(cmd) - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: data) - #expect(decoded == cmd) - } - - @Test func playbackStateEventRoundTrip() throws { - let event = HostEvent.playbackState(PlaybackStatePayload( - trackId: 42, isPlaying: true, currentTime: 65.3, - duration: 210.0, volume: 0.75, isShuffled: false - )) - let data = try JSONEncoder().encode(event) - let decoded = try JSONDecoder().decode(HostEvent.self, from: data) - #expect(decoded == event) - } - - @Test func dbReadyEventRoundTrip() throws { - let event = HostEvent.dbReady - let data = try JSONEncoder().encode(event) - let decoded = try JSONDecoder().decode(HostEvent.self, from: data) - #expect(decoded == event) - } - - @Test func errorEventRoundTrip() throws { - let event = HostEvent.error(message: "Track file not found") - let data = try JSONEncoder().encode(event) - let decoded = try JSONDecoder().decode(HostEvent.self, from: data) - #expect(decoded == event) - } - - @Test func handshakeRoundTrip() throws { - let msg = HandshakeMessage(protocolVersion: RemoteProtocolVersion, appVersion: "1.0.0") - let data = try JSONEncoder().encode(msg) - let decoded = try JSONDecoder().decode(HandshakeMessage.self, from: data) - #expect(decoded == msg) - } - - // Decodes from known JSON strings to verify wire format matches the spec. - @Test func playCommandDecodesFromWireFormat() throws { - let json = """ - {"type":"play","payload":{"trackId":42,"queueIds":[42,43,44,45]}} - """ - let decoded = try JSONDecoder().decode(RemoteCommand.self, from: Data(json.utf8)) - #expect(decoded == .play(trackId: 42, queueIds: [42, 43, 44, 45])) - } - - @Test func playbackStateDecodesFromWireFormat() throws { - let json = """ - {"type":"playbackState","payload":{"trackId":42,"isPlaying":true,"currentTime":65.3,"duration":210.0,"volume":0.75,"isShuffled":false}} - """ - let decoded = try JSONDecoder().decode(HostEvent.self, from: Data(json.utf8)) - #expect(decoded == .playbackState(PlaybackStatePayload( - trackId: 42, isPlaying: true, currentTime: 65.3, - duration: 210.0, volume: 0.75, isShuffled: false - ))) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/RemoteProtocolTests 2>&1 | tail -20` - -Expected: Compilation failure — `RemoteCommand`, `HostEvent`, etc. not defined. - -- [ ] **Step 3: Implement the protocol types** - -```swift -// Music/Remote/RemoteProtocol.swift -import Foundation - -let RemoteProtocolVersion: Int = 1 - -nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable { - var trackId: Int64? - var isPlaying: Bool - var currentTime: Double - var duration: Double - var volume: Float - var isShuffled: Bool -} - -nonisolated struct HandshakeMessage: Codable, Equatable, Sendable { - var protocolVersion: Int - var appVersion: String -} - -nonisolated enum RemoteCommand: Codable, 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 - - private enum CodingKeys: String, CodingKey { - case type, payload - } - - private enum CommandType: String, Codable { - case play, pause, resume, next, previous, seek, setVolume, toggleShuffle, refreshDB - } - - private struct PlayPayload: Codable { let trackId: Int64; let queueIds: [Int64] } - private struct SeekPayload: Codable { let position: Double } - private struct VolumePayload: Codable { let 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(CommandType.play, forKey: .type) - try container.encode(PlayPayload(trackId: trackId, queueIds: queueIds), forKey: .payload) - case .pause: try container.encode(CommandType.pause, forKey: .type) - case .resume: try container.encode(CommandType.resume, forKey: .type) - case .next: try container.encode(CommandType.next, forKey: .type) - case .previous: try container.encode(CommandType.previous, forKey: .type) - case .seek(let position): - try container.encode(CommandType.seek, forKey: .type) - try container.encode(SeekPayload(position: position), forKey: .payload) - case .setVolume(let level): - try container.encode(CommandType.setVolume, forKey: .type) - try container.encode(VolumePayload(level: level), forKey: .payload) - case .toggleShuffle: try container.encode(CommandType.toggleShuffle, forKey: .type) - case .refreshDB: try container.encode(CommandType.refreshDB, forKey: .type) - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(CommandType.self, forKey: .type) - switch type { - case .play: - let p = try container.decode(PlayPayload.self, forKey: .payload) - self = .play(trackId: p.trackId, queueIds: p.queueIds) - case .pause: self = .pause - case .resume: self = .resume - case .next: self = .next - case .previous: self = .previous - case .seek: - let p = try container.decode(SeekPayload.self, forKey: .payload) - self = .seek(position: p.position) - case .setVolume: - let p = try container.decode(VolumePayload.self, forKey: .payload) - self = .setVolume(level: p.level) - case .toggleShuffle: self = .toggleShuffle - case .refreshDB: self = .refreshDB - } - } -} - -nonisolated enum HostEvent: Codable, Equatable, Sendable { - case playbackState(PlaybackStatePayload) - case dbReady - case error(message: String) - - private enum CodingKeys: String, CodingKey { case type, payload } - private enum EventType: String, Codable { case playbackState, dbReady, error } - private struct ErrorPayload: Codable { let message: String } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .playbackState(let payload): - try container.encode(EventType.playbackState, forKey: .type) - try container.encode(payload, forKey: .payload) - case .dbReady: try container.encode(EventType.dbReady, forKey: .type) - case .error(let message): - try container.encode(EventType.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(EventType.self, forKey: .type) - switch type { - case .playbackState: - self = .playbackState(try container.decode(PlaybackStatePayload.self, forKey: .payload)) - case .dbReady: self = .dbReady - case .error: - self = .error(message: try container.decode(ErrorPayload.self, forKey: .payload).message) - } - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/RemoteProtocolTests 2>&1 | tail -20` - -Expected: All 15 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Remote/RemoteProtocol.swift MusicTests/RemoteProtocolTests.swift -git commit -m "feat(remote): add RemoteCommand and HostEvent protocol types with tests" -``` - ---- - -### Task 2: Remote Logger - -**Files:** -- Create: `Music/Remote/RemoteLogger.swift` - -- [ ] **Step 1: Implement the logger** - -```swift -// Music/Remote/RemoteLogger.swift -import os - -enum RemoteLogger { - static let host = Logger(subsystem: "com.music.remote", category: "host") - static let client = Logger(subsystem: "com.music.remote", category: "client") -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add Music/Remote/RemoteLogger.swift -git commit -m "feat(remote): add structured os.Logger for host and client" -``` - ---- - -### Task 3: Connection State Machine - -**Files:** -- Create: `Music/Remote/ConnectionState.swift` -- Test: `MusicTests/ConnectionStateTests.swift` - -- [ ] **Step 1: Write failing tests** - -```swift -// MusicTests/ConnectionStateTests.swift -import Testing -import Foundation -@testable import Music - -struct ConnectionStateTests { - - // Verifies that each valid forward transition is allowed. - @Test func validTransitionsSucceed() { - #expect(ConnectionState.disconnected.canTransition(to: .discovering) == true) - #expect(ConnectionState.discovering.canTransition(to: .foundHost("Mac Mini")) == true) - #expect(ConnectionState.foundHost("Mac Mini").canTransition(to: .downloadingDB) == true) - #expect(ConnectionState.downloadingDB.canTransition(to: .connectingCommandChannel) == true) - #expect(ConnectionState.connectingCommandChannel.canTransition(to: .connected("Mac Mini")) == true) - #expect(ConnectionState.connected("Mac Mini").canTransition(to: .connectionLost("Network changed")) == true) - #expect(ConnectionState.connectionLost("Network changed").canTransition(to: .discovering) == true) - } - - // Verifies that skipping states is rejected. - @Test func invalidTransitionsRejected() { - #expect(ConnectionState.disconnected.canTransition(to: .connected("Mac Mini")) == false) - #expect(ConnectionState.discovering.canTransition(to: .connected("Mac Mini")) == false) - #expect(ConnectionState.connected("Mac Mini").canTransition(to: .discovering) == false) - } - - // Any state can transition to disconnected (user can always disconnect). - @Test func anyStateCanDisconnect() { - #expect(ConnectionState.discovering.canTransition(to: .disconnected) == true) - #expect(ConnectionState.foundHost("X").canTransition(to: .disconnected) == true) - #expect(ConnectionState.downloadingDB.canTransition(to: .disconnected) == true) - #expect(ConnectionState.connectingCommandChannel.canTransition(to: .disconnected) == true) - #expect(ConnectionState.connected("X").canTransition(to: .disconnected) == true) - } - - // Verifies the human-readable status message for each state. - @Test func userVisibleDescriptions() { - #expect(ConnectionState.disconnected.userMessage == nil) - #expect(ConnectionState.discovering.userMessage == "Searching for hosts...") - #expect(ConnectionState.foundHost("Mac Mini").userMessage == "Found Mac Mini") - #expect(ConnectionState.downloadingDB.userMessage == "Downloading library...") - #expect(ConnectionState.connectingCommandChannel.userMessage == "Connecting...") - #expect(ConnectionState.connected("Mac Mini").userMessage == "Connected to Mac Mini") - #expect(ConnectionState.connectionLost("Network changed").userMessage == "Connection lost — Network changed") - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/ConnectionStateTests 2>&1 | tail -20` - -Expected: Compilation failure. - -- [ ] **Step 3: Implement the state machine** - -```swift -// Music/Remote/ConnectionState.swift -import Foundation - -nonisolated enum ConnectionState: Equatable, Sendable { - case disconnected - case discovering - case foundHost(String) - case downloadingDB - case connectingCommandChannel - case connected(String) - case connectionLost(String) - - var userMessage: String? { - switch self { - case .disconnected: nil - case .discovering: "Searching for hosts..." - case .foundHost(let name): "Found \(name)" - case .downloadingDB: "Downloading library..." - case .connectingCommandChannel: "Connecting..." - case .connected(let name): "Connected to \(name)" - case .connectionLost(let reason): "Connection lost — \(reason)" - } - } - - var isConnected: Bool { - if case .connected = self { return true } - return false - } - - func canTransition(to next: ConnectionState) -> Bool { - if next == .disconnected { return true } - switch (self, next) { - case (.disconnected, .discovering), - (.discovering, .foundHost), - (.foundHost, .downloadingDB), - (.downloadingDB, .connectingCommandChannel), - (.connectingCommandChannel, .connected), - (.connected, .connectionLost), - (.connectionLost, .discovering): - return true - default: - return false - } - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/ConnectionStateTests 2>&1 | tail -20` - -Expected: All 4 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Remote/ConnectionState.swift MusicTests/ConnectionStateTests.swift -git commit -m "feat(remote): add ConnectionState machine with transition validation" -``` - ---- - -### Task 4: NDJSONTransport — Shared Line-Buffered Framing - -**Files:** -- Create: `Music/Remote/NDJSONTransport.swift` -- Test: `MusicTests/NDJSONTransportTests.swift` - -This class handles the newline-delimited JSON framing over a raw TCP connection. Both `HostServer` and `RemoteClient` use it for their command channels, eliminating duplicated line-buffering logic. - -- [ ] **Step 1: Write failing tests** - -```swift -// MusicTests/NDJSONTransportTests.swift -import Testing -import Foundation -@testable import Music - -struct NDJSONTransportTests { - - // Verifies that processReceivedData correctly splits newline-delimited - // input into individual lines, handling partial lines across calls. - @Test func splitsLinesCorrectly() { - var lines: [String] = [] - let transport = NDJSONLineBuffer { lines.append($0) } - - transport.feed(Data("{\"type\":\"pause\"}\n{\"type\":\"resume\"}\n".utf8)) - #expect(lines == ["{\"type\":\"pause\"}", "{\"type\":\"resume\"}"]) - } - - // Verifies that a line split across two TCP reads is reassembled. - @Test func handlesPartialLines() { - var lines: [String] = [] - let transport = NDJSONLineBuffer { lines.append($0) } - - transport.feed(Data("{\"type\":\"pa".utf8)) - #expect(lines.isEmpty) - - transport.feed(Data("use\"}\n".utf8)) - #expect(lines == ["{\"type\":\"pause\"}"]) - } - - // Verifies empty lines are ignored. - @Test func ignoresEmptyLines() { - var lines: [String] = [] - let transport = NDJSONLineBuffer { lines.append($0) } - - transport.feed(Data("\n\n{\"type\":\"next\"}\n\n".utf8)) - #expect(lines == ["{\"type\":\"next\"}"]) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/NDJSONTransportTests 2>&1 | tail -20` - -Expected: Compilation failure. - -- [ ] **Step 3: Implement NDJSONTransport** - -```swift -// Music/Remote/NDJSONTransport.swift -import Foundation -import Network - -final class NDJSONLineBuffer: @unchecked Sendable { - private var buffer = "" - private let onLine: (String) -> Void - - init(onLine: @escaping (String) -> Void) { - self.onLine = onLine - } - - func feed(_ data: Data) { - guard let text = String(data: data, encoding: .utf8) else { return } - buffer += text - while let newlineIndex = buffer.firstIndex(of: "\n") { - let line = String(buffer[buffer.startIndex.. Void)? - var onClose: (() -> Void)? - - init(connection: NWConnection, logger: os.Logger) { - self.connection = connection - self.logger = logger - self.lineBuffer = NDJSONLineBuffer { [weak self] line in - self?.onLine?(line) - } - } - - func send(_ message: T) { - do { - var data = try JSONEncoder().encode(message) - data.append(contentsOf: "\n".utf8) - connection.send(content: data, completion: .contentProcessed { [logger] error in - if let error { - logger.error("Send error: \(error.localizedDescription)") - } - }) - } catch { - logger.error("Encode error: \(error.localizedDescription)") - } - } - - func startReceiving() { - receiveNext() - } - - func close() { - connection.cancel() - } - - private func receiveNext() { - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, isComplete, error in - Task { @MainActor in - guard let self else { return } - if let data { - self.lineBuffer.feed(data) - } - if isComplete { - self.logger.info("Connection closed by peer") - self.onClose?() - } else if let error { - self.logger.error("Receive error: \(error.localizedDescription)") - self.onClose?() - } else { - self.receiveNext() - } - } - } - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/NDJSONTransportTests 2>&1 | tail -20` - -Expected: All 3 tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Remote/NDJSONTransport.swift MusicTests/NDJSONTransportTests.swift -git commit -m "feat(remote): add NDJSONTransport for line-buffered JSON framing over TCP" -``` - ---- - -### Task 5: DatabaseService.fetchTracksByIds - -**Files:** -- Modify: `Music/Services/DatabaseService.swift` -- Modify: `MusicTests/DatabaseServiceTests.swift` - -- [ ] **Step 1: Write failing test** - -Append to `MusicTests/DatabaseServiceTests.swift`: - -```swift -// Inserts 5 tracks, fetches 3 by ID, verifies only those 3 are returned -// in the order of the requested IDs. -@Test func fetchTracksByIds() throws { - let db = try DatabaseService(inMemory: true) - var tracks = (0..<5).map { i in - Track.fixture(fileURL: "/track\(i).mp3", title: "Track \(i)") - } - for i in tracks.indices { - try db.insert(&tracks[i]) - } - - let ids: [Int64] = [tracks[2].id!, tracks[0].id!, tracks[4].id!] - let result = try db.fetchTracksByIds(ids) - - #expect(result.count == 3) - #expect(result[0].id == tracks[2].id) - #expect(result[1].id == tracks[0].id) - #expect(result[2].id == tracks[4].id) -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/DatabaseServiceTests/fetchTracksByIds 2>&1 | tail -20` - -Expected: Compilation failure — `fetchTracksByIds` not defined. - -- [ ] **Step 3: Implement the method** - -Add to `DatabaseService.swift` in the `// MARK: - Read` section: - -```swift -func fetchTracksByIds(_ ids: [Int64]) throws -> [Track] { - guard !ids.isEmpty else { return [] } - let tracks = try dbPool.read { db in - let placeholders = databaseQuestionMarks(count: ids.count) - return try Track.fetchAll( - db, - sql: "SELECT * FROM tracks WHERE id IN (\(placeholders))", - arguments: StatementArguments(ids) - ) - } - let trackMap = Dictionary(uniqueKeysWithValues: tracks.compactMap { t in t.id.map { ($0, t) } }) - return ids.compactMap { trackMap[$0] } -} -``` - -- [ ] **Step 4: Run test to verify it passes** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/DatabaseServiceTests/fetchTracksByIds 2>&1 | tail -20` - -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Services/DatabaseService.swift MusicTests/DatabaseServiceTests.swift -git commit -m "feat(remote): add DatabaseService.fetchTracksByIds for efficient ID-based lookups" -``` - ---- - -### Task 6: Refactor PlayerViewModel to Own Playback State - -This is the key architectural change. PlayerViewModel becomes the single source of truth for ALL playback state. Views will read from PlayerViewModel instead of AudioService. - -**Files:** -- Modify: `Music/Services/AudioService.swift` -- Modify: `Music/ViewModels/PlayerViewModel.swift` -- Modify: `MusicTests/PlayerViewModelTests.swift` - -- [ ] **Step 1: Add `onPlaybackStateChanged` callback to AudioService** - -In `AudioService.swift`, add a new callback alongside `onTrackFinished`: - -```swift -var onPlaybackStateChanged: (() -> Void)? -``` - -Fire it from the periodic time observer (inside the existing closure, after updating `currentTime` and `duration`): - -```swift -self.onPlaybackStateChanged?() -``` - -Also fire it at the end of `play(url:)` (after `isPlaying = true`), `pause()` (after `isPlaying = false`), `resume()` (after `isPlaying = true`), and `stop()` (after setting all state). Also fire in the `endObserver` callback (after `isPlaying = false`). - -- [ ] **Step 2: Add playback state properties to PlayerViewModel** - -In `PlayerViewModel.swift`, add new stored properties: - -```swift -var isPlaying = false -var currentTime: Double = 0 -var duration: Double = 0 -var volume: Float = 0.65 -``` - -Add a private helper and the sync callback in `init`: - -```swift -private var remoteClient: RemoteClient? -var trackResolver: ((Int64) -> Track?)? - -private var isRemote: Bool { remoteClient != nil } -``` - -At the end of `init`, add: - -```swift -audio.onPlaybackStateChanged = { [weak self] in - self?.syncFromAudio() -} -``` - -Add the sync method: - -```swift -private func syncFromAudio() { - guard !isRemote else { return } - isPlaying = audio.isPlaying - if !audio.isScrubbing { - currentTime = audio.currentTime - } - duration = audio.duration - checkHalfway() -} -``` - -- [ ] **Step 3: Route playback actions through PlayerViewModel** - -Add these methods to `PlayerViewModel`: - -```swift -func togglePlayPause() { - if isPlaying { pause() } else { resume() } -} - -func pause() { - isPlaying = false - if isRemote { remoteClient!.sendCommand(.pause) } else { audio.pause() } -} - -func resume() { - isPlaying = true - if isRemote { remoteClient!.sendCommand(.resume) } else { audio.resume() } -} - -func seek(to position: Double) { - currentTime = position - if isRemote { remoteClient!.sendCommand(.seek(position: position)) } else { audio.seek(to: position) } -} - -func setVolume(_ level: Float) { - volume = level - if isRemote { remoteClient!.sendCommand(.setVolume(level: level)) } else { audio.volume = level } -} - -func beginScrubbing() { - if !isRemote { audio.beginScrubbing() } -} - -func scrub(to position: Double) { - currentTime = position - if !isRemote { audio.scrub(to: position) } -} - -func endScrubbing(at position: Double) { - currentTime = position - if isRemote { remoteClient!.sendCommand(.seek(position: position)) } else { audio.endScrubbing(at: position) } -} - -func stop() { - isPlaying = false - currentTime = 0 - duration = 0 - currentTrack = nil - currentIndex = nil - if !isRemote { audio.stop() } -} -``` - -Update the existing `play(_ track:)` method to also update the new state properties: - -```swift -func play(_ track: Track) { - currentTrack = track - currentIndex = queue.firstIndex(where: { $0.id == track.id }) - halfwayReported = false - isPlaying = true - currentTime = 0 - - if let client = remoteClient { - guard let trackId = track.id else { return } - client.sendCommand(.play(trackId: trackId, queueIds: queue.compactMap(\.id))) - } else { - guard let url = URL(string: track.fileURL) else { return } - audio.play(url: url) - } -} -``` - -Update `next()`: - -```swift -func next() { - if isRemote { - remoteClient!.sendCommand(.next) - return - } - guard let idx = currentIndex else { return } - let nextIdx = idx + 1 - if nextIdx < queue.count { - play(queue[nextIdx]) - } else { - stop() - } -} -``` - -Update `previous()`: - -```swift -func previous() { - if isRemote { - remoteClient!.sendCommand(.previous) - return - } - guard let idx = currentIndex else { return } - let prevIdx = max(0, idx - 1) - play(queue[prevIdx]) -} -``` - -Update `toggleShuffle()`: - -```swift -func toggleShuffle() { - isShuffled.toggle() - if isRemote { - remoteClient!.sendCommand(.toggleShuffle) - return - } - if isShuffled { - queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack) - } else { - queue = originalQueue - } - if let current = currentTrack { - currentIndex = queue.firstIndex(where: { $0.id == current.id }) - } -} -``` - -Update `trackDidFinish()` to guard against remote mode: - -```swift -private func trackDidFinish() { - guard !isRemote else { return } - if let track = currentTrack, let trackId = track.id, !halfwayReported { - let newCount = track.playCount + 1 - try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) - } - next() -} -``` - -- [ ] **Step 4: Add remote mode entry/exit methods** - -```swift -func enterRemoteMode(client: RemoteClient) { - audio.stop() - remoteClient = client - currentTrack = nil - currentIndex = nil - isPlaying = false - currentTime = 0 - duration = 0 - queue = [] - originalQueue = [] -} - -func exitRemoteMode() { - remoteClient = nil - trackResolver = nil - currentTrack = nil - currentIndex = nil - isPlaying = false - currentTime = 0 - duration = 0 - queue = [] - originalQueue = [] -} - -func applyRemoteState(_ state: PlaybackStatePayload) { - guard isRemote else { return } - isPlaying = state.isPlaying - currentTime = state.currentTime - duration = state.duration - volume = state.volume - isShuffled = state.isShuffled - - if let trackId = state.trackId, currentTrack?.id != trackId { - currentTrack = trackResolver?(trackId) - } else if state.trackId == nil { - currentTrack = nil - } -} -``` - -- [ ] **Step 5: Run existing PlayerViewModel tests** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/PlayerViewModelTests 2>&1 | tail -20` - -Expected: All 6 existing tests pass — they test queue/track logic, not playback state. - -- [ ] **Step 6: Commit** - -```bash -git add Music/Services/AudioService.swift Music/ViewModels/PlayerViewModel.swift -git commit -m "refactor: make PlayerViewModel single source of truth for all playback state" -``` - ---- - -### Task 7: Update ContentView to Read from PlayerViewModel - -This removes ContentView's direct dependency on AudioService. No behavior change — just rewiring where state is read from. - -**Files:** -- Modify: `Music/ContentView.swift` -- Modify: `Music/MusicApp.swift` - -- [ ] **Step 1: Remove `audio` property from ContentView and replace all references** - -In `ContentView.swift`: - -Remove the `audio` property: - -```swift -// DELETE: var audio: AudioService -``` - -Replace `playerControls`: - -```swift -private var playerControls: some View { - PlayerControlsView( - currentTrack: player.currentTrack, - isPlaying: player.isPlaying, - currentTime: player.currentTime, - duration: player.duration, - volume: player.volume, - isShuffled: player.isShuffled, - onPlayPause: { - if player.currentTrack == nil { - let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks - if let first = trackList.first { - player.setQueue(trackList) - player.play(first) - } - } else { - player.togglePlayPause() - } - }, - onNext: { player.next() }, - onPrevious: { player.previous() }, - onSeek: { player.seek(to: $0) }, - onScrubStart: { player.beginScrubbing() }, - onScrub: { player.scrub(to: $0) }, - onScrubEnd: { player.endScrubbing(at: $0) }, - onVolumeChange: { player.setVolume($0) }, - onShuffleToggle: { player.toggleShuffle() }, - onNowPlayingTap: { scrollToPlayingTrigger = UUID() } - ) -} -``` - -Remove the `.onChange(of: audio.currentTime)` handler — `checkHalfway` is now called internally by `syncFromAudio()`: - -```swift -// DELETE: .onChange(of: audio.currentTime) { _, _ in -// player.checkHalfway() -// } -``` - -Update `installKeyboardMonitor` — remove `audio` from capture list, replace `audio.togglePlayPause()` with `player.togglePlayPause()`: - -```swift -keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [player, library, playlist] event in - guard event.modifierFlags.intersection([.command, .control, .option, .shift]).isEmpty else { - return event - } - guard let responder = NSApp.keyWindow?.firstResponder, - !(responder is NSTextView) else { - return event - } - switch event.keyCode { - case 49: // space - if player.currentTrack == nil { - let trackList = playlist.selectedItem != nil ? playlist.playlistTracks : library.tracks - if let first = trackList.first { - player.setQueue(trackList) - player.play(first) - } - } else { - player.togglePlayPause() - } - return nil - case 123: // left arrow - player.previous() - return nil - case 124: // right arrow - player.next() - return nil - default: - return event - } -} -``` - -- [ ] **Step 2: Update MusicApp ContentView call to remove `audio` parameter** - -In `MusicApp.swift`, remove the `audio: audioService` parameter from the `ContentView(...)` call. - -- [ ] **Step 3: Build and run existing tests** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E '(Executed|FAIL)'` - -Expected: All tests pass. This is a pure refactor — no behavior change. - -- [ ] **Step 4: Commit** - -```bash -git add Music/ContentView.swift Music/MusicApp.swift -git commit -m "refactor: remove AudioService from ContentView — all playback state via PlayerViewModel" -``` - ---- - -### Task 8: HostServer — Bonjour + HTTP + Command Channel - -**Files:** -- Create: `Music/Remote/HostServer.swift` -- Test: `MusicTests/HostServerIntegrationTests.swift` - -- [ ] **Step 1: Write failing integration test for DB download** - -```swift -// MusicTests/HostServerIntegrationTests.swift -import Testing -import Foundation -import Network -@testable import Music - -@MainActor -struct HostServerIntegrationTests { - - // Starts a HostServer, connects via TCP, sends GET /db, - // verifies the response is valid SQLite data. - @Test(.timeLimit(.minutes(1))) - func dbDownloadReturnsValidSQLite() async throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let dbPath = tempDir.appendingPathComponent("db.sqlite").path - let db = try DatabaseService(path: dbPath) - var track = Track.fixture(fileURL: "/test.mp3") - try db.insert(&track) - - let server = HostServer(dbPath: dbPath) - try server.start() - let port = server.actualPort! - - let responseData = try await httpGet(host: "127.0.0.1", port: port, path: "/db") - - let header = String(data: responseData.prefix(16), encoding: .utf8) ?? "" - #expect(header.hasPrefix("SQLite format 3")) - - let downloadedPath = tempDir.appendingPathComponent("downloaded.sqlite").path - try responseData.write(to: URL(fileURLWithPath: downloadedPath)) - let downloadedDb = try DatabaseService(path: downloadedPath) - #expect(try downloadedDb.trackCount() == 1) - - server.stop() - try? FileManager.default.removeItem(at: tempDir) - } - - // Connects to /cmd, sends a pause command, verifies a playbackState event comes back. - @Test(.timeLimit(.minutes(1))) - func commandChannelRoundTrip() async throws { - let tempDir = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString) - try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) - let dbPath = tempDir.appendingPathComponent("db.sqlite").path - _ = try DatabaseService(path: dbPath) - - let audio = AudioService() - let player = PlayerViewModel(audio: audio, db: nil) - let server = HostServer(dbPath: dbPath) - server.configure(player: player, db: nil) - try server.start() - let port = server.actualPort! - - // Connect and send GET /cmd to establish command channel. - let connection = try await connectCommandChannel(host: "127.0.0.1", port: port) - - // Send a pause command. - let pauseCmd = try JSONEncoder().encode(RemoteCommand.pause) - var lineData = pauseCmd - lineData.append(contentsOf: "\n".utf8) - connection.send(content: lineData, completion: .contentProcessed { _ in }) - - // Wait for a playbackState response. - let responseLine = try await receiveOneLine(on: connection) - let event = try JSONDecoder().decode(HostEvent.self, from: Data(responseLine.utf8)) - - if case .playbackState(let payload) = event { - #expect(payload.isPlaying == false) - } else { - Issue.record("Expected playbackState, got \(event)") - } - - connection.cancel() - server.stop() - try? FileManager.default.removeItem(at: tempDir) - } - - // MARK: - Helpers - - private func httpGet(host: String, port: UInt16, path: String) async throws -> Data { - try await withCheckedThrowingContinuation { continuation in - let connection = NWConnection( - host: NWEndpoint.Host(host), - port: NWEndpoint.Port(rawValue: port)!, - using: .tcp - ) - connection.stateUpdateHandler = { state in - if case .ready = state { - let request = "GET \(path) HTTP/1.1\r\nHost: \(host)\r\nConnection: close\r\n\r\n" - connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) - connection.receiveMessage { data, _, _, error in - if let error { - continuation.resume(throwing: error) - } else if let data, let range = data.range(of: Data("\r\n\r\n".utf8)) { - continuation.resume(returning: Data(data[range.upperBound...])) - } else { - continuation.resume(returning: data ?? Data()) - } - connection.cancel() - } - } else if case .failed(let error) = state { - continuation.resume(throwing: error) - } - } - connection.start(queue: .main) - } - } - - private func connectCommandChannel(host: String, port: UInt16) async throws -> NWConnection { - try await withCheckedThrowingContinuation { continuation in - let connection = NWConnection( - host: NWEndpoint.Host(host), - port: NWEndpoint.Port(rawValue: port)!, - using: .tcp - ) - connection.stateUpdateHandler = { state in - if case .ready = state { - let request = "GET /cmd HTTP/1.1\r\nHost: \(host)\r\nConnection: keep-alive\r\n\r\n" - connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) - // Read the 200 OK response. - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in - continuation.resume(returning: connection) - } - } else if case .failed(let error) = state { - continuation.resume(throwing: error) - } - } - connection.start(queue: .main) - } - } - - private func receiveOneLine(on connection: NWConnection) async throws -> String { - try await withCheckedThrowingContinuation { continuation in - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { data, _, _, error in - if let error { - continuation.resume(throwing: error) - } else { - let text = data.flatMap { String(data: $0, encoding: .utf8) } ?? "" - continuation.resume(returning: text.split(separator: "\n").first.map(String.init) ?? text) - } - } - } - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/HostServerIntegrationTests 2>&1 | tail -20` - -Expected: Compilation failure. - -- [ ] **Step 3: Implement HostServer** - -```swift -// Music/Remote/HostServer.swift -import Foundation -import Network -import Observation - -@MainActor -@Observable -final class HostServer { - var isHosting = false - var connectedRemoteName: String? - private(set) var actualPort: UInt16? - - private let dbPath: String - private var listener: NWListener? - private var commandTransport: NDJSONTransport? - private var player: PlayerViewModel? - private var db: DatabaseService? - private var stateTimer: Timer? - - init(dbPath: String) { - self.dbPath = dbPath - } - - func configure(player: PlayerViewModel, db: DatabaseService?) { - self.player = player - self.db = db - } - - func start() throws { - let params = NWParameters.tcp - params.includePeerToPeer = true - let listener = try NWListener(using: params) - listener.service = NWListener.Service(name: Host.current().localizedName, type: "_musicremote._tcp") - - listener.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - switch state { - case .ready: - if let port = listener.port?.rawValue { - self?.actualPort = port - RemoteLogger.host.info("Host started on port \(port)") - } - case .failed(let error): - RemoteLogger.host.error("Listener failed: \(error.localizedDescription)") - self?.stop() - default: break - } - } - } - - listener.newConnectionHandler = { [weak self] connection in - Task { @MainActor in self?.handleNewConnection(connection) } - } - - listener.start(queue: .main) - self.listener = listener - isHosting = true - } - - func stop() { - stateTimer?.invalidate() - stateTimer = nil - commandTransport?.close() - commandTransport = nil - connectedRemoteName = nil - listener?.cancel() - listener = nil - actualPort = nil - isHosting = false - RemoteLogger.host.info("Host stopped") - } - - // MARK: - Connection Handling - - private func handleNewConnection(_ connection: NWConnection) { - connection.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - switch state { - case .ready: - self?.receiveInitialRequest(on: connection) - case .failed(let error): - RemoteLogger.host.error("Connection failed: \(error.localizedDescription)") - connection.cancel() - case .cancelled: - if self?.commandTransport != nil { - self?.commandTransport = nil - self?.connectedRemoteName = nil - self?.stateTimer?.invalidate() - self?.stateTimer = nil - } - default: break - } - } - } - connection.start(queue: .main) - } - - private func receiveInitialRequest(on connection: NWConnection) { - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { [weak self] data, _, _, error in - Task { @MainActor in - guard let self, let data, let request = String(data: data, encoding: .utf8) else { - if let error { RemoteLogger.host.error("Receive error: \(error.localizedDescription)") } - connection.cancel() - return - } - RemoteLogger.host.debug("Request: \(request.prefix(80))") - - if request.hasPrefix("GET /db") { - self.serveDatabase(on: connection) - } else if request.hasPrefix("GET /cmd") { - self.setupCommandChannel(on: connection) - } else { - self.sendHTTP(status: "404 Not Found", body: nil, on: connection, close: true) - } - } - } - } - - // MARK: - HTTP DB Download - - private func serveDatabase(on connection: NWConnection) { - do { - let data = try Data(contentsOf: URL(fileURLWithPath: dbPath)) - RemoteLogger.host.info("Serving database: \(data.count) bytes") - sendHTTP(status: "200 OK", body: data, contentType: "application/octet-stream", on: connection, close: true) - } catch { - RemoteLogger.host.error("Failed to read database: \(error.localizedDescription)") - sendHTTP(status: "500 Internal Server Error", body: nil, on: connection, close: true) - } - } - - private func sendHTTP(status: String, body: Data?, contentType: String = "text/plain", on connection: NWConnection, close: Bool) { - let bodyLen = body?.count ?? 0 - let header = "HTTP/1.1 \(status)\r\nContent-Type: \(contentType)\r\nContent-Length: \(bodyLen)\r\nConnection: \(close ? "close" : "keep-alive")\r\n\r\n" - var response = Data(header.utf8) - if let body { response.append(body) } - connection.send(content: response, completion: .contentProcessed { _ in - if close { connection.cancel() } - }) - } - - // MARK: - Command Channel - - private func setupCommandChannel(on connection: NWConnection) { - if commandTransport != nil { - RemoteLogger.host.info("Rejecting second remote — already connected") - sendHTTP(status: "409 Conflict", body: nil, on: connection, close: true) - return - } - - let response = "HTTP/1.1 200 OK\r\nContent-Type: application/x-ndjson\r\nConnection: keep-alive\r\n\r\n" - connection.send(content: Data(response.utf8), completion: .contentProcessed { [weak self] _ in - Task { @MainActor in - guard let self else { return } - let transport = NDJSONTransport(connection: connection, logger: RemoteLogger.host) - transport.onLine = { [weak self] line in self?.handleCommandLine(line) } - transport.onClose = { [weak self] in - Task { @MainActor in - RemoteLogger.host.info("Remote disconnected") - self?.commandTransport = nil - self?.connectedRemoteName = nil - self?.stateTimer?.invalidate() - self?.stateTimer = nil - } - } - transport.startReceiving() - - self.commandTransport = transport - self.connectedRemoteName = "Remote" - self.startStateUpdates() - RemoteLogger.host.info("Command channel established") - } - }) - } - - private func handleCommandLine(_ line: String) { - guard let data = line.data(using: .utf8) else { return } - - if let handshake = try? JSONDecoder().decode(HandshakeMessage.self, from: data) { - RemoteLogger.host.info("Handshake: protocol v\(handshake.protocolVersion), app v\(handshake.appVersion)") - if handshake.protocolVersion != RemoteProtocolVersion { - RemoteLogger.host.error("Protocol mismatch: host v\(RemoteProtocolVersion), remote v\(handshake.protocolVersion)") - commandTransport?.send(HostEvent.error(message: "Protocol version mismatch")) - } - connectedRemoteName = handshake.appVersion - return - } - - do { - let command = try JSONDecoder().decode(RemoteCommand.self, from: data) - RemoteLogger.host.debug("Command: \(line.prefix(80))") - executeCommand(command) - } catch { - RemoteLogger.host.error("Decode failed: \(error.localizedDescription)") - } - } - - private func executeCommand(_ command: RemoteCommand) { - guard let player else { return } - - switch command { - case .play(let trackId, let queueIds): - guard let db else { - commandTransport?.send(HostEvent.error(message: "No database available")) - return - } - do { - let tracks = try db.fetchTracksByIds(queueIds) - guard let track = tracks.first(where: { $0.id == trackId }) else { - commandTransport?.send(HostEvent.error(message: "Track not found")) - return - } - player.setQueue(tracks) - player.play(track) - } catch { - RemoteLogger.host.error("Failed to resolve tracks: \(error.localizedDescription)") - commandTransport?.send(HostEvent.error(message: "Failed to load tracks")) - } - case .pause: player.pause() - case .resume: player.resume() - case .next: player.next() - case .previous: player.previous() - case .seek(let position): player.seek(to: position) - case .setVolume(let level): player.setVolume(level) - case .toggleShuffle: player.toggleShuffle() - case .refreshDB: - RemoteLogger.host.info("DB refresh requested") - commandTransport?.send(HostEvent.dbReady) - } - - sendCurrentState() - } - - private func sendCurrentState() { - guard let player, let transport = commandTransport else { return } - transport.send(HostEvent.playbackState(PlaybackStatePayload( - trackId: player.currentTrack?.id, - isPlaying: player.isPlaying, - currentTime: player.currentTime, - duration: player.duration, - volume: player.volume, - isShuffled: player.isShuffled - ))) - } - - private func startStateUpdates() { - stateTimer?.invalidate() - stateTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in - Task { @MainActor in self?.sendCurrentState() } - } - } -} -``` - -- [ ] **Step 4: Run integration tests** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/HostServerIntegrationTests 2>&1 | tail -20` - -Expected: Both tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Remote/HostServer.swift MusicTests/HostServerIntegrationTests.swift -git commit -m "feat(remote): add HostServer with Bonjour, HTTP DB download, and NDJSON command channel" -``` - ---- - -### Task 9: RemoteClient — Discovery, DB Download, Command Channel - -**Files:** -- Create: `Music/Remote/RemoteClient.swift` - -- [ ] **Step 1: Implement RemoteClient** - -```swift -// Music/Remote/RemoteClient.swift -import Foundation -import Network -import Observation - -@MainActor -@Observable -final class RemoteClient { - var connectionState = ConnectionState.disconnected - var discoveredHosts: [(name: String, endpoint: NWEndpoint)] = [] - var onPlaybackState: ((PlaybackStatePayload) -> Void)? - var onDBReady: (() -> Void)? - - private var browser: NWBrowser? - private var commandTransport: NDJSONTransport? - private var hostEndpoint: NWEndpoint? - private var pingTimer: Timer? - private var missedPings = 0 - - // MARK: - Discovery - - func startDiscovery() { - transition(to: .discovering) - discoveredHosts = [] - - let params = NWParameters() - params.includePeerToPeer = true - let browser = NWBrowser(for: .bonjour(type: "_musicremote._tcp", domain: nil), using: params) - - browser.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - if case .failed(let error) = state { - RemoteLogger.client.error("Browser failed: \(error.localizedDescription)") - self?.transition(to: .disconnected) - } - } - } - - browser.browseResultsChangedHandler = { [weak self] results, _ in - Task { @MainActor in - self?.discoveredHosts = results.compactMap { result in - if case .service(let name, _, _, _) = result.endpoint { - return (name: name, endpoint: result.endpoint) - } - return nil - } - RemoteLogger.client.info("Discovered \(results.count) host(s)") - } - } - - browser.start(queue: .main) - self.browser = browser - } - - func stopDiscovery() { - browser?.cancel() - browser = nil - discoveredHosts = [] - if case .discovering = connectionState { transition(to: .disconnected) } - } - - // MARK: - Connect / Disconnect - - func connect(to host: (name: String, endpoint: NWEndpoint)) { - transition(to: .foundHost(host.name)) - browser?.cancel() - browser = nil - - let connection = NWConnection(to: host.endpoint, using: .tcp) - connection.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - switch state { - case .ready: - self?.hostEndpoint = host.endpoint - self?.downloadDatabase(on: connection, hostName: host.name) - case .failed(let error): - RemoteLogger.client.error("Connection failed: \(error.localizedDescription)") - self?.transition(to: .connectionLost(error.localizedDescription)) - default: break - } - } - } - connection.start(queue: .main) - } - - func disconnect() { - pingTimer?.invalidate() - pingTimer = nil - commandTransport?.close() - commandTransport = nil - browser?.cancel() - browser = nil - discoveredHosts = [] - transition(to: .disconnected) - RemoteLogger.client.info("Disconnected") - try? FileManager.default.removeItem(atPath: Self.remoteDBPath) - } - - func sendCommand(_ command: RemoteCommand) { - guard let transport = commandTransport else { - RemoteLogger.client.error("No command channel — cannot send") - return - } - transport.send(command) - RemoteLogger.client.debug("Sent: \(String(describing: command))") - } - - // MARK: - DB Download - - static var remoteDBPath: String { - let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask - ).first!.appendingPathComponent("Music", isDirectory: true) - return appSupport.appendingPathComponent("remote_db.sqlite").path - } - - private func downloadDatabase(on connection: NWConnection, hostName: String) { - transition(to: .downloadingDB) - let startTime = Date() - let request = "GET /db HTTP/1.1\r\nHost: local\r\nConnection: close\r\n\r\n" - - connection.send(content: Data(request.utf8), completion: .contentProcessed { [weak self] error in - if let error { - Task { @MainActor in - RemoteLogger.client.error("DB request failed: \(error.localizedDescription)") - self?.transition(to: .connectionLost(error.localizedDescription)) - } - return - } - connection.receiveMessage { [weak self] data, _, _, error in - Task { @MainActor in self?.handleDBResponse(data: data, error: error, startTime: startTime, hostName: hostName) } - } - }) - } - - private func handleDBResponse(data: Data?, error: NWError?, startTime: Date, hostName: String) { - if let error { - RemoteLogger.client.error("DB download error: \(error.localizedDescription)") - transition(to: .connectionLost(error.localizedDescription)) - return - } - guard var data else { - RemoteLogger.client.error("DB download returned no data") - transition(to: .connectionLost("No data received")) - return - } - if let range = data.range(of: Data("\r\n\r\n".utf8)) { - data = Data(data[range.upperBound...]) - } - - let elapsed = Date().timeIntervalSince(startTime) - RemoteLogger.client.info("DB downloaded (\(String(format: "%.1f", Double(data.count) / 1024)) KB, \(String(format: "%.0f", elapsed * 1000))ms)") - - do { - let url = URL(fileURLWithPath: Self.remoteDBPath) - try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true) - try data.write(to: url) - } catch { - RemoteLogger.client.error("Failed to save remote DB: \(error.localizedDescription)") - transition(to: .connectionLost("Failed to save database")) - return - } - - connectCommandChannel(hostName: hostName) - } - - // MARK: - Command Channel - - private func connectCommandChannel(hostName: String) { - transition(to: .connectingCommandChannel) - guard let endpoint = hostEndpoint else { - transition(to: .connectionLost("No host endpoint")) - return - } - - let connection = NWConnection(to: endpoint, using: .tcp) - connection.stateUpdateHandler = { [weak self] state in - Task { @MainActor in - switch state { - case .ready: - let request = "GET /cmd HTTP/1.1\r\nHost: local\r\nConnection: keep-alive\r\n\r\n" - connection.send(content: Data(request.utf8), completion: .contentProcessed { _ in }) - connection.receive(minimumIncompleteLength: 1, maximumLength: 65536) { _, _, _, _ in - Task { @MainActor in self?.commandChannelReady(connection: connection, hostName: hostName) } - } - case .failed(let error): - RemoteLogger.client.error("Command channel failed: \(error.localizedDescription)") - self?.transition(to: .connectionLost(error.localizedDescription)) - default: break - } - } - } - connection.start(queue: .main) - } - - private func commandChannelReady(connection: NWConnection, hostName: String) { - let transport = NDJSONTransport(connection: connection, logger: RemoteLogger.client) - transport.onLine = { [weak self] line in self?.handleEventLine(line) } - transport.onClose = { [weak self] in - Task { @MainActor in - self?.commandTransport = nil - self?.transition(to: .connectionLost("Host closed connection")) - self?.pingTimer?.invalidate() - self?.pingTimer = nil - } - } - transport.startReceiving() - - self.commandTransport = transport - transition(to: .connected(hostName)) - - let handshake = HandshakeMessage( - protocolVersion: RemoteProtocolVersion, - appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" - ) - transport.send(handshake) - startPingTimer() - } - - private func handleEventLine(_ line: String) { - guard !line.isEmpty, let data = line.data(using: .utf8) else { return } - do { - let event = try JSONDecoder().decode(HostEvent.self, from: data) - switch event { - case .playbackState(let payload): - missedPings = 0 - onPlaybackState?(payload) - case .dbReady: - RemoteLogger.client.info("Host signals DB ready for re-download") - onDBReady?() - case .error(let message): - RemoteLogger.client.error("Host error: \(message)") - } - } catch { - RemoteLogger.client.error("Decode failed: \(error.localizedDescription)") - } - } - - // MARK: - Keep-alive - - private func startPingTimer() { - missedPings = 0 - pingTimer?.invalidate() - pingTimer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: true) { [weak self] _ in - Task { @MainActor in - guard let self else { return } - self.missedPings += 1 - if self.missedPings >= 3 { - RemoteLogger.client.error("No response from host for 15s — connection lost") - self.transition(to: .connectionLost("Host not responding")) - self.commandTransport?.close() - self.commandTransport = nil - self.pingTimer?.invalidate() - self.pingTimer = nil - } - } - } - } - - // MARK: - State - - private func transition(to newState: ConnectionState) { - let oldState = connectionState - if oldState == newState { return } - guard oldState.canTransition(to: newState) else { - RemoteLogger.client.error("Invalid transition: \(String(describing: oldState)) → \(String(describing: newState))") - return - } - connectionState = newState - RemoteLogger.client.info("State: \(newState.userMessage ?? "disconnected")") - } -} -``` - -- [ ] **Step 2: Build and verify** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10` - -Expected: Build succeeds. - -- [ ] **Step 3: Commit** - -```bash -git add Music/Remote/RemoteClient.swift -git commit -m "feat(remote): add RemoteClient with Bonjour discovery, DB download, NDJSON command channel" -``` - ---- - -### Task 10: NetworkStatus Model - -A small value type that encapsulates what ContentView needs to know about host/remote mode. Keeps networking details out of the View. - -**Files:** -- Create: `Music/Remote/NetworkStatus.swift` - -- [ ] **Step 1: Implement NetworkStatus** - -```swift -// Music/Remote/NetworkStatus.swift -import Foundation - -struct NetworkStatus { - enum Mode { - case hosting(connectedRemote: String?) - case remote(hostName: String) - } - - var mode: Mode - var onDisconnect: (() -> Void)? - var onRefreshLibrary: (() -> Void)? - - var isRemoteMode: Bool { - if case .remote = mode { return true } - return false - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add Music/Remote/NetworkStatus.swift -git commit -m "feat(remote): add NetworkStatus model for View-layer network state" -``` - ---- - -### Task 11: Wire Host/Remote into MusicApp - -**Files:** -- Modify: `Music/MusicApp.swift` - -- [ ] **Step 1: Add state properties** - -Add to `MusicApp`: - -```swift -@State private var hostServer: HostServer? -@State private var remoteClient = RemoteClient() -@State private var showConnectionSheet = false -``` - -- [ ] **Step 2: Add menu items** - -Replace the `.commands { ... }` block: - -```swift -.commands { - CommandGroup(after: .newItem) { - Button("Open Music Folder...") { - pickFolder() - } - .keyboardShortcut("o") - .disabled(remoteClient.connectionState.isConnected) - - Button("New Playlist...") { - showNewPlaylistAlert = true - } - .keyboardShortcut("n") - .disabled(remoteClient.connectionState.isConnected) - - Divider() - - Toggle("Enable Host Mode", isOn: Binding( - get: { hostServer?.isHosting ?? false }, - set: { $0 ? startHosting() : hostServer?.stop() } - )) - .disabled(remoteClient.connectionState.isConnected) - - Button("Connect to Remote...") { - showConnectionSheet = true - remoteClient.startDiscovery() - } - .disabled(hostServer?.isHosting ?? false) - } -} -``` - -- [ ] **Step 3: Add onChange for remote connection state and DB swap** - -Add to the `Group` in `body`, after `.frame(...)`: - -```swift -.onChange(of: remoteClient.connectionState) { _, newState in - if case .connected = newState { - enterRemoteMode() - } else if newState == .disconnected { - exitRemoteMode() - } -} -.sheet(isPresented: $showConnectionSheet) { - ConnectionSheet(remoteClient: remoteClient, isPresented: $showConnectionSheet) -} -``` - -- [ ] **Step 4: Add remote mode enter/exit methods** - -```swift -private func enterRemoteMode() { - guard let player = playerVM else { return } - do { - let remoteDb = try DatabaseService(path: RemoteClient.remoteDBPath) - self.libraryVM = LibraryViewModel(db: remoteDb) - self.playlistVM = PlaylistViewModel(db: remoteDb) - - player.enterRemoteMode(client: remoteClient) - player.trackResolver = { [weak self] trackId in - self?.libraryVM?.tracks.first(where: { $0.id == trackId }) - } - - remoteClient.onPlaybackState = { [weak player] state in - player?.applyRemoteState(state) - } - } catch { - print("Failed to load remote DB: \(error)") - remoteClient.disconnect() - } -} - -private func exitRemoteMode() { - playerVM?.exitRemoteMode() - remoteClient.onPlaybackState = nil - guard let db = dbService else { return } - self.libraryVM = LibraryViewModel(db: db) - self.playlistVM = PlaylistViewModel(db: db) - try? FileManager.default.removeItem(atPath: RemoteClient.remoteDBPath) -} -``` - -- [ ] **Step 5: Add startHosting method** - -```swift -private func startHosting() { - guard let db = dbService, let player = playerVM else { return } - let appSupport = FileManager.default.urls( - for: .applicationSupportDirectory, in: .userDomainMask - ).first!.appendingPathComponent("Music", isDirectory: true) - let dbPath = appSupport.appendingPathComponent("db.sqlite").path - - let server = HostServer(dbPath: dbPath) - server.configure(player: player, db: db) - do { - try server.start() - hostServer = server - } catch { - print("Failed to start host: \(error)") - } -} -``` - -- [ ] **Step 6: Update ContentView call to pass networkStatus** - -Compute `networkStatus` and pass it: - -```swift -ContentView( - library: library, - player: player, - scanner: scanner, - playlist: playlist, - shazam: shazamService, - db: db, - showNewPlaylistAlert: $showNewPlaylistAlert, - networkStatus: computeNetworkStatus() -) -``` - -```swift -private func computeNetworkStatus() -> NetworkStatus? { - if remoteClient.connectionState.isConnected { - let hostName: String - if case .connected(let name) = remoteClient.connectionState { hostName = name } else { hostName = "Unknown" } - return NetworkStatus( - mode: .remote(hostName: hostName), - onDisconnect: { [remoteClient] in remoteClient.disconnect() }, - onRefreshLibrary: { [remoteClient] in remoteClient.sendCommand(.refreshDB) } - ) - } - if let server = hostServer, server.isHosting { - return NetworkStatus(mode: .hosting(connectedRemote: server.connectedRemoteName)) - } - return nil -} -``` - -- [ ] **Step 7: Build and verify** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10` - -Expected: Build succeeds. - -- [ ] **Step 8: Commit** - -```bash -git add Music/MusicApp.swift -git commit -m "feat(remote): wire HostServer and RemoteClient into MusicApp with menu items and DB swap" -``` - ---- - -### Task 12: ConnectionSheet UI - -**Files:** -- Create: `Music/Views/ConnectionSheet.swift` - -- [ ] **Step 1: Implement ConnectionSheet** - -```swift -// Music/Views/ConnectionSheet.swift -import SwiftUI - -struct ConnectionSheet: View { - var remoteClient: RemoteClient - @Binding var isPresented: Bool - - var body: some View { - VStack(spacing: 16) { - Text("Connect to Host") - .font(.headline) - - if let message = remoteClient.connectionState.userMessage { - HStack(spacing: 8) { - if !remoteClient.connectionState.isConnected && - remoteClient.connectionState != .disconnected { - ProgressView().controlSize(.small) - } - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - - if case .connectionLost(let reason) = remoteClient.connectionState { - VStack(spacing: 8) { - Text(reason).font(.caption).foregroundStyle(.red) - Button("Retry") { remoteClient.startDiscovery() } - } - } - - if remoteClient.discoveredHosts.isEmpty && remoteClient.connectionState == .discovering { - VStack(spacing: 8) { - ProgressView() - Text("Looking for hosts on your network...") - .font(.caption).foregroundStyle(.secondary) - } - .frame(height: 100) - } else { - List(remoteClient.discoveredHosts, id: \.name) { host in - HStack { - Image(systemName: "desktopcomputer").foregroundStyle(.secondary) - Text(host.name) - Spacer() - Button("Connect") { remoteClient.connect(to: host) } - .buttonStyle(.borderedProminent).controlSize(.small) - } - .padding(.vertical, 4) - } - .frame(minHeight: 100, maxHeight: 200) - } - - Button("Cancel") { - remoteClient.stopDiscovery() - isPresented = false - } - .keyboardShortcut(.cancelAction) - } - .padding(20) - .frame(width: 380) - .onChange(of: remoteClient.connectionState) { _, newState in - if case .connected = newState { isPresented = false } - } - } -} -``` - -- [ ] **Step 2: Commit** - -```bash -git add Music/Views/ConnectionSheet.swift -git commit -m "feat(remote): add ConnectionSheet for discovering and connecting to hosts" -``` - ---- - -### Task 13: ContentView — Banner, Disable Writes, Host Indicator - -**Files:** -- Modify: `Music/ContentView.swift` -- Modify: `Music/Views/PlaylistBarView.swift` - -- [ ] **Step 1: Add networkStatus property to ContentView** - -Replace the `audio` property with: - -```swift -var networkStatus: NetworkStatus? -``` - -- [ ] **Step 2: Add network status banners** - -Insert right after the opening `VStack(spacing: 0) {` in `body`: - -```swift -if let status = networkStatus { - switch status.mode { - case .remote(let hostName): - HStack(spacing: 8) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 10)).foregroundStyle(.blue) - Text("Connected to \(hostName)") - .font(.system(size: 11, weight: .medium)).foregroundStyle(.blue) - Spacer() - Button("Refresh") { status.onRefreshLibrary?() } - .font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.secondary) - Button("Disconnect") { status.onDisconnect?() } - .font(.system(size: 11)).buttonStyle(.plain).foregroundStyle(.red) - } - .padding(.horizontal, 12).padding(.vertical, 4) - .background(Color.blue.opacity(0.08)) - case .hosting(let remoteName): - HStack(spacing: 8) { - Image(systemName: "antenna.radiowaves.left.and.right") - .font(.system(size: 10)).foregroundStyle(.green) - Text(remoteName != nil ? "Hosting · \(remoteName!) connected" : "Hosting") - .font(.system(size: 11, weight: .medium)).foregroundStyle(.green) - Spacer() - } - .padding(.horizontal, 12).padding(.vertical, 4) - .background(Color.green.opacity(0.08)) - } -} -``` - -- [ ] **Step 3: Disable playlist context menus in remote mode** - -In `PlaylistBarView.swift`, add property: - -```swift -var isRemoteMode: Bool = false -``` - -Wrap the context menu content: - -```swift -.contextMenu { - if !isRemoteMode { - Button("Rename...") { onRename(item) } - if let smart = item as? SmartPlaylist { - Button("Edit Search Query...") { onEditQuery(smart) } - } - Button("Delete") { onDelete(item) } - } -} -``` - -- [ ] **Step 4: Pass isRemoteMode to PlaylistBarView** - -In `ContentView.swift`, update the `PlaylistBarView(...)` call: - -```swift -PlaylistBarView( - playlists: playlist.allPlaylists, - selectedItem: showHome ? nil : playlist.selectedItem, - isHomeSelected: showHome, - isRemoteMode: networkStatus?.isRemoteMode ?? false, - // ... rest unchanged -``` - -- [ ] **Step 5: Build and verify** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -10` - -Expected: Build succeeds. - -- [ ] **Step 6: Commit** - -```bash -git add Music/ContentView.swift Music/Views/PlaylistBarView.swift -git commit -m "feat(remote): add network status banners, disable write actions in remote mode" -``` - ---- - -### Task 14: Run All Tests - -**Files:** None — verification only. - -- [ ] **Step 1: Run the full test suite** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E '(Test Suite|Test Case|Executed|FAIL)'` - -Expected: All tests pass — existing tests unchanged, new tests (RemoteProtocol, ConnectionState, NDJSONTransport, HostServerIntegration) pass. - -- [ ] **Step 2: Fix any failures and commit** - ---- - -### Task 15: Manual Testing - -- [ ] **Step 1: Test Host Mode** - -1. Launch app → File → "Enable Host Mode" → verify toggle works -2. Open Console.app, filter "com.music.remote" → verify "Host started on port XXXX" -3. Verify green "Hosting" banner appears -4. Toggle off → verify "Host stopped" and banner disappears - -- [ ] **Step 2: Test Remote Connection** - -1. On second machine, launch app → File → "Connect to Remote..." -2. Verify host appears in list → click "Connect" -3. Verify DB downloads, blue "Connected to [name]" banner appears -4. Verify library shows host's tracks - -- [ ] **Step 3: Test Remote Playback** - -1. Double-click a track → audio plays on host, not remote -2. Play/pause, next, previous, seek, volume, shuffle all work -3. Now-playing indicator updates on remote - -- [ ] **Step 4: Test Error Handling** - -1. Click "Disconnect" → returns to local mode cleanly -2. Kill host app mid-playback → "Connection lost" appears on remote -3. Check Console.app for clear diagnostic logs at every step diff --git a/docs/superpowers/plans/2026-05-27-music-streaming.md b/docs/superpowers/plans/2026-05-27-music-streaming.md deleted file mode 100644 index 9154951..0000000 --- a/docs/superpowers/plans/2026-05-27-music-streaming.md +++ /dev/null @@ -1,3010 +0,0 @@ -# Music Streaming Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add internet-based HLS music streaming so a client app can browse the host's library and play audio remotely over HTTPS, exposed through a Cloudflare Tunnel. - -**Architecture:** A `PlaybackProvider` protocol abstracts local/remote/streaming playback so `PlayerViewModel` is role-agnostic. An `AppRole` enum (`local`, `remoteHost`, `remoteClient`, `streamHost`, `streamClient`) drives which capabilities are active. A `MusicShared` local Swift package holds wire protocol types, HLS manifest generation, and constants for future iOS reuse. The host runs a Hummingbird HTTP server serving HLS-segmented MP3s; the client uses `AVPlayer` with HLS URLs. - -**Tech Stack:** Swift, SwiftUI, Hummingbird 2.x (HTTP server), HummingbirdWebSocket (WebSocket), AVFoundation (HLS playback + segment extraction), Cloudflare Tunnel, URLSession - ---- - -## File Structure - -### MusicShared/ (new local Swift package at repo root) - -``` -MusicShared/ -├── Package.swift -├── Sources/MusicShared/ -│ ├── RemoteProtocol.swift ← moved from Music/Remote/RemoteProtocol.swift -│ ├── AppRole.swift ← new: role enum -│ ├── HLSManifestGenerator.swift ← new: .m3u8 generation (pure logic) -│ ├── StreamingRoutes.swift ← new: route path constants -│ ├── StreamingConstants.swift ← new: segment duration, port, etc. -│ └── APIModels.swift ← new: AuthResponse, DBMetadata DTOs -└── Tests/MusicSharedTests/ - ├── RemoteProtocolTests.swift ← moved from MusicTests/RemoteProtocolTests.swift - └── HLSManifestGeneratorTests.swift ← new -``` - -### Music/ (app target — new files) - -``` -Music/ -├── Protocols/ -│ ├── PlaylistRepresentable.swift (existing, unchanged) -│ └── PlaybackProvider.swift ← new: playback abstraction protocol -├── Providers/ -│ ├── LocalPlaybackProvider.swift ← new: wraps AudioService -│ ├── RemotePlaybackProvider.swift ← new: sends commands over NDJSON (extracted from PlayerViewModel) -│ └── StreamingPlaybackProvider.swift ← new: AVPlayer + HLS URLs -├── Streaming/ -│ ├── StreamingServer.swift ← new: Hummingbird HTTP + WebSocket server -│ ├── HLSSegmenter.swift ← new: AVAssetReader segment extraction -│ ├── TunnelManager.swift ← new: cloudflared process management -│ └── StreamingClient.swift ← new: HTTP client + WebSocket for streaming mode -``` - -### Music/ (app target — modified files) - -``` -Music/ -├── MusicApp.swift ← modified: role switching, streaming server/client wiring -├── ViewModels/ -│ └── PlayerViewModel.swift ← modified: use PlaybackProvider instead of direct AudioService -├── Remote/ -│ ├── RemoteProtocol.swift ← deleted (moved to MusicShared) -│ ├── NetworkStatus.swift ← modified: add streaming modes -│ └── HostServer.swift ← modified: import MusicShared -│ └── RemoteClient.swift ← modified: import MusicShared -├── ContentView.swift ← modified: streaming UI indicators -``` - -### MusicTests/ (test target — modified) - -``` -MusicTests/ -├── RemoteProtocolTests.swift ← deleted (moved to MusicSharedTests) -├── PlayerViewModelTests.swift ← modified: use PlaybackProvider -├── HLSSegmenterTests.swift ← new -├── StreamingServerTests.swift ← new -├── TunnelManagerTests.swift ← new -``` - ---- - -## Task 1: Create MusicShared Package and Move RemoteProtocol - -**Files:** -- Create: `MusicShared/Package.swift` -- Move: `Music/Remote/RemoteProtocol.swift` → `MusicShared/Sources/MusicShared/RemoteProtocol.swift` -- Move: `MusicTests/RemoteProtocolTests.swift` → `MusicShared/Tests/MusicSharedTests/RemoteProtocolTests.swift` -- Modify: `Music/Remote/HostServer.swift` (add `import MusicShared`) -- Modify: `Music/Remote/RemoteClient.swift` (add `import MusicShared`) -- Modify: `Music/ViewModels/PlayerViewModel.swift` (add `import MusicShared`) -- Modify: `Music/Remote/NDJSONTransport.swift` (add `import MusicShared` if it references protocol types) -- Modify: `Music.xcodeproj/project.pbxproj` (remove old file refs, add package dependency) - -- [ ] **Step 1: Create the MusicShared package directory and Package.swift** - -```bash -mkdir -p MusicShared/Sources/MusicShared -mkdir -p MusicShared/Tests/MusicSharedTests -``` - -Create `MusicShared/Package.swift`: - -```swift -// swift-tools-version: 5.10 -import PackageDescription - -let package = Package( - name: "MusicShared", - platforms: [.macOS(.v14), .iOS(.v17)], - products: [ - .library(name: "MusicShared", targets: ["MusicShared"]), - ], - targets: [ - .target(name: "MusicShared"), - .testTarget(name: "MusicSharedTests", dependencies: ["MusicShared"]), - ] -) -``` - -- [ ] **Step 2: Move RemoteProtocol.swift to MusicShared** - -```bash -cp Music/Remote/RemoteProtocol.swift MusicShared/Sources/MusicShared/RemoteProtocol.swift -``` - -Edit the copied file: add `public` access to all types, properties, initializers, and the `RemoteProtocolVersion` constant. Every type and member that is used outside the package must be `public`. - -`MusicShared/Sources/MusicShared/RemoteProtocol.swift`: - -```swift -import Foundation - -// MARK: - Protocol Version - -public nonisolated let RemoteProtocolVersion: Int = 1 - -// MARK: - Supporting Types - -public nonisolated struct PlaybackStatePayload: Codable, Equatable, Sendable { - public var trackId: Int64? - public var isPlaying: Bool - public var currentTime: Double - public var duration: Double - public var volume: Float - public var isShuffled: Bool - - public init(trackId: Int64? = nil, isPlaying: Bool, currentTime: Double, duration: Double, volume: Float, isShuffled: Bool) { - self.trackId = trackId - self.isPlaying = isPlaying - self.currentTime = currentTime - self.duration = duration - self.volume = volume - self.isShuffled = isShuffled - } -} - -public nonisolated struct HandshakeMessage: Codable, Equatable, Sendable { - public var protocolVersion: Int - public var appVersion: String - - public init(protocolVersion: Int, appVersion: String) { - self.protocolVersion = protocolVersion - self.appVersion = appVersion - } -} - -// MARK: - RemoteCommand - -public 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 - } - - private struct PlayPayload: Codable { - var trackId: Int64 - var queueIds: [Int64] - } - - private struct SeekPayload: Codable { - var position: Double - } - - private struct VolumePayload: Codable { - var level: Float - } - - public 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) - } - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(TypeKey.self, forKey: .type) - switch type { - 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 - -public 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 - } - - public 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) - } - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(TypeKey.self, forKey: .type) - switch type { - 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) - } - } -} -``` - -- [ ] **Step 3: Move RemoteProtocolTests.swift to MusicSharedTests** - -```bash -cp MusicTests/RemoteProtocolTests.swift MusicShared/Tests/MusicSharedTests/RemoteProtocolTests.swift -``` - -Edit the copied test file: change `@testable import Music` to `@testable import MusicShared`. Remove the `@MainActor` annotation if present (MusicShared types are `nonisolated`). - -- [ ] **Step 4: Run MusicShared tests to verify the move** - -```bash -cd MusicShared && swift test -``` - -Expected: all RemoteProtocol tests pass. - -- [ ] **Step 5: Integrate MusicShared into the Xcode project** - -In Xcode: -1. Drag the `MusicShared/` folder into the project navigator (at the root level). -2. Xcode detects it as a local Swift package automatically. -3. Select the Music app target → General → "Frameworks, Libraries, and Embedded Content" → click `+` → select `MusicShared` library. -4. Delete the original `Music/Remote/RemoteProtocol.swift` from the project (Move to Trash). -5. Delete `MusicTests/RemoteProtocolTests.swift` from the project (Move to Trash). - -- [ ] **Step 6: Add `import MusicShared` to all files that reference protocol types** - -Add `import MusicShared` to the top of these files: -- `Music/Remote/HostServer.swift` -- `Music/Remote/RemoteClient.swift` -- `Music/Remote/NDJSONTransport.swift` -- `Music/ViewModels/PlayerViewModel.swift` - -- [ ] **Step 7: Build the Xcode project to verify everything compiles** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED - -- [ ] **Step 8: Commit** - -```bash -git add MusicShared/ Music/Remote/ Music/ViewModels/PlayerViewModel.swift MusicTests/ Music.xcodeproj -git commit -m "refactor: extract MusicShared package, move RemoteProtocol" -``` - ---- - -## Task 2: Add AppRole Enum to MusicShared - -**Files:** -- Create: `MusicShared/Sources/MusicShared/AppRole.swift` - -- [ ] **Step 1: Create AppRole.swift** - -`MusicShared/Sources/MusicShared/AppRole.swift`: - -```swift -import Foundation - -public enum AppRole: String, Codable, CaseIterable, Sendable { - case local - case remoteHost - case remoteClient - case streamHost - case streamClient -} - -extension AppRole { - public var isHost: Bool { self == .remoteHost || self == .streamHost } - public var isClient: Bool { self == .remoteClient || self == .streamClient } - public var isLocal: Bool { self == .local } - public var usesLocalAudio: Bool { self == .local || self == .remoteHost || self == .streamClient } - public var isReadOnlyLibrary: Bool { self == .remoteClient || self == .streamClient } - public var needsNetworkServer: Bool { self == .remoteHost || self == .streamHost } - public var isStreaming: Bool { self == .streamHost || self == .streamClient } -} -``` - -- [ ] **Step 2: Build MusicShared to verify** - -```bash -cd MusicShared && swift build -``` - -Expected: build succeeds. - -- [ ] **Step 3: Commit** - -```bash -git add MusicShared/Sources/MusicShared/AppRole.swift -git commit -m "feat: add AppRole enum to MusicShared" -``` - ---- - -## Task 3: Add StreamingConstants, Routes, and APIModels to MusicShared - -**Files:** -- Create: `MusicShared/Sources/MusicShared/StreamingConstants.swift` -- Create: `MusicShared/Sources/MusicShared/StreamingRoutes.swift` -- Create: `MusicShared/Sources/MusicShared/APIModels.swift` - -- [ ] **Step 1: Create StreamingConstants.swift** - -```swift -import Foundation - -public enum StreamingConstants: Sendable { - public static let defaultPort: Int = 8420 - public static let segmentDuration: Double = 6.0 - public static let protocolVersion: Int = 1 -} -``` - -- [ ] **Step 2: Create StreamingRoutes.swift** - -```swift -import Foundation - -public enum StreamingRoutes: Sendable { - public static let auth = "/auth" - public static let db = "/db" - public static let ws = "/ws" - - public static func trackManifest(trackId: Int64) -> String { - "/tracks/\(trackId)/stream.m3u8" - } - - public static func trackSegment(trackId: Int64, index: Int) -> String { - "/tracks/\(trackId)/segments/\(index).mp3" - } - - public static func trackManifestPattern() -> String { - "/tracks/:trackId/stream.m3u8" - } - - public static func trackSegmentPattern() -> String { - "/tracks/:trackId/segments/:index" - } -} -``` - -- [ ] **Step 3: Create APIModels.swift** - -```swift -import Foundation - -public struct AuthResponse: Codable, Equatable, Sendable { - public var hostName: String - public var protocolVersion: Int - - public init(hostName: String, protocolVersion: Int) { - self.hostName = hostName - self.protocolVersion = protocolVersion - } -} - -public struct DBMetadata: Codable, Equatable, Sendable { - public var checksum: String - public var trackCount: Int - - public init(checksum: String, trackCount: Int) { - self.checksum = checksum - self.trackCount = trackCount - } -} -``` - -- [ ] **Step 4: Build MusicShared to verify** - -```bash -cd MusicShared && swift build -``` - -Expected: build succeeds. - -- [ ] **Step 5: Commit** - -```bash -git add MusicShared/Sources/MusicShared/StreamingConstants.swift \ - MusicShared/Sources/MusicShared/StreamingRoutes.swift \ - MusicShared/Sources/MusicShared/APIModels.swift -git commit -m "feat: add StreamingConstants, Routes, and APIModels to MusicShared" -``` - ---- - -## Task 4: Add HLSManifestGenerator (TDD) - -**Files:** -- Create: `MusicShared/Sources/MusicShared/HLSManifestGenerator.swift` -- Create: `MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift` - -- [ ] **Step 1: Write the failing tests** - -`MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift`: - -```swift -import Testing -@testable import MusicShared - -struct HLSManifestGeneratorTests { - // Generates a manifest for a 16-second track with 6s segments. - // Expects 3 segments: 6s, 6s, 4s (remainder). - @Test func generatesCorrectManifestForTypicalTrack() { - let manifest = HLSManifestGenerator.manifest( - trackId: 42, - duration: 16.0, - segmentDuration: 6.0 - ) - - #expect(manifest.contains("#EXTM3U")) - #expect(manifest.contains("#EXT-X-VERSION:3")) - #expect(manifest.contains("#EXT-X-TARGETDURATION:6")) - #expect(manifest.contains("#EXT-X-MEDIA-SEQUENCE:0")) - #expect(manifest.contains("#EXTINF:6.000,")) - #expect(manifest.contains("#EXTINF:4.000,")) - #expect(manifest.contains("segments/0.mp3")) - #expect(manifest.contains("segments/1.mp3")) - #expect(manifest.contains("segments/2.mp3")) - #expect(!manifest.contains("segments/3.mp3")) - #expect(manifest.contains("#EXT-X-ENDLIST")) - } - - // A track whose duration is an exact multiple of the segment duration. - // Expects no short final segment. - @Test func exactMultipleOfSegmentDuration() { - let manifest = HLSManifestGenerator.manifest( - trackId: 1, - duration: 12.0, - segmentDuration: 6.0 - ) - - let segmentCount = manifest.components(separatedBy: "#EXTINF:6.000,").count - 1 - #expect(segmentCount == 2) - #expect(!manifest.contains("segments/2.mp3")) - } - - // A very short track (shorter than one segment). - // Expects a single segment with the track's full duration. - @Test func veryShortTrack() { - let manifest = HLSManifestGenerator.manifest( - trackId: 7, - duration: 2.5, - segmentDuration: 6.0 - ) - - #expect(manifest.contains("#EXTINF:2.500,")) - #expect(manifest.contains("segments/0.mp3")) - #expect(!manifest.contains("segments/1.mp3")) - } - - // Segment count helper returns the correct number of segments. - @Test func segmentCountCalculation() { - #expect(HLSManifestGenerator.segmentCount(duration: 16.0, segmentDuration: 6.0) == 3) - #expect(HLSManifestGenerator.segmentCount(duration: 12.0, segmentDuration: 6.0) == 2) - #expect(HLSManifestGenerator.segmentCount(duration: 2.5, segmentDuration: 6.0) == 1) - #expect(HLSManifestGenerator.segmentCount(duration: 6.0, segmentDuration: 6.0) == 1) - } - - // Time range for a given segment index returns correct start and duration. - @Test func segmentTimeRange() { - // Track: 16s, segment: 6s → segments at 0-6, 6-12, 12-16 - let range0 = HLSManifestGenerator.segmentTimeRange( - index: 0, trackDuration: 16.0, segmentDuration: 6.0 - ) - #expect(range0.start == 0.0) - #expect(range0.duration == 6.0) - - let range2 = HLSManifestGenerator.segmentTimeRange( - index: 2, trackDuration: 16.0, segmentDuration: 6.0 - ) - #expect(range2.start == 12.0) - #expect(range2.duration == 4.0) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -cd MusicShared && swift test 2>&1 | grep -E "(FAIL|error:|Build)" -``` - -Expected: build error — `HLSManifestGenerator` not found. - -- [ ] **Step 3: Implement HLSManifestGenerator** - -`MusicShared/Sources/MusicShared/HLSManifestGenerator.swift`: - -```swift -import Foundation - -public enum HLSManifestGenerator: Sendable { - public struct TimeRange: Equatable, Sendable { - public var start: Double - public var duration: Double - } - - public static func manifest(trackId: Int64, duration: Double, segmentDuration: Double) -> String { - let count = segmentCount(duration: duration, segmentDuration: segmentDuration) - let targetDuration = Int(segmentDuration.rounded(.up)) - - var lines: [String] = [ - "#EXTM3U", - "#EXT-X-VERSION:3", - "#EXT-X-TARGETDURATION:\(targetDuration)", - "#EXT-X-MEDIA-SEQUENCE:0", - ] - - for i in 0.. Int { - guard duration > 0, segmentDuration > 0 else { return 0 } - return Int((duration / segmentDuration).rounded(.up)) - } - - public static func segmentTimeRange(index: Int, trackDuration: Double, segmentDuration: Double) -> TimeRange { - let start = Double(index) * segmentDuration - let remaining = trackDuration - start - let duration = min(segmentDuration, remaining) - return TimeRange(start: start, duration: duration) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -cd MusicShared && swift test -``` - -Expected: all HLSManifestGenerator tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add MusicShared/Sources/MusicShared/HLSManifestGenerator.swift \ - MusicShared/Tests/MusicSharedTests/HLSManifestGeneratorTests.swift -git commit -m "feat: add HLSManifestGenerator with TDD tests" -``` - ---- - -## Task 5: Define PlaybackProvider Protocol - -**Files:** -- Create: `Music/Protocols/PlaybackProvider.swift` - -This protocol abstracts the three playback modes so `PlayerViewModel` never checks which mode it's in. - -- [ ] **Step 1: Create PlaybackProvider.swift** - -`Music/Protocols/PlaybackProvider.swift`: - -```swift -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 } - - /// Resolve a Track to the URL the provider should play. - /// Local providers return the file URL; streaming providers return the HLS URL. - 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) -} -``` - -- [ ] **Step 2: Build to verify** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED - -- [ ] **Step 3: Commit** - -```bash -git add Music/Protocols/PlaybackProvider.swift Music.xcodeproj -git commit -m "feat: add PlaybackProvider protocol" -``` - ---- - -## Task 6: Conform AudioService to PlaybackProvider - -**Files:** -- Modify: `Music/Services/AudioService.swift` - -`AudioService` already has every method and property that `PlaybackProvider` requires except `urlForTrack(_:)`. We declare the conformance and add the missing method. - -- [ ] **Step 1: Add protocol conformance and urlForTrack to AudioService** - -In `Music/Services/AudioService.swift`, change the class declaration at line 11 from: - -```swift -final class AudioService { -``` - -to: - -```swift -final class AudioService: PlaybackProvider { -``` - -Add this method anywhere in the class body: - -```swift -func urlForTrack(_ track: Track) -> URL? { - URL(string: track.fileURL) -} -``` - -- [ ] **Step 2: Build to verify conformance** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED — AudioService now satisfies all PlaybackProvider requirements. - -- [ ] **Step 3: Commit** - -```bash -git add Music/Services/AudioService.swift -git commit -m "feat: conform AudioService to PlaybackProvider" -``` - ---- - -## Task 7: Create RemotePlaybackProvider - -**Files:** -- Create: `Music/Providers/RemotePlaybackProvider.swift` - -This extracts the remote-command-sending logic that currently lives inside `PlayerViewModel` into a dedicated `PlaybackProvider` conformer. In remote mode, commands are forwarded over the network; playback state comes from `applyRemoteState()`. - -- [ ] **Step 1: Create RemotePlaybackProvider.swift** - -`Music/Providers/RemotePlaybackProvider.swift`: - -```swift -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 // Remote mode doesn't use URLs — commands are sent via sendPlayCommand - } - - 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?() - } -} -``` - -- [ ] **Step 2: Build to verify** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED - -- [ ] **Step 3: Commit** - -```bash -git add Music/Providers/RemotePlaybackProvider.swift Music.xcodeproj -git commit -m "feat: add RemotePlaybackProvider" -``` - ---- - -## Task 8: Refactor PlayerViewModel to Use PlaybackProvider - -**Files:** -- Modify: `Music/ViewModels/PlayerViewModel.swift` -- Modify: `MusicTests/PlayerViewModelTests.swift` - -This is the key refactor. `PlayerViewModel` drops its direct `AudioService` reference and instead holds a `PlaybackProvider`. The `enterRemoteMode`/`exitRemoteMode` methods are replaced by `setProvider(_:)`. - -- [ ] **Step 1: Rewrite PlayerViewModel to use PlaybackProvider** - -Replace the contents of `Music/ViewModels/PlayerViewModel.swift`: - -```swift -import Foundation -import Observation -import MusicShared - -protocol RemoteCommandSender: AnyObject { - func sendCommand(_ command: RemoteCommand) -} - -@Observable -final class PlayerViewModel { - var currentTrack: Track? - var currentIndex: Int? - var isShuffled = false - var isPlaying = false - var currentTime: Double = 0 - var duration: Double = 0 - var volume: Float = 0.65 - - private(set) var queue: [Track] = [] - private var originalQueue: [Track] = [] - private var provider: PlaybackProvider - private let db: DatabaseService? - private var halfwayReported = false - - var trackResolver: ((Int64) -> Track?)? - - private var remoteProvider: RemotePlaybackProvider? { - provider as? RemotePlaybackProvider - } - - init(provider: PlaybackProvider, db: DatabaseService?) { - self.provider = provider - self.db = db - bindProvider() - } - - // MARK: - Provider Management - - func setProvider(_ newProvider: PlaybackProvider) { - provider.stop() - provider = newProvider - currentTrack = nil - currentIndex = nil - isPlaying = false - currentTime = 0 - duration = 0 - queue = [] - originalQueue = [] - halfwayReported = false - bindProvider() - } - - private func bindProvider() { - provider.onTrackFinished = { [weak self] in - self?.trackDidFinish() - } - provider.onPlaybackStateChanged = { [weak self] in - self?.syncFromProvider() - } - } - - // MARK: - Provider Sync - - private func syncFromProvider() { - isPlaying = provider.isPlaying - if !provider.isScrubbing { - currentTime = provider.currentTime - } - duration = provider.duration - volume = provider.volume - checkHalfway() - } - - // MARK: - Queue Management - - func setQueue(_ tracks: [Track]) { - originalQueue = tracks - if isShuffled { - queue = buildShuffledQueue(from: tracks, startingWith: currentTrack) - } else { - queue = tracks - } - if let current = currentTrack { - currentIndex = queue.firstIndex(where: { $0.id == current.id }) - } - } - - // MARK: - Playback Controls - - func play(_ track: Track) { - currentTrack = track - currentIndex = queue.firstIndex(where: { $0.id == track.id }) - halfwayReported = false - isPlaying = true - currentTime = 0 - - if let remote = remoteProvider { - guard let trackId = track.id else { return } - remote.sendPlayCommand(trackId: trackId, queueIds: queue.compactMap(\.id)) - } else { - guard let url = provider.urlForTrack(track) else { return } - provider.play(url: url) - } - } - - func togglePlayPause() { - if isPlaying { pause() } else { resume() } - } - - func pause() { - isPlaying = false - provider.pause() - } - - func resume() { - isPlaying = true - provider.resume() - } - - func seek(to position: Double) { - currentTime = position - provider.seek(to: position) - } - - func setVolume(_ level: Float) { - volume = level - provider.setVolume(level) - } - - func beginScrubbing() { - provider.beginScrubbing() - } - - func scrub(to position: Double) { - currentTime = position - provider.scrub(to: position) - } - - func endScrubbing(at position: Double) { - currentTime = position - provider.endScrubbing(at: position) - } - - func next() { - if let remote = remoteProvider { - remote.sendNext() - return - } - guard let idx = currentIndex else { return } - let nextIdx = idx + 1 - if nextIdx < queue.count { - play(queue[nextIdx]) - } else { - stop() - } - } - - func previous() { - if let remote = remoteProvider { - remote.sendPrevious() - return - } - guard let idx = currentIndex else { return } - let prevIdx = max(0, idx - 1) - play(queue[prevIdx]) - } - - func toggleShuffle() { - isShuffled.toggle() - if let remote = remoteProvider { - remote.sendToggleShuffle() - return - } - if isShuffled { - queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack) - } else { - queue = originalQueue - } - if let current = currentTrack { - currentIndex = queue.firstIndex(where: { $0.id == current.id }) - } - } - - func stop() { - isPlaying = false - currentTime = 0 - duration = 0 - currentTrack = nil - currentIndex = nil - provider.stop() - } - - // MARK: - Remote State - - func applyRemoteState(_ state: PlaybackStatePayload) { - guard let remote = remoteProvider else { return } - remote.applyRemoteState(state) - isPlaying = state.isPlaying - currentTime = state.currentTime - duration = state.duration - volume = state.volume - isShuffled = state.isShuffled - - if let trackId = state.trackId, currentTrack?.id != trackId { - currentTrack = trackResolver?(trackId) - } else if state.trackId == nil { - currentTrack = nil - } - } - - // MARK: - Internal - - func checkHalfway() { - guard !halfwayReported, - duration > 0, - currentTime >= duration * 0.5, - let track = currentTrack, - let trackId = track.id else { return } - - halfwayReported = true - let newCount = track.playCount + 1 - try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) - } - - private func trackDidFinish() { - if let track = currentTrack, let trackId = track.id, !halfwayReported { - let newCount = track.playCount + 1 - try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date()) - } - next() - } - - private func buildShuffledQueue(from tracks: [Track], startingWith current: Track?) -> [Track] { - var shuffled = tracks.shuffled() - if let current, let idx = shuffled.firstIndex(where: { $0.id == current.id }) { - shuffled.remove(at: idx) - shuffled.insert(current, at: 0) - } - return shuffled - } -} -``` - -- [ ] **Step 2: Update MusicApp.swift to use new PlayerViewModel init** - -In `Music/MusicApp.swift`, change line 98 from: - -```swift -let player = PlayerViewModel(audio: audioService, db: db) -``` - -to: - -```swift -let player = PlayerViewModel(provider: audioService, db: db) -``` - -Update `enterRemoteMode()` (around line 197) — replace: - -```swift -player.enterRemoteMode(client: remoteClient) -``` - -with: - -```swift -let remoteProvider = RemotePlaybackProvider(commandSender: remoteClient) -player.setProvider(remoteProvider) -``` - -Update `exitRemoteMode()` (around line 218) — replace: - -```swift -playerVM?.exitRemoteMode() -``` - -with: - -```swift -playerVM?.setProvider(audioService) -``` - -- [ ] **Step 3: Update PlayerViewModelTests** - -Replace `PlayerViewModel(audio: AudioService(), db: nil)` with `PlayerViewModel(provider: AudioService(), db: nil)` in every test. In `MusicTests/PlayerViewModelTests.swift`, change the `makeTracks` helper's test setup. Every occurrence of: - -```swift -let vm = PlayerViewModel(audio: AudioService(), db: nil) -``` - -becomes: - -```swift -let vm = PlayerViewModel(provider: AudioService(), db: nil) -``` - -- [ ] **Step 4: Build and run tests** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20 -``` - -Expected: all PlayerViewModelTests pass, build succeeds. - -- [ ] **Step 5: Commit** - -```bash -git add Music/ViewModels/PlayerViewModel.swift Music/MusicApp.swift \ - MusicTests/PlayerViewModelTests.swift Music/Providers/ -git commit -m "refactor: PlayerViewModel uses PlaybackProvider protocol" -``` - ---- - -## Task 9: Update NetworkStatus for Streaming Modes - -**Files:** -- Modify: `Music/Remote/NetworkStatus.swift` - -- [ ] **Step 1: Add streaming modes to NetworkStatus.Mode** - -Replace the contents of `Music/Remote/NetworkStatus.swift`: - -```swift -import Foundation -import MusicShared - -struct NetworkStatus { - enum Mode { - case hosting(connectedRemote: String?) - case remote(hostName: String) - case streamHosting(tunnelURL: String?) - case streamClient(hostName: String) - } - - var mode: Mode - var onDisconnect: (() -> Void)? - var onRefreshLibrary: (() -> Void)? - - var isRemoteMode: Bool { - switch mode { - case .remote, .streamClient: return true - default: return false - } - } - - var isHostMode: Bool { - switch mode { - case .hosting, .streamHosting: return true - default: return false - } - } - - var statusMessage: String { - switch mode { - case .hosting(let remote): - if let remote { return "Hosting · \(remote) connected" } - return "Hosting" - case .remote(let host): - return "Connected to \(host)" - case .streamHosting(let url): - if let url { return "Streaming · \(url)" } - return "Streaming server starting..." - case .streamClient(let host): - return "Streaming from \(host)" - } - } -} -``` - -- [ ] **Step 2: Build to verify** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED (existing `computeNetworkStatus()` in MusicApp.swift still compiles since the original cases still exist). - -- [ ] **Step 3: Commit** - -```bash -git add Music/Remote/NetworkStatus.swift -git commit -m "feat: add streaming modes to NetworkStatus" -``` - ---- - -## Task 10: Add Hummingbird Dependency - -**Files:** -- Modify: `MusicShared/Package.swift` (add Hummingbird as dependency) -- Modify: `Music.xcodeproj/project.pbxproj` (Xcode auto-handles via local package) - -Hummingbird is added to MusicShared so the streaming server types can be imported by both macOS and (eventually) iOS targets. Only the host target will actually use server functionality. - -- [ ] **Step 1: Update MusicShared/Package.swift to add Hummingbird** - -Replace `MusicShared/Package.swift`: - -```swift -// swift-tools-version: 5.10 -import PackageDescription - -let package = Package( - name: "MusicShared", - platforms: [.macOS(.v14), .iOS(.v17)], - products: [ - .library(name: "MusicShared", targets: ["MusicShared"]), - ], - dependencies: [ - .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.0.0"), - .package(url: "https://github.com/hummingbird-project/hummingbird-websocket.git", from: "2.0.0"), - ], - targets: [ - .target( - name: "MusicShared", - dependencies: [ - .product(name: "Hummingbird", package: "hummingbird"), - .product(name: "HummingbirdWebSocket", package: "hummingbird-websocket"), - ] - ), - .testTarget(name: "MusicSharedTests", dependencies: ["MusicShared"]), - ] -) -``` - -- [ ] **Step 2: Resolve and build** - -```bash -cd MusicShared && swift package resolve && swift build -``` - -Expected: Hummingbird downloads and builds successfully. - -- [ ] **Step 3: Verify Xcode picks up the dependency** - -In Xcode, clean build folder and rebuild. The local package's new dependencies should resolve automatically. - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED - -- [ ] **Step 4: Commit** - -```bash -git add MusicShared/Package.swift MusicShared/Package.resolved -git commit -m "chore: add Hummingbird dependency to MusicShared" -``` - ---- - -## Task 11: Create HLSSegmenter (TDD) - -**Files:** -- Create: `Music/Streaming/HLSSegmenter.swift` -- Create: `MusicTests/HLSSegmenterTests.swift` - -The segmenter uses `AVAssetReader` with time ranges to extract audio segments from MP3 files. It handles VBR files and frame-boundary alignment correctly. - -- [ ] **Step 1: Write the failing tests** - -`MusicTests/HLSSegmenterTests.swift`: - -```swift -import Testing -import Foundation -@testable import Music - -// Integration test using a real audio file. -// Requires a test MP3 fixture (see Step 3). -@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 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 MP3 file. - // The file is generated once using afconvert if it doesn't exist. - static func shortMP3URL() throws -> URL { - let tempDir = FileManager.default.temporaryDirectory - let url = tempDir.appendingPathComponent("test_fixture.mp3") - - if !FileManager.default.fileExists(atPath: url.path) { - // Generate a 3-second silent MP3 using afconvert via a CAF intermediary - let cafURL = tempDir.appendingPathComponent("test_fixture.caf") - let sampleRate = 44100 - let channels = 1 - let durationSamples = sampleRate * 3 - let bytesPerSample = 2 - let dataSize = durationSamples * channels * bytesPerSample - - // Create a minimal WAV as input for afconvert - let wavURL = tempDir.appendingPathComponent("test_fixture.wav") - 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) // PCM - 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)) // silence - - try wavData.write(to: wavURL) - - // Convert WAV → MP3 using afconvert - let process = Process() - process.executableURL = URL(fileURLWithPath: "/usr/bin/afconvert") - process.arguments = [wavURL.path, url.path, "-f", "MPE3", "-d", "mp3"] - 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 MP3"]) - } - } - - return url - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music test \ - -only-testing:MusicTests/HLSSegmenterTests 2>&1 | tail -10 -``` - -Expected: build error — `HLSSegmenter` not found. - -- [ ] **Step 3: Implement HLSSegmenter** - -`Music/Streaming/HLSSegmenter.swift`: - -```swift -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 - - let outputSettings: [String: Any] = [ - AVFormatIDKey: kAudioFormatMPEGLayer3, - ] - - // 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?) -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music test \ - -only-testing:MusicTests/HLSSegmenterTests 2>&1 | tail -20 -``` - -Expected: all HLSSegmenterTests pass. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Streaming/HLSSegmenter.swift MusicTests/HLSSegmenterTests.swift Music.xcodeproj -git commit -m "feat: add HLSSegmenter with AVAssetReader-based extraction" -``` - ---- - -## Task 12: Create StreamingServer - -**Files:** -- Create: `Music/Streaming/StreamingServer.swift` -- Create: `MusicTests/StreamingServerTests.swift` - -The streaming server is a Hummingbird HTTP server that serves: -- `GET /auth` — API key validation -- `GET /db` — SQLite database download -- `GET /tracks/:trackId/stream.m3u8` — HLS manifest -- `GET /tracks/:trackId/segments/:index.mp3` — Audio segment -- WebSocket at `/ws` — real-time events - -- [ ] **Step 1: Write integration tests for the streaming server** - -`MusicTests/StreamingServerTests.swift`: - -```swift -import Testing -import Foundation -@testable import Music -@testable import MusicShared - -@MainActor -struct StreamingServerTests { - static let testAPIKey = "test-key-12345" - - // Verifies that GET /auth with a valid API key returns 200 and an AuthResponse. - @Test func authEndpointAcceptsValidKey() async throws { - let server = try makeServer() - try await server.start() - defer { Task { await server.stop() } } - - let port = try #require(server.actualPort) - var request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) - request.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") - - let (data, response) = try await URLSession.shared.data(for: request) - let httpResponse = try #require(response as? HTTPURLResponse) - #expect(httpResponse.statusCode == 200) - - let authResponse = try JSONDecoder().decode(AuthResponse.self, from: data) - #expect(authResponse.protocolVersion == StreamingConstants.protocolVersion) - } - - // Verifies that GET /auth without a key returns 401. - @Test func authEndpointRejectsNoKey() async throws { - let server = try makeServer() - try await server.start() - defer { Task { await server.stop() } } - - let port = try #require(server.actualPort) - let request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) - - let (_, response) = try await URLSession.shared.data(for: request) - let httpResponse = try #require(response as? HTTPURLResponse) - #expect(httpResponse.statusCode == 401) - } - - // Verifies that GET /db returns a non-empty SQLite file. - @Test func dbEndpointReturnsDatabaseFile() async throws { - let server = try makeServer() - try await server.start() - defer { Task { await server.stop() } } - - let port = try #require(server.actualPort) - var request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.db)")!) - request.setValue("Bearer \(Self.testAPIKey)", 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" - #expect(data.count > 16) - #expect(String(data: data.prefix(15), encoding: .utf8) == "SQLite format 3") - } - - private func makeServer() throws -> StreamingServer { - let db = try DatabaseService(inMemory: true) - return StreamingServer( - db: db, - apiKey: Self.testAPIKey, - port: 0 // OS-assigned port - ) - } -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music test \ - -only-testing:MusicTests/StreamingServerTests 2>&1 | tail -10 -``` - -Expected: build error — `StreamingServer` not found. - -- [ ] **Step 3: Implement StreamingServer** - -`Music/Streaming/StreamingServer.swift`: - -```swift -import Foundation -import Hummingbird -import HummingbirdWebSocket -import MusicShared -import os - -@MainActor -@Observable -final class StreamingServer { - var isRunning = false - private(set) var actualPort: Int? - - private let db: DatabaseService - private let apiKey: String - private let requestedPort: Int - private var serverTask: Task? - private let logger = Logger(subsystem: "com.music.streaming", category: "server") - - // Cache segmenters by track ID to avoid re-reading duration on every request - private var segmenters: [Int64: HLSSegmenter] = [:] - - init(db: DatabaseService, apiKey: String, port: Int = StreamingConstants.defaultPort) { - self.db = db - self.apiKey = apiKey - self.requestedPort = port - } - - func start() async throws { - let router = Router() - - router.middlewares.add(AuthMiddleware(apiKey: apiKey)) - - router.get(StreamingRoutes.auth) { [weak self] request, context -> Response in - guard let self else { return Response(status: .internalServerError) } - let hostName = Host.current().localizedName ?? "Music Host" - let response = AuthResponse(hostName: hostName, protocolVersion: StreamingConstants.protocolVersion) - let data = try JSONEncoder().encode(response) - return Response( - status: .ok, - headers: [.contentType: "application/json"], - body: .init(byteBuffer: .init(data: data)) - ) - } - - router.get(StreamingRoutes.db) { [weak self] request, context -> Response in - guard let self else { return Response(status: .internalServerError) } - let tempURL = FileManager.default.temporaryDirectory - .appendingPathComponent(UUID().uuidString + ".sqlite") - defer { try? FileManager.default.removeItem(at: tempURL) } - try self.db.backup(to: tempURL.path) - let data = try Data(contentsOf: tempURL) - return Response( - status: .ok, - headers: [.contentType: "application/octet-stream"], - body: .init(byteBuffer: .init(data: data)) - ) - } - - router.get("tracks/:trackId/stream.m3u8") { [weak self] request, context -> Response in - guard let self else { return Response(status: .internalServerError) } - guard let trackIdStr = context.parameters.get("trackId"), - let trackId = Int64(trackIdStr) else { - return Response(status: .badRequest) - } - let segmenter = try self.getOrCreateSegmenter(trackId: trackId) - let manifest = HLSManifestGenerator.manifest( - trackId: trackId, - duration: segmenter.duration, - segmentDuration: StreamingConstants.segmentDuration - ) - return Response( - status: .ok, - headers: [ - .contentType: "application/vnd.apple.mpegurl", - .cacheControl: "no-cache", - ], - body: .init(byteBuffer: .init(string: manifest)) - ) - } - - router.get("tracks/:trackId/segments/:index") { [weak self] request, context -> Response in - guard let self else { return Response(status: .internalServerError) } - guard let trackIdStr = context.parameters.get("trackId"), - let trackId = Int64(trackIdStr), - let indexStr = context.parameters.get("index"), - let index = Int(indexStr.replacingOccurrences(of: ".mp3", with: "")) else { - return Response(status: .badRequest) - } - let segmenter = try self.getOrCreateSegmenter(trackId: trackId) - guard let data = try await segmenter.segment(at: index, segmentDuration: StreamingConstants.segmentDuration) else { - return Response(status: .notFound) - } - return Response( - status: .ok, - headers: [.contentType: "audio/mpeg"], - body: .init(byteBuffer: .init(data: data)) - ) - } - - // WebSocket route for real-time events - router.ws(StreamingRoutes.ws) { [weak self] request, context in - // Auth check for WebSocket upgrade - guard let authHeader = request.headers[.authorization], - authHeader == "Bearer \(self?.apiKey ?? "")" else { - return .dontUpgrade - } - return .upgrade([:]) - } onUpgrade: { [weak self] inbound, outbound, context in - guard let self else { return } - for try await message in inbound.messages { - switch message { - case .text(let text): - self.handleWSMessage(text, outbound: outbound) - case .binary: - break - } - } - } - - let app = Application( - router: router, - configuration: .init(address: .hostname("127.0.0.1", port: requestedPort)) - ) - - serverTask = Task { - try await app.run() - } - - // Give the server a moment to bind - try await Task.sleep(for: .milliseconds(200)) - actualPort = requestedPort == 0 ? app.port : requestedPort - isRunning = true - logger.info("Streaming server started on port \(self.actualPort ?? 0)") - } - - func stop() { - serverTask?.cancel() - serverTask = nil - segmenters = [:] - actualPort = nil - isRunning = false - logger.info("Streaming server stopped") - } - - // MARK: - WebSocket - - private func handleWSMessage(_ text: String, outbound: WebSocketOutboundWriter) { - guard let data = text.data(using: .utf8) else { return } - let decoder = JSONDecoder() - - if let handshake = try? decoder.decode(HandshakeMessage.self, from: data) { - logger.info("WS handshake: v\(handshake.protocolVersion), app \(handshake.appVersion)") - return - } - - if let command = try? decoder.decode(RemoteCommand.self, from: data) { - logger.info("WS command: \(String(describing: command))") - if case .refreshDB = command { - let event = HostEvent.dbReady - if let encoded = try? JSONEncoder().encode(event), - let str = String(data: encoded, encoding: .utf8) { - Task { try? await outbound.write(.text(str)) } - } - } - } - } - - // MARK: - Helpers - - private func getOrCreateSegmenter(trackId: Int64) throws -> HLSSegmenter { - if let cached = segmenters[trackId] { - return cached - } - let tracks = try db.fetchTracksByIds([trackId]) - guard let track = tracks.first else { - throw StreamingServerError.trackNotFound(trackId) - } - let fileURL = URL(fileURLWithPath: track.fileURL) - let segmenter = try HLSSegmenter(fileURL: fileURL) - segmenters[trackId] = segmenter - return segmenter - } -} - -enum StreamingServerError: Error { - case trackNotFound(Int64) -} - -struct AuthMiddleware: MiddlewareProtocol { - let apiKey: String - - func handle( - _ request: Request, - context: some RequestContext, - next: (Request, some RequestContext) async throws -> Response - ) async throws -> Response { - let authHeader = request.headers[.authorization] - guard authHeader == "Bearer \(apiKey)" else { - return Response(status: .unauthorized) - } - return try await next(request, context) - } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music test \ - -only-testing:MusicTests/StreamingServerTests 2>&1 | tail -20 -``` - -Expected: all StreamingServerTests pass. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Streaming/StreamingServer.swift MusicTests/StreamingServerTests.swift Music.xcodeproj -git commit -m "feat: add StreamingServer with Hummingbird HTTP endpoints" -``` - ---- - -## Task 13: Create TunnelManager - -**Files:** -- Create: `Music/Streaming/TunnelManager.swift` - -Manages the `cloudflared` child process. Supports both quick tunnels (random URL) and named tunnels (stable URL). - -- [ ] **Step 1: Create TunnelManager.swift** - -`Music/Streaming/TunnelManager.swift`: - -```swift -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 { - FileManager.default.fileExists(atPath: "/opt/homebrew/bin/cloudflared") - || FileManager.default.fileExists(atPath: "/usr/local/bin/cloudflared") - } - - 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 { - 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 = ["tunnel", "--url", "http://localhost:\(localPort)"] - - let pipe = Pipe() - process.standardError = pipe // cloudflared writes URL to stderr - - 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 { } else { - self.state = .failed(message: "cloudflared exited with code \(proc.terminationStatus)") - } - self.state = .stopped - } - } - - try process.run() - self.process = process - self.outputPipe = pipe - logger.info("Started cloudflared quick tunnel on port \(localPort)") - } - - func startNamedTunnel(tunnelName: String, localPort: Int) 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 = ["tunnel", "run", "--url", "http://localhost:\(localPort)", tunnelName] - - 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] _ in - Task { @MainActor [weak self] in - self?.state = .stopped - } - } - - try process.run() - self.process = process - self.outputPipe = pipe - logger.info("Started cloudflared named tunnel '\(tunnelName)' on port \(localPort)") - } - - func stop() { - process?.terminate() - process = nil - outputPipe?.fileHandleForReading.readabilityHandler = nil - outputPipe = nil - state = .stopped - logger.info("Stopped cloudflared tunnel") - } - - private func parseOutput(_ output: String) { - // cloudflared prints the tunnel URL to stderr in a line like: - // "... https://xxx-yyy-zzz.trycloudflare.com ..." - // or for named tunnels, the configured hostname - 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)") - } - } - } -} -``` - -- [ ] **Step 2: Build to verify** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED - -- [ ] **Step 3: Commit** - -```bash -git add Music/Streaming/TunnelManager.swift Music.xcodeproj -git commit -m "feat: add TunnelManager for cloudflared process management" -``` - ---- - -## Task 14: Create StreamingPlaybackProvider - -**Files:** -- Create: `Music/Providers/StreamingPlaybackProvider.swift` - -Uses `AVPlayer` with HLS URLs and injects the API key as a custom HTTP header. This reuses AVPlayer the same way `AudioService` does, but points at remote URLs instead of local files. - -- [ ] **Step 1: Create StreamingPlaybackProvider.swift** - -`Music/Providers/StreamingPlaybackProvider.swift`: - -```swift -import AVFoundation -import Foundation -import Observation -import MusicShared - -@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 onTrackFinished: (() -> Void)? - var onPlaybackStateChanged: (() -> Void)? - - private var player: AVPlayer? - private var timeObserver: Any? - private var endObserver: NSObjectProtocol? - private var seekInProgress = false - private var pendingSeekTime: Double? - - private let hostURL: String - private let apiKey: String - - 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 } - return URL(string: "\(hostURL)\(StreamingRoutes.trackManifest(trackId: trackId))") - } - - func play(url: URL) { - cleanup() - - let headers = ["Authorization": "Bearer \(apiKey)"] - let asset = AVURLAsset(url: url, options: ["AVURLAssetHTTPHeaderFieldsKey": headers]) - let item = AVPlayerItem(asset: asset) - player = AVPlayer(playerItem: item) - player?.volume = volume - - 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?() - } - - player?.play() - isPlaying = true - onPlaybackStateChanged?() - } - - func playTrack(id: Int64) { - let urlString = "\(hostURL)\(StreamingRoutes.trackManifest(trackId: id))" - guard let url = URL(string: urlString) else { return } - play(url: url) - } - - 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 - // Chase-seek for smooth scrubbing - 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 - 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() { - if let obs = timeObserver { - player?.removeTimeObserver(obs) - timeObserver = nil - } - if let obs = endObserver { - NotificationCenter.default.removeObserver(obs) - endObserver = nil - } - player?.pause() - player = nil - } - - nonisolated deinit {} -} -``` - -- [ ] **Step 2: Build to verify** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED - -- [ ] **Step 3: Commit** - -```bash -git add Music/Providers/StreamingPlaybackProvider.swift Music.xcodeproj -git commit -m "feat: add StreamingPlaybackProvider with HLS AVPlayer" -``` - ---- - -## Task 15: Create StreamingClient - -**Files:** -- Create: `Music/Streaming/StreamingClient.swift` - -Handles the client-side connection lifecycle: auth validation, DB download, WebSocket for events. - -- [ ] **Step 1: Create StreamingClient.swift** - -`Music/Streaming/StreamingClient.swift`: - -```swift -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 var hostURL: String = "" - private var apiKey: String = "" - private var webSocketTask: URLSessionWebSocketTask? - private let logger = Logger(subsystem: "com.music.streaming", category: "client") - - 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 - - // Step 1: Validate auth - do { - let authResponse = try await authenticate() - logger.info("Authenticated with host: \(authResponse.hostName)") - - // Step 2: Download DB - state = .downloadingDB - try await downloadDatabase() - logger.info("Database downloaded") - - // Step 3: Connect WebSocket - connectWebSocket() - - 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 - - // Send handshake - 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 // Not used in streaming mode (client drives its own playback) - 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" - } - } -} -``` - -- [ ] **Step 2: Build to verify** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED - -- [ ] **Step 3: Commit** - -```bash -git add Music/Streaming/StreamingClient.swift Music.xcodeproj -git commit -m "feat: add StreamingClient with auth, DB download, and WebSocket" -``` - ---- - -## Task 16: Wire Up MusicApp.swift with Streaming Host and Client - -**Files:** -- Modify: `Music/MusicApp.swift` - -Add streaming server/client state, menu items for stream host and stream client, and mode switching logic. - -- [ ] **Step 1: Add streaming state properties** - -In `MusicApp.swift`, add these `@State` properties after the existing ones (around line 16): - -```swift -@State private var streamingServer: StreamingServer? -@State private var tunnelManager = TunnelManager() -@State private var streamingClient = StreamingClient() -@State private var showStreamingSettings = false -@State private var streamHostURL = UserDefaults.standard.string(forKey: "streamHostURL") ?? "" -@State private var streamAPIKey = UserDefaults.standard.string(forKey: "streamAPIKey") ?? "" -``` - -- [ ] **Step 2: Add streaming menu commands** - -In the `.commands` block, after the existing "Connect to Remote..." button (around line 83), add: - -```swift -Divider() - -Button("Start Streaming Server...") { - startStreamingServer() -} -.disabled(streamingServer?.isRunning ?? false || remoteClient.connectionState.isConnected) - -Button("Stop Streaming Server") { - stopStreamingServer() -} -.disabled(!(streamingServer?.isRunning ?? false)) - -Divider() - -Button("Connect to Stream Host...") { - showStreamingSettings = true -} -.disabled(streamingClient.state.isConnected || hostServer?.isHosting ?? false) - -if streamingClient.state.isConnected { - Button("Disconnect from Stream") { - exitStreamingClientMode() - } -} -``` - -- [ ] **Step 3: Add streaming server methods** - -Add these methods to `MusicApp`: - -```swift -// MARK: - Streaming Host - -private func startStreamingServer() { - guard let db = dbService else { return } - - // Generate API key if first time - var key = UserDefaults.standard.string(forKey: "streamServerAPIKey") ?? "" - if key.isEmpty { - key = UUID().uuidString - UserDefaults.standard.set(key, forKey: "streamServerAPIKey") - } - - let server = StreamingServer(db: db, apiKey: key) - Task { - do { - try await server.start() - self.streamingServer = server - // Start tunnel - if TunnelManager.isCloudflaredInstalled() { - try tunnelManager.startQuickTunnel(localPort: server.actualPort ?? StreamingConstants.defaultPort) - } - } catch { - print("Failed to start streaming server: \(error)") - } - } -} - -private func stopStreamingServer() { - streamingServer?.stop() - streamingServer = nil - tunnelManager.stop() -} - -// MARK: - Streaming Client - -private func enterStreamingClientMode() { - guard !streamHostURL.isEmpty, !streamAPIKey.isEmpty else { return } - - UserDefaults.standard.set(streamHostURL, forKey: "streamHostURL") - UserDefaults.standard.set(streamAPIKey, forKey: "streamAPIKey") - - Task { - await streamingClient.connect(hostURL: streamHostURL, apiKey: streamAPIKey) - - if streamingClient.state.isConnected { - do { - let streamingDb = try DatabaseService(path: StreamingClient.streamingDBPath) - self.libraryVM = LibraryViewModel(db: streamingDb) - self.playlistVM = PlaylistViewModel(db: streamingDb) - - let streamProvider = StreamingPlaybackProvider( - hostURL: streamHostURL, - apiKey: streamAPIKey - ) - playerVM?.setProvider(streamProvider) - playerVM?.trackResolver = { trackId in - self.libraryVM?.tracks.first(where: { $0.id == trackId }) - } - - streamingClient.onDBReady = { [weak self] in - guard let self else { return } - Task { - try? await self.refreshStreamingDB() - } - } - } catch { - print("Failed to load streaming DB: \(error)") - streamingClient.disconnect() - } - } - } -} - -private func exitStreamingClientMode() { - streamingClient.disconnect() - playerVM?.setProvider(audioService) - playerVM?.trackResolver = nil - guard let db = dbService else { return } - self.libraryVM = LibraryViewModel(db: db) - self.playlistVM = PlaylistViewModel(db: db) -} - -private func refreshStreamingDB() async throws { - await streamingClient.connect(hostURL: streamHostURL, apiKey: streamAPIKey) - if streamingClient.state.isConnected { - let streamingDb = try DatabaseService(path: StreamingClient.streamingDBPath) - self.libraryVM = LibraryViewModel(db: streamingDb) - self.playlistVM = PlaylistViewModel(db: streamingDb) - } -} -``` - -- [ ] **Step 4: Update computeNetworkStatus() to include streaming modes** - -Add streaming cases to `computeNetworkStatus()`: - -```swift -private func computeNetworkStatus() -> NetworkStatus? { - if remoteClient.connectionState.isConnected { - let hostName: String - if case .connected(let name) = remoteClient.connectionState { hostName = name } else { hostName = "Unknown" } - return NetworkStatus( - mode: .remote(hostName: hostName), - onDisconnect: { [remoteClient] in remoteClient.disconnect() }, - onRefreshLibrary: { [remoteClient] in remoteClient.sendCommand(.refreshDB) } - ) - } - if let server = hostServer, server.isHosting { - return NetworkStatus(mode: .hosting(connectedRemote: server.connectedRemoteName)) - } - if let server = streamingServer, server.isRunning { - return NetworkStatus(mode: .streamHosting(tunnelURL: tunnelManager.tunnelURL)) - } - if case .connected(let hostName) = streamingClient.state { - return NetworkStatus( - mode: .streamClient(hostName: hostName), - onDisconnect: { [weak self] in self?.exitStreamingClientMode() }, - onRefreshLibrary: { [streamingClient] in streamingClient.requestDBRefresh() } - ) - } - return nil -} -``` - -- [ ] **Step 5: Add streaming settings sheet** - -Add a `.sheet` modifier for streaming connection settings. After the existing `.sheet(isPresented: $showConnectionSheet)` block: - -```swift -.sheet(isPresented: $showStreamingSettings) { - VStack(spacing: 16) { - Text("Connect to Streaming Host") - .font(.headline) - - TextField("Host URL", text: $streamHostURL) - .textFieldStyle(.roundedBorder) - - SecureField("API Key", text: $streamAPIKey) - .textFieldStyle(.roundedBorder) - - HStack { - Button("Cancel") { - showStreamingSettings = false - } - Button("Connect") { - showStreamingSettings = false - enterStreamingClientMode() - } - .disabled(streamHostURL.isEmpty || streamAPIKey.isEmpty) - .keyboardShortcut(.defaultAction) - } - } - .padding(24) - .frame(width: 400) -} -``` - -- [ ] **Step 6: Build to verify everything compiles** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED - -- [ ] **Step 7: Run all tests to verify nothing is broken** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20 -``` - -Expected: all tests pass. - -- [ ] **Step 8: Commit** - -```bash -git add Music/MusicApp.swift Music.xcodeproj -git commit -m "feat: wire up streaming host and client in MusicApp" -``` - ---- - -## Task 17: Update ContentView for Streaming Status Display - -**Files:** -- Modify: `Music/ContentView.swift` - -The network status bar in ContentView already displays `NetworkStatus` — we just need to make sure the new streaming modes show properly and that editing is disabled in streaming client mode. - -- [ ] **Step 1: Update the network status bar display** - -In `Music/ContentView.swift`, find the network status bar section (around line 29-56). The bar likely reads `networkStatus?.mode`. Add handling for the new cases. If the existing code uses a `switch` on `networkStatus.mode`, add: - -```swift -case .streamHosting(let url): - HStack { - Image(systemName: "antenna.radiowaves.left.and.right") - Text(url != nil ? "Streaming · \(url!)" : "Streaming server starting...") - } -case .streamClient(let host): - HStack { - Image(systemName: "music.note.tv") - Text("Streaming from \(host)") - Spacer() - if let onRefresh = networkStatus?.onRefreshLibrary { - Button("Refresh Library") { onRefresh() } - .buttonStyle(.borderless) - } - if let onDisconnect = networkStatus?.onDisconnect { - Button("Disconnect") { onDisconnect() } - .buttonStyle(.borderless) - } - } -``` - -If the code uses `networkStatus.statusMessage`, the `statusMessage` property added in Task 9 already handles all modes. - -- [ ] **Step 2: Disable editing in streaming client mode** - -Anywhere the UI allows playlist creation, track deletion, or library modification, check `networkStatus?.isRemoteMode`. This property already returns `true` for `.streamClient` (set in Task 9). Existing guards for remote mode should cover streaming client mode automatically. - -- [ ] **Step 3: Build and verify** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music build 2>&1 | tail -5 -``` - -Expected: BUILD SUCCEEDED - -- [ ] **Step 4: Commit** - -```bash -git add Music/ContentView.swift -git commit -m "feat: update ContentView for streaming status display" -``` - ---- - -## Task 18: End-to-End Integration Test - -**Files:** -- Create: `MusicTests/StreamingIntegrationTests.swift` - -Full round-trip test: start server, connect client, verify DB download, request HLS manifest. - -- [ ] **Step 1: Write integration tests** - -`MusicTests/StreamingIntegrationTests.swift`: - -```swift -import Testing -import Foundation -@testable import Music -@testable import MusicShared - -@MainActor -struct StreamingIntegrationTests { - static let testAPIKey = "integration-test-key" - - // Full flow: start server, authenticate, download DB, request manifest. - // 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. Open the downloaded DB and verify the track is present - // 6. Request HLS manifest for the track (will fail since file doesn't exist, but verifies routing) - @Test func fullConnectionFlow() async throws { - // 1. Setup - let db = try DatabaseService(inMemory: true) - var track = Track.fixture(id: nil, fileURL: "/tmp/test.mp3", title: "Test Song") - 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 { Task { await server.stop() } } - let port = try #require(server.actualPort) - let baseURL = "http://localhost:\(port)" - - // 3. Authenticate - var authReq = URLRequest(url: URL(string: "\(baseURL)\(StreamingRoutes.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)\(StreamingRoutes.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") - - // 6. Request manifest (routing works, but file won't exist so expect 500) - var manifestReq = URLRequest(url: URL(string: "\(baseURL)\(StreamingRoutes.trackManifest(trackId: trackId))")!) - manifestReq.setValue("Bearer \(Self.testAPIKey)", forHTTPHeaderField: "Authorization") - let (_, manifestResp) = try await URLSession.shared.data(for: manifestReq) - let manifestHTTP = try #require(manifestResp as? HTTPURLResponse) - // File at /tmp/test.mp3 doesn't exist → server returns 500 - // This is expected — we're testing routing, not actual file serving - #expect(manifestHTTP.statusCode == 500 || manifestHTTP.statusCode == 200) - } - - // Verifies that requests without auth get 401. - @Test 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 { Task { await server.stop() } } - let port = try #require(server.actualPort) - - let request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.auth)")!) - let (_, response) = try await URLSession.shared.data(for: request) - let httpResponse = try #require(response as? HTTPURLResponse) - #expect(httpResponse.statusCode == 401) - } - - // Verifies that wrong API key gets 401. - @Test 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 { Task { await server.stop() } } - let port = try #require(server.actualPort) - - var request = URLRequest(url: URL(string: "http://localhost:\(port)\(StreamingRoutes.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) - } -} -``` - -- [ ] **Step 2: Run integration tests** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music test \ - -only-testing:MusicTests/StreamingIntegrationTests 2>&1 | tail -20 -``` - -Expected: all tests pass. - -- [ ] **Step 3: Run the full test suite** - -```bash -xcodebuild -project Music.xcodeproj -scheme Music test 2>&1 | tail -20 -``` - -Expected: ALL tests pass — no regressions. - -- [ ] **Step 4: Commit** - -```bash -git add MusicTests/StreamingIntegrationTests.swift Music.xcodeproj -git commit -m "test: add end-to-end streaming integration tests" -``` - ---- - -## Summary - -| Task | What | Files | -|------|------|-------| -| 1 | Create MusicShared package, move RemoteProtocol | Package, protocol, tests | -| 2 | Add AppRole enum | `AppRole.swift` | -| 3 | Add constants, routes, API models | 3 files in MusicShared | -| 4 | Add HLSManifestGenerator (TDD) | Generator + tests | -| 5 | Define PlaybackProvider protocol | `PlaybackProvider.swift` | -| 6 | Conform AudioService to PlaybackProvider | 1-line change | -| 7 | Create RemotePlaybackProvider | New provider | -| 8 | Refactor PlayerViewModel + update tests | ViewModel + tests | -| 9 | Update NetworkStatus for streaming modes | `NetworkStatus.swift` | -| 10 | Add Hummingbird dependency | `Package.swift` | -| 11 | Create HLSSegmenter (TDD) | Segmenter + tests | -| 12 | Create StreamingServer | Hummingbird routes + tests | -| 13 | Create TunnelManager | cloudflared process | -| 14 | Create StreamingPlaybackProvider | AVPlayer + HLS | -| 15 | Create StreamingClient | HTTP + WebSocket client | -| 16 | Wire up MusicApp.swift | Mode switching, menus, sheets | -| 17 | Update ContentView | Status bar, edit guards | -| 18 | Integration tests | End-to-end round-trip | diff --git a/docs/superpowers/plans/2026-05-30-add-to-new-playlist.md b/docs/superpowers/plans/2026-05-30-add-to-new-playlist.md deleted file mode 100644 index b24d6fb..0000000 --- a/docs/superpowers/plans/2026-05-30-add-to-new-playlist.md +++ /dev/null @@ -1,327 +0,0 @@ -# Add "New Playlist…" to the Add-to-Playlist menu — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Let a user create a new regular playlist from a track's "Add to Playlist" context submenu, name it via a prompt, and have the track added to it on save. - -**Architecture:** A new "New Playlist…" item in the existing `Menu("Add to Playlist")` (in `TrackContextMenuModifier`) calls a new optional closure on `TrackContextMenuConfig`. `ContentView` owns that closure: it stashes the pending track and presents an `.alert` + `TextField` (mirroring the app's existing New Playlist alert). On save it calls a new `PlaylistViewModel.createPlaylistAndAddTrack(name:track:)`, which creates the regular playlist and adds the track in one step. - -**Tech Stack:** Swift, SwiftUI, GRDB, Swift Testing (`@Test`), Xcode (`Music` scheme). - -**Git note:** This project's owner never auto-commits — commits are made by the user via the `/commit` skill. The "Commit" steps below describe the *suggested grouping* of changes for when the user chooses to commit; do **not** run `git commit` yourself. - -**Spec:** `docs/superpowers/specs/2026-05-30-add-to-new-playlist-design.md` - ---- - -## File Structure - -- `Music/ViewModels/PlaylistViewModel.swift` — **Modify.** Add `createPlaylistAndAddTrack(name:track:)` orchestration method. -- `MusicTests/PlaylistViewModelTests.swift` — **Create.** Unit test for the new method. -- `Music/Models/TrackContextMenuConfig.swift` — **Modify.** Add `onAddToNewPlaylist` optional closure (+ init param, default `nil`). -- `Music/Views/TrackContextMenuModifier.swift` — **Modify.** Add "New Playlist…" button + relax the submenu visibility guard. -- `Music/ContentView.swift` — **Modify.** New `@State` for the pending track, wire `onAddToNewPlaylist`, present the name alert. - ---- - -## Task 1: `PlaylistViewModel.createPlaylistAndAddTrack` - -**Files:** -- Test: `MusicTests/PlaylistViewModelTests.swift` (create) -- Modify: `Music/ViewModels/PlaylistViewModel.swift` (add method after `addTrack`, ~line 77) - -- [ ] **Step 1: Write the failing test** - -Create `MusicTests/PlaylistViewModelTests.swift`: - -```swift -import Testing -import Foundation -@testable import Music - -@MainActor -struct PlaylistViewModelTests { - - // Verifies createPlaylistAndAddTrack does the full job in one call: - // 1. Seed a track into an in-memory DB and build a PlaylistViewModel over it. - // 2. Call createPlaylistAndAddTrack with a name and the seeded track. - // 3. The returned playlist has the given name and a real (non-nil) id. - // 4. The DB shows that playlist now contains exactly the seeded track. - // 5. The new playlist is recorded as the last-used playlist. - @Test func createPlaylistAndAddTrackCreatesPlaylistAndAddsTrack() throws { - // 1. Seed a track and build the view model. - let db = try DatabaseService(inMemory: true) - var track = Track.fixture(fileURL: "/song.mp3", title: "Song A") - try db.insert(&track) - let vm = PlaylistViewModel(db: db) - - // 2. Create a new playlist and add the track in one step. - let created = try vm.createPlaylistAndAddTrack(name: "Road Trip", track: track) - - // 3. The returned playlist is well-formed. - #expect(created.id != nil) - #expect(created.name == "Road Trip") - - // 4. The playlist contains exactly the seeded track. - let tracks = try db.fetchPlaylistTracks(playlistId: created.id!) - #expect(tracks.count == 1) - #expect(tracks[0].id == track.id) - - // 5. The new playlist became the last-used playlist. - #expect(vm.lastUsedPlaylistId == created.id) - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: -```bash -xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlaylistViewModelTests -``` -Expected: **build/compile failure** — `value of type 'PlaylistViewModel' has no member 'createPlaylistAndAddTrack'`. - -- [ ] **Step 3: Write the minimal implementation** - -In `Music/ViewModels/PlaylistViewModel.swift`, add this method directly after `addTrack(_:to:)` (after line 77): - -```swift - @discardableResult - func createPlaylistAndAddTrack(name: String, track: Track) throws -> Playlist { - let playlist = try db.createPlaylist(name: name) - try addTrack(track, to: playlist) - return playlist - } -``` - -`db.createPlaylist` returns a `Playlist` with its assigned `id`; the existing `addTrack` inserts the join row and sets `lastUsedPlaylistId`. - -- [ ] **Step 4: Run the test to verify it passes** - -Run: -```bash -xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlaylistViewModelTests -``` -Expected: **PASS** (`createPlaylistAndAddTrackCreatesPlaylistAndAddsTrack`). - -- [ ] **Step 5: Commit (suggested grouping — leave to the user / `/commit`)** - -Changed files: `MusicTests/PlaylistViewModelTests.swift`, `Music/ViewModels/PlaylistViewModel.swift`. -Suggested message: `feat: add PlaylistViewModel.createPlaylistAndAddTrack` - ---- - -## Task 2: Add `onAddToNewPlaylist` to `TrackContextMenuConfig` - -This is a pure data struct; the new field is optional with a `nil` default, so existing call sites and `TrackContextMenuConfigTests` keep compiling. No new unit test (the struct just stores a closure). - -**Files:** -- Modify: `Music/Models/TrackContextMenuConfig.swift` - -- [ ] **Step 1: Add the stored property** - -In `TrackContextMenuConfig`, add the property after `onAddToQueue` (after line 15): - -```swift - // nil hides the "New Playlist…" item (e.g. tests that don't supply it). - let onAddToNewPlaylist: ((Track) -> Void)? -``` - -- [ ] **Step 2: Add the init parameter (with default `nil`)** - -In the explicit `init`, add the parameter after `onAddToQueue` (line 30): - -```swift - onAddToQueue: ((Track) -> Void)? = nil, - onAddToNewPlaylist: ((Track) -> Void)? = nil, - onGetInfo: (([Track]) -> Void)? = nil -``` - -And add the assignment in the body, after `self.onAddToQueue = onAddToQueue` (line 40): - -```swift - self.onAddToNewPlaylist = onAddToNewPlaylist -``` - -- [ ] **Step 3: Build to verify it compiles** - -Run: -```bash -xcodebuild build -scheme Music -destination 'platform=macOS' -``` -Expected: **BUILD SUCCEEDED** (existing call sites still compile because the new param defaults to `nil`). - -- [ ] **Step 4: Run the existing config tests to confirm no regression** - -Run: -```bash -xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/TrackContextMenuConfigTests -``` -Expected: **PASS** (all existing tests). - -- [ ] **Step 5: Commit (suggested grouping — leave to the user / `/commit`)** - -Changed file: `Music/Models/TrackContextMenuConfig.swift`. -Suggested message: `feat: add onAddToNewPlaylist to TrackContextMenuConfig` - ---- - -## Task 3: Add "New Playlist…" to the submenu - -SwiftUI view code; no unit test (consistent with the codebase). Verify by build. - -**Files:** -- Modify: `Music/Views/TrackContextMenuModifier.swift:30-38` - -- [ ] **Step 1: Replace the "Add to Playlist" submenu block** - -Replace the current block (lines 30–38): - -```swift - if !config.playlists.isEmpty { - Menu("Add to Playlist") { - ForEach(config.playlists) { playlist in - Button(playlist.name) { - config.onAddToPlaylist(track, playlist) - } - } - } - } -``` - -with: - -```swift - if !config.playlists.isEmpty || config.onAddToNewPlaylist != nil { - Menu("Add to Playlist") { - if let onAddToNewPlaylist = config.onAddToNewPlaylist { - Button("New Playlist…") { - onAddToNewPlaylist(track) - } - if !config.playlists.isEmpty { - Divider() - } - } - ForEach(config.playlists) { playlist in - Button(playlist.name) { - config.onAddToPlaylist(track, playlist) - } - } - } - } -``` - -Notes: the button label uses a real ellipsis character `…` (macOS convention for "opens a prompt"). The submenu now appears even when the user has zero playlists, showing just "New Playlist…". The `Divider` only appears when there are existing playlists to separate from. - -- [ ] **Step 2: Build to verify it compiles** - -Run: -```bash -xcodebuild build -scheme Music -destination 'platform=macOS' -``` -Expected: **BUILD SUCCEEDED**. - -- [ ] **Step 3: Commit (suggested grouping — leave to the user / `/commit`)** - -Changed file: `Music/Views/TrackContextMenuModifier.swift`. -Suggested message: `feat: add "New Playlist…" item to Add to Playlist submenu` - ---- - -## Task 4: Wire the prompt in `ContentView` - -SwiftUI view code; no unit test. Verify by build + manual check. - -**Files:** -- Modify: `Music/ContentView.swift` — add `@State` (near the other playlist alert state, e.g. by `playlistNameInput`/`showNewPlaylistAlert`), wire the closure in `trackContextMenuConfig` (line 433–434 area), add the alert (by the existing New Playlist alert at line 273). - -- [ ] **Step 1: Add state for the pending track** - -Find the existing `@State` declarations for the playlist alerts (the same scope that declares `showNewPlaylistAlert` and `playlistNameInput`). Add nearby: - -```swift - @State private var newPlaylistTrack: Track? - @State private var newPlaylistNameInput = "" -``` - -(`newPlaylistNameInput` is kept separate from the sidebar's `playlistNameInput` so the two flows can't clobber each other's text.) - -- [ ] **Step 2: Wire the closure in `trackContextMenuConfig`** - -In `trackContextMenuConfig` (line 412), add `onAddToNewPlaylist` to the `TrackContextMenuConfig(...)` call. Insert it after the `onAddToQueue:` argument (line 433), before `onGetInfo:`: - -```swift - onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil, - onAddToNewPlaylist: { track in newPlaylistTrack = track }, - onGetInfo: { tracks in - if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks) } - } -``` - -(Wired unconditionally — matches `onAddToPlaylist`, which is also not gated on `queueEnabled`.) - -- [ ] **Step 3: Add the name-prompt alert** - -Immediately after the existing New Playlist alert block (after line 283, the closing `}` of `.alert("New Playlist", isPresented: $showNewPlaylistAlert)`), add: - -```swift - .alert("New Playlist", isPresented: Binding( - get: { newPlaylistTrack != nil }, - set: { if !$0 { newPlaylistTrack = nil; newPlaylistNameInput = "" } } - )) { - TextField("Playlist name", text: $newPlaylistNameInput) - Button("Cancel", role: .cancel) { - newPlaylistNameInput = "" - newPlaylistTrack = nil - } - Button("Create") { - let name = newPlaylistNameInput.trimmingCharacters(in: .whitespaces) - if !name.isEmpty, let track = newPlaylistTrack { - try? playlist.createPlaylistAndAddTrack(name: name, track: track) - } - newPlaylistNameInput = "" - newPlaylistTrack = nil - } - } -``` - -- [ ] **Step 4: Build to verify it compiles** - -Run: -```bash -xcodebuild build -scheme Music -destination 'platform=macOS' -``` -Expected: **BUILD SUCCEEDED**. - -- [ ] **Step 5: Manual verification** - -Launch the app. Right-click a track → **Add to Playlist** → **New Playlist…**. Enter a name, click **Create**. Confirm: -- A new playlist with that name appears in the sidebar. -- Selecting it shows the track you added. -- The sidebar selection did **not** change automatically (stayed on the current view). -- Re-open the context menu: "Add to " now appears as the last-used playlist shortcut. -- Empty name → clicking Create does nothing (no empty playlist created). - -- [ ] **Step 6: Commit (suggested grouping — leave to the user / `/commit`)** - -Changed file: `Music/ContentView.swift`. -Suggested message: `feat: prompt for name and add track when creating playlist from menu` - ---- - -## Final verification - -- [ ] Run the full test target once: - -```bash -xcodebuild test -scheme Music -destination 'platform=macOS' -``` -Expected: **all tests PASS**, including the new `PlaylistViewModelTests`. - ---- - -## Self-review (done while writing this plan) - -- **Spec coverage:** view-model create+add (Task 1), config closure (Task 2), submenu item + empty-list handling (Task 3), prompt + wiring + behavior decisions: single clicked track, no navigation, empty-name no-op (Task 4). All spec sections map to a task. -- **Placeholder scan:** none — every code step shows the exact code. -- **Type/name consistency:** `createPlaylistAndAddTrack(name:track:)` and `onAddToNewPlaylist` are used identically across Tasks 1–4; `newPlaylistTrack` / `newPlaylistNameInput` are consistent within Task 4. diff --git a/docs/superpowers/plans/2026-05-30-fix-zero-bitrate.md b/docs/superpowers/plans/2026-05-30-fix-zero-bitrate.md deleted file mode 100644 index 69569b2..0000000 --- a/docs/superpowers/plans/2026-05-30-fix-zero-bitrate.md +++ /dev/null @@ -1,607 +0,0 @@ -# Fix `bitrate = 0` Tracks Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Stop the importer from ever storing a bitrate of `0`, and backfill the real bitrate onto existing tracks where it is `0` or `NULL`. - -**Architecture:** Two independent pieces. (1) A pure, unit-tested Swift function `ScannerService.resolveBitrate` that derives kbps from AVFoundation's estimate with a file-size/duration fallback, returning `nil` (never `0`) when nothing is derivable; wired into `extractMetadata`. (2) A stdlib-only Python backfill script `scripts/backfill_bitrate.py` (modeled on the existing `backfill_itunes_dates.py`) that recomputes bitrate via `ffprobe`, falling back to the same formula, with dry-run default and `--apply` + timestamped backup. - -**Tech Stack:** Swift (AVFoundation, swift-testing), Python 3 stdlib (`sqlite3`, `subprocess`), `ffprobe` (optional external tool). - -**Project conventions to respect:** -- User's global rule: **never auto-commit.** Each "Commit checkpoint" step means *stop and run the `/commit` skill* — do not run `git commit` directly. -- Tests use swift-testing (`import Testing`, `@Test`, `#expect`), one struct per file, every test carries a step-by-step comment. -- Swift static utilities in `ScannerService` are `nonisolated static`. - -**Spec:** `docs/superpowers/specs/2026-05-30-fix-zero-bitrate-design.md` - ---- - -### Task 1: `ScannerService.resolveBitrate` pure function (Swift, TDD) - -**Files:** -- Test: `MusicTests/ScannerServiceTests.swift` (add tests to the existing `ScannerServiceTests` struct) -- Modify: `Music/Services/ScannerService.swift` (add the static function) - -- [ ] **Step 1: Write the failing tests** - -Add these five tests inside the existing `struct ScannerServiceTests { ... }` in `MusicTests/ScannerServiceTests.swift`, just before the closing brace: - -```swift - // Verifies resolveBitrate uses the OS estimate when it is positive. - // 1. Passes a positive estimatedDataRate in bits/sec (320450). - // 2. Expects it rounded to kbps (320450/1000 = 320.45 -> 320), ignoring size/duration. - @Test func resolveBitrateUsesEstimateWhenPositive() { - let kbps = ScannerService.resolveBitrate(estimatedDataRate: 320_450, - fileSizeBytes: 5_000_000, - durationSeconds: 200) - #expect(kbps == 320) - } - - // Verifies the size/duration fallback when the OS estimate is 0 (the AVFoundation bug). - // 1. Passes estimatedDataRate 0 with a real file size and duration. - // 2. Expects 230_358_479 * 8 / 7198.54 / 1000 -> ~256.0 -> 256 kbps (matches ffprobe). - @Test func resolveBitrateFallsBackToSizeAndDuration() { - let kbps = ScannerService.resolveBitrate(estimatedDataRate: 0, - fileSizeBytes: 230_358_479, - durationSeconds: 7198.5371428571425) - #expect(kbps == 256) - } - - // Verifies nil (never 0) when the estimate is 0 and duration is unusable. - // 1. Zero duration cannot yield a value -> nil. - // 2. A NaN duration (CMTimeGetSeconds can return NaN) is also nil, not 0. - @Test func resolveBitrateReturnsNilWhenNoDuration() { - #expect(ScannerService.resolveBitrate(estimatedDataRate: 0, - fileSizeBytes: 230_358_479, - durationSeconds: 0) == nil) - #expect(ScannerService.resolveBitrate(estimatedDataRate: 0, - fileSizeBytes: 230_358_479, - durationSeconds: .nan) == nil) - } - - // Verifies nil when the estimate is 0 and there is no file size. - // 1. Missing fileSizeBytes with estimate 0 -> nil (never 0). - @Test func resolveBitrateReturnsNilWhenNoFileSize() { - #expect(ScannerService.resolveBitrate(estimatedDataRate: 0, - fileSizeBytes: nil, - durationSeconds: 200) == nil) - } - - // Verifies the core invariant: no input combination ever yields 0. - // 1. All-zero inputs return nil so the UI renders "—" instead of "0 kbps". - @Test func resolveBitrateNeverReturnsZero() { - #expect(ScannerService.resolveBitrate(estimatedDataRate: 0, - fileSizeBytes: 0, - durationSeconds: 0) == nil) - } -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: -```bash -xcodebuild test -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \ - -scheme Music -destination 'platform=macOS' \ - -only-testing:MusicTests/ScannerServiceTests 2>&1 | tail -30 -``` -Expected: **compile failure** — `type 'ScannerService' has no member 'resolveBitrate'`. -(If the destination errors, list options with `xcodebuild -showdestinations -project Music.xcodeproj -scheme Music` and use the macOS one.) - -- [ ] **Step 3: Write the minimal implementation** - -In `Music/Services/ScannerService.swift`, add this method right after the `discoverAudioFiles` static function (after line 35, inside the class): - -```swift - /// Resolve a track's bitrate in kbps from the OS estimate, falling back to a - /// size/duration average. Returns nil when nothing can be derived — never 0, - /// so the UI shows "—" instead of a meaningless "0 kbps". - /// - /// AVFoundation's `estimatedDataRate` returns 0 for some files (observed on - /// long/VBR MP3s); for those we compute the true average bitrate from the - /// file size and duration, which matches ffprobe to the kbps. - nonisolated static func resolveBitrate(estimatedDataRate: Double, - fileSizeBytes: Int64?, - durationSeconds: Double?) -> Int? { - if estimatedDataRate > 0 { - return Int((estimatedDataRate / 1000).rounded()) - } - // NaN-safe: `dur > 0` is false for .nan, so we return nil rather than 0. - if let size = fileSizeBytes, size > 0, - let dur = durationSeconds, dur > 0 { - return Int((Double(size) * 8 / dur / 1000).rounded()) - } - return nil - } -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: -```bash -xcodebuild test -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \ - -scheme Music -destination 'platform=macOS' \ - -only-testing:MusicTests/ScannerServiceTests 2>&1 | tail -30 -``` -Expected: **TEST SUCCEEDED**, all `ScannerServiceTests` pass (the new five plus the existing `discoverAudioFiles`). - -- [ ] **Step 5: Commit checkpoint** - -Stop and run the `/commit` skill (do not `git commit` directly). Suggested message: `feat: add ScannerService.resolveBitrate with size/duration fallback`. - ---- - -### Task 2: Wire `resolveBitrate` into `extractMetadata` - -**Files:** -- Modify: `Music/Services/ScannerService.swift:146-162` (the bitrate block inside `extractMetadata`) - -- [ ] **Step 1: Move the file-stats computation above the bitrate block** - -In `extractMetadata`, `TrackFileStats.compute` currently runs at line 162, *after* the bitrate block. Move it up so its `fileSize` is available to `resolveBitrate`. Replace the current block that spans from the duration load through the bitrate computation (lines 146-160): - -```swift - let duration = try await asset.load(.duration) - let durationSeconds = CMTimeGetSeconds(duration) - - var bitrate: Int? - var sampleRate: Int? - if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first { - let estimatedRate = try await audioTrack.load(.estimatedDataRate) - bitrate = Int(estimatedRate / 1000) - let descriptions = try await audioTrack.load(.formatDescriptions) - if let desc = descriptions.first { - if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) { - sampleRate = Int(asbd.pointee.mSampleRate) - } - } - } -``` - -with this: - -```swift - let duration = try await asset.load(.duration) - let durationSeconds = CMTimeGetSeconds(duration) - - let stats = try TrackFileStats.compute(for: url) - - var bitrate: Int? - var sampleRate: Int? - if let audioTrack = try await asset.loadTracks(withMediaType: .audio).first { - let estimatedRate = try await audioTrack.load(.estimatedDataRate) - bitrate = Self.resolveBitrate(estimatedDataRate: estimatedRate, - fileSizeBytes: stats.fileSize, - durationSeconds: durationSeconds) - let descriptions = try await audioTrack.load(.formatDescriptions) - if let desc = descriptions.first { - if let asbd = CMAudioFormatDescriptionGetStreamBasicDescription(desc) { - sampleRate = Int(asbd.pointee.mSampleRate) - } - } - } else { - // No audio track loaded — still attempt the size/duration fallback - // so we never silently lose the bitrate. - bitrate = Self.resolveBitrate(estimatedDataRate: 0, - fileSizeBytes: stats.fileSize, - durationSeconds: durationSeconds) - } -``` - -- [ ] **Step 2: Remove the now-duplicate `TrackFileStats.compute` call** - -The original line 162 `let stats = try TrackFileStats.compute(for: url)` (now appearing just above the `return Track(` call) is a duplicate — delete that single line. The `Track(...)` initializer still references `stats.fileSize`, `stats.dateModified`, and `stats.fileHash`, which now come from the moved-up computation. Confirm exactly one `let stats = try TrackFileStats.compute(for: url)` remains in the function. - -- [ ] **Step 3: Build to verify it compiles** - -Run: -```bash -xcodebuild build -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \ - -scheme Music -destination 'platform=macOS' 2>&1 | tail -15 -``` -Expected: **BUILD SUCCEEDED**. - -- [ ] **Step 4: Re-run the full test target to confirm no regressions** - -Run: -```bash -xcodebuild test -project /Users/laurentmorvillier/code/Music/Music.xcodeproj \ - -scheme Music -destination 'platform=macOS' \ - -only-testing:MusicTests 2>&1 | tail -20 -``` -Expected: **TEST SUCCEEDED** for the whole `MusicTests` target. - -- [ ] **Step 5: Commit checkpoint** - -Stop and run the `/commit` skill. Suggested message: `fix: importer derives bitrate via resolveBitrate instead of storing 0`. - ---- - -### Task 3: Backfill script — pure helpers + self-test (Python, TDD) - -**Files:** -- Create: `scripts/backfill_bitrate.py` - -- [ ] **Step 1: Create the script with helpers and a failing self-test** - -Create `scripts/backfill_bitrate.py` with exactly this content: - -```python -#!/usr/bin/env python3 -"""One-time backfill of real bitrate onto tracks stored with bitrate 0 or NULL. - -ScannerService writes `bitrate = Int(estimatedDataRate / 1000)` at scan time. -AVFoundation's estimatedDataRate returns 0 for some files (long/VBR MP3s), so a -literal 0 gets stored; other tracks were imported before bitrate existed and are -NULL. This script recomputes bitrate for those rows using ffprobe, falling back -to fileSize*8/duration (the same average the app's importer now uses) when -ffprobe is unavailable or can't determine a value. - -Dry-run by default. Pass --apply to write (a timestamped backup is made first). - -Usage: - python3 backfill_bitrate.py [--db ] [--apply] - python3 backfill_bitrate.py --self-test - -Stdlib only; uses ffprobe if present on PATH (optional). -""" - -import argparse -import os -import shutil -import sqlite3 -import subprocess -import sys -import unicodedata -from datetime import datetime -from urllib.parse import unquote - -# Default DB path for the sandboxed app (bundle id com.staxriver.mu). Computed from -# $HOME so it resolves to the right user on whichever Mac the script runs on. -DEFAULT_DB = os.path.expanduser( - "~/Library/Containers/com.staxriver.mu/Data/Library/" - "Application Support/Music/db.sqlite" -) - - -def norm_path(u): - """Reduce a file:// URL (or bare path) to a comparable, on-disk POSIX path. - - The app stores `fileURL` as Foundation's url.absoluteString (a percent-encoded - file URL). Decode it, drop the file:// (or file://localhost) prefix, NFC- - normalize, and strip a trailing slash so it can be stat'd on APFS. - """ - s = u - if s.startswith("file://"): - s = s[len("file://"):] - if s.startswith("localhost/"): - s = s[len("localhost"):] # leaves the leading "/" - s = unquote(s) - s = unicodedata.normalize("NFC", s) - if len(s) > 1 and s.endswith("/"): - s = s[:-1] - return s - - -def parse_ffprobe_bitrate(stdout): - """Parse ffprobe's bit_rate stdout (bits/sec) into integer kbps, or None. - - Returns None for empty output, 'N/A', or any non-integer text so the caller - falls back to the formula. - """ - s = stdout.strip() - if not s or s == "N/A": - return None - try: - return round(int(s) / 1000) - except ValueError: - return None - - -def kbps_from_ffprobe(path): - """Return integer kbps from ffprobe's format bit_rate, or None if unavailable. - - None on: ffprobe not installed, ffprobe error, or N/A/empty/non-integer output. - """ - try: - out = subprocess.run( - ["ffprobe", "-v", "error", "-show_entries", "format=bit_rate", - "-of", "default=nw=1:nk=1", path], - capture_output=True, text=True, timeout=30, - ) - except (FileNotFoundError, subprocess.SubprocessError): - return None - return parse_ffprobe_bitrate(out.stdout) - - -def kbps_from_formula(file_size, duration): - """Average kbps from size (bytes) and duration (seconds): size*8/duration/1000. - - Returns None when inputs can't yield a meaningful value (missing size, or - non-positive/missing duration). - """ - if not file_size or not duration or duration <= 0: - return None - return round(file_size * 8 / duration / 1000) - - -def resolve_bitrate(path, duration): - """Best available kbps for an on-disk file: ffprobe first, formula fallback. - - `duration` is the DB's stored seconds; file size is read from disk. Returns - None if neither method can produce a positive value. - """ - kbps = kbps_from_ffprobe(path) - if kbps and kbps > 0: - return kbps - try: - size = os.path.getsize(path) - except OSError: - size = None - return kbps_from_formula(size, duration) - - -def ffprobe_available(): - return shutil.which("ffprobe") is not None - - -def self_test(): - """Fast smoke check of the pure helpers (no DB, no ffprobe needed).""" - # ffprobe stdout parsing - assert parse_ffprobe_bitrate("256005\n") == 256 - assert parse_ffprobe_bitrate("N/A") is None - assert parse_ffprobe_bitrate("") is None - assert parse_ffprobe_bitrate("garbage") is None - - # formula: 230_358_479 bytes over 7198.54 s -> 256 kbps (matches ffprobe sample) - assert kbps_from_formula(230_358_479, 7198.5371428571425) == 256 - assert kbps_from_formula(None, 100) is None - assert kbps_from_formula(1000, 0) is None - assert kbps_from_formula(1000, None) is None - - # path normalization (NFD vs NFC accents, percent-encoding, localhost host) - nfc = norm_path("file:///Users/x/Mu%CC%81sica/Cafe%CC%81.mp3") - nfd = norm_path("file://localhost/Users/x/M%C3%BAsica/Caf%C3%A9.mp3") - assert nfc == nfd == "/Users/x/Música/Café.mp3", (nfc, nfd) - assert norm_path("file:///a/b%20c%23d.mp3") == "/a/b c#d.mp3" - - print("self-test OK") - - -def main(argv=None): - p = argparse.ArgumentParser(description=__doc__) - p.add_argument("--db", default=DEFAULT_DB, help=f"App DB path (default: {DEFAULT_DB})") - p.add_argument("--apply", action="store_true", help="Write changes (default: dry run).") - p.add_argument("--self-test", action="store_true", help="Run the built-in smoke test.") - args = p.parse_args(argv) - - if args.self_test: - self_test() - return 0 - - if not os.path.exists(args.db): - p.error(f"DB not found: {args.db}") - - run(args.db, args.apply) - return 0 - - -if __name__ == "__main__": - sys.exit(main()) -``` - -Note: `run(...)` is referenced by `main` but not yet defined — Task 4 adds it. The self-test does not call `run`, so `--self-test` works now. - -- [ ] **Step 2: Run the self-test to verify the helpers pass** - -Run: -```bash -python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --self-test -``` -Expected: `self-test OK`. - -- [ ] **Step 3: Verify the dry-run path fails cleanly (run undefined)** - -Run: -```bash -python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --db /nonexistent.sqlite -``` -Expected: argparse error `DB not found: /nonexistent.sqlite` (exit 2) — confirms arg handling before `run` is implemented. - -- [ ] **Step 4: Commit checkpoint** - -Stop and run the `/commit` skill. Suggested message: `feat: add backfill_bitrate.py helpers + self-test`. - ---- - -### Task 4: Backfill script — DB wiring, dry-run report, `--apply` - -**Files:** -- Modify: `scripts/backfill_bitrate.py` (add `fetch_rows`, `build_updates`, `backup_db`, `apply_updates`, `run`) - -- [ ] **Step 1: Add the DB + reporting functions** - -Insert these functions into `scripts/backfill_bitrate.py` immediately before `def main(`: - -```python -def fetch_rows(db_path): - """Return candidate rows: (id, fileURL, duration, bitrate) where bitrate is 0/NULL.""" - con = sqlite3.connect(db_path) - try: - return con.execute( - "SELECT id, fileURL, duration, bitrate FROM tracks " - "WHERE bitrate = 0 OR bitrate IS NULL" - ).fetchall() - finally: - con.close() - - -def build_updates(rows): - """Resolve a new bitrate for each candidate row. - - Returns (updates, missing, undeterminable): - - updates: list of {id, file_url, old, new} where new is a positive kbps - - missing: (id, path) for rows whose file is not on disk (left untouched) - - undeterminable: (id, path) for on-disk files whose bitrate couldn't be found - """ - updates, missing, undeterminable = [], [], [] - for row_id, file_url, duration, old in rows: - path = norm_path(file_url) - if not os.path.exists(path): - missing.append((row_id, path)) - continue - new = resolve_bitrate(path, duration) - if not new or new <= 0: - undeterminable.append((row_id, path)) - continue - updates.append({"id": row_id, "file_url": file_url, "old": old, "new": new}) - return updates, missing, undeterminable - - -def backup_db(db_path): - """Copy db.sqlite (+ -wal, -shm) under backups// next to the DB.""" - stamp = datetime.now().strftime("%Y%m%d-%H%M%S") - backup_dir = os.path.join(os.path.dirname(db_path), "backups", stamp) - os.makedirs(backup_dir, exist_ok=True) - for suffix in ("", "-wal", "-shm"): - src = db_path + suffix - if os.path.exists(src): - shutil.copy2(src, os.path.join(backup_dir, os.path.basename(src))) - return backup_dir - - -def apply_updates(db_path, updates): - """Write bitrate updates in a single transaction, then checkpoint the WAL.""" - con = sqlite3.connect(db_path) - try: - con.execute("BEGIN") - con.executemany("UPDATE tracks SET bitrate=:new WHERE id=:id", updates) - con.commit() - con.execute("PRAGMA wal_checkpoint(TRUNCATE)") - finally: - con.close() - - -def run(db_path, apply): - rows = fetch_rows(db_path) - updates, missing, undeterminable = build_updates(rows) - - print(f"Candidate rows (bitrate 0 or NULL): {len(rows)}") - print(f"Resolvable (will set): {len(updates)}") - print(f"Skipped — file missing on disk: {len(missing)}") - print(f"Skipped — could not determine: {len(undeterminable)}") - if not ffprobe_available(): - print("NOTE: ffprobe not on PATH — used the filesize/duration formula for all rows.") - print() - - for u in updates[:15]: - name = os.path.basename(norm_path(u["file_url"])) - old = "NULL" if u["old"] is None else u["old"] - print(f" • {name}") - print(f" bitrate {old} -> {u['new']} kbps") - if len(updates) > 15: - print(f" ... and {len(updates) - 15} more") - print() - - if missing[:5]: - print("Sample of skipped (file missing on disk, left untouched):") - for row_id, path in missing[:5]: - print(f" - [{row_id}] {os.path.basename(path)}") - print() - - if undeterminable[:5]: - print("Sample of skipped (could not determine bitrate, left untouched):") - for row_id, path in undeterminable[:5]: - print(f" - [{row_id}] {os.path.basename(path)}") - print() - - if not apply: - print("DRY RUN — nothing written. Re-run with --apply to commit these changes.") - return - - if not updates: - print("Nothing to apply.") - return - - backup_dir = backup_db(db_path) - print(f"Backup written to: {backup_dir}") - apply_updates(db_path, updates) - print(f"Applied {len(updates)} bitrate updates to {db_path}") -``` - -- [ ] **Step 2: Re-run the self-test (ensure the new code didn't break helpers)** - -Run: -```bash -python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --self-test -``` -Expected: `self-test OK`. - -- [ ] **Step 3: Dry-run against a temp DB to verify end-to-end wiring** - -This builds a tiny throwaway DB with one real bitrate=0 row, so the run path is exercised without touching the app DB: - -```bash -python3 - <<'PY' -import sqlite3, os, tempfile -d = tempfile.mkdtemp() -db = os.path.join(d, "db.sqlite") -con = sqlite3.connect(db) -con.execute("CREATE TABLE tracks (id INTEGER PRIMARY KEY, fileURL TEXT, duration REAL, bitrate INTEGER)") -# A file that does not exist -> should be reported as 'missing', not crash. -con.execute("INSERT INTO tracks (fileURL, duration, bitrate) VALUES (?,?,?)", - ("file:///no/such/file.mp3", 100.0, 0)) -con.commit(); con.close() -print(db) -PY -``` -Take the printed path and run: -```bash -python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --db -``` -Expected: a summary with `Candidate rows ... : 1`, `Skipped — file missing on disk: 1`, and `DRY RUN — nothing written.` (no traceback). - -- [ ] **Step 4: Commit checkpoint** - -Stop and run the `/commit` skill. Suggested message: `feat: backfill_bitrate.py DB wiring, dry-run report, --apply`. - ---- - -### Task 5: Verify against the real library (manual, no code change) - -**Files:** none. - -- [ ] **Step 1: Dry-run against the real app DB** - -Quit the Music app first (avoids WAL/lock contention). Then: -```bash -python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py -``` -Expected: a non-zero `Resolvable (will set)` count and a sample of `bitrate 0 -> NNN kbps` lines. Eyeball a few values for plausibility (typical 128–320 kbps). - -- [ ] **Step 2: Apply** - -```bash -python3 /Users/laurentmorvillier/code/Music/scripts/backfill_bitrate.py --apply -``` -Expected: `Backup written to: .../backups/` then `Applied N bitrate updates`. - -- [ ] **Step 3: Confirm the DB no longer has 0/NULL bitrates (except undeterminable)** - -```bash -DB="$HOME/Library/Containers/com.staxriver.mu/Data/Library/Application Support/Music/db.sqlite" -sqlite3 "$DB" "SELECT COUNT(*) AS still_zero_or_null FROM tracks WHERE bitrate = 0 OR bitrate IS NULL;" -``` -Expected: `0`, or the small count the dry-run reported as "could not determine"/"file missing". - -- [ ] **Step 4: Reopen the app and spot-check** - -Open a previously-0 track's Get Info (or the Bit Rate column) and confirm it now shows a real value. - ---- - -## Self-Review Notes - -- **Spec coverage:** Root cause + invariant → Tasks 1–2 (`resolveBitrate`, never stores 0). Backfill (0 and NULL, ffprobe→formula, missing-file skip, dry-run/backup/apply) → Tasks 3–4. Manual verification → Task 5. All spec sections covered. -- **Type consistency:** `resolveBitrate(estimatedDataRate:fileSizeBytes:durationSeconds:)` is defined identically in Task 1 and called identically in Task 2; `stats.fileSize` is `Int64` (matches `fileSizeBytes: Int64?`). Python helper names (`parse_ffprobe_bitrate`, `kbps_from_ffprobe`, `kbps_from_formula`, `resolve_bitrate`, `fetch_rows`, `build_updates`, `backup_db`, `apply_updates`, `run`) are defined once and referenced consistently. -- **No placeholders:** every code step shows complete code; every run step shows the command and expected output. diff --git a/docs/superpowers/plans/2026-05-30-playing-queue.md b/docs/superpowers/plans/2026-05-30-playing-queue.md deleted file mode 100644 index 083dcff..0000000 --- a/docs/superpowers/plans/2026-05-30-playing-queue.md +++ /dev/null @@ -1,780 +0,0 @@ -# Playing Queue Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a Spotify-style priority "Up Next" queue: tracks can be pushed to the front ("Play Next") or end ("Add to Queue") via the track context menu, played before the playlist/album context resumes, and managed in a right-docked panel. - -**Architecture:** `PlayerViewModel` keeps its existing `queue`/`currentIndex` as the playback **context** and gains a parallel `manualQueue` of `QueueEntry` (a track + stable UUID). `next()` drains the manual queue before advancing the context; a dedicated `playManual(_:)` plays a queued track without moving `currentIndex`, so the context resumes correctly. A new `QueueView` renders the panel; the context menu and `ContentView` are wired up. Local-only for v1 — queue actions are hidden when driving a remote device. - -**Tech Stack:** Swift, SwiftUI + AppKit (`NSTableView`), Swift Testing (`import Testing`), Xcode 16 synchronized groups (no `pbxproj` edits for new files). - -**Spec:** `docs/superpowers/specs/2026-05-30-playing-queue-design.md` - -> **Project rule — commits:** This repo's CLAUDE.md says *never commit unless triggered by the `/commit` skill*. The "Commit" steps below are checkpoints: stage the listed files and ask the user to run `/commit` (or run it when they direct). Do **not** commit autonomously. - -> **Test command:** `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing: 2>&1 | tail -30`. Full build: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -20`. - ---- - -## Task 1: `QueueEntry` model + queue state and add actions - -**Files:** -- Create: `Music/Models/QueueEntry.swift` -- Modify: `Music/ViewModels/PlayerViewModel.swift` (add state + methods) -- Test: `MusicTests/PlayerViewModelTests.swift` - -- [ ] **Step 1: Write the failing tests** - -Add these tests inside the `PlayerViewModelTests` struct in `MusicTests/PlayerViewModelTests.swift` (after the existing tests, before the closing `}` of the struct): - -```swift - // Step 1: context [1,2,3], track 1 playing. - // Step 2: addToQueue twice → manual queue holds those tracks in arrival order. - // Step 3: playNext jumps a track to the FRONT of the manual queue. - @Test func addToQueueAppendsAndPlayNextInsertsFront() { - let vm = PlayerViewModel(provider: AudioService(), db: nil) - let tracks = makeTracks(6) - vm.setQueue(Array(tracks[0..<3])) - vm.play(tracks[0]) - - vm.addToQueue(tracks[3]) // id 4 - vm.addToQueue(tracks[4]) // id 5 - #expect(vm.manualQueue.map { $0.track.id } == [4, 5]) - - vm.playNext(tracks[5]) // id 6 to the front - #expect(vm.manualQueue.map { $0.track.id } == [6, 4, 5]) - } - - // Step 1: a view model with nothing playing (idle). - // Step 2: addToQueue should start playback immediately (queue-while-idle) and - // leave the manual queue empty because the track was consumed to play. - @Test func queueWhileIdleStartsPlayback() { - let vm = PlayerViewModel(provider: AudioService(), db: nil) - let track = Track.fixture(id: 1, fileURL: "/a.mp3", title: "A") - - vm.addToQueue(track) - - #expect(vm.currentTrack?.id == 1) - #expect(vm.manualQueue.isEmpty) - } -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30` -Expected: FAIL to compile — `value of type 'PlayerViewModel' has no member 'manualQueue' / 'addToQueue' / 'playNext'`. - -- [ ] **Step 3: Create the `QueueEntry` model** - -Create `Music/Models/QueueEntry.swift`: - -```swift -import Foundation - -// A single slot in the manual "Up Next" queue. Carries its own stable identity so -// the same track can be queued more than once without SwiftUI confusing the rows — -// Track.id alone is not unique across duplicate queue entries. -nonisolated struct QueueEntry: Identifiable { - let id = UUID() - let track: Track -} -``` - -- [ ] **Step 4: Add queue state to `PlayerViewModel`** - -In `Music/ViewModels/PlayerViewModel.swift`, add the new stored properties immediately after the `originalQueue` line (currently line 20): - -```swift - private var originalQueue: [Track] = [] - /// The manual "Up Next" queue. Plays ahead of `queue` (the context) and survives - /// starting a new context. `queue`/`currentIndex` remain the CONTEXT position. - private(set) var manualQueue: [QueueEntry] = [] - /// Display label for the panel's "Next from: " section. - private(set) var contextName: String? -``` - -- [ ] **Step 5: Add the manual-queue methods and `playManual`** - -In the same file, add a new section just after the `// MARK: - Queue Management` block (after `setQueue`'s closing brace, currently line 99). Note the `remoteProvider == nil` guard — the manual queue is local-only for v1: - -```swift - // MARK: - Manual Queue - - func playNext(_ track: Track) { - guard remoteProvider == nil else { return } - manualQueue.insert(QueueEntry(track: track), at: 0) - startQueuedTrackIfIdle() - } - - func addToQueue(_ track: Track) { - guard remoteProvider == nil else { return } - manualQueue.append(QueueEntry(track: track)) - startQueuedTrackIfIdle() - } - - func removeFromQueue(at offsets: IndexSet) { - manualQueue.remove(atOffsets: offsets) - } - - func moveInQueue(from source: IndexSet, to destination: Int) { - manualQueue.move(fromOffsets: source, toOffset: destination) - } - - /// Context tracks after the current context position — the panel's "Next from" - /// section. Empty when there is no context or we are at its end. - var upcomingContext: [Track] { - guard let idx = currentIndex, idx + 1 < queue.count else { return [] } - return Array(queue[(idx + 1)...]) - } - - // If nothing is playing, start the just-queued track immediately rather than - // parking it — matches Spotify's "queue while idle starts playback". - private func startQueuedTrackIfIdle() { - guard currentTrack == nil, !manualQueue.isEmpty else { return } - let entry = manualQueue.removeFirst() - playManual(entry.track) - } - - // Plays a track pulled from the manual queue. Mirrors play(_:) but deliberately - // does NOT touch currentIndex, so the context position is preserved and resumes - // once the manual queue drains. - private func playManual(_ track: Track) { - currentTrack = track - halfwayReported = false - isPlaying = true - currentTime = 0 - duration = track.duration - guard let url = provider.urlForTrack(track) else { return } - provider.play(url: url) - } -``` - -- [ ] **Step 6: Run the tests to verify they pass** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30` -Expected: PASS (the two new tests plus all existing PlayerViewModel tests). - -- [ ] **Step 7: Commit (checkpoint — via `/commit`)** - -Stage and request a commit: - -```bash -git add Music/Models/QueueEntry.swift Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift -# Then ask the user to run /commit (suggested message: "feat: add manual queue state and Play Next / Add to Queue to PlayerViewModel") -``` - ---- - -## Task 2: `next()` drains the manual queue, then resumes the context - -**Files:** -- Modify: `Music/ViewModels/PlayerViewModel.swift:160-172` (the `next()` method) -- Test: `MusicTests/PlayerViewModelTests.swift` - -- [ ] **Step 1: Write the failing tests** - -Add to `PlayerViewModelTests`: - -```swift - // Step 1: context [1,2,3], track 1 playing (currentIndex 0). - // Step 2: queue track 5. next() must play the QUEUED track, not context track 2, - // consume it from the queue, and leave currentIndex at 0 (context held). - @Test func nextConsumesManualQueueBeforeContext() { - let vm = PlayerViewModel(provider: AudioService(), db: nil) - let tracks = makeTracks(6) - vm.setQueue(Array(tracks[0..<3])) - vm.play(tracks[0]) - - vm.addToQueue(tracks[4]) // id 5 - vm.next() - - #expect(vm.currentTrack?.id == 5) - #expect(vm.manualQueue.isEmpty) - #expect(vm.currentIndex == 0) - } - - // Step 1: context [1,2,3], track 1 playing; queue track 5. - // Step 2: first next() plays the queued track 5 (context still at index 0). - // Step 3: second next() finds the queue empty and resumes the context at index 1 - // → track 2. - @Test func contextResumesAfterManualQueueDrains() { - let vm = PlayerViewModel(provider: AudioService(), db: nil) - let tracks = makeTracks(6) - vm.setQueue(Array(tracks[0..<3])) - vm.play(tracks[0]) - - vm.addToQueue(tracks[4]) // id 5 - vm.next() // plays queued track 5 - vm.next() // resumes context - - #expect(vm.currentTrack?.id == 2) - #expect(vm.currentIndex == 1) - } -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests/nextConsumesManualQueueBeforeContext 2>&1 | tail -30` -Expected: FAIL — `next()` currently advances the context, so `currentTrack?.id` is `2`, not `5`. - -- [ ] **Step 3: Update `next()`** - -In `Music/ViewModels/PlayerViewModel.swift`, replace the existing `next()` method: - -```swift - func next() { - if let remote = remoteProvider { - remote.sendNext() - return - } - guard let idx = currentIndex else { return } - let nextIdx = idx + 1 - if nextIdx < queue.count { - play(queue[nextIdx]) - } else { - stop() - } - } -``` - -with: - -```swift - func next() { - if let remote = remoteProvider { - remote.sendNext() - return - } - // Manual queue takes priority and is consumed as it plays. - if !manualQueue.isEmpty { - let entry = manualQueue.removeFirst() - playManual(entry.track) - return - } - // Otherwise advance the context from the preserved position. - guard let idx = currentIndex else { return } - let nextIdx = idx + 1 - if nextIdx < queue.count { - play(queue[nextIdx]) - } else { - stop() - } - } -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30` -Expected: PASS (new tests + existing tests, including `nextAtEndStops` and `nextAdvancesToNextTrack`, still green). - -- [ ] **Step 5: Commit (checkpoint — via `/commit`)** - -```bash -git add Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift -# Suggested message: "feat: next() drains the manual queue before advancing the context" -``` - ---- - -## Task 3: Edit ops, `upcomingContext`, `setQueue(contextName:)`, shuffle isolation - -**Files:** -- Modify: `Music/ViewModels/PlayerViewModel.swift` (`setQueue`, `setProvider`) -- Test: `MusicTests/PlayerViewModelTests.swift` - -- [ ] **Step 1: Write the failing tests** - -Add to `PlayerViewModelTests`: - -```swift - // Step 1: context [1,2,3], track 1 playing; queue tracks 4,5,6. - // Step 2: removeFromQueue removes the middle entry → [4,6]. - // Step 3: moveInQueue moves the last entry to the front → [6,4]. - @Test func removeAndMoveMutateManualQueue() { - let vm = PlayerViewModel(provider: AudioService(), db: nil) - let tracks = makeTracks(6) - vm.setQueue(Array(tracks[0..<3])) - vm.play(tracks[0]) - vm.addToQueue(tracks[3]); vm.addToQueue(tracks[4]); vm.addToQueue(tracks[5]) - #expect(vm.manualQueue.map { $0.track.id } == [4, 5, 6]) - - vm.removeFromQueue(at: IndexSet(integer: 1)) - #expect(vm.manualQueue.map { $0.track.id } == [4, 6]) - - vm.moveInQueue(from: IndexSet(integer: 1), to: 0) - #expect(vm.manualQueue.map { $0.track.id } == [6, 4]) - } - - // Step 1: context [1,2,3,4], track 2 playing (currentIndex 1). - // Step 2: upcomingContext is the slice after the current position → [3,4]. - @Test func upcomingContextReturnsTracksAfterCurrent() { - let vm = PlayerViewModel(provider: AudioService(), db: nil) - let tracks = makeTracks(4) - vm.setQueue(tracks) - vm.play(tracks[1]) - - #expect(vm.upcomingContext.map { $0.id } == [3, 4]) - } - - // Step 1: 10-track context, one playing; queue tracks 11 and 12 in order. - // Step 2: toggling shuffle reorders only the context — the manual queue order - // must be left exactly as the user arranged it. - @Test func shuffleLeavesManualQueueIntact() { - let vm = PlayerViewModel(provider: AudioService(), db: nil) - let tracks = makeTracks(12) - vm.setQueue(Array(tracks[0..<10])) - vm.play(tracks[0]) - vm.addToQueue(tracks[10]) // id 11 - vm.addToQueue(tracks[11]) // id 12 - - vm.toggleShuffle() - - #expect(vm.manualQueue.map { $0.track.id } == [11, 12]) - } - - // Step 1: setQueue accepts an optional context label for the panel header. - @Test func setQueueStoresContextName() { - let vm = PlayerViewModel(provider: AudioService(), db: nil) - vm.setQueue(makeTracks(2), contextName: "Synthwave") - #expect(vm.contextName == "Synthwave") - } -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests/setQueueStoresContextName 2>&1 | tail -30` -Expected: FAIL to compile — `setQueue` has no `contextName:` parameter. (`removeAndMoveMutateManualQueue`, `upcomingContext...`, and `shuffleLeaves...` already pass from Task 1's methods, except the `contextName` compile error blocks the whole suite.) - -- [ ] **Step 3: Add the `contextName` parameter to `setQueue`** - -In `Music/ViewModels/PlayerViewModel.swift`, replace the `setQueue` signature line: - -```swift - func setQueue(_ tracks: [Track]) { - originalQueue = tracks -``` - -with: - -```swift - func setQueue(_ tracks: [Track], contextName: String? = nil) { - self.contextName = contextName - originalQueue = tracks -``` - -(The default `nil` keeps existing callers and tests compiling.) - -- [ ] **Step 4: Reset the new state in `setProvider`** - -In the `setProvider(_:)` method, add the two resets next to the existing `queue = []` / `originalQueue = []` lines (currently lines 55-56): - -```swift - queue = [] - originalQueue = [] - manualQueue = [] - contextName = nil -``` - -- [ ] **Step 5: Run the tests to verify they pass** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/PlayerViewModelTests 2>&1 | tail -30` -Expected: PASS (all PlayerViewModel tests, old and new). - -- [ ] **Step 6: Commit (checkpoint — via `/commit`)** - -```bash -git add Music/ViewModels/PlayerViewModel.swift MusicTests/PlayerViewModelTests.swift -# Suggested message: "feat: queue edit ops, upcomingContext, and contextName label" -``` - ---- - -## Task 4: Context-menu config — `onPlayNext` / `onAddToQueue` + both menu builders - -**Files:** -- Modify: `Music/Models/TrackContextMenuConfig.swift` -- Modify: `Music/Views/TrackContextMenuModifier.swift` (SwiftUI menu) -- Modify: `Music/Views/TrackTableView.swift:328-394` (AppKit menu + actions) -- Test: `MusicTests/TrackContextMenuConfigTests.swift` - -- [ ] **Step 1: Write the failing test** - -The two new config fields get `= nil` defaults (Step 3), so the existing -`TrackContextMenuConfig(...)` constructions in this file and in `ContentView` -keep compiling untouched. Just add a new test to `TrackContextMenuConfigTests`: - -```swift - // Verifies the queue callbacks fire with the right track. - @Test func queueCallbacksFire() { - let track = Track.fixture(id: 7, title: "Q") - var playNextTrack: Track? = nil - var addQueueTrack: Track? = nil - - let config = TrackContextMenuConfig( - playlists: [], - lastUsedPlaylistName: nil, - selectedPlaylist: nil, - onAddToPlaylist: { _, _ in }, - onAddToLastPlaylist: nil, - onRemoveFromPlaylist: nil, - onPlayNext: { t in playNextTrack = t }, - onAddToQueue: { t in addQueueTrack = t } - ) - - config.onPlayNext?(track) - config.onAddToQueue?(track) - - #expect(playNextTrack?.id == 7) - #expect(addQueueTrack?.id == 7) - } -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/TrackContextMenuConfigTests 2>&1 | tail -30` -Expected: FAIL to compile — `extra arguments 'onPlayNext', 'onAddToQueue'` (the struct has no such members yet). - -- [ ] **Step 3: Add the closures to the config struct** - -In `Music/Models/TrackContextMenuConfig.swift`, add the two fields after `onRemoveFromPlaylist`. The `= nil` defaults flow into the synthesized memberwise initializer, so existing callers (ContentView + the other config test) keep compiling and the items stay hidden until wired: - -```swift - let onRemoveFromPlaylist: ((Track) -> Void)? - // nil hides the corresponding item (e.g. when driving a remote device). - let onPlayNext: ((Track) -> Void)? = nil - let onAddToQueue: ((Track) -> Void)? = nil -``` - -- [ ] **Step 4: Run the config test to verify it passes** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing:MusicTests/TrackContextMenuConfigTests 2>&1 | tail -30` -Expected: PASS, and the app target still builds (existing callers use the `nil` defaults). - -> Note: the menu items are wired with real closures in Task 6. Until then `ContentView` passes the default `nil`, so the items stay hidden — expected interim state. - -- [ ] **Step 5: Render the items in the SwiftUI menu** - -In `Music/Views/TrackContextMenuModifier.swift`, inside `if let track, let config {`, insert this block **before** the existing `lastUsedPlaylistName` block (so Play Next / Add to Queue appear at the top): - -```swift - if let onPlayNext = config.onPlayNext { - Button("Play Next") { onPlayNext(track) } - } - if let onAddToQueue = config.onAddToQueue { - Button("Add to Queue") { onAddToQueue(track) } - } - if config.onPlayNext != nil || config.onAddToQueue != nil { - Divider() - } - -``` - -- [ ] **Step 6: Render the items in the AppKit menu** - -In `Music/Views/TrackTableView.swift`, in `menuNeedsUpdate(_:)`, insert this block immediately after the two `guard` lines (after `guard let config = parent.contextMenuConfig else { return }`) and **before** the `if let lastPlaylistName` block: - -```swift - if config.onPlayNext != nil { - let item = NSMenuItem(title: "Play Next", action: #selector(playNext(_:)), keyEquivalent: "") - item.target = self - menu.addItem(item) - } - if config.onAddToQueue != nil { - let item = NSMenuItem(title: "Add to Queue", action: #selector(addToQueue(_:)), keyEquivalent: "") - item.target = self - menu.addItem(item) - } - if config.onPlayNext != nil || config.onAddToQueue != nil { - menu.addItem(.separator()) - } -``` - -Then add the two action handlers next to the existing `addToLastPlaylist` / `removeFromPlaylist` handlers (after `removeFromPlaylist(_:)`'s closing brace, currently line 394): - -```swift - @objc func playNext(_ sender: NSMenuItem) { - guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } - guard let config = parent.contextMenuConfig else { return } - config.onPlayNext?(tracks[tableView.clickedRow]) - } - - @objc func addToQueue(_ sender: NSMenuItem) { - guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } - guard let config = parent.contextMenuConfig else { return } - config.onAddToQueue?(tracks[tableView.clickedRow]) - } -``` - -- [ ] **Step 7: Commit (checkpoint — via `/commit`)** - -```bash -git add Music/Models/TrackContextMenuConfig.swift Music/Views/TrackContextMenuModifier.swift Music/Views/TrackTableView.swift MusicTests/TrackContextMenuConfigTests.swift -# Suggested message: "feat: add Play Next / Add to Queue context-menu items" -``` - ---- - -## Task 5: `QueueView` panel + `PlayerControlsView` toggle button - -**Files:** -- Create: `Music/Views/QueueView.swift` -- Modify: `Music/Views/PlayerControlsView.swift` - -This task is UI; it is verified by a clean build and (optionally) by running the app, not by unit tests. - -- [ ] **Step 1: Create `QueueView`** - -Create `Music/Views/QueueView.swift`: - -```swift -import SwiftUI - -// The right-docked "Up Next" panel. The manual "Queue" section is reorderable and -// removable; the "Next from" section is the read-only upcoming context (double-click -// a row to jump to it). -struct QueueView: View { - var player: PlayerViewModel - - var body: some View { - List { - if player.manualQueue.isEmpty && player.upcomingContext.isEmpty { - Text("Queue is empty.\nRight-click a track → Add to Queue.") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 24) - .listRowSeparator(.hidden) - } - - if !player.manualQueue.isEmpty { - Section("Queue") { - ForEach(player.manualQueue) { entry in - HStack(spacing: 8) { - trackRow(entry.track) - Spacer() - Button { - if let idx = player.manualQueue.firstIndex(where: { $0.id == entry.id }) { - player.removeFromQueue(at: IndexSet(integer: idx)) - } - } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.tertiary) - } - .buttonStyle(.plain) - } - } - .onMove(perform: player.moveInQueue) - } - } - - if !player.upcomingContext.isEmpty { - Section("Next from: \(player.contextName ?? "Library")") { - ForEach(Array(player.upcomingContext.enumerated()), id: \.offset) { _, track in - trackRow(track) - .contentShape(Rectangle()) - .onTapGesture(count: 2) { player.play(track) } - } - } - } - } - .listStyle(.inset) - .frame(width: 280) - } - - private func trackRow(_ track: Track) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(track.title) - .font(.system(size: 12, weight: .medium)) - .lineLimit(1) - Text(track.artist) - .font(.system(size: 10)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } -} -``` - -- [ ] **Step 2: Add toggle inputs to `PlayerControlsView`** - -In `Music/Views/PlayerControlsView.swift`, add three properties immediately after `var contextMenuConfig: TrackContextMenuConfig? = nil` (line 23): - -```swift - var isQueueVisible: Bool = false - var showQueueButton: Bool = true - var onToggleQueue: (() -> Void)? = nil -``` - -- [ ] **Step 3: Render the queue button** - -In the same file, replace the start of `volumeSection`: - -```swift - private var volumeSection: some View { - HStack(spacing: 8) { - Image(systemName: volumeIconName) -``` - -with: - -```swift - private var volumeSection: some View { - HStack(spacing: 8) { - if showQueueButton { - Button(action: { onToggleQueue?() }) { - Image(systemName: "list.bullet") - .font(.system(size: 13)) - .foregroundStyle(isQueueVisible ? .blue : .secondary) - } - .buttonStyle(.plain) - } - - Image(systemName: volumeIconName) -``` - -- [ ] **Step 4: Build to verify it compiles** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -20` -Expected: `** BUILD SUCCEEDED **` (the new toggle props are unused until Task 6 wires them — defaults keep `ContentView` compiling). - -- [ ] **Step 5: Commit (checkpoint — via `/commit`)** - -```bash -git add Music/Views/QueueView.swift Music/Views/PlayerControlsView.swift -# Suggested message: "feat: add Up Next QueueView panel and transport queue toggle" -``` - ---- - -## Task 6: Wire everything into `ContentView` - -**Files:** -- Modify: `Music/ContentView.swift` - -UI integration — verified by a full build and the complete test suite. - -- [ ] **Step 1: Add panel visibility state** - -In `Music/ContentView.swift`, add to the `@State` block (e.g. after `@State private var showHome = false`, line 24): - -```swift - @State private var showQueue = false -``` - -- [ ] **Step 2: Wire the queue closures into the context-menu config** - -Replace the whole `trackContextMenuConfig` computed property (lines 358-377) with: - -```swift - private var trackContextMenuConfig: TrackContextMenuConfig { - // Queue actions are local-only for v1: hidden when driving a remote device. - let queueEnabled = !(networkStatus?.isRemoteMode ?? false) - return TrackContextMenuConfig( - playlists: playlist.playlists, - lastUsedPlaylistName: playlist.lastUsedPlaylistName, - selectedPlaylist: playlist.selectedPlaylist, - onAddToPlaylist: { track, targetPlaylist in - try? playlist.addTrack(track, to: targetPlaylist) - }, - onAddToLastPlaylist: { track in - try? playlist.addTrackToLastUsedPlaylist(track) - }, - // Outer nil hides the "Remove from Playlist" menu item when not in a playlist view. - // Inner re-check defends against the playlist being deselected between menu display and action. - onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in - if let selected = playlist.selectedPlaylist { - try? playlist.removeTrack(track, from: selected) - } - } : nil, - onPlayNext: queueEnabled ? { track in player.playNext(track) } : nil, - onAddToQueue: queueEnabled ? { track in player.addToQueue(track) } : nil - ) - } -``` - -- [ ] **Step 3: Dock the panel beside the main content** - -In `body`, wrap the main-content region in an `HStack` and append the panel. Replace the opening of that region (currently lines 118-119): - -```swift - VStack(spacing: 0) { - if showHome || playlist.selectedItem != nil { -``` - -with: - -```swift - HStack(spacing: 0) { - VStack(spacing: 0) { - if showHome || playlist.selectedItem != nil { -``` - -Then replace its closing `.frame(maxHeight: .infinity)` (currently line 191) with: - -```swift - } - .frame(maxHeight: .infinity) - - if showQueue { - Divider() - QueueView(player: player) - } - } -``` - -(The first `}` closes the existing inner `VStack`; the new outer `}` closes the added `HStack`.) - -- [ ] **Step 4: Pass context labels at every `setQueue` call site** - -Make these four edits in `ContentView.swift`: - -1. HomeView `onTrackDoubleClick` (line 155): `player.setQueue(recentTracks)` → `player.setQueue(recentTracks, contextName: "Recently Added")` -2. TrackTableView `onDoubleClick` (line 178): `player.setQueue(trackList)` → `player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")` -3. `onPlayPause` empty-state (line 393): `player.setQueue(trackList)` → `player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")` -4. Keyboard space handler (line 426): `player.setQueue(trackList)` → `player.setQueue(trackList, contextName: playlist.selectedItem?.name ?? "Library")` - -- [ ] **Step 5: Pass toggle props to `PlayerControlsView`** - -In the `playerControls` computed property, add these arguments after `onNowPlayingTap:` and before `contextMenuConfig:` (line 408-409): - -```swift - onNowPlayingTap: { scrollToPlayingTrigger = UUID() }, - isQueueVisible: showQueue, - showQueueButton: !(networkStatus?.isRemoteMode ?? false), - onToggleQueue: { showQueue.toggle() }, - contextMenuConfig: trackContextMenuConfig -``` - -- [ ] **Step 6: Build to verify it compiles** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | tail -20` -Expected: `** BUILD SUCCEEDED **`. - -- [ ] **Step 7: Run the full test suite** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | tail -30` -Expected: all tests pass, including the new queue tests and all pre-existing suites. - -- [ ] **Step 8: Manual verification (optional but recommended)** - -Use the `/run` or `/verify` skill to launch the app and confirm: -- Right-click a track → "Play Next" and "Add to Queue" appear and work. -- The transport `list.bullet` button toggles the right panel. -- Queued tracks show under "Queue", reorder by drag, and remove via the × button. -- Playing a queued track removes it from the panel; after the queue drains, the original playlist resumes at the right spot. - -- [ ] **Step 9: Commit (checkpoint — via `/commit`)** - -```bash -git add Music/ContentView.swift -# Suggested message: "feat: wire Up Next panel, queue toggle, and queue actions into ContentView" -``` - ---- - -## Self-Review Notes (for the implementer) - -- **Backward compatibility:** `queue`/`currentIndex` keep meaning the *context*; every pre-existing `PlayerViewModelTests` case must stay green at each task. If one breaks, the new logic touched the context path incorrectly. -- **Remote gate:** queue methods early-return when `remoteProvider != nil`, and `ContentView` passes `nil` queue closures + hides the button when `networkStatus.isRemoteMode`. Streaming-client mode is *not* gated (it plays locally). -- **Duplicates:** `QueueEntry.id` (UUID) is the SwiftUI identity, so the same track can be queued multiple times without row glitches; removal looks up the entry by `id`, never by track. diff --git a/docs/superpowers/plans/2026-05-30-smart-playlist-conditions.md b/docs/superpowers/plans/2026-05-30-smart-playlist-conditions.md deleted file mode 100644 index c350b70..0000000 --- a/docs/superpowers/plans/2026-05-30-smart-playlist-conditions.md +++ /dev/null @@ -1,1143 +0,0 @@ -# Smart Playlist Conditions Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Extend smart playlists to support structured metadata-based conditions (e.g. artist = "Alicia Keys", year > 2015) with a new condition builder sheet, while keeping the existing FTS search-based smart playlists working unchanged. - -**Architecture:** Add a nullable `conditions: [SmartPlaylistCondition]?` column (stored as JSON TEXT) to the `smart_playlists` table via a new DB migration. When `conditions` is nil the existing FTS path runs; when non-nil a SQL WHERE clause is generated from the conditions. A new `SmartPlaylistBuilderSheet` view handles create and edit, wired into the app menu and playlist context menu. - -**Tech Stack:** Swift, SwiftUI, GRDB 7.10.0, Swift Testing - ---- - -## File Map - -| Action | File | Purpose | -|--------|------|---------| -| Create | `Music/Models/SmartPlaylistCondition.swift` | TrackField, ConditionOperator, ConditionValue, SmartPlaylistCondition | -| Modify | `Music/Models/SmartPlaylist.swift` | Add `conditions` property, update fixture | -| Modify | `Music/Services/DatabaseService.swift` | Migration v5, buildWhereClause, fetchTracks(conditions:), createSmartPlaylist(name:conditions:), updateSmartPlaylistConditions | -| Modify | `Music/ViewModels/PlaylistViewModel.swift` | Branch observeSmartPlaylistTracks, add createSmartPlaylist(name:conditions:), updateSmartPlaylistConditions | -| Create | `Music/Views/SmartPlaylistBuilderSheet.swift` | Condition builder sheet UI + ConditionRowView | -| Modify | `Music/Views/PlaylistBarView.swift` | Add onEditConditions callback, update context menu | -| Modify | `Music/ContentView.swift` | Add showSmartPlaylistBuilder binding, sheet, onEditConditions wiring | -| Modify | `Music/MusicApp.swift` | Add showSmartPlaylistBuilder state + "New Smart Playlist…" menu item | -| Modify | `MusicTests/SmartPlaylistTests.swift` | Tests for conditions model, query evaluation, JSON round-trip | - ---- - -## Task 1: SmartPlaylistCondition Model - -**Files:** -- Create: `Music/Models/SmartPlaylistCondition.swift` -- Test: `MusicTests/SmartPlaylistTests.swift` - -- [ ] **Step 1: Write the failing test** - -Add to `MusicTests/SmartPlaylistTests.swift`: - -```swift -// Encodes and decodes a SmartPlaylistCondition to/from JSON, -// verifying that all fields survive the round-trip. -@Test func conditionCodableRoundTrip() throws { - let condition = SmartPlaylistCondition( - field: .artist, - op: .equals, - value: .string("Miles Davis") - ) - let data = try JSONEncoder().encode(condition) - let decoded = try JSONDecoder().decode(SmartPlaylistCondition.self, from: data) - #expect(decoded.field == .artist) - #expect(decoded.op == .equals) - if case .string(let s) = decoded.value { - #expect(s == "Miles Davis") - } else { - Issue.record("Expected string value") - } -} - -// Encodes and decodes an array of conditions with mixed value types. -@Test func conditionsArrayCodableRoundTrip() throws { - let conditions: [SmartPlaylistCondition] = [ - SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("Miles")), - SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)), - SmartPlaylistCondition(field: .dateAdded, op: .lessThan, value: .date(Date(timeIntervalSince1970: 0))) - ] - let data = try JSONEncoder().encode(conditions) - let decoded = try JSONDecoder().decode([SmartPlaylistCondition].self, from: data) - #expect(decoded.count == 3) - #expect(decoded[0].field == .artist) - #expect(decoded[1].op == .greaterThan) - if case .int(let y) = decoded[1].value { #expect(y == 1960) } else { Issue.record("Expected int") } - if case .date(let d) = decoded[2].value { #expect(d == Date(timeIntervalSince1970: 0)) } else { Issue.record("Expected date") } -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/conditionCodableRoundTrip 2>&1 | grep -E "error:|FAILED|PASSED" | head -20` - -Expected: compile error — `SmartPlaylistCondition` not defined. - -- [ ] **Step 3: Create `Music/Models/SmartPlaylistCondition.swift`** - -```swift -import Foundation - -// Classifies a track field for operator and UI purposes. -enum FieldType { - case string, int, double, date -} - -// Represents a track column that can be filtered on. -// Raw value matches the SQLite column name in the "tracks" table. -enum TrackField: String, Codable, CaseIterable, Identifiable, Sendable { - case title, artist, albumArtist, album, genre, composer, fileFormat - case year, bpm, rating, playCount, trackNumber, discNumber, bitrate, sampleRate - case fileSize, duration - case dateAdded, dateModified, lastPlayedAt - - var id: String { rawValue } - - var displayName: String { - switch self { - case .title: return "Title" - case .artist: return "Artist" - case .albumArtist: return "Album Artist" - case .album: return "Album" - case .genre: return "Genre" - case .composer: return "Composer" - case .fileFormat: return "File Format" - case .year: return "Year" - case .bpm: return "BPM" - case .rating: return "Rating" - case .playCount: return "Play Count" - case .trackNumber: return "Track Number" - case .discNumber: return "Disc Number" - case .bitrate: return "Bitrate" - case .sampleRate: return "Sample Rate" - case .fileSize: return "File Size" - case .duration: return "Duration" - case .dateAdded: return "Date Added" - case .dateModified: return "Date Modified" - case .lastPlayedAt: return "Last Played" - } - } - - var fieldType: FieldType { - switch self { - case .title, .artist, .albumArtist, .album, .genre, .composer, .fileFormat: - return .string - case .year, .bpm, .rating, .playCount, .trackNumber, .discNumber, .bitrate, .sampleRate, .fileSize: - return .int - case .duration: - return .double - case .dateAdded, .dateModified, .lastPlayedAt: - return .date - } - } - - var validOperators: [ConditionOperator] { - switch fieldType { - case .string: return [.equals, .startsWith] - case .int, .double, .date: return [.equals, .greaterThan, .lessThan] - } - } - - var defaultValue: ConditionValue { - switch fieldType { - case .string: return .string("") - case .int: return .int(0) - case .double: return .double(0) - case .date: return .date(Date()) - } - } -} - -enum ConditionOperator: String, Codable, Identifiable, Sendable { - case equals - case startsWith - case greaterThan - case lessThan - - var id: String { rawValue } - - var displayName: String { - switch self { - case .equals: return "is" - case .startsWith: return "starts with" - case .greaterThan: return "is greater than" - case .lessThan: return "is less than" - } - } -} - -// Tagged union storing the actual filter value with its type. -// Uses custom Codable to survive JSON round-trips cleanly. -enum ConditionValue: Equatable, Sendable { - case string(String) - case int(Int) - case double(Double) - case date(Date) - - var isEmpty: Bool { - if case .string(let s) = self { - return s.trimmingCharacters(in: .whitespaces).isEmpty - } - return false - } -} - -extension ConditionValue: Codable { - private enum CodingKeys: String, CodingKey { case type, value } - - func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - switch self { - case .string(let s): - try container.encode("string", forKey: .type) - try container.encode(s, forKey: .value) - case .int(let i): - try container.encode("int", forKey: .type) - try container.encode(i, forKey: .value) - case .double(let d): - try container.encode("double", forKey: .type) - try container.encode(d, forKey: .value) - case .date(let date): - try container.encode("date", forKey: .type) - try container.encode(date.timeIntervalSince1970, forKey: .value) - } - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - let type = try container.decode(String.self, forKey: .type) - switch type { - case "string": - self = .string(try container.decode(String.self, forKey: .value)) - case "int": - self = .int(try container.decode(Int.self, forKey: .value)) - case "double": - self = .double(try container.decode(Double.self, forKey: .value)) - case "date": - self = .date(Date(timeIntervalSince1970: try container.decode(Double.self, forKey: .value))) - default: - throw DecodingError.dataCorruptedError(forKey: .type, in: container, debugDescription: "Unknown type: \(type)") - } - } -} - -nonisolated struct SmartPlaylistCondition: Codable, Equatable, Sendable { - var field: TrackField - var op: ConditionOperator - var value: ConditionValue - - var isEmpty: Bool { value.isEmpty } -} -``` - -- [ ] **Step 4: Run tests to verify they pass** - -Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/conditionCodableRoundTrip -only-testing:MusicTests/SmartPlaylistTests/conditionsArrayCodableRoundTrip 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"` - -Expected: both tests PASSED. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Models/SmartPlaylistCondition.swift MusicTests/SmartPlaylistTests.swift -git commit -m "feat: add SmartPlaylistCondition model with Codable types" -``` - ---- - -## Task 2: Extend SmartPlaylist Model - -**Files:** -- Modify: `Music/Models/SmartPlaylist.swift` -- Modify: `MusicTests/SmartPlaylistTests.swift` - -- [ ] **Step 1: Write the failing test** - -Add to `MusicTests/SmartPlaylistTests.swift`: - -```swift -// Creates a SmartPlaylist fixture with conditions and verifies the conditions -// field is preserved and the isSmartPlaylist flag is true. -@Test func smartPlaylistWithConditions() throws { - let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))] - let sp = SmartPlaylist.fixture(conditions: conditions) - #expect(sp.conditions?.count == 1) - #expect(sp.conditions?[0].field == .artist) - #expect(sp.isSmartPlaylist == true) -} -``` - -- [ ] **Step 2: Run test to verify it fails** - -Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/smartPlaylistWithConditions 2>&1 | grep -E "error:|FAILED|PASSED" | head -10` - -Expected: compile error — fixture doesn't accept `conditions` parameter. - -- [ ] **Step 3: Modify `Music/Models/SmartPlaylist.swift`** - -Add `conditions` property to the struct and update the fixture. Replace the entire file content: - -```swift -import Foundation -import GRDB - -nonisolated struct SmartPlaylist: Codable, Identifiable, Equatable, Hashable, Sendable { - var id: Int64? - var name: String - var searchQuery: String - var createdAt: Date - var conditions: [SmartPlaylistCondition]? -} - -nonisolated extension SmartPlaylist: FetchableRecord, MutablePersistableRecord { - static let databaseTableName = "smart_playlists" - - mutating func didInsert(_ inserted: InsertionSuccess) { - id = inserted.rowID - } -} - -extension SmartPlaylist: PlaylistRepresentable { - var isSmartPlaylist: Bool { true } -} - -#if DEBUG -extension SmartPlaylist { - static func fixture( - id: Int64? = nil, - name: String = "Test Smart Playlist", - searchQuery: String = "test query", - createdAt: Date = Date(), - conditions: [SmartPlaylistCondition]? = nil - ) -> SmartPlaylist { - SmartPlaylist( - id: id, name: name, searchQuery: searchQuery, - createdAt: createdAt, conditions: conditions - ) - } -} -#endif -``` - -> **Note on GRDB Codable synthesis:** GRDB 7 automatically encodes/decodes `[SmartPlaylistCondition]?` as a JSON TEXT column named "conditions" — no custom `init(row:)` or `encode(to:)` needed. Existing rows where the column is NULL decode as `nil`. - -- [ ] **Step 4: Update the memberwise init call in DatabaseService** - -In `Music/Services/DatabaseService.swift`, find the `createSmartPlaylist(name:searchQuery:)` method (line ~447) and update the init call: - -Old: -```swift -var smartPlaylist = SmartPlaylist( - id: nil, name: name, searchQuery: searchQuery, createdAt: Date() -) -``` - -New: -```swift -var smartPlaylist = SmartPlaylist( - id: nil, name: name, searchQuery: searchQuery, createdAt: Date(), conditions: nil -) -``` - -- [ ] **Step 5: Update the memberwise init call in SmartPlaylistTests** - -In `MusicTests/SmartPlaylistTests.swift`, find `SmartPlaylistTests.smartPlaylistProperties()` (line ~8) and update: - -Old: -```swift -let sp = SmartPlaylist( - id: nil, - name: "Miles Davis", - searchQuery: "miles davis", - createdAt: Date() -) -``` - -New: -```swift -let sp = SmartPlaylist( - id: nil, - name: "Miles Davis", - searchQuery: "miles davis", - createdAt: Date(), - conditions: nil -) -``` - -- [ ] **Step 6: Run all SmartPlaylistTests to verify they pass** - -Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"` - -Expected: all tests PASSED. - -- [ ] **Step 7: Commit** - -```bash -git add Music/Models/SmartPlaylist.swift Music/Services/DatabaseService.swift MusicTests/SmartPlaylistTests.swift -git commit -m "feat: add conditions field to SmartPlaylist model" -``` - ---- - -## Task 3: DB Migration + Query Evaluation - -**Files:** -- Modify: `Music/Services/DatabaseService.swift` -- Test: `MusicTests/SmartPlaylistTests.swift` - -- [ ] **Step 1: Write failing tests** - -Add to `MusicTests/SmartPlaylistTests.swift`: - -```swift -// Creates an in-memory DB and verifies existing smart playlists (conditions = nil) -// still load correctly after the v5 migration adds the conditions column. -@Test func existingFTSPlaylistSurvivesMigration() throws { - // Step 1: Create DB (migration runs automatically, including v5) - // Step 2: Create a FTS smart playlist using the old searchQuery path - // Step 3: Fetch it back and verify conditions is nil - let db = try DatabaseService(inMemory: true) - let sp = try db.createSmartPlaylist(name: "Jazz", searchQuery: "jazz") - let all = try db.fetchSmartPlaylists() - #expect(all.count == 1) - #expect(all[0].searchQuery == "jazz") - #expect(all[0].conditions == nil) - _ = sp -} - -// Inserts tracks and verifies that fetchTracks(conditions:) with an equals -// condition on artist returns only the matching track. -@Test func fetchTracksWithEqualsCondition() throws { - // Step 1: Insert two tracks with different artists - // Step 2: Fetch with artist equals "Miles Davis" - // Step 3: Verify only one track is returned - let db = try DatabaseService(inMemory: true) - var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis") - var t2 = Track.fixture(fileURL: "/b.mp3", title: "Hotel California", artist: "Eagles") - try db.insert(&t1) - try db.insert(&t2) - - let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis"))] - let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) - #expect(results.count == 1) - #expect(results[0].artist == "Miles Davis") -} - -// Verifies that equals is case-insensitive for string fields. -@Test func fetchTracksEqualsIsCaseInsensitive() throws { - // Step 1: Insert a track with mixed-case artist - // Step 2: Fetch using lowercase artist value - // Step 3: Verify it matches - let db = try DatabaseService(inMemory: true) - var t = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis") - try db.insert(&t) - - let conditions = [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("miles davis"))] - let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) - #expect(results.count == 1) -} - -// Verifies that startsWith matches tracks whose artist begins with the given prefix, -// regardless of case. -@Test func fetchTracksWithStartsWithCondition() throws { - // Step 1: Insert tracks — one starting with "Miles", one not - // Step 2: Fetch with artist startsWith "miles" - // Step 3: Verify only the Miles Davis track is returned - let db = try DatabaseService(inMemory: true) - var t1 = Track.fixture(fileURL: "/a.mp3", artist: "Miles Davis") - var t2 = Track.fixture(fileURL: "/b.mp3", artist: "Eagles") - try db.insert(&t1) - try db.insert(&t2) - - let conditions = [SmartPlaylistCondition(field: .artist, op: .startsWith, value: .string("miles"))] - let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) - #expect(results.count == 1) - #expect(results[0].artist == "Miles Davis") -} - -// Verifies that greaterThan on an integer field returns tracks where the value -// strictly exceeds the condition value. -@Test func fetchTracksWithGreaterThanCondition() throws { - // Step 1: Insert tracks with years 1990, 2010, 2020 - // Step 2: Fetch with year > 2000 - // Step 3: Verify 2010 and 2020 are returned; 1990 is not - let db = try DatabaseService(inMemory: true) - var t1 = Track.fixture(fileURL: "/a.mp3", year: 1990) - var t2 = Track.fixture(fileURL: "/b.mp3", year: 2010) - var t3 = Track.fixture(fileURL: "/c.mp3", year: 2020) - try db.insert(&t1) - try db.insert(&t2) - try db.insert(&t3) - - let conditions = [SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(2000))] - let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) - #expect(results.count == 2) - #expect(results.allSatisfy { ($0.year ?? 0) > 2000 }) -} - -// Verifies that multiple AND conditions filter correctly — only tracks matching -// all conditions are returned. -@Test func fetchTracksWithMultipleAndConditions() throws { - // Step 1: Insert three tracks: two Miles Davis (years 1959, 1970), one Eagles (1975) - // Step 2: Fetch with artist = "Miles Davis" AND year > 1960 - // Step 3: Verify only the 1970 Miles Davis track is returned - let db = try DatabaseService(inMemory: true) - var t1 = Track.fixture(fileURL: "/a.mp3", title: "Kind of Blue", artist: "Miles Davis", year: 1959) - var t2 = Track.fixture(fileURL: "/b.mp3", title: "Bitches Brew", artist: "Miles Davis", year: 1970) - var t3 = Track.fixture(fileURL: "/c.mp3", title: "Hotel California", artist: "Eagles", year: 1975) - try db.insert(&t1) - try db.insert(&t2) - try db.insert(&t3) - - let conditions = [ - SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")), - SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)) - ] - let results = try db.fetchTracks(conditions: conditions, sortColumn: "title", ascending: true) - #expect(results.count == 1) - #expect(results[0].title == "Bitches Brew") -} - -// Creates a smart playlist with structured conditions, fetches it back, and -// verifies the conditions survive the JSON round-trip through the database. -@Test func createSmartPlaylistWithConditionsPersists() throws { - // Step 1: Create DB and build a conditions-based smart playlist - // Step 2: Fetch all smart playlists - // Step 3: Verify conditions, field, operator, and value round-tripped correctly - let db = try DatabaseService(inMemory: true) - let conditions = [ - SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Miles Davis")), - SmartPlaylistCondition(field: .year, op: .greaterThan, value: .int(1960)) - ] - _ = try db.createSmartPlaylist(name: "Late Miles", conditions: conditions) - - let all = try db.fetchSmartPlaylists() - #expect(all.count == 1) - #expect(all[0].conditions?.count == 2) - #expect(all[0].conditions?[0].field == .artist) - #expect(all[0].conditions?[1].op == .greaterThan) - if case .int(let y) = all[0].conditions?[1].value { - #expect(y == 1960) - } else { - Issue.record("Expected int value") - } -} - -// Updates the conditions of a structured smart playlist and verifies the new -// conditions are persisted. -@Test func updateSmartPlaylistConditions() throws { - // Step 1: Create a conditions-based playlist - // Step 2: Update its conditions to a different set - // Step 3: Fetch and verify the updated conditions - let db = try DatabaseService(inMemory: true) - let sp = try db.createSmartPlaylist( - name: "Test", - conditions: [SmartPlaylistCondition(field: .artist, op: .equals, value: .string("Eagles"))] - ) - let newConditions = [SmartPlaylistCondition(field: .genre, op: .startsWith, value: .string("Jazz"))] - try db.updateSmartPlaylistConditions(id: sp.id!, conditions: newConditions) - - let all = try db.fetchSmartPlaylists() - #expect(all[0].conditions?.count == 1) - #expect(all[0].conditions?[0].field == .genre) -} -``` - -- [ ] **Step 2: Run tests to verify they fail** - -Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests/fetchTracksWithEqualsCondition 2>&1 | grep -E "error:|FAILED|PASSED" | head -10` - -Expected: compile error — `fetchTracks(conditions:)` not defined. - -- [ ] **Step 3: Add migration v5 and query methods to `Music/Services/DatabaseService.swift`** - -**3a — Add migration v5** after the `"v4-drop-artworkData"` migration (before `try migrator.migrate(db)`): - -```swift -migrator.registerMigration("v5-add-smart-playlist-conditions") { db in - try db.alter(table: "smart_playlists") { t in - t.add(column: "conditions", .text) - } -} -``` - -**3b — Add `buildWhereClause` private method** to `DatabaseService` (place in the `// MARK: - Smart Playlists` section): - -```swift -private func buildWhereClause(_ conditions: [SmartPlaylistCondition]) -> (sql: String, arguments: StatementArguments) { - guard !conditions.isEmpty else { return ("", StatementArguments()) } - var fragments: [String] = [] - var args: [DatabaseValueConvertible?] = [] - - for condition in conditions { - let col = condition.field.rawValue - switch (condition.op, condition.value) { - case (.equals, .string(let s)): - fragments.append("LOWER(\(col)) = LOWER(?)") - args.append(s) - case (.startsWith, .string(let s)): - fragments.append("LOWER(\(col)) LIKE LOWER(?) || '%'") - args.append(s) - case (.equals, .int(let i)): - fragments.append("\(col) = ?"); args.append(i) - case (.greaterThan, .int(let i)): - fragments.append("\(col) > ?"); args.append(i) - case (.lessThan, .int(let i)): - fragments.append("\(col) < ?"); args.append(i) - case (.equals, .double(let d)): - fragments.append("\(col) = ?"); args.append(d) - case (.greaterThan, .double(let d)): - fragments.append("\(col) > ?"); args.append(d) - case (.lessThan, .double(let d)): - fragments.append("\(col) < ?"); args.append(d) - case (.equals, .date(let date)): - fragments.append("\(col) = ?"); args.append(date) - case (.greaterThan, .date(let date)): - fragments.append("\(col) > ?"); args.append(date) - case (.lessThan, .date(let date)): - fragments.append("\(col) < ?"); args.append(date) - default: - break - } - } - return (fragments.joined(separator: " AND "), StatementArguments(args)) -} -``` - -**3c — Add public `fetchTracks(conditions:)` methods** after the existing `fetchTracks(db:search:sortColumn:ascending:)` method: - -```swift -func fetchTracks(conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] { - try dbPool.read { db in - try self.fetchTracks(db: db, conditions: conditions, sortColumn: sortColumn, ascending: ascending) - } -} - -func fetchTracks(db: Database, conditions: [SmartPlaylistCondition], sortColumn: String, ascending: Bool) throws -> [Track] { - let col = Self.validSortColumns.contains(sortColumn) ? sortColumn : "title" - let order = ascending ? "ASC" : "DESC" - let (whereSQL, args) = buildWhereClause(conditions) - if whereSQL.isEmpty { - return try Track.fetchAll(db, sql: "SELECT * FROM tracks ORDER BY \(col) COLLATE NOCASE \(order)") - } - return try Track.fetchAll( - db, - sql: "SELECT * FROM tracks WHERE \(whereSQL) ORDER BY \(col) COLLATE NOCASE \(order)", - arguments: args - ) -} -``` - -**3d — Add `createSmartPlaylist(name:conditions:)` overload** after the existing `createSmartPlaylist(name:searchQuery:)` method: - -```swift -func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws -> SmartPlaylist { - try dbPool.write { db in - var smartPlaylist = SmartPlaylist( - id: nil, name: name, searchQuery: "", createdAt: Date(), conditions: conditions - ) - try smartPlaylist.insert(db) - return smartPlaylist - } -} -``` - -**3e — Add `updateSmartPlaylistConditions(id:conditions:)`** after `updateSmartPlaylistQuery`: - -```swift -func updateSmartPlaylistConditions(id: Int64, conditions: [SmartPlaylistCondition]) throws { - let json: String? - if let data = try? JSONEncoder().encode(conditions) { - json = String(data: data, encoding: .utf8) - } else { - json = nil - } - try dbPool.write { db in - try db.execute( - sql: "UPDATE smart_playlists SET conditions = ? WHERE id = ?", - arguments: [json, id] - ) - } -} -``` - -- [ ] **Step 4: Run all new tests** - -Run: `xcodebuild test -scheme Music -only-testing:MusicTests/SmartPlaylistTests 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed"` - -Expected: all tests PASSED. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Services/DatabaseService.swift MusicTests/SmartPlaylistTests.swift -git commit -m "feat: add migration v5 and structured condition query support to DatabaseService" -``` - ---- - -## Task 4: PlaylistViewModel — Branch on Conditions - -**Files:** -- Modify: `Music/ViewModels/PlaylistViewModel.swift` - -No separate test — the DB-level tests in Task 3 cover the query logic; the ViewModel wiring is validated by running the app in Task 7. - -- [ ] **Step 1: Rename and update `observeSmartPlaylistTracks`** - -Replace the existing `observeSmartPlaylistTracks(searchQuery:)` private method with a new signature that takes the full `SmartPlaylist`: - -Old signature: `private func observeSmartPlaylistTracks(searchQuery: String)` - -New implementation (replace the entire method): - -```swift -private func observeSmartPlaylistTracks(for smartPlaylist: SmartPlaylist) { - tracksCancellable?.cancel() - let col = sortColumn - let asc = sortAscending - - if let conditions = smartPlaylist.conditions { - let observation = ValueObservation.tracking { [db] dbAccess in - try db.fetchTracks(db: dbAccess, conditions: conditions, sortColumn: col, ascending: asc) - } - tracksCancellable = observation.start( - in: db.dbPool, - onError: { error in print("Smart playlist tracks observation error: \(error)") }, - onChange: { [weak self] tracks in self?.playlistTracks = tracks } - ) - } else { - let searchQuery = smartPlaylist.searchQuery - let observation = ValueObservation.tracking { [db] dbAccess in - try db.fetchTracks(db: dbAccess, search: searchQuery, sortColumn: col, ascending: asc) - } - tracksCancellable = observation.start( - in: db.dbPool, - onError: { error in print("Smart playlist tracks observation error: \(error)") }, - onChange: { [weak self] tracks in self?.playlistTracks = tracks } - ) - } -} -``` - -- [ ] **Step 2: Update all call sites of `observeSmartPlaylistTracks`** - -**In `selectItem`**, change: -```swift -} else if let smart = item as? SmartPlaylist { - observeSmartPlaylistTracks(searchQuery: smart.searchQuery) -} -``` -to: -```swift -} else if let smart = item as? SmartPlaylist { - observeSmartPlaylistTracks(for: smart) -} -``` - -**In `updateSmartPlaylistQuery`**, change: -```swift -if selectedSmartPlaylist?.id == id { - observeSmartPlaylistTracks(searchQuery: query) -} -``` -to: -```swift -if selectedSmartPlaylist?.id == id { - var updated = smartPlaylist - updated.searchQuery = query - updated.conditions = nil - observeSmartPlaylistTracks(for: updated) -} -``` - -**In `sort`**, change: -```swift -if let smart = selectedSmartPlaylist { - observeSmartPlaylistTracks(searchQuery: smart.searchQuery) -} -``` -to: -```swift -if let smart = selectedSmartPlaylist { - observeSmartPlaylistTracks(for: smart) -} -``` - -- [ ] **Step 3: Add `createSmartPlaylist(name:conditions:)` to PlaylistViewModel** - -Add after the existing `createSmartPlaylist(searchQuery:)` method: - -```swift -func createSmartPlaylist(name: String, conditions: [SmartPlaylistCondition]) throws { - _ = try db.createSmartPlaylist(name: name, conditions: conditions) -} -``` - -- [ ] **Step 4: Add `updateSmartPlaylistConditions(_:to:)` to PlaylistViewModel** - -Add after `updateSmartPlaylistQuery`: - -```swift -func updateSmartPlaylistConditions(_ smartPlaylist: SmartPlaylist, to conditions: [SmartPlaylistCondition]) throws { - guard let id = smartPlaylist.id else { return } - try db.updateSmartPlaylistConditions(id: id, conditions: conditions) - if selectedSmartPlaylist?.id == id { - var updated = smartPlaylist - updated.conditions = conditions - observeSmartPlaylistTracks(for: updated) - } -} -``` - -- [ ] **Step 5: Verify the project still builds** - -Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"` - -Expected: `BUILD SUCCEEDED` - -- [ ] **Step 6: Commit** - -```bash -git add Music/ViewModels/PlaylistViewModel.swift -git commit -m "feat: branch PlaylistViewModel on conditions for structured smart playlist observation" -``` - ---- - -## Task 5: SmartPlaylistBuilderSheet UI - -**Files:** -- Create: `Music/Views/SmartPlaylistBuilderSheet.swift` - -- [ ] **Step 1: Create `Music/Views/SmartPlaylistBuilderSheet.swift`** - -```swift -import SwiftUI - -struct SmartPlaylistBuilderSheet: View { - var editingPlaylist: SmartPlaylist? - var onSave: (String, [SmartPlaylistCondition]) -> Void - var onCancel: () -> Void - - @State private var name: String - @State private var conditions: [SmartPlaylistCondition] - - init( - editingPlaylist: SmartPlaylist? = nil, - onSave: @escaping (String, [SmartPlaylistCondition]) -> Void, - onCancel: @escaping () -> Void - ) { - self.editingPlaylist = editingPlaylist - self.onSave = onSave - self.onCancel = onCancel - let defaultCondition = SmartPlaylistCondition(field: .artist, op: .equals, value: .string("")) - _name = State(initialValue: editingPlaylist?.name ?? "") - _conditions = State(initialValue: editingPlaylist?.conditions ?? [defaultCondition]) - } - - private var canSave: Bool { - !name.trimmingCharacters(in: .whitespaces).isEmpty && - conditions.allSatisfy { !$0.isEmpty } - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(editingPlaylist == nil ? "New Smart Playlist" : "Edit Smart Playlist") - .font(.headline) - - VStack(alignment: .leading, spacing: 4) { - Text("Name") - .font(.caption) - .foregroundStyle(.secondary) - TextField("Playlist name", text: $name) - .textFieldStyle(.roundedBorder) - } - - VStack(alignment: .leading, spacing: 6) { - Text("Conditions (all must match)") - .font(.caption) - .foregroundStyle(.secondary) - - ForEach(conditions.indices, id: \.self) { index in - ConditionRowView( - condition: $conditions[index], - canRemove: conditions.count > 1, - onRemove: { conditions.remove(at: index) } - ) - } - - Button("+ Add Condition") { - conditions.append(SmartPlaylistCondition(field: .artist, op: .equals, value: .string(""))) - } - .buttonStyle(.plain) - .foregroundStyle(.accentColor) - .font(.system(size: 12)) - } - - Divider() - - HStack { - Spacer() - Button("Cancel", action: onCancel) - Button("Save") { - onSave(name.trimmingCharacters(in: .whitespaces), conditions) - } - .disabled(!canSave) - .keyboardShortcut(.defaultAction) - } - } - .padding(20) - .frame(width: 540) - } -} - -private struct ConditionRowView: View { - @Binding var condition: SmartPlaylistCondition - var canRemove: Bool - var onRemove: () -> Void - - var body: some View { - HStack(spacing: 8) { - Picker("", selection: $condition.field) { - ForEach(TrackField.allCases) { field in - Text(field.displayName).tag(field) - } - } - .labelsHidden() - .frame(maxWidth: 130) - .onChange(of: condition.field) { _, newField in - condition.op = newField.validOperators[0] - condition.value = newField.defaultValue - } - - Picker("", selection: $condition.op) { - ForEach(condition.field.validOperators) { op in - Text(op.displayName).tag(op) - } - } - .labelsHidden() - .frame(maxWidth: 130) - - valueField - - Button(action: onRemove) { - Image(systemName: "minus.circle.fill") - .foregroundStyle(canRemove ? .secondary : .secondary.opacity(0.3)) - } - .buttonStyle(.plain) - .disabled(!canRemove) - } - } - - @ViewBuilder - private var valueField: some View { - switch condition.field.fieldType { - case .string: - TextField("Value", text: Binding( - get: { if case .string(let s) = condition.value { return s } else { return "" } }, - set: { condition.value = .string($0) } - )) - .textFieldStyle(.roundedBorder) - case .int: - TextField("Value", text: Binding( - get: { if case .int(let i) = condition.value { return String(i) } else { return "0" } }, - set: { condition.value = .int(Int($0) ?? 0) } - )) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 100) - case .double: - TextField("Value", text: Binding( - get: { if case .double(let d) = condition.value { return String(d) } else { return "0" } }, - set: { condition.value = .double(Double($0) ?? 0) } - )) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 100) - case .date: - DatePicker("", selection: Binding( - get: { if case .date(let d) = condition.value { return d } else { return Date() } }, - set: { condition.value = .date($0) } - ), displayedComponents: .date) - .labelsHidden() - } - } -} -``` - -- [ ] **Step 2: Verify the project builds** - -Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"` - -Expected: `BUILD SUCCEEDED` - -- [ ] **Step 3: Commit** - -```bash -git add Music/Views/SmartPlaylistBuilderSheet.swift -git commit -m "feat: add SmartPlaylistBuilderSheet with ConditionRowView" -``` - ---- - -## Task 6: PlaylistBarView — Context Menu Update - -**Files:** -- Modify: `Music/Views/PlaylistBarView.swift` - -- [ ] **Step 1: Add `onEditConditions` callback and update the context menu** - -In `Music/Views/PlaylistBarView.swift`, add the new callback property after `onEditQuery`: - -```swift -var onEditConditions: (SmartPlaylist) -> Void -``` - -Then update the context menu block (currently lines 38-46): - -Old: -```swift -.contextMenu { - if !isRemoteMode { - Button("Rename...") { onRename(item) } - if let smart = item as? SmartPlaylist { - Button("Edit Search Query...") { onEditQuery(smart) } - } - Button("Delete") { onDelete(item) } - } -} -``` - -New: -```swift -.contextMenu { - if !isRemoteMode { - Button("Rename...") { onRename(item) } - if let smart = item as? SmartPlaylist { - if smart.conditions != nil { - Button("Edit...") { onEditConditions(smart) } - } else { - Button("Edit Search Query...") { onEditQuery(smart) } - } - } - Button("Delete") { onDelete(item) } - } -} -``` - -- [ ] **Step 2: Verify the project builds (PlaylistBarView call site in ContentView will fail — expected)** - -Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"` - -Expected: BUILD FAILED with a missing `onEditConditions` argument error in `ContentView.swift` — this is the compile error that Task 7 will fix. - -- [ ] **Step 3: Do NOT commit yet — commit together with Task 7** - ---- - -## Task 7: ContentView + MusicApp Wiring - -**Files:** -- Modify: `Music/ContentView.swift` -- Modify: `Music/MusicApp.swift` - -- [ ] **Step 1: Add binding and sheet states to ContentView** - -In `Music/ContentView.swift`, add a new `@Binding` parameter for the smart playlist builder alongside the existing `showNewPlaylistAlert` binding: - -Add after `@Binding var showNewPlaylistAlert: Bool`: -```swift -@Binding var showSmartPlaylistBuilder: Bool -``` - -Add to the `@State` block (after `showEditQueryAlert`): -```swift -@State private var smartPlaylistBuilderEditing: SmartPlaylist? -``` - -- [ ] **Step 2: Pass `onEditConditions` to PlaylistBarView in ContentView** - -Find the `PlaylistBarView(...)` call in `ContentView.body` and add the missing `onEditConditions` argument after `onEditQuery`: - -```swift -onEditConditions: { smart in - smartPlaylistBuilderEditing = smart -} -``` - -- [ ] **Step 3: Add sheets to ContentView** - -Add two `.sheet` modifiers after the existing `.alert` modifiers at the bottom of `ContentView.body`: - -```swift -.sheet(isPresented: $showSmartPlaylistBuilder) { - SmartPlaylistBuilderSheet( - editingPlaylist: nil, - onSave: { name, conditions in - try? playlist.createSmartPlaylist(name: name, conditions: conditions) - showSmartPlaylistBuilder = false - }, - onCancel: { showSmartPlaylistBuilder = false } - ) -} -.sheet(item: $smartPlaylistBuilderEditing) { smart in - SmartPlaylistBuilderSheet( - editingPlaylist: smart, - onSave: { name, conditions in - if name != smart.name { - try? playlist.renameSmartPlaylist(smart, to: name) - } - try? playlist.updateSmartPlaylistConditions(smart, to: conditions) - smartPlaylistBuilderEditing = nil - }, - onCancel: { smartPlaylistBuilderEditing = nil } - ) -} -``` - -- [ ] **Step 4: Add `showSmartPlaylistBuilder` state and menu item to MusicApp** - -In `Music/MusicApp.swift`: - -Add state after `showNewPlaylistAlert`: -```swift -@State private var showSmartPlaylistBuilder = false -``` - -Pass it to ContentView — in the `ContentView(...)` initializer call, add after `showNewPlaylistAlert: $showNewPlaylistAlert`: -```swift -showSmartPlaylistBuilder: $showSmartPlaylistBuilder, -``` - -Add menu item after `"New Playlist..."` button in the `.commands` block: -```swift -Button("New Smart Playlist...") { - showSmartPlaylistBuilder = true -} -.keyboardShortcut("n", modifiers: [.command, .shift]) -.disabled(remoteClient.connectionState.isConnected) -``` - -- [ ] **Step 5: Verify the project builds** - -Run: `xcodebuild build -scheme Music 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED"` - -Expected: `BUILD SUCCEEDED` - -- [ ] **Step 6: Run all tests** - -Run: `xcodebuild test -scheme Music 2>&1 | grep -E "error:|FAILED|PASSED|Test.*passed|Test.*failed" | tail -20` - -Expected: all tests PASSED. - -- [ ] **Step 7: Commit** - -```bash -git add Music/Views/PlaylistBarView.swift Music/ContentView.swift Music/MusicApp.swift -git commit -m "feat: wire SmartPlaylistBuilderSheet into menu and context menu" -``` diff --git a/docs/superpowers/plans/2026-05-30-track-context-menu-bottom-controls.md b/docs/superpowers/plans/2026-05-30-track-context-menu-bottom-controls.md deleted file mode 100644 index 4519b7a..0000000 --- a/docs/superpowers/plans/2026-05-30-track-context-menu-bottom-controls.md +++ /dev/null @@ -1,525 +0,0 @@ -# Track Context Menu on Bottom Controls — Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Right-clicking the now-playing area at bottom-left shows the same Add/Remove playlist context menu as right-clicking a track row in the track table. - -**Architecture:** Introduce a `TrackContextMenuConfig` value type that bundles all menu data + callbacks. A new `TrackContextMenuModifier` SwiftUI view modifier applies `.contextMenu` using that config. `TrackTableView` is refactored to accept a single `contextMenuConfig` parameter (replacing six individual playlist params), and `PlayerControlsView` gains the same optional parameter with the modifier applied to `nowPlayingSection`. `ContentView` constructs one config and passes it to both views. - -**Tech Stack:** Swift 5.9+, SwiftUI `.contextMenu`, AppKit `NSMenu` (table view keeps existing AppKit path), Swift Testing framework. - ---- - -## File Map - -| File | Action | -|------|--------| -| `Music/Models/TrackContextMenuConfig.swift` | **Create** — value type holding playlists + callbacks | -| `Music/Views/TrackContextMenuModifier.swift` | **Create** — SwiftUI ViewModifier + View extension | -| `MusicTests/TrackContextMenuConfigTests.swift` | **Create** — unit tests for config struct | -| `Music/Views/TrackTableView.swift` | **Modify** — replace 6 playlist params with `contextMenuConfig`, update `menuNeedsUpdate` | -| `Music/Views/PlayerControlsView.swift` | **Modify** — add `contextMenuConfig` param, apply modifier to `nowPlayingSection` | -| `Music/ContentView.swift` | **Modify** — construct and pass `TrackContextMenuConfig` to both views | - ---- - -### Task 1: Create `TrackContextMenuConfig` - -**Files:** -- Create: `Music/Models/TrackContextMenuConfig.swift` -- Test: `MusicTests/TrackContextMenuConfigTests.swift` - -- [ ] **Step 1: Write the failing test** - -Create `MusicTests/TrackContextMenuConfigTests.swift`: - -```swift -import Testing -@testable import Music - -struct TrackContextMenuConfigTests { - // Builds a config with all fields set and verifies: - // - stored playlists, lastUsedPlaylistName, selectedPlaylist match the inputs - // - onAddToPlaylist callback fires with the correct track and playlist - // - onAddToLastPlaylist callback fires with the correct track - // - onRemoveFromPlaylist callback fires with the correct track - // - when optional callbacks are nil, optionally calling them is safe - - @Test func storesPropertiesAndFiresCallbacks() { - // 1. Create fixture data - let pl1 = Playlist.fixture(id: 1, name: "Favorites") - let pl2 = Playlist.fixture(id: 2, name: "Chill") - let track = Track.fixture(id: 42, title: "Test") - - var addedTrack: Track? = nil - var addedPlaylist: Playlist? = nil - var lastTrack: Track? = nil - var removedTrack: Track? = nil - - // 2. Build config with all callbacks - let config = TrackContextMenuConfig( - playlists: [pl1, pl2], - lastUsedPlaylistName: "Favorites", - selectedPlaylist: pl1, - onAddToPlaylist: { t, p in addedTrack = t; addedPlaylist = p }, - onAddToLastPlaylist: { t in lastTrack = t }, - onRemoveFromPlaylist: { t in removedTrack = t } - ) - - // 3. Verify stored properties - #expect(config.playlists.count == 2) - #expect(config.playlists[0].name == "Favorites") - #expect(config.lastUsedPlaylistName == "Favorites") - #expect(config.selectedPlaylist == pl1) - - // 4. Invoke callbacks and verify they fire correctly - config.onAddToPlaylist(track, pl2) - config.onAddToLastPlaylist?(track) - config.onRemoveFromPlaylist?(track) - - #expect(addedTrack?.id == track.id) - #expect(addedPlaylist?.id == pl2.id) - #expect(lastTrack?.id == track.id) - #expect(removedTrack?.id == track.id) - } - - @Test func nilOptionalCallbacksAreSafe() { - // Verifies that a config with nil optional callbacks does not crash - // when you call them via optional chaining (the normal usage pattern) - let pl = Playlist.fixture(id: 1, name: "Rock") - let track = Track.fixture() - - let config = TrackContextMenuConfig( - playlists: [pl], - lastUsedPlaylistName: nil, - selectedPlaylist: nil, - onAddToPlaylist: { _, _ in }, - onAddToLastPlaylist: nil, - onRemoveFromPlaylist: nil - ) - - // These must not crash - config.onAddToLastPlaylist?(track) - config.onRemoveFromPlaylist?(track) - - #expect(config.lastUsedPlaylistName == nil) - #expect(config.selectedPlaylist == nil) - } -} -``` - -- [ ] **Step 2: Run tests to confirm they fail (type not found)** - -``` -xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/TrackContextMenuConfigTests 2>&1 | grep -E "error:|FAIL|PASS|warning: cannot" -``` - -Expected: compile error — `TrackContextMenuConfig` not found. - -- [ ] **Step 3: Create `Music/Models/TrackContextMenuConfig.swift`** - -```swift -import Foundation - -struct TrackContextMenuConfig { - let playlists: [Playlist] - let lastUsedPlaylistName: String? - let selectedPlaylist: Playlist? - let onAddToPlaylist: (Track, Playlist) -> Void - let onAddToLastPlaylist: ((Track) -> Void)? - let onRemoveFromPlaylist: ((Track) -> Void)? -} -``` - -- [ ] **Step 4: Add the new file to the Xcode project** - -In Xcode, right-click the `Models` group in the project navigator → **Add Files to "Music"** → select `TrackContextMenuConfig.swift`. Make sure "Add to targets: Music" is checked. - -- [ ] **Step 5: Run tests to confirm they pass** - -``` -xcodebuild test -scheme Music -destination 'platform=macOS' -only-testing MusicTests/TrackContextMenuConfigTests 2>&1 | grep -E "Test.*passed|Test.*failed|error:" -``` - -Expected: both tests pass. - -- [ ] **Step 6: Commit** - -```bash -git add Music/Models/TrackContextMenuConfig.swift MusicTests/TrackContextMenuConfigTests.swift -git commit -m "feat: add TrackContextMenuConfig value type" -``` - ---- - -### Task 2: Create `TrackContextMenuModifier` - -**Files:** -- Create: `Music/Views/TrackContextMenuModifier.swift` - -> No unit test for this task — SwiftUI view modifier behaviour requires UI/snapshot testing not set up in this project. Manual verification is in Task 5. - -- [ ] **Step 1: Create `Music/Views/TrackContextMenuModifier.swift`** - -```swift -import SwiftUI - -// Attaches a context menu matching the track table's right-click menu. -// No-ops silently when track or config is nil so callers can pass optionals freely. -struct TrackContextMenuModifier: ViewModifier { - let track: Track? - let config: TrackContextMenuConfig? - - func body(content: Content) -> some View { - if let track, let config { - content.contextMenu { - if let lastPlaylistName = config.lastUsedPlaylistName, - let onAddToLastPlaylist = config.onAddToLastPlaylist { - Button("Add to \(lastPlaylistName)") { - onAddToLastPlaylist(track) - } - Divider() - } - - if !config.playlists.isEmpty { - Menu("Add to Playlist") { - ForEach(config.playlists) { playlist in - Button(playlist.name) { - config.onAddToPlaylist(track, playlist) - } - } - } - } - - if config.selectedPlaylist != nil, - let onRemoveFromPlaylist = config.onRemoveFromPlaylist { - Divider() - Button("Remove from Playlist") { - onRemoveFromPlaylist(track) - } - } - } - } else { - content - } - } -} - -extension View { - func trackContextMenu(track: Track?, config: TrackContextMenuConfig?) -> some View { - modifier(TrackContextMenuModifier(track: track, config: config)) - } -} -``` - -- [ ] **Step 2: Add the new file to the Xcode project** - -In Xcode, right-click the `Views` group → **Add Files to "Music"** → select `TrackContextMenuModifier.swift`. Make sure "Add to targets: Music" is checked. - -- [ ] **Step 3: Build to confirm it compiles** - -``` -xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED" -``` - -Expected: `BUILD SUCCEEDED`. - -- [ ] **Step 4: Commit** - -```bash -git add Music/Views/TrackContextMenuModifier.swift -git commit -m "feat: add TrackContextMenuModifier SwiftUI view modifier" -``` - ---- - -### Task 3: Refactor `TrackTableView` to use `contextMenuConfig` - -**Files:** -- Modify: `Music/Views/TrackTableView.swift:45-50` (replace 6 playlist params) -- Modify: `Music/Views/TrackTableView.swift:333-394` (update `menuNeedsUpdate` + action handlers) - -- [ ] **Step 1: Replace the 6 individual playlist properties with `contextMenuConfig`** - -In `Music/Views/TrackTableView.swift`, find lines 45–51: - -```swift - var playlists: [Playlist] - var lastUsedPlaylistName: String? - var selectedPlaylist: Playlist? - var onAddToPlaylist: ((Track, Playlist) -> Void)? - var onAddToLastPlaylist: ((Track) -> Void)? - var onRemoveFromPlaylist: ((Track) -> Void)? - var onReorder: ((Int, Int) -> Void)? -``` - -Replace with: - -```swift - var contextMenuConfig: TrackContextMenuConfig? - var onReorder: ((Int, Int) -> Void)? -``` - -- [ ] **Step 2: Update `menuNeedsUpdate` to read from `contextMenuConfig`** - -Find the entire `menuNeedsUpdate` method (lines ~333–375) and replace it: - -```swift - func menuNeedsUpdate(_ menu: NSMenu) { - menu.removeAllItems() - guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } - guard let config = parent.contextMenuConfig else { return } - - if let lastPlaylistName = config.lastUsedPlaylistName, config.onAddToLastPlaylist != nil { - let lastItem = NSMenuItem( - title: "Add to \(lastPlaylistName)", - action: #selector(addToLastPlaylist(_:)), - keyEquivalent: "" - ) - lastItem.target = self - menu.addItem(lastItem) - menu.addItem(.separator()) - } - - if !config.playlists.isEmpty { - let submenu = NSMenu() - for (index, playlist) in config.playlists.enumerated() { - let item = NSMenuItem( - title: playlist.name, - action: #selector(addToPlaylist(_:)), - keyEquivalent: "" - ) - item.target = self - item.tag = index - submenu.addItem(item) - } - let submenuItem = NSMenuItem(title: "Add to Playlist", action: nil, keyEquivalent: "") - submenuItem.submenu = submenu - menu.addItem(submenuItem) - } - - if config.selectedPlaylist != nil, config.onRemoveFromPlaylist != nil { - menu.addItem(.separator()) - let removeItem = NSMenuItem( - title: "Remove from Playlist", - action: #selector(removeFromPlaylist(_:)), - keyEquivalent: "" - ) - removeItem.target = self - menu.addItem(removeItem) - } - } -``` - -- [ ] **Step 3: Update the three `@objc` menu action methods to use `contextMenuConfig`** - -Find `addToPlaylist`, `addToLastPlaylist`, and `removeFromPlaylist` (lines ~377–394) and replace all three: - -```swift - @objc func addToPlaylist(_ sender: NSMenuItem) { - guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } - guard let config = parent.contextMenuConfig else { return } - let track = tracks[tableView.clickedRow] - let playlist = config.playlists[sender.tag] - config.onAddToPlaylist(track, playlist) - } - - @objc func addToLastPlaylist(_ sender: NSMenuItem) { - guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } - guard let config = parent.contextMenuConfig else { return } - let track = tracks[tableView.clickedRow] - config.onAddToLastPlaylist?(track) - } - - @objc func removeFromPlaylist(_ sender: NSMenuItem) { - guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } - guard let config = parent.contextMenuConfig else { return } - let track = tracks[tableView.clickedRow] - config.onRemoveFromPlaylist?(track) - } -``` - -- [ ] **Step 4: Build — expect ContentView compile errors (call site not yet updated)** - -``` -xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep "error:" -``` - -Expected: errors in `ContentView.swift` about removed parameters. `TrackTableView.swift` itself should be clean. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Views/TrackTableView.swift -git commit -m "refactor: replace TrackTableView playlist params with TrackContextMenuConfig" -``` - ---- - -### Task 4: Add `contextMenuConfig` to `PlayerControlsView` - -**Files:** -- Modify: `Music/Views/PlayerControlsView.swift:22` (add param after `onNowPlayingTap`) -- Modify: `Music/Views/PlayerControlsView.swift:112-118` (apply modifier to `nowPlayingSection`) - -- [ ] **Step 1: Add the new parameter to `PlayerControlsView`** - -In `Music/Views/PlayerControlsView.swift`, find line 22: - -```swift - let onNowPlayingTap: () -> Void -``` - -Add after it: - -```swift - let onNowPlayingTap: () -> Void - var contextMenuConfig: TrackContextMenuConfig? = nil -``` - -- [ ] **Step 2: Apply the modifier to `nowPlayingSection`** - -In `PlayerControlsView.swift`, find the closing of `nowPlayingSection` (lines ~112–118): - -```swift - .contentShape(Rectangle()) - .onTapGesture { - if currentTrack != nil { - onNowPlayingTap() - } - } - } -``` - -Replace with: - -```swift - .contentShape(Rectangle()) - .onTapGesture { - if currentTrack != nil { - onNowPlayingTap() - } - } - .trackContextMenu(track: currentTrack, config: contextMenuConfig) - } -``` - -- [ ] **Step 3: Build — expect ContentView errors only** - -``` -xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep "error:" -``` - -Expected: only `ContentView.swift` errors remain (old params still passed there). - -- [ ] **Step 4: Commit** - -```bash -git add Music/Views/PlayerControlsView.swift -git commit -m "feat: add contextMenuConfig param to PlayerControlsView" -``` - ---- - -### Task 5: Update `ContentView` — wire both call sites - -**Files:** -- Modify: `Music/ContentView.swift:181-194` (TrackTableView call site) -- Modify: `Music/ContentView.swift:333-362` (PlayerControlsView call site) - -- [ ] **Step 1: Replace the TrackTableView playlist params with `contextMenuConfig`** - -In `Music/ContentView.swift`, find lines 181–200 (inside `TrackTableView(...)`): - -```swift - playlists: playlist.playlists, - lastUsedPlaylistName: playlist.lastUsedPlaylistName, - selectedPlaylist: playlist.selectedPlaylist, - onAddToPlaylist: { track, targetPlaylist in - try? playlist.addTrack(track, to: targetPlaylist) - }, - onAddToLastPlaylist: { track in - try? playlist.addTrackToLastUsedPlaylist(track) - }, - onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in - if let selected = playlist.selectedPlaylist { - try? playlist.removeTrack(track, from: selected) - } - } : nil, -``` - -Replace with: - -```swift - contextMenuConfig: TrackContextMenuConfig( - playlists: playlist.playlists, - lastUsedPlaylistName: playlist.lastUsedPlaylistName, - selectedPlaylist: playlist.selectedPlaylist, - onAddToPlaylist: { track, targetPlaylist in - try? playlist.addTrack(track, to: targetPlaylist) - }, - onAddToLastPlaylist: { track in - try? playlist.addTrackToLastUsedPlaylist(track) - }, - onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in - if let selected = playlist.selectedPlaylist { - try? playlist.removeTrack(track, from: selected) - } - } : nil - ), -``` - -- [ ] **Step 2: Pass `contextMenuConfig` to `PlayerControlsView`** - -In `Music/ContentView.swift`, find the `PlayerControlsView(...)` block (lines ~333–362). Add `contextMenuConfig` after `onNowPlayingTap`: - -```swift - onNowPlayingTap: { scrollToPlayingTrigger = UUID() }, - contextMenuConfig: TrackContextMenuConfig( - playlists: playlist.playlists, - lastUsedPlaylistName: playlist.lastUsedPlaylistName, - selectedPlaylist: playlist.selectedPlaylist, - onAddToPlaylist: { track, targetPlaylist in - try? playlist.addTrack(track, to: targetPlaylist) - }, - onAddToLastPlaylist: { track in - try? playlist.addTrackToLastUsedPlaylist(track) - }, - onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in - if let selected = playlist.selectedPlaylist { - try? playlist.removeTrack(track, from: selected) - } - } : nil - ) -``` - -- [ ] **Step 3: Build cleanly** - -``` -xcodebuild build -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "error:|BUILD SUCCEEDED|BUILD FAILED" -``` - -Expected: `BUILD SUCCEEDED` with no errors. - -- [ ] **Step 4: Run the full test suite** - -``` -xcodebuild test -scheme Music -destination 'platform=macOS' 2>&1 | grep -E "Test.*passed|Test.*failed|error:|BUILD" -``` - -Expected: all tests pass, including `TrackContextMenuConfigTests`. - -- [ ] **Step 5: Manual verification** - -Run the app. With at least one playlist created: - -1. **Track table:** right-click any track row → confirm the menu shows "Add to [last]" / "Add to Playlist" submenu / "Remove from Playlist" (when in a playlist view). Behaviour must be unchanged. -2. **Bottom controls:** play a track, then right-click anywhere on the now-playing area (album art + title + artist) → confirm the same menu appears. -3. **No track playing:** right-click the empty now-playing area → confirm no menu appears (the modifier is a no-op when `currentTrack` is nil). - -- [ ] **Step 6: Commit** - -```bash -git add Music/ContentView.swift -git commit -m "feat: wire TrackContextMenuConfig to bottom controls and track table" -``` diff --git a/docs/superpowers/plans/2026-05-30-track-get-info.md b/docs/superpowers/plans/2026-05-30-track-get-info.md deleted file mode 100644 index 8728bad..0000000 --- a/docs/superpowers/plans/2026-05-30-track-get-info.md +++ /dev/null @@ -1,1194 +0,0 @@ -# Track "Get Info" Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add a macOS-Music-style "Get Info" dialog (right-click + ⌘I) that views and edits a track's metadata, persisting edits to the SQLite DB always and writing them back into the audio file's tags (mp3 + m4a/alac/aac) best-effort. - -**Architecture:** A `TagWriter` protocol abstracts per-format tag writing (ID3TagEditor for mp3, AVFoundation passthrough-export for m4a-family; flac/wav/aiff fall back to DB-only). A `TrackEditService` orchestrates each save (apply edited fields → write file tags → refresh file stats → DB update). A tabbed SwiftUI `TrackInfoSheet` collects edits; the context menu resolves single- or multi-track targets from the table selection. - -**Tech Stack:** Swift 6 / SwiftUI / AppKit (NSTableView), GRDB (SQLite + FTS5), AVFoundation, ID3TagEditor (new SPM dep), Swift Testing. - ---- - -## Verified facts (from codebase inspection — trust these over assumptions) - -- **`Track`** (`Music/Models/Track.swift`): 23 stored props incl. `id: Int64?`, `fileURL: String` (stored as `url.absoluteString`, i.e. `file://…`), the editable set (`title, artist, albumArtist, album, genre, composer: String`; `year, trackNumber, discNumber, bpm: Int?`; `rating: Int`), and `fileSize: Int64`, `dateModified: Date`, `fileHash: String`. Conforms to `FetchableRecord, MutablePersistableRecord`, `databaseTableName = "tracks"`. Has `Track.computeHash(fileSize:modificationDate:) -> String` returning `"\(fileSize)_\(Int(modificationDate.timeIntervalSince1970))"`. Has `Track.fixture(...)` (DEBUG) for tests. -- **`DatabaseService`** (`Music/Services/DatabaseService.swift`): `nonisolated final class … Sendable`; connection is `let dbPool: DatabaseWriter`; writes via `dbPool.write { db in … }`. `init(inMemory: Bool)` for tests. FTS5 `tracks_ft` is created with `t.synchronize(withTable: "tracks")`, which installs INSERT/UPDATE/DELETE **triggers** — so a plain `track.update(db)` keeps FTS in sync automatically (no manual FTS code). No `updateTrack` exists yet. -- **`ScannerService.extractMetadata`** (`Music/Services/ScannerService.swift:162-188`) computes stats as: `let attrs = try FileManager.default.attributesOfItem(atPath: url.path); let fileSize = attrs[.size] as? Int64 ?? 0; let modDate = attrs[.modificationDate] as? Date ?? Date()` then `fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate)`. Supported extensions: `mp3, m4a, aac, wav, aiff, alac, flac`. -- **`LibraryViewModel`** (`Music/ViewModels/LibraryViewModel.swift`): `@Observable final class` (implicitly `@MainActor`). Exposes `var tracks: [Track]`. Holds `private let db: DatabaseService`. Uses GRDB **`ValueObservation`** (`updateQuery()`), so `tracks` **auto-refreshes whenever the DB changes** — there is NO `loadTracks()` and none is needed after an edit. -- **`TrackTableView`** (`Music/Views/TrackTableView.swift`): `NSViewRepresentable`. Props: `tracks, playingTrackId, sortColumn, sortAscending, onSort, onDoubleClick, contextMenuConfig, onReorder, scrollToPlayingTrigger`. **`allowsMultipleSelection = false` (line 57)** and there is **no selection binding** — selection lives in `NSTableView.selectedRowIndexes`. The `Coordinator` (NSMenuDelegate) builds the menu in `menuNeedsUpdate(_:)` off `tableView.clickedRow` and maps rows via its own `tracks` array. `@objc` handlers (`addToPlaylist` etc.) follow the pattern: guard `clickedRow`, read `config`, call closure. -- **`TrackContextMenuConfig`** (`Music/Models/TrackContextMenuConfig.swift`): `nonisolated struct` with `playlists, lastUsedPlaylistName, selectedPlaylist, onAddToPlaylist, onAddToLastPlaylist?, onRemoveFromPlaylist?`. -- **`TrackContextMenuModifier`** (`Music/Views/TrackContextMenuModifier.swift`): `struct … ViewModifier` whose `body(content:)` adds `content.contextMenu { if let track, let config { … } }`; plus `extension View { func trackContextMenu(track:config:) }`. Used by `PlayerControlsView` (`.trackContextMenu(track: currentTrack, config: contextMenuConfig)`). -- **`ContentView`** (`Music/ContentView.swift` — note: under `Music/`, not `Music/Views/`): has a computed `private var trackContextMenuConfig: TrackContextMenuConfig` that delegates to the `playlist` view model; passes it into `TrackTableView(... contextMenuConfig: trackContextMenuConfig ...)`. `displayedTracks` returns `library.tracks`. Already holds `var library: LibraryViewModel` and `var db: DatabaseService`. Uses `.sheet(isPresented:)` and `.sheet(item:)` patterns. -- **Sheet UI pattern** (`Music/Views/SmartPlaylistBuilderSheet.swift`): `struct` with `var onSave/onCancel` closures, `@State` form fields set in a custom `init`, a `canSave` computed flag, body is a `VStack(alignment:.leading, spacing:16)` with `.padding(20).frame(width: 540)`, and an `HStack { Spacer(); Button("Cancel", action: onCancel); Button("Save"){…}.disabled(!canSave).keyboardShortcut(.defaultAction) }`. -- **Sandbox (CRITICAL):** `Music/Music.entitlements` has `com.apple.security.files.user-selected.read-only = true` and `com.apple.security.files.bookmarks.app-scope = true` — i.e. the app currently has **READ-ONLY** access to the user's music folder. `Music/MusicApp.swift` lets the user pick a folder via `NSOpenPanel` (line ~175), saves a security-scoped bookmark (`url.bookmarkData(options: .withSecurityScope)`, key `"musicFolderBookmark"`), and resolves it at launch with `url.startAccessingSecurityScopedResource()`. **File-tag writeback is impossible until the entitlement is changed to read-write AND a read-write bookmark is obtained** (Task 1). -- **SPM:** GRDB is integrated as a remote SPM package (`packageReferences` in `Music.xcodeproj/project.pbxproj`, lines ~234, ~658-685). ID3TagEditor must be added the same way (Task 2). -- **Tests:** `MusicTests/` uses **Swift Testing** (`import Testing`, `@testable import Music`, `struct SomeTests { @Test func name() throws { … } }`, assertions via `#expect(...)` and `try #require(...)`), constructs `try DatabaseService(inMemory: true)`, and uses `Track.fixture(...)`. (Do NOT use XCTest.) Async tests are `@Test func name() async throws`. To reach bundled fixtures from a `struct` suite, use a token class: `private final class BundleToken {}` then `Bundle(for: BundleToken.self)`. - -### Commands used throughout -- Build: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'` -- Test one class: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/` -- If `-scheme Music` is wrong, list with `xcodebuild -list -project Music.xcodeproj` and use the app scheme. -- A "failing test" in Swift/Xcode is often a **build failure** (symbol not found). That counts as red. - ---- - -## Task 1: Enable read-write file access and prove a write works - -**Files:** -- Modify: `Music/Music.entitlements` -- Verify: `Music/MusicApp.swift` (bookmark save/resolve), no code change required unless re-grant needed - -- [ ] **Step 1: Flip the user-selected entitlement to read-write** - -In `Music/Music.entitlements`, replace: -```xml - com.apple.security.files.user-selected.read-only - -``` -with: -```xml - com.apple.security.files.user-selected.read-write - -``` -If the Xcode target also carries a managed build setting `ENABLE_USER_SELECTED_FILES = readonly`, set it to `readwrite` (Target → Signing & Capabilities → App Sandbox → User Selected File → Read/Write) so it doesn't override the entitlements file. Confirm: `grep -n "user-selected" Music/Music.entitlements` shows `read-write`. - -- [ ] **Step 2: Re-grant the music folder with write access** - -The existing bookmark in `UserDefaults["musicFolderBookmark"]` was created read-only. After the entitlement change, run the app and **re-select the music folder** via the existing folder picker so `saveBookmark(for:)` stores a read-write security-scoped bookmark. (No code change — just exercise the existing picker once.) - -- [ ] **Step 3: Build** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'` -Expected: BUILD SUCCEEDED. - -- [ ] **Step 4: Permission spike — prove a write succeeds** - -Add a temporary throwaway `@Test` that copies a fixture audio file into the user music folder and writes a byte, OR (simpler) run the app, open a track in the folder, and in a scratch action call: -```swift -let url = URL(string: track.fileURL)! -let fh = try FileHandle(forWritingTo: url) // throws if not writable -try fh.close() -``` -Expected: no permission error (`FileHandle(forWritingTo:)` succeeds). If it throws "Operation not permitted", the bookmark is still read-only — repeat Step 2. **Do not proceed past this task until a write to a real library file succeeds.** Remove the scratch code afterward. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Music.entitlements -git commit -m "feat: enable read-write file access for tag writeback" -``` - ---- - -## Task 2: Add the ID3TagEditor SPM dependency - -**Files:** -- Modify: `Music.xcodeproj/project.pbxproj` + `Music.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved` (Xcode-managed) - -- [ ] **Step 1: Add the package** - -In Xcode: File → Add Package Dependencies → `https://github.com/chicio/ID3TagEditor` → Up to Next Major `4.0.0` → add the `ID3TagEditor` product to the **Music** app target only. (Headless fallback: mirror the GRDB entries in `project.pbxproj` — `XCRemoteSwiftPackageReference`, `XCSwiftPackageProductDependency`, `packageReferences`, and the Music target's `Frameworks` build phase + `packageProductDependencies` — then `xcodebuild -resolvePackageDependencies -project Music.xcodeproj`. This is fiddly; prefer the Xcode UI, and surface to the user if it can't be done headless.) - -- [ ] **Step 2: Verify it links** - -Add `import ID3TagEditor` to the top of `Music/Services/ScannerService.swift` temporarily, then build: -Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'` -Expected: BUILD SUCCEEDED. Remove the temporary import. - -- [ ] **Step 3: Commit** - -```bash -git add Music.xcodeproj -git commit -m "build: add ID3TagEditor SPM dependency" -``` - ---- - -## Task 3: `TrackFileStats` helper (shared stat/hash computation) - -**Files:** -- Create: `Music/Services/TrackFileStats.swift` -- Create: `MusicTests/TrackFileStatsTests.swift` -- Modify: `Music/Services/ScannerService.swift:162-188` (use the helper) - -- [ ] **Step 1: Write the failing test** - -`MusicTests/TrackFileStatsTests.swift`: -```swift -import Foundation -import Testing -@testable import Music - -// Verifies the shared file-stat helper reads size/mod-date from disk and -// produces a fileHash identical to Track.computeHash (the existing canonical formula). -struct TrackFileStatsTests { - @Test func compute_matchesTrackComputeHash() throws { - // Step 1: write a temp file with known bytes. - let url = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(UUID().uuidString + ".bin") - try Data(repeating: 0xAB, count: 1234).write(to: url) - defer { try? FileManager.default.removeItem(at: url) } - - // Step 2: compute stats via the helper. - let stats = try TrackFileStats.compute(for: url) - - // Step 3: independently read attrs and assert the helper agrees. - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - let size = attrs[.size] as? Int64 ?? -1 - let mod = attrs[.modificationDate] as? Date ?? Date.distantPast - #expect(stats.fileSize == size) - #expect(stats.dateModified == mod) - #expect(stats.fileHash == Track.computeHash(fileSize: size, modificationDate: mod)) - } -} -``` - -- [ ] **Step 2: Run it (expect failure)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TrackFileStatsTests` -Expected: FAIL — build error "cannot find 'TrackFileStats' in scope". - -- [ ] **Step 3: Implement the helper** - -`Music/Services/TrackFileStats.swift`: -```swift -import Foundation - -// Reads a file's size + modification date and derives the library fileHash. -// Centralizes the computation so ScannerService (import) and TrackEditService -// (post-writeback refresh) can never drift. Hash uses Track.computeHash so the -// format stays identical to import-time hashes. -nonisolated struct TrackFileStats: Sendable { - let fileSize: Int64 - let dateModified: Date - let fileHash: String - - static func compute(for url: URL) throws -> TrackFileStats { - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - let fileSize = attrs[.size] as? Int64 ?? 0 - let modDate = attrs[.modificationDate] as? Date ?? Date() - return TrackFileStats( - fileSize: fileSize, - dateModified: modDate, - fileHash: Track.computeHash(fileSize: fileSize, modificationDate: modDate) - ) - } -} -``` - -- [ ] **Step 4: Run it (expect pass)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TrackFileStatsTests` -Expected: PASS. - -- [ ] **Step 5: Refactor ScannerService to use it (behavior-preserving)** - -In `Music/Services/ScannerService.swift`, replace lines 162-164: -```swift - let attrs = try FileManager.default.attributesOfItem(atPath: url.path) - let fileSize = attrs[.size] as? Int64 ?? 0 - let modDate = attrs[.modificationDate] as? Date ?? Date() -``` -with: -```swift - let stats = try TrackFileStats.compute(for: url) -``` -and update the `Track(...)` initializer to use `fileSize: stats.fileSize`, `dateModified: stats.dateModified`, `fileHash: stats.fileHash` (replacing the three `fileSize` / `modDate` / `Track.computeHash(...)` usages at lines 182, 187, 188). - -- [ ] **Step 6: Build + run scanner-related tests** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'` -Expected: BUILD SUCCEEDED. - -- [ ] **Step 7: Commit** - -```bash -git add Music/Services/TrackFileStats.swift MusicTests/TrackFileStatsTests.swift Music/Services/ScannerService.swift -git commit -m "refactor: extract TrackFileStats shared stat/hash helper" -``` - ---- - -## Task 4: `EditableTrackFields` + `TrackField` + diff/shared/apply logic - -**Files:** -- Create: `Music/Models/EditableTrackFields.swift` -- Create: `MusicTests/EditableTrackFieldsTests.swift` - -- [ ] **Step 1: Write the failing test** - -`MusicTests/EditableTrackFieldsTests.swift`: -```swift -import Foundation -import Testing -@testable import Music - -// Verifies the pure single/multi-track edit logic: extraction, change detection, -// shared-vs-mixed across many tracks, and applying only edited fields. -struct EditableTrackFieldsTests { - @Test func initCopiesEditableValues() { - // Step 1: build fields from a fixture track. - let t = Track.fixture(title: "A", artist: "B", album: "C", year: 2001, rating: 3) - let f = EditableTrackFields(from: t) - // Step 2: editable values match. - #expect(f.title == "A"); #expect(f.artist == "B") - #expect(f.album == "C"); #expect(f.year == 2001); #expect(f.rating == 3) - } - - @Test func changedFieldsDetectsOnlyDifferences() { - // Step 1: two field sets differing only in genre + bpm. - let a = EditableTrackFields(from: .fixture(genre: "Rock", bpm: 120)) - var b = a; b.genre = "Jazz"; b.bpm = 90 - // Step 2: change set is exactly {genre, bpm}. - #expect(a.changedFields(to: b) == [.genre, .bpm]) - } - - @Test func sharedMarksDifferingFieldsMixed() { - // Step 1: two tracks share artist but differ in genre. - let t1 = Track.fixture(artist: "Same", genre: "Rock") - let t2 = Track.fixture(artist: "Same", genre: "Pop") - // Step 2: shared() returns common artist and flags genre as mixed. - let (values, mixed) = EditableTrackFields.shared(across: [t1, t2]) - #expect(values.artist == "Same") - #expect(mixed.contains(.genre)) - #expect(!mixed.contains(.artist)) - } - - @Test func applyOnlyWritesEditedFields() { - // Step 1: a track and a fields object that changes album only. - let t = Track.fixture(album: "Old", genre: "Rock") - var f = EditableTrackFields(from: t); f.album = "New"; f.genre = "IGNORED" - // Step 2: applying with editing={.album} changes album, leaves genre. - let out = f.apply(editing: [.album], to: t) - #expect(out.album == "New") - #expect(out.genre == "Rock") - } - - @Test func applyEmptyEditSetReturnsUnchanged() { - let t = Track.fixture(title: "Keep") - let f = EditableTrackFields(from: t) - #expect(f.apply(editing: [], to: t) == t) - } -} -``` - -- [ ] **Step 2: Run it (expect failure)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/EditableTrackFieldsTests` -Expected: FAIL — "cannot find 'EditableTrackFields' / 'TrackField'". - -- [ ] **Step 3: Implement** - -`Music/Models/EditableTrackFields.swift`: -```swift -import Foundation - -// The user-editable subset of Track, plus the pure logic for single- and -// multi-track editing. No UI, no I/O — fully unit-testable. -nonisolated enum TrackField: CaseIterable, Sendable { - case title, artist, albumArtist, album, genre, composer - case year, trackNumber, discNumber, bpm, rating -} - -nonisolated struct EditableTrackFields: Equatable, Sendable { - var title: String - var artist: String - var albumArtist: String - var album: String - var genre: String - var composer: String - var year: Int? - var trackNumber: Int? - var discNumber: Int? - var bpm: Int? - var rating: Int - - init(from t: Track) { - title = t.title; artist = t.artist; albumArtist = t.albumArtist - album = t.album; genre = t.genre; composer = t.composer - year = t.year; trackNumber = t.trackNumber; discNumber = t.discNumber - bpm = t.bpm; rating = t.rating - } - - func changedFields(to other: EditableTrackFields) -> Set { - var changed: Set = [] - if title != other.title { changed.insert(.title) } - if artist != other.artist { changed.insert(.artist) } - if albumArtist != other.albumArtist { changed.insert(.albumArtist) } - if album != other.album { changed.insert(.album) } - if genre != other.genre { changed.insert(.genre) } - if composer != other.composer { changed.insert(.composer) } - if year != other.year { changed.insert(.year) } - if trackNumber != other.trackNumber { changed.insert(.trackNumber) } - if discNumber != other.discNumber { changed.insert(.discNumber) } - if bpm != other.bpm { changed.insert(.bpm) } - if rating != other.rating { changed.insert(.rating) } - return changed - } - - // Returns prefill values (from the first track) plus the set of fields whose - // values are NOT identical across all tracks (shown as "Mixed" in the UI). - static func shared(across tracks: [Track]) -> (values: EditableTrackFields, mixed: Set) { - let base = EditableTrackFields(from: tracks[0]) - var mixed: Set = [] - for t in tracks.dropFirst() { - mixed.formUnion(base.changedFields(to: EditableTrackFields(from: t))) - } - return (base, mixed) - } - - // Copies ONLY the edited fields onto the track; everything else is untouched. - func apply(editing edited: Set, to track: Track) -> Track { - var t = track - if edited.contains(.title) { t.title = title } - if edited.contains(.artist) { t.artist = artist } - if edited.contains(.albumArtist) { t.albumArtist = albumArtist } - if edited.contains(.album) { t.album = album } - if edited.contains(.genre) { t.genre = genre } - if edited.contains(.composer) { t.composer = composer } - if edited.contains(.year) { t.year = year } - if edited.contains(.trackNumber) { t.trackNumber = trackNumber } - if edited.contains(.discNumber) { t.discNumber = discNumber } - if edited.contains(.bpm) { t.bpm = bpm } - if edited.contains(.rating) { t.rating = rating } - return t - } -} -``` - -- [ ] **Step 4: Run it (expect pass)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/EditableTrackFieldsTests` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Models/EditableTrackFields.swift MusicTests/EditableTrackFieldsTests.swift -git commit -m "feat: add EditableTrackFields with diff/shared/apply logic" -``` - ---- - -## Task 5: `TagWriter` protocol, factory, and format writers - -**Files:** -- Create: `Music/Services/TagWriting/TagWriter.swift` -- Create: `Music/Services/TagWriting/MP4TagWriter.swift` -- Create: `Music/Services/TagWriting/ID3TagWriter.swift` -- Create: `MusicTests/TagWriterTests.swift` -- Create: `MusicTests/Fixtures/sample.m4a`, `MusicTests/Fixtures/sample.mp3` (added to the MusicTests target as bundle resources) - -> **Fixtures:** generate `sample.m4a` with `say "test" -o /tmp/s.aiff && afconvert -d aac -f m4af /tmp/s.aiff MusicTests/Fixtures/sample.m4a`. macOS has no mp3 encoder; obtain a tiny `sample.mp3` with `ffmpeg -i /tmp/s.aiff -b:a 64k MusicTests/Fixtures/sample.mp3` if ffmpeg is available, else commit any small (<100 KB) mp3. Add both to the MusicTests target's "Copy Bundle Resources". If no mp3 can be obtained, the mp3 round-trip test returns early (passes trivially) — **say so explicitly** in the task output rather than letting it silently pass. - -- [ ] **Step 1: Write the failing test** - -`MusicTests/TagWriterTests.swift`: -```swift -import Foundation -import AVFoundation -import Testing -@testable import Music - -private final class BundleToken {} // locates the test bundle from a struct suite - -// Verifies format routing and that writing tags round-trips through a real file -// without corrupting audio. -struct TagWriterTests { - - private func fixtureURL(_ name: String, _ ext: String) -> URL? { - Bundle(for: BundleToken.self).url(forResource: name, withExtension: ext) - } - private func tempCopy(of url: URL) throws -> URL { - let dst = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(UUID().uuidString + "." + url.pathExtension) - try FileManager.default.copyItem(at: url, to: dst) - return dst - } - private func readCommonTitle(_ url: URL) async throws -> String? { - let md = try await AVURLAsset(url: url).load(.metadata) - let items = AVMetadataItem.metadataItems(from: md, withKey: AVMetadataKey.commonKeyTitle, keySpace: .common) - return try await items.first?.load(.stringValue) - } - - @Test func factoryRoutesByExtension() { - #expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.mp3")) is ID3TagWriter) - #expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.m4a")) is MP4TagWriter) - #expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.flac")) == nil) - #expect(TagWriterFactory.writer(for: URL(fileURLWithPath: "/a.wav")) == nil) - } - - @Test func m4aRoundTrips() async throws { - // Step 1: copy the fixture so we don't mutate the bundled file. - let src = try #require(fixtureURL("sample", "m4a"), "missing sample.m4a fixture") - let url = try tempCopy(of: src) - defer { try? FileManager.default.removeItem(at: url) } - // Step 2: write new title/artist. - var f = EditableTrackFields(from: .fixture()) - f.title = "Round Trip"; f.artist = "The Verifier" - try MP4TagWriter().write(f, to: url) - // Step 3: re-read and assert, and assert audio still loads. - #expect(try await readCommonTitle(url) == "Round Trip") - let tracks = try await AVURLAsset(url: url).loadTracks(withMediaType: .audio) - #expect(!tracks.isEmpty) // audio track survived the write - } - - @Test func mp3RoundTrips() async throws { - // If no mp3 fixture is available, pass trivially (note this in the run output). - guard let src = fixtureURL("sample", "mp3") else { return } - let url = try tempCopy(of: src) - defer { try? FileManager.default.removeItem(at: url) } - var f = EditableTrackFields(from: .fixture()) - f.title = "ID3 Round Trip"; f.artist = "Tagger" - try ID3TagWriter().write(f, to: url) - #expect(try await readCommonTitle(url) == "ID3 Round Trip") - } -} -``` - -- [ ] **Step 2: Run it (expect failure)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TagWriterTests` -Expected: FAIL — "cannot find 'TagWriterFactory' / 'MP4TagWriter' / 'ID3TagWriter'". - -- [ ] **Step 3: Implement protocol + factory** - -`Music/Services/TagWriting/TagWriter.swift`: -```swift -import Foundation - -// Writes the editable, tag-mappable fields into an audio file. rating is -// intentionally NOT written (DB-only in v1). Implementations write atomically. -nonisolated protocol TagWriter: Sendable { - func write(_ fields: EditableTrackFields, to url: URL) throws -} - -nonisolated enum TagWriterError: Error { case exportUnavailable, exportFailed } - -nonisolated enum TagWriterFactory { - // Returns nil for formats with no v1 writer (flac/wav/aiff) → DB-only. - static func writer(for url: URL) -> TagWriter? { - switch url.pathExtension.lowercased() { - case "mp3": return ID3TagWriter() - case "m4a", "alac", "aac": return MP4TagWriter() - default: return nil - } - } -} -``` - -- [ ] **Step 4: Implement MP4TagWriter** - -`Music/Services/TagWriting/MP4TagWriter.swift`: -```swift -import Foundation -import AVFoundation - -// Writes iTunes/common metadata into m4a-family files via a passthrough export -// to a temp file, then an atomic replace of the original. NOTE: passthrough -// export rewrites the metadata set, so unmodeled atoms (e.g. custom tags) may -// not survive — acceptable for v1; TagLib would remove this limitation. -nonisolated struct MP4TagWriter: TagWriter { - func write(_ fields: EditableTrackFields, to url: URL) throws { - let asset = AVURLAsset(url: url) - guard let export = AVAssetExportSession(asset: asset, presetName: AVAssetExportPresetPassthrough) else { - throw TagWriterError.exportUnavailable - } - let tmp = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(UUID().uuidString + ".m4a") - export.outputURL = tmp - export.outputFileType = .m4a - export.metadata = Self.items(from: fields) - - // Block this background thread until export completes (callers run this - // off the main actor). Safe for v1's sequential saves. - let sema = DispatchSemaphore(value: 0) - var exportError: Error? - export.exportAsynchronously { - if export.status != .completed { exportError = export.error ?? TagWriterError.exportFailed } - sema.signal() - } - sema.wait() - if let exportError { try? FileManager.default.removeItem(at: tmp); throw exportError } - - _ = try FileManager.default.replaceItemAt(url, withItemAt: tmp) - } - - private static func items(from f: EditableTrackFields) -> [AVMetadataItem] { - func item(_ id: AVMetadataIdentifier, _ value: (any NSCopying & NSObjectProtocol)?) -> AVMetadataItem? { - guard let value else { return nil } - let m = AVMutableMetadataItem() - m.identifier = id - m.value = value - return m - } - var out: [AVMetadataItem?] = [ - item(.commonIdentifierTitle, f.title as NSString), - item(.commonIdentifierArtist, f.artist as NSString), - item(.commonIdentifierAlbumName, f.album as NSString), - item(.iTunesMetadataAlbumArtist, f.albumArtist as NSString), - item(.iTunesMetadataUserGenre, f.genre as NSString), - item(.iTunesMetadataComposer, f.composer as NSString), - ] - if let y = f.year { out.append(item(.iTunesMetadataReleaseDate, String(y) as NSString)) } - if let n = f.trackNumber { out.append(item(.iTunesMetadataTrackNumber, NSNumber(value: n))) } - if let d = f.discNumber { out.append(item(.iTunesMetadataDiscNumber, NSNumber(value: d))) } - if let b = f.bpm { out.append(item(.iTunesMetadataBeatsPerMin, NSNumber(value: b))) } - return out.compactMap { $0 } - } -} -``` - -- [ ] **Step 5: Implement ID3TagWriter** - -`Music/Services/TagWriting/ID3TagWriter.swift`: -```swift -import Foundation -import ID3TagEditor - -// Writes ID3v2.3 string frames into mp3 files. ID3TagEditor writes the new tag -// in place. NOTE: builds a fresh tag with the managed string frames; unmodeled -// frames (e.g. attached artwork) may not be preserved in v1 — acceptable; TagLib -// later. VERIFY the exact ID3TagEditor 4.x builder API against its README; the -// shape below targets 4.x. -nonisolated struct ID3TagWriter: TagWriter { - func write(_ fields: EditableTrackFields, to url: URL) throws { - let editor = ID3TagEditor() - var builder = ID32v3TagBuilder() - .title(frame: ID3FrameWithStringContent(content: fields.title)) - .artist(frame: ID3FrameWithStringContent(content: fields.artist)) - .albumArtist(frame: ID3FrameWithStringContent(content: fields.albumArtist)) - .album(frame: ID3FrameWithStringContent(content: fields.album)) - .genre(genre: ID3FrameGenre(genre: nil, description: fields.genre)) - .composer(frame: ID3FrameWithStringContent(content: fields.composer)) - if let y = fields.year { - builder = builder.recordingYear(frame: ID3FrameWithIntegerContent(value: y)) - } - if let n = fields.trackNumber { - builder = builder.trackPosition(frame: ID3FramePartOfTotal(part: n, total: nil)) - } - if let d = fields.discNumber { - builder = builder.discPosition(frame: ID3FramePartOfTotal(part: d, total: nil)) - } - if let b = fields.bpm { - builder = builder.beatsPerMinute(frame: ID3FrameWithIntegerContent(value: b)) - } - let tag = builder.build() - try editor.write(tag: tag, to: url.path) // ID3TagEditor writes in place - } -} -``` - -- [ ] **Step 6: Run it (expect pass)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TagWriterTests` -Expected: PASS (mp3 test may be skipped if no fixture — note it explicitly). If ID3TagEditor symbol names differ, fix per its README until green. - -- [ ] **Step 7: Commit** - -```bash -git add Music/Services/TagWriting MusicTests/TagWriterTests.swift MusicTests/Fixtures -git commit -m "feat: add TagWriter protocol with mp3/m4a writers" -``` - ---- - -## Task 6: `DatabaseService.updateTrack` - -**Files:** -- Modify: `Music/Services/DatabaseService.swift` (add method under `// MARK: - Write`, near line 204) -- Modify: `MusicTests/DatabaseServiceTests.swift` (add cases) - -- [ ] **Step 1: Write the failing test** - -Append inside the existing `struct DatabaseServiceTests { … }`: -```swift - // Verifies updateTrack persists edited fields and that the tracks_ft index - // stays in sync (the synchronize-installed triggers fire on UPDATE). - @Test func updateTrackPersistsFieldsAndSyncsFTS() throws { - // Step 1: insert a track. - let db = try DatabaseService(inMemory: true) - var t = Track.fixture(title: "Original Title", artist: "X") - try db.insert(&t) - // Step 2: edit fields and update. - t.title = "Renamed Title"; t.album = "New Album" - try db.updateTrack(t) - // Step 3: re-fetch and assert persisted. - let fetched = try #require(db.fetchTracksByIds([t.id!]).first) - #expect(fetched.title == "Renamed Title") - #expect(fetched.album == "New Album") - // Step 4: FTS reflects the new title and not the old (triggers keep it synced). - #expect(try db.fetchTracks(search: "Renamed", sortColumn: "title", ascending: true).count == 1) - #expect(try db.fetchTracks(search: "Original", sortColumn: "title", ascending: true).count == 0) - } -``` - -- [ ] **Step 2: Run it (expect failure)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/DatabaseServiceTests/updateTrackPersistsFieldsAndSyncsFTS` -Expected: FAIL — "value of type 'DatabaseService' has no member 'updateTrack'". - -- [ ] **Step 3: Implement** - -In `Music/Services/DatabaseService.swift`, after `updatePlayStats(...)` (line 204): -```swift - // Full-record update for metadata edits. The tracks_ft FTS5 index is kept in - // sync automatically by the triggers installed via synchronize(withTable:), - // so no manual FTS write is needed here. - func updateTrack(_ track: Track) throws { - try dbPool.write { db in - try track.update(db) - } - } -``` - -- [ ] **Step 4: Run it (expect pass)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/DatabaseServiceTests/updateTrackPersistsFieldsAndSyncsFTS` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Services/DatabaseService.swift MusicTests/DatabaseServiceTests.swift -git commit -m "feat: add DatabaseService.updateTrack" -``` - ---- - -## Task 7: `TrackEditService` orchestration - -**Files:** -- Create: `Music/Services/TrackEditService.swift` -- Create: `MusicTests/TrackEditServiceTests.swift` - -- [ ] **Step 1: Write the failing test** - -`MusicTests/TrackEditServiceTests.swift`: -```swift -import Foundation -import Testing -@testable import Music - -// Verifies the save orchestration: DB always updated; file writeback best-effort; -// stats refreshed on success; warnings on unsupported format / writer failure. -struct TrackEditServiceTests { - - // A spy writer we can make succeed or throw. - struct SpyWriter: TagWriter { - let shouldThrow: Bool - func write(_ fields: EditableTrackFields, to url: URL) throws { - if shouldThrow { throw TagWriterError.exportFailed } - // simulate a real write by appending a byte so size/mtime change. - let h = try FileHandle(forWritingTo: url); try h.seekToEnd() - try h.write(contentsOf: Data([0])); try h.close() - } - } - - private func tempTrack(ext: String) throws -> Track { - let url = URL(fileURLWithPath: NSTemporaryDirectory()) - .appendingPathComponent(UUID().uuidString + "." + ext) - try Data(repeating: 1, count: 100).write(to: url) - return .fixture(fileURL: url.absoluteString, fileFormat: ext) - } - - @Test func supportedFormatSuccessUpdatesDBAndRefreshesStats() throws { - // Step 1: DB + a real temp file + an edit changing the title. - let db = try DatabaseService(inMemory: true) - var t = try tempTrack(ext: "mp3"); try db.insert(&t) - let original = EditableTrackFields(from: t) - var edited = original; edited.title = "Edited" - // Step 2: save via a succeeding writer. - let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: false) }) - let warnings = svc.save(edited, editing: original.changedFields(to: edited), to: [t]) - // Step 3: no warnings; DB has new title and refreshed hash/size. - #expect(warnings.isEmpty) - let f = try #require(db.fetchTracksByIds([t.id!]).first) - #expect(f.title == "Edited") - #expect(f.fileHash != t.fileHash) // writer changed the file - try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) - } - - @Test func unsupportedFormatSavesDBOnlyWithWarning() throws { - let db = try DatabaseService(inMemory: true) - var t = try tempTrack(ext: "flac"); try db.insert(&t) - var edited = EditableTrackFields(from: t); edited.album = "DB Only" - let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) // nil for flac - let warnings = svc.save(edited, editing: [.album], to: [t]) - #expect(warnings.count == 1) - #expect(warnings.first?.kind == .dbOnlyUnsupported) - #expect(try #require(db.fetchTracksByIds([t.id!]).first).album == "DB Only") - try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) - } - - @Test func writerThrowsSavesDBOnlyWithFailureWarning() throws { - let db = try DatabaseService(inMemory: true) - var t = try tempTrack(ext: "mp3"); try db.insert(&t) - var edited = EditableTrackFields(from: t); edited.genre = "Still Saved" - let svc = TrackEditService(database: db, writerFactory: { _ in SpyWriter(shouldThrow: true) }) - let warnings = svc.save(edited, editing: [.genre], to: [t]) - #expect(warnings.first?.kind == .fileWriteFailed) - #expect(try #require(db.fetchTracksByIds([t.id!]).first).genre == "Still Saved") - try? FileManager.default.removeItem(at: URL(string: t.fileURL)!) - } - - @Test func multiTrackAppliesOnlyEditedFields() throws { - let db = try DatabaseService(inMemory: true) - var a = try tempTrack(ext: "flac"); a.album = "OldA"; a.genre = "RockA"; try db.insert(&a) - var b = try tempTrack(ext: "flac"); b.album = "OldB"; b.genre = "RockB"; try db.insert(&b) - var edited = EditableTrackFields(from: a); edited.album = "Shared" - let svc = TrackEditService(database: db, writerFactory: TagWriterFactory.writer) - _ = svc.save(edited, editing: [.album], to: [a, b]) - // album applied to both; each genre untouched. - #expect(try #require(db.fetchTracksByIds([a.id!]).first).album == "Shared") - #expect(try #require(db.fetchTracksByIds([b.id!]).first).album == "Shared") - #expect(try #require(db.fetchTracksByIds([b.id!]).first).genre == "RockB") - try? FileManager.default.removeItem(at: URL(string: a.fileURL)!) - try? FileManager.default.removeItem(at: URL(string: b.fileURL)!) - } -} -``` - -- [ ] **Step 2: Run it (expect failure)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TrackEditServiceTests` -Expected: FAIL — "cannot find 'TrackEditService'". - -- [ ] **Step 3: Implement** - -`Music/Services/TrackEditService.swift`: -```swift -import Foundation - -nonisolated struct TrackEditWarning: Sendable, Equatable { - enum Kind: Sendable, Equatable { case dbOnlyUnsupported, fileWriteFailed } - let trackId: Int64? - let fileURL: String - let kind: Kind - let reason: String -} - -// Orchestrates a metadata save: apply edited fields → best-effort file-tag write -// → refresh file stats on success → DB update. The DB is ALWAYS updated; file -// writeback failures are collected as warnings, never blocking the library edit. -nonisolated final class TrackEditService: Sendable { - private let database: DatabaseService - private let writerFactory: @Sendable (URL) -> TagWriter? - - init(database: DatabaseService, - writerFactory: @escaping @Sendable (URL) -> TagWriter? = TagWriterFactory.writer) { - self.database = database - self.writerFactory = writerFactory - } - - func save(_ values: EditableTrackFields, - editing edited: Set, - to tracks: [Track]) -> [TrackEditWarning] { - var warnings: [TrackEditWarning] = [] - for track in tracks { - var updated = values.apply(editing: edited, to: track) - // rating is DB-only; only attempt file writes if tag-mappable fields changed. - let tagFieldsChanged = !edited.subtracting([.rating]).isEmpty - - if let url = URL(string: track.fileURL), tagFieldsChanged { - if let writer = writerFactory(url) { - do { - try writer.write(values, to: url) - if let stats = try? TrackFileStats.compute(for: url) { - updated.fileSize = stats.fileSize - updated.dateModified = stats.dateModified - updated.fileHash = stats.fileHash - } - } catch { - warnings.append(.init(trackId: track.id, fileURL: track.fileURL, - kind: .fileWriteFailed, reason: error.localizedDescription)) - } - } else { - warnings.append(.init(trackId: track.id, fileURL: track.fileURL, - kind: .dbOnlyUnsupported, - reason: "Tag writing not supported for .\(url.pathExtension)")) - } - } - try? database.updateTrack(updated) - } - return warnings - } -} -``` - -- [ ] **Step 4: Run it (expect pass)** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64' -only-testing:MusicTests/TrackEditServiceTests` -Expected: PASS. - -- [ ] **Step 5: Commit** - -```bash -git add Music/Services/TrackEditService.swift MusicTests/TrackEditServiceTests.swift -git commit -m "feat: add TrackEditService save orchestration" -``` - ---- - -## Task 8: `LibraryViewModel.applyTrackEdits` - -**Files:** -- Modify: `Music/ViewModels/LibraryViewModel.swift` - -> The list auto-refreshes via the existing `ValueObservation` once the DB changes — no manual reload. Run the save off the main actor (file I/O), then return warnings to the caller. - -- [ ] **Step 1: Add an edit service + method** - -In `Music/ViewModels/LibraryViewModel.swift`, add a lazily-built service and a method: -```swift - private lazy var editService = TrackEditService(database: db) - - // Applies edits to one or more tracks. File writes run off the main actor; - // the library list refreshes automatically via the DB observation. - func applyTrackEdits(_ values: EditableTrackFields, - editing edited: Set, - to tracks: [Track]) async -> [TrackEditWarning] { - let service = editService - return await Task.detached { service.save(values, editing: edited, to: tracks) }.value - } -``` -(`db` is already a stored `private let`; `TrackEditService` is `Sendable`, so capturing it in `Task.detached` is safe.) - -- [ ] **Step 2: Build** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'` -Expected: BUILD SUCCEEDED. - -- [ ] **Step 3: Commit** - -```bash -git add Music/ViewModels/LibraryViewModel.swift -git commit -m "feat: add LibraryViewModel.applyTrackEdits" -``` - ---- - -## Task 9: `TrackInfoSheet` UI (tabbed Details / File) - -**Files:** -- Create: `Music/Views/TrackInfoSheet.swift` - -- [ ] **Step 1: Implement the sheet** - -`Music/Views/TrackInfoSheet.swift` (mirrors `SmartPlaylistBuilderSheet`'s structure): -```swift -import SwiftUI - -// Get Info dialog. Edits one or many tracks. For multi-edit, fields that differ -// across tracks show a "Mixed" placeholder and only fields the user touches are -// applied. onSave hands back the edited values + the set of edited fields. -struct TrackInfoSheet: View { - let tracks: [Track] - var onSave: (EditableTrackFields, Set) -> Void - var onCancel: () -> Void - - @State private var fields: EditableTrackFields - @State private var mixed: Set - @State private var edited: Set = [] - @State private var tab = 0 - - init(tracks: [Track], - onSave: @escaping (EditableTrackFields, Set) -> Void, - onCancel: @escaping () -> Void) { - self.tracks = tracks - self.onSave = onSave - self.onCancel = onCancel - let (values, mixed) = EditableTrackFields.shared(across: tracks) - _fields = State(initialValue: values) - _mixed = State(initialValue: mixed) - } - - private var isMulti: Bool { tracks.count > 1 } - private var hasUnsupported: Bool { - tracks.contains { t in - ["flac", "wav", "aiff"].contains((URL(string: t.fileURL)?.pathExtension ?? "").lowercased()) - } - } - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text(isMulti ? "Get Info — \(tracks.count) tracks" : "Get Info") - .font(.headline) - - if hasUnsupported { - Text("Edits save to your library only — tag writing isn’t supported for some selected formats yet.") - .font(.caption).foregroundStyle(.secondary) - } - - Picker("", selection: $tab) { - Text("Details").tag(0) - if !isMulti { Text("File").tag(1) } - } - .pickerStyle(.segmented) - .labelsHidden() - - if tab == 0 { detailsTab } else { fileTab } - - Divider() - HStack { - Spacer() - Button("Cancel", action: onCancel) - Button("Save") { onSave(fields, edited) } - .keyboardShortcut(.defaultAction) - } - } - .padding(20) - .frame(width: 460) - } - - // Binding helper that marks a field edited whenever it changes. - private func text(_ field: TrackField, _ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { mixed.contains(field) && !edited.contains(field) ? "" : fields[keyPath: keyPath] }, - set: { fields[keyPath: keyPath] = $0; edited.insert(field) } - ) - } - private func int(_ field: TrackField, _ keyPath: WritableKeyPath) -> Binding { - Binding( - get: { mixed.contains(field) && !edited.contains(field) ? "" : (fields[keyPath: keyPath].map(String.init) ?? "") }, - set: { fields[keyPath: keyPath] = Int($0.filter(\.isNumber)); edited.insert(field) } - ) - } - private func placeholder(_ field: TrackField) -> String { - mixed.contains(field) && !edited.contains(field) ? "Mixed" : "" - } - - private var detailsTab: some View { - VStack(alignment: .leading, spacing: 8) { - labeled("Title") { TextField(placeholder(.title), text: text(.title, \.title)) } - labeled("Artist") { TextField(placeholder(.artist), text: text(.artist, \.artist)) } - labeled("Album Artist") { TextField(placeholder(.albumArtist), text: text(.albumArtist, \.albumArtist)) } - labeled("Album") { TextField(placeholder(.album), text: text(.album, \.album)) } - labeled("Genre") { TextField(placeholder(.genre), text: text(.genre, \.genre)) } - labeled("Composer") { TextField(placeholder(.composer), text: text(.composer, \.composer)) } - HStack(spacing: 12) { - labeled("Year") { TextField(placeholder(.year), text: int(.year, \.year)).frame(width: 70) } - labeled("Track") { TextField(placeholder(.trackNumber), text: int(.trackNumber, \.trackNumber)).frame(width: 50) } - labeled("Disc") { TextField(placeholder(.discNumber), text: int(.discNumber, \.discNumber)).frame(width: 50) } - labeled("BPM") { TextField(placeholder(.bpm), text: int(.bpm, \.bpm)).frame(width: 60) } - } - labeled("Rating") { - Stepper(value: Binding( - get: { fields.rating }, - set: { fields.rating = max(0, min(5, $0)); edited.insert(.rating) } - ), in: 0...5) { Text(String(repeating: "★", count: fields.rating)) } - } - } - .textFieldStyle(.roundedBorder) - } - - @ViewBuilder private var fileTab: some View { - if let t = tracks.first { - VStack(alignment: .leading, spacing: 6) { - row("Kind", t.fileFormat.uppercased()) - row("Bit Rate", t.bitrate.map { "\($0) kbps" } ?? "—") - row("Sample Rate", t.sampleRate.map { "\($0) Hz" } ?? "—") - row("Size", ByteCountFormatter.string(fromByteCount: t.fileSize, countStyle: .file)) - row("Duration", String(format: "%d:%02d", Int(t.duration) / 60, Int(t.duration) % 60)) - row("Plays", "\(t.playCount)") - row("Where", URL(string: t.fileURL)?.path ?? t.fileURL) - } - .font(.system(size: 12)) - } - } - - private func labeled(_ title: String, @ViewBuilder _ content: () -> C) -> some View { - VStack(alignment: .leading, spacing: 2) { - Text(title).font(.caption).foregroundStyle(.secondary) - content() - } - } - private func row(_ k: String, _ v: String) -> some View { - HStack(alignment: .top) { - Text(k).foregroundStyle(.secondary).frame(width: 90, alignment: .leading) - Text(v).textSelection(.enabled) - } - } -} -``` - -- [ ] **Step 2: Build** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'` -Expected: BUILD SUCCEEDED. - -- [ ] **Step 3: Commit** - -```bash -git add Music/Views/TrackInfoSheet.swift -git commit -m "feat: add tabbed TrackInfoSheet" -``` - ---- - -## Task 10: Wire "Get Info" into the context menus and ContentView - -**Files:** -- Modify: `Music/Models/TrackContextMenuConfig.swift` -- Modify: `Music/Views/TrackTableView.swift` (enable multi-select; add menu item + handler) -- Modify: `Music/Views/TrackContextMenuModifier.swift` -- Modify: `Music/ContentView.swift` - -- [ ] **Step 1: Add the closure to the config** - -In `Music/Models/TrackContextMenuConfig.swift`, add a field (after `onRemoveFromPlaylist`): -```swift - let onGetInfo: ([Track]) -> Void -``` - -- [ ] **Step 2: Enable multi-select in the table** - -In `Music/Views/TrackTableView.swift` line 57, change: -```swift - tableView.allowsMultipleSelection = false -``` -to: -```swift - tableView.allowsMultipleSelection = true -``` - -- [ ] **Step 3: Add the "Get Info" menu item** - -In `Music/Views/TrackTableView.swift` `menuNeedsUpdate(_:)`, insert at the TOP of the menu (right after the `guard let config …` on line 331, before the "Add to Last Playlist" block): -```swift - let infoItem = NSMenuItem(title: "Get Info", action: #selector(getInfo(_:)), keyEquivalent: "i") - infoItem.target = self - menu.addItem(infoItem) - menu.addItem(.separator()) -``` - -- [ ] **Step 4: Add the handler with target resolution** - -In `Music/Views/TrackTableView.swift`, alongside the other `@objc` handlers (after `removeFromPlaylist`, line 394): -```swift - // macOS Music behavior: operate on the full selection if the right-clicked - // row is part of it; otherwise just the clicked row. - @objc func getInfo(_ sender: NSMenuItem) { - guard let tableView, tableView.clickedRow >= 0, tableView.clickedRow < tracks.count else { return } - guard let config = parent.contextMenuConfig else { return } - let clicked = tableView.clickedRow - let rows: [Int] = tableView.selectedRowIndexes.contains(clicked) - ? tableView.selectedRowIndexes.sorted() - : [clicked] - let targets = rows.compactMap { $0 < tracks.count ? tracks[$0] : nil } - config.onGetInfo(targets) - } -``` - -- [ ] **Step 5: Add "Get Info" to the SwiftUI modifier** - -In `Music/Views/TrackContextMenuModifier.swift`, inside `content.contextMenu { if let track, let config { … } }`, add at the top of the block: -```swift - Button("Get Info") { config.onGetInfo([track]) } - .keyboardShortcut("i") - Divider() -``` - -- [ ] **Step 6: Wire ContentView — state, config closure, sheet** - -In `Music/ContentView.swift`: - -(a) Add presentation state near the other `@State` (after line 27): -```swift - @State private var infoRequest: TrackInfoRequest? - @State private var saveWarning: String? -``` -(b) Add an identifiable wrapper (top-level, below the imports or above `ContentView`): -```swift -struct TrackInfoRequest: Identifiable { - let id = UUID() - let tracks: [Track] -} -``` -(c) In the computed `trackContextMenuConfig`, add the closure (alongside the other `on…` args): -```swift - onGetInfo: { tracks in - if !tracks.isEmpty { infoRequest = TrackInfoRequest(tracks: tracks) } - } -``` -(d) Add the sheet + warning alert to `body` (after the existing `.sheet(item: $smartPlaylistBuilderEditing) { … }` block, which ends at line 355): -```swift - .sheet(item: $infoRequest) { req in - TrackInfoSheet( - tracks: req.tracks, - onSave: { values, edited in - let targets = req.tracks - infoRequest = nil - Task { - let warnings = await library.applyTrackEdits(values, editing: edited, to: targets) - if !warnings.isEmpty { - let failed = warnings.filter { $0.kind == .fileWriteFailed }.count - let dbOnly = warnings.filter { $0.kind == .dbOnlyUnsupported }.count - var msg = "Saved to your library." - if failed > 0 { msg += " Couldn’t write tags to \(failed) file(s)." } - if dbOnly > 0 { msg += " \(dbOnly) file(s) use a format without tag writing." } - saveWarning = msg - } - } - }, - onCancel: { infoRequest = nil } - ) - } - .alert("Edit Saved", isPresented: Binding( - get: { saveWarning != nil }, - set: { if !$0 { saveWarning = nil } } - )) { - Button("OK", role: .cancel) { saveWarning = nil } - } message: { - Text(saveWarning ?? "") - } -``` - -- [ ] **Step 7: Build** - -Run: `xcodebuild build -scheme Music -destination 'platform=macOS,arch=arm64'` -Expected: BUILD SUCCEEDED. (If any other site constructs `TrackContextMenuConfig`, add the `onGetInfo:` argument there too — `grep -rn "TrackContextMenuConfig(" Music/` to find them.) - -- [ ] **Step 8: Commit** - -```bash -git add Music/Models/TrackContextMenuConfig.swift Music/Views/TrackTableView.swift Music/Views/TrackContextMenuModifier.swift Music/ContentView.swift -git commit -m "feat: wire Get Info (⌘I) into context menus and ContentView" -``` - ---- - -## Task 11: Manual end-to-end verification - -**Files:** none (verification only) - -- [ ] **Step 1: Run the full suite** - -Run: `xcodebuild test -scheme Music -destination 'platform=macOS,arch=arm64'` -Expected: all tests pass (note if the mp3 round-trip test passed trivially due to a missing fixture). - -- [ ] **Step 2: mp3 writeback** — In the running app, right-click an mp3 under the music folder → Get Info → change Title → Save. Reopen Get Info: the new title shows. In Finder "Get Info" / another tag reader, the file tag changed and the file still plays. - -- [ ] **Step 3: m4a writeback** — Repeat Step 2 for an m4a. - -- [ ] **Step 4: flac DB-only** — Edit a flac: the library updates, the "library only" note showed in the sheet, and the post-save alert mentions a format without tag writing (no failure error). - -- [ ] **Step 5: multi-track** — Select 3 tracks, right-click one of them → Get Info → set Album → Save. All three get the new album; fields left as "Mixed" are unchanged per-track. - -- [ ] **Step 6: no re-scan churn** — Trigger a library rescan and confirm edited files are not reprocessed (the refreshed `fileHash` matches), and FTS search finds the edited tracks by their new titles. - -- [ ] **Step 7: Commit any fixups** - -```bash -git add -A -git commit -m "test: verify Get Info end-to-end" -``` - ---- - -## Self-review notes -- **Spec coverage:** DB+file writeback (Tasks 5–8), single+multi-track with Mixed (Tasks 4, 9, 10), existing-fields-only + read-only File tab (Task 9), mp3/m4a writers behind a TagLib-ready protocol (Task 5), tabbed UI (Task 9), DB-always/file-best-effort failure model (Task 7), ⌘I context menu (Task 10), rating DB-only (enforced in Task 7 via `edited.subtracting([.rating])` and excluded from writers). The sandbox risk flagged in the spec is resolved in Task 1; FTS sync confirmed automatic (Task 6). -- **Type consistency:** `EditableTrackFields`, `TrackField`, `TagWriter`, `TagWriterFactory.writer`, `TagWriterError`, `TrackFileStats.compute`, `TrackEditService.save`, `TrackEditWarning.Kind`, `DatabaseService.updateTrack`, `LibraryViewModel.applyTrackEdits`, `TrackContextMenuConfig.onGetInfo`, `TrackInfoRequest` — names used identically across tasks. -- **Known v1 limitations (intentional):** unmodeled tags (artwork/custom atoms) may not survive a writeback; raw `.aac` may fail export and fall back to DB-only with a warning; rating is not written to files. -- **Verify-at-implementation:** exact ID3TagEditor 4.x builder API (Task 5 Step 5); the Xcode scheme name; that adding the SPM package can be done in the working environment (Task 2). diff --git a/docs/superpowers/specs/2026-05-26-music-streaming-design.md b/docs/superpowers/specs/2026-05-26-music-streaming-design.md deleted file mode 100644 index 3ae07fd..0000000 --- a/docs/superpowers/specs/2026-05-26-music-streaming-design.md +++ /dev/null @@ -1,276 +0,0 @@ -# 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 ` 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 `. -- 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:///tracks//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. diff --git a/docs/superpowers/specs/2026-05-26-remote-mode-design.md b/docs/superpowers/specs/2026-05-26-remote-mode-design.md deleted file mode 100644 index 93b5de3..0000000 --- a/docs/superpowers/specs/2026-05-26-remote-mode-design.md +++ /dev/null @@ -1,270 +0,0 @@ -# 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. diff --git a/docs/superpowers/specs/2026-05-30-add-to-new-playlist-design.md b/docs/superpowers/specs/2026-05-30-add-to-new-playlist-design.md deleted file mode 100644 index b1c785d..0000000 --- a/docs/superpowers/specs/2026-05-30-add-to-new-playlist-design.md +++ /dev/null @@ -1,110 +0,0 @@ -# Add "New Playlist…" to the Add-to-Playlist menu - -**Date:** 2026-05-30 -**Status:** Approved design - -## Goal - -In a track's right-click "Add to Playlist" submenu, let the user create a brand-new -regular playlist on the fly: pick **New Playlist…**, enter a name, and on save the -playlist is created and the track is added to it. - -## Background - -The "Add to Playlist" submenu lives in `TrackContextMenuModifier.swift` and is driven by -the data-only `TrackContextMenuConfig` struct (a `playlists` array plus action closures), -built in `ContentView.trackContextMenuConfig` (`ContentView.swift:415`). - -The app already creates playlists from the sidebar via an `.alert` + `TextField` -(`ContentView.swift:273`), calling `PlaylistViewModel.createPlaylist(name:)` → -`DatabaseService.createPlaylist(name:) -> Playlist`. Adding a track is -`PlaylistViewModel.addTrack(_:to:)`, which also records the playlist as last-used. - -Regular and smart playlists are separate tables; this feature only creates **regular** -playlists (`Playlist`). - -## Approach - -Route the name prompt up to `ContentView`, which already owns the new-playlist alert. - -SwiftUI alerts do not present reliably when attached *inside* a context menu's content -(the menu dismisses, and the per-row modifier is re-instantiated). So the menu item only -signals intent — "create a new playlist for this track" — and `ContentView` owns the -prompt and the orchestration. This reuses the existing alert pattern rather than -duplicating it inside the modifier (the rejected alternative, which would also need the -modifier to reach into `PlaylistViewModel`). - -## Behavior decisions - -- Adds **only the clicked track** (matches the current single-track "Add to Playlist"). -- **No navigation** — after create+add the sidebar selection is unchanged. -- Empty / whitespace-only name → no-op (matches the existing create flow). -- When the user has **no** existing playlists, the submenu still appears showing just - "New Playlist…" (today the whole submenu is hidden when `playlists` is empty). -- Remote mode → wired the same way as the existing "Add to Playlist" action - (unconditionally), keeping parity with current behavior. - -## Changes - -### 1. `PlaylistViewModel` (`Music/ViewModels/PlaylistViewModel.swift`) - -Add one orchestration method — the unit under test: - -```swift -@discardableResult -func createPlaylistAndAddTrack(name: String, track: Track) throws -> Playlist { - let playlist = try db.createPlaylist(name: name) - try addTrack(track, to: playlist) - return playlist -} -``` - -`db.createPlaylist` returns a `Playlist` with its assigned `id`; `addTrack` adds the track -and sets `lastUsedPlaylistId` to the new playlist (so the "Add to " item updates too). - -### 2. `TrackContextMenuConfig` (`Music/Models/TrackContextMenuConfig.swift`) - -Add a new optional closure, defaulting to `nil` (so all existing call sites and tests -compile unchanged): - -```swift -let onAddToNewPlaylist: ((Track) -> Void)? -``` - -Add it to the explicit `init` with a `= nil` default, alongside the other optionals. - -### 3. `TrackContextMenuModifier` (`Music/Views/TrackContextMenuModifier.swift`) - -Inside the "Add to Playlist" submenu: - -- Add a **New Playlist…** button at the top (ellipsis = opens a prompt) when - `onAddToNewPlaylist != nil`, followed by a `Divider` before the list of existing - playlists. -- Relax the visibility guard so the submenu shows when - `!config.playlists.isEmpty || config.onAddToNewPlaylist != nil` (previously hidden - whenever `playlists` was empty). - -### 4. `ContentView` (`Music/ContentView.swift`) - -- Add state: `@State private var newPlaylistTrack: Track?` and a name field - (reuse/parallel the existing `playlistNameInput` style; a dedicated field is fine). -- In `trackContextMenuConfig`, wire `onAddToNewPlaylist: { track in newPlaylistTrack = track }`. -- Present an alert (mirroring the existing New Playlist alert) gated on - `newPlaylistTrack != nil`. **Create** trims whitespace, and if non-empty calls - `playlist.createPlaylistAndAddTrack(name:track:)` with the pending track; **Cancel** and - completion both clear `newPlaylistTrack` and the name field. - -## Testing (TDD) - -Unit-test `PlaylistViewModel.createPlaylistAndAddTrack(name:track:)` against an in-memory -database: - -1. Seed a track in the DB. -2. Call `createPlaylistAndAddTrack(name:track:)`. -3. Assert a regular playlist with that name now exists. -4. Assert the playlist's tracks contain the seeded track. -5. Assert `lastUsedPlaylistId` equals the new playlist's id. - -The SwiftUI menu/alert rendering is not unit-tested, consistent with the rest of the -codebase. `TrackContextMenuConfig`'s new optional defaults to `nil`, so existing -`TrackContextMenuConfigTests` are unaffected. diff --git a/docs/superpowers/specs/2026-05-30-fix-zero-bitrate-design.md b/docs/superpowers/specs/2026-05-30-fix-zero-bitrate-design.md deleted file mode 100644 index 9d04559..0000000 --- a/docs/superpowers/specs/2026-05-30-fix-zero-bitrate-design.md +++ /dev/null @@ -1,166 +0,0 @@ -# Fix `bitrate = 0` Tracks — Design - -**Date:** 2026-05-30 -**Status:** Approved (design) - -## Problem - -Many tracks in the library have a stored `bitrate` of `0`. Bitrate of `0` is -displayed literally and is meaningless. Users want these tracks to show their -real bitrate, and want new imports to stop producing the problem. - -## Root Cause - -`ScannerService.extractMetadata()` extracts bitrate via AVFoundation: - -```swift -// Music/Services/ScannerService.swift (~line 151) -let estimatedRate = try await audioTrack.load(.estimatedDataRate) -bitrate = Int(estimatedRate / 1000) // bits/sec -> kbps -``` - -For some files (observed: long / VBR MP3s such as 2-hour DJ "Essential Mix" -recordings), `AVAsset.estimatedDataRate` returns `0`. `Int(0 / 1000)` is `0`, so -a literal `0` is written to the DB. The `duration` field is extracted correctly -for the same files, which is what makes recovery reliable. - -Tracks with `bitrate IS NULL` exist as well — the importer never produced any -value. These display as "—" and are treated as equally "missing" for this work. - -### Evidence - -Validated against a real `bitrate = 0` row (a ~120 min MP3): - -| Method | Result | -|------------------------------------------|---------------| -| `fileSize × 8 ÷ duration ÷ 1000` | 256.0 kbps | -| `ffprobe -show_entries format=bit_rate` | 256005 → 256.0 kbps | - -The two agree to the kbps. For VBR, both methods yield the true **average** -bitrate, which is the meaningful value. - -## Scope - -Both halves of the problem: - -1. **Backfill** existing rows where `bitrate = 0 OR bitrate IS NULL`. -2. **Fix the importer** so future imports never store `0` again. - -No mass rescan is required: the script repairs today's rows; the importer fix -only needs to guarantee correctness for new imports. - -## Component 1 — Importer Fix (`ScannerService`) - -Extract the bitrate decision into a pure, unit-testable function: - -```swift -/// Resolve a track's bitrate (kbps) from the OS estimate, with a -/// file-size/duration fallback. Returns nil when no value can be derived — -/// never 0. -static func resolveBitrate(estimatedDataRate: Double, - fileSizeBytes: Int64?, - durationSeconds: Double?) -> Int? -``` - -Logic: - -- `estimatedDataRate > 0` - → `Int((estimatedDataRate / 1000).rounded())` (current behaviour, now rounded - rather than truncated). -- else if `fileSizeBytes != nil` **and** `durationSeconds != nil && > 0` - → `Int((Double(fileSizeBytes) * 8 / durationSeconds / 1000).rounded())`. -- else → `nil`. - -**Key invariant: the importer never stores `0`.** When nothing can be derived it -stores `nil`, which the UI already renders as "—". - -`extractMetadata()` is updated to: - -- read the file size it can already obtain via `FileManager.attributesOfItem(atPath:)`, -- pass `estimatedDataRate`, file size, and the loaded `duration` into - `resolveBitrate`, -- assign the result to `bitrate`. - -This keeps the AVFoundation I/O in `extractMetadata` and the arithmetic in a pure -function that tests can drive directly. - -## Component 2 — Backfill Script (`scripts/backfill_bitrate.py`) - -Mirrors the conventions of the existing `scripts/backfill_itunes_dates.py`: - -- Same `DEFAULT_DB` resolution (`~/Library/Containers/com.staxriver.mu/Data/ - Library/Application Support/Music/db.sqlite`), with `--db` override. -- Reuses the `norm_path` / percent-decoding approach to turn a stored `fileURL` - into a POSIX path. -- **Dry-run by default**; `--apply` writes after a timestamped backup of the DB. -- `--self-test` runs offline unit checks and exits. -- Stdlib only (`sqlite3`, `subprocess`, `os`, `urllib`, …), plus `ffprobe` as an - optional external tool. - -### Selection - -```sql -SELECT id, fileURL, duration, bitrate -FROM tracks -WHERE bitrate = 0 OR bitrate IS NULL; -``` - -### Per-row bitrate determination - -1. Resolve `fileURL` → POSIX path. If the file does not exist on disk → - report as **skipped (missing file)**, do not update. -2. Run `ffprobe -v error -show_entries format=bit_rate -of default=nw=1:nk=1 `. - - Parse an integer bps → `round(bps / 1000)` kbps. -3. If ffprobe is **absent**, **errors**, or returns **`N/A`/empty**, fall back to - the formula: `round(fileSizeBytes * 8 / durationSeconds / 1000)`. - - If there is also no usable duration → report as - **skipped (undeterminable)**, do not update. - -### Output - -- **Dry-run:** a table of `path · old → new` plus a summary. -- **`--apply`:** create `db.sqlite.bak-`, then `UPDATE tracks SET - bitrate = ? WHERE id = ?` per resolved row, in a single transaction. -- **Summary (both modes):** counts for updated, skipped-missing-file, - skipped-undeterminable, and whether ffprobe was available. - -## Testing (TDD — tests written before implementation) - -### Swift (`MusicTests`) - -Unit tests for `ScannerService.resolveBitrate`: - -1. Positive `estimatedDataRate` → rounded kbps (e.g. `320450.0` → `320`). -2. `estimatedDataRate == 0` with valid size + duration → formula result - (e.g. 230_358_479 bytes, 7198.54 s → `256`). -3. `estimatedDataRate == 0`, valid size, **no/zero duration** → `nil`. -4. `estimatedDataRate == 0`, **no file size** → `nil`. -5. Confirms the function never returns `0`. - -Each test carries a step-by-step comment describing what it exercises. - -### Python (`--self-test`) - -1. ffprobe-output parsing: `"256005\n"` → `256`. -2. `N/A`/empty ffprobe output → triggers formula fallback. -3. Formula math: `(230_358_479 * 8 / 7198.54 / 1000)` rounds to `256`. -4. `norm_path` edge cases (percent-encoding, `file://localhost/`, NFC, trailing - slash) — mirrored from the existing script's expectations. - -### Manual verification - -Run the script in dry-run against the real library DB and eyeball a sample -(ffprobe vs formula agreement) before `--apply`. - -## Operational Notes - -- `--apply` writes directly to the SQLite file. **Quit the app first** to avoid - WAL/lock contention — same caveat as `backfill_itunes_dates.py`. -- A timestamped backup is created before any write; restore by copying it back. - -## Out of Scope - -- No new UI (no in-app "repair bitrates" command); the script covers existing - rows and the importer covers future ones. -- No change to how bitrate is displayed. -- No re-encoding or modification of audio files — read-only analysis only. diff --git a/docs/superpowers/specs/2026-05-30-itunes-date-backfill-design.md b/docs/superpowers/specs/2026-05-30-itunes-date-backfill-design.md deleted file mode 100644 index 62c774a..0000000 --- a/docs/superpowers/specs/2026-05-30-itunes-date-backfill-design.md +++ /dev/null @@ -1,97 +0,0 @@ -# iTunes/Music → app DB date & stats backfill (one-time) - -Date: 2026-05-30 - -## Problem - -`ScannerService.extractMetadata` sets `dateAdded: Date()` at scan time -(`Music/Services/ScannerService.swift:186`), so every track's "added date" in the -app DB is really its *scan* date, not the date the user originally added it in -Apple Music. The user wants the **true `Date Added`** (and, since Music.app tracks -them too, `Play Count`, `Rating`, and last-played) copied from their Apple Music -library into the app's SQLite database. - -## Context (verified) - -- The app is **sandboxed**. Current `PRODUCT_BUNDLE_IDENTIFIER` is `com.staxriver.mu` - (HEAD and working tree; not part of the uncommitted diff). The live DB is therefore at: - `~/Library/Containers/com.staxriver.mu/Data/Library/Application Support/Music/db.sqlite` -- **The real app and real library are on a different computer.** This machine only has a - 3-track dev DB. The script must be portable and is intended to run on the other Mac. -- The user confirmed the audio files are the **same files** Apple Music references (they - live inside Apple Music's media folder, e.g. - `~/Music/Music/Media.localized/Music/...`). So the join key is the **file path**. -- `tracks.dateAdded` is a GRDB `.datetime` column, stored as the string - `YYYY-MM-DD HH:MM:SS.SSS` in UTC (confirmed from existing rows, e.g. - `2026-05-24 06:46:01.713`). GRDB is lenient on read, so `....000` round-trips. -- App rating scale is **0–5 stars** (`TrackTableView.swift:284` renders - `String(repeating: "★", count: track.rating)`). Music.app stores 0–100, so map `// 20`. - -## Approach - -A single **stdlib-only Python 3 script**, run once. Source of truth is the Music.app -**File ▸ Library ▸ Export Library…** XML plist (chosen over live AppleScript: no -Automation prompt, no timeouts, trivially parseable with `plistlib`). On matched tracks -it does a **blunt overwrite** of all four fields, with Music.app as the source of truth. - -## Matching (the bug-prone part) - -Join Music.app `Location` to `tracks.fileURL` on a **normalized decoded POSIX path**, -not raw URL strings. `norm_path()`: - -1. strip leading `file://`, then optional `localhost` host segment, -2. percent-decode (`urllib.parse.unquote`), -3. `unicodedata.normalize("NFC", …)` — neutralizes the accented-filename NFC/NFD mismatch - between APFS storage and the two URL string sources, -4. strip a trailing slash. - -Music tracks with no `Location` (Apple Music streaming entries) are skipped. - -## Field mapping (matched rows only; blunt overwrite) - -| Column | XML key | Rule | -|----------------|------------------|-------------------------------------------------------------| -| `dateAdded` | `Date Added` | `%Y-%m-%d %H:%M:%S.000` UTC. If absent, keep existing (col is NOT NULL). | -| `playCount` | `Play Count` | integer, `0` if absent. | -| `rating` | `Rating` (0–100) | `// 20` → 0–5, `0` if absent. | -| `lastPlayedAt` | `Play Date UTC` | same date format, or `NULL` if absent. | - -## Safety - -- **Dry-run by default**: prints match rate, a sample of before→after changes, and the - counts + samples of unmatched-in-DB and unmatched-in-XML. Writes nothing. -- `--apply`: first copies `db.sqlite` + `-wal` + `-shm` to a timestamped backup, then - performs all writes in a single transaction, then `PRAGMA wal_checkpoint(TRUNCATE)`. - Reversible by restoring the backup. -- The app must be **quit** before running so the sandbox DB isn't mid-write. - -## CLI - -``` -python3 scripts/backfill_itunes_dates.py --xml [--db ] [--apply] [--self-test] -``` - -- Default `--db`: `~/Library/Containers/com.staxriver.mu/Data/Library/Application Support/Music/db.sqlite` - computed from `$HOME`, so it resolves on the other Mac too. - -## Testing - -`scripts/test_backfill_itunes_dates.py` (stdlib `unittest`): - -- `norm_path`: NFC/NFD equivalence, `file://localhost/` form, percent-encoding, - filenames with spaces/`#`/parentheses/apostrophes. -- `build_updates`: date formatting, rating `// 20`, playCount & lastPlayed present/absent, - unmatched-row handling. -- Integration: a temp SQLite DB with the real `tracks` schema seeded with the user's actual - 3 track paths + scan dates and a synthetic Library.xml → `apply` → assert rows updated. - -## Delivery - -Script + test live in the repo under `scripts/`. The user commits (via `/commit`), pushes, -pulls on the real machine, runs **File ▸ Library ▸ Export Library…** there, then runs the -dry-run, eyeballs the match rate, and re-runs with `--apply`. - -## Out of scope - -Scanning the library into the app (the user does that in-app first), ongoing/automatic sync, -and non-file (streaming) tracks. diff --git a/docs/superpowers/specs/2026-05-30-playing-queue-design.md b/docs/superpowers/specs/2026-05-30-playing-queue-design.md deleted file mode 100644 index 061d3a9..0000000 --- a/docs/superpowers/specs/2026-05-30-playing-queue-design.md +++ /dev/null @@ -1,243 +0,0 @@ -# Playing Queue — Design - -**Date:** 2026-05-30 -**Status:** Approved (pending spec review) - -## Overview - -Add a Spotify-style **priority "Up Next" queue** to the Music app. Users can push -tracks to the front ("Play Next") or end ("Add to Queue") of a manual queue via the -track context menu. The manual queue plays before the current playback context -(playlist/album) resumes, survives starting a new context, and is visible and -editable in a right-docked "Up Next" panel. - -## Goals - -- "Play Next" and "Add to Queue" actions on the track context menu. -- A manual queue that takes priority over the playback context and persists when a - new context (playlist/album/library) starts playing. -- A visible, right-docked "Up Next" panel showing the manual queue and the upcoming - context tracks. -- Drag-to-reorder and remove **within the manual queue**. - -## Non-Goals (v1) - -- **Remote/streaming support.** When this app is driving a remote device, queue - actions are hidden and `next()` continues to delegate over the wire. No - `RemoteProtocol` changes. (Possible follow-up.) -- **Persistence across app restart.** The queue is in-memory in `PlayerViewModel`. -- **Reordering the context** from the panel (that is playlist editing, already - handled elsewhere). The "Next from" section is read-only. -- **Clear-all-queue** action (not requested). -- **Multi-select queueing.** Actions operate on the single right-clicked track, - consistent with the existing "Add to Playlist". - -## Resolved Decisions - -| Decision | Choice | Reason | -|---|---|---| -| Queue model | Spotify-style priority queue (manual queue distinct from context) | User selection | -| Panel placement | Right-docked slide-out, toggled from transport bar | User selection (mockup A) | -| Remote scope | Local-only for v1; hide actions in remote mode | User selection | -| Persistence | In-memory only | User selection | -| `queue`/`currentIndex` naming | Keep as the **context**; add doc comments | Backward-compatible; avoids touching remote-sync + existing tests | -| "Next from" section | Read-only, double-click to jump | Context reordering is out of scope | - -## Data Model (`PlayerViewModel`) - -Existing `queue` / `originalQueue` / `currentIndex` are **retained and keep their -current meaning: the playback CONTEXT** (playlist/album, with shuffle applied). New -state is added alongside: - -```swift -private(set) var queue: [Track] // UNCHANGED — the context (shuffled view) -private var originalQueue: [Track] // UNCHANGED — context, original order -var currentIndex: Int? // UNCHANGED — index into `queue` (the CONTEXT - // position), held even while a manual track plays -private(set) var manualQueue: [QueueEntry] = [] // NEW — priority "Up Next" entries -private(set) var contextName: String? // NEW — label for "Next from: " -``` - -Each manual entry carries its own identity so the same track can be queued twice -without SwiftUI confusing the rows (the codebase has prior id-collision bugs): - -```swift -nonisolated struct QueueEntry: Identifiable { - let id = UUID() - let track: Track -} -``` - -A dedicated `playManual(_:)` path plays a queued track **without** touching -`currentIndex`, so the context position is preserved automatically — no extra "am I -playing from the queue?" flag is needed. - -Computed for the panel: - -```swift -// Context tracks after the current context position; the "Next from" section. -var upcomingContext: [Track] { - guard let idx = currentIndex, idx + 1 < queue.count else { return [] } - return Array(queue[(idx + 1)...]) -} -``` - -`currentIndex` deliberately tracks the **context** position, not "what is playing." -While a manual-queue track plays, `currentIndex` stays put so the context resumes at -the correct spot once the manual queue drains. - -## Behavior - -### Adding to the queue - -```swift -func playNext(_ track: Track) // insert at front of manualQueue -func addToQueue(_ track: Track) // append to end of manualQueue -``` - -Both: if **nothing is currently playing** (`currentTrack == nil`), immediately pop -and play the just-queued track instead of leaving it parked (queue-while-idle starts -playback). - -### Advancing - -```swift -func next() { - if remoteProvider != nil { remote.sendNext(); return } // UNCHANGED remote path - if !manualQueue.isEmpty { - let entry = manualQueue.removeFirst() // consume-on-play - playManual(entry.track) // currentIndex unchanged - } else { - // existing context-advance logic (currentIndex + 1, stop at end) - } -} -``` - -- **Consume-on-play:** `removeFirst()` removes the track from "Up Next" the instant - it starts. The Queue section only ever shows not-yet-played tracks. -- **Resume point:** when `manualQueue` empties, `next()` advances the context from - the preserved `currentIndex`. -- Triggered identically by user-pressed Next and by auto-advance (`trackDidFinish`). - -### Previous - -Unchanged: steps back through the **context** (`currentIndex − 1`, clamped at 0). -It never re-adds consumed queue items and does not consult `manualQueue` (accepted -v1 simplification). - -### Shuffle - -Unchanged: only the context (`queue`) is shuffled. `manualQueue` order is always -preserved. - -### Explicit play / new context - -`play(_:)` (double-click a row, space-bar, Home) sets a new **context** via -`setQueue(_:contextName:)` and plays from it (setting `currentIndex`). The manual -queue is **not** cleared — it survives the new context, matching the chosen Spotify -model. - -### Queue editing - -```swift -func moveInQueue(from: IndexSet, to: Int) // reorder manualQueue (panel drag) -func removeFromQueue(at: IndexSet) // remove from manualQueue (panel × / swipe) -``` - -## UI - -### `setQueue` signature change - -```swift -func setQueue(_ tracks: [Track], contextName: String? = nil) -``` - -Call sites in `ContentView` pass the context label: playlist/smart-playlist name, -`"Library"`, or `"Recently Added"` (Home). Default `nil` keeps existing tests and -any unlabeled callers compiling. - -### Up Next panel — new `QueueView` - -A SwiftUI `List`, docked on the right of the main content: - -- **Section "Queue"** — `player.manualQueue`. `.onMove` → `moveInQueue`; row × / - `.swipeActions` → `removeFromQueue`. -- **Section "Next from: \"** — `player.upcomingContext`, read-only. - Double-click a row to jump to it in the context (sets `currentIndex` and plays). -- **Empty state** — "Queue is empty. Right-click a track → Add to Queue." - -### Integration into `ContentView` - -- New `@State private var showQueue = false`. -- Wrap the main-content region (the `VStack` holding `HomeView`/`TrackTableView`) in - `HStack(spacing: 0) { mainContent; if showQueue { QueueView(player: player) } }`. -- `PlayerControlsView` gains a queue-toggle button (bottom-right of the transport - bar) bound to `showQueue`. The button is **hidden when `networkStatus` indicates - remote-drive mode**. - -### Context menu — `TrackContextMenuConfig` - -Add two optional closures: - -```swift -let onPlayNext: ((Track) -> Void)? -let onAddToQueue: ((Track) -> Void)? -``` - -Rendered in **both** menu builders, as a group above "Add to Playlist": - -- `TrackTableView.Coordinator.menuNeedsUpdate(_:)` (AppKit `NSMenuItem`s + actions). -- `TrackContextMenuModifier` (SwiftUI `Button`s). - -Each is shown only when its closure is non-nil. In `ContentView.trackContextMenuConfig` -they are wired to `player.playNext` / `player.addToQueue`, and passed as **`nil` -when driving a remote device** so the items are hidden. - -## Remote / Edge Handling (local-only v1) - -- The disable gate is specifically the **`RemotePlaybackProvider`** case (driving a - separate remote device, `networkStatus.mode == .remote`): `next()` delegates over - the wire (unchanged) and never consults `manualQueue`; queue menu items hidden (nil - closures); queue-toggle button hidden. No protocol changes. -- **Streaming client** mode plays locally through `StreamingPlaybackProvider`, so it - is **not** gated — the queue works there, as it does for local and streaming-host - playback. -- Empty manual queue + empty upcoming context: panel shows empty state; `next()` - at the end of the context stops, as today. - -## Testing (TDD) - -New `PlayerViewModelTests` cases (reusing the `AudioService()` / `FakeStreamingProvider` -pattern already in the file). Each test carries a step-by-step comment: - -1. `addToQueue` appends to `manualQueue`; `playNext` inserts at the front. -2. `next()` plays the front of `manualQueue` before advancing the context, and - `removeFirst` consumes it. -3. After `manualQueue` drains, `next()` resumes the context at `currentIndex + 1`. -4. Queue-while-idle (`currentTrack == nil`) starts playback immediately. -5. `toggleShuffle()` leaves `manualQueue` order unchanged. -6. `removeFromQueue` / `moveInQueue` mutate `manualQueue` correctly. -7. `upcomingContext` returns the correct slice of the context. -8. Existing PlayerViewModel tests remain green (backward-compatible model). - -New `TrackContextMenuConfigTests` case: the `onPlayNext` / `onAddToQueue` closures -fire with the expected track. - -## Files Touched - -- `Music/ViewModels/PlayerViewModel.swift` — state, `playNext`/`addToQueue`/ - `moveInQueue`/`removeFromQueue`, `next()` priority logic, `upcomingContext`, - `setQueue(contextName:)`. -- `Music/Models/QueueEntry.swift` — **new** identity wrapper for queued tracks. -- `Music/Models/TrackContextMenuConfig.swift` — two new closures. -- `Music/Views/TrackTableView.swift` — AppKit menu items + actions. -- `Music/Views/TrackContextMenuModifier.swift` — SwiftUI menu buttons. -- `Music/Views/PlayerControlsView.swift` — queue-toggle button. -- `Music/Views/QueueView.swift` — **new** Up Next panel. -- `Music/ContentView.swift` — `showQueue` state, panel layout, wiring, `contextName` - at `setQueue` call sites. -- `MusicTests/PlayerViewModelTests.swift`, `MusicTests/TrackContextMenuConfigTests.swift` - — new tests. - -No `project.pbxproj` edit is needed: the project uses Xcode 16 file-system -synchronized groups, so new files under `Music/` are picked up automatically. diff --git a/docs/superpowers/specs/2026-05-30-smart-playlist-conditions-design.md b/docs/superpowers/specs/2026-05-30-smart-playlist-conditions-design.md deleted file mode 100644 index d31a5ec..0000000 --- a/docs/superpowers/specs/2026-05-30-smart-playlist-conditions-design.md +++ /dev/null @@ -1,166 +0,0 @@ -# Smart Playlist Conditions — Design Spec - -**Date:** 2026-05-30 -**Status:** Approved - -## Overview - -Extend smart playlists to support structured metadata-based conditions (e.g. artist = "Alicia Keys", year > 2015). Multiple conditions combine with AND. The existing FTS free-text smart playlist flow is preserved unchanged. - -## Data Model - -### SmartPlaylist (extended) - -Add one new optional field to the existing `SmartPlaylist` struct: - -```swift -var conditions: [SmartPlaylistCondition]? -``` - -- `nil` → FTS mode (existing behavior, no change) -- non-nil → structured SQL WHERE mode (new behavior) - -### SmartPlaylistCondition - -```swift -struct SmartPlaylistCondition: Codable, Equatable, Sendable { - var field: TrackField - var op: ConditionOperator - var value: ConditionValue -} -``` - -### TrackField - -`String` raw-value enum. Raw value matches the SQLite column name exactly (used directly in query building): - -``` -title, artist, albumArtist, album, genre, composer, -year, bpm, rating, playCount, trackNumber, discNumber, -duration, bitrate, sampleRate, fileSize, -dateAdded, dateModified, lastPlayedAt, fileFormat -``` - -Each field has an associated `FieldType`: `.string`, `.int`, `.double`, `.date`. - -### ConditionOperator - -```swift -enum ConditionOperator: String, Codable { - case equals - case startsWith // strings only - case greaterThan // numbers and dates only - case lessThan // numbers and dates only -} -``` - -Valid operators per field type: -- String: `equals`, `startsWith` -- Number (Int, Double): `equals`, `greaterThan`, `lessThan` -- Date: `equals`, `greaterThan`, `lessThan` - -### ConditionValue - -Tagged Codable union: - -```swift -enum ConditionValue: Codable, Equatable, Sendable { - case string(String) - case int(Int) - case double(Double) - case date(Date) -} -``` - -## Persistence - -### DB Migration (v5) - -```sql -ALTER TABLE smart_playlists ADD COLUMN conditions TEXT; -``` - -Nullable, no default. Existing rows stay `NULL` (FTS mode). - -### Encoding - -`SmartPlaylist.conditions` is encoded as a JSON string when writing to the `conditions` column and decoded on read. GRDB's `Codable` conformance handles this automatically via a custom `columnEncodingStrategy` or manual encode/decode. - -## Query Evaluation - -### SQL Generation - -New private method in `DatabaseService`: - -```swift -private func buildWhereClause(_ conditions: [SmartPlaylistCondition]) -> (sql: String, args: StatementArguments) -``` - -Mapping: - -| Field type | Operator | SQL fragment | -|------------|--------------|-------------------------------------------| -| String | equals | `LOWER({col}) = LOWER(?)` | -| String | startsWith | `LOWER({col}) LIKE LOWER(?) || '%'` | -| Number | equals | `{col} = ?` | -| Number | greaterThan | `{col} > ?` | -| Number | lessThan | `{col} < ?` | -| Date | equals | `{col} = ?` | -| Date | greaterThan | `{col} > ?` | -| Date | lessThan | `{col} < ?` | - -Fragments joined with ` AND `. Final query: - -```sql -SELECT * FROM tracks WHERE ORDER BY COLLATE NOCASE -``` - -### fetchTracks branch - -`PlaylistViewModel.observeSmartPlaylistTracks` branches on `smartPlaylist.conditions`: -- `nil` → existing FTS `ValueObservation` (unchanged) -- non-nil → new SQL WHERE `ValueObservation` using `buildWhereClause` - -## UI - -### Entry Point - -New "New Smart Playlist…" item in the app menu (alongside existing "New Playlist…"). Triggers a sheet (not an alert) since the form has multiple fields. - -### Condition Builder Sheet - -``` -┌─────────────────────────────────────────┐ -│ New Smart Playlist │ -│ │ -│ NAME │ -│ [________________________] │ -│ │ -│ CONDITIONS (all must match) │ -│ [Field ▾] [Operator ▾] [Value ] [−] │ -│ [Field ▾] [Operator ▾] [Value ] [−] │ -│ │ -│ [+ Add Condition ] │ -│ │ -│ [Cancel] [Save] │ -└─────────────────────────────────────────┘ -``` - -- One condition row shown by default -- Operator picker options update when field changes (string → is/starts with; number/date → is/greater than/less than) -- Value input adapts to field type: `TextField` for strings and numbers, `DatePicker` for date fields -- Remove (−) button disabled when only one condition remains -- Save disabled until name is non-empty and all condition values are non-empty - -### Edit Flow - -- **Structured smart playlists:** context menu shows "Edit…" → reopens the builder sheet pre-populated -- **FTS smart playlists:** context menu keeps existing "Edit Search Query…" → existing text alert (no change) - -## Out of Scope - -- OR logic between conditions -- Nested condition groups -- Track limit per playlist ("limit to N songs") -- Migrating existing FTS playlists to structured format -- Live-updating toggle (not needed — `ValueObservation` already handles this automatically) diff --git a/docs/superpowers/specs/2026-05-30-track-context-menu-bottom-controls-design.md b/docs/superpowers/specs/2026-05-30-track-context-menu-bottom-controls-design.md deleted file mode 100644 index d2b1440..0000000 --- a/docs/superpowers/specs/2026-05-30-track-context-menu-bottom-controls-design.md +++ /dev/null @@ -1,86 +0,0 @@ ---- -title: Track Context Menu on Bottom Controls -date: 2026-05-30 -status: approved ---- - -## Goal - -Right-clicking the now-playing area (bottom-left of the window) shows the same context menu as right-clicking a track in the track table: add to last playlist, add to any playlist via submenu, and remove from the current playlist when one is open. - -## Shared Config Struct - -A new `TrackContextMenuConfig` struct captures everything the menu needs: - -```swift -struct TrackContextMenuConfig { - let playlists: [Playlist] - let lastUsedPlaylistName: String? - let selectedPlaylist: Playlist? - let onAddToPlaylist: (Track, Playlist) -> Void - let onAddToLastPlaylist: ((Track) -> Void)? - let onRemoveFromPlaylist: ((Track) -> Void)? -} -``` - -This is the single source of truth for menu data. Both `TrackTableView` and `PlayerControlsView` receive one instance, constructed by `ContentView`. - -## Shared ViewModifier - -`TrackContextMenuModifier` is a SwiftUI `ViewModifier` that takes a `Track?` and `TrackContextMenuConfig?` and applies `.contextMenu` when both are non-nil: - -- **"Add to [last]"** button — shown only when `lastUsedPlaylistName` is set and `onAddToLastPlaylist` is non-nil. -- **"Add to Playlist"** submenu — one `Button` per playlist in `playlists`. Calls `onAddToPlaylist(track, playlist)`. -- **Divider + "Remove from Playlist"** — shown only when `selectedPlaylist != nil` and `onRemoveFromPlaylist` is non-nil. - -Menu is omitted entirely (no empty menu flicker) when `track` or `config` is nil. - -## PlayerControlsView Changes - -`PlayerControlsView` gains one new optional parameter: - -```swift -let contextMenuConfig: TrackContextMenuConfig? -``` - -The `nowPlayingSection` view applies `.trackContextMenu(track: currentTrack, config: contextMenuConfig)` (a convenience extension wrapping `TrackContextMenuModifier`). - -## TrackTableView Refactor - -`TrackTableView`'s existing four playlist-related parameters (`playlists`, `lastUsedPlaylistName`, `selectedPlaylist`, `onAddToLastPlaylist`, `onRemoveFromPlaylist`) are replaced by a single `contextMenuConfig: TrackContextMenuConfig?`. The `Coordinator.menuNeedsUpdate` builds its `NSMenu` from this config. This makes both call sites symmetric. - -> The AppKit `NSMenu` path in `TrackTableView` is kept — SwiftUI `.contextMenu` does not attach per-row in an `NSTableView`, so the table continues using `menuNeedsUpdate`. - -## ContentView Changes - -`ContentView` constructs one `TrackContextMenuConfig` and passes it to both views: - -```swift -let menuConfig = TrackContextMenuConfig( - playlists: playlist.allPlaylists, - lastUsedPlaylistName: playlist.lastUsedPlaylistName, - selectedPlaylist: playlist.selectedPlaylist, - onAddToPlaylist: { track, pl in try? playlist.addTrack(track, to: pl) }, - onAddToLastPlaylist: { track in try? playlist.addTrackToLastUsedPlaylist(track) }, - onRemoveFromPlaylist: playlist.selectedPlaylist != nil ? { track in - if let selected = playlist.selectedPlaylist { - try? playlist.removeTrack(track, from: selected) - } - } : nil -) -``` - -## Files Affected - -| File | Change | -|------|--------| -| `Music/Models/TrackContextMenuConfig.swift` | New file — struct definition | -| `Music/Views/TrackContextMenuModifier.swift` | New file — SwiftUI ViewModifier | -| `Music/Views/PlayerControlsView.swift` | Add `contextMenuConfig` param, apply modifier to `nowPlayingSection` | -| `Music/Views/TrackTableView.swift` | Replace individual playlist params with `contextMenuConfig`, adapt `menuNeedsUpdate` | -| `Music/ContentView.swift` | Construct and pass `TrackContextMenuConfig` to both views | - -## Out of Scope - -- Keyboard shortcut for the menu -- Any new menu items not already in the track table menu diff --git a/docs/superpowers/specs/2026-05-30-track-get-info-design.md b/docs/superpowers/specs/2026-05-30-track-get-info-design.md deleted file mode 100644 index 0d2ceaf..0000000 --- a/docs/superpowers/specs/2026-05-30-track-get-info-design.md +++ /dev/null @@ -1,206 +0,0 @@ -# Track "Get Info" — Design Spec - -**Date:** 2026-05-30 -**Status:** Approved (design); pending implementation plan -**Branch:** feat/music-streaming - -## Goal - -Replicate the macOS Music app's right-click **Get Info** experience: a dialog that -shows all of a track's metadata and lets the user edit it. Edits persist to the -app's library (SQLite DB) **and** are written back into the audio file's embedded -tags, mirroring how the real Music app mutates files. - -## Decisions (locked) - -| Question | Decision | -|---|---| -| Where edits are saved | **DB + write file tags** (best-effort file writeback) | -| Single vs multi-track | **Both** — single track shows all fields; multiple selected tracks use mixed-value handling | -| Field scope | **Existing model fields only** — no schema migration; read-only File info section | -| Writeback formats (v1) | **mp3** (ID3TagEditor) + **m4a / alac / aac** (AVFoundation). flac/wav/aiff → DB only with a UI note | -| Tag library strategy | **Lighter path, TagLib-ready** — abstract behind a `TagWriter` protocol so a TagLib writer can be added later for flac/wav/aiff with no rework | -| Layout | **Tabbed** (Details / File), like macOS Music | -| Failure model | **DB is always saved; file writeback is best-effort** with a non-blocking warning on failure | - -## Non-goals (v1) - -- No new metadata fields (no comments, lyrics, sorting, artwork). Editing is limited to - fields already present on the `Track` model. -- No album-artwork display or editing. -- No flac/wav/aiff **file-tag** writeback (those edits save to the DB only for now). - The architecture leaves a clean seam to add TagLib later. - -## Data model context - -`Track` (Music/Models/Track.swift) already holds every field we need. No migration. - -**Editable fields** (surfaced on the Details tab): -`title`, `artist`, `albumArtist`, `album`, `genre`, `composer` (String); -`year`, `trackNumber`, `discNumber`, `bpm` (Int?); `rating` (Int 0–5). - -> **rating is DB-only in v1.** It's an app/iTunes concept with format-specific -> scales (ID3 POPM 0–255, iTunes atom 0–100), so writing it to file tags is -> deferred. `EditableTrackFields` includes `rating`, but the `TagWriter`s ignore it -> — rating persists only via `updateTrack`. All other editable fields are written to -> both file and DB (where a writer exists). - -**Read-only fields** (File tab): `fileURL` (path), `fileFormat`, `bitrate`, -`sampleRate`, `fileSize`, `duration`, `playCount`, `lastPlayedAt`, `dateAdded`, -`dateModified`. (`playCount`/`lastPlayedAt`/`rating` are app-managed, not file tags.) - -> Metadata source today: tracks are read from file tags via AVFoundation **once at -> import** (ScannerService), then the DB is the source of truth. `insertBatch` uses -> `.ignore` on conflict, so re-scans do not clobber DB edits. - -## Architecture & components - -Each unit has one responsibility and a well-defined interface. - -### `EditableTrackFields` (new value type) -A plain struct of the ~11 editable fields. The unit of "what the user can change" -and "what changed." Decouples the sheet and the writer from the full `Track`. - -### `TagWriter` protocol + factory (new) -``` -protocol TagWriter { - func write(_ fields: EditableTrackFields, to fileURL: URL) throws -} -``` -- `TagWriterFactory.writer(for: URL) -> TagWriter?` selects by file extension; - returns `nil` for unsupported formats (flac/wav/aiff in v1). -- Implementations: - - `ID3TagWriter` — mp3, via the **ID3TagEditor** SPM package. - - `MP4TagWriter` — m4a / alac / aac, via **AVFoundation** `AVAssetExportSession` - (`AVAssetExportPresetPassthrough`) writing iTunes/common metadata items. -- **No code outside the writers knows how tags are encoded per format.** - -### `TrackEditService` (new) -Orchestrates one save. For each target track: -1. Diff `EditableTrackFields` against the original → set of changed fields. -2. If a `TagWriter` exists for the format: write tags to a **temp copy**, then - `FileManager.replaceItemAt` to atomically swap the original (never leaves a - half-written file). -3. Recompute `fileSize`, `dateModified`, `fileHash` from the new file. -4. Build the updated `Track` (changed fields + refreshed stats) and persist via - `DatabaseService.updateTrack`. -5. On file-write failure (or unsupported format): still persist DB changes; collect - a warning for that track. - -Owns the **single- and multi-track diff logic** as pure, testable functions -(no UI, no I/O in the diff step). - -### `TrackInfoSheet` (new SwiftUI view) -The Get Info dialog (see Layout). Holds local `@State` for the edited fields, -prefilled from the target(s). On Save, hands `EditableTrackFields` + the target -track set to `TrackEditService`. Models the `.sheet` pattern already used by -`SmartPlaylistBuilderSheet`. - -### `DatabaseService.updateTrack(_ track: Track) throws` (new method) -GRDB `track.update(db)` inside `dbPool.write`. **Implementation plan must verify -whether the `tracks_ft` FTS5 table is kept in sync automatically (triggers / -external-content) and, if not, update it here.** - -### Context-menu integration (edits) -- Add **Get Info** (⌘I) to `TrackContextMenuConfig`, `TrackTableView`'s `NSMenu` - (`menuNeedsUpdate`), and `TrackContextMenuModifier`. -- Target resolution: the menu operates on the **current selection if the - right-clicked row is part of it**, otherwise just the clicked row (matches macOS - Music). This requires the config to expose the current multi-selection (or a - callback that returns the target set), not just a single `Track`. -- `ContentView` holds the presented-target state and shows the `.sheet`. - -## Save sequence (per track) - -``` -edit fields ─▶ diff vs original ─▶ writer for format? - │ yes │ no / unsupported - ▼ ▼ - write tags to temp copy (skip file write, - ─▶ atomic replace original collect "DB-only" note) - │ - success? │ fail ─────────┐ - ▼ ▼ - recompute size/mod/hash keep old stats + warning - │ │ - └──────┬────────┘ - ▼ - updateTrack(...) in DB -``` - -Result: the **DB edit always lands**; file writeback is best-effort. Failures -surface as a single non-blocking summary alert ("Saved to library. Couldn't write -tags to N file(s): "). - -## Multi-track behavior - -- Prefill: fields with one shared value across all targets prefill normally; - fields that differ show a **"Mixed"** placeholder and start empty. -- Apply: **only fields the user actually edits** are applied — to all targets. - Untouched "Mixed" fields are left per-track unchanged. -- Saving N tracks runs in a background `Task`, sequentially, with a small progress - indicator when N is large. Per-track failures aggregate into one summary. - -## UI layout (tabbed) - -``` -┌─ Get Info ──────────────────────────────┐ -│ [ Details ] [ File ] │ -├──────────────────────────────────────────┤ -│ Title [_______________________] │ -│ Artist [_______________________] │ -│ Album Artist[_______________________] │ -│ Album [_______________________] │ -│ Genre [_______________________] │ -│ Composer [_______________________] │ -│ Year [____] Track [__]/[__] Disc [__]/[__] -│ BPM [____] Rating ★★★☆☆ │ -├──────────────────────────────────────────┤ -│ File tab: format, bitrate, sample rate, │ -│ size, duration, path, date added, │ -│ play count, last played — read-only │ -├──────────────────────────────────────────┤ -│ [ Cancel ] [ Save ] │ -└──────────────────────────────────────────┘ -``` - -- Numeric fields (year / track / disc / bpm) validate on input. -- flac/wav/aiff targets show a subtle note under the tabs: *"Edits save to your - library only — tag writing isn't supported for .flac yet."* -- Cancel = `.cancelAction`, Save = `.defaultAction`. - -## Error handling & risks - -- **Atomic replace** (temp file + `replaceItemAt`) prevents audio-file corruption on - a failed/interrupted write. -- **File-write failure** → DB still saved + non-blocking warning with the reason. -- **Sandbox / permissions (must verify early):** if the app is sandboxed, writing to - user audio files requires the appropriate entitlement and/or a security-scoped - bookmark for the music folder. If writing is blocked, file writeback cannot work - regardless of library — DB edits still work. Verify before building the writers. -- **New dependency:** ID3TagEditor added via SPM to the Xcode project. -- **Hash drift:** writing tags changes file size/mod-date → `fileHash`. Step 3 - refreshes these so the next scan doesn't treat the file as changed. - -## Testing (TDD) - -- **TagWriter round-trip:** write `EditableTrackFields` to bundled mp3 and m4a - fixtures, re-read via AVFoundation, assert each field; assert the file remains a - valid, playable asset after atomic replace. -- **Diff / multi-track logic:** pure-function, table-driven tests for "which fields - changed," "shared vs Mixed across N tracks," and "apply only edited fields to all." -- **Stat refresh:** `fileSize` / `dateModified` / `fileHash` recomputed after - writeback. -- **Factory:** correct writer per extension; `nil` for flac/wav/aiff. -- **`updateTrack`:** persists edited fields and keeps `tracks_ft` in sync. - -## Implementation outline - -1. Add ID3TagEditor SPM dependency; confirm sandbox/file-write permissions. -2. `EditableTrackFields` + diff/multi-track pure logic (+ tests). -3. `TagWriter` protocol, factory, `ID3TagWriter`, `MP4TagWriter` (+ round-trip tests). -4. `DatabaseService.updateTrack` (+ FTS sync) (+ test). -5. `TrackEditService` wiring the save sequence (+ tests). -6. `TrackInfoSheet` UI (tabs, validation, Mixed handling). -7. Context-menu "Get Info" (⌘I) + target resolution + `ContentView` sheet presentation. -8. Manual verification against real mp3/m4a/flac files.