+65
-22
.claude/commands/status-update.md
+65
-22
.claude/commands/status-update.md
···
1
1
# status update
2
2
3
-
update STATUS.md after completing significant work.
3
+
update STATUS.md to reflect recent work.
4
4
5
-
## when to update
5
+
## workflow
6
+
7
+
### 1. understand current state
6
8
7
-
after shipping something notable:
8
-
- new features or endpoints
9
-
- bug fixes worth documenting
10
-
- architectural changes
11
-
- deployment/infrastructure changes
12
-
- incidents and their resolutions
9
+
read STATUS.md to understand:
10
+
- what's already documented in `## recent work`
11
+
- the last update date (noted at the bottom)
12
+
- current priorities and known issues
13
+
14
+
### 2. find undocumented work
15
+
16
+
```bash
17
+
# find the last STATUS.md update
18
+
git log --oneline -1 -- STATUS.md
19
+
20
+
# get all commits since then
21
+
git log --oneline <last-status-commit>..HEAD
22
+
```
23
+
24
+
for each significant commit or PR:
25
+
- read the commit message and changed files
26
+
- understand WHY the change was made, not just what changed
27
+
- note architectural decisions, trade-offs, or lessons learned
28
+
29
+
### 3. decide what to document
30
+
31
+
not everything needs documentation. focus on:
32
+
- **features**: new capabilities users or developers can use
33
+
- **fixes**: bugs that affected users, especially if they might recur
34
+
- **architecture**: changes to how systems connect or data flows
35
+
- **decisions**: trade-offs made and why (future readers need context)
36
+
- **incidents**: what broke, why, and how it was resolved
37
+
38
+
skip:
39
+
- routine maintenance (dependency bumps, typo fixes)
40
+
- work-in-progress that didn't ship
41
+
- changes already well-documented in the PR
42
+
43
+
### 4. write the update
44
+
45
+
add a new subsection under `## recent work` following existing patterns:
13
46
14
-
**tip**: after running `/deploy`, consider running `/status-update` to document what shipped.
47
+
```markdown
48
+
#### brief title (PRs #NNN, date)
15
49
16
-
## how to update
50
+
**why**: the problem or motivation (1-2 sentences)
17
51
18
-
1. add a new subsection under `## recent work` with today's date
19
-
2. describe what shipped, why it matters, and any relevant PR numbers
20
-
3. update `## immediate priorities` if priorities changed
21
-
4. update `## technical state` if architecture changed
52
+
**what shipped**:
53
+
- concrete changes users or developers will notice
54
+
- link to relevant docs if applicable
22
55
23
-
## structure
56
+
**technical notes** (if architectural):
57
+
- decisions made and why
58
+
- trade-offs accepted
59
+
```
24
60
25
-
STATUS.md follows this structure:
26
-
- **long-term vision** - why the project exists
27
-
- **recent work** - chronological log of what shipped (newest first)
28
-
- **immediate priorities** - what's next
29
-
- **technical state** - architecture, what's working, known issues
61
+
### 5. update other sections if needed
30
62
31
-
old content is automatically archived to `.status_history/` - you don't need to manage this.
63
+
- `## priorities` - if focus has shifted
64
+
- `## known issues` - if bugs were fixed or discovered
65
+
- `## technical state` - if architecture changed
32
66
33
67
## tone
34
68
35
-
direct, technical, honest about limitations. useful for someone with no prior context.
69
+
write for someone with no prior context who needs to understand:
70
+
- what changed
71
+
- why it matters
72
+
- why this approach was chosen over alternatives
73
+
74
+
be direct and technical. avoid marketing language.
75
+
76
+
## after updating
77
+
78
+
commit the STATUS.md changes and open a PR for review.
+22
-1
STATUS.md
+22
-1
STATUS.md
···
79
79
80
80
---
81
81
82
+
#### auth stabilization (PRs #734-736, Jan 6-7)
83
+
84
+
**why**: multi-account support introduced edge cases where auth state could become inconsistent between frontend components, and sessions could outlive their refresh tokens.
85
+
86
+
**session expiry alignment** (PR #734):
87
+
- sessions now track refresh token lifetime and respect it during validation
88
+
- prevents sessions from appearing valid after their underlying OAuth grant expires
89
+
- dev token expiration handling aligned with same pattern
90
+
91
+
**queue auth boundary fix** (PR #735):
92
+
- queue component now uses shared layout auth state instead of localStorage session IDs
93
+
- fixes race condition where queue could attempt authenticated requests before layout resolved auth
94
+
- ensures remote queue snapshots don't inherit local update flags during hydration
95
+
96
+
**playlist cover upload fix** (PR #736):
97
+
- `R2Storage.save()` was rejecting `BytesIO` objects due to beartype's strict `BinaryIO` protocol checking
98
+
- changed type hint to `BinaryIO | BytesIO` to explicitly accept both
99
+
- found via Logfire: only 2 failures in production, both on Jan 3
100
+
101
+
---
102
+
82
103
#### artist bio links (PRs #700-701, Jan 2)
83
104
84
105
**links in artist bios now render as clickable** - supports full URLs and bare domains (e.g., "example.com"):
···
347
368
348
369
---
349
370
350
-
this is a living document. last updated 2026-01-06.
371
+
this is a living document. last updated 2026-01-07.
+15
backend/src/backend/_internal/atproto/client.py
+15
backend/src/backend/_internal/atproto/client.py
···
3
3
import asyncio
4
4
import json
5
5
import logging
6
+
from datetime import UTC, datetime, timedelta
6
7
from typing import Any
7
8
8
9
from atproto_oauth.models import OAuthSession
···
10
11
11
12
from backend._internal import Session as AuthSession
12
13
from backend._internal import get_oauth_client, get_session, update_session_tokens
14
+
from backend._internal.auth import (
15
+
get_client_auth_method,
16
+
get_refresh_token_lifetime_days,
17
+
)
13
18
14
19
logger = logging.getLogger(__name__)
15
20
···
123
128
"dpop_authserver_nonce": refreshed_session.dpop_authserver_nonce,
124
129
"dpop_pds_nonce": refreshed_session.dpop_pds_nonce or "",
125
130
}
131
+
client_auth_method = get_client_auth_method(updated_oauth_data)
132
+
refresh_lifetime_days = get_refresh_token_lifetime_days(client_auth_method)
133
+
refresh_expires_at = datetime.now(UTC) + timedelta(
134
+
days=refresh_lifetime_days
135
+
)
136
+
updated_session_data["client_auth_method"] = client_auth_method
137
+
updated_session_data["refresh_token_lifetime_days"] = refresh_lifetime_days
138
+
updated_session_data["refresh_token_expires_at"] = (
139
+
refresh_expires_at.isoformat()
140
+
)
126
141
127
142
# update session in database
128
143
await update_session_tokens(session_id, updated_session_data)
+113
-9
backend/src/backend/_internal/auth.py
+113
-9
backend/src/backend/_internal/auth.py
···
25
25
26
26
logger = logging.getLogger(__name__)
27
27
28
+
PUBLIC_REFRESH_TOKEN_DAYS = 14
29
+
CONFIDENTIAL_REFRESH_TOKEN_DAYS = 180
30
+
28
31
29
32
def _parse_scopes(scope_string: str) -> set[str]:
30
33
"""parse an OAuth scope string into a set of individual scopes.
···
163
166
return bool(settings.atproto.oauth_jwk)
164
167
165
168
169
+
def get_client_auth_method(oauth_session_data: dict[str, Any] | None = None) -> str:
170
+
"""resolve client auth method for a session."""
171
+
if oauth_session_data:
172
+
method = oauth_session_data.get("client_auth_method")
173
+
if method in {"public", "confidential"}:
174
+
return method
175
+
return "confidential" if is_confidential_client() else "public"
176
+
177
+
178
+
def get_refresh_token_lifetime_days(client_auth_method: str | None) -> int:
179
+
"""get expected refresh token lifetime in days."""
180
+
method = client_auth_method or get_client_auth_method()
181
+
return (
182
+
CONFIDENTIAL_REFRESH_TOKEN_DAYS
183
+
if method == "confidential"
184
+
else PUBLIC_REFRESH_TOKEN_DAYS
185
+
)
186
+
187
+
188
+
def _compute_refresh_token_expires_at(
189
+
now: datetime, client_auth_method: str | None
190
+
) -> datetime:
191
+
"""compute refresh token expiration time."""
192
+
return now + timedelta(days=get_refresh_token_lifetime_days(client_auth_method))
193
+
194
+
195
+
def _parse_datetime(value: str | None) -> datetime | None:
196
+
"""parse ISO datetime string safely."""
197
+
if not value:
198
+
return None
199
+
try:
200
+
return datetime.fromisoformat(value)
201
+
except ValueError:
202
+
return None
203
+
204
+
205
+
def _get_refresh_token_expires_at(
206
+
user_session: UserSession,
207
+
oauth_session_data: dict[str, Any],
208
+
) -> datetime | None:
209
+
"""determine refresh token expiry for a session."""
210
+
parsed = _parse_datetime(oauth_session_data.get("refresh_token_expires_at"))
211
+
if parsed:
212
+
return parsed
213
+
214
+
client_auth_method = oauth_session_data.get("client_auth_method")
215
+
if client_auth_method:
216
+
return user_session.created_at + timedelta(
217
+
days=get_refresh_token_lifetime_days(client_auth_method)
218
+
)
219
+
220
+
if user_session.is_developer_token:
221
+
return user_session.created_at + timedelta(days=PUBLIC_REFRESH_TOKEN_DAYS)
222
+
223
+
return None
224
+
225
+
166
226
def get_oauth_client(include_teal: bool = False) -> OAuthClient:
167
227
"""create an OAuth client with the appropriate scopes.
168
228
···
249
309
did: user's decentralized identifier
250
310
handle: user's ATProto handle
251
311
oauth_session: OAuth session data to encrypt and store
252
-
expires_in_days: session expiration in days (default 14, use 0 for no expiration)
312
+
expires_in_days: session expiration in days (default 14, capped by refresh lifetime)
253
313
is_developer_token: whether this is a developer token (for listing/revocation)
254
314
token_name: optional name for the token (only for developer tokens)
255
315
group_id: optional session group ID for multi-account support
256
316
"""
257
317
session_id = secrets.token_urlsafe(32)
318
+
now = datetime.now(UTC)
258
319
259
-
encrypted_data = _encrypt_data(json.dumps(oauth_session))
320
+
client_auth_method = get_client_auth_method(oauth_session)
321
+
refresh_lifetime_days = get_refresh_token_lifetime_days(client_auth_method)
322
+
refresh_expires_at = _compute_refresh_token_expires_at(now, client_auth_method)
260
323
261
-
expires_at = (
262
-
datetime.now(UTC) + timedelta(days=expires_in_days)
263
-
if expires_in_days > 0
264
-
else None
324
+
oauth_session = dict(oauth_session)
325
+
oauth_session.setdefault("client_auth_method", client_auth_method)
326
+
oauth_session.setdefault("refresh_token_lifetime_days", refresh_lifetime_days)
327
+
oauth_session.setdefault("refresh_token_expires_at", refresh_expires_at.isoformat())
328
+
329
+
effective_days = (
330
+
refresh_lifetime_days
331
+
if expires_in_days <= 0
332
+
else min(expires_in_days, refresh_lifetime_days)
265
333
)
334
+
expires_at = now + timedelta(days=effective_days)
335
+
336
+
encrypted_data = _encrypt_data(json.dumps(oauth_session))
266
337
267
338
async with db_session() as db:
268
339
user_session = UserSession(
···
301
372
if decrypted_data is None:
302
373
# decryption failed - session is invalid (key changed or data corrupted)
303
374
# delete the corrupted session
375
+
await delete_session(session_id)
376
+
return None
377
+
378
+
oauth_session_data = json.loads(decrypted_data)
379
+
380
+
refresh_expires_at = _get_refresh_token_expires_at(
381
+
user_session, oauth_session_data
382
+
)
383
+
if refresh_expires_at and datetime.now(UTC) > refresh_expires_at:
304
384
await delete_session(session_id)
305
385
return None
306
386
···
308
388
session_id=user_session.session_id,
309
389
did=user_session.did,
310
390
handle=user_session.handle,
311
-
oauth_session=json.loads(decrypted_data),
391
+
oauth_session=oauth_session_data,
312
392
)
313
393
314
394
···
445
525
encryption_algorithm=serialization.NoEncryption(),
446
526
).decode("utf-8")
447
527
528
+
client_auth_method = get_client_auth_method()
529
+
refresh_lifetime_days = get_refresh_token_lifetime_days(client_auth_method)
530
+
refresh_expires_at = _compute_refresh_token_expires_at(
531
+
datetime.now(UTC), client_auth_method
532
+
)
533
+
448
534
# store full OAuth session with tokens in database
449
535
session_data = {
450
536
"did": oauth_session.did,
···
457
543
"dpop_private_key_pem": dpop_key_pem,
458
544
"dpop_authserver_nonce": oauth_session.dpop_authserver_nonce,
459
545
"dpop_pds_nonce": oauth_session.dpop_pds_nonce or "",
546
+
"client_auth_method": client_auth_method,
547
+
"refresh_token_lifetime_days": refresh_lifetime_days,
548
+
"refresh_token_expires_at": refresh_expires_at.isoformat(),
460
549
}
461
550
return oauth_session.did, oauth_session.handle, session_data
462
551
except Exception as e:
···
658
747
sessions = result.scalars().all()
659
748
660
749
tokens = []
750
+
now = datetime.now(UTC)
661
751
for session in sessions:
752
+
decrypted_data = _decrypt_data(session.oauth_session_data)
753
+
oauth_session_data = (
754
+
json.loads(decrypted_data) if decrypted_data is not None else {}
755
+
)
756
+
refresh_expires_at = _get_refresh_token_expires_at(
757
+
session, oauth_session_data
758
+
)
759
+
effective_expires_at = session.expires_at
760
+
if refresh_expires_at and (
761
+
effective_expires_at is None
762
+
or refresh_expires_at < effective_expires_at
763
+
):
764
+
effective_expires_at = refresh_expires_at
765
+
662
766
# check if expired
663
-
if session.expires_at and datetime.now(UTC) > session.expires_at:
767
+
if effective_expires_at and now > effective_expires_at:
664
768
continue # skip expired tokens
665
769
666
770
tokens.append(
···
668
772
session_id=session.session_id,
669
773
token_name=session.token_name,
670
774
created_at=session.created_at,
671
-
expires_at=session.expires_at,
775
+
expires_at=effective_expires_at,
672
776
)
673
777
)
674
778
+5
-2
backend/src/backend/_internal/background.py
+5
-2
backend/src/backend/_internal/background.py
···
92
92
)
93
93
yield docket
94
94
finally:
95
-
# cancel the worker task and wait for it to finish
95
+
# cancel the worker task with timeout to avoid hanging on shutdown
96
96
if worker_task:
97
97
worker_task.cancel()
98
98
try:
99
-
await worker_task
99
+
# wait briefly for clean shutdown, but don't block forever
100
+
await asyncio.wait_for(worker_task, timeout=2.0)
101
+
except TimeoutError:
102
+
logger.warning("docket worker did not stop within timeout")
100
103
except asyncio.CancelledError:
101
104
logger.debug("docket worker task cancelled")
102
105
# clear global after worker is fully stopped
+6
-1
backend/src/backend/api/auth.py
+6
-1
backend/src/backend/api/auth.py
···
35
35
start_oauth_flow_with_scopes,
36
36
switch_active_account,
37
37
)
38
+
from backend._internal.auth import get_refresh_token_lifetime_days
38
39
from backend._internal.background_tasks import schedule_atproto_sync
39
40
from backend.config import settings
40
41
from backend.models import Artist, get_db
···
466
467
if expires_in_days > max_days:
467
468
raise HTTPException(
468
469
status_code=400,
469
-
detail=f"expires_in_days cannot exceed {max_days} (use 0 for no expiration)",
470
+
detail=f"expires_in_days cannot exceed {max_days}",
470
471
)
472
+
473
+
refresh_lifetime_days = get_refresh_token_lifetime_days(None)
474
+
if expires_in_days <= 0 or expires_in_days > refresh_lifetime_days:
475
+
expires_in_days = refresh_lifetime_days
471
476
472
477
# start OAuth flow using the user's handle
473
478
auth_url, state = await start_oauth_flow(session.handle)
+2
-1
backend/src/backend/api/tracks/metadata_service.py
+2
-1
backend/src/backend/api/tracks/metadata_service.py
···
6
6
from io import BytesIO
7
7
from typing import TYPE_CHECKING, Any
8
8
9
-
from fastapi import HTTPException, UploadFile
9
+
from fastapi import HTTPException
10
10
from sqlalchemy.ext.asyncio import AsyncSession
11
11
from sqlalchemy.orm import attributes
12
+
from starlette.datastructures import UploadFile
12
13
13
14
from backend._internal.atproto.handles import resolve_handle
14
15
from backend._internal.image import ImageFormat
+22
-4
backend/src/backend/api/tracks/mutations.py
+22
-4
backend/src/backend/api/tracks/mutations.py
···
180
180
Form(description="JSON object for supporter gating, or 'null' to remove"),
181
181
] = None,
182
182
image: UploadFile | None = File(None),
183
+
remove_image: Annotated[
184
+
str | None,
185
+
Form(description="Set to 'true' to remove artwork"),
186
+
] = None,
183
187
) -> TrackResponse:
184
188
"""Update track metadata (only by owner)."""
185
189
result = await db.execute(
···
250
254
251
255
image_changed = False
252
256
image_url = None
253
-
if image and image.filename:
257
+
258
+
# handle image removal
259
+
if remove_image and remove_image.lower() == "true" and track.image_id:
260
+
# only delete image from R2 if album doesn't share it
261
+
album_shares_image = (
262
+
track.album_rel and track.album_rel.image_id == track.image_id
263
+
)
264
+
if not album_shares_image:
265
+
with contextlib.suppress(Exception):
266
+
await storage.delete(track.image_id)
267
+
track.image_id = None
268
+
track.image_url = None
269
+
image_changed = True
270
+
elif image and image.filename:
271
+
# handle image upload/replacement
254
272
image_id, image_url = await upload_track_image(image)
255
273
256
274
if track.image_id:
···
305
323
try:
306
324
await _update_atproto_record(track, auth_session, image_url)
307
325
except Exception as exc:
308
-
logger.error(
309
-
f"failed to update ATProto record for track {track.id}: {exc}",
310
-
exc_info=True,
326
+
logfire.exception(
327
+
"failed to update ATProto record",
328
+
track_id=track.id,
311
329
)
312
330
await db.rollback()
313
331
raise HTTPException(
+1
-1
backend/src/backend/config.py
+1
-1
backend/src/backend/config.py
···
666
666
667
667
developer_token_default_days: int = Field(
668
668
default=90,
669
-
description="Default expiration in days for developer tokens (0 = no expiration)",
669
+
description="Default expiration in days for developer tokens (capped by refresh lifetime)",
670
670
)
671
671
developer_token_max_days: int = Field(
672
672
default=365,
+10
-3
backend/src/backend/main.py
+10
-3
backend/src/backend/main.py
···
1
1
"""relay fastapi application."""
2
2
3
+
import asyncio
3
4
import logging
4
5
import re
5
6
import warnings
···
157
158
app.state.docket = docket
158
159
yield
159
160
160
-
# shutdown: cleanup resources
161
-
await notification_service.shutdown()
162
-
await queue_service.shutdown()
161
+
# shutdown: cleanup resources with timeouts to avoid hanging
162
+
try:
163
+
await asyncio.wait_for(notification_service.shutdown(), timeout=2.0)
164
+
except TimeoutError:
165
+
logging.warning("notification_service.shutdown() timed out")
166
+
try:
167
+
await asyncio.wait_for(queue_service.shutdown(), timeout=2.0)
168
+
except TimeoutError:
169
+
logging.warning("queue_service.shutdown() timed out")
163
170
164
171
165
172
app = FastAPI(
+3
-2
backend/src/backend/storage/r2.py
+3
-2
backend/src/backend/storage/r2.py
···
2
2
3
3
import time
4
4
from collections.abc import Callable
5
+
from io import BytesIO
5
6
from pathlib import Path
6
7
from typing import BinaryIO
7
8
···
120
121
121
122
async def save(
122
123
self,
123
-
file: BinaryIO,
124
+
file: BinaryIO | BytesIO,
124
125
filename: str,
125
126
progress_callback: Callable[[float], None] | None = None,
126
127
) -> str:
···
444
445
445
446
async def save_gated(
446
447
self,
447
-
file: BinaryIO,
448
+
file: BinaryIO | BytesIO,
448
449
filename: str,
449
450
progress_callback: Callable[[float], None] | None = None,
450
451
) -> str:
+14
-6
backend/tests/test_auth.py
+14
-6
backend/tests/test_auth.py
···
17
17
create_session,
18
18
delete_session,
19
19
get_public_jwks,
20
+
get_refresh_token_lifetime_days,
20
21
get_session,
21
22
is_confidential_client,
22
23
update_session_tokens,
···
259
260
260
261
261
262
async def test_create_session_with_custom_expiration(db_session: AsyncSession):
262
-
"""verify session creation with custom expiration works."""
263
+
"""verify session creation with custom expiration is capped by refresh lifetime."""
263
264
did = "did:plc:customexp123"
264
265
handle = "customexp.bsky.social"
265
266
oauth_data = {"access_token": "token", "refresh_token": "refresh"}
···
280
281
assert db_session_record is not None
281
282
assert db_session_record.expires_at is not None
282
283
283
-
# should expire roughly 30 days from now
284
-
expected_expiry = datetime.now(UTC) + timedelta(days=30)
284
+
expected_days = min(30, get_refresh_token_lifetime_days(None))
285
+
# should expire roughly expected_days from now
286
+
expected_expiry = datetime.now(UTC) + timedelta(days=expected_days)
285
287
actual_expiry = db_session_record.expires_at.replace(tzinfo=UTC)
286
288
diff = abs((expected_expiry - actual_expiry).total_seconds())
287
289
assert diff < 60 # within 1 minute
288
290
289
291
290
292
async def test_create_session_with_no_expiration(db_session: AsyncSession):
291
-
"""verify session creation with expires_in_days=0 creates non-expiring session."""
293
+
"""verify session creation with expires_in_days=0 caps to refresh lifetime."""
292
294
did = "did:plc:noexp123"
293
295
handle = "noexp.bsky.social"
294
296
oauth_data = {"access_token": "token", "refresh_token": "refresh"}
···
301
303
assert session is not None
302
304
assert session.did == did
303
305
304
-
# verify expires_at is None
306
+
# verify expires_at is capped to refresh token lifetime
305
307
result = await db_session.execute(
306
308
select(UserSession).where(UserSession.session_id == session_id)
307
309
)
308
310
db_session_record = result.scalar_one_or_none()
309
311
assert db_session_record is not None
310
-
assert db_session_record.expires_at is None
312
+
assert db_session_record.expires_at is not None
313
+
314
+
expected_days = get_refresh_token_lifetime_days(None)
315
+
expected_expiry = datetime.now(UTC) + timedelta(days=expected_days)
316
+
actual_expiry = db_session_record.expires_at.replace(tzinfo=UTC)
317
+
diff = abs((expected_expiry - actual_expiry).total_seconds())
318
+
assert diff < 60 # within 1 minute
311
319
312
320
313
321
async def test_create_session_default_expiration(db_session: AsyncSession):
+53
backend/tests/test_storage_types.py
+53
backend/tests/test_storage_types.py
···
1
+
"""test storage type hints accept BytesIO.
2
+
3
+
regression test for: https://github.com/zzstoatzz/plyr.fm/pull/736
4
+
beartype was rejecting BytesIO for BinaryIO type hint in R2Storage.save()
5
+
"""
6
+
7
+
from io import BytesIO
8
+
from unittest.mock import AsyncMock, patch
9
+
10
+
from backend.storage.r2 import R2Storage
11
+
12
+
13
+
async def test_r2_save_accepts_bytesio():
14
+
"""R2Storage.save() should accept BytesIO objects.
15
+
16
+
BytesIO is the standard way to create in-memory binary streams,
17
+
and is used throughout the codebase for image uploads.
18
+
19
+
This test verifies that the type hint on save() is compatible
20
+
with BytesIO, which beartype validates at runtime.
21
+
"""
22
+
# create a minimal image-like BytesIO
23
+
image_data = b"\x89PNG\r\n\x1a\n" + b"\x00" * 100 # fake PNG header
24
+
file_obj = BytesIO(image_data)
25
+
26
+
# mock the R2 client internals
27
+
with (
28
+
patch.object(R2Storage, "__init__", lambda self: None),
29
+
patch("backend.storage.r2.hash_file_chunked", return_value="abc123def456"),
30
+
):
31
+
storage = R2Storage()
32
+
storage.async_session = AsyncMock()
33
+
storage.image_bucket_name = "test-images"
34
+
storage.audio_bucket_name = "test-audio"
35
+
36
+
# mock the async context manager for S3 client
37
+
mock_client = AsyncMock()
38
+
mock_client.upload_fileobj = AsyncMock()
39
+
40
+
mock_cm = AsyncMock()
41
+
mock_cm.__aenter__ = AsyncMock(return_value=mock_client)
42
+
mock_cm.__aexit__ = AsyncMock(return_value=None)
43
+
storage.async_session.client = lambda *args, **kwargs: mock_cm
44
+
storage.endpoint_url = "https://test.r2.dev"
45
+
storage.aws_access_key_id = "test"
46
+
storage.aws_secret_access_key = "test"
47
+
48
+
# this should NOT raise a beartype error
49
+
# before the fix: BeartypeCallHintParamViolation
50
+
file_id = await storage.save(file_obj, "test.png")
51
+
52
+
assert file_id == "abc123def456"[:16]
53
+
mock_client.upload_fileobj.assert_called_once()
+3
-3
docs/authentication.md
+3
-3
docs/authentication.md
···
439
439
backend settings in `AuthSettings`:
440
440
- `developer_token_default_days`: default expiration (90 days)
441
441
- `developer_token_max_days`: max allowed expiration (365 days)
442
-
- use `expires_in_days: 0` for tokens that never expire
442
+
- use `expires_in_days: 0` to request the maximum allowed by refresh lifetime
443
443
444
444
### how it works
445
445
···
485
485
with public clients, the underlying ATProto refresh token expires after 2 weeks regardless of what we store in our database. users would need to re-authenticate with their PDS every 2 weeks.
486
486
487
487
with confidential clients:
488
-
- **developer tokens actually work long-term** - not limited to 2 weeks
488
+
- **developer tokens work long-term** - not limited to 2 weeks
489
489
- **users don't get randomly kicked out** after 2 weeks of inactivity
490
-
- **sessions last effectively forever** as long as tokens are refreshed within 180 days
490
+
- **sessions last up to refresh lifetime** as long as tokens are refreshed within 180 days
491
491
492
492
### how it works
493
493
+7
-11
docs/frontend/data-loading.md
+7
-11
docs/frontend/data-loading.md
···
44
44
45
45
used for:
46
46
- auth-dependent data (liked tracks, user preferences)
47
-
- data that needs client context (localStorage, cookies)
47
+
- data that needs client context (local caches, media state)
48
48
- progressive enhancement
49
49
50
50
```typescript
51
51
// frontend/src/routes/liked/+page.ts
52
52
export const load: PageLoad = async ({ fetch }) => {
53
-
const sessionId = localStorage.getItem('session_id');
54
-
if (!sessionId) return { tracks: [] };
55
-
56
53
const response = await fetch(`${API_URL}/tracks/liked`, {
57
-
headers: { 'Authorization': `Bearer ${sessionId}` }
54
+
credentials: 'include'
58
55
});
56
+
57
+
if (!response.ok) return { tracks: [] };
59
58
60
59
return { tracks: await response.json() };
61
60
};
62
61
```
63
62
64
63
**benefits**:
65
-
- access to browser APIs (localStorage, cookies)
66
-
- runs on client, can use session tokens
64
+
- access to browser APIs (window, local caches)
65
+
- runs on client, can use HttpOnly cookie auth
67
66
- still loads before component mounts (faster than `onMount`)
68
67
69
68
### layout loading (`+layout.ts`)
···
75
74
```typescript
76
75
// frontend/src/routes/+layout.ts
77
76
export async function load({ fetch }: LoadEvent) {
78
-
const sessionId = localStorage.getItem('session_id');
79
-
if (!sessionId) return { user: null, isAuthenticated: false };
80
-
81
77
const response = await fetch(`${API_URL}/auth/me`, {
82
-
headers: { 'Authorization': `Bearer ${sessionId}` }
78
+
credentials: 'include'
83
79
});
84
80
85
81
if (response.ok) {
+53
docs/frontend/state-management.md
+53
docs/frontend/state-management.md
···
133
133
2. no console errors about "Cannot access X before initialization"
134
134
3. UI reflects current variable value
135
135
136
+
### waiting for async conditions with `$effect`
137
+
138
+
when you need to perform an action after some async condition is met (like audio being ready), **don't rely on event listeners** - they may not attach in time if the target element doesn't exist yet or the event fires before your listener is registered.
139
+
140
+
**instead, use a reactive `$effect` that watches for the conditions to be met:**
141
+
142
+
```typescript
143
+
// โ WRONG - event listener may not attach in time
144
+
onMount(() => {
145
+
queue.playNow(track); // triggers async loading in Player component
146
+
147
+
// player.audioElement might be undefined here!
148
+
// even if it exists, loadedmetadata may fire before this runs
149
+
player.audioElement?.addEventListener('loadedmetadata', () => {
150
+
player.audioElement.currentTime = seekTime;
151
+
});
152
+
});
153
+
```
154
+
155
+
```typescript
156
+
// โ
CORRECT - reactive effect waits for conditions
157
+
let pendingSeekMs = $state<number | null>(null);
158
+
159
+
onMount(() => {
160
+
pendingSeekMs = 11000; // store the pending action
161
+
queue.playNow(track); // trigger the async operation
162
+
});
163
+
164
+
// effect runs whenever dependencies change, including when audio becomes ready
165
+
$effect(() => {
166
+
if (
167
+
pendingSeekMs !== null &&
168
+
player.currentTrack?.id === track.id &&
169
+
player.audioElement &&
170
+
player.audioElement.readyState >= 1
171
+
) {
172
+
player.audioElement.currentTime = pendingSeekMs / 1000;
173
+
pendingSeekMs = null; // clear after performing action
174
+
}
175
+
});
176
+
```
177
+
178
+
**why this works:**
179
+
- `$effect` re-runs whenever any of its dependencies change
180
+
- when `player.audioElement` becomes available and ready, the effect fires
181
+
- no race condition - the effect will catch the ready state even if it happened "in the past"
182
+
- setting `pendingSeekMs = null` ensures the action only runs once
183
+
184
+
**use this pattern when:**
185
+
- waiting for DOM elements to exist
186
+
- waiting for async operations to complete
187
+
- coordinating between components that load independently
188
+
136
189
## global state management
137
190
138
191
### overview
+5
-3
frontend/src/lib/queue.svelte.ts
+5
-3
frontend/src/lib/queue.svelte.ts
···
2
2
import type { QueueResponse, QueueState, Track } from './types';
3
3
import { API_URL } from './config';
4
4
import { APP_BROADCAST_PREFIX } from './branding';
5
+
import { auth } from './auth.svelte';
5
6
6
7
const SYNC_DEBOUNCE_MS = 250;
7
8
···
141
142
142
143
private isAuthenticated(): boolean {
143
144
if (!browser) return false;
144
-
return !!localStorage.getItem('session_id');
145
+
return auth.isAuthenticated;
145
146
}
146
147
147
148
async fetchQueue(force = false) {
···
188
189
this.revision = data.revision;
189
190
this.etag = newEtag;
190
191
192
+
this.lastUpdateWasLocal = false;
191
193
this.applySnapshot(data);
192
194
} catch (error) {
193
195
console.error('failed to fetch queue:', error);
···
414
416
this.schedulePush();
415
417
}
416
418
417
-
playNow(track: Track) {
418
-
this.lastUpdateWasLocal = true;
419
+
playNow(track: Track, autoPlay = true) {
420
+
this.lastUpdateWasLocal = autoPlay;
419
421
const upNext = this.tracks.slice(this.currentIndex + 1);
420
422
this.tracks = [track, ...upNext];
421
423
this.originalOrder = [...this.tracks];
+3
frontend/src/routes/+layout.svelte
+3
frontend/src/routes/+layout.svelte
+359
-96
frontend/src/routes/portal/+page.svelte
+359
-96
frontend/src/routes/portal/+page.svelte
···
28
28
let editFeaturedArtists = $state<FeaturedArtist[]>([]);
29
29
let editTags = $state<string[]>([]);
30
30
let editImageFile = $state<File | null>(null);
31
+
let editImagePreviewUrl = $state<string | null>(null);
32
+
let editRemoveImage = $state(false);
31
33
let editSupportGate = $state(false);
32
34
let hasUnresolvedEditFeaturesInput = $state(false);
33
35
···
328
330
editFeaturedArtists = [];
329
331
editTags = [];
330
332
editImageFile = null;
333
+
if (editImagePreviewUrl) {
334
+
URL.revokeObjectURL(editImagePreviewUrl);
335
+
}
336
+
editImagePreviewUrl = null;
337
+
editRemoveImage = false;
331
338
editSupportGate = false;
332
339
}
333
340
···
351
358
} else {
352
359
formData.append('support_gate', 'null');
353
360
}
354
-
if (editImageFile) {
361
+
// handle artwork: remove, replace, or leave unchanged
362
+
if (editRemoveImage) {
363
+
formData.append('remove_image', 'true');
364
+
} else if (editImageFile) {
355
365
formData.append('image', editImageFile);
356
366
}
357
367
···
730
740
/>
731
741
</div>
732
742
<div class="edit-field-group">
733
-
<label for="edit-image" class="edit-label">artwork (optional)</label>
734
-
{#if track.image_url && !editImageFile}
735
-
<div class="current-image-preview">
736
-
<img src={track.image_url} alt="current artwork" />
737
-
<span class="current-image-label">current artwork</span>
738
-
</div>
739
-
{/if}
740
-
<input
741
-
id="edit-image"
742
-
type="file"
743
-
accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif"
744
-
onchange={(e) => {
745
-
const target = e.target as HTMLInputElement;
746
-
editImageFile = target.files?.[0] ?? null;
747
-
}}
748
-
class="edit-input"
749
-
/>
750
-
{#if editImageFile}
751
-
<p class="file-info">{editImageFile.name} (will replace current)</p>
752
-
{/if}
743
+
<span class="edit-label">artwork (optional)</span>
744
+
<div class="artwork-editor">
745
+
{#if editImagePreviewUrl}
746
+
<!-- New image selected - show preview -->
747
+
<div class="artwork-preview">
748
+
<img src={editImagePreviewUrl} alt="new artwork preview" />
749
+
<div class="artwork-preview-overlay">
750
+
<button
751
+
type="button"
752
+
class="artwork-action-btn"
753
+
onclick={() => {
754
+
editImageFile = null;
755
+
if (editImagePreviewUrl) {
756
+
URL.revokeObjectURL(editImagePreviewUrl);
757
+
}
758
+
editImagePreviewUrl = null;
759
+
}}
760
+
title="remove selection"
761
+
>
762
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
763
+
<line x1="18" y1="6" x2="6" y2="18"></line>
764
+
<line x1="6" y1="6" x2="18" y2="18"></line>
765
+
</svg>
766
+
</button>
767
+
</div>
768
+
</div>
769
+
<span class="artwork-status">new artwork selected</span>
770
+
{:else if editRemoveImage}
771
+
<!-- User chose to remove artwork -->
772
+
<div class="artwork-removed">
773
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
774
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
775
+
<line x1="9" y1="9" x2="15" y2="15"></line>
776
+
<line x1="15" y1="9" x2="9" y2="15"></line>
777
+
</svg>
778
+
<span>artwork will be removed</span>
779
+
<button
780
+
type="button"
781
+
class="undo-remove-btn"
782
+
onclick={() => { editRemoveImage = false; }}
783
+
>
784
+
undo
785
+
</button>
786
+
</div>
787
+
{:else if track.image_url}
788
+
<!-- Current artwork exists -->
789
+
<div class="artwork-preview">
790
+
<img src={track.image_url} alt="current artwork" />
791
+
<div class="artwork-preview-overlay">
792
+
<button
793
+
type="button"
794
+
class="artwork-action-btn"
795
+
onclick={() => { editRemoveImage = true; }}
796
+
title="remove artwork"
797
+
>
798
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
799
+
<polyline points="3 6 5 6 21 6"></polyline>
800
+
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
801
+
</svg>
802
+
</button>
803
+
</div>
804
+
</div>
805
+
<span class="artwork-status current">current artwork</span>
806
+
{:else}
807
+
<!-- No artwork -->
808
+
<div class="artwork-empty">
809
+
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
810
+
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect>
811
+
<circle cx="8.5" cy="8.5" r="1.5"></circle>
812
+
<polyline points="21 15 16 10 5 21"></polyline>
813
+
</svg>
814
+
<span>no artwork</span>
815
+
</div>
816
+
{/if}
817
+
{#if !editRemoveImage}
818
+
<label class="artwork-upload-btn">
819
+
<input
820
+
type="file"
821
+
accept=".jpg,.jpeg,.png,.webp,.gif,image/jpeg,image/png,image/webp,image/gif"
822
+
onchange={(e) => {
823
+
const target = e.target as HTMLInputElement;
824
+
const file = target.files?.[0];
825
+
if (file) {
826
+
editImageFile = file;
827
+
if (editImagePreviewUrl) {
828
+
URL.revokeObjectURL(editImagePreviewUrl);
829
+
}
830
+
editImagePreviewUrl = URL.createObjectURL(file);
831
+
}
832
+
}}
833
+
/>
834
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
835
+
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
836
+
<polyline points="17 8 12 3 7 8"></polyline>
837
+
<line x1="12" y1="3" x2="12" y2="15"></line>
838
+
</svg>
839
+
{track.image_url || editImagePreviewUrl ? 'replace' : 'upload'}
840
+
</label>
841
+
{/if}
842
+
</div>
753
843
</div>
754
844
{#if atprotofansEligible || track.support_gate}
755
845
<div class="edit-field-group">
···
771
861
</div>
772
862
<div class="edit-actions">
773
863
<button
774
-
class="action-btn save-btn"
775
-
onclick={() => saveTrackEdit(track.id)}
776
-
disabled={hasUnresolvedEditFeaturesInput}
777
-
title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"}
864
+
type="button"
865
+
class="edit-cancel-btn"
866
+
onclick={cancelEdit}
778
867
>
779
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
780
-
<polyline points="20 6 9 17 4 12"></polyline>
781
-
</svg>
868
+
cancel
782
869
</button>
783
870
<button
784
-
class="action-btn cancel-btn"
785
-
onclick={cancelEdit}
786
-
title="cancel"
871
+
type="button"
872
+
class="edit-save-btn"
873
+
onclick={() => saveTrackEdit(track.id)}
874
+
disabled={hasUnresolvedEditFeaturesInput}
875
+
title={hasUnresolvedEditFeaturesInput ? "please select or clear featured artist" : "save changes"}
787
876
>
788
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
789
-
<line x1="18" y1="6" x2="6" y2="18"></line>
790
-
<line x1="6" y1="6" x2="18" y2="18"></line>
791
-
</svg>
877
+
save changes
792
878
</button>
793
879
</div>
794
880
</div>
···
880
966
</div>
881
967
<div class="track-actions">
882
968
<button
883
-
class="action-btn edit-btn"
969
+
type="button"
970
+
class="track-action-btn edit"
884
971
onclick={() => startEditTrack(track)}
885
-
title="edit track"
886
972
>
887
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
973
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
888
974
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"></path>
889
975
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"></path>
890
976
</svg>
977
+
edit
891
978
</button>
892
979
<button
893
-
class="action-btn delete-btn"
980
+
type="button"
981
+
class="track-action-btn delete"
894
982
onclick={() => deleteTrack(track.id, track.title)}
895
-
title="delete track"
896
983
>
897
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
984
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
898
985
<polyline points="3 6 5 6 21 6"></polyline>
899
986
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"></path>
900
987
</svg>
988
+
delete
901
989
</button>
902
990
</div>
903
991
{/if}
···
1451
1539
cursor: not-allowed;
1452
1540
}
1453
1541
1454
-
.file-info {
1455
-
margin-top: 0.5rem;
1456
-
font-size: var(--text-sm);
1457
-
color: var(--text-muted);
1458
-
}
1459
-
1460
-
button {
1542
+
/* form submit buttons only */
1543
+
form button[type="submit"] {
1461
1544
width: 100%;
1462
1545
padding: 0.75rem;
1463
1546
background: var(--accent);
···
1471
1554
transition: all 0.2s;
1472
1555
}
1473
1556
1474
-
button:hover:not(:disabled) {
1557
+
form button[type="submit"]:hover:not(:disabled) {
1475
1558
background: var(--accent-hover);
1476
1559
transform: translateY(-1px);
1477
1560
box-shadow: 0 4px 12px color-mix(in srgb, var(--accent) 30%, transparent);
1478
1561
}
1479
1562
1480
-
button:disabled {
1563
+
form button[type="submit"]:disabled {
1481
1564
opacity: 0.5;
1482
1565
cursor: not-allowed;
1483
1566
transform: none;
1484
1567
}
1485
1568
1486
-
button:active:not(:disabled) {
1569
+
form button[type="submit"]:active:not(:disabled) {
1487
1570
transform: translateY(0);
1488
1571
}
1489
1572
···
1787
1870
align-self: flex-start;
1788
1871
}
1789
1872
1790
-
.action-btn {
1791
-
display: flex;
1873
+
/* track action buttons (edit/delete in non-editing state) */
1874
+
.track-action-btn {
1875
+
display: inline-flex;
1792
1876
align-items: center;
1793
-
justify-content: center;
1794
-
width: 32px;
1795
-
height: 32px;
1796
-
padding: 0;
1877
+
gap: 0.35rem;
1878
+
padding: 0.4rem 0.65rem;
1797
1879
background: transparent;
1798
1880
border: 1px solid var(--border-default);
1799
-
border-radius: var(--radius-base);
1881
+
border-radius: var(--radius-full);
1800
1882
color: var(--text-tertiary);
1883
+
font-size: var(--text-sm);
1884
+
font-family: inherit;
1885
+
font-weight: 500;
1801
1886
cursor: pointer;
1802
1887
transition: all 0.15s;
1803
-
flex-shrink: 0;
1888
+
white-space: nowrap;
1889
+
width: auto;
1890
+
}
1891
+
1892
+
.track-action-btn:hover {
1893
+
transform: none;
1894
+
box-shadow: none;
1895
+
border-color: var(--border-emphasis);
1896
+
color: var(--text-secondary);
1897
+
}
1898
+
1899
+
.track-action-btn.delete:hover {
1900
+
color: var(--text-secondary);
1901
+
}
1902
+
1903
+
/* edit mode action buttons */
1904
+
.edit-actions {
1905
+
display: flex;
1906
+
gap: 0.75rem;
1907
+
justify-content: flex-end;
1908
+
padding-top: 0.75rem;
1909
+
border-top: 1px solid var(--border-subtle);
1910
+
margin-top: 0.5rem;
1804
1911
}
1805
1912
1806
-
.action-btn svg {
1807
-
flex-shrink: 0;
1913
+
.edit-cancel-btn {
1914
+
padding: 0.6rem 1.25rem;
1915
+
background: transparent;
1916
+
border: 1px solid var(--border-default);
1917
+
border-radius: var(--radius-base);
1918
+
color: var(--text-secondary);
1919
+
font-size: var(--text-base);
1920
+
font-weight: 500;
1921
+
font-family: inherit;
1922
+
cursor: pointer;
1923
+
transition: all 0.15s;
1924
+
width: auto;
1808
1925
}
1809
1926
1810
-
.action-btn:hover {
1927
+
.edit-cancel-btn:hover {
1928
+
border-color: var(--text-tertiary);
1929
+
background: var(--bg-hover);
1811
1930
transform: none;
1812
1931
box-shadow: none;
1813
1932
}
1814
1933
1815
-
.edit-btn:hover {
1816
-
background: color-mix(in srgb, var(--accent) 12%, transparent);
1817
-
border-color: var(--accent);
1934
+
.edit-save-btn {
1935
+
padding: 0.6rem 1.25rem;
1936
+
background: transparent;
1937
+
border: 1px solid var(--accent);
1938
+
border-radius: var(--radius-base);
1818
1939
color: var(--accent);
1940
+
font-size: var(--text-base);
1941
+
font-weight: 500;
1942
+
font-family: inherit;
1943
+
cursor: pointer;
1944
+
transition: all 0.15s;
1945
+
width: auto;
1819
1946
}
1820
1947
1821
-
.delete-btn:hover {
1822
-
background: color-mix(in srgb, var(--error) 12%, transparent);
1823
-
border-color: var(--error);
1824
-
color: var(--error);
1948
+
.edit-save-btn:hover:not(:disabled) {
1949
+
background: color-mix(in srgb, var(--accent) 8%, transparent);
1825
1950
}
1826
1951
1827
-
.save-btn:hover {
1828
-
background: color-mix(in srgb, var(--success) 12%, transparent);
1829
-
border-color: var(--success);
1830
-
color: var(--success);
1831
-
}
1832
-
1833
-
.cancel-btn:hover {
1834
-
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
1835
-
border-color: var(--text-tertiary);
1836
-
color: var(--text-secondary);
1952
+
.edit-save-btn:disabled {
1953
+
opacity: 0.5;
1954
+
cursor: not-allowed;
1837
1955
}
1838
1956
1839
1957
.edit-input {
···
1847
1965
font-family: inherit;
1848
1966
}
1849
1967
1850
-
.current-image-preview {
1968
+
/* artwork editor */
1969
+
.artwork-editor {
1851
1970
display: flex;
1852
1971
align-items: center;
1853
-
gap: 0.75rem;
1854
-
padding: 0.5rem;
1972
+
gap: 1rem;
1973
+
padding: 0.75rem;
1855
1974
background: var(--bg-primary);
1856
1975
border: 1px solid var(--border-default);
1857
-
border-radius: var(--radius-sm);
1858
-
margin-bottom: 0.5rem;
1976
+
border-radius: var(--radius-base);
1859
1977
}
1860
1978
1861
-
.current-image-preview img {
1862
-
width: 48px;
1863
-
height: 48px;
1864
-
border-radius: var(--radius-sm);
1979
+
.artwork-preview {
1980
+
position: relative;
1981
+
width: 80px;
1982
+
height: 80px;
1983
+
border-radius: var(--radius-base);
1984
+
overflow: hidden;
1985
+
flex-shrink: 0;
1986
+
}
1987
+
1988
+
.artwork-preview img {
1989
+
width: 100%;
1990
+
height: 100%;
1865
1991
object-fit: cover;
1866
1992
}
1867
1993
1868
-
.current-image-label {
1994
+
.artwork-preview-overlay {
1995
+
position: absolute;
1996
+
inset: 0;
1997
+
background: rgba(0, 0, 0, 0.5);
1998
+
display: flex;
1999
+
align-items: center;
2000
+
justify-content: center;
2001
+
opacity: 0;
2002
+
transition: opacity 0.15s;
2003
+
}
2004
+
2005
+
.artwork-preview:hover .artwork-preview-overlay {
2006
+
opacity: 1;
2007
+
}
2008
+
2009
+
.artwork-action-btn {
2010
+
display: flex;
2011
+
align-items: center;
2012
+
justify-content: center;
2013
+
width: 32px;
2014
+
height: 32px;
2015
+
padding: 0;
2016
+
background: rgba(255, 255, 255, 0.15);
2017
+
border: none;
2018
+
border-radius: var(--radius-full);
2019
+
color: white;
2020
+
cursor: pointer;
2021
+
transition: all 0.15s;
2022
+
}
2023
+
2024
+
.artwork-action-btn:hover {
2025
+
background: var(--error);
2026
+
transform: scale(1.1);
2027
+
box-shadow: none;
2028
+
}
2029
+
2030
+
.artwork-status {
2031
+
font-size: var(--text-sm);
2032
+
color: var(--accent);
2033
+
font-weight: 500;
2034
+
}
2035
+
2036
+
.artwork-status.current {
1869
2037
color: var(--text-tertiary);
2038
+
font-weight: 400;
2039
+
}
2040
+
2041
+
.artwork-removed {
2042
+
display: flex;
2043
+
flex-direction: column;
2044
+
align-items: center;
2045
+
gap: 0.5rem;
2046
+
padding: 0.75rem 1rem;
2047
+
color: var(--text-tertiary);
2048
+
}
2049
+
2050
+
.artwork-removed span {
1870
2051
font-size: var(--text-sm);
2052
+
}
2053
+
2054
+
.undo-remove-btn {
2055
+
padding: 0.25rem 0.75rem;
2056
+
background: transparent;
2057
+
border: 1px solid var(--border-default);
2058
+
border-radius: var(--radius-full);
2059
+
color: var(--accent);
2060
+
font-size: var(--text-sm);
2061
+
font-family: inherit;
2062
+
cursor: pointer;
2063
+
transition: all 0.15s;
2064
+
width: auto;
2065
+
}
2066
+
2067
+
.undo-remove-btn:hover {
2068
+
border-color: var(--accent);
2069
+
background: color-mix(in srgb, var(--accent) 10%, transparent);
2070
+
transform: none;
2071
+
box-shadow: none;
2072
+
}
2073
+
2074
+
.artwork-empty {
2075
+
display: flex;
2076
+
flex-direction: column;
2077
+
align-items: center;
2078
+
gap: 0.5rem;
2079
+
padding: 0.75rem 1rem;
2080
+
color: var(--text-muted);
2081
+
}
2082
+
2083
+
.artwork-empty span {
2084
+
font-size: var(--text-sm);
2085
+
}
2086
+
2087
+
.artwork-upload-btn {
2088
+
display: inline-flex;
2089
+
align-items: center;
2090
+
gap: 0.4rem;
2091
+
padding: 0.5rem 0.85rem;
2092
+
background: transparent;
2093
+
border: 1px solid var(--accent);
2094
+
border-radius: var(--radius-full);
2095
+
color: var(--accent);
2096
+
font-size: var(--text-sm);
2097
+
font-weight: 500;
2098
+
cursor: pointer;
2099
+
transition: all 0.15s;
2100
+
margin-left: auto;
2101
+
}
2102
+
2103
+
.artwork-upload-btn:hover {
2104
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
2105
+
}
2106
+
2107
+
.artwork-upload-btn input {
2108
+
display: none;
1871
2109
}
1872
2110
1873
2111
.edit-input:focus {
···
2424
2662
.track-actions {
2425
2663
margin-left: 0.5rem;
2426
2664
gap: 0.35rem;
2665
+
flex-direction: column;
2427
2666
}
2428
2667
2429
-
.action-btn {
2430
-
width: 30px;
2431
-
height: 30px;
2668
+
.track-action-btn {
2669
+
padding: 0.35rem 0.55rem;
2670
+
font-size: var(--text-xs);
2432
2671
}
2433
2672
2434
-
.action-btn svg {
2435
-
width: 14px;
2436
-
height: 14px;
2673
+
.track-action-btn svg {
2674
+
width: 12px;
2675
+
height: 12px;
2437
2676
}
2438
2677
2439
2678
/* edit mode mobile */
···
2455
2694
}
2456
2695
2457
2696
.edit-actions {
2458
-
gap: 0.35rem;
2697
+
gap: 0.5rem;
2698
+
flex-direction: column;
2699
+
}
2700
+
2701
+
.edit-cancel-btn,
2702
+
.edit-save-btn {
2703
+
width: 100%;
2704
+
padding: 0.6rem;
2705
+
font-size: var(--text-sm);
2706
+
}
2707
+
2708
+
/* artwork editor mobile */
2709
+
.artwork-editor {
2710
+
flex-direction: column;
2711
+
gap: 0.75rem;
2712
+
padding: 0.65rem;
2713
+
}
2714
+
2715
+
.artwork-preview {
2716
+
width: 64px;
2717
+
height: 64px;
2718
+
}
2719
+
2720
+
.artwork-upload-btn {
2721
+
margin-left: 0;
2459
2722
}
2460
2723
2461
2724
/* data section mobile */
+51
-4
frontend/src/routes/track/[id]/+page.svelte
+51
-4
frontend/src/routes/track/[id]/+page.svelte
···
1
1
<script lang="ts">
2
2
import { fade } from 'svelte/transition';
3
+
import { onMount } from 'svelte';
3
4
import { browser } from '$app/environment';
5
+
import { page } from '$app/stores';
4
6
import type { PageData } from './$types';
5
7
import { APP_NAME, APP_CANONICAL_URL } from '$lib/branding';
6
8
import { API_URL } from '$lib/config';
···
231
233
}
232
234
}
233
235
236
+
async function copyCommentLink(timestampMs: number) {
237
+
const seconds = Math.floor(timestampMs / 1000);
238
+
const url = `${window.location.origin}/track/${track.id}?t=${seconds}`;
239
+
await navigator.clipboard.writeText(url);
240
+
toast.success('link copied');
241
+
}
242
+
234
243
function formatRelativeTime(isoString: string): string {
235
244
const date = new Date(isoString);
236
245
const now = new Date();
···
308
317
// track if we've loaded liked state for this track (separate from general load)
309
318
let likedStateLoadedForTrackId = $state<number | null>(null);
310
319
320
+
// pending seek time from ?t= URL param (milliseconds)
321
+
let pendingSeekMs = $state<number | null>(null);
322
+
311
323
// reload data when navigating between track pages
312
324
// watch data.track.id (from server) not track.id (local state)
313
325
$effect(() => {
···
324
336
editingCommentId = null;
325
337
editingCommentText = '';
326
338
likedStateLoadedForTrackId = null; // reset liked state tracking
339
+
pendingSeekMs = null; // reset pending seek
327
340
328
341
// sync track from server data
329
342
track = data.track;
···
355
368
shareUrl = `${window.location.origin}/track/${track.id}`;
356
369
}
357
370
});
371
+
372
+
// handle ?t= timestamp param for deep linking (youtube-style)
373
+
onMount(() => {
374
+
const t = $page.url.searchParams.get('t');
375
+
if (t) {
376
+
const seconds = parseInt(t, 10);
377
+
if (!isNaN(seconds) && seconds >= 0) {
378
+
pendingSeekMs = seconds * 1000;
379
+
// load the track without auto-playing (browser blocks autoplay without interaction)
380
+
if (track.gated) {
381
+
void playTrack(track);
382
+
} else {
383
+
queue.playNow(track, false);
384
+
}
385
+
}
386
+
}
387
+
});
388
+
389
+
// perform pending seek once track is loaded and ready
390
+
$effect(() => {
391
+
if (
392
+
pendingSeekMs !== null &&
393
+
player.currentTrack?.id === track.id &&
394
+
player.audioElement &&
395
+
player.audioElement.readyState >= 1
396
+
) {
397
+
const seekTo = pendingSeekMs / 1000;
398
+
pendingSeekMs = null;
399
+
player.audioElement.currentTime = seekTo;
400
+
// don't auto-play - browser policy blocks it without user interaction
401
+
// user will click play themselves
402
+
}
403
+
});
358
404
</script>
359
405
360
406
<svelte:head>
···
647
693
</div>
648
694
{:else}
649
695
<p class="comment-text">{#each parseTextWithLinks(comment.text) as segment}{#if segment.type === 'link'}<a href={segment.url} target="_blank" rel="noopener noreferrer" class="comment-link">{segment.url}</a>{:else}{segment.content}{/if}{/each}</p>
650
-
{#if auth.user?.did === comment.user_did}
651
-
<div class="comment-actions">
696
+
<div class="comment-actions">
697
+
<button class="comment-action-btn" onclick={() => copyCommentLink(comment.timestamp_ms)}>share</button>
698
+
{#if auth.user?.did === comment.user_did}
652
699
<button class="comment-action-btn" onclick={() => startEditing(comment)}>edit</button>
653
700
<button class="comment-action-btn delete" onclick={() => deleteComment(comment.id)}>delete</button>
654
-
</div>
655
-
{/if}
701
+
{/if}
702
+
</div>
656
703
{/if}
657
704
</div>
658
705
</div>