audio streaming app plyr.fm

feat: add platform-wide activity feed (#1001)

* 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>

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
cc29d46c cd2e7b78

+892 -7
+45
backend/alembic/versions/2026_03_02_011216_a3d9fe3d8d02_add_activity_feed_indexes.py
···
··· 1 + """add activity feed indexes 2 + 3 + Revision ID: a3d9fe3d8d02 4 + Revises: 75e853113f1b 5 + Create Date: 2026-03-02 01:12:16.153266 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + 13 + from alembic import op 14 + 15 + # revision identifiers, used by Alembic. 16 + revision: str = "a3d9fe3d8d02" 17 + down_revision: str | Sequence[str] | None = "75e853113f1b" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Add created_at DESC indexes for activity feed query.""" 24 + op.create_index( 25 + "ix_artists_created_at", 26 + "artists", 27 + [sa.literal_column("created_at DESC")], 28 + ) 29 + op.create_index( 30 + "ix_track_likes_created_at", 31 + "track_likes", 32 + [sa.literal_column("created_at DESC")], 33 + ) 34 + op.create_index( 35 + "ix_track_comments_created_at", 36 + "track_comments", 37 + [sa.literal_column("created_at DESC")], 38 + ) 39 + 40 + 41 + def downgrade() -> None: 42 + """Remove activity feed indexes.""" 43 + op.drop_index("ix_track_comments_created_at", table_name="track_comments") 44 + op.drop_index("ix_track_likes_created_at", table_name="track_likes") 45 + op.drop_index("ix_artists_created_at", table_name="artists")
+2
backend/src/backend/api/__init__.py
··· 1 """api routers.""" 2 3 from backend.api.account import router as account_router 4 from backend.api.artists import router as artists_router 5 from backend.api.discover import router as discover_router 6 from backend.api.meta import router as meta_router ··· 21 22 __all__ = [ 23 "account_router", 24 "artists_router", 25 "audio_router", 26 "auth_router",
··· 1 """api routers.""" 2 3 from backend.api.account import router as account_router 4 + from backend.api.activity import router as activity_router 5 from backend.api.artists import router as artists_router 6 from backend.api.discover import router as discover_router 7 from backend.api.meta import router as meta_router ··· 22 23 __all__ = [ 24 "account_router", 25 + "activity_router", 26 "artists_router", 27 "audio_router", 28 "auth_router",
+224
backend/src/backend/api/activity.py
···
··· 1 + """activity feed — platform-wide chronological event stream.""" 2 + 3 + import logging 4 + from datetime import datetime 5 + from typing import Annotated, Literal 6 + 7 + from fastapi import APIRouter, Depends, HTTPException, Query 8 + from pydantic import BaseModel, field_validator 9 + from sqlalchemy import text 10 + from sqlalchemy.ext.asyncio import AsyncSession 11 + 12 + from backend._internal.atproto.profile import normalize_avatar_url 13 + from backend.models import get_db 14 + 15 + logger = logging.getLogger(__name__) 16 + 17 + router = APIRouter(prefix="/activity", tags=["activity"]) 18 + 19 + 20 + class ActivityActor(BaseModel): 21 + """actor who performed the activity.""" 22 + 23 + did: str 24 + handle: str 25 + display_name: str 26 + avatar_url: str | None 27 + 28 + @field_validator("avatar_url", mode="before") 29 + @classmethod 30 + def normalize_avatar(cls, v: str | None) -> str | None: 31 + return normalize_avatar_url(v) 32 + 33 + 34 + class ActivityTrack(BaseModel): 35 + """track referenced in an activity event.""" 36 + 37 + id: int 38 + title: str 39 + artist_handle: str 40 + image_url: str | None 41 + thumbnail_url: str | None 42 + 43 + 44 + class ActivityEvent(BaseModel): 45 + """single activity event.""" 46 + 47 + type: Literal["like", "track", "comment", "join"] 48 + actor: ActivityActor 49 + track: ActivityTrack | None = None 50 + comment_text: str | None = None 51 + created_at: datetime 52 + 53 + 54 + class ActivityFeedResponse(BaseModel): 55 + """paginated activity feed.""" 56 + 57 + events: list[ActivityEvent] 58 + next_cursor: str | None = None 59 + has_more: bool = False 60 + 61 + 62 + # raw SQL for the UNION ALL query — each branch selects the same column shape 63 + _BASE_COLUMNS = """ 64 + a.did AS actor_did, 65 + a.handle AS actor_handle, 66 + a.display_name AS actor_display_name, 67 + a.avatar_url AS actor_avatar_url 68 + """ 69 + 70 + _TRACK_COLUMNS = """ 71 + t.id AS track_id, 72 + t.title AS track_title, 73 + ta.handle AS track_artist_handle, 74 + t.image_url AS track_image_url, 75 + t.thumbnail_url AS track_thumbnail_url 76 + """ 77 + 78 + _LIKE_QUERY = f""" 79 + (SELECT 'like' AS event_type, 80 + {_BASE_COLUMNS}, 81 + {_TRACK_COLUMNS}, 82 + NULL AS comment_text, 83 + tl.created_at AS created_at 84 + FROM track_likes tl 85 + JOIN artists a ON a.did = tl.user_did 86 + JOIN tracks t ON t.id = tl.track_id 87 + JOIN artists ta ON ta.did = t.artist_did 88 + {{cursor_clause}} 89 + ORDER BY tl.created_at DESC LIMIT :limit) 90 + """ 91 + 92 + _TRACK_QUERY = f""" 93 + (SELECT 'track' AS event_type, 94 + {_BASE_COLUMNS}, 95 + t.id AS track_id, 96 + t.title AS track_title, 97 + a.handle AS track_artist_handle, 98 + t.image_url AS track_image_url, 99 + t.thumbnail_url AS track_thumbnail_url, 100 + NULL AS comment_text, 101 + t.created_at AS created_at 102 + FROM tracks t 103 + JOIN artists a ON a.did = t.artist_did 104 + {{cursor_clause}} 105 + ORDER BY t.created_at DESC LIMIT :limit) 106 + """ 107 + 108 + _COMMENT_QUERY = f""" 109 + (SELECT 'comment' AS event_type, 110 + {_BASE_COLUMNS}, 111 + {_TRACK_COLUMNS}, 112 + tc.text AS comment_text, 113 + tc.created_at AS created_at 114 + FROM track_comments tc 115 + JOIN artists a ON a.did = tc.user_did 116 + JOIN tracks t ON t.id = tc.track_id 117 + JOIN artists ta ON ta.did = t.artist_did 118 + {{cursor_clause}} 119 + ORDER BY tc.created_at DESC LIMIT :limit) 120 + """ 121 + 122 + _JOIN_QUERY = f""" 123 + (SELECT 'join' AS event_type, 124 + {_BASE_COLUMNS}, 125 + NULL::integer AS track_id, 126 + NULL AS track_title, 127 + NULL AS track_artist_handle, 128 + NULL AS track_image_url, 129 + NULL AS track_thumbnail_url, 130 + NULL AS comment_text, 131 + a.created_at AS created_at 132 + FROM artists a 133 + {{cursor_clause}} 134 + ORDER BY a.created_at DESC LIMIT :limit) 135 + """ 136 + 137 + 138 + def _build_query(cursor: datetime | None) -> str: 139 + """build the UNION ALL query, conditionally including cursor filter. 140 + 141 + each branch has its own ORDER BY + LIMIT so postgres can index-scan 142 + the top N from each table independently, rather than materializing 143 + all qualifying rows before sorting. 144 + """ 145 + if cursor: 146 + like_clause = "WHERE tl.created_at < :cursor" 147 + track_clause = "WHERE t.created_at < :cursor" 148 + comment_clause = "WHERE tc.created_at < :cursor" 149 + join_clause = "WHERE a.created_at < :cursor" 150 + else: 151 + like_clause = "" 152 + track_clause = "" 153 + comment_clause = "" 154 + join_clause = "" 155 + 156 + parts = [ 157 + _LIKE_QUERY.format(cursor_clause=like_clause), 158 + _TRACK_QUERY.format(cursor_clause=track_clause), 159 + _COMMENT_QUERY.format(cursor_clause=comment_clause), 160 + _JOIN_QUERY.format(cursor_clause=join_clause), 161 + ] 162 + 163 + return " UNION ALL ".join(parts) + " ORDER BY created_at DESC LIMIT :limit" 164 + 165 + 166 + @router.get("/") 167 + async def get_activity_feed( 168 + db: Annotated[AsyncSession, Depends(get_db)], 169 + cursor: str | None = Query(None), 170 + limit: int = Query(20), 171 + ) -> ActivityFeedResponse: 172 + """get the platform-wide activity feed.""" 173 + limit = max(1, min(limit, 100)) 174 + 175 + cursor_time: datetime | None = None 176 + if cursor: 177 + try: 178 + cursor_time = datetime.fromisoformat(cursor) 179 + except ValueError as e: 180 + raise HTTPException(status_code=400, detail="invalid cursor format") from e 181 + 182 + query = _build_query(cursor_time) 183 + params: dict[str, object] = {"limit": limit + 1} 184 + if cursor_time: 185 + params["cursor"] = cursor_time 186 + 187 + result = await db.execute(text(query), params) 188 + rows = result.fetchall() 189 + 190 + has_more = len(rows) > limit 191 + if has_more: 192 + rows = rows[:limit] 193 + 194 + events = [ 195 + ActivityEvent( 196 + type=row.event_type, 197 + actor=ActivityActor( 198 + did=row.actor_did, 199 + handle=row.actor_handle, 200 + display_name=row.actor_display_name, 201 + avatar_url=row.actor_avatar_url, 202 + ), 203 + track=ActivityTrack( 204 + id=row.track_id, 205 + title=row.track_title, 206 + artist_handle=row.track_artist_handle, 207 + image_url=row.track_image_url, 208 + thumbnail_url=row.track_thumbnail_url, 209 + ) 210 + if row.track_id is not None 211 + else None, 212 + comment_text=row.comment_text, 213 + created_at=row.created_at, 214 + ) 215 + for row in rows 216 + ] 217 + 218 + next_cursor = events[-1].created_at.isoformat() if has_more and events else None 219 + 220 + return ActivityFeedResponse( 221 + events=events, 222 + next_cursor=next_cursor, 223 + has_more=has_more, 224 + )
+2
backend/src/backend/main.py
··· 16 from backend._internal.background import background_worker_lifespan 17 from backend.api import ( 18 account_router, 19 artists_router, 20 audio_router, 21 auth_router, ··· 125 # routers 126 app.include_router(auth_router) 127 app.include_router(account_router) 128 app.include_router(artists_router) 129 app.include_router(discover_router) 130 app.include_router(tracks_router)
··· 16 from backend._internal.background import background_worker_lifespan 17 from backend.api import ( 18 account_router, 19 + activity_router, 20 artists_router, 21 audio_router, 22 auth_router, ··· 126 # routers 127 app.include_router(auth_router) 128 app.include_router(account_router) 129 + app.include_router(activity_router) 130 app.include_router(artists_router) 131 app.include_router(discover_router) 132 app.include_router(tracks_router)
+6 -1
backend/src/backend/models/artist.py
··· 3 from datetime import UTC, datetime 4 from typing import TYPE_CHECKING 5 6 - from sqlalchemy import DateTime, String 7 from sqlalchemy.orm import Mapped, mapped_column, relationship 8 9 from backend.models.database import Base ··· 50 playlists: Mapped[list["Playlist"]] = relationship( 51 "Playlist", back_populates="owner" 52 )
··· 3 from datetime import UTC, datetime 4 from typing import TYPE_CHECKING 5 6 + from sqlalchemy import DateTime, Index, String 7 from sqlalchemy.orm import Mapped, mapped_column, relationship 8 9 from backend.models.database import Base ··· 50 playlists: Mapped[list["Playlist"]] = relationship( 51 "Playlist", back_populates="owner" 52 ) 53 + 54 + __table_args__ = ( 55 + # index for global feed queries (activity feed ORDER BY created_at DESC) 56 + Index("ix_artists_created_at", created_at.desc()), 57 + )
+2
backend/src/backend/models/track_comment.py
··· 66 Index("ix_track_comments_track_timestamp", "track_id", "timestamp_ms"), 67 # composite index for user's comments (order by recency handled in queries) 68 Index("ix_track_comments_user_created", "user_did", "created_at"), 69 )
··· 66 Index("ix_track_comments_track_timestamp", "track_id", "timestamp_ms"), 67 # composite index for user's comments (order by recency handled in queries) 68 Index("ix_track_comments_user_created", "user_did", "created_at"), 69 + # standalone index for global feed queries (activity feed ORDER BY created_at DESC) 70 + Index("ix_track_comments_created_at", created_at.desc()), 71 )
+2
backend/src/backend/models/track_like.py
··· 53 UniqueConstraint("track_id", "user_did", name="uq_track_user_like"), 54 # composite index for efficient user likes queries with sorting 55 Index("ix_track_likes_user_did_created_at", "user_did", created_at.desc()), 56 )
··· 53 UniqueConstraint("track_id", "user_did", name="uq_track_user_like"), 54 # composite index for efficient user likes queries with sorting 55 Index("ix_track_likes_user_did_created_at", "user_did", created_at.desc()), 56 + # standalone index for global feed queries (activity feed ORDER BY created_at DESC) 57 + Index("ix_track_likes_created_at", created_at.desc()), 58 )
+236
backend/tests/api/test_activity.py
···
··· 1 + """tests for activity feed endpoint.""" 2 + 3 + from datetime import UTC, datetime, timedelta 4 + 5 + import pytest 6 + from fastapi.testclient import TestClient 7 + from sqlalchemy.ext.asyncio import AsyncSession 8 + 9 + from backend.models import Artist, Track, TrackComment, TrackLike 10 + 11 + 12 + @pytest.fixture 13 + async def artist(db_session: AsyncSession) -> Artist: 14 + """create a test artist.""" 15 + artist = Artist( 16 + did="did:plc:activity_artist", 17 + handle="activity-artist.bsky.social", 18 + display_name="Activity Artist", 19 + ) 20 + db_session.add(artist) 21 + await db_session.commit() 22 + return artist 23 + 24 + 25 + @pytest.fixture 26 + async def other_artist(db_session: AsyncSession) -> Artist: 27 + """create a second test artist.""" 28 + artist = Artist( 29 + did="did:plc:activity_other", 30 + handle="other-artist.bsky.social", 31 + display_name="Other Artist", 32 + ) 33 + db_session.add(artist) 34 + await db_session.commit() 35 + return artist 36 + 37 + 38 + @pytest.fixture 39 + async def track(db_session: AsyncSession, artist: Artist) -> Track: 40 + """create a test track.""" 41 + track = Track( 42 + title="Test Track", 43 + artist_did=artist.did, 44 + file_id="activity_track_1", 45 + file_type="mp3", 46 + image_url="https://example.com/image.jpg", 47 + thumbnail_url="https://example.com/thumb.jpg", 48 + ) 49 + db_session.add(track) 50 + await db_session.commit() 51 + return track 52 + 53 + 54 + async def test_empty_feed(client: TestClient, db_session: AsyncSession) -> None: 55 + """empty database returns empty events with no cursor.""" 56 + response = client.get("/activity/") 57 + assert response.status_code == 200 58 + data = response.json() 59 + assert data["events"] == [] 60 + assert data["next_cursor"] is None 61 + assert data["has_more"] is False 62 + 63 + 64 + async def test_all_event_types( 65 + client: TestClient, 66 + db_session: AsyncSession, 67 + artist: Artist, 68 + other_artist: Artist, 69 + track: Track, 70 + ) -> None: 71 + """all four event types appear with correct type field.""" 72 + like = TrackLike( 73 + track_id=track.id, 74 + user_did=other_artist.did, 75 + ) 76 + comment = TrackComment( 77 + track_id=track.id, 78 + user_did=other_artist.did, 79 + text="great track!", 80 + timestamp_ms=5000, 81 + ) 82 + db_session.add_all([like, comment]) 83 + await db_session.commit() 84 + 85 + response = client.get("/activity/") 86 + assert response.status_code == 200 87 + data = response.json() 88 + 89 + event_types = {e["type"] for e in data["events"]} 90 + assert event_types == {"like", "track", "comment", "join"} 91 + 92 + 93 + async def test_chronological_order( 94 + client: TestClient, 95 + db_session: AsyncSession, 96 + artist: Artist, 97 + track: Track, 98 + ) -> None: 99 + """events are ordered by created_at DESC.""" 100 + response = client.get("/activity/") 101 + assert response.status_code == 200 102 + data = response.json() 103 + 104 + timestamps = [e["created_at"] for e in data["events"]] 105 + assert timestamps == sorted(timestamps, reverse=True) 106 + 107 + 108 + async def test_cursor_pagination( 109 + client: TestClient, 110 + db_session: AsyncSession, 111 + artist: Artist, 112 + ) -> None: 113 + """cursor pagination returns two pages with no overlap.""" 114 + now = datetime.now(UTC) 115 + tracks = [] 116 + for i in range(5): 117 + t = Track( 118 + title=f"Track {i}", 119 + artist_did=artist.did, 120 + file_id=f"pagination_{i}", 121 + file_type="mp3", 122 + created_at=now + timedelta(seconds=i + 1), 123 + ) 124 + tracks.append(t) 125 + db_session.add_all(tracks) 126 + await db_session.commit() 127 + 128 + # page 1 (limit=3: 5 tracks + 1 join = 6 total events, should have more) 129 + resp1 = client.get("/activity/", params={"limit": 3}) 130 + assert resp1.status_code == 200 131 + page1 = resp1.json() 132 + assert page1["has_more"] is True 133 + assert page1["next_cursor"] is not None 134 + 135 + # page 2 136 + resp2 = client.get( 137 + "/activity/", params={"limit": 3, "cursor": page1["next_cursor"]} 138 + ) 139 + assert resp2.status_code == 200 140 + page2 = resp2.json() 141 + 142 + # no overlap 143 + page1_times = {e["created_at"] for e in page1["events"]} 144 + page2_times = {e["created_at"] for e in page2["events"]} 145 + assert page1_times.isdisjoint(page2_times) 146 + 147 + 148 + async def test_invalid_cursor(client: TestClient, db_session: AsyncSession) -> None: 149 + """invalid cursor returns 400.""" 150 + response = client.get("/activity/", params={"cursor": "not-a-date"}) 151 + assert response.status_code == 400 152 + assert "cursor" in response.json()["detail"].lower() 153 + 154 + 155 + async def test_limit_clamping( 156 + client: TestClient, 157 + db_session: AsyncSession, 158 + artist: Artist, 159 + ) -> None: 160 + """limit is clamped: 0 → 1, 200 → 100.""" 161 + resp_low = client.get("/activity/", params={"limit": 0}) 162 + assert resp_low.status_code == 200 163 + 164 + resp_high = client.get("/activity/", params={"limit": 200}) 165 + assert resp_high.status_code == 200 166 + 167 + 168 + async def test_like_includes_track_info( 169 + client: TestClient, 170 + db_session: AsyncSession, 171 + artist: Artist, 172 + other_artist: Artist, 173 + track: Track, 174 + ) -> None: 175 + """like events include track info.""" 176 + like = TrackLike( 177 + track_id=track.id, 178 + user_did=other_artist.did, 179 + ) 180 + db_session.add(like) 181 + await db_session.commit() 182 + 183 + response = client.get("/activity/") 184 + assert response.status_code == 200 185 + data = response.json() 186 + 187 + like_events = [e for e in data["events"] if e["type"] == "like"] 188 + assert len(like_events) >= 1 189 + like_event = like_events[0] 190 + assert like_event["track"] is not None 191 + assert like_event["track"]["id"] == track.id 192 + assert like_event["track"]["title"] == track.title 193 + assert like_event["track"]["artist_handle"] == artist.handle 194 + 195 + 196 + async def test_comment_includes_text( 197 + client: TestClient, 198 + db_session: AsyncSession, 199 + artist: Artist, 200 + other_artist: Artist, 201 + track: Track, 202 + ) -> None: 203 + """comment events include comment_text.""" 204 + comment = TrackComment( 205 + track_id=track.id, 206 + user_did=other_artist.did, 207 + text="this slaps", 208 + timestamp_ms=0, 209 + ) 210 + db_session.add(comment) 211 + await db_session.commit() 212 + 213 + response = client.get("/activity/") 214 + assert response.status_code == 200 215 + data = response.json() 216 + 217 + comment_events = [e for e in data["events"] if e["type"] == "comment"] 218 + assert len(comment_events) >= 1 219 + assert comment_events[0]["comment_text"] == "this slaps" 220 + assert comment_events[0]["track"] is not None 221 + 222 + 223 + async def test_join_has_null_track( 224 + client: TestClient, 225 + db_session: AsyncSession, 226 + artist: Artist, 227 + ) -> None: 228 + """join events have track: null.""" 229 + response = client.get("/activity/") 230 + assert response.status_code == 200 231 + data = response.json() 232 + 233 + join_events = [e for e in data["events"] if e["type"] == "join"] 234 + assert len(join_events) >= 1 235 + assert join_events[0]["track"] is None 236 + assert join_events[0]["comment_text"] is None
+6 -2
backend/tests/api/test_audio.py
··· 134 assert response.headers["location"] == expected_url 135 136 137 - async def test_stream_audio_track_not_found(test_app: FastAPI): 138 """test that endpoint returns 404 when track doesn't exist in DB.""" 139 async with AsyncClient( 140 transport=ASGITransport(app=test_app), base_url="http://test" ··· 231 test_app.dependency_overrides.pop(require_auth, None) 232 233 234 - async def test_get_audio_url_not_found(test_app: FastAPI, mock_session: Session): 235 """test that /url endpoint returns 404 for nonexistent track.""" 236 test_app.dependency_overrides[require_auth] = lambda: mock_session 237
··· 134 assert response.headers["location"] == expected_url 135 136 137 + async def test_stream_audio_track_not_found( 138 + test_app: FastAPI, db_session: AsyncSession 139 + ): 140 """test that endpoint returns 404 when track doesn't exist in DB.""" 141 async with AsyncClient( 142 transport=ASGITransport(app=test_app), base_url="http://test" ··· 233 test_app.dependency_overrides.pop(require_auth, None) 234 235 236 + async def test_get_audio_url_not_found( 237 + test_app: FastAPI, mock_session: Session, db_session: AsyncSession 238 + ): 239 """test that /url endpoint returns 404 for nonexistent track.""" 240 test_app.dependency_overrides[require_auth] = lambda: mock_session 241
+30 -4
backend/tests/conftest.py
··· 1 """pytest configuration for relay tests.""" 2 3 import os 4 from collections.abc import AsyncGenerator, Callable, Generator 5 from contextlib import asynccontextmanager ··· 377 378 379 @pytest.fixture(scope="session") 380 - def fastapi_app() -> FastAPI: 381 - """provides the FastAPI app instance (session-scoped for performance).""" 382 from backend.main import app as main_app 383 384 - return main_app 385 386 387 @pytest.fixture(scope="session") ··· 389 """provides a TestClient for testing the FastAPI application. 390 391 session-scoped to avoid the overhead of starting the full lifespan 392 - (database init, services, docket worker) for each test. 393 """ 394 with TestClient(fastapi_app) as tc: 395 yield tc
··· 1 """pytest configuration for relay tests.""" 2 3 + import asyncio 4 + import contextlib 5 import os 6 from collections.abc import AsyncGenerator, Callable, Generator 7 from contextlib import asynccontextmanager ··· 379 380 381 @pytest.fixture(scope="session") 382 + def fastapi_app() -> Generator[FastAPI, None, None]: 383 + """provides the FastAPI app with a test lifespan that skips docket worker. 384 + 385 + docket Worker binds asyncio.Tasks to the TestClient's portal loop; under 386 + xdist, session teardown runs on a different loop → RuntimeError. no test 387 + needs a live worker (all docket usage is mocked), so skip it. 388 + """ 389 from backend.main import app as main_app 390 391 + original_lifespan = main_app.router.lifespan_context 392 + main_app.router.lifespan_context = _test_lifespan 393 + yield main_app 394 + main_app.router.lifespan_context = original_lifespan 395 + 396 + 397 + @asynccontextmanager 398 + async def _test_lifespan(app: FastAPI) -> AsyncGenerator[None, None]: 399 + """test lifespan — skips docket worker to avoid event loop issues.""" 400 + from backend._internal import jam_service, notification_service, queue_service 401 + 402 + await notification_service.setup() 403 + await queue_service.setup() 404 + await jam_service.setup() 405 + 406 + yield 407 + 408 + for service in (notification_service, queue_service, jam_service): 409 + with contextlib.suppress(TimeoutError): 410 + await asyncio.wait_for(service.shutdown(), timeout=2.0) 411 412 413 @pytest.fixture(scope="session") ··· 415 """provides a TestClient for testing the FastAPI application. 416 417 session-scoped to avoid the overhead of starting the full lifespan 418 + (database init, services) for each test. 419 """ 420 with TestClient(fastapi_app) as tc: 421 yield tc
+23
frontend/src/lib/types.ts
··· 166 avatar_url?: string; 167 } 168 169 export interface JamPlaybackState { 170 track_ids: string[]; 171 current_index: number;
··· 166 avatar_url?: string; 167 } 168 169 + export interface ActivityActor { 170 + did: string; 171 + handle: string; 172 + display_name: string; 173 + avatar_url: string | null; 174 + } 175 + 176 + export interface ActivityTrack { 177 + id: number; 178 + title: string; 179 + artist_handle: string; 180 + image_url: string | null; 181 + thumbnail_url: string | null; 182 + } 183 + 184 + export interface ActivityEvent { 185 + type: 'like' | 'track' | 'comment' | 'join'; 186 + actor: ActivityActor; 187 + track: ActivityTrack | null; 188 + comment_text: string | null; 189 + created_at: string; 190 + } 191 + 192 export interface JamPlaybackState { 193 track_ids: string[]; 194 current_index: number;
+1
frontend/src/routes/+layout.svelte
··· 30 // pages that define their own <title> in svelte:head 31 let hasPageMetadata = $derived( 32 $page.url.pathname === '/' || // homepage 33 $page.url.pathname.startsWith('/track/') || // track detail 34 $page.url.pathname.startsWith('/playlist/') || // playlist detail 35 $page.url.pathname.startsWith('/tag/') || // tag detail
··· 30 // pages that define their own <title> in svelte:head 31 let hasPageMetadata = $derived( 32 $page.url.pathname === '/' || // homepage 33 + $page.url.pathname === '/activity' || // activity feed 34 $page.url.pathname.startsWith('/track/') || // track detail 35 $page.url.pathname.startsWith('/playlist/') || // playlist detail 36 $page.url.pathname.startsWith('/tag/') || // tag detail
+284
frontend/src/routes/activity/+page.svelte
···
··· 1 + <script lang="ts"> 2 + import { onMount } from 'svelte'; 3 + import Header from '$lib/components/Header.svelte'; 4 + import WaveLoading from '$lib/components/WaveLoading.svelte'; 5 + import { auth } from '$lib/auth.svelte'; 6 + import { API_URL } from '$lib/config'; 7 + import { APP_NAME } from '$lib/branding'; 8 + import type { ActivityEvent } from '$lib/types'; 9 + 10 + let { data } = $props(); 11 + 12 + let events = $state<ActivityEvent[]>([]); 13 + let nextCursor = $state<string | null>(null); 14 + let hasMore = $state(false); 15 + let loadingMore = $state(false); 16 + let initialLoad = $state(true); 17 + 18 + let sentinelElement = $state<HTMLDivElement | null>(null); 19 + 20 + onMount(() => { 21 + auth.initialize(); 22 + events = data.events; 23 + nextCursor = data.next_cursor; 24 + hasMore = data.has_more; 25 + initialLoad = false; 26 + }); 27 + 28 + function timeAgo(iso: string): string { 29 + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); 30 + if (seconds < 60) return `${seconds}s ago`; 31 + const minutes = Math.floor(seconds / 60); 32 + if (minutes < 60) return `${minutes}m ago`; 33 + const hours = Math.floor(minutes / 60); 34 + if (hours < 24) return `${hours}h ago`; 35 + const days = Math.floor(hours / 24); 36 + if (days < 30) return `${days}d ago`; 37 + const months = Math.floor(days / 30); 38 + if (months < 12) return `${months}mo ago`; 39 + const years = Math.floor(days / 365); 40 + return `${years}y ago`; 41 + } 42 + 43 + async function loadMore() { 44 + if (!hasMore || !nextCursor || loadingMore) return; 45 + loadingMore = true; 46 + try { 47 + const response = await fetch(`${API_URL}/activity/?cursor=${nextCursor}`); 48 + if (response.ok) { 49 + const result = await response.json(); 50 + events = [...events, ...result.events]; 51 + nextCursor = result.next_cursor; 52 + hasMore = result.has_more; 53 + } 54 + } catch (e) { 55 + console.error('failed to load more activity:', e); 56 + } finally { 57 + loadingMore = false; 58 + } 59 + } 60 + 61 + // infinite scroll via IntersectionObserver 62 + $effect(() => { 63 + if (!sentinelElement) return; 64 + 65 + const observer = new IntersectionObserver( 66 + (entries) => { 67 + if (entries[0].isIntersecting && hasMore && !loadingMore) { 68 + loadMore(); 69 + } 70 + }, 71 + { rootMargin: '200px' } 72 + ); 73 + 74 + observer.observe(sentinelElement); 75 + 76 + return () => { 77 + observer.disconnect(); 78 + }; 79 + }); 80 + 81 + async function logout() { 82 + await auth.logout(); 83 + window.location.href = '/'; 84 + } 85 + </script> 86 + 87 + <svelte:head> 88 + <title>activity - {APP_NAME}</title> 89 + </svelte:head> 90 + 91 + <Header user={auth.user} isAuthenticated={auth.isAuthenticated} onLogout={logout} /> 92 + 93 + <main> 94 + <h1>activity</h1> 95 + 96 + {#if initialLoad} 97 + <div class="loading-container"> 98 + <WaveLoading size="lg" message="loading activity..." /> 99 + </div> 100 + {:else if events.length === 0} 101 + <p class="empty">no activity yet</p> 102 + {:else} 103 + <div class="event-list"> 104 + {#each events as event (event.created_at + event.actor.did + event.type)} 105 + <div class="event-item"> 106 + <a href="/u/{event.actor.handle}" class="avatar-link"> 107 + {#if event.actor.avatar_url} 108 + <img 109 + src={event.actor.avatar_url} 110 + alt={event.actor.display_name} 111 + class="avatar" 112 + /> 113 + {:else} 114 + <div class="avatar placeholder"></div> 115 + {/if} 116 + </a> 117 + 118 + <div class="event-body"> 119 + <p class="event-description"> 120 + <a href="/u/{event.actor.handle}" class="handle-link"> 121 + {event.actor.display_name || event.actor.handle} 122 + </a> 123 + 124 + {#if event.type === 'like' && event.track} 125 + liked 126 + <a href="/track/{event.track.id}" class="track-link"> 127 + {event.track.title} 128 + </a> 129 + {:else if event.type === 'track' && event.track} 130 + posted 131 + <a href="/track/{event.track.id}" class="track-link"> 132 + {event.track.title} 133 + </a> 134 + {:else if event.type === 'comment' && event.track} 135 + commented on 136 + <a href="/track/{event.track.id}" class="track-link"> 137 + {event.track.title} 138 + </a> 139 + {:else if event.type === 'join'} 140 + joined plyr.fm 141 + {/if} 142 + </p> 143 + 144 + {#if event.type === 'comment' && event.comment_text} 145 + <p class="comment-preview"> 146 + {event.comment_text.length > 100 147 + ? event.comment_text.slice(0, 100) + '...' 148 + : event.comment_text} 149 + </p> 150 + {/if} 151 + 152 + <span class="event-time">{timeAgo(event.created_at)}</span> 153 + </div> 154 + </div> 155 + {/each} 156 + </div> 157 + 158 + {#if hasMore} 159 + <div bind:this={sentinelElement} class="scroll-sentinel"> 160 + {#if loadingMore} 161 + <WaveLoading size="sm" message="loading more..." /> 162 + {/if} 163 + </div> 164 + {/if} 165 + {/if} 166 + </main> 167 + 168 + <style> 169 + main { 170 + max-width: 800px; 171 + margin: 0 auto; 172 + padding: 0 1rem calc(var(--player-height, 0px) + 2rem + env(safe-area-inset-bottom, 0px)); 173 + } 174 + 175 + h1 { 176 + font-size: var(--text-page-heading); 177 + font-weight: 700; 178 + color: var(--text-primary); 179 + margin: 0 0 1.5rem 0; 180 + } 181 + 182 + .loading-container { 183 + display: flex; 184 + justify-content: center; 185 + padding: 3rem 2rem; 186 + } 187 + 188 + .empty { 189 + color: var(--text-tertiary); 190 + padding: 2rem; 191 + text-align: center; 192 + } 193 + 194 + .event-list { 195 + display: flex; 196 + flex-direction: column; 197 + } 198 + 199 + .event-item { 200 + display: flex; 201 + gap: 0.75rem; 202 + padding: 0.75rem 0; 203 + border-bottom: 1px solid var(--border-subtle); 204 + } 205 + 206 + .event-item:last-child { 207 + border-bottom: none; 208 + } 209 + 210 + .avatar-link { 211 + flex-shrink: 0; 212 + } 213 + 214 + .avatar { 215 + width: 32px; 216 + height: 32px; 217 + border-radius: 50%; 218 + object-fit: cover; 219 + } 220 + 221 + .avatar.placeholder { 222 + background: var(--bg-tertiary); 223 + } 224 + 225 + .event-body { 226 + flex: 1; 227 + min-width: 0; 228 + } 229 + 230 + .event-description { 231 + font-size: var(--text-sm); 232 + color: var(--text-secondary); 233 + margin: 0; 234 + line-height: 1.4; 235 + } 236 + 237 + .handle-link { 238 + color: var(--text-primary); 239 + font-weight: 600; 240 + text-decoration: none; 241 + } 242 + 243 + .handle-link:hover { 244 + color: var(--accent); 245 + } 246 + 247 + .track-link { 248 + color: var(--text-primary); 249 + text-decoration: none; 250 + font-weight: 500; 251 + } 252 + 253 + .track-link:hover { 254 + color: var(--accent); 255 + } 256 + 257 + .comment-preview { 258 + font-size: var(--text-xs); 259 + color: var(--text-tertiary); 260 + margin: 0.25rem 0 0 0; 261 + line-height: 1.4; 262 + font-style: italic; 263 + } 264 + 265 + .event-time { 266 + font-size: var(--text-xs); 267 + color: var(--text-tertiary); 268 + margin-top: 0.125rem; 269 + display: block; 270 + } 271 + 272 + .scroll-sentinel { 273 + display: flex; 274 + justify-content: center; 275 + padding: 2rem 0; 276 + min-height: 60px; 277 + } 278 + 279 + @media (max-width: 768px) { 280 + main { 281 + padding: 0 0.75rem calc(var(--player-height, 0px) + 1.25rem + env(safe-area-inset-bottom, 0px)); 282 + } 283 + } 284 + </style>
+29
frontend/src/routes/activity/+page.ts
···
··· 1 + import { browser } from '$app/environment'; 2 + import { API_URL } from '$lib/config'; 3 + import type { ActivityEvent } from '$lib/types'; 4 + 5 + export interface PageData { 6 + events: ActivityEvent[]; 7 + next_cursor: string | null; 8 + has_more: boolean; 9 + } 10 + 11 + export const ssr = false; 12 + 13 + export async function load(): Promise<PageData> { 14 + if (!browser) { 15 + return { events: [], next_cursor: null, has_more: false }; 16 + } 17 + 18 + try { 19 + const response = await fetch(`${API_URL}/activity/`); 20 + if (!response.ok) { 21 + console.error('failed to load activity feed:', response.status); 22 + return { events: [], next_cursor: null, has_more: false }; 23 + } 24 + return await response.json(); 25 + } catch (e) { 26 + console.error('failed to load activity feed:', e); 27 + return { events: [], next_cursor: null, has_more: false }; 28 + } 29 + }