+21
-28
CLAUDE.md
+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
+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
+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
+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
+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
+6
frontend/CLAUDE.md
+1394
frontend/package-lock.json
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+8
src/relay/_internal/CLAUDE.md
+2
src/relay/_internal/__init__.py
+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
+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
+7
src/relay/api/CLAUDE.md
+2
src/relay/api/__init__.py
+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
+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
+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
+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
+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
+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
+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
+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
+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
+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
+5
tests/CLAUDE.md
+1
tests/api/__init__.py
+1
tests/api/__init__.py
···
1
+
"""api tests."""
+296
tests/api/test_queue.py
+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
+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
+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" },