commits
sparkline: moved histogram fetch into the page loader so it arrives
with the initial data instead of a separate onMount fetch that was
silently failing in production.
pagination: added staggered fly-in transitions for newly loaded items
(batch-index-based delay via svelte/transition). loading spinner now
only appears after 400ms so fast responses don't flash it.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- revert sparkline to 7-day window (was accidentally changed to 30)
- wrap verb text in accent-tinted spans (smaller, subtly colored)
- add artist_avatar_url to ActivityTrack model + inline avatar rendering
- fix pagination by encoding cursor (+ in UTC offset decoded as space)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- sparkline: fetch 30 days instead of 7, skip render when all buckets zero
- neon glow: use inset overlay with border-radius: inherit so accent
follows card corners instead of floating as a clipped bar
- timestamp: add title attr for native hover tooltip with full date/time
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- add GET /activity/histogram endpoint (daily counts over last 7 days)
- SVG sparkline at top of activity page with accent gradient fill
- replace solid border-left with fuzzy neon glow (::before + box-shadow)
- smaller, less blurred lava blobs (5 blobs, 50px blur, ~180-280px max)
- fix TrackCard avatar fallback: container becomes circular when showing
artist avatar so no ugly square outline around circle
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- animated lava lamp with accent-tinted gradient orbs (3 blobs, 24-34s cycles)
- artwork moved to left column with avatar badge overlay (fixes alignment)
- glass cards with backdrop-filter and type-tinted hover glow via --type-color
- respects prefers-reduced-motion
- mobile-responsive, compact CSS (331 lines)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- show track thumbnail/image on right side of event cards
- use person silhouette SVG for avatar placeholders (matches TrackItem/TrackCard)
- stats-informed header showing track/artist/duration counts
- play button placeholder for tracks without artwork
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
drop filled icons for outline-style strokes matching app conventions.
upload arrow for shares (not music note — could be any audio), heart
outline for likes, person+ for joins (not star). add per-type colored
left border accent for visual rhythm. larger avatars, two-line layout
with name/time header + action line below.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- filter out artists with empty handle/display_name from join events
(corrupted row: did:plc:dqbj7k7dierhqdowuyv5ll2z in staging)
- redesign activity page: card containers with glass bg + hover states,
event type icons (heart/note/chat/star), accent-styled comment
previews, right-aligned timestamps, consistent design token usage
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add platform-wide activity feed (#971)
chronological feed of likes, track uploads, comments, and profile
joins. unlisted page at /activity — no nav link, accessible by URL.
reconstructed from existing DB tables via raw SQL UNION ALL query
with cursor-based pagination. includes 9 backend tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add db_session to audio tests missing database setup
test_stream_audio_track_not_found and test_get_audio_url_not_found
were missing db_session fixture, causing "relation tracks does not
exist" under xdist when no prior test on the worker triggered table
creation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: handle docket worker teardown on stale event loop
under xdist, session-scoped TestClient teardown can run on a
different event loop than the one the docket worker task was created
on, causing RuntimeError. catch and log it during shutdown.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: skip docket worker in test lifespan to fix xdist teardown
the production lifespan starts a docket Worker that creates
asyncio.Task objects bound to the TestClient's portal event loop.
under xdist, session teardown runs on a different loop, causing
"attached to a different loop" in Worker.__aexit__.
no test needs a live docket worker (all docket usage is mocked),
so swap in a lightweight test lifespan that skips it entirely.
also reverts the _is_stale_loop_error hack in background.py.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf: add per-branch LIMIT and created_at indexes for activity feed
each UNION ALL branch now sorts+limits independently so postgres can
index-scan the top N per table instead of materializing all rows.
adds standalone created_at DESC indexes on artists, track_likes, and
track_comments to support the global feed query pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Research on preserving deep links through auth flows, covering
approaches from GitHub, Discord, Slack, and others. Recommends
combining query parameter + cookie + OAuth state for ATProto.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1px accent bar glows on playback (matching main Player style), dims on pause.
Inline share button copies the plyr.fm page URL with "copied!" feedback.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add SHORT container query (max-height: 99px) to track embed that hides
player-controls when container is too short, preventing clipped progress
bar. Guard CollectionEmbed WIDE query with aspect-ratio > 1.2 to prevent
it from applying in portrait containers.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Replace art-on-top layout with centered art card on blurred background
for square/tall containers. Remove conflicting VERY TALL mode and guard
WIDE queries with aspect-ratio > 1.2 to prevent overriding tall mode.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The site redirects zzstoatzz.github.io → zzstoatzz.io, so the
browser origin is the custom domain. Allow both with a regex group.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add the origin to the production CORS allowlist so the personal
site can use the search API for its embedded plyr.fm player.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: preserve jam link through login flow
unauthenticated users hitting a jam invite link now see a preview card
(host avatar, name, participant count) with a "sign in to join" button
instead of a confusing error. the jam path is stored in a cookie that
survives the OAuth round-trip, redirecting back after login or profile
setup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address self-review — open redirect, design consistency, docs
- validate return_to param in login page back link (was unsanitized href)
- re-validate cookie value in getReturnUrl() (cookies are client-writable)
- extract isValidReturnPath() for shared validation logic
- use WaveLoading component instead of plain text for auth loading state
- add card surface (bg-tertiary, border, radius) to jam preview card
- remove unique avatar border to match other avatar patterns
- add docs/frontend/redirect-after-login.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
95% opacity bg-tertiary + backdrop blur — frosted glass look without
the glow bar bleeding through and making text unreadable.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
z-index was 1 (below glow bar's 2) and background was semi-transparent,
letting the glow bleed through.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
/jam/ was missing from hasPageMetadata in root layout, so default OG
tags rendered first and crawlers used the generic logo instead of
jam-specific metadata. Also expanded jam OG tags to match track/album
quality (og:type, og:site_name, twitter card, image dimensions).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
disconnect_ws called _clear_output_if_matches (which publishes to Redis)
while the closing WS was still in self._connections. The _stream_reader
background task could pick up the event and _fan_out would try to send
to the already-closed WS, causing "Cannot call send once a close message
has been sent."
Move the discard(ws) before the _clear_output_if_matches call, matching
the pattern already used in _close_ws_for_did. This also improves
_find_fallback_output since it won't consider the departing WS as a
fallback candidate.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: polish jam UI — hide code, clarify output labels, add link previews
- remove meaningless 8-char jam code from header (already in share URL)
- clarify output labels: "all devices" / "this device" / "another device"
- add public GET /jams/{code}/preview endpoint for OG meta tags
- add server-side load + og:title/description/image/url for jam links
- add regression tests for preview endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: bump loq limit for test_jams.py, restore readable test style
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: collapse participant strip to 2 avatars + N chip
Shows first 2 participants with a clickable +N button to expand.
Follows the same pattern as TrackItem tag overflow.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
upload_blob (phase 4) can refresh the OAuth token on 401 and persist it
to DB, but create_track_record (phase 6) was still using the stale
in-memory ctx.auth_session. The PDS would return 401 on record creation,
causing the upload to roll back.
Reload ctx.auth_session from DB after _upload_to_pds returns a result,
so downstream phases always use the current token.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
reduces coupling to settings by using storage.audio_bucket_name and
storage.public_audio_bucket_url instead of settings.storage.r2_bucket
and settings.storage.r2_public_bucket_url for zip upload and download URL.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The release script only checked backend/src/backend/ for changes,
missing migration-only commits that require a backend redeploy.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The migration was using a1b2c3d4e5f6 which collides with an existing
migration (add_ui_settings_jsonb), causing a cycle detection failure
during deploy.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: generate 96x96 WebP thumbnails for track/album/playlist artwork
Full-resolution images (potentially megabytes) were served for 48px
display contexts. This adds thumbnail generation on upload, a storage
protocol for type safety, fixes the image delete key prefix bug, and
includes a backfill script for existing images.
- Add StorageProtocol for type-safe dependency injection
- Generate 96x96 WebP thumbnails via Pillow on image upload
- Add thumbnail_url column to tracks, albums, playlists
- Fix image delete key missing images/ prefix
- Add build_image_url() to consolidate URL construction
- Frontend falls back to image_url when thumbnail_url is null
- Backfill script: scripts/backfill_thumbnails.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: improve test_storage_types patterns
- Return mock client from factory instead of module-level global
- Replace inspect.getsource assertion with behavioral delete test
that verifies the actual key passed to head_object
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: move deferred imports to top-level, add generate_and_save tests
- Move BytesIO and generate_and_save imports to module level in
uploads.py, albums.py, lists.py, and metadata_service.py
- Restore accidentally deleted comments in metadata_service.py
- Add test coverage for generate_and_save success/failure paths
- Add thumbnail_url to Playlist frontend type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
auth.isAuthenticated starts false and resolves asynchronously via
/auth/me. The onMount check evaluated it before it was true, so the
fetch was skipped entirely on first page load. Use a $effect to
reactively trigger the fetch when auth resolves.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
networkArtists was component-local $state — destroyed on every
navigation away from the homepage and re-fetched on return. Now uses
a module-level singleton cache (like tracksCache, statsCache) with a
5-minute TTL so the section appears instantly on subsequent visits.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The /discover/network call was unnecessarily deferred until after top
tracks and latest tracks resolved. No dependency exists — fire all
three concurrently so the artists section appears sooner.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Matches the gold standard from the artist detail page analytics section:
shimmer→content transitions now fade smoothly instead of hard-cutting.
WaveLoading respects prefers-reduced-motion.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
GET /discover/network made 4 sequential HTTP calls to Bluesky on every
request (~2s). Extract follow graph logic into _internal/follow_graph.py
with Redis read-through caching (600s TTL) and warm the cache on login
via a docket background task.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
the previous migration used a placeholder revision ID that caused a
cycle detection error during staging deploy. regenerated properly
with `alembic revision`.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
SQLAlchemy JSONB stores Python None as JSON literal `null` (not SQL NULL)
by default. This made ungated tracks invisible to the PDS backfill query
(which filters on `support_gate IS NULL`) after any portal edit.
- add `none_as_null=True` to JSONB columns (track.support_gate, job.result)
- migration to fix 6 affected production rows
- show actual API error detail in PdsBackfillControl toast
- regression tests for NULL roundtrip behavior
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Jams no longer require the per-user "jams" flag. All authenticated
users can now create and join shared listening rooms.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
client-side safety net: when a state update arrives with one_speaker
mode and no output_client_id, the client immediately sends set_output.
first client to do this wins (server validates), eliminating the
"playing elsewhere with nobody assigned" state.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
backend: add fallback output on disconnect (prefers host), broaden
auto-output to any connecting client, not just host.
frontend: split jam header into two rows (identity+actions / output+mode),
remove "no output" UI branch, add defensive mobile overflow CSS.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Lets the host toggle between single-speaker (party mode) and all-play
(LDR mode) mid-jam. When "everyone", all clients play audio, output
device tracking is skipped, and disconnect no longer pauses playback.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: enable reorder and clear queue during jams
Add move_track and clear_upcoming backend commands, wire them through
the JamBridge interface, and show drag handles + clear button in the
Queue component during jam mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: replace per-command jam queue logic with single update_queue
the backend no longer reimplements next/previous/add/remove/move/clear —
the frontend does the mutation locally (same code path for solo and jam),
then pushes the resulting state via a single update_queue command.
enables setQueue, clear, and playNow in jams for free.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
dict(jam.state) creates a shallow copy where nested lists (track_ids)
are shared references. In-place mutations via .extend() mutate both
copies, so SQLAlchemy doesn't detect changes on reassignment. This
caused add_tracks data to be silently lost on subsequent commands.
Fixes all three state-copy sites: handle_command, _clear_output_if_matches,
and _handle_sync auto-output.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
PDS servers expand include:ns.permSet into granular repo:/rpc: scopes,
so the granted scope never contains the literal include: token. Check
namespace authority instead of exact string match.
This was causing 403 scope_upgrade_required for all sessions on staging
where resolved_scope uses permission sets (include:fm.plyr.stg.authFullApp).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: adopt spec-compliant scope parsing from atproto SDK
Replace naive set-subset scope checking with ScopesSet from the new
atproto_oauth.scopes library, which handles the full ATProto permission
grammar: positional/query format equivalence, wildcard matching, and
action filtering.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address self-review — imports, dead code, consistent teal scope checks
- Move deferred imports to top-level (no circular dep risk)
- Remove dead blob branch in check_scope_coverage (functionally identical
to the generic fallback)
- Migrate remaining teal scope substring checks in playback.py and oauth.py
to use ScopesSet.matches() for consistency with preferences.py
- Bump atproto dep to pick up blob matching fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
add output_client_id to jam state so only one participant's browser
plays audio. everyone else sees the queue, controls playback, adds
tracks — but their browser doesn't produce sound.
backend:
- output_client_id/output_did in jam state, set_output command
- auto-set output to host on first WS sync
- clear output + pause when output device disconnects or leaves
- fix _close_ws_for_did race: clear output before popping client_id
- validate jam_id in set_output to prevent cross-jam spoofing
frontend playback fixes (discovered during integration):
- autoplay policy: queue.play()/pause() set player.paused synchronously
alongside jam bridge call — WS round-trip broke gesture context
- audio event fight: onplay/onpause handlers skip during jam —
drift correction seeking fired onpause, which paused playback
- output transfer: explicitly pause audio when isOutputDevice flips
false — was returning early without stopping the audio element
frontend UI:
- output status in queue panel and player stripe
- "play here" button for non-output devices
- speaker badge on output participant's avatar
- non-output progress bar interpolation (250ms interval)
12 new tests covering output lifecycle, cross-client commands,
WS replacement race condition, and jam_id validation.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
fetchQueue() fired as fire-and-forget during layout init, racing with
jam.join(). When the response arrived after syncToQueue() had set
queue.tracks to jam tracks, applySnapshot() overwrote them with the
personal queue — disabling next/previous even though the Queue UI
(reading jam.tracks directly) showed the correct tracks.
Three guards in queue.svelte.ts:
- fetchQueue top: skip if jam bridge active
- fetchQueue before applySnapshot: catch mid-flight jam activation
- pushQueue top: don't push jam tracks as personal queue
Also reorder layout init: check for active jam first, skip fetchQueue
entirely when rejoining (avoids wasted request + eliminates the race).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
jam.join() now returns the server's detail message (e.g. "jams feature
not enabled") instead of a generic boolean, so the join page and toast
show the actual reason for failure.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add jams — shared listening rooms via queue bridge pattern
Jams let users listen together in real-time. The queue becomes the shared
state — a jam is "your queue, but shared." Any participant can change
playback, add/remove tracks, or seek.
Backend: Redis Streams for real-time state broadcast, WebSocket for
bidirectional sync, Postgres for jam/participant persistence. All
commands go through a central handler with revision-based ordering.
Frontend: Bridge pattern — queue is the single gate for all playback
mutations. When a jam is active, queue methods route through a JamBridge
that sends WebSocket commands instead of local mutations. No scattered
`if (jam.active)` conditionals in UI components.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: jam join uses goto() instead of full reload, reconnect on startup
- join page: window.location.href → goto('/') to preserve runtime state
- layout: fetchActive() on startup reconnects to active jams after refresh
- layout: $effect auto-opens queue panel when jam activates
- docs: comprehensive rewrite of jams.md with current implementation state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address 5 review items for jams feature
1. bridge read-path: jam state now syncs tracks/index into queue so
hasNext/hasPrevious/handleTrackEnded work correctly for joiners
2. WS track metadata: include hydrated tracks in Redis stream events
when tracks_changed, so clients can update their track list
3. concurrent command race: SELECT FOR UPDATE serializes commands
4. WS membership check: verify participant before ws.accept()
5. keyboard shortcuts: space/seek/previous route through queue methods
so jam bridge intercepts them
adds 2 regression tests (non-participant rejection, sequential revisions)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: close remaining jam bridge bypasses and stale WS connections
1. route player.togglePlayPause() through queue in track/album/playlist
pages so the jam bridge intercepts play/pause during active jams
2. route Media Session seek handlers through queue.seek() instead of
directly mutating audioElement.currentTime
3. always include tracks array in WS events when tracks_changed (even
when empty) so clients can clear their track list
4. track WS connections by DID and close stale sockets when a user
connects to a new jam (prevents ghost listeners after auto-leave)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: block unbridged queue mutations during jams, add DID socket test
- Guard setQueue/toggleShuffle/moveTrack/clearUpNext in queue class
when jamBridge is active (no backend commands exist for these)
- Block playQueue() during jams with user-facing toast
- Hide shuffle/clear/drag-reorder controls in jam-mode Queue.svelte
- Add unit test for DID socket replacement behavior (code 4010)
- Update jams.md: resolved unbridged methods, noted product semantics
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: handle terminal WS close codes, implement participant event handler
- Stop reconnect loop on codes 4003 (not participant) and 4010 (replaced);
reset local jam state and restore personal queue instead
- Implement handleParticipantMessage: fetch fresh participant list with
metadata on join/leave events so avatars update in real time
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: report flag margin position, queue stuck open during jam
- Header .margin-right: use right:0 instead of right:var(--queue-width)
since the header is already constrained by parent margin-right
- Queue auto-open effect: untrack showQueue so it only fires on
jam.active transition, not on every queue toggle (was a reactive loop)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: gate fetchActive behind jams flag to skip wasted 403 for non-flagged users
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
piggyback on existing queue sync — add progress_ms to the QueueState
JSON dict (no backend changes needed, stored and returned verbatim).
- Player $effect syncs currentTime → queue.progressMs continuously
- pushQueue includes progress_ms in every save
- applySnapshot restores progressMs from server state
- loadeddata handler seeks to saved position on initial hydration
(skips if near end of track to avoid stale near-end seeks)
- flushSync always pushes if tracks exist (saves position on tab close)
- keepalive: true on the PUT fetch so it survives page teardown
- 30s periodic save interval for crash resilience (~30s max loss)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
debounced search against GET /tracks/tags with suggestions dropdown,
keyboard navigation, and compact glass-effect styling matching the
existing filter bar aesthetic.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
supporters who have Bluesky accounts but haven't used plyr.fm showed
only their initial letter because /artists/batch only returns DIDs in
our DB. now falls back to constructing a CDN URL from the atprotofans
API's avatar blob data (did + CID).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
previous commit removed all copyright notifications including admin.
re-added as admin-only: sends copyright flag DM to recipient_did (admin)
without DMing the artist.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
copyright scan notifications were DMing both the artist and admin
on every flag. removed the notification block from _store_scan_result,
the send_copyright_notification method, and the dead _emit_copyright_label
function.
scans still run and store results — they just don't notify anyone.
admin DMs for new tracks, flagged images, and user reports are unchanged.
refs #702
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
sparkline: moved histogram fetch into the page loader so it arrives
with the initial data instead of a separate onMount fetch that was
silently failing in production.
pagination: added staggered fly-in transitions for newly loaded items
(batch-index-based delay via svelte/transition). loading spinner now
only appears after 400ms so fast responses don't flash it.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- revert sparkline to 7-day window (was accidentally changed to 30)
- wrap verb text in accent-tinted spans (smaller, subtly colored)
- add artist_avatar_url to ActivityTrack model + inline avatar rendering
- fix pagination by encoding cursor (+ in UTC offset decoded as space)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- sparkline: fetch 30 days instead of 7, skip render when all buckets zero
- neon glow: use inset overlay with border-radius: inherit so accent
follows card corners instead of floating as a clipped bar
- timestamp: add title attr for native hover tooltip with full date/time
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- add GET /activity/histogram endpoint (daily counts over last 7 days)
- SVG sparkline at top of activity page with accent gradient fill
- replace solid border-left with fuzzy neon glow (::before + box-shadow)
- smaller, less blurred lava blobs (5 blobs, 50px blur, ~180-280px max)
- fix TrackCard avatar fallback: container becomes circular when showing
artist avatar so no ugly square outline around circle
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- animated lava lamp with accent-tinted gradient orbs (3 blobs, 24-34s cycles)
- artwork moved to left column with avatar badge overlay (fixes alignment)
- glass cards with backdrop-filter and type-tinted hover glow via --type-color
- respects prefers-reduced-motion
- mobile-responsive, compact CSS (331 lines)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- show track thumbnail/image on right side of event cards
- use person silhouette SVG for avatar placeholders (matches TrackItem/TrackCard)
- stats-informed header showing track/artist/duration counts
- play button placeholder for tracks without artwork
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
drop filled icons for outline-style strokes matching app conventions.
upload arrow for shares (not music note — could be any audio), heart
outline for likes, person+ for joins (not star). add per-type colored
left border accent for visual rhythm. larger avatars, two-line layout
with name/time header + action line below.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
- filter out artists with empty handle/display_name from join events
(corrupted row: did:plc:dqbj7k7dierhqdowuyv5ll2z in staging)
- redesign activity page: card containers with glass bg + hover states,
event type icons (heart/note/chat/star), accent-styled comment
previews, right-aligned timestamps, consistent design token usage
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add platform-wide activity feed (#971)
chronological feed of likes, track uploads, comments, and profile
joins. unlisted page at /activity — no nav link, accessible by URL.
reconstructed from existing DB tables via raw SQL UNION ALL query
with cursor-based pagination. includes 9 backend tests.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: add db_session to audio tests missing database setup
test_stream_audio_track_not_found and test_get_audio_url_not_found
were missing db_session fixture, causing "relation tracks does not
exist" under xdist when no prior test on the worker triggered table
creation.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: handle docket worker teardown on stale event loop
under xdist, session-scoped TestClient teardown can run on a
different event loop than the one the docket worker task was created
on, causing RuntimeError. catch and log it during shutdown.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: skip docket worker in test lifespan to fix xdist teardown
the production lifespan starts a docket Worker that creates
asyncio.Task objects bound to the TestClient's portal event loop.
under xdist, session teardown runs on a different loop, causing
"attached to a different loop" in Worker.__aexit__.
no test needs a live docket worker (all docket usage is mocked),
so swap in a lightweight test lifespan that skips it entirely.
also reverts the _is_stale_loop_error hack in background.py.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* perf: add per-branch LIMIT and created_at indexes for activity feed
each UNION ALL branch now sorts+limits independently so postgres can
index-scan the top N per table instead of materializing all rows.
adds standalone created_at DESC indexes on artists, track_likes, and
track_comments to support the global feed query pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add SHORT container query (max-height: 99px) to track embed that hides
player-controls when container is too short, preventing clipped progress
bar. Guard CollectionEmbed WIDE query with aspect-ratio > 1.2 to prevent
it from applying in portrait containers.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The site redirects zzstoatzz.github.io → zzstoatzz.io, so the
browser origin is the custom domain. Allow both with a regex group.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Add the origin to the production CORS allowlist so the personal
site can use the search API for its embedded plyr.fm player.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: preserve jam link through login flow
unauthenticated users hitting a jam invite link now see a preview card
(host avatar, name, participant count) with a "sign in to join" button
instead of a confusing error. the jam path is stored in a cookie that
survives the OAuth round-trip, redirecting back after login or profile
setup.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address self-review — open redirect, design consistency, docs
- validate return_to param in login page back link (was unsanitized href)
- re-validate cookie value in getReturnUrl() (cookies are client-writable)
- extract isValidReturnPath() for shared validation logic
- use WaveLoading component instead of plain text for auth loading state
- add card surface (bg-tertiary, border, radius) to jam preview card
- remove unique avatar border to match other avatar patterns
- add docs/frontend/redirect-after-login.md
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
/jam/ was missing from hasPageMetadata in root layout, so default OG
tags rendered first and crawlers used the generic logo instead of
jam-specific metadata. Also expanded jam OG tags to match track/album
quality (og:type, og:site_name, twitter card, image dimensions).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
disconnect_ws called _clear_output_if_matches (which publishes to Redis)
while the closing WS was still in self._connections. The _stream_reader
background task could pick up the event and _fan_out would try to send
to the already-closed WS, causing "Cannot call send once a close message
has been sent."
Move the discard(ws) before the _clear_output_if_matches call, matching
the pattern already used in _close_ws_for_did. This also improves
_find_fallback_output since it won't consider the departing WS as a
fallback candidate.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* fix: polish jam UI — hide code, clarify output labels, add link previews
- remove meaningless 8-char jam code from header (already in share URL)
- clarify output labels: "all devices" / "this device" / "another device"
- add public GET /jams/{code}/preview endpoint for OG meta tags
- add server-side load + og:title/description/image/url for jam links
- add regression tests for preview endpoint
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* chore: bump loq limit for test_jams.py, restore readable test style
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* feat: collapse participant strip to 2 avatars + N chip
Shows first 2 participants with a clickable +N button to expand.
Follows the same pattern as TrackItem tag overflow.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
upload_blob (phase 4) can refresh the OAuth token on 401 and persist it
to DB, but create_track_record (phase 6) was still using the stale
in-memory ctx.auth_session. The PDS would return 401 on record creation,
causing the upload to roll back.
Reload ctx.auth_session from DB after _upload_to_pds returns a result,
so downstream phases always use the current token.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
The release script only checked backend/src/backend/ for changes,
missing migration-only commits that require a backend redeploy.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: generate 96x96 WebP thumbnails for track/album/playlist artwork
Full-resolution images (potentially megabytes) were served for 48px
display contexts. This adds thumbnail generation on upload, a storage
protocol for type safety, fixes the image delete key prefix bug, and
includes a backfill script for existing images.
- Add StorageProtocol for type-safe dependency injection
- Generate 96x96 WebP thumbnails via Pillow on image upload
- Add thumbnail_url column to tracks, albums, playlists
- Fix image delete key missing images/ prefix
- Add build_image_url() to consolidate URL construction
- Frontend falls back to image_url when thumbnail_url is null
- Backfill script: scripts/backfill_thumbnails.py
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: improve test_storage_types patterns
- Return mock client from factory instead of module-level global
- Replace inspect.getsource assertion with behavioral delete test
that verifies the actual key passed to head_object
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: move deferred imports to top-level, add generate_and_save tests
- Move BytesIO and generate_and_save imports to module level in
uploads.py, albums.py, lists.py, and metadata_service.py
- Restore accidentally deleted comments in metadata_service.py
- Add test coverage for generate_and_save success/failure paths
- Add thumbnail_url to Playlist frontend type
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
networkArtists was component-local $state — destroyed on every
navigation away from the homepage and re-fetched on return. Now uses
a module-level singleton cache (like tracksCache, statsCache) with a
5-minute TTL so the section appears instantly on subsequent visits.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
SQLAlchemy JSONB stores Python None as JSON literal `null` (not SQL NULL)
by default. This made ungated tracks invisible to the PDS backfill query
(which filters on `support_gate IS NULL`) after any portal edit.
- add `none_as_null=True` to JSONB columns (track.support_gate, job.result)
- migration to fix 6 affected production rows
- show actual API error detail in PdsBackfillControl toast
- regression tests for NULL roundtrip behavior
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Jams no longer require the per-user "jams" flag. All authenticated
users can now create and join shared listening rooms.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
client-side safety net: when a state update arrives with one_speaker
mode and no output_client_id, the client immediately sends set_output.
first client to do this wins (server validates), eliminating the
"playing elsewhere with nobody assigned" state.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
backend: add fallback output on disconnect (prefers host), broaden
auto-output to any connecting client, not just host.
frontend: split jam header into two rows (identity+actions / output+mode),
remove "no output" UI branch, add defensive mobile overflow CSS.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: enable reorder and clear queue during jams
Add move_track and clear_upcoming backend commands, wire them through
the JamBridge interface, and show drag handles + clear button in the
Queue component during jam mode.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* refactor: replace per-command jam queue logic with single update_queue
the backend no longer reimplements next/previous/add/remove/move/clear —
the frontend does the mutation locally (same code path for solo and jam),
then pushes the resulting state via a single update_queue command.
enables setQueue, clear, and playNow in jams for free.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
dict(jam.state) creates a shallow copy where nested lists (track_ids)
are shared references. In-place mutations via .extend() mutate both
copies, so SQLAlchemy doesn't detect changes on reassignment. This
caused add_tracks data to be silently lost on subsequent commands.
Fixes all three state-copy sites: handle_command, _clear_output_if_matches,
and _handle_sync auto-output.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
PDS servers expand include:ns.permSet into granular repo:/rpc: scopes,
so the granted scope never contains the literal include: token. Check
namespace authority instead of exact string match.
This was causing 403 scope_upgrade_required for all sessions on staging
where resolved_scope uses permission sets (include:fm.plyr.stg.authFullApp).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: adopt spec-compliant scope parsing from atproto SDK
Replace naive set-subset scope checking with ScopesSet from the new
atproto_oauth.scopes library, which handles the full ATProto permission
grammar: positional/query format equivalence, wildcard matching, and
action filtering.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address self-review — imports, dead code, consistent teal scope checks
- Move deferred imports to top-level (no circular dep risk)
- Remove dead blob branch in check_scope_coverage (functionally identical
to the generic fallback)
- Migrate remaining teal scope substring checks in playback.py and oauth.py
to use ScopesSet.matches() for consistency with preferences.py
- Bump atproto dep to pick up blob matching fix
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
add output_client_id to jam state so only one participant's browser
plays audio. everyone else sees the queue, controls playback, adds
tracks — but their browser doesn't produce sound.
backend:
- output_client_id/output_did in jam state, set_output command
- auto-set output to host on first WS sync
- clear output + pause when output device disconnects or leaves
- fix _close_ws_for_did race: clear output before popping client_id
- validate jam_id in set_output to prevent cross-jam spoofing
frontend playback fixes (discovered during integration):
- autoplay policy: queue.play()/pause() set player.paused synchronously
alongside jam bridge call — WS round-trip broke gesture context
- audio event fight: onplay/onpause handlers skip during jam —
drift correction seeking fired onpause, which paused playback
- output transfer: explicitly pause audio when isOutputDevice flips
false — was returning early without stopping the audio element
frontend UI:
- output status in queue panel and player stripe
- "play here" button for non-output devices
- speaker badge on output participant's avatar
- non-output progress bar interpolation (250ms interval)
12 new tests covering output lifecycle, cross-client commands,
WS replacement race condition, and jam_id validation.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
fetchQueue() fired as fire-and-forget during layout init, racing with
jam.join(). When the response arrived after syncToQueue() had set
queue.tracks to jam tracks, applySnapshot() overwrote them with the
personal queue — disabling next/previous even though the Queue UI
(reading jam.tracks directly) showed the correct tracks.
Three guards in queue.svelte.ts:
- fetchQueue top: skip if jam bridge active
- fetchQueue before applySnapshot: catch mid-flight jam activation
- pushQueue top: don't push jam tracks as personal queue
Also reorder layout init: check for active jam first, skip fetchQueue
entirely when rejoining (avoids wasted request + eliminates the race).
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
* feat: add jams — shared listening rooms via queue bridge pattern
Jams let users listen together in real-time. The queue becomes the shared
state — a jam is "your queue, but shared." Any participant can change
playback, add/remove tracks, or seek.
Backend: Redis Streams for real-time state broadcast, WebSocket for
bidirectional sync, Postgres for jam/participant persistence. All
commands go through a central handler with revision-based ordering.
Frontend: Bridge pattern — queue is the single gate for all playback
mutations. When a jam is active, queue methods route through a JamBridge
that sends WebSocket commands instead of local mutations. No scattered
`if (jam.active)` conditionals in UI components.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: jam join uses goto() instead of full reload, reconnect on startup
- join page: window.location.href → goto('/') to preserve runtime state
- layout: fetchActive() on startup reconnects to active jams after refresh
- layout: $effect auto-opens queue panel when jam activates
- docs: comprehensive rewrite of jams.md with current implementation state
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: address 5 review items for jams feature
1. bridge read-path: jam state now syncs tracks/index into queue so
hasNext/hasPrevious/handleTrackEnded work correctly for joiners
2. WS track metadata: include hydrated tracks in Redis stream events
when tracks_changed, so clients can update their track list
3. concurrent command race: SELECT FOR UPDATE serializes commands
4. WS membership check: verify participant before ws.accept()
5. keyboard shortcuts: space/seek/previous route through queue methods
so jam bridge intercepts them
adds 2 regression tests (non-participant rejection, sequential revisions)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: close remaining jam bridge bypasses and stale WS connections
1. route player.togglePlayPause() through queue in track/album/playlist
pages so the jam bridge intercepts play/pause during active jams
2. route Media Session seek handlers through queue.seek() instead of
directly mutating audioElement.currentTime
3. always include tracks array in WS events when tracks_changed (even
when empty) so clients can clear their track list
4. track WS connections by DID and close stale sockets when a user
connects to a new jam (prevents ghost listeners after auto-leave)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: block unbridged queue mutations during jams, add DID socket test
- Guard setQueue/toggleShuffle/moveTrack/clearUpNext in queue class
when jamBridge is active (no backend commands exist for these)
- Block playQueue() during jams with user-facing toast
- Hide shuffle/clear/drag-reorder controls in jam-mode Queue.svelte
- Add unit test for DID socket replacement behavior (code 4010)
- Update jams.md: resolved unbridged methods, noted product semantics
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: handle terminal WS close codes, implement participant event handler
- Stop reconnect loop on codes 4003 (not participant) and 4010 (replaced);
reset local jam state and restore personal queue instead
- Implement handleParticipantMessage: fetch fresh participant list with
metadata on join/leave events so avatars update in real time
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: report flag margin position, queue stuck open during jam
- Header .margin-right: use right:0 instead of right:var(--queue-width)
since the header is already constrained by parent margin-right
- Queue auto-open effect: untrack showQueue so it only fires on
jam.active transition, not on every queue toggle (was a reactive loop)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
* fix: gate fetchActive behind jams flag to skip wasted 403 for non-flagged users
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
piggyback on existing queue sync — add progress_ms to the QueueState
JSON dict (no backend changes needed, stored and returned verbatim).
- Player $effect syncs currentTime → queue.progressMs continuously
- pushQueue includes progress_ms in every save
- applySnapshot restores progressMs from server state
- loadeddata handler seeks to saved position on initial hydration
(skips if near end of track to avoid stale near-end seeks)
- flushSync always pushes if tracks exist (saves position on tab close)
- keepalive: true on the PUT fetch so it survives page teardown
- 30s periodic save interval for crash resilience (~30s max loss)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
previous commit removed all copyright notifications including admin.
re-added as admin-only: sends copyright flag DM to recipient_did (admin)
without DMing the artist.
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
copyright scan notifications were DMing both the artist and admin
on every flag. removed the notification block from _store_scan_result,
the send_copyright_notification method, and the dead _emit_copyright_label
function.
scans still run and store results — they just don't notify anyone.
admin DMs for new tracks, flagged images, and user reports are unchanged.
refs #702
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>