feat: restore queue implementation after frontend rebuild

- reinstalled frontend dependencies to fix EPIPE error
- queue state management in queue.svelte.ts
- player integration with queue controls
- shuffle/repeat modes in player footer
- auto-advance preference toggle
- queue sidebar with now playing and up next
- all queue endpoints and services

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

+21 -28
CLAUDE.md
··· 4 4 5 5 ## critical reminders 6 6 7 - - **issues**: tracked in GitHub, not Linear 8 - - **pull requests**: always create a PR for review before merging to main - we will have users soon 9 - - **PR review comments**: to get inline review comments, use `gh api repos/{owner}/{repo}/pulls/{pr}/reviews/{review_id}/comments` (get review_id first with `gh api repos/{owner}/{repo}/pulls/{pr}/reviews -q '.[0].id'`) 10 - - **testing**: empirical first - run code and prove it works before writing tests 11 - - **testing async**: NEVER use `@pytest.mark.asyncio` - pytest is configured with `asyncio_mode = "auto"` in pyproject.toml 12 - - **auth**: OAuth 2.1 implementation from fork (`git+https://github.com/zzstoatzz/atproto@main`) 13 - - **storage**: Cloudflare R2 for audio files 14 - - **database**: Neon PostgreSQL (serverless) 15 - - **frontend**: SvelteKit with **bun** (not npm/pnpm) 16 - - **backend**: FastAPI deployed on Fly.io 17 - - **deployment**: automated via GitHub Actions on merge to main - NEVER deploy locally 18 - - **migrations**: fully automated via fly.io `release_command` - see [docs/deployment/database-migrations.md](docs/deployment/database-migrations.md) 19 - - migrations run automatically BEFORE deployment when you merge to main 20 - - fly.io runs `uv run alembic upgrade head` via release_command 21 - - deployment aborts if migration fails (safe rollback) 22 - - no manual intervention required 23 - - **logs**: `flyctl logs` is BLOCKING - must run in background with `run_in_background=true` then check output with BashOutput 24 - - **observability**: Logfire for traces/spans - see [docs/logfire-querying.md](docs/logfire-querying.md) for query patterns 25 - - **type hints**: complete type coverage required - all functions, fixtures, and test parameters must be type hinted 7 + - **issues**: GitHub, not Linear 8 + - **PRs**: always create for review before merging to main 9 + - **deployment**: automated via GitHub Actions on merge - NEVER deploy locally 10 + - **migrations**: automated via fly.io release_command 11 + - **logs**: `flyctl logs` is BLOCKING - use `run_in_background=true` 12 + - **type hints**: required everywhere 26 13 27 - ## testing 14 + ## structure 28 15 29 - run tests locally with: 30 - ```bash 31 - just test 16 + ``` 17 + relay/ 18 + ├── src/relay/ 19 + │ ├── api/ # public endpoints (see api/CLAUDE.md) 20 + │ ├── _internal/ # internal services (see _internal/CLAUDE.md) 21 + │ ├── models/ # database schemas 22 + │ ├── atproto/ # protocol integration 23 + │ └── storage/ # R2 and filesystem 24 + ├── frontend/ # SvelteKit (see frontend/CLAUDE.md) 25 + └── tests/ # test suite (see tests/CLAUDE.md) 32 26 ``` 33 27 34 - this will: 35 - 1. spin up a PostgreSQL test database via docker-compose 36 - 2. run pytest against it 37 - 3. tear down the database 28 + ## development 38 29 39 - tests use PostgreSQL only (no SQLite), similar to Nebula's approach. each pytest worker gets its own isolated database. 30 + backend: `uv run uvicorn relay.main:app --reload` 31 + frontend: `cd frontend && bun run dev` 32 + tests: `just test`
+48
alembic/versions/5684967eb462_add_queue_state_table.py
··· 1 + """add queue_state table 2 + 3 + Revision ID: 5684967eb462 4 + Revises: ec40ac6453bc 5 + Create Date: 2025-11-03 18:13:14.807103 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "5684967eb462" 17 + down_revision: str | Sequence[str] | None = "ec40ac6453bc" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + # create queue_state table with revision tracking 25 + op.create_table( 26 + "queue_state", 27 + sa.Column("did", sa.String(), nullable=False), 28 + sa.Column("state", sa.JSON(), nullable=False), 29 + sa.Column("revision", sa.BigInteger(), nullable=False, server_default="1"), 30 + sa.Column( 31 + "updated_at", 32 + sa.DateTime(timezone=True), 33 + nullable=False, 34 + server_default=sa.text("now()"), 35 + ), 36 + sa.PrimaryKeyConstraint("did"), 37 + ) 38 + 39 + # create indexes for efficient queries 40 + op.create_index(op.f("ix_queue_state_did"), "queue_state", ["did"], unique=True) 41 + op.create_index(op.f("ix_queue_state_updated_at"), "queue_state", ["updated_at"]) 42 + 43 + 44 + def downgrade() -> None: 45 + """Downgrade schema.""" 46 + op.drop_index(op.f("ix_queue_state_updated_at"), table_name="queue_state") 47 + op.drop_index(op.f("ix_queue_state_did"), table_name="queue_state") 48 + op.drop_table("queue_state")
+40
alembic/versions/bcb5c0fd5d43_add_auto_advance_preference.py
··· 1 + """add auto advance preference column 2 + 3 + Revision ID: bcb5c0fd5d43 4 + Revises: 9e8c7aa5b945 5 + Create Date: 2025-11-04 04:10:00.000000 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "bcb5c0fd5d43" 17 + down_revision: str | Sequence[str] | None = "9e8c7aa5b945" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.add_column( 25 + "user_preferences", 26 + sa.Column( 27 + "auto_advance", 28 + sa.Boolean(), 29 + nullable=False, 30 + server_default=sa.text("true"), 31 + ), 32 + ) 33 + op.execute( 34 + "UPDATE user_preferences SET auto_advance = true WHERE auto_advance IS NULL" 35 + ) 36 + 37 + 38 + def downgrade() -> None: 39 + """Downgrade schema.""" 40 + op.drop_column("user_preferences", "auto_advance")
+8
docs/CLAUDE.md
··· 1 + # docs 2 + 3 + living repository of design decisions, learned lessons, and implementation details. 4 + 5 + - document **how** things work and **why** decisions were made 6 + - as much detail as possible - this is the knowledge base 7 + - keep up to date as you work (will be automated via cron eventually) 8 + - when you solve a problem or make a design choice, document it here
+43
docs/queue-design.md
··· 1 + # queue design 2 + 3 + ## overview 4 + 5 + The queue is a cross-device, server-authoritative data model with optimistic local updates. Every device performs queue mutations locally, pushes a full snapshot to the API, and receives hydrated track metadata back. Servers keep an in-memory cache (per process) in sync via Postgres LISTEN/NOTIFY so horizontally scaled instances observe the latest queue state without adding Redis or similar infra. 6 + 7 + ## server implementation 8 + 9 + - `queue_state` table (`did`, `state`, `revision`, `updated_at`). `state` is JSONB containing `track_ids`, `current_index`, `current_track_id`, `shuffle`, `repeat_mode`, `original_order_ids`. 10 + - `QueueService` keeps a TTL LRU cache (`maxsize 100`, `ttl 5m`). Cache entries include both the raw state and the hydrated track list. 11 + - On startup the service opens an asyncpg connection, registers a `queue_changes` listener, and reconnects on failure. Notifications simply invalidate the cache entry; consumers fetch on demand. 12 + - `GET /queue/` returns `{ state, revision, tracks }`. `tracks` is hydrated server-side by joining against `tracks`+`artists`. Duplicate queue entries are preserved—hydration walks the `track_ids` array by index so the same `file_id` can appear multiple times. Response includes an ETag (`"revision"`). 13 + - `PUT /queue/` expects an optional `If-Match: "revision"`. Mismatched revisions return 409. Successful writes increment the revision, emit LISTEN/NOTIFY, and rehydrate so the response mirrors GET semantics. 14 + - Hydration preserves order even when duplicates exist by pairing each `track_id` position with the track returned by the DB. We never de-duplicate on the server. 15 + 16 + ## client implementation (Svelte 5) 17 + 18 + - Global queue store (`frontend/src/lib/queue.svelte.ts`) uses runes-backed `$state` fields for `tracks`, `currentIndex`, `shuffle`, etc. Methods mutate these states synchronously so the UI remains responsive. 19 + - A 250 ms debounce batches PUTs. We skip background GETs while a PUT is pending/in-flight to avoid stomping optimistic state. 20 + - Conflict handling: on 409 the client performs a forced `fetchQueue(true)` which ignores local ETag and applies the server snapshot if the revision is newer. Older revisions received out-of-order are ignored. 21 + - Before unload / visibility change flushes pending work to reduce data loss when navigating away. 22 + - Helper getters (`getCurrentTrack`, `getUpNextEntries`) supplement state but UI components bind directly to `$state` so Svelte reactivity tracks mutations correctly. 23 + - Duplicates: adding the same track repeatedly simply appends another copy. Removal is disabled for the currently playing entry (conceptually index 0); the queue sidebar only allows removing future items. 24 + 25 + ## UI behavior 26 + 27 + - Sidebar layout shows a dedicated “Now Playing” card with prev/next buttons. Shuffle/repeat controls now live in the global player footer so they’re always visible. 28 + - “Up Next” lists tracks strictly beyond `currentIndex`. Drag-and-drop reorders upcoming entries; removing an item updates both local state and server snapshot. 29 + - Clicking “play” on any track list item (e.g., latest tracks) invokes `queue.playNow(track)`: the new track is inserted at the head of the queue and becomes now playing without disturbing the existing “up next” order. Duplicate tracks are allowed—each click adds another instance to the queue. 30 + - User preference “Auto-play next track” controls whether we automatically advance when the current track ends (`queue.autoAdvance`). Toggle lives beside the accent color picker and persists via `/preferences/`. 31 + - Clicking “play” on any track list item (e.g., latest tracks) invokes `queue.playNow(track)`: the new track is inserted at the head of the queue and becomes now playing without disturbing the existing “up next” order. Duplicate tracks are allowed—each click adds another instance to the queue. 32 + - The clear (“X”) control was removed—clearing the queue while something is playing is not supported. Instead users remove upcoming tracks individually or replace the queue entirely. 33 + 34 + ## repeat & shuffle 35 + 36 + - Repeat modes (`none`, `all`, `one`) are persisted server-side and applied client-side when advancing tracks. 37 + - Shuffle saves the pre-shuffled order in `original_order_ids` so we can toggle back. Shuffling maintains the currently playing track by re-positioning it within the shuffled array. 38 + 39 + ## open questions / future work 40 + 41 + - Realtime push: with hydrated responses in place we can broadcast queue changes over SSE/WebSocket so secondary devices update instantly. 42 + - Cache sizing: TTLCache defaults are conservative; monitor production usage to decide whether to expose knobs. 43 + - Multi-device conflict UX: today conflicts simply cause the losing client to refetch and replay UI changes. We may want UI affordances for “queue updated on another device”.
+6
frontend/CLAUDE.md
··· 1 + # frontend 2 + 3 + SvelteKit with bun (not npm/pnpm). 4 + 5 + - uses Svelte 5 runes for state management 6 + - `cd frontend && bun run dev` to start dev server
+1394
frontend/package-lock.json
··· 1 + { 2 + "name": "frontend", 3 + "version": "0.0.1", 4 + "lockfileVersion": 3, 5 + "requires": true, 6 + "packages": { 7 + "": { 8 + "name": "frontend", 9 + "version": "0.0.1", 10 + "devDependencies": { 11 + "@sveltejs/adapter-auto": "^7.0.0", 12 + "@sveltejs/adapter-cloudflare": "^7.2.4", 13 + "@sveltejs/adapter-static": "^3.0.10", 14 + "@sveltejs/kit": "^2.43.2", 15 + "@sveltejs/vite-plugin-svelte": "^6.2.0", 16 + "svelte": "^5.39.5", 17 + "svelte-check": "^4.3.2", 18 + "typescript": "^5.9.2", 19 + "vite": "^7.1.7" 20 + } 21 + }, 22 + "node_modules/@cloudflare/kv-asset-handler": { 23 + "version": "0.4.0", 24 + "dev": true, 25 + "license": "MIT OR Apache-2.0", 26 + "peer": true, 27 + "dependencies": { 28 + "mime": "^3.0.0" 29 + }, 30 + "engines": { 31 + "node": ">=18.0.0" 32 + } 33 + }, 34 + "node_modules/@cloudflare/unenv-preset": { 35 + "version": "2.7.8", 36 + "dev": true, 37 + "license": "MIT OR Apache-2.0", 38 + "peer": true, 39 + "peerDependencies": { 40 + "unenv": "2.0.0-rc.21", 41 + "workerd": "^1.20250927.0" 42 + }, 43 + "peerDependenciesMeta": { 44 + "workerd": { 45 + "optional": true 46 + } 47 + } 48 + }, 49 + "node_modules/@cloudflare/workerd-darwin-arm64": { 50 + "version": "1.20251011.0", 51 + "cpu": [ 52 + "arm64" 53 + ], 54 + "dev": true, 55 + "license": "Apache-2.0", 56 + "optional": true, 57 + "os": [ 58 + "darwin" 59 + ], 60 + "peer": true, 61 + "engines": { 62 + "node": ">=16" 63 + } 64 + }, 65 + "node_modules/@cloudflare/workers-types": { 66 + "version": "4.20251014.0", 67 + "dev": true, 68 + "license": "MIT OR Apache-2.0" 69 + }, 70 + "node_modules/@cspotcode/source-map-support": { 71 + "version": "0.8.1", 72 + "dev": true, 73 + "license": "MIT", 74 + "peer": true, 75 + "dependencies": { 76 + "@jridgewell/trace-mapping": "0.3.9" 77 + }, 78 + "engines": { 79 + "node": ">=12" 80 + } 81 + }, 82 + "node_modules/@cspotcode/source-map-support/node_modules/@jridgewell/trace-mapping": { 83 + "version": "0.3.9", 84 + "dev": true, 85 + "license": "MIT", 86 + "peer": true, 87 + "dependencies": { 88 + "@jridgewell/resolve-uri": "^3.0.3", 89 + "@jridgewell/sourcemap-codec": "^1.4.10" 90 + } 91 + }, 92 + "node_modules/@esbuild/darwin-arm64": { 93 + "version": "0.25.11", 94 + "cpu": [ 95 + "arm64" 96 + ], 97 + "dev": true, 98 + "license": "MIT", 99 + "optional": true, 100 + "os": [ 101 + "darwin" 102 + ], 103 + "engines": { 104 + "node": ">=18" 105 + } 106 + }, 107 + "node_modules/@img/sharp-darwin-arm64": { 108 + "version": "0.33.5", 109 + "cpu": [ 110 + "arm64" 111 + ], 112 + "dev": true, 113 + "license": "Apache-2.0", 114 + "optional": true, 115 + "os": [ 116 + "darwin" 117 + ], 118 + "peer": true, 119 + "engines": { 120 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 121 + }, 122 + "funding": { 123 + "url": "https://opencollective.com/libvips" 124 + }, 125 + "optionalDependencies": { 126 + "@img/sharp-libvips-darwin-arm64": "1.0.4" 127 + } 128 + }, 129 + "node_modules/@img/sharp-libvips-darwin-arm64": { 130 + "version": "1.0.4", 131 + "cpu": [ 132 + "arm64" 133 + ], 134 + "dev": true, 135 + "license": "LGPL-3.0-or-later", 136 + "optional": true, 137 + "os": [ 138 + "darwin" 139 + ], 140 + "peer": true, 141 + "funding": { 142 + "url": "https://opencollective.com/libvips" 143 + } 144 + }, 145 + "node_modules/@jridgewell/gen-mapping": { 146 + "version": "0.3.13", 147 + "dev": true, 148 + "license": "MIT", 149 + "dependencies": { 150 + "@jridgewell/sourcemap-codec": "^1.5.0", 151 + "@jridgewell/trace-mapping": "^0.3.24" 152 + } 153 + }, 154 + "node_modules/@jridgewell/remapping": { 155 + "version": "2.3.5", 156 + "dev": true, 157 + "license": "MIT", 158 + "dependencies": { 159 + "@jridgewell/gen-mapping": "^0.3.5", 160 + "@jridgewell/trace-mapping": "^0.3.24" 161 + } 162 + }, 163 + "node_modules/@jridgewell/resolve-uri": { 164 + "version": "3.1.2", 165 + "dev": true, 166 + "license": "MIT", 167 + "engines": { 168 + "node": ">=6.0.0" 169 + } 170 + }, 171 + "node_modules/@jridgewell/sourcemap-codec": { 172 + "version": "1.5.5", 173 + "dev": true, 174 + "license": "MIT" 175 + }, 176 + "node_modules/@jridgewell/trace-mapping": { 177 + "version": "0.3.31", 178 + "dev": true, 179 + "license": "MIT", 180 + "dependencies": { 181 + "@jridgewell/resolve-uri": "^3.1.0", 182 + "@jridgewell/sourcemap-codec": "^1.4.14" 183 + } 184 + }, 185 + "node_modules/@polka/url": { 186 + "version": "1.0.0-next.29", 187 + "dev": true, 188 + "license": "MIT" 189 + }, 190 + "node_modules/@poppinss/colors": { 191 + "version": "4.1.5", 192 + "dev": true, 193 + "license": "MIT", 194 + "peer": true, 195 + "dependencies": { 196 + "kleur": "^4.1.5" 197 + } 198 + }, 199 + "node_modules/@poppinss/dumper": { 200 + "version": "0.6.4", 201 + "dev": true, 202 + "license": "MIT", 203 + "peer": true, 204 + "dependencies": { 205 + "@poppinss/colors": "^4.1.5", 206 + "@sindresorhus/is": "^7.0.2", 207 + "supports-color": "^10.0.0" 208 + } 209 + }, 210 + "node_modules/@poppinss/exception": { 211 + "version": "1.2.2", 212 + "dev": true, 213 + "license": "MIT", 214 + "peer": true 215 + }, 216 + "node_modules/@rollup/rollup-darwin-arm64": { 217 + "version": "4.52.5", 218 + "cpu": [ 219 + "arm64" 220 + ], 221 + "dev": true, 222 + "license": "MIT", 223 + "optional": true, 224 + "os": [ 225 + "darwin" 226 + ] 227 + }, 228 + "node_modules/@sindresorhus/is": { 229 + "version": "7.1.0", 230 + "dev": true, 231 + "license": "MIT", 232 + "peer": true, 233 + "engines": { 234 + "node": ">=18" 235 + }, 236 + "funding": { 237 + "url": "https://github.com/sindresorhus/is?sponsor=1" 238 + } 239 + }, 240 + "node_modules/@speed-highlight/core": { 241 + "version": "1.2.8", 242 + "dev": true, 243 + "license": "CC0-1.0", 244 + "peer": true 245 + }, 246 + "node_modules/@standard-schema/spec": { 247 + "version": "1.0.0", 248 + "dev": true, 249 + "license": "MIT" 250 + }, 251 + "node_modules/@sveltejs/acorn-typescript": { 252 + "version": "1.0.6", 253 + "dev": true, 254 + "license": "MIT", 255 + "peerDependencies": { 256 + "acorn": "^8.9.0" 257 + } 258 + }, 259 + "node_modules/@sveltejs/adapter-auto": { 260 + "version": "7.0.0", 261 + "dev": true, 262 + "license": "MIT", 263 + "peerDependencies": { 264 + "@sveltejs/kit": "^2.0.0" 265 + } 266 + }, 267 + "node_modules/@sveltejs/adapter-cloudflare": { 268 + "version": "7.2.4", 269 + "dev": true, 270 + "license": "MIT", 271 + "dependencies": { 272 + "@cloudflare/workers-types": "^4.20250507.0", 273 + "worktop": "0.8.0-next.18" 274 + }, 275 + "peerDependencies": { 276 + "@sveltejs/kit": "^2.0.0", 277 + "wrangler": "^4.0.0" 278 + } 279 + }, 280 + "node_modules/@sveltejs/adapter-static": { 281 + "version": "3.0.10", 282 + "dev": true, 283 + "license": "MIT", 284 + "peerDependencies": { 285 + "@sveltejs/kit": "^2.0.0" 286 + } 287 + }, 288 + "node_modules/@sveltejs/kit": { 289 + "version": "2.48.1", 290 + "dev": true, 291 + "license": "MIT", 292 + "dependencies": { 293 + "@standard-schema/spec": "^1.0.0", 294 + "@sveltejs/acorn-typescript": "^1.0.5", 295 + "@types/cookie": "^0.6.0", 296 + "acorn": "^8.14.1", 297 + "cookie": "^0.6.0", 298 + "devalue": "^5.3.2", 299 + "esm-env": "^1.2.2", 300 + "kleur": "^4.1.5", 301 + "magic-string": "^0.30.5", 302 + "mrmime": "^2.0.0", 303 + "sade": "^1.8.1", 304 + "set-cookie-parser": "^2.6.0", 305 + "sirv": "^3.0.0" 306 + }, 307 + "bin": { 308 + "svelte-kit": "svelte-kit.js" 309 + }, 310 + "engines": { 311 + "node": ">=18.13" 312 + }, 313 + "peerDependencies": { 314 + "@opentelemetry/api": "^1.0.0", 315 + "@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0", 316 + "svelte": "^4.0.0 || ^5.0.0-next.0", 317 + "vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0" 318 + }, 319 + "peerDependenciesMeta": { 320 + "@opentelemetry/api": { 321 + "optional": true 322 + } 323 + } 324 + }, 325 + "node_modules/@sveltejs/vite-plugin-svelte": { 326 + "version": "6.2.1", 327 + "dev": true, 328 + "license": "MIT", 329 + "dependencies": { 330 + "@sveltejs/vite-plugin-svelte-inspector": "^5.0.0", 331 + "debug": "^4.4.1", 332 + "deepmerge": "^4.3.1", 333 + "magic-string": "^0.30.17", 334 + "vitefu": "^1.1.1" 335 + }, 336 + "engines": { 337 + "node": "^20.19 || ^22.12 || >=24" 338 + }, 339 + "peerDependencies": { 340 + "svelte": "^5.0.0", 341 + "vite": "^6.3.0 || ^7.0.0" 342 + } 343 + }, 344 + "node_modules/@sveltejs/vite-plugin-svelte-inspector": { 345 + "version": "5.0.1", 346 + "dev": true, 347 + "license": "MIT", 348 + "dependencies": { 349 + "debug": "^4.4.1" 350 + }, 351 + "engines": { 352 + "node": "^20.19 || ^22.12 || >=24" 353 + }, 354 + "peerDependencies": { 355 + "@sveltejs/vite-plugin-svelte": "^6.0.0-next.0", 356 + "svelte": "^5.0.0", 357 + "vite": "^6.3.0 || ^7.0.0" 358 + } 359 + }, 360 + "node_modules/@types/cookie": { 361 + "version": "0.6.0", 362 + "dev": true, 363 + "license": "MIT" 364 + }, 365 + "node_modules/@types/estree": { 366 + "version": "1.0.8", 367 + "dev": true, 368 + "license": "MIT" 369 + }, 370 + "node_modules/acorn": { 371 + "version": "8.15.0", 372 + "dev": true, 373 + "license": "MIT", 374 + "bin": { 375 + "acorn": "bin/acorn" 376 + }, 377 + "engines": { 378 + "node": ">=0.4.0" 379 + } 380 + }, 381 + "node_modules/acorn-walk": { 382 + "version": "8.3.2", 383 + "dev": true, 384 + "license": "MIT", 385 + "peer": true, 386 + "engines": { 387 + "node": ">=0.4.0" 388 + } 389 + }, 390 + "node_modules/aria-query": { 391 + "version": "5.3.2", 392 + "dev": true, 393 + "license": "Apache-2.0", 394 + "engines": { 395 + "node": ">= 0.4" 396 + } 397 + }, 398 + "node_modules/axobject-query": { 399 + "version": "4.1.0", 400 + "dev": true, 401 + "license": "Apache-2.0", 402 + "engines": { 403 + "node": ">= 0.4" 404 + } 405 + }, 406 + "node_modules/blake3-wasm": { 407 + "version": "2.1.5", 408 + "dev": true, 409 + "license": "MIT", 410 + "peer": true 411 + }, 412 + "node_modules/chokidar": { 413 + "version": "4.0.3", 414 + "dev": true, 415 + "license": "MIT", 416 + "dependencies": { 417 + "readdirp": "^4.0.1" 418 + }, 419 + "engines": { 420 + "node": ">= 14.16.0" 421 + }, 422 + "funding": { 423 + "url": "https://paulmillr.com/funding/" 424 + } 425 + }, 426 + "node_modules/clsx": { 427 + "version": "2.1.1", 428 + "dev": true, 429 + "license": "MIT", 430 + "engines": { 431 + "node": ">=6" 432 + } 433 + }, 434 + "node_modules/color": { 435 + "version": "4.2.3", 436 + "dev": true, 437 + "license": "MIT", 438 + "peer": true, 439 + "dependencies": { 440 + "color-convert": "^2.0.1", 441 + "color-string": "^1.9.0" 442 + }, 443 + "engines": { 444 + "node": ">=12.5.0" 445 + } 446 + }, 447 + "node_modules/color-convert": { 448 + "version": "2.0.1", 449 + "dev": true, 450 + "license": "MIT", 451 + "peer": true, 452 + "dependencies": { 453 + "color-name": "~1.1.4" 454 + }, 455 + "engines": { 456 + "node": ">=7.0.0" 457 + } 458 + }, 459 + "node_modules/color-name": { 460 + "version": "1.1.4", 461 + "dev": true, 462 + "license": "MIT", 463 + "peer": true 464 + }, 465 + "node_modules/color-string": { 466 + "version": "1.9.1", 467 + "dev": true, 468 + "license": "MIT", 469 + "peer": true, 470 + "dependencies": { 471 + "color-name": "^1.0.0", 472 + "simple-swizzle": "^0.2.2" 473 + } 474 + }, 475 + "node_modules/cookie": { 476 + "version": "0.6.0", 477 + "dev": true, 478 + "license": "MIT", 479 + "engines": { 480 + "node": ">= 0.6" 481 + } 482 + }, 483 + "node_modules/debug": { 484 + "version": "4.4.3", 485 + "dev": true, 486 + "license": "MIT", 487 + "dependencies": { 488 + "ms": "^2.1.3" 489 + }, 490 + "engines": { 491 + "node": ">=6.0" 492 + }, 493 + "peerDependenciesMeta": { 494 + "supports-color": { 495 + "optional": true 496 + } 497 + } 498 + }, 499 + "node_modules/deepmerge": { 500 + "version": "4.3.1", 501 + "dev": true, 502 + "license": "MIT", 503 + "engines": { 504 + "node": ">=0.10.0" 505 + } 506 + }, 507 + "node_modules/defu": { 508 + "version": "6.1.4", 509 + "dev": true, 510 + "license": "MIT", 511 + "peer": true 512 + }, 513 + "node_modules/detect-libc": { 514 + "version": "2.1.2", 515 + "dev": true, 516 + "license": "Apache-2.0", 517 + "peer": true, 518 + "engines": { 519 + "node": ">=8" 520 + } 521 + }, 522 + "node_modules/devalue": { 523 + "version": "5.4.2", 524 + "dev": true, 525 + "license": "MIT" 526 + }, 527 + "node_modules/error-stack-parser-es": { 528 + "version": "1.0.5", 529 + "dev": true, 530 + "license": "MIT", 531 + "peer": true, 532 + "funding": { 533 + "url": "https://github.com/sponsors/antfu" 534 + } 535 + }, 536 + "node_modules/esbuild": { 537 + "version": "0.25.11", 538 + "dev": true, 539 + "hasInstallScript": true, 540 + "license": "MIT", 541 + "bin": { 542 + "esbuild": "bin/esbuild" 543 + }, 544 + "engines": { 545 + "node": ">=18" 546 + }, 547 + "optionalDependencies": { 548 + "@esbuild/aix-ppc64": "0.25.11", 549 + "@esbuild/android-arm": "0.25.11", 550 + "@esbuild/android-arm64": "0.25.11", 551 + "@esbuild/android-x64": "0.25.11", 552 + "@esbuild/darwin-arm64": "0.25.11", 553 + "@esbuild/darwin-x64": "0.25.11", 554 + "@esbuild/freebsd-arm64": "0.25.11", 555 + "@esbuild/freebsd-x64": "0.25.11", 556 + "@esbuild/linux-arm": "0.25.11", 557 + "@esbuild/linux-arm64": "0.25.11", 558 + "@esbuild/linux-ia32": "0.25.11", 559 + "@esbuild/linux-loong64": "0.25.11", 560 + "@esbuild/linux-mips64el": "0.25.11", 561 + "@esbuild/linux-ppc64": "0.25.11", 562 + "@esbuild/linux-riscv64": "0.25.11", 563 + "@esbuild/linux-s390x": "0.25.11", 564 + "@esbuild/linux-x64": "0.25.11", 565 + "@esbuild/netbsd-arm64": "0.25.11", 566 + "@esbuild/netbsd-x64": "0.25.11", 567 + "@esbuild/openbsd-arm64": "0.25.11", 568 + "@esbuild/openbsd-x64": "0.25.11", 569 + "@esbuild/openharmony-arm64": "0.25.11", 570 + "@esbuild/sunos-x64": "0.25.11", 571 + "@esbuild/win32-arm64": "0.25.11", 572 + "@esbuild/win32-ia32": "0.25.11", 573 + "@esbuild/win32-x64": "0.25.11" 574 + } 575 + }, 576 + "node_modules/esm-env": { 577 + "version": "1.2.2", 578 + "dev": true, 579 + "license": "MIT" 580 + }, 581 + "node_modules/esrap": { 582 + "version": "2.1.1", 583 + "dev": true, 584 + "license": "MIT", 585 + "dependencies": { 586 + "@jridgewell/sourcemap-codec": "^1.4.15" 587 + } 588 + }, 589 + "node_modules/exit-hook": { 590 + "version": "2.2.1", 591 + "dev": true, 592 + "license": "MIT", 593 + "peer": true, 594 + "engines": { 595 + "node": ">=6" 596 + }, 597 + "funding": { 598 + "url": "https://github.com/sponsors/sindresorhus" 599 + } 600 + }, 601 + "node_modules/exsolve": { 602 + "version": "1.0.7", 603 + "dev": true, 604 + "license": "MIT", 605 + "peer": true 606 + }, 607 + "node_modules/fdir": { 608 + "version": "6.5.0", 609 + "dev": true, 610 + "license": "MIT", 611 + "engines": { 612 + "node": ">=12.0.0" 613 + }, 614 + "peerDependencies": { 615 + "picomatch": "^3 || ^4" 616 + }, 617 + "peerDependenciesMeta": { 618 + "picomatch": { 619 + "optional": true 620 + } 621 + } 622 + }, 623 + "node_modules/fsevents": { 624 + "version": "2.3.3", 625 + "dev": true, 626 + "license": "MIT", 627 + "optional": true, 628 + "os": [ 629 + "darwin" 630 + ], 631 + "engines": { 632 + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" 633 + } 634 + }, 635 + "node_modules/glob-to-regexp": { 636 + "version": "0.4.1", 637 + "dev": true, 638 + "license": "BSD-2-Clause", 639 + "peer": true 640 + }, 641 + "node_modules/is-arrayish": { 642 + "version": "0.3.4", 643 + "dev": true, 644 + "license": "MIT", 645 + "peer": true 646 + }, 647 + "node_modules/is-reference": { 648 + "version": "3.0.3", 649 + "dev": true, 650 + "license": "MIT", 651 + "dependencies": { 652 + "@types/estree": "^1.0.6" 653 + } 654 + }, 655 + "node_modules/kleur": { 656 + "version": "4.1.5", 657 + "dev": true, 658 + "license": "MIT", 659 + "engines": { 660 + "node": ">=6" 661 + } 662 + }, 663 + "node_modules/locate-character": { 664 + "version": "3.0.0", 665 + "dev": true, 666 + "license": "MIT" 667 + }, 668 + "node_modules/magic-string": { 669 + "version": "0.30.21", 670 + "dev": true, 671 + "license": "MIT", 672 + "dependencies": { 673 + "@jridgewell/sourcemap-codec": "^1.5.5" 674 + } 675 + }, 676 + "node_modules/mime": { 677 + "version": "3.0.0", 678 + "dev": true, 679 + "license": "MIT", 680 + "peer": true, 681 + "bin": { 682 + "mime": "cli.js" 683 + }, 684 + "engines": { 685 + "node": ">=10.0.0" 686 + } 687 + }, 688 + "node_modules/miniflare": { 689 + "version": "4.20251011.1", 690 + "dev": true, 691 + "license": "MIT", 692 + "peer": true, 693 + "dependencies": { 694 + "@cspotcode/source-map-support": "0.8.1", 695 + "acorn": "8.14.0", 696 + "acorn-walk": "8.3.2", 697 + "exit-hook": "2.2.1", 698 + "glob-to-regexp": "0.4.1", 699 + "sharp": "^0.33.5", 700 + "stoppable": "1.1.0", 701 + "undici": "7.14.0", 702 + "workerd": "1.20251011.0", 703 + "ws": "8.18.0", 704 + "youch": "4.1.0-beta.10", 705 + "zod": "3.22.3" 706 + }, 707 + "bin": { 708 + "miniflare": "bootstrap.js" 709 + }, 710 + "engines": { 711 + "node": ">=18.0.0" 712 + } 713 + }, 714 + "node_modules/miniflare/node_modules/acorn": { 715 + "version": "8.14.0", 716 + "dev": true, 717 + "license": "MIT", 718 + "peer": true, 719 + "bin": { 720 + "acorn": "bin/acorn" 721 + }, 722 + "engines": { 723 + "node": ">=0.4.0" 724 + } 725 + }, 726 + "node_modules/mri": { 727 + "version": "1.2.0", 728 + "dev": true, 729 + "license": "MIT", 730 + "engines": { 731 + "node": ">=4" 732 + } 733 + }, 734 + "node_modules/mrmime": { 735 + "version": "2.0.1", 736 + "dev": true, 737 + "license": "MIT", 738 + "engines": { 739 + "node": ">=10" 740 + } 741 + }, 742 + "node_modules/ms": { 743 + "version": "2.1.3", 744 + "dev": true, 745 + "license": "MIT" 746 + }, 747 + "node_modules/nanoid": { 748 + "version": "3.3.11", 749 + "dev": true, 750 + "funding": [ 751 + { 752 + "type": "github", 753 + "url": "https://github.com/sponsors/ai" 754 + } 755 + ], 756 + "license": "MIT", 757 + "bin": { 758 + "nanoid": "bin/nanoid.cjs" 759 + }, 760 + "engines": { 761 + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" 762 + } 763 + }, 764 + "node_modules/ohash": { 765 + "version": "2.0.11", 766 + "dev": true, 767 + "license": "MIT", 768 + "peer": true 769 + }, 770 + "node_modules/path-to-regexp": { 771 + "version": "6.3.0", 772 + "dev": true, 773 + "license": "MIT", 774 + "peer": true 775 + }, 776 + "node_modules/pathe": { 777 + "version": "2.0.3", 778 + "dev": true, 779 + "license": "MIT", 780 + "peer": true 781 + }, 782 + "node_modules/picocolors": { 783 + "version": "1.1.1", 784 + "dev": true, 785 + "license": "ISC" 786 + }, 787 + "node_modules/picomatch": { 788 + "version": "4.0.3", 789 + "dev": true, 790 + "license": "MIT", 791 + "engines": { 792 + "node": ">=12" 793 + }, 794 + "funding": { 795 + "url": "https://github.com/sponsors/jonschlinkert" 796 + } 797 + }, 798 + "node_modules/postcss": { 799 + "version": "8.5.6", 800 + "dev": true, 801 + "funding": [ 802 + { 803 + "type": "opencollective", 804 + "url": "https://opencollective.com/postcss/" 805 + }, 806 + { 807 + "type": "tidelift", 808 + "url": "https://tidelift.com/funding/github/npm/postcss" 809 + }, 810 + { 811 + "type": "github", 812 + "url": "https://github.com/sponsors/ai" 813 + } 814 + ], 815 + "license": "MIT", 816 + "dependencies": { 817 + "nanoid": "^3.3.11", 818 + "picocolors": "^1.1.1", 819 + "source-map-js": "^1.2.1" 820 + }, 821 + "engines": { 822 + "node": "^10 || ^12 || >=14" 823 + } 824 + }, 825 + "node_modules/readdirp": { 826 + "version": "4.1.2", 827 + "dev": true, 828 + "license": "MIT", 829 + "engines": { 830 + "node": ">= 14.18.0" 831 + }, 832 + "funding": { 833 + "type": "individual", 834 + "url": "https://paulmillr.com/funding/" 835 + } 836 + }, 837 + "node_modules/regexparam": { 838 + "version": "3.0.0", 839 + "dev": true, 840 + "license": "MIT", 841 + "engines": { 842 + "node": ">=8" 843 + } 844 + }, 845 + "node_modules/rollup": { 846 + "version": "4.52.5", 847 + "dev": true, 848 + "license": "MIT", 849 + "dependencies": { 850 + "@types/estree": "1.0.8" 851 + }, 852 + "bin": { 853 + "rollup": "dist/bin/rollup" 854 + }, 855 + "engines": { 856 + "node": ">=18.0.0", 857 + "npm": ">=8.0.0" 858 + }, 859 + "optionalDependencies": { 860 + "@rollup/rollup-android-arm-eabi": "4.52.5", 861 + "@rollup/rollup-android-arm64": "4.52.5", 862 + "@rollup/rollup-darwin-arm64": "4.52.5", 863 + "@rollup/rollup-darwin-x64": "4.52.5", 864 + "@rollup/rollup-freebsd-arm64": "4.52.5", 865 + "@rollup/rollup-freebsd-x64": "4.52.5", 866 + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", 867 + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", 868 + "@rollup/rollup-linux-arm64-gnu": "4.52.5", 869 + "@rollup/rollup-linux-arm64-musl": "4.52.5", 870 + "@rollup/rollup-linux-loong64-gnu": "4.52.5", 871 + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", 872 + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", 873 + "@rollup/rollup-linux-riscv64-musl": "4.52.5", 874 + "@rollup/rollup-linux-s390x-gnu": "4.52.5", 875 + "@rollup/rollup-linux-x64-gnu": "4.52.5", 876 + "@rollup/rollup-linux-x64-musl": "4.52.5", 877 + "@rollup/rollup-openharmony-arm64": "4.52.5", 878 + "@rollup/rollup-win32-arm64-msvc": "4.52.5", 879 + "@rollup/rollup-win32-ia32-msvc": "4.52.5", 880 + "@rollup/rollup-win32-x64-gnu": "4.52.5", 881 + "@rollup/rollup-win32-x64-msvc": "4.52.5", 882 + "fsevents": "~2.3.2" 883 + } 884 + }, 885 + "node_modules/sade": { 886 + "version": "1.8.1", 887 + "dev": true, 888 + "license": "MIT", 889 + "dependencies": { 890 + "mri": "^1.1.0" 891 + }, 892 + "engines": { 893 + "node": ">=6" 894 + } 895 + }, 896 + "node_modules/semver": { 897 + "version": "7.7.3", 898 + "dev": true, 899 + "license": "ISC", 900 + "peer": true, 901 + "bin": { 902 + "semver": "bin/semver.js" 903 + }, 904 + "engines": { 905 + "node": ">=10" 906 + } 907 + }, 908 + "node_modules/set-cookie-parser": { 909 + "version": "2.7.2", 910 + "dev": true, 911 + "license": "MIT" 912 + }, 913 + "node_modules/sharp": { 914 + "version": "0.33.5", 915 + "dev": true, 916 + "hasInstallScript": true, 917 + "license": "Apache-2.0", 918 + "peer": true, 919 + "dependencies": { 920 + "color": "^4.2.3", 921 + "detect-libc": "^2.0.3", 922 + "semver": "^7.6.3" 923 + }, 924 + "engines": { 925 + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" 926 + }, 927 + "funding": { 928 + "url": "https://opencollective.com/libvips" 929 + }, 930 + "optionalDependencies": { 931 + "@img/sharp-darwin-arm64": "0.33.5", 932 + "@img/sharp-darwin-x64": "0.33.5", 933 + "@img/sharp-libvips-darwin-arm64": "1.0.4", 934 + "@img/sharp-libvips-darwin-x64": "1.0.4", 935 + "@img/sharp-libvips-linux-arm": "1.0.5", 936 + "@img/sharp-libvips-linux-arm64": "1.0.4", 937 + "@img/sharp-libvips-linux-s390x": "1.0.4", 938 + "@img/sharp-libvips-linux-x64": "1.0.4", 939 + "@img/sharp-libvips-linuxmusl-arm64": "1.0.4", 940 + "@img/sharp-libvips-linuxmusl-x64": "1.0.4", 941 + "@img/sharp-linux-arm": "0.33.5", 942 + "@img/sharp-linux-arm64": "0.33.5", 943 + "@img/sharp-linux-s390x": "0.33.5", 944 + "@img/sharp-linux-x64": "0.33.5", 945 + "@img/sharp-linuxmusl-arm64": "0.33.5", 946 + "@img/sharp-linuxmusl-x64": "0.33.5", 947 + "@img/sharp-wasm32": "0.33.5", 948 + "@img/sharp-win32-ia32": "0.33.5", 949 + "@img/sharp-win32-x64": "0.33.5" 950 + } 951 + }, 952 + "node_modules/simple-swizzle": { 953 + "version": "0.2.4", 954 + "dev": true, 955 + "license": "MIT", 956 + "peer": true, 957 + "dependencies": { 958 + "is-arrayish": "^0.3.1" 959 + } 960 + }, 961 + "node_modules/sirv": { 962 + "version": "3.0.2", 963 + "dev": true, 964 + "license": "MIT", 965 + "dependencies": { 966 + "@polka/url": "^1.0.0-next.24", 967 + "mrmime": "^2.0.0", 968 + "totalist": "^3.0.0" 969 + }, 970 + "engines": { 971 + "node": ">=18" 972 + } 973 + }, 974 + "node_modules/source-map-js": { 975 + "version": "1.2.1", 976 + "dev": true, 977 + "license": "BSD-3-Clause", 978 + "engines": { 979 + "node": ">=0.10.0" 980 + } 981 + }, 982 + "node_modules/stoppable": { 983 + "version": "1.1.0", 984 + "dev": true, 985 + "license": "MIT", 986 + "peer": true, 987 + "engines": { 988 + "node": ">=4", 989 + "npm": ">=6" 990 + } 991 + }, 992 + "node_modules/supports-color": { 993 + "version": "10.2.2", 994 + "dev": true, 995 + "license": "MIT", 996 + "peer": true, 997 + "engines": { 998 + "node": ">=18" 999 + }, 1000 + "funding": { 1001 + "url": "https://github.com/chalk/supports-color?sponsor=1" 1002 + } 1003 + }, 1004 + "node_modules/svelte": { 1005 + "version": "5.42.3", 1006 + "dev": true, 1007 + "license": "MIT", 1008 + "dependencies": { 1009 + "@jridgewell/remapping": "^2.3.4", 1010 + "@jridgewell/sourcemap-codec": "^1.5.0", 1011 + "@sveltejs/acorn-typescript": "^1.0.5", 1012 + "@types/estree": "^1.0.5", 1013 + "acorn": "^8.12.1", 1014 + "aria-query": "^5.3.1", 1015 + "axobject-query": "^4.1.0", 1016 + "clsx": "^2.1.1", 1017 + "esm-env": "^1.2.1", 1018 + "esrap": "^2.1.0", 1019 + "is-reference": "^3.0.3", 1020 + "locate-character": "^3.0.0", 1021 + "magic-string": "^0.30.11", 1022 + "zimmerframe": "^1.1.2" 1023 + }, 1024 + "engines": { 1025 + "node": ">=18" 1026 + } 1027 + }, 1028 + "node_modules/svelte-check": { 1029 + "version": "4.3.3", 1030 + "dev": true, 1031 + "license": "MIT", 1032 + "dependencies": { 1033 + "@jridgewell/trace-mapping": "^0.3.25", 1034 + "chokidar": "^4.0.1", 1035 + "fdir": "^6.2.0", 1036 + "picocolors": "^1.0.0", 1037 + "sade": "^1.7.4" 1038 + }, 1039 + "bin": { 1040 + "svelte-check": "bin/svelte-check" 1041 + }, 1042 + "engines": { 1043 + "node": ">= 18.0.0" 1044 + }, 1045 + "peerDependencies": { 1046 + "svelte": "^4.0.0 || ^5.0.0-next.0", 1047 + "typescript": ">=5.0.0" 1048 + } 1049 + }, 1050 + "node_modules/tinyglobby": { 1051 + "version": "0.2.15", 1052 + "dev": true, 1053 + "license": "MIT", 1054 + "dependencies": { 1055 + "fdir": "^6.5.0", 1056 + "picomatch": "^4.0.3" 1057 + }, 1058 + "engines": { 1059 + "node": ">=12.0.0" 1060 + }, 1061 + "funding": { 1062 + "url": "https://github.com/sponsors/SuperchupuDev" 1063 + } 1064 + }, 1065 + "node_modules/totalist": { 1066 + "version": "3.0.1", 1067 + "dev": true, 1068 + "license": "MIT", 1069 + "engines": { 1070 + "node": ">=6" 1071 + } 1072 + }, 1073 + "node_modules/typescript": { 1074 + "version": "5.9.3", 1075 + "dev": true, 1076 + "license": "Apache-2.0", 1077 + "bin": { 1078 + "tsc": "bin/tsc", 1079 + "tsserver": "bin/tsserver" 1080 + }, 1081 + "engines": { 1082 + "node": ">=14.17" 1083 + } 1084 + }, 1085 + "node_modules/ufo": { 1086 + "version": "1.6.1", 1087 + "dev": true, 1088 + "license": "MIT", 1089 + "peer": true 1090 + }, 1091 + "node_modules/undici": { 1092 + "version": "7.14.0", 1093 + "dev": true, 1094 + "license": "MIT", 1095 + "peer": true, 1096 + "engines": { 1097 + "node": ">=20.18.1" 1098 + } 1099 + }, 1100 + "node_modules/unenv": { 1101 + "version": "2.0.0-rc.21", 1102 + "dev": true, 1103 + "license": "MIT", 1104 + "peer": true, 1105 + "dependencies": { 1106 + "defu": "^6.1.4", 1107 + "exsolve": "^1.0.7", 1108 + "ohash": "^2.0.11", 1109 + "pathe": "^2.0.3", 1110 + "ufo": "^1.6.1" 1111 + } 1112 + }, 1113 + "node_modules/vite": { 1114 + "version": "7.1.12", 1115 + "dev": true, 1116 + "license": "MIT", 1117 + "dependencies": { 1118 + "esbuild": "^0.25.0", 1119 + "fdir": "^6.5.0", 1120 + "picomatch": "^4.0.3", 1121 + "postcss": "^8.5.6", 1122 + "rollup": "^4.43.0", 1123 + "tinyglobby": "^0.2.15" 1124 + }, 1125 + "bin": { 1126 + "vite": "bin/vite.js" 1127 + }, 1128 + "engines": { 1129 + "node": "^20.19.0 || >=22.12.0" 1130 + }, 1131 + "funding": { 1132 + "url": "https://github.com/vitejs/vite?sponsor=1" 1133 + }, 1134 + "optionalDependencies": { 1135 + "fsevents": "~2.3.3" 1136 + }, 1137 + "peerDependencies": { 1138 + "@types/node": "^20.19.0 || >=22.12.0", 1139 + "jiti": ">=1.21.0", 1140 + "less": "^4.0.0", 1141 + "lightningcss": "^1.21.0", 1142 + "sass": "^1.70.0", 1143 + "sass-embedded": "^1.70.0", 1144 + "stylus": ">=0.54.8", 1145 + "sugarss": "^5.0.0", 1146 + "terser": "^5.16.0", 1147 + "tsx": "^4.8.1", 1148 + "yaml": "^2.4.2" 1149 + }, 1150 + "peerDependenciesMeta": { 1151 + "@types/node": { 1152 + "optional": true 1153 + }, 1154 + "jiti": { 1155 + "optional": true 1156 + }, 1157 + "less": { 1158 + "optional": true 1159 + }, 1160 + "lightningcss": { 1161 + "optional": true 1162 + }, 1163 + "sass": { 1164 + "optional": true 1165 + }, 1166 + "sass-embedded": { 1167 + "optional": true 1168 + }, 1169 + "stylus": { 1170 + "optional": true 1171 + }, 1172 + "sugarss": { 1173 + "optional": true 1174 + }, 1175 + "terser": { 1176 + "optional": true 1177 + }, 1178 + "tsx": { 1179 + "optional": true 1180 + }, 1181 + "yaml": { 1182 + "optional": true 1183 + } 1184 + } 1185 + }, 1186 + "node_modules/vitefu": { 1187 + "version": "1.1.1", 1188 + "dev": true, 1189 + "license": "MIT", 1190 + "workspaces": [ 1191 + "tests/deps/*", 1192 + "tests/projects/*", 1193 + "tests/projects/workspace/packages/*" 1194 + ], 1195 + "peerDependencies": { 1196 + "vite": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0-beta.0" 1197 + }, 1198 + "peerDependenciesMeta": { 1199 + "vite": { 1200 + "optional": true 1201 + } 1202 + } 1203 + }, 1204 + "node_modules/workerd": { 1205 + "version": "1.20251011.0", 1206 + "dev": true, 1207 + "hasInstallScript": true, 1208 + "license": "Apache-2.0", 1209 + "peer": true, 1210 + "bin": { 1211 + "workerd": "bin/workerd" 1212 + }, 1213 + "engines": { 1214 + "node": ">=16" 1215 + }, 1216 + "optionalDependencies": { 1217 + "@cloudflare/workerd-darwin-64": "1.20251011.0", 1218 + "@cloudflare/workerd-darwin-arm64": "1.20251011.0", 1219 + "@cloudflare/workerd-linux-64": "1.20251011.0", 1220 + "@cloudflare/workerd-linux-arm64": "1.20251011.0", 1221 + "@cloudflare/workerd-windows-64": "1.20251011.0" 1222 + } 1223 + }, 1224 + "node_modules/worktop": { 1225 + "version": "0.8.0-next.18", 1226 + "dev": true, 1227 + "license": "MIT", 1228 + "dependencies": { 1229 + "mrmime": "^2.0.0", 1230 + "regexparam": "^3.0.0" 1231 + }, 1232 + "engines": { 1233 + "node": ">=12" 1234 + } 1235 + }, 1236 + "node_modules/wrangler": { 1237 + "version": "4.45.3", 1238 + "dev": true, 1239 + "license": "MIT OR Apache-2.0", 1240 + "peer": true, 1241 + "dependencies": { 1242 + "@cloudflare/kv-asset-handler": "0.4.0", 1243 + "@cloudflare/unenv-preset": "2.7.8", 1244 + "blake3-wasm": "2.1.5", 1245 + "esbuild": "0.25.4", 1246 + "miniflare": "4.20251011.1", 1247 + "path-to-regexp": "6.3.0", 1248 + "unenv": "2.0.0-rc.21", 1249 + "workerd": "1.20251011.0" 1250 + }, 1251 + "bin": { 1252 + "wrangler": "bin/wrangler.js", 1253 + "wrangler2": "bin/wrangler.js" 1254 + }, 1255 + "engines": { 1256 + "node": ">=18.0.0" 1257 + }, 1258 + "optionalDependencies": { 1259 + "fsevents": "~2.3.2" 1260 + }, 1261 + "peerDependencies": { 1262 + "@cloudflare/workers-types": "^4.20251011.0" 1263 + }, 1264 + "peerDependenciesMeta": { 1265 + "@cloudflare/workers-types": { 1266 + "optional": true 1267 + } 1268 + } 1269 + }, 1270 + "node_modules/wrangler/node_modules/esbuild": { 1271 + "version": "0.25.4", 1272 + "dev": true, 1273 + "hasInstallScript": true, 1274 + "license": "MIT", 1275 + "peer": true, 1276 + "bin": { 1277 + "esbuild": "bin/esbuild" 1278 + }, 1279 + "engines": { 1280 + "node": ">=18" 1281 + }, 1282 + "optionalDependencies": { 1283 + "@esbuild/aix-ppc64": "0.25.4", 1284 + "@esbuild/android-arm": "0.25.4", 1285 + "@esbuild/android-arm64": "0.25.4", 1286 + "@esbuild/android-x64": "0.25.4", 1287 + "@esbuild/darwin-arm64": "0.25.4", 1288 + "@esbuild/darwin-x64": "0.25.4", 1289 + "@esbuild/freebsd-arm64": "0.25.4", 1290 + "@esbuild/freebsd-x64": "0.25.4", 1291 + "@esbuild/linux-arm": "0.25.4", 1292 + "@esbuild/linux-arm64": "0.25.4", 1293 + "@esbuild/linux-ia32": "0.25.4", 1294 + "@esbuild/linux-loong64": "0.25.4", 1295 + "@esbuild/linux-mips64el": "0.25.4", 1296 + "@esbuild/linux-ppc64": "0.25.4", 1297 + "@esbuild/linux-riscv64": "0.25.4", 1298 + "@esbuild/linux-s390x": "0.25.4", 1299 + "@esbuild/linux-x64": "0.25.4", 1300 + "@esbuild/netbsd-arm64": "0.25.4", 1301 + "@esbuild/netbsd-x64": "0.25.4", 1302 + "@esbuild/openbsd-arm64": "0.25.4", 1303 + "@esbuild/openbsd-x64": "0.25.4", 1304 + "@esbuild/sunos-x64": "0.25.4", 1305 + "@esbuild/win32-arm64": "0.25.4", 1306 + "@esbuild/win32-ia32": "0.25.4", 1307 + "@esbuild/win32-x64": "0.25.4" 1308 + } 1309 + }, 1310 + "node_modules/wrangler/node_modules/esbuild/node_modules/@esbuild/darwin-arm64": { 1311 + "version": "0.25.4", 1312 + "cpu": [ 1313 + "arm64" 1314 + ], 1315 + "dev": true, 1316 + "license": "MIT", 1317 + "optional": true, 1318 + "os": [ 1319 + "darwin" 1320 + ], 1321 + "peer": true, 1322 + "engines": { 1323 + "node": ">=18" 1324 + } 1325 + }, 1326 + "node_modules/ws": { 1327 + "version": "8.18.0", 1328 + "dev": true, 1329 + "license": "MIT", 1330 + "peer": true, 1331 + "engines": { 1332 + "node": ">=10.0.0" 1333 + }, 1334 + "peerDependencies": { 1335 + "bufferutil": "^4.0.1", 1336 + "utf-8-validate": ">=5.0.2" 1337 + }, 1338 + "peerDependenciesMeta": { 1339 + "bufferutil": { 1340 + "optional": true 1341 + }, 1342 + "utf-8-validate": { 1343 + "optional": true 1344 + } 1345 + } 1346 + }, 1347 + "node_modules/youch": { 1348 + "version": "4.1.0-beta.10", 1349 + "dev": true, 1350 + "license": "MIT", 1351 + "peer": true, 1352 + "dependencies": { 1353 + "@poppinss/colors": "^4.1.5", 1354 + "@poppinss/dumper": "^0.6.4", 1355 + "@speed-highlight/core": "^1.2.7", 1356 + "cookie": "^1.0.2", 1357 + "youch-core": "^0.3.3" 1358 + } 1359 + }, 1360 + "node_modules/youch-core": { 1361 + "version": "0.3.3", 1362 + "dev": true, 1363 + "license": "MIT", 1364 + "peer": true, 1365 + "dependencies": { 1366 + "@poppinss/exception": "^1.2.2", 1367 + "error-stack-parser-es": "^1.0.5" 1368 + } 1369 + }, 1370 + "node_modules/youch/node_modules/cookie": { 1371 + "version": "1.0.2", 1372 + "dev": true, 1373 + "license": "MIT", 1374 + "peer": true, 1375 + "engines": { 1376 + "node": ">=18" 1377 + } 1378 + }, 1379 + "node_modules/zimmerframe": { 1380 + "version": "1.1.4", 1381 + "dev": true, 1382 + "license": "MIT" 1383 + }, 1384 + "node_modules/zod": { 1385 + "version": "3.22.3", 1386 + "dev": true, 1387 + "license": "MIT", 1388 + "peer": true, 1389 + "funding": { 1390 + "url": "https://github.com/sponsors/colinhacks" 1391 + } 1392 + } 1393 + } 1394 + }
+138 -1
frontend/src/lib/components/Player.svelte
··· 1 1 <script lang="ts"> 2 2 import { player } from '$lib/player.svelte'; 3 + import { queue } from '$lib/queue.svelte'; 3 4 import { API_URL } from '$lib/config'; 4 5 import { onMount } from 'svelte'; 5 6 ··· 81 82 }); 82 83 } 83 84 }); 85 + 86 + // sync queue.currentTrack with player 87 + let previousQueueIndex = $state<number>(-1); 88 + $effect(() => { 89 + if (queue.currentTrack) { 90 + const trackChanged = queue.currentTrack.id !== player.currentTrack?.id; 91 + const indexChanged = queue.currentIndex !== previousQueueIndex; 92 + 93 + if (trackChanged) { 94 + player.playTrack(queue.currentTrack); 95 + previousQueueIndex = queue.currentIndex; 96 + } else if (indexChanged) { 97 + player.currentTime = 0; 98 + player.paused = false; 99 + previousQueueIndex = queue.currentIndex; 100 + } 101 + } 102 + }); 103 + 104 + function handleTrackEnded() { 105 + if (queue.repeatMode === 'one') { 106 + player.currentTime = 0; 107 + player.paused = false; 108 + return; 109 + } 110 + 111 + if (!queue.autoAdvance) { 112 + player.reset(); 113 + return; 114 + } 115 + 116 + if (queue.hasNext) { 117 + queue.next(); 118 + } else if (queue.repeatMode === 'all') { 119 + queue.next(); 120 + } else { 121 + player.reset(); 122 + } 123 + } 84 124 </script> 85 125 86 126 {#if player.currentTrack} ··· 91 131 bind:currentTime={player.currentTime} 92 132 bind:duration={player.duration} 93 133 bind:volume={player.volume} 94 - onended={() => player.reset()} 134 + onended={handleTrackEnded} 95 135 ></audio> 96 136 97 137 <div class="player-content"> ··· 112 152 113 153 <div class="player-controls"> 114 154 <button 155 + class="control-btn" 156 + class:disabled={!queue.hasPrevious} 157 + onclick={() => queue.previous()} 158 + title="previous track" 159 + disabled={!queue.hasPrevious} 160 + > 161 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 162 + <path d="M6 4h2v16H6V4zm12 0l-10 8 10 8V4z"></path> 163 + </svg> 164 + </button> 165 + 166 + <button 115 167 class="control-btn play-pause" 116 168 onclick={() => player.togglePlayPause()} 117 169 title={player.paused ? 'play' : 'pause'} ··· 128 180 {/if} 129 181 </button> 130 182 183 + <button 184 + class="control-btn" 185 + class:disabled={!queue.hasNext} 186 + onclick={() => queue.next()} 187 + title="next track" 188 + disabled={!queue.hasNext} 189 + > 190 + <svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor"> 191 + <path d="M16 4h2v16h-2V4zM6 4l10 8-10 8V4z"></path> 192 + </svg> 193 + </button> 194 + 195 + <div class="playback-options"> 196 + <button 197 + class="option-btn" 198 + class:active={queue.shuffle} 199 + onclick={() => queue.toggleShuffle()} 200 + title={queue.shuffle ? 'disable shuffle' : 'enable shuffle'} 201 + > 202 + <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 203 + <polyline points="16 3 21 3 21 8"></polyline> 204 + <line x1="4" y1="20" x2="21" y2="3"></line> 205 + <polyline points="21 16 21 21 16 21"></polyline> 206 + <line x1="15" y1="15" x2="21" y2="21"></line> 207 + <line x1="4" y1="4" x2="9" y2="9"></line> 208 + </svg> 209 + </button> 210 + 211 + <button 212 + class="option-btn" 213 + class:active={queue.repeatMode !== 'none'} 214 + class:repeat-one={queue.repeatMode === 'one'} 215 + onclick={() => queue.cycleRepeat()} 216 + title={queue.repeatMode === 'none' ? 'repeat off' : queue.repeatMode === 'all' ? 'repeat all' : 'repeat one'} 217 + > 218 + <svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"> 219 + {#if queue.repeatMode === 'one'} 220 + <path d="M17 2L21 6L17 10V7H7V10H5V5H17V2Z M7 22L3 18L7 14V17H17V14H19V19H7V22Z M13 17H15V13H13V17Z" /> 221 + {:else} 222 + <path d="M17 2L21 6L17 10V7H7V10H5V5H17V2Z M7 22L3 18L7 14V17H17V14H19V19H7V22Z" /> 223 + {/if} 224 + </svg> 225 + </button> 226 + </div> 227 + 131 228 <div class="time-control"> 132 229 <span class="time">{formattedCurrentTime}</span> 133 230 <input ··· 269 366 display: flex; 270 367 align-items: center; 271 368 gap: 1.5rem; 369 + } 370 + 371 + .playback-options { 372 + display: flex; 373 + align-items: center; 374 + gap: 0.5rem; 375 + } 376 + 377 + .option-btn { 378 + background: transparent; 379 + border: 1px solid var(--border-default); 380 + color: var(--text-secondary); 381 + cursor: pointer; 382 + width: 36px; 383 + height: 36px; 384 + display: flex; 385 + align-items: center; 386 + justify-content: center; 387 + border-radius: 6px; 388 + transition: all 0.2s; 389 + position: relative; 390 + } 391 + 392 + .option-btn:hover { 393 + color: var(--text-primary); 394 + border-color: var(--accent); 395 + } 396 + 397 + .option-btn.active { 398 + color: var(--accent); 399 + border-color: var(--accent); 400 + } 401 + 402 + .option-btn.repeat-one::after { 403 + content: '1'; 404 + position: absolute; 405 + font-size: 0.6rem; 406 + font-weight: 600; 407 + margin-top: 1px; 408 + color: currentColor; 272 409 } 273 410 274 411 .control-btn {
+423
frontend/src/lib/components/Queue.svelte
··· 1 + <script lang="ts"> 2 + import { queue } from '$lib/queue.svelte'; 3 + import type { Track } from '$lib/types'; 4 + 5 + let draggedIndex = $state<number | null>(null); 6 + let dragOverIndex = $state<number | null>(null); 7 + 8 + const currentTrack = $derived.by<Track | null>(() => queue.tracks[queue.currentIndex] ?? null); 9 + const upcoming = $derived.by<{ track: Track; index: number }[]>(() => { 10 + return queue.tracks 11 + .map((track, index) => ({ track, index })) 12 + .filter(({ index }) => index > queue.currentIndex); 13 + }); 14 + 15 + function handleDragStart(index: number) { 16 + draggedIndex = index; 17 + } 18 + 19 + function handleDragOver(event: DragEvent, index: number) { 20 + event.preventDefault(); 21 + dragOverIndex = index; 22 + } 23 + 24 + function handleDrop(event: DragEvent, index: number) { 25 + event.preventDefault(); 26 + if (draggedIndex !== null && draggedIndex !== index) { 27 + queue.moveTrack(draggedIndex, index); 28 + } 29 + draggedIndex = null; 30 + dragOverIndex = null; 31 + } 32 + 33 + function handleDragEnd() { 34 + draggedIndex = null; 35 + dragOverIndex = null; 36 + } 37 + </script> 38 + 39 + {#if queue.tracks.length > 0} 40 + <div class="queue"> 41 + <div class="queue-header"> 42 + <h2>queue</h2> 43 + </div> 44 + 45 + <div class="queue-body"> 46 + {#if currentTrack} 47 + <section class="now-playing"> 48 + <div class="section-label">now playing</div> 49 + <div class="now-playing-card"> 50 + <div class="track-info"> 51 + <div class="track-title">{currentTrack.title}</div> 52 + <div class="track-artist"> 53 + <a href="/u/{currentTrack.artist_handle}">{currentTrack.artist}</a> 54 + </div> 55 + </div> 56 + 57 + <div class="now-playing-actions"> 58 + <button 59 + class="queue-action" 60 + onclick={() => queue.previous()} 61 + title="play previous" 62 + disabled={!queue.hasPrevious} 63 + > 64 + prev 65 + </button> 66 + <button 67 + class="queue-action" 68 + onclick={() => queue.next()} 69 + title="play next" 70 + disabled={!queue.hasNext} 71 + > 72 + next 73 + </button> 74 + </div> 75 + </div> 76 + </section> 77 + {/if} 78 + 79 + <section class="queue-upcoming"> 80 + <div class="section-header"> 81 + <h3>up next</h3> 82 + <span>{upcoming.length}</span> 83 + </div> 84 + 85 + {#if upcoming.length > 0} 86 + <div class="queue-tracks"> 87 + {#each upcoming as { track, index } (`${track.file_id}:${index}`)} 88 + <div 89 + class="queue-track" 90 + class:drag-over={dragOverIndex === index} 91 + draggable="true" 92 + ondragstart={() => handleDragStart(index)} 93 + ondragover={(e) => handleDragOver(e, index)} 94 + ondrop={(e) => handleDrop(e, index)} 95 + ondragend={handleDragEnd} 96 + onclick={() => queue.goTo(index)} 97 + > 98 + <div class="track-info"> 99 + <div class="track-title">{track.title}</div> 100 + <div class="track-artist"> 101 + <a href="/u/{track.artist_handle}" onclick={(e) => e.stopPropagation()}> 102 + {track.artist} 103 + </a> 104 + </div> 105 + </div> 106 + 107 + <button 108 + class="remove-btn" 109 + onclick={(e) => { 110 + e.stopPropagation(); 111 + queue.removeTrack(index); 112 + }} 113 + title="remove from queue" 114 + > 115 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 116 + <line x1="18" y1="6" x2="6" y2="18"></line> 117 + <line x1="6" y1="6" x2="18" y2="18"></line> 118 + </svg> 119 + </button> 120 + </div> 121 + {/each} 122 + </div> 123 + {:else} 124 + <div class="empty-up-next"> 125 + <span>nothing else in the queue</span> 126 + </div> 127 + {/if} 128 + </section> 129 + </div> 130 + </div> 131 + {:else} 132 + <div class="queue empty"> 133 + <div class="empty-state"> 134 + <svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"> 135 + <path d="M9 18V5l12-2v13"></path> 136 + <circle cx="6" cy="18" r="3"></circle> 137 + <circle cx="18" cy="16" r="3"></circle> 138 + </svg> 139 + <p>queue is empty</p> 140 + <span>add tracks to get started</span> 141 + </div> 142 + </div> 143 + {/if} 144 + 145 + <style> 146 + .queue { 147 + display: flex; 148 + flex-direction: column; 149 + height: 100%; 150 + padding: 1.5rem 1.25rem 140px; 151 + background: var(--bg-primary); 152 + border-left: 1px solid var(--border-subtle); 153 + gap: 1rem; 154 + } 155 + 156 + .queue-header h2 { 157 + margin: 0; 158 + font-size: 1rem; 159 + text-transform: uppercase; 160 + letter-spacing: 0.12em; 161 + color: var(--text-tertiary); 162 + } 163 + 164 + .queue-header { 165 + display: flex; 166 + justify-content: space-between; 167 + align-items: center; 168 + } 169 + 170 + .queue-body { 171 + display: flex; 172 + flex-direction: column; 173 + gap: 1.25rem; 174 + flex: 1; 175 + overflow: hidden; 176 + } 177 + 178 + .section-label { 179 + font-size: 0.75rem; 180 + text-transform: uppercase; 181 + letter-spacing: 0.08em; 182 + color: var(--text-tertiary); 183 + margin-bottom: 0.5rem; 184 + } 185 + 186 + .now-playing-card { 187 + display: flex; 188 + align-items: center; 189 + justify-content: space-between; 190 + padding: 1rem 1.1rem; 191 + border-radius: 10px; 192 + background: var(--bg-secondary); 193 + border: 1px solid var(--border-default); 194 + gap: 1rem; 195 + } 196 + 197 + .now-playing-card .track-title { 198 + font-weight: 600; 199 + color: var(--text-primary); 200 + margin-bottom: 0.35rem; 201 + } 202 + 203 + .now-playing-card .track-artist { 204 + font-size: 0.9rem; 205 + color: var(--text-secondary); 206 + } 207 + 208 + .now-playing-card .track-artist a { 209 + color: inherit; 210 + text-decoration: none; 211 + transition: color 0.2s; 212 + } 213 + 214 + .now-playing-card .track-artist a:hover { 215 + color: var(--accent); 216 + } 217 + 218 + .now-playing-actions { 219 + display: flex; 220 + align-items: center; 221 + gap: 0.75rem; 222 + } 223 + 224 + .queue-action { 225 + background: var(--bg-secondary); 226 + border: 1px solid var(--border-subtle); 227 + color: var(--text-secondary); 228 + padding: 0.3rem 0.75rem; 229 + border-radius: 4px; 230 + cursor: pointer; 231 + font-size: 0.75rem; 232 + text-transform: uppercase; 233 + letter-spacing: 0.05em; 234 + transition: all 0.2s; 235 + } 236 + 237 + .queue-action:hover:not(:disabled) { 238 + color: var(--text-primary); 239 + border-color: var(--border-default); 240 + background: var(--bg-hover); 241 + } 242 + 243 + .queue-action:disabled { 244 + opacity: 0.4; 245 + cursor: not-allowed; 246 + } 247 + 248 + .queue-upcoming { 249 + display: flex; 250 + flex-direction: column; 251 + flex: 1; 252 + min-height: 0; 253 + gap: 0.75rem; 254 + } 255 + 256 + .section-header { 257 + display: flex; 258 + justify-content: space-between; 259 + align-items: center; 260 + color: var(--text-tertiary); 261 + font-size: 0.85rem; 262 + text-transform: uppercase; 263 + letter-spacing: 0.08em; 264 + } 265 + 266 + .section-header h3 { 267 + margin: 0; 268 + font-size: 0.85rem; 269 + font-weight: 600; 270 + color: var(--text-secondary); 271 + text-transform: uppercase; 272 + letter-spacing: 0.08em; 273 + } 274 + 275 + .queue-tracks { 276 + flex: 1; 277 + overflow-y: auto; 278 + display: flex; 279 + flex-direction: column; 280 + gap: 0.5rem; 281 + padding-right: 0.35rem; 282 + } 283 + 284 + .queue-track { 285 + display: flex; 286 + align-items: center; 287 + justify-content: space-between; 288 + padding: 0.85rem 0.9rem; 289 + border-radius: 8px; 290 + cursor: pointer; 291 + transition: all 0.2s; 292 + border: 1px solid var(--border-subtle); 293 + background: var(--bg-secondary); 294 + } 295 + 296 + .queue-track:hover { 297 + background: var(--bg-hover); 298 + border-color: var(--border-default); 299 + } 300 + 301 + .queue-track.drag-over { 302 + border-color: var(--accent); 303 + background: color-mix(in srgb, var(--accent) 12%, transparent); 304 + } 305 + 306 + .queue-track.current { 307 + border-color: var(--accent); 308 + background: color-mix(in srgb, var(--accent) 14%, var(--bg-secondary)); 309 + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 30%, transparent); 310 + } 311 + 312 + .queue-track.current .track-title { 313 + color: var(--accent); 314 + font-weight: 600; 315 + } 316 + 317 + .track-info { 318 + flex: 1; 319 + min-width: 0; 320 + } 321 + 322 + .track-title { 323 + font-weight: 500; 324 + color: var(--text-primary); 325 + white-space: nowrap; 326 + overflow: hidden; 327 + text-overflow: ellipsis; 328 + margin-bottom: 0.25rem; 329 + } 330 + 331 + .track-artist { 332 + font-size: 0.85rem; 333 + color: var(--text-tertiary); 334 + white-space: nowrap; 335 + overflow: hidden; 336 + text-overflow: ellipsis; 337 + } 338 + 339 + .track-artist a { 340 + color: inherit; 341 + text-decoration: none; 342 + transition: color 0.2s; 343 + } 344 + 345 + .track-artist a:hover { 346 + color: var(--text-secondary); 347 + } 348 + 349 + .remove-btn { 350 + background: transparent; 351 + border: none; 352 + color: var(--text-tertiary); 353 + cursor: pointer; 354 + padding: 0.5rem; 355 + display: flex; 356 + align-items: center; 357 + justify-content: center; 358 + transition: all 0.2s; 359 + border-radius: 4px; 360 + opacity: 0; 361 + } 362 + 363 + .queue-track:hover .remove-btn { 364 + opacity: 1; 365 + } 366 + 367 + .remove-btn:hover { 368 + color: var(--error); 369 + background: color-mix(in srgb, var(--error) 12%, transparent); 370 + } 371 + 372 + .empty-up-next { 373 + border: 1px dashed var(--border-subtle); 374 + border-radius: 6px; 375 + padding: 1.25rem; 376 + text-align: center; 377 + color: var(--text-tertiary); 378 + } 379 + 380 + .queue.empty { 381 + display: flex; 382 + align-items: center; 383 + justify-content: center; 384 + } 385 + 386 + .empty-state { 387 + text-align: center; 388 + color: var(--text-tertiary); 389 + padding: 2rem; 390 + } 391 + 392 + .empty-state svg { 393 + margin-bottom: 1rem; 394 + opacity: 0.5; 395 + } 396 + 397 + .empty-state p { 398 + margin: 0.5rem 0 0.25rem; 399 + font-size: 1.1rem; 400 + color: var(--text-secondary); 401 + } 402 + 403 + .empty-state span { 404 + font-size: 0.9rem; 405 + } 406 + 407 + .queue-tracks::-webkit-scrollbar { 408 + width: 8px; 409 + } 410 + 411 + .queue-tracks::-webkit-scrollbar-track { 412 + background: transparent; 413 + } 414 + 415 + .queue-tracks::-webkit-scrollbar-thumb { 416 + background: var(--border-default); 417 + border-radius: 4px; 418 + } 419 + 420 + .queue-tracks::-webkit-scrollbar-thumb:hover { 421 + background: var(--border-emphasis); 422 + } 423 + </style>
+354
frontend/src/lib/components/SettingsMenu.svelte
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import { API_URL } from '$lib/config'; 4 + import { queue } from '$lib/queue.svelte'; 5 + 6 + let showSettings = $state(false); 7 + let currentColor = $state('#6a9fff'); 8 + let autoAdvance = $state(true); 9 + 10 + const presetColors = [ 11 + { name: 'blue', value: '#6a9fff' }, 12 + { name: 'purple', value: '#a78bfa' }, 13 + { name: 'pink', value: '#f472b6' }, 14 + { name: 'green', value: '#4ade80' }, 15 + { name: 'orange', value: '#fb923c' }, 16 + { name: 'red', value: '#ef4444' } 17 + ]; 18 + 19 + onMount(async () => { 20 + const savedAccent = localStorage.getItem('accentColor'); 21 + if (savedAccent) { 22 + currentColor = savedAccent; 23 + applyColorLocally(savedAccent); 24 + } 25 + 26 + const savedAutoAdvance = localStorage.getItem('autoAdvance'); 27 + autoAdvance = savedAutoAdvance === null ? true : savedAutoAdvance !== '0'; 28 + queue.setAutoAdvance(autoAdvance); 29 + 30 + try { 31 + const sessionId = localStorage.getItem('session_id'); 32 + if (!sessionId) return; 33 + 34 + const response = await fetch(`${API_URL}/preferences/`, { 35 + headers: { 36 + Authorization: `Bearer ${sessionId}` 37 + } 38 + }); 39 + 40 + if (!response.ok) return; 41 + 42 + const data = await response.json(); 43 + if (data.accent_color) { 44 + currentColor = data.accent_color; 45 + applyColorLocally(data.accent_color); 46 + localStorage.setItem('accentColor', data.accent_color); 47 + } 48 + 49 + autoAdvance = data.auto_advance ?? true; 50 + queue.setAutoAdvance(autoAdvance); 51 + localStorage.setItem('autoAdvance', autoAdvance ? '1' : '0'); 52 + } catch (error) { 53 + console.error('failed to fetch preferences:', error); 54 + } 55 + }); 56 + 57 + function toggleSettings() { 58 + showSettings = !showSettings; 59 + } 60 + 61 + function applyColorLocally(color: string) { 62 + document.documentElement.style.setProperty('--accent', color); 63 + 64 + const r = parseInt(color.slice(1, 3), 16); 65 + const g = parseInt(color.slice(3, 5), 16); 66 + const b = parseInt(color.slice(5, 7), 16); 67 + const hover = `rgb(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)})`; 68 + document.documentElement.style.setProperty('--accent-hover', hover); 69 + } 70 + 71 + async function savePreferences(update: Record<string, unknown>) { 72 + try { 73 + const sessionId = localStorage.getItem('session_id'); 74 + if (!sessionId) return; 75 + 76 + await fetch(`${API_URL}/preferences/`, { 77 + method: 'POST', 78 + headers: { 79 + 'Content-Type': 'application/json', 80 + Authorization: `Bearer ${sessionId}` 81 + }, 82 + body: JSON.stringify(update) 83 + }); 84 + } catch (error) { 85 + console.error('failed to save preferences:', error); 86 + } 87 + } 88 + 89 + function applyColor(color: string) { 90 + currentColor = color; 91 + applyColorLocally(color); 92 + localStorage.setItem('accentColor', color); 93 + savePreferences({ accent_color: color }); 94 + } 95 + 96 + function handleColorInput(event: Event) { 97 + const input = event.target as HTMLInputElement; 98 + applyColor(input.value); 99 + } 100 + 101 + function selectPreset(color: string) { 102 + applyColor(color); 103 + } 104 + 105 + function handleAutoAdvanceToggle(event: Event) { 106 + const input = event.target as HTMLInputElement; 107 + autoAdvance = input.checked; 108 + queue.setAutoAdvance(autoAdvance); 109 + localStorage.setItem('autoAdvance', autoAdvance ? '1' : '0'); 110 + savePreferences({ auto_advance: autoAdvance }); 111 + } 112 + </script> 113 + 114 + <div class="settings-menu"> 115 + <button class="settings-toggle" onclick={toggleSettings} title="settings"> 116 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 117 + <path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"></path> 118 + <circle cx="12" cy="12" r="3"></circle> 119 + </svg> 120 + </button> 121 + 122 + {#if showSettings} 123 + <div class="settings-panel"> 124 + <div class="panel-header"> 125 + <span>settings</span> 126 + <button class="close-btn" onclick={toggleSettings}>×</button> 127 + </div> 128 + 129 + <section class="settings-section"> 130 + <h3>accent color</h3> 131 + <div class="color-picker-row"> 132 + <input type="color" value={currentColor} oninput={handleColorInput} class="color-input" /> 133 + <span class="color-value">{currentColor}</span> 134 + </div> 135 + 136 + <div class="preset-grid"> 137 + {#each presetColors as preset} 138 + <button 139 + class="preset-btn" 140 + class:active={currentColor.toLowerCase() === preset.value.toLowerCase()} 141 + style="background: {preset.value}" 142 + onclick={() => selectPreset(preset.value)} 143 + title={preset.name} 144 + ></button> 145 + {/each} 146 + </div> 147 + </section> 148 + 149 + <section class="settings-section"> 150 + <h3>playback</h3> 151 + <label class="toggle"> 152 + <input type="checkbox" checked={autoAdvance} oninput={handleAutoAdvanceToggle} /> 153 + <span class="toggle-indicator"></span> 154 + <span class="toggle-text">auto-play next track</span> 155 + </label> 156 + <p class="toggle-hint">when a track ends, start the next item in your queue</p> 157 + </section> 158 + </div> 159 + {/if} 160 + </div> 161 + 162 + <style> 163 + .settings-menu { 164 + position: relative; 165 + } 166 + 167 + .settings-toggle { 168 + background: transparent; 169 + border: 1px solid var(--border-default); 170 + color: var(--text-secondary); 171 + padding: 0.5rem; 172 + border-radius: 4px; 173 + cursor: pointer; 174 + transition: all 0.2s; 175 + display: flex; 176 + align-items: center; 177 + justify-content: center; 178 + } 179 + 180 + .settings-toggle:hover { 181 + color: var(--accent); 182 + border-color: var(--accent); 183 + } 184 + 185 + .settings-panel { 186 + position: absolute; 187 + top: calc(100% + 0.5rem); 188 + right: 0; 189 + background: var(--bg-secondary); 190 + border: 1px solid var(--border-default); 191 + border-radius: 8px; 192 + padding: 1.25rem; 193 + min-width: 260px; 194 + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.45); 195 + z-index: 1000; 196 + display: flex; 197 + flex-direction: column; 198 + gap: 1.25rem; 199 + } 200 + 201 + .panel-header { 202 + display: flex; 203 + justify-content: space-between; 204 + align-items: center; 205 + color: var(--text-primary); 206 + font-weight: 600; 207 + font-size: 0.95rem; 208 + } 209 + 210 + .close-btn { 211 + background: transparent; 212 + border: none; 213 + color: var(--text-secondary); 214 + font-size: 1.4rem; 215 + cursor: pointer; 216 + width: 28px; 217 + height: 28px; 218 + display: flex; 219 + align-items: center; 220 + justify-content: center; 221 + transition: color 0.2s; 222 + } 223 + 224 + .close-btn:hover { 225 + color: var(--text-primary); 226 + } 227 + 228 + .settings-section { 229 + display: flex; 230 + flex-direction: column; 231 + gap: 0.75rem; 232 + } 233 + 234 + .settings-section h3 { 235 + margin: 0; 236 + font-size: 0.85rem; 237 + text-transform: uppercase; 238 + letter-spacing: 0.08em; 239 + color: var(--text-tertiary); 240 + } 241 + 242 + .color-picker-row { 243 + display: flex; 244 + align-items: center; 245 + gap: 0.75rem; 246 + } 247 + 248 + .color-input { 249 + width: 48px; 250 + height: 32px; 251 + border: 1px solid var(--border-default); 252 + border-radius: 4px; 253 + cursor: pointer; 254 + background: transparent; 255 + } 256 + 257 + .color-input::-webkit-color-swatch-wrapper { 258 + padding: 2px; 259 + } 260 + 261 + .color-input::-webkit-color-swatch { 262 + border-radius: 2px; 263 + border: none; 264 + } 265 + 266 + .color-value { 267 + font-family: monospace; 268 + font-size: 0.85rem; 269 + color: var(--text-secondary); 270 + } 271 + 272 + .preset-grid { 273 + display: grid; 274 + grid-template-columns: repeat(6, 1fr); 275 + gap: 0.5rem; 276 + } 277 + 278 + .preset-btn { 279 + width: 32px; 280 + height: 32px; 281 + border-radius: 4px; 282 + border: 2px solid transparent; 283 + cursor: pointer; 284 + transition: all 0.2s; 285 + padding: 0; 286 + } 287 + 288 + .preset-btn:hover { 289 + transform: scale(1.08); 290 + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); 291 + } 292 + 293 + .preset-btn.active { 294 + border-color: var(--text-primary); 295 + box-shadow: 0 0 0 1px var(--bg-secondary), 0 2px 8px rgba(0, 0, 0, 0.35); 296 + } 297 + 298 + .toggle { 299 + display: flex; 300 + align-items: center; 301 + gap: 0.65rem; 302 + color: var(--text-primary); 303 + font-size: 0.9rem; 304 + } 305 + 306 + .toggle input { 307 + appearance: none; 308 + width: 42px; 309 + height: 22px; 310 + border-radius: 999px; 311 + background: var(--border-default); 312 + position: relative; 313 + cursor: pointer; 314 + transition: background 0.2s, border 0.2s; 315 + border: 1px solid var(--border-default); 316 + } 317 + 318 + .toggle input::after { 319 + content: ''; 320 + position: absolute; 321 + top: 2px; 322 + left: 2px; 323 + width: 16px; 324 + height: 16px; 325 + border-radius: 50%; 326 + background: var(--text-secondary); 327 + transition: transform 0.2s, background 0.2s; 328 + } 329 + 330 + .toggle input:checked { 331 + background: color-mix(in srgb, var(--accent) 65%, transparent); 332 + border-color: var(--accent); 333 + } 334 + 335 + .toggle input:checked::after { 336 + transform: translateX(20px); 337 + background: var(--accent); 338 + } 339 + 340 + .toggle-indicator { 341 + display: none; 342 + } 343 + 344 + .toggle-text { 345 + white-space: nowrap; 346 + } 347 + 348 + .toggle-hint { 349 + margin: 0; 350 + color: var(--text-tertiary); 351 + font-size: 0.8rem; 352 + line-height: 1.3; 353 + } 354 + </style>
+527
frontend/src/lib/queue.svelte.ts
··· 1 + import { browser } from '$app/environment'; 2 + import type { QueueResponse, QueueState, RepeatMode, Track } from './types'; 3 + import { API_URL } from './config'; 4 + 5 + const SYNC_DEBOUNCE_MS = 250; 6 + 7 + // global queue state using Svelte 5 runes 8 + class Queue { 9 + tracks = $state<Track[]>([]); 10 + currentIndex = $state(0); 11 + shuffle = $state(false); 12 + repeatMode = $state<RepeatMode>('none'); 13 + originalOrder = $state<Track[]>([]); 14 + autoAdvance = $state(true); 15 + 16 + revision = $state<number | null>(null); 17 + etag = $state<string | null>(null); 18 + syncInProgress = $state(false); 19 + 20 + initialized = false; 21 + hydrating = false; 22 + 23 + syncTimer: number | null = null; 24 + pendingSync = false; 25 + 26 + get currentTrack(): Track | null { 27 + if (this.tracks.length === 0) return null; 28 + return this.tracks[this.currentIndex] ?? null; 29 + } 30 + 31 + get hasNext(): boolean { 32 + if (this.repeatMode === 'one') return true; 33 + if (this.repeatMode === 'all' && this.tracks.length > 0) return true; 34 + return this.currentIndex < this.tracks.length - 1; 35 + } 36 + 37 + get hasPrevious(): boolean { 38 + if (this.repeatMode === 'one') return true; 39 + if (this.repeatMode === 'all' && this.tracks.length > 0) return true; 40 + return this.currentIndex > 0; 41 + } 42 + 43 + get upNext(): Track[] { 44 + if (this.tracks.length === 0) return []; 45 + return this.tracks.slice(this.currentIndex + 1); 46 + } 47 + 48 + get upNextEntries(): { track: Track; index: number }[] { 49 + if (this.tracks.length === 0) return []; 50 + return this.tracks 51 + .map((track, index) => ({ track, index })) 52 + .filter(({ index }) => index > this.currentIndex); 53 + } 54 + 55 + getCurrentTrack(): Track | null { 56 + if (this.tracks.length === 0) return null; 57 + return this.tracks[this.currentIndex] ?? null; 58 + } 59 + 60 + getUpNextEntries(): { track: Track; index: number }[] { 61 + if (this.tracks.length === 0) return []; 62 + return this.tracks 63 + .map((track, index) => ({ track, index })) 64 + .filter(({ index }) => index > this.currentIndex); 65 + } 66 + 67 + setAutoAdvance(value: boolean) { 68 + this.autoAdvance = value; 69 + if (browser) { 70 + localStorage.setItem('autoAdvance', value ? '1' : '0'); 71 + } 72 + } 73 + 74 + async initialize() { 75 + if (!browser || this.initialized) return; 76 + this.initialized = true; 77 + 78 + const savedAutoAdvance = localStorage.getItem('autoAdvance'); 79 + if (savedAutoAdvance !== null) { 80 + this.autoAdvance = savedAutoAdvance !== '0'; 81 + } 82 + 83 + await this.fetchQueue(); 84 + 85 + document.addEventListener('visibilitychange', this.handleVisibilityChange); 86 + window.addEventListener('beforeunload', this.handleBeforeUnload); 87 + } 88 + 89 + handleVisibilityChange = () => { 90 + if (document.visibilityState === 'hidden') { 91 + void this.flushSync(); 92 + } 93 + }; 94 + 95 + handleBeforeUnload = () => { 96 + void this.flushSync(); 97 + }; 98 + 99 + async flushSync() { 100 + if (this.syncTimer) { 101 + window.clearTimeout(this.syncTimer); 102 + this.syncTimer = null; 103 + await this.pushQueue(); 104 + return; 105 + } 106 + 107 + if (this.pendingSync && !this.syncInProgress) { 108 + await this.pushQueue(); 109 + } 110 + } 111 + 112 + async fetchQueue(force = false) { 113 + if (!browser) return; 114 + 115 + // while we have unsent or in-flight local changes, skip non-forced fetches 116 + if ( 117 + !force && 118 + (this.syncInProgress || this.syncTimer !== null || this.pendingSync) 119 + ) { 120 + return; 121 + } 122 + 123 + try { 124 + this.hydrating = true; 125 + 126 + const sessionId = localStorage.getItem('session_id'); 127 + const headers: HeadersInit = {}; 128 + 129 + if (sessionId) { 130 + headers['Authorization'] = `Bearer ${sessionId}`; 131 + } 132 + 133 + if (this.etag && !force) { 134 + headers['If-None-Match'] = this.etag; 135 + } 136 + 137 + const response = await fetch(`${API_URL}/queue/`, { headers }); 138 + 139 + if (response.status === 304) { 140 + return; 141 + } 142 + 143 + if (!response.ok) { 144 + throw new Error(`failed to fetch queue: ${response.statusText}`); 145 + } 146 + 147 + const data: QueueResponse = await response.json(); 148 + const newEtag = response.headers.get('etag'); 149 + 150 + if (this.revision !== null && data.revision < this.revision) { 151 + return; 152 + } 153 + 154 + this.revision = data.revision; 155 + this.etag = newEtag; 156 + 157 + this.applySnapshot(data); 158 + } catch (error) { 159 + console.error('failed to fetch queue:', error); 160 + } finally { 161 + this.hydrating = false; 162 + } 163 + } 164 + 165 + applySnapshot(snapshot: QueueResponse) { 166 + const { state, tracks } = snapshot; 167 + const trackIds = state.track_ids ?? []; 168 + const serverTracks = tracks ?? []; 169 + const previousTracks = [...this.tracks]; 170 + 171 + const orderedTracks: Track[] = []; 172 + for (let i = 0; i < trackIds.length; i++) { 173 + const fileId = trackIds[i]; 174 + const serverTrack = serverTracks[i]; 175 + const existingTrack = previousTracks[i]; 176 + const fallbackTrack = previousTracks.find((track) => track.file_id === fileId); 177 + const next = serverTrack ?? existingTrack ?? fallbackTrack; 178 + 179 + if (next) { 180 + orderedTracks.push(next); 181 + } 182 + } 183 + 184 + if (orderedTracks.length > 0 || trackIds.length === 0) { 185 + this.tracks = orderedTracks; 186 + } 187 + 188 + const originalIds = 189 + state.original_order_ids && state.original_order_ids.length > 0 190 + ? state.original_order_ids 191 + : trackIds; 192 + 193 + const pools = new Map<string, Track[]>(); 194 + for (const track of orderedTracks) { 195 + const list = pools.get(track.file_id) ?? []; 196 + list.push(track); 197 + pools.set(track.file_id, list); 198 + } 199 + 200 + const originalTracks: Track[] = []; 201 + for (const fileId of originalIds) { 202 + const pool = pools.get(fileId); 203 + if (pool && pool.length > 0) { 204 + originalTracks.push(pool.shift()!); 205 + continue; 206 + } 207 + 208 + const fallback = orderedTracks.find((track) => track.file_id === fileId); 209 + if (fallback) { 210 + originalTracks.push(fallback); 211 + } 212 + } 213 + 214 + if (originalTracks.length > 0 || originalIds.length === 0) { 215 + this.originalOrder = originalTracks.length ? originalTracks : [...orderedTracks]; 216 + } 217 + 218 + this.shuffle = state.shuffle; 219 + this.repeatMode = state.repeat_mode; 220 + 221 + this.currentIndex = this.resolveCurrentIndex( 222 + state.current_track_id, 223 + state.current_index, 224 + this.tracks 225 + ); 226 + } 227 + 228 + resolveCurrentIndex(currentTrackId: string | null, index: number, tracks: Track[]): number { 229 + if (tracks.length === 0) return 0; 230 + 231 + const indexInRange = Number.isInteger(index) && index >= 0 && index < tracks.length; 232 + 233 + // trust the explicit index first – the server always sends the correct slot 234 + if (indexInRange) { 235 + return index; 236 + } 237 + 238 + if (currentTrackId) { 239 + const match = tracks.findIndex((track) => track.file_id === currentTrackId); 240 + if (match !== -1) return match; 241 + } 242 + 243 + return 0; 244 + } 245 + 246 + clampIndex(index: number): number { 247 + if (this.tracks.length === 0) return 0; 248 + if (index < 0) return 0; 249 + if (index >= this.tracks.length) return this.tracks.length - 1; 250 + return index; 251 + } 252 + 253 + schedulePush() { 254 + if (!browser) return; 255 + 256 + if (this.syncTimer !== null) { 257 + window.clearTimeout(this.syncTimer); 258 + } 259 + 260 + this.syncTimer = window.setTimeout(() => { 261 + this.syncTimer = null; 262 + void this.pushQueue(); 263 + }, SYNC_DEBOUNCE_MS); 264 + } 265 + 266 + async pushQueue(): Promise<boolean> { 267 + if (!browser) return false; 268 + 269 + if (this.syncInProgress) { 270 + this.pendingSync = true; 271 + return false; 272 + } 273 + 274 + if (this.syncTimer !== null) { 275 + window.clearTimeout(this.syncTimer); 276 + this.syncTimer = null; 277 + } 278 + 279 + this.syncInProgress = true; 280 + this.pendingSync = false; 281 + 282 + try { 283 + const sessionId = localStorage.getItem('session_id'); 284 + const state: QueueState = { 285 + track_ids: this.tracks.map((t) => t.file_id), 286 + current_index: this.currentIndex, 287 + current_track_id: this.currentTrack?.file_id ?? null, 288 + shuffle: this.shuffle, 289 + repeat_mode: this.repeatMode, 290 + original_order_ids: this.originalOrder.map((t) => t.file_id) 291 + }; 292 + 293 + const headers: HeadersInit = { 294 + 'Content-Type': 'application/json' 295 + }; 296 + 297 + if (sessionId) { 298 + headers['Authorization'] = `Bearer ${sessionId}`; 299 + } 300 + 301 + if (this.revision !== null) { 302 + headers['If-Match'] = `"${this.revision}"`; 303 + } 304 + 305 + const response = await fetch(`${API_URL}/queue/`, { 306 + method: 'PUT', 307 + headers, 308 + body: JSON.stringify({ state }) 309 + }); 310 + 311 + if (response.status === 409) { 312 + console.warn('queue conflict detected, fetching latest state'); 313 + await this.fetchQueue(true); 314 + return false; 315 + } 316 + 317 + if (!response.ok) { 318 + throw new Error(`failed to push queue: ${response.statusText}`); 319 + } 320 + 321 + const data: QueueResponse = await response.json(); 322 + const newEtag = response.headers.get('etag'); 323 + 324 + if (this.revision !== null && data.revision < this.revision) { 325 + return true; 326 + } 327 + 328 + this.revision = data.revision; 329 + this.etag = newEtag; 330 + 331 + this.applySnapshot(data); 332 + 333 + return true; 334 + } catch (error) { 335 + console.error('failed to push queue:', error); 336 + return false; 337 + } finally { 338 + this.syncInProgress = false; 339 + 340 + if (this.pendingSync) { 341 + this.pendingSync = false; 342 + void this.pushQueue(); 343 + } 344 + } 345 + } 346 + 347 + addTracks(tracks: Track[], playNow = false) { 348 + if (tracks.length === 0) return; 349 + 350 + this.tracks = [...this.tracks, ...tracks]; 351 + this.originalOrder = [...this.originalOrder, ...tracks]; 352 + 353 + if (playNow) { 354 + this.currentIndex = this.tracks.length - tracks.length; 355 + } 356 + 357 + this.schedulePush(); 358 + } 359 + 360 + setQueue(tracks: Track[], startIndex = 0) { 361 + if (tracks.length === 0) { 362 + this.clear(); 363 + return; 364 + } 365 + 366 + this.tracks = [...tracks]; 367 + this.originalOrder = [...tracks]; 368 + this.currentIndex = this.clampIndex(startIndex); 369 + this.schedulePush(); 370 + } 371 + 372 + playNow(track: Track) { 373 + const upNext = this.tracks.slice(this.currentIndex + 1); 374 + this.tracks = [track, ...upNext]; 375 + this.originalOrder = [...this.tracks]; 376 + this.currentIndex = 0; 377 + this.schedulePush(); 378 + } 379 + 380 + clear() { 381 + this.tracks = []; 382 + this.originalOrder = []; 383 + this.currentIndex = 0; 384 + this.schedulePush(); 385 + } 386 + 387 + goTo(index: number) { 388 + if (index < 0 || index >= this.tracks.length) return; 389 + this.currentIndex = index; 390 + this.schedulePush(); 391 + } 392 + 393 + next() { 394 + if (!this.hasNext) return; 395 + 396 + if (this.repeatMode === 'one') { 397 + return; 398 + } 399 + 400 + if (this.currentIndex < this.tracks.length - 1) { 401 + this.currentIndex += 1; 402 + } else if (this.repeatMode === 'all') { 403 + this.currentIndex = 0; 404 + } 405 + 406 + this.schedulePush(); 407 + } 408 + 409 + previous() { 410 + if (!this.hasPrevious) return; 411 + 412 + if (this.repeatMode === 'one') { 413 + return; 414 + } 415 + 416 + if (this.currentIndex > 0) { 417 + this.currentIndex -= 1; 418 + } else if (this.repeatMode === 'all') { 419 + this.currentIndex = this.tracks.length - 1; 420 + } 421 + 422 + this.schedulePush(); 423 + } 424 + 425 + toggleShuffle() { 426 + if (this.tracks.length <= 1) { 427 + this.shuffle = false; 428 + return; 429 + } 430 + 431 + const current = this.currentTrack; 432 + 433 + if (!this.shuffle) { 434 + this.originalOrder = [...this.tracks]; 435 + const shuffled = [...this.tracks]; 436 + 437 + for (let i = shuffled.length - 1; i > 0; i--) { 438 + const j = Math.floor(Math.random() * (i + 1)); 439 + [shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; 440 + } 441 + 442 + this.tracks = shuffled; 443 + this.shuffle = true; 444 + } else { 445 + this.tracks = [...this.originalOrder]; 446 + this.shuffle = false; 447 + } 448 + 449 + if (current) { 450 + const newIndex = this.tracks.findIndex((track) => track.file_id === current.file_id); 451 + this.currentIndex = newIndex === -1 ? this.clampIndex(this.currentIndex) : newIndex; 452 + } else { 453 + this.currentIndex = this.clampIndex(this.currentIndex); 454 + } 455 + 456 + this.schedulePush(); 457 + } 458 + 459 + cycleRepeat() { 460 + if (this.repeatMode === 'none') { 461 + this.repeatMode = 'all'; 462 + } else if (this.repeatMode === 'all') { 463 + this.repeatMode = 'one'; 464 + } else { 465 + this.repeatMode = 'none'; 466 + } 467 + 468 + this.schedulePush(); 469 + } 470 + 471 + moveTrack(fromIndex: number, toIndex: number) { 472 + if (fromIndex === toIndex) return; 473 + if (fromIndex < 0 || fromIndex >= this.tracks.length) return; 474 + if (toIndex < 0 || toIndex >= this.tracks.length) return; 475 + 476 + const updated = [...this.tracks]; 477 + const [moved] = updated.splice(fromIndex, 1); 478 + updated.splice(toIndex, 0, moved); 479 + 480 + if (fromIndex === this.currentIndex) { 481 + this.currentIndex = toIndex; 482 + } else if (fromIndex < this.currentIndex && toIndex >= this.currentIndex) { 483 + this.currentIndex -= 1; 484 + } else if (fromIndex > this.currentIndex && toIndex <= this.currentIndex) { 485 + this.currentIndex += 1; 486 + } 487 + 488 + this.tracks = updated; 489 + 490 + if (!this.shuffle) { 491 + this.originalOrder = [...updated]; 492 + } 493 + 494 + this.schedulePush(); 495 + } 496 + 497 + removeTrack(index: number) { 498 + if (index < 0 || index >= this.tracks.length) return; 499 + if (index === this.currentIndex) return; 500 + 501 + const updated = [...this.tracks]; 502 + const [removed] = updated.splice(index, 1); 503 + 504 + this.tracks = updated; 505 + this.originalOrder = this.originalOrder.filter((track) => track.file_id !== removed.file_id); 506 + 507 + if (updated.length === 0) { 508 + this.currentIndex = 0; 509 + this.schedulePush(); 510 + return; 511 + } 512 + 513 + if (index < this.currentIndex) { 514 + this.currentIndex -= 1; 515 + } else if (index === this.currentIndex) { 516 + this.currentIndex = this.clampIndex(this.currentIndex); 517 + } 518 + 519 + this.schedulePush(); 520 + } 521 + } 522 + 523 + export const queue = new Queue(); 524 + 525 + if (browser) { 526 + void queue.initialize(); 527 + }
+17
frontend/src/lib/types.ts
··· 34 34 avatar_url?: string; 35 35 bio?: string; 36 36 } 37 + 38 + export type RepeatMode = 'none' | 'all' | 'one'; 39 + 40 + export interface QueueState { 41 + track_ids: string[]; 42 + current_index: number; 43 + current_track_id: string | null; 44 + shuffle: boolean; 45 + repeat_mode: RepeatMode; 46 + original_order_ids: string[]; 47 + } 48 + 49 + export interface QueueResponse { 50 + state: QueueState; 51 + revision: number; 52 + tracks: Track[]; 53 + }
+14 -12
frontend/src/routes/+layout.svelte
··· 46 46 47 47 <script> 48 48 // prevent flash by applying saved settings immediately 49 - (function() { 50 - const savedAccent = localStorage.getItem('accentColor'); 51 - if (savedAccent) { 52 - document.documentElement.style.setProperty('--accent', savedAccent); 53 - // simple lightening for hover state 54 - const r = parseInt(savedAccent.slice(1, 3), 16); 55 - const g = parseInt(savedAccent.slice(3, 5), 16); 56 - const b = parseInt(savedAccent.slice(5, 7), 16); 57 - const hover = `rgb(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)})`; 58 - document.documentElement.style.setProperty('--accent-hover', hover); 59 - } 60 - })(); 49 + if (typeof window !== 'undefined') { 50 + (function() { 51 + const savedAccent = localStorage.getItem('accentColor'); 52 + if (savedAccent) { 53 + document.documentElement.style.setProperty('--accent', savedAccent); 54 + // simple lightening for hover state 55 + const r = parseInt(savedAccent.slice(1, 3), 16); 56 + const g = parseInt(savedAccent.slice(3, 5), 16); 57 + const b = parseInt(savedAccent.slice(5, 7), 16); 58 + const hover = `rgb(${Math.min(255, r + 30)}, ${Math.min(255, g + 30)}, ${Math.min(255, b + 30)})`; 59 + document.documentElement.style.setProperty('--accent-hover', hover); 60 + } 61 + })(); 62 + } 61 63 </script> 62 64 </svelte:head> 63 65
+3 -2
frontend/src/routes/+page.svelte
··· 4 4 import Header from '$lib/components/Header.svelte'; 5 5 import type { User } from '$lib/types'; 6 6 import { API_URL } from '$lib/config'; 7 - import { player } from '$lib/player.svelte'; 7 + import { player } from '$lib/player.svelte'; 8 + import { queue } from '$lib/queue.svelte'; 8 9 import { tracksCache } from '$lib/tracks.svelte'; 9 10 10 11 let user = $state<User | null>(null); ··· 65 66 <TrackItem 66 67 {track} 67 68 isPlaying={player.currentTrack?.id === track.id} 68 - onPlay={(t) => player.playTrack(t)} 69 + onPlay={(t) => queue.playNow(t)} 69 70 /> 70 71 {/each} 71 72 </div>
+4 -3
frontend/src/routes/u/[handle]/+page.svelte
··· 3 3 import { page } from '$app/stores'; 4 4 import { API_URL } from '$lib/config'; 5 5 import type { Track, Artist, User } from '$lib/types'; 6 - import TrackItem from '$lib/components/TrackItem.svelte'; 6 + import TrackItem from '$lib/components/TrackItem.svelte'; 7 7 import Header from '$lib/components/Header.svelte'; 8 - import { player } from '$lib/player.svelte'; 8 + import { player } from '$lib/player.svelte'; 9 + import { queue } from '$lib/queue.svelte'; 9 10 import type { PageData } from './$types'; 10 11 11 12 // receive server-loaded data ··· 179 180 <TrackItem 180 181 {track} 181 182 isPlaying={player.currentTrack?.id === track.id} 182 - onPlay={(t) => player.playTrack(t)} 183 + onPlay={(t) => queue.playNow(t)} 183 184 /> 184 185 {/each} 185 186 </div>
+4
pyproject.toml
··· 22 22 "psycopg[binary]>=3.2.12", 23 23 "greenlet>=3.2.4", 24 24 "logfire[fastapi,sqlalchemy]>=4.14.2", 25 + "cachetools>=6.2.1", 25 26 ] 26 27 27 28 requires-python = ">=3.11" ··· 85 86 python_files = ["test_*.py", "*_test.py"] 86 87 python_classes = ["Test*"] 87 88 python_functions = ["test_*"] 89 + filterwarnings = [ 90 + "ignore::pydantic.warnings.UnsupportedFieldAttributeWarning", 91 + ] 88 92 89 93 [tool.ruff.lint] 90 94 fixable = ["ALL"]
+8
src/relay/_internal/CLAUDE.md
··· 1 + # _internal 2 + 3 + internal services not exposed as public APIs. 4 + 5 + - auth: session management, OAuth state encryption 6 + - uploads: multipart handling, format validation 7 + - queue: shuffle/repeat logic, state persistence 8 + - notifications: user preferences, delivery (future)
+2
src/relay/_internal/__init__.py
··· 15 15 update_session_tokens, 16 16 ) 17 17 from relay._internal.notifications import notification_service 18 + from relay._internal.queue import queue_service 18 19 19 20 __all__ = [ 20 21 "Session", ··· 26 27 "handle_oauth_callback", 27 28 "notification_service", 28 29 "oauth_client", 30 + "queue_service", 29 31 "require_artist_profile", 30 32 "require_auth", 31 33 "start_oauth_flow",
+282
src/relay/_internal/queue.py
··· 1 + """queue service with LISTEN/NOTIFY for cross-instance sync.""" 2 + 3 + import asyncio 4 + import contextlib 5 + import json 6 + import logging 7 + from datetime import UTC, datetime 8 + from typing import Any 9 + 10 + import asyncpg 11 + from cachetools import TTLCache 12 + from sqlalchemy import select 13 + from sqlalchemy.exc import IntegrityError 14 + from sqlalchemy.orm import selectinload 15 + 16 + from relay.config import settings 17 + from relay.models import QueueState, Track 18 + from relay.utilities.database import db_session 19 + 20 + logger = logging.getLogger(__name__) 21 + 22 + 23 + class QueueService: 24 + """service for managing queue state with cross-instance sync via LISTEN/NOTIFY.""" 25 + 26 + def __init__(self): 27 + # TTLCache provides both LRU eviction and TTL expiration 28 + self.cache: TTLCache[str, tuple[dict[str, Any], int, list[dict[str, Any]]]] = ( 29 + TTLCache(maxsize=100, ttl=300) 30 + ) 31 + self.conn: asyncpg.Connection | None = None 32 + self.listener_task: asyncio.Task | None = None 33 + self.reconnect_delay = 5 # seconds 34 + 35 + async def setup(self) -> None: 36 + """initialize the queue service and start LISTEN task.""" 37 + logger.info("starting queue service") 38 + try: 39 + await self._connect() 40 + # start background listener 41 + self.listener_task = asyncio.create_task(self._listen_loop()) 42 + except Exception: 43 + logger.exception("failed to setup queue service") 44 + 45 + async def _connect(self) -> None: 46 + """establish asyncpg connection for LISTEN/NOTIFY.""" 47 + # parse connection string - asyncpg needs specific format 48 + db_url = settings.database_url 49 + # convert sqlalchemy URL to asyncpg format if needed 50 + if db_url.startswith("postgresql+asyncpg://"): 51 + db_url = db_url.replace("postgresql+asyncpg://", "postgresql://") 52 + elif db_url.startswith("postgresql+psycopg://"): 53 + db_url = db_url.replace("postgresql+psycopg://", "postgresql://") 54 + 55 + try: 56 + self.conn = await asyncpg.connect(db_url) 57 + await self.conn.add_listener("queue_changes", self._handle_notification) 58 + logger.info("queue service connected to database and listening") 59 + except Exception: 60 + logger.exception("failed to connect to database for queue listening") 61 + raise 62 + 63 + async def _listen_loop(self) -> None: 64 + """background task to maintain LISTEN connection with reconnection logic.""" 65 + while True: 66 + try: 67 + # if connection is None or closed, reconnect 68 + if self.conn is None or self.conn.is_closed(): 69 + logger.warning("queue listener connection lost, reconnecting...") 70 + await asyncio.sleep(self.reconnect_delay) 71 + await self._connect() 72 + 73 + # keep connection alive 74 + await asyncio.sleep(30) 75 + 76 + except asyncio.CancelledError: 77 + logger.info("queue listener task cancelled") 78 + break 79 + except Exception: 80 + logger.exception("error in queue listener loop") 81 + await asyncio.sleep(self.reconnect_delay) 82 + 83 + async def _handle_notification( 84 + self, conn: asyncpg.Connection, pid: int, channel: str, payload: str 85 + ) -> None: 86 + """handle queue change notifications.""" 87 + try: 88 + data = json.loads(payload) 89 + did = data.get("did") 90 + if did: 91 + logger.debug(f"received queue change notification for user {did}") 92 + # invalidate cache for this user 93 + self.cache.pop(did, None) 94 + except Exception: 95 + logger.exception(f"error handling queue notification: {payload}") 96 + 97 + async def get_queue( 98 + self, did: str 99 + ) -> tuple[dict[str, Any], int, list[dict[str, Any]]] | None: 100 + """get queue state for user, returns (state, revision, tracks) or None.""" 101 + # check cache first 102 + cached = self.cache.get(did) 103 + if cached: 104 + logger.debug(f"cache hit for queue {did}") 105 + return cached 106 + 107 + # fetch from database 108 + async with db_session() as db: 109 + stmt = select(QueueState).where(QueueState.did == did) 110 + result = await db.execute(stmt) 111 + queue_state = result.scalar_one_or_none() 112 + 113 + if queue_state: 114 + tracks = await self._hydrate_tracks( 115 + db, 116 + queue_state.state.get("track_ids", []), 117 + ) 118 + data = (queue_state.state, queue_state.revision, tracks) 119 + self.cache[did] = data 120 + return data 121 + 122 + return None 123 + 124 + async def update_queue( 125 + self, 126 + did: str, 127 + state: dict[str, Any], 128 + expected_revision: int | None = None, 129 + ) -> tuple[dict[str, Any], int, list[dict[str, Any]]] | None: 130 + """update queue state with optimistic locking. 131 + 132 + args: 133 + did: user DID 134 + state: new queue state 135 + expected_revision: expected current revision (for conflict detection) 136 + 137 + returns: 138 + (new_state, new_revision) on success, None on conflict 139 + """ 140 + async with db_session() as db: 141 + # fetch current state 142 + stmt = select(QueueState).where(QueueState.did == did) 143 + result = await db.execute(stmt) 144 + existing = result.scalar_one_or_none() 145 + 146 + if existing: 147 + # check for conflicts 148 + if ( 149 + expected_revision is not None 150 + and existing.revision != expected_revision 151 + ): 152 + logger.warning( 153 + f"queue update conflict for {did}: " 154 + f"expected {expected_revision}, got {existing.revision}" 155 + ) 156 + return None 157 + 158 + # update existing 159 + existing.state = state 160 + existing.revision += 1 161 + existing.updated_at = datetime.now(UTC) 162 + 163 + else: 164 + # create new 165 + existing = QueueState( 166 + did=did, 167 + state=state, 168 + revision=1, 169 + updated_at=datetime.now(UTC), 170 + ) 171 + db.add(existing) 172 + 173 + try: 174 + await db.commit() 175 + await db.refresh(existing) 176 + 177 + tracks = await self._hydrate_tracks( 178 + db, 179 + existing.state.get("track_ids", []), 180 + ) 181 + 182 + # notify other instances 183 + await self._notify_change(did) 184 + 185 + # update cache 186 + result_data = (existing.state, existing.revision, tracks) 187 + self.cache[did] = result_data 188 + 189 + return result_data 190 + 191 + except IntegrityError: 192 + await db.rollback() 193 + logger.exception(f"integrity error updating queue for {did}") 194 + return None 195 + 196 + async def _notify_change(self, did: str) -> None: 197 + """send NOTIFY to inform other instances of queue change.""" 198 + if not self.conn or self.conn.is_closed(): 199 + logger.warning("cannot send notification: no connection") 200 + return 201 + 202 + try: 203 + payload = json.dumps({"did": did}) 204 + await self.conn.execute(f"NOTIFY queue_changes, '{payload}'") 205 + except Exception: 206 + logger.exception(f"error sending queue change notification for {did}") 207 + 208 + async def shutdown(self) -> None: 209 + """cleanup resources.""" 210 + logger.info("shutting down queue service") 211 + 212 + # cancel listener task 213 + if self.listener_task: 214 + self.listener_task.cancel() 215 + with contextlib.suppress(asyncio.CancelledError): 216 + await self.listener_task 217 + 218 + # close connection 219 + if self.conn and not self.conn.is_closed(): 220 + try: 221 + await self.conn.remove_listener( 222 + "queue_changes", self._handle_notification 223 + ) 224 + await self.conn.close() 225 + except Exception: 226 + logger.exception("error closing queue service connection") 227 + 228 + self.cache.clear() 229 + 230 + async def _hydrate_tracks( 231 + self, 232 + db, 233 + track_ids: list[str], 234 + ) -> list[dict[str, Any]]: 235 + """fetch track metadata for queue display, preserving order.""" 236 + if not track_ids: 237 + return [] 238 + 239 + stmt = ( 240 + select(Track) 241 + .options(selectinload(Track.artist)) 242 + .where(Track.file_id.in_(track_ids)) 243 + ) 244 + result = await db.execute(stmt) 245 + tracks = result.scalars().all() 246 + track_by_file_id = {track.file_id: track for track in tracks} 247 + 248 + serialized: list[dict[str, Any]] = [] 249 + for file_id in track_ids: 250 + track = track_by_file_id.get(file_id) 251 + if not track: 252 + continue 253 + 254 + serialized.append( 255 + { 256 + "id": track.id, 257 + "title": track.title, 258 + "artist": track.artist.display_name 259 + if track.artist 260 + else track.artist_did, 261 + "artist_handle": track.artist.handle 262 + if track.artist 263 + else track.artist_did, 264 + "artist_avatar_url": track.artist.avatar_url 265 + if track.artist 266 + else None, 267 + "album": track.album, 268 + "file_id": track.file_id, 269 + "file_type": track.file_type, 270 + "features": track.features, 271 + "r2_url": track.r2_url, 272 + "atproto_record_uri": track.atproto_record_uri, 273 + "play_count": track.play_count, 274 + "created_at": track.created_at.isoformat(), 275 + } 276 + ) 277 + 278 + return serialized 279 + 280 + 281 + # global instance 282 + queue_service = QueueService()
+7
src/relay/api/CLAUDE.md
··· 1 + # api 2 + 3 + public HTTP endpoints for relay. 4 + 5 + - all endpoints except `/auth/*` require session authentication 6 + - OAuth 2.1 flow: authorize, callback, logout 7 + - main resources: tracks, artists, audio streaming, queue, preferences
+2
src/relay/api/__init__.py
··· 4 4 from relay.api.audio import router as audio_router 5 5 from relay.api.auth import router as auth_router 6 6 from relay.api.preferences import router as preferences_router 7 + from relay.api.queue import router as queue_router 7 8 from relay.api.search import router as search_router 8 9 from relay.api.tracks import router as tracks_router 9 10 ··· 12 13 "audio_router", 13 14 "auth_router", 14 15 "preferences_router", 16 + "queue_router", 15 17 "search_router", 16 18 "tracks_router", 17 19 ]
+3 -4
src/relay/api/artists.py
··· 5 5 from typing import Annotated 6 6 7 7 from fastapi import APIRouter, Depends, HTTPException 8 - from pydantic import BaseModel 8 + from pydantic import BaseModel, ConfigDict 9 9 from sqlalchemy import select 10 10 from sqlalchemy.ext.asyncio import AsyncSession 11 11 ··· 38 38 class ArtistResponse(BaseModel): 39 39 """artist profile response.""" 40 40 41 + model_config = ConfigDict(from_attributes=True) 42 + 41 43 did: str 42 44 handle: str 43 45 display_name: str ··· 45 47 avatar_url: str | None 46 48 created_at: datetime 47 49 updated_at: datetime 48 - 49 - class Config: 50 - from_attributes = True 51 50 52 51 53 52 # endpoints
+5
src/relay/api/audio.py
··· 25 25 return RedirectResponse(url=url) 26 26 27 27 # filesystem: serve file directly 28 + from relay.storage.filesystem import FilesystemStorage 29 + 30 + if not isinstance(storage, FilesystemStorage): 31 + raise HTTPException(status_code=500, detail="invalid storage backend") 32 + 28 33 file_path = storage.get_path(file_id) 29 34 30 35 if not file_path:
+15 -3
src/relay/api/preferences.py
··· 17 17 """user preferences response model.""" 18 18 19 19 accent_color: str 20 + auto_advance: bool 20 21 21 22 22 23 class PreferencesUpdate(BaseModel): 23 24 """user preferences update model.""" 24 25 25 26 accent_color: str | None = None 27 + auto_advance: bool | None = None 26 28 27 29 28 30 @router.get("/") ··· 43 45 await db.commit() 44 46 await db.refresh(prefs) 45 47 46 - return PreferencesResponse(accent_color=prefs.accent_color) 48 + return PreferencesResponse( 49 + accent_color=prefs.accent_color, auto_advance=prefs.auto_advance 50 + ) 47 51 48 52 49 53 @router.post("/") ··· 61 65 if not prefs: 62 66 # create new preferences 63 67 prefs = UserPreferences( 64 - did=session.did, accent_color=update.accent_color or "#6a9fff" 68 + did=session.did, 69 + accent_color=update.accent_color or "#6a9fff", 70 + auto_advance=update.auto_advance 71 + if update.auto_advance is not None 72 + else True, 65 73 ) 66 74 db.add(prefs) 67 75 else: 68 76 # update existing 69 77 if update.accent_color is not None: 70 78 prefs.accent_color = update.accent_color 79 + if update.auto_advance is not None: 80 + prefs.auto_advance = update.auto_advance 71 81 72 82 await db.commit() 73 83 await db.refresh(prefs) 74 84 75 - return PreferencesResponse(accent_color=prefs.accent_color) 85 + return PreferencesResponse( 86 + accent_color=prefs.accent_color, auto_advance=prefs.auto_advance 87 + )
+95
src/relay/api/queue.py
··· 1 + """queue api endpoints.""" 2 + 3 + from typing import Annotated, Any 4 + 5 + from fastapi import APIRouter, Depends, Header, HTTPException, Response 6 + from pydantic import BaseModel, Field 7 + 8 + from relay._internal import Session, queue_service, require_auth 9 + 10 + router = APIRouter(prefix="/queue", tags=["queue"]) 11 + 12 + 13 + class QueueResponse(BaseModel): 14 + """queue state response model.""" 15 + 16 + state: dict[str, Any] 17 + revision: int 18 + tracks: list[dict[str, Any]] = Field(default_factory=list) 19 + 20 + 21 + class QueueUpdate(BaseModel): 22 + """queue state update model.""" 23 + 24 + state: dict[str, Any] 25 + 26 + 27 + @router.get("/") 28 + async def get_queue( 29 + response: Response, 30 + session: Session = Depends(require_auth), 31 + ) -> QueueResponse: 32 + """get current queue state with ETag for caching.""" 33 + result = await queue_service.get_queue(session.did) 34 + 35 + if result is None: 36 + # return empty queue 37 + state = { 38 + "track_ids": [], 39 + "current_index": 0, 40 + "current_track_id": None, 41 + "shuffle": False, 42 + "repeat_mode": "none", 43 + "original_order_ids": [], 44 + } 45 + revision = 0 46 + response.headers["ETag"] = f'"{revision}"' 47 + return QueueResponse(state=state, revision=revision, tracks=[]) 48 + 49 + state, revision, tracks = result 50 + # set ETag header for client caching 51 + response.headers["ETag"] = f'"{revision}"' 52 + 53 + return QueueResponse(state=state, revision=revision, tracks=tracks) 54 + 55 + 56 + @router.put("/") 57 + async def update_queue( 58 + update: QueueUpdate, 59 + session: Session = Depends(require_auth), 60 + if_match: Annotated[str | None, Header()] = None, 61 + ) -> QueueResponse: 62 + """update queue state with optimistic locking via If-Match header. 63 + 64 + the If-Match header should contain the expected revision number (as ETag). 65 + if there's a conflict (revision mismatch), returns 409. 66 + """ 67 + # parse expected revision from If-Match header 68 + expected_revision: int | None = None 69 + if if_match: 70 + # strip quotes from ETag format: "123" -> 123 71 + try: 72 + expected_revision = int(if_match.strip('"')) 73 + except ValueError: 74 + raise HTTPException( 75 + status_code=400, 76 + detail="invalid If-Match header format (expected quoted integer)", 77 + ) from None 78 + 79 + # update with conflict detection 80 + result = await queue_service.update_queue( 81 + did=session.did, 82 + state=update.state, 83 + expected_revision=expected_revision, 84 + ) 85 + 86 + if result is None: 87 + # conflict detected 88 + raise HTTPException( 89 + status_code=409, 90 + detail="queue state conflict: state has been modified by another client. " 91 + "please fetch the latest state and retry.", 92 + ) 93 + 94 + state, revision, tracks = result 95 + return QueueResponse(state=state, revision=revision, tracks=tracks)
+5 -1
src/relay/main.py
··· 9 9 from fastapi import FastAPI 10 10 from fastapi.middleware.cors import CORSMiddleware 11 11 12 - from relay._internal import notification_service 12 + from relay._internal import notification_service, queue_service 13 13 from relay._internal.auth import _state_store 14 14 from relay.api import ( 15 15 artists_router, 16 16 audio_router, 17 17 auth_router, 18 18 preferences_router, 19 + queue_router, 19 20 search_router, 20 21 tracks_router, 21 22 ) ··· 42 43 async def run_periodic_tasks(): 43 44 """run periodic background tasks.""" 44 45 await notification_service.setup() 46 + await queue_service.setup() 45 47 46 48 while True: 47 49 try: ··· 71 73 with contextlib.suppress(asyncio.CancelledError): 72 74 await task 73 75 await notification_service.shutdown() 76 + await queue_service.shutdown() 74 77 75 78 76 79 app = FastAPI( ··· 99 102 app.include_router(audio_router) 100 103 app.include_router(search_router) 101 104 app.include_router(preferences_router) 105 + app.include_router(queue_router) 102 106 103 107 104 108 @app.get("/health")
+2
src/relay/models/__init__.py
··· 6 6 from relay.models.exchange_token import ExchangeToken 7 7 from relay.models.oauth_state import OAuthStateModel 8 8 from relay.models.preferences import UserPreferences 9 + from relay.models.queue import QueueState 9 10 from relay.models.session import UserSession 10 11 from relay.models.track import Track 11 12 from relay.utilities.database import db_session, get_db, init_db ··· 16 17 "Base", 17 18 "ExchangeToken", 18 19 "OAuthStateModel", 20 + "QueueState", 19 21 "Track", 20 22 "UserPreferences", 21 23 "UserSession",
+4 -1
src/relay/models/preferences.py
··· 2 2 3 3 from datetime import UTC, datetime 4 4 5 - from sqlalchemy import DateTime, String 5 + from sqlalchemy import Boolean, DateTime, String, text 6 6 from sqlalchemy.orm import Mapped, mapped_column 7 7 8 8 from relay.models.database import Base ··· 18 18 19 19 # ui preferences 20 20 accent_color: Mapped[str] = mapped_column(String, nullable=False, default="#6a9fff") 21 + auto_advance: Mapped[bool] = mapped_column( 22 + Boolean, nullable=False, default=True, server_default=text("true") 23 + ) 21 24 22 25 # metadata 23 26 created_at: Mapped[datetime] = mapped_column(
+25
src/relay/models/queue.py
··· 1 + """queue state model.""" 2 + 3 + from datetime import UTC, datetime 4 + 5 + from sqlalchemy import BigInteger, DateTime, String 6 + from sqlalchemy.dialects.postgresql import JSON 7 + from sqlalchemy.orm import Mapped, mapped_column 8 + 9 + from relay.models.database import Base 10 + 11 + 12 + class QueueState(Base): 13 + """queue state model with revision tracking for optimistic concurrency control.""" 14 + 15 + __tablename__ = "queue_state" 16 + 17 + did: Mapped[str] = mapped_column(String, primary_key=True, index=True) 18 + state: Mapped[dict] = mapped_column(JSON, nullable=False) 19 + revision: Mapped[int] = mapped_column(BigInteger, nullable=False, default=1) 20 + updated_at: Mapped[datetime] = mapped_column( 21 + DateTime(timezone=True), 22 + default=lambda: datetime.now(UTC), 23 + nullable=False, 24 + index=True, 25 + )
+6 -12
src/relay/utilities/database.py
··· 61 61 return ENGINES[cache_key] 62 62 63 63 64 - async def get_db() -> AsyncGenerator[AsyncSession, None]: 65 - """get async database session (for FastAPI dependency injection).""" 64 + @asynccontextmanager 65 + async def db_session() -> AsyncGenerator[AsyncSession, None]: 66 + """get async database session.""" 66 67 engine = get_engine() 67 68 async_session_maker = sessionmaker( 68 69 bind=engine, ··· 73 74 yield session 74 75 75 76 76 - @asynccontextmanager 77 - async def db_session() -> AsyncGenerator[AsyncSession, None]: 78 - """get async database session (for manual async with usage).""" 79 - engine = get_engine() 80 - async_session_maker = sessionmaker( 81 - bind=engine, 82 - class_=AsyncSession, 83 - expire_on_commit=False, 84 - ) 85 - async with async_session_maker() as session: 77 + async def get_db() -> AsyncGenerator[AsyncSession, None]: 78 + """get async database session (for FastAPI dependency injection).""" 79 + async with db_session() as session: 86 80 yield session 87 81 88 82
+5
tests/CLAUDE.md
··· 1 + # tests 2 + 3 + - NEVER use `@pytest.mark.asyncio` - pytest is configured with `asyncio_mode = "auto"` 4 + - all fixtures and test parameters must be type hinted 5 + - `just test` runs tests with isolated PostgreSQL databases per worker
+1
tests/api/__init__.py
··· 1 + """api tests."""
+296
tests/api/test_queue.py
··· 1 + """tests for queue api endpoints.""" 2 + 3 + from collections.abc import Generator 4 + 5 + import pytest 6 + from fastapi import FastAPI 7 + from httpx import ASGITransport, AsyncClient 8 + from sqlalchemy.ext.asyncio import AsyncSession 9 + 10 + from relay._internal import Session, queue_service 11 + from relay.main import app 12 + 13 + 14 + # create a mock session object 15 + class MockSession(Session): 16 + """mock session for auth bypass in tests.""" 17 + 18 + def __init__(self, did: str = "did:test:user123"): 19 + self.did = did 20 + self.access_token = "test_token" 21 + self.refresh_token = "test_refresh" 22 + 23 + 24 + @pytest.fixture 25 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 26 + """create test app with mocked auth.""" 27 + from relay._internal import require_auth 28 + 29 + # mock the auth dependency to return a mock session 30 + async def mock_require_auth() -> Session: 31 + return MockSession() 32 + 33 + # override the auth dependency 34 + app.dependency_overrides[require_auth] = mock_require_auth 35 + 36 + # clear the queue service cache before each test 37 + # the queue_service is a singleton that persists state across tests 38 + queue_service.cache.clear() 39 + 40 + yield app 41 + 42 + # cleanup 43 + app.dependency_overrides.clear() 44 + # clear cache after test too 45 + queue_service.cache.clear() 46 + 47 + 48 + async def test_get_queue_empty_state(test_app: FastAPI, db_session: AsyncSession): 49 + """test GET /queue returns empty state for new user.""" 50 + async with AsyncClient( 51 + transport=ASGITransport(app=test_app), base_url="http://test" 52 + ) as client: 53 + response = await client.get("/queue/") 54 + 55 + assert response.status_code == 200 56 + data = response.json() 57 + 58 + # verify empty queue structure 59 + assert data["state"]["track_ids"] == [] 60 + assert data["state"]["current_index"] == 0 61 + assert data["state"]["current_track_id"] is None 62 + assert data["state"]["shuffle"] is False 63 + assert data["state"]["repeat_mode"] == "none" 64 + assert data["state"]["original_order_ids"] == [] 65 + assert data["revision"] == 0 66 + assert data["tracks"] == [] 67 + 68 + # verify ETag header 69 + assert response.headers["etag"] == '"0"' 70 + 71 + 72 + async def test_put_queue_creates_new_state(test_app: FastAPI, db_session: AsyncSession): 73 + """test PUT /queue creates new queue state.""" 74 + new_state = { 75 + "track_ids": ["track1", "track2", "track3"], 76 + "current_index": 1, 77 + "current_track_id": "track2", 78 + "shuffle": True, 79 + "repeat_mode": "all", 80 + "original_order_ids": ["track1", "track2", "track3"], 81 + } 82 + 83 + async with AsyncClient( 84 + transport=ASGITransport(app=test_app), base_url="http://test" 85 + ) as client: 86 + response = await client.put("/queue/", json={"state": new_state}) 87 + 88 + assert response.status_code == 200 89 + data = response.json() 90 + 91 + assert data["state"] == new_state 92 + assert data["revision"] == 1 # first update should be revision 1 93 + assert data["tracks"] == [] 94 + 95 + 96 + async def test_get_queue_returns_updated_state( 97 + test_app: FastAPI, db_session: AsyncSession 98 + ): 99 + """test GET /queue returns previously saved state.""" 100 + # first, create a queue 101 + new_state = { 102 + "track_ids": ["track1", "track2"], 103 + "current_index": 0, 104 + "current_track_id": "track1", 105 + "shuffle": False, 106 + "repeat_mode": "one", 107 + "original_order_ids": ["track1", "track2"], 108 + } 109 + 110 + async with AsyncClient( 111 + transport=ASGITransport(app=test_app), base_url="http://test" 112 + ) as client: 113 + put_response = await client.put("/queue/", json={"state": new_state}) 114 + assert put_response.status_code == 200 115 + put_revision = put_response.json()["revision"] 116 + 117 + # now get it back 118 + get_response = await client.get("/queue/") 119 + 120 + assert get_response.status_code == 200 121 + data = get_response.json() 122 + 123 + assert data["state"] == new_state 124 + assert data["revision"] == put_revision 125 + assert data["tracks"] == [] 126 + 127 + # verify ETag matches revision 128 + assert get_response.headers["etag"] == f'"{put_revision}"' 129 + 130 + 131 + async def test_put_queue_with_matching_revision_succeeds( 132 + test_app: FastAPI, db_session: AsyncSession 133 + ): 134 + """test PUT /queue with correct If-Match header succeeds.""" 135 + # create initial state 136 + initial_state = { 137 + "track_ids": ["track1"], 138 + "current_index": 0, 139 + "current_track_id": "track1", 140 + "shuffle": False, 141 + "repeat_mode": "none", 142 + "original_order_ids": ["track1"], 143 + } 144 + 145 + async with AsyncClient( 146 + transport=ASGITransport(app=test_app), base_url="http://test" 147 + ) as client: 148 + # create initial state 149 + response1 = await client.put("/queue/", json={"state": initial_state}) 150 + assert response1.status_code == 200 151 + revision1 = response1.json()["revision"] 152 + 153 + # update with matching revision 154 + updated_state = { 155 + **initial_state, 156 + "track_ids": ["track1", "track2"], 157 + } 158 + response2 = await client.put( 159 + "/queue/", 160 + json={"state": updated_state}, 161 + headers={"If-Match": f'"{revision1}"'}, 162 + ) 163 + 164 + assert response2.status_code == 200 165 + data = response2.json() 166 + assert data["state"]["track_ids"] == ["track1", "track2"] 167 + assert data["revision"] == revision1 + 1 168 + assert data["tracks"] == [] 169 + 170 + 171 + async def test_put_queue_with_mismatched_revision_fails( 172 + test_app: FastAPI, db_session: AsyncSession 173 + ): 174 + """test PUT /queue with wrong If-Match header returns 409 conflict.""" 175 + # create initial state 176 + initial_state = { 177 + "track_ids": ["track1"], 178 + "current_index": 0, 179 + "current_track_id": "track1", 180 + "shuffle": False, 181 + "repeat_mode": "none", 182 + "original_order_ids": ["track1"], 183 + } 184 + 185 + async with AsyncClient( 186 + transport=ASGITransport(app=test_app), base_url="http://test" 187 + ) as client: 188 + # create initial state 189 + response1 = await client.put("/queue/", json={"state": initial_state}) 190 + assert response1.status_code == 200 191 + 192 + # try to update with wrong revision 193 + updated_state = { 194 + **initial_state, 195 + "track_ids": ["track1", "track2"], 196 + } 197 + response2 = await client.put( 198 + "/queue/", 199 + json={"state": updated_state}, 200 + headers={"If-Match": '"999"'}, # wrong revision 201 + ) 202 + 203 + assert response2.status_code == 409 204 + assert "conflict" in response2.json()["detail"].lower() 205 + 206 + 207 + async def test_put_queue_without_if_match_always_succeeds( 208 + test_app: FastAPI, db_session: AsyncSession 209 + ): 210 + """test PUT /queue without If-Match header always updates (no conflict check).""" 211 + initial_state = { 212 + "track_ids": ["track1"], 213 + "current_index": 0, 214 + "current_track_id": "track1", 215 + "shuffle": False, 216 + "repeat_mode": "none", 217 + "original_order_ids": ["track1"], 218 + } 219 + 220 + async with AsyncClient( 221 + transport=ASGITransport(app=test_app), base_url="http://test" 222 + ) as client: 223 + # create initial state 224 + response1 = await client.put("/queue/", json={"state": initial_state}) 225 + assert response1.status_code == 200 226 + 227 + # update without If-Match (should always succeed) 228 + updated_state = { 229 + **initial_state, 230 + "track_ids": ["track1", "track2", "track3"], 231 + } 232 + response2 = await client.put("/queue/", json={"state": updated_state}) 233 + 234 + assert response2.status_code == 200 235 + data = response2.json() 236 + assert data["state"]["track_ids"] == ["track1", "track2", "track3"] 237 + 238 + 239 + async def test_queue_state_isolated_by_did(test_app: FastAPI, db_session: AsyncSession): 240 + """test that different users have isolated queue states.""" 241 + from relay._internal import require_auth 242 + 243 + # user 1 244 + async def mock_user1_auth() -> Session: 245 + return MockSession(did="did:test:user1") 246 + 247 + app.dependency_overrides[require_auth] = mock_user1_auth 248 + 249 + user1_state = { 250 + "track_ids": ["user1_track1"], 251 + "current_index": 0, 252 + "current_track_id": "user1_track1", 253 + "shuffle": False, 254 + "repeat_mode": "none", 255 + "original_order_ids": ["user1_track1"], 256 + } 257 + 258 + async with AsyncClient( 259 + transport=ASGITransport(app=test_app), base_url="http://test" 260 + ) as client: 261 + response1 = await client.put("/queue/", json={"state": user1_state}) 262 + assert response1.status_code == 200 263 + 264 + # user 2 265 + async def mock_user2_auth() -> Session: 266 + return MockSession(did="did:test:user2") 267 + 268 + app.dependency_overrides[require_auth] = mock_user2_auth 269 + 270 + user2_state = { 271 + "track_ids": ["user2_track1"], 272 + "current_index": 0, 273 + "current_track_id": "user2_track1", 274 + "shuffle": False, 275 + "repeat_mode": "none", 276 + "original_order_ids": ["user2_track1"], 277 + } 278 + 279 + async with AsyncClient( 280 + transport=ASGITransport(app=test_app), base_url="http://test" 281 + ) as client: 282 + response2 = await client.put("/queue/", json={"state": user2_state}) 283 + assert response2.status_code == 200 284 + 285 + # verify user2 sees only their state 286 + get_response = await client.get("/queue/") 287 + assert get_response.json()["state"]["track_ids"] == ["user2_track1"] 288 + 289 + # switch back to user 1 and verify their state persisted 290 + app.dependency_overrides[require_auth] = mock_user1_auth 291 + 292 + async with AsyncClient( 293 + transport=ASGITransport(app=test_app), base_url="http://test" 294 + ) as client: 295 + get_response = await client.get("/queue/") 296 + assert get_response.json()["state"]["track_ids"] == ["user1_track1"]
+6 -16
tests/conftest.py
··· 14 14 ) 15 15 from sqlalchemy.orm import sessionmaker 16 16 17 + from relay.config import settings 17 18 from relay.models import Base 18 19 19 20 20 21 @asynccontextmanager 21 22 async def session_context(engine: AsyncEngine) -> AsyncGenerator[AsyncSession, None]: 22 - """create a database session context, matching Nebula's pattern.""" 23 + """create a database session context.""" 23 24 async_session_maker = sessionmaker( 24 25 bind=engine, 25 26 class_=AsyncSession, ··· 34 35 ) -> None: 35 36 """creates a stored procedure in the test database used for quickly clearing 36 37 the database between tests. 37 - 38 - this follows the pattern used in Nebula - delete only data created after test start. 39 38 """ 40 - # get all tables in dependency order (reversed for deletion) 41 39 tables = list(reversed(Base.metadata.sorted_tables)) 42 40 43 41 def schema(table: sa.Table) -> str: ··· 100 98 def test_database_url(worker_id: str) -> str: 101 99 """generate a unique test database URL for each pytest worker. 102 100 103 - uses port 5433 which maps to the test database in tests/docker-compose.yml. 101 + reads from settings.database_url and appends worker suffix if needed. 104 102 """ 105 - base_url = "postgresql+asyncpg://relay_test:relay_test@localhost:5433/relay_test" 103 + base_url = settings.database_url 106 104 107 105 # for parallel test execution, append worker id to database name 108 106 if worker_id == "master": ··· 124 122 125 123 126 124 @pytest.fixture(scope="session") 127 - def _database_setup(test_database_url: str): 125 + def _database_setup(test_database_url: str) -> None: 128 126 """create tables and stored procedures once per test session.""" 129 127 import asyncio 130 128 131 129 asyncio.run(_setup_database(test_database_url)) 132 - try: 133 - yield 134 - finally: 135 - # nothing to tear down; procedure is idempotent and tables persist for session 136 - pass 137 130 138 131 139 132 @pytest.fixture() ··· 147 140 148 141 @pytest.fixture() 149 142 async def _clear_db(_engine: AsyncEngine) -> AsyncGenerator[None, None]: 150 - """clear the database after each test. 151 - 152 - this fixture must be yielded before the session fixture to prevent locking. 153 - """ 143 + """clear the database after each test.""" 154 144 start_time = datetime.now(UTC) 155 145 156 146 try:
+11
uv.lock
··· 226 226 ] 227 227 228 228 [[package]] 229 + name = "cachetools" 230 + version = "6.2.1" 231 + source = { registry = "https://pypi.org/simple" } 232 + sdist = { url = "https://files.pythonhosted.org/packages/cc/7e/b975b5814bd36faf009faebe22c1072a1fa1168db34d285ef0ba071ad78c/cachetools-6.2.1.tar.gz", hash = "sha256:3f391e4bd8f8bf0931169baf7456cc822705f4e2a31f840d218f445b9a854201", size = 31325, upload-time = "2025-10-12T14:55:30.139Z" } 233 + wheels = [ 234 + { url = "https://files.pythonhosted.org/packages/96/c5/1e741d26306c42e2bf6ab740b2202872727e0f606033c9dd713f8b93f5a8/cachetools-6.2.1-py3-none-any.whl", hash = "sha256:09868944b6dde876dfd44e1d47e18484541eaf12f26f29b7af91b26cc892d701", size = 11280, upload-time = "2025-10-12T14:55:28.382Z" }, 235 + ] 236 + 237 + [[package]] 229 238 name = "certifi" 230 239 version = "2025.10.5" 231 240 source = { registry = "https://pypi.org/simple" } ··· 1885 1894 { name = "asyncpg" }, 1886 1895 { name = "atproto" }, 1887 1896 { name = "boto3" }, 1897 + { name = "cachetools" }, 1888 1898 { name = "fastapi" }, 1889 1899 { name = "greenlet" }, 1890 1900 { name = "httpx" }, ··· 1924 1934 { name = "asyncpg", specifier = ">=0.30.0" }, 1925 1935 { name = "atproto", git = "https://github.com/zzstoatzz/atproto?rev=main" }, 1926 1936 { name = "boto3", specifier = ">=1.37.0" }, 1937 + { name = "cachetools", specifier = ">=6.2.1" }, 1927 1938 { name = "fastapi", specifier = ">=0.115.0" }, 1928 1939 { name = "greenlet", specifier = ">=3.2.4" }, 1929 1940 { name = "httpx", specifier = ">=0.28.0" },