audio streaming app plyr.fm
38
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add now-playing API for teal.fm/Piper integration (#416)

* feat: add now-playing API for teal.fm/Piper integration

exposes real-time playback state for external scrobbler services.

backend:
- NowPlayingService with TTL cache (5 min expiry) for ephemeral state
- POST /now-playing/ - frontend reports playback state
- DELETE /now-playing/ - clear state on stop
- GET /now-playing/by-handle/{handle} - public endpoint for Piper
- GET /now-playing/by-did/{did} - alternative by DID
- returns 204 when nothing playing (matches Spotify pattern)
- response format compatible with Piper's expectations

frontend:
- now-playing.svelte.ts service with throttling/debouncing
- reports every 10s during playback, debounced for seeks
- integrated into Player.svelte effects

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

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

* fix: use cookie-based auth check in now-playing service

auth is cookie-based (HttpOnly), not localStorage. the localStorage.getItem('session_id')
check was leftover dead code that always failed, preventing now-playing from reporting.

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

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

* fix: use latest state in debounced now-playing reports

when pause events were debounced, the scheduled report used stale values
captured at schedule time instead of the latest state. now stores pending
state separately so the timer always fires with current values.

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

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

---------

Co-authored-by: Claude <noreply@anthropic.com>

authored by zzstoatzz.io

Claude and committed by
GitHub
7ec68dd7 177857bb

+815
+2
backend/src/backend/_internal/__init__.py
··· 24 24 ) 25 25 from backend._internal.constellation import get_like_count_safe 26 26 from backend._internal.notifications import notification_service 27 + from backend._internal.now_playing import now_playing_service 27 28 from backend._internal.queue import queue_service 28 29 29 30 __all__ = [ ··· 42 43 "handle_oauth_callback", 43 44 "list_developer_tokens", 44 45 "notification_service", 46 + "now_playing_service", 45 47 "oauth_client", 46 48 "queue_service", 47 49 "require_artist_profile",
+102
backend/src/backend/_internal/now_playing.py
··· 1 + """now playing service for real-time playback state. 2 + 3 + exposes current playback state for external integrations like teal.fm/Piper. 4 + """ 5 + 6 + import logging 7 + from dataclasses import dataclass 8 + from datetime import UTC, datetime 9 + 10 + from cachetools import TTLCache 11 + 12 + logger = logging.getLogger(__name__) 13 + 14 + # playback state expires after 5 minutes of no updates 15 + # this handles cases where user closes browser without explicit stop 16 + NOW_PLAYING_TTL_SECONDS = 300 17 + 18 + 19 + @dataclass 20 + class NowPlayingState: 21 + """current playback state for a user.""" 22 + 23 + # track metadata 24 + track_name: str 25 + artist_name: str 26 + album_name: str | None 27 + duration_ms: int 28 + progress_ms: int 29 + 30 + # track identifiers 31 + track_id: int 32 + file_id: str 33 + 34 + # plyr.fm URLs 35 + track_url: str # e.g., https://plyr.fm/track/123 36 + image_url: str | None 37 + 38 + # playback state 39 + is_playing: bool 40 + updated_at: datetime 41 + 42 + 43 + class NowPlayingService: 44 + """service for tracking real-time playback state. 45 + 46 + uses TTL cache - playback state expires after 5 minutes of no updates. 47 + this is ephemeral data, no database persistence needed. 48 + """ 49 + 50 + def __init__(self, ttl_seconds: int = NOW_PLAYING_TTL_SECONDS): 51 + # keyed by DID 52 + self._cache: TTLCache[str, NowPlayingState] = TTLCache( 53 + maxsize=10000, ttl=ttl_seconds 54 + ) 55 + 56 + def update( 57 + self, 58 + did: str, 59 + track_name: str, 60 + artist_name: str, 61 + album_name: str | None, 62 + duration_ms: int, 63 + progress_ms: int, 64 + track_id: int, 65 + file_id: str, 66 + track_url: str, 67 + image_url: str | None, 68 + is_playing: bool, 69 + ) -> None: 70 + """update playback state for a user.""" 71 + state = NowPlayingState( 72 + track_name=track_name, 73 + artist_name=artist_name, 74 + album_name=album_name, 75 + duration_ms=duration_ms, 76 + progress_ms=progress_ms, 77 + track_id=track_id, 78 + file_id=file_id, 79 + track_url=track_url, 80 + image_url=image_url, 81 + is_playing=is_playing, 82 + updated_at=datetime.now(UTC), 83 + ) 84 + self._cache[did] = state 85 + logger.debug(f"updated now playing for {did}: {track_name} by {artist_name}") 86 + 87 + def get(self, did: str) -> NowPlayingState | None: 88 + """get current playback state for a user.""" 89 + return self._cache.get(did) 90 + 91 + def clear(self, did: str) -> None: 92 + """clear playback state for a user (stopped playing).""" 93 + self._cache.pop(did, None) 94 + logger.debug(f"cleared now playing for {did}") 95 + 96 + def get_active_count(self) -> int: 97 + """get count of users currently playing (for metrics).""" 98 + return len(self._cache) 99 + 100 + 101 + # global instance 102 + now_playing_service = NowPlayingService()
+2
backend/src/backend/api/__init__.py
··· 5 5 from backend.api.audio import router as audio_router 6 6 from backend.api.auth import router as auth_router 7 7 from backend.api.exports import router as exports_router 8 + from backend.api.now_playing import router as now_playing_router 8 9 from backend.api.oembed import router as oembed_router 9 10 from backend.api.preferences import router as preferences_router 10 11 from backend.api.queue import router as queue_router ··· 18 19 "audio_router", 19 20 "auth_router", 20 21 "exports_router", 22 + "now_playing_router", 21 23 "oembed_router", 22 24 "preferences_router", 23 25 "queue_router",
+180
backend/src/backend/api/now_playing.py
··· 1 + """now playing API endpoints for external scrobbler integrations. 2 + 3 + exposes real-time playback state for services like teal.fm/Piper. 4 + """ 5 + 6 + from typing import Annotated 7 + 8 + from fastapi import APIRouter, Depends, HTTPException, Response 9 + from pydantic import BaseModel 10 + from sqlalchemy import select 11 + from sqlalchemy.ext.asyncio import AsyncSession 12 + 13 + from backend._internal import Session, now_playing_service, require_auth 14 + from backend.config import settings 15 + from backend.models import Artist, get_db 16 + 17 + router = APIRouter(prefix="/now-playing", tags=["now-playing"]) 18 + 19 + 20 + class NowPlayingUpdate(BaseModel): 21 + """request to update now playing state.""" 22 + 23 + track_id: int 24 + file_id: str 25 + track_name: str 26 + artist_name: str 27 + album_name: str | None = None 28 + duration_ms: int 29 + progress_ms: int 30 + is_playing: bool 31 + image_url: str | None = None 32 + 33 + 34 + class NowPlayingResponse(BaseModel): 35 + """now playing state response. 36 + 37 + designed to be compatible with teal.fm/Piper expectations. 38 + matches the fields Piper expects from music sources like Spotify. 39 + """ 40 + 41 + # track metadata (required by teal.fm lexicon) 42 + track_name: str 43 + artist_name: str 44 + album_name: str | None 45 + 46 + # playback state 47 + duration_ms: int 48 + progress_ms: int 49 + is_playing: bool 50 + 51 + # plyr.fm-specific identifiers 52 + track_id: int 53 + file_id: str 54 + track_url: str 55 + image_url: str | None 56 + 57 + # service identifier for Piper 58 + service_base_url: str = "plyr.fm" 59 + 60 + 61 + @router.post("/") 62 + async def update_now_playing( 63 + update: NowPlayingUpdate, 64 + session: Session = Depends(require_auth), 65 + ) -> dict: 66 + """update now playing state (authenticated). 67 + 68 + called by frontend to report current playback state. 69 + state expires after 5 minutes of no updates. 70 + """ 71 + track_url = f"{settings.frontend.url}/track/{update.track_id}" 72 + 73 + now_playing_service.update( 74 + did=session.did, 75 + track_name=update.track_name, 76 + artist_name=update.artist_name, 77 + album_name=update.album_name, 78 + duration_ms=update.duration_ms, 79 + progress_ms=update.progress_ms, 80 + track_id=update.track_id, 81 + file_id=update.file_id, 82 + track_url=track_url, 83 + image_url=update.image_url, 84 + is_playing=update.is_playing, 85 + ) 86 + 87 + return {"status": "ok"} 88 + 89 + 90 + @router.delete("/") 91 + async def clear_now_playing( 92 + session: Session = Depends(require_auth), 93 + ) -> dict: 94 + """clear now playing state (authenticated). 95 + 96 + called when user explicitly stops playback. 97 + """ 98 + now_playing_service.clear(session.did) 99 + return {"status": "ok"} 100 + 101 + 102 + @router.get("/by-handle/{handle}") 103 + async def get_now_playing_by_handle( 104 + handle: str, 105 + response: Response, 106 + db: Annotated[AsyncSession, Depends(get_db)], 107 + ) -> NowPlayingResponse: 108 + """get now playing state by handle (public). 109 + 110 + this is the endpoint Piper will poll to fetch current playback state. 111 + returns 204 No Content if nothing is playing. 112 + 113 + response format matches what Piper expects from music sources: 114 + - track_name: track title 115 + - artist_name: artist display name 116 + - album_name: album name (optional) 117 + - duration_ms: total duration in milliseconds 118 + - progress_ms: current playback position in milliseconds 119 + - is_playing: whether actively playing 120 + - track_url: link to track on plyr.fm 121 + - service_base_url: "plyr.fm" for Piper to identify the source 122 + """ 123 + # resolve handle to DID 124 + result = await db.execute(select(Artist.did).where(Artist.handle == handle)) 125 + did = result.scalar_one_or_none() 126 + 127 + if not did: 128 + raise HTTPException(status_code=404, detail="user not found") 129 + 130 + state = now_playing_service.get(did) 131 + 132 + if not state or not state.is_playing: 133 + # nothing playing - return 204 like Spotify does 134 + response.status_code = 204 135 + # must return something for FastAPI, but 204 has no body 136 + raise HTTPException(status_code=204) 137 + 138 + return NowPlayingResponse( 139 + track_name=state.track_name, 140 + artist_name=state.artist_name, 141 + album_name=state.album_name, 142 + duration_ms=state.duration_ms, 143 + progress_ms=state.progress_ms, 144 + is_playing=state.is_playing, 145 + track_id=state.track_id, 146 + file_id=state.file_id, 147 + track_url=state.track_url, 148 + image_url=state.image_url, 149 + service_base_url="plyr.fm", 150 + ) 151 + 152 + 153 + @router.get("/by-did/{did}") 154 + async def get_now_playing_by_did( 155 + did: str, 156 + response: Response, 157 + ) -> NowPlayingResponse: 158 + """get now playing state by DID (public). 159 + 160 + alternative to by-handle for clients that already have the DID. 161 + returns 204 No Content if nothing is playing. 162 + """ 163 + state = now_playing_service.get(did) 164 + 165 + if not state or not state.is_playing: 166 + raise HTTPException(status_code=204) 167 + 168 + return NowPlayingResponse( 169 + track_name=state.track_name, 170 + artist_name=state.artist_name, 171 + album_name=state.album_name, 172 + duration_ms=state.duration_ms, 173 + progress_ms=state.progress_ms, 174 + is_playing=state.is_playing, 175 + track_id=state.track_id, 176 + file_id=state.file_id, 177 + track_url=state.track_url, 178 + image_url=state.image_url, 179 + service_base_url="plyr.fm", 180 + )
+2
backend/src/backend/main.py
··· 30 30 audio_router, 31 31 auth_router, 32 32 exports_router, 33 + now_playing_router, 33 34 oembed_router, 34 35 preferences_router, 35 36 queue_router, ··· 198 199 app.include_router(search_router) 199 200 app.include_router(preferences_router) 200 201 app.include_router(queue_router) 202 + app.include_router(now_playing_router) 201 203 app.include_router(migration_router) 202 204 app.include_router(exports_router) 203 205 app.include_router(oembed_router)
+341
backend/tests/api/test_now_playing.py
··· 1 + """tests for now-playing api endpoints.""" 2 + 3 + from collections.abc import Generator 4 + 5 + import pytest 6 + from fastapi import FastAPI 7 + from httpx import ASGITransport, AsyncClient 8 + from sqlalchemy.ext.asyncio import AsyncSession 9 + 10 + from backend._internal import Session, now_playing_service 11 + from backend.main import app 12 + from backend.models import Artist 13 + 14 + 15 + # create a mock session object 16 + class MockSession(Session): 17 + """mock session for auth bypass in tests.""" 18 + 19 + def __init__(self, did: str = "did:test:user123", handle: str = "test.user"): 20 + self.did = did 21 + self.handle = handle 22 + self.session_id = "test_session" 23 + self.access_token = "test_token" 24 + self.refresh_token = "test_refresh" 25 + 26 + 27 + @pytest.fixture 28 + def test_app(db_session: AsyncSession) -> Generator[FastAPI, None, None]: 29 + """create test app with mocked auth.""" 30 + from backend._internal import require_auth 31 + 32 + # mock the auth dependency to return a mock session 33 + async def mock_require_auth() -> Session: 34 + return MockSession() 35 + 36 + # override the auth dependency 37 + app.dependency_overrides[require_auth] = mock_require_auth 38 + 39 + # clear the now_playing_service cache before each test 40 + now_playing_service._cache.clear() 41 + 42 + yield app 43 + 44 + # cleanup 45 + app.dependency_overrides.clear() 46 + now_playing_service._cache.clear() 47 + 48 + 49 + async def test_update_now_playing(test_app: FastAPI, db_session: AsyncSession): 50 + """test POST /now-playing updates playback state.""" 51 + payload = { 52 + "track_id": 123, 53 + "file_id": "test-file-abc", 54 + "track_name": "Test Track", 55 + "artist_name": "Test Artist", 56 + "album_name": "Test Album", 57 + "duration_ms": 180000, 58 + "progress_ms": 30000, 59 + "is_playing": True, 60 + "image_url": "https://example.com/cover.jpg", 61 + } 62 + 63 + async with AsyncClient( 64 + transport=ASGITransport(app=test_app), base_url="http://test" 65 + ) as client: 66 + response = await client.post("/now-playing/", json=payload) 67 + 68 + assert response.status_code == 200 69 + assert response.json() == {"status": "ok"} 70 + 71 + # verify state was stored 72 + state = now_playing_service.get("did:test:user123") 73 + assert state is not None 74 + assert state.track_name == "Test Track" 75 + assert state.artist_name == "Test Artist" 76 + assert state.album_name == "Test Album" 77 + assert state.duration_ms == 180000 78 + assert state.progress_ms == 30000 79 + assert state.is_playing is True 80 + 81 + 82 + async def test_clear_now_playing(test_app: FastAPI, db_session: AsyncSession): 83 + """test DELETE /now-playing clears playback state.""" 84 + # first set a state 85 + now_playing_service.update( 86 + did="did:test:user123", 87 + track_name="Test", 88 + artist_name="Artist", 89 + album_name=None, 90 + duration_ms=100000, 91 + progress_ms=0, 92 + track_id=1, 93 + file_id="test", 94 + track_url="https://plyr.fm/track/1", 95 + image_url=None, 96 + is_playing=True, 97 + ) 98 + 99 + async with AsyncClient( 100 + transport=ASGITransport(app=test_app), base_url="http://test" 101 + ) as client: 102 + response = await client.delete("/now-playing/") 103 + 104 + assert response.status_code == 200 105 + assert response.json() == {"status": "ok"} 106 + 107 + # verify state was cleared 108 + state = now_playing_service.get("did:test:user123") 109 + assert state is None 110 + 111 + 112 + async def test_get_now_playing_by_handle(test_app: FastAPI, db_session: AsyncSession): 113 + """test GET /now-playing/by-handle/{handle} returns playback state.""" 114 + # create artist with matching handle 115 + artist = Artist( 116 + did="did:test:user123", 117 + handle="test.user", 118 + display_name="Test User", 119 + ) 120 + db_session.add(artist) 121 + await db_session.commit() 122 + 123 + # set now playing state 124 + now_playing_service.update( 125 + did="did:test:user123", 126 + track_name="Test Track", 127 + artist_name="Test Artist", 128 + album_name="Test Album", 129 + duration_ms=180000, 130 + progress_ms=45000, 131 + track_id=123, 132 + file_id="test-file-abc", 133 + track_url="https://plyr.fm/track/123", 134 + image_url="https://example.com/cover.jpg", 135 + is_playing=True, 136 + ) 137 + 138 + async with AsyncClient( 139 + transport=ASGITransport(app=test_app), base_url="http://test" 140 + ) as client: 141 + response = await client.get("/now-playing/by-handle/test.user") 142 + 143 + assert response.status_code == 200 144 + data = response.json() 145 + 146 + assert data["track_name"] == "Test Track" 147 + assert data["artist_name"] == "Test Artist" 148 + assert data["album_name"] == "Test Album" 149 + assert data["duration_ms"] == 180000 150 + assert data["progress_ms"] == 45000 151 + assert data["is_playing"] is True 152 + assert data["track_id"] == 123 153 + assert data["file_id"] == "test-file-abc" 154 + assert data["track_url"] == "https://plyr.fm/track/123" 155 + assert data["image_url"] == "https://example.com/cover.jpg" 156 + assert data["service_base_url"] == "plyr.fm" 157 + 158 + 159 + async def test_get_now_playing_by_handle_returns_204_when_not_playing( 160 + test_app: FastAPI, db_session: AsyncSession 161 + ): 162 + """test GET /now-playing/by-handle/{handle} returns 204 when nothing playing.""" 163 + # create artist 164 + artist = Artist( 165 + did="did:test:user123", 166 + handle="test.user", 167 + display_name="Test User", 168 + ) 169 + db_session.add(artist) 170 + await db_session.commit() 171 + 172 + # no now playing state set 173 + 174 + async with AsyncClient( 175 + transport=ASGITransport(app=test_app), base_url="http://test" 176 + ) as client: 177 + response = await client.get("/now-playing/by-handle/test.user") 178 + 179 + assert response.status_code == 204 180 + 181 + 182 + async def test_get_now_playing_by_handle_returns_204_when_paused( 183 + test_app: FastAPI, db_session: AsyncSession 184 + ): 185 + """test GET /now-playing/by-handle/{handle} returns 204 when paused.""" 186 + # create artist 187 + artist = Artist( 188 + did="did:test:user123", 189 + handle="test.user", 190 + display_name="Test User", 191 + ) 192 + db_session.add(artist) 193 + await db_session.commit() 194 + 195 + # set state with is_playing=False 196 + now_playing_service.update( 197 + did="did:test:user123", 198 + track_name="Test Track", 199 + artist_name="Test Artist", 200 + album_name=None, 201 + duration_ms=180000, 202 + progress_ms=45000, 203 + track_id=123, 204 + file_id="test-file-abc", 205 + track_url="https://plyr.fm/track/123", 206 + image_url=None, 207 + is_playing=False, # paused 208 + ) 209 + 210 + async with AsyncClient( 211 + transport=ASGITransport(app=test_app), base_url="http://test" 212 + ) as client: 213 + response = await client.get("/now-playing/by-handle/test.user") 214 + 215 + assert response.status_code == 204 216 + 217 + 218 + async def test_get_now_playing_by_handle_returns_404_for_unknown_user( 219 + test_app: FastAPI, db_session: AsyncSession 220 + ): 221 + """test GET /now-playing/by-handle/{handle} returns 404 for unknown handle.""" 222 + async with AsyncClient( 223 + transport=ASGITransport(app=test_app), base_url="http://test" 224 + ) as client: 225 + response = await client.get("/now-playing/by-handle/unknown.user") 226 + 227 + assert response.status_code == 404 228 + assert "not found" in response.json()["detail"].lower() 229 + 230 + 231 + async def test_get_now_playing_by_did(test_app: FastAPI, db_session: AsyncSession): 232 + """test GET /now-playing/by-did/{did} returns playback state.""" 233 + # set now playing state 234 + now_playing_service.update( 235 + did="did:test:user123", 236 + track_name="Test Track", 237 + artist_name="Test Artist", 238 + album_name=None, 239 + duration_ms=180000, 240 + progress_ms=60000, 241 + track_id=456, 242 + file_id="test-file-xyz", 243 + track_url="https://plyr.fm/track/456", 244 + image_url=None, 245 + is_playing=True, 246 + ) 247 + 248 + async with AsyncClient( 249 + transport=ASGITransport(app=test_app), base_url="http://test" 250 + ) as client: 251 + response = await client.get("/now-playing/by-did/did:test:user123") 252 + 253 + assert response.status_code == 200 254 + data = response.json() 255 + 256 + assert data["track_name"] == "Test Track" 257 + assert data["artist_name"] == "Test Artist" 258 + assert data["is_playing"] is True 259 + assert data["service_base_url"] == "plyr.fm" 260 + 261 + 262 + async def test_get_now_playing_by_did_returns_204_when_not_playing( 263 + test_app: FastAPI, db_session: AsyncSession 264 + ): 265 + """test GET /now-playing/by-did/{did} returns 204 when nothing playing.""" 266 + async with AsyncClient( 267 + transport=ASGITransport(app=test_app), base_url="http://test" 268 + ) as client: 269 + response = await client.get("/now-playing/by-did/did:test:unknown") 270 + 271 + assert response.status_code == 204 272 + 273 + 274 + async def test_now_playing_state_isolated_by_user( 275 + test_app: FastAPI, db_session: AsyncSession 276 + ): 277 + """test that different users have isolated now-playing states.""" 278 + # set states for two different users 279 + now_playing_service.update( 280 + did="did:test:user1", 281 + track_name="Track 1", 282 + artist_name="Artist 1", 283 + album_name=None, 284 + duration_ms=100000, 285 + progress_ms=10000, 286 + track_id=1, 287 + file_id="file1", 288 + track_url="https://plyr.fm/track/1", 289 + image_url=None, 290 + is_playing=True, 291 + ) 292 + 293 + now_playing_service.update( 294 + did="did:test:user2", 295 + track_name="Track 2", 296 + artist_name="Artist 2", 297 + album_name=None, 298 + duration_ms=200000, 299 + progress_ms=20000, 300 + track_id=2, 301 + file_id="file2", 302 + track_url="https://plyr.fm/track/2", 303 + image_url=None, 304 + is_playing=True, 305 + ) 306 + 307 + # verify states are isolated 308 + state1 = now_playing_service.get("did:test:user1") 309 + state2 = now_playing_service.get("did:test:user2") 310 + 311 + assert state1 is not None 312 + assert state2 is not None 313 + assert state1.track_name == "Track 1" 314 + assert state2.track_name == "Track 2" 315 + 316 + 317 + async def test_update_now_playing_without_album( 318 + test_app: FastAPI, db_session: AsyncSession 319 + ): 320 + """test POST /now-playing works without optional album_name.""" 321 + payload = { 322 + "track_id": 123, 323 + "file_id": "test-file-abc", 324 + "track_name": "Single Track", 325 + "artist_name": "Solo Artist", 326 + "duration_ms": 180000, 327 + "progress_ms": 0, 328 + "is_playing": True, 329 + } 330 + 331 + async with AsyncClient( 332 + transport=ASGITransport(app=test_app), base_url="http://test" 333 + ) as client: 334 + response = await client.post("/now-playing/", json=payload) 335 + 336 + assert response.status_code == 200 337 + 338 + state = now_playing_service.get("did:test:user123") 339 + assert state is not None 340 + assert state.album_name is None 341 + assert state.image_url is None
+15
frontend/src/lib/components/player/Player.svelte
··· 1 1 <script lang="ts"> 2 2 import { player } from '$lib/player.svelte'; 3 3 import { queue } from '$lib/queue.svelte'; 4 + import { nowPlaying } from '$lib/now-playing.svelte'; 4 5 import { API_URL } from '$lib/config'; 5 6 import { onMount } from 'svelte'; 6 7 import { page } from '$app/stores'; ··· 212 213 } 213 214 }); 214 215 216 + // report now-playing state for external integrations (teal.fm/Piper) 217 + $effect(() => { 218 + if (!player.currentTrack || !player.duration) return; 219 + 220 + nowPlaying.report( 221 + player.currentTrack, 222 + !player.paused, 223 + player.currentTime * 1000, // convert to ms 224 + player.duration * 1000 225 + ); 226 + }); 227 + 215 228 // handle track changes - load new audio when track changes 216 229 let previousTrackId = $state<number | null>(null); 217 230 let isLoadingTrack = $state(false); ··· 289 302 function handleTrackEnded() { 290 303 if (!queue.autoAdvance) { 291 304 player.reset(); 305 + nowPlaying.clear(); 292 306 return; 293 307 } 294 308 ··· 297 311 queue.next(); 298 312 } else { 299 313 player.reset(); 314 + nowPlaying.clear(); 300 315 } 301 316 } 302 317
+171
frontend/src/lib/now-playing.svelte.ts
··· 1 + /** 2 + * now-playing service for reporting playback state to backend. 3 + * 4 + * enables external integrations like teal.fm/Piper to fetch current playback. 5 + * reports are throttled to avoid excessive API calls. 6 + */ 7 + 8 + import { browser } from '$app/environment'; 9 + import { auth } from './auth.svelte'; 10 + import { API_URL } from './config'; 11 + import type { Track } from './types'; 12 + 13 + const REPORT_INTERVAL_MS = 10_000; // report every 10 seconds during playback 14 + const REPORT_DEBOUNCE_MS = 1_000; // debounce rapid changes 15 + 16 + class NowPlayingService { 17 + private lastReportTime = 0; 18 + private reportTimer: number | null = null; 19 + private lastReportedState: string | null = null; 20 + 21 + /** 22 + * report current playback state to backend. 23 + * automatically debounces and throttles to avoid excessive API calls. 24 + */ 25 + async report( 26 + track: Track | null, 27 + isPlaying: boolean, 28 + currentTimeMs: number, 29 + durationMs: number 30 + ): Promise<void> { 31 + if (!browser || !track) { 32 + return; 33 + } 34 + 35 + // skip if not authenticated (auth uses HttpOnly cookies) 36 + if (!auth.isAuthenticated) { 37 + return; 38 + } 39 + 40 + // create state fingerprint to detect actual changes 41 + const stateFingerprint = JSON.stringify({ 42 + trackId: track.id, 43 + isPlaying, 44 + // round progress to nearest 5 seconds to reduce noise 45 + progressBucket: Math.floor(currentTimeMs / 5000) 46 + }); 47 + 48 + // skip if state hasn't meaningfully changed 49 + if (stateFingerprint === this.lastReportedState) { 50 + return; 51 + } 52 + 53 + // throttle reports during continuous playback 54 + const now = Date.now(); 55 + const timeSinceLastReport = now - this.lastReportTime; 56 + 57 + if (timeSinceLastReport < REPORT_DEBOUNCE_MS) { 58 + // debounce rapid changes (e.g., seeking) 59 + this.scheduleReport(track, isPlaying, currentTimeMs, durationMs); 60 + return; 61 + } 62 + 63 + if (isPlaying && timeSinceLastReport < REPORT_INTERVAL_MS) { 64 + // throttle during continuous playback 65 + this.scheduleReport(track, isPlaying, currentTimeMs, durationMs); 66 + return; 67 + } 68 + 69 + // clear any pending scheduled report 70 + if (this.reportTimer !== null) { 71 + window.clearTimeout(this.reportTimer); 72 + this.reportTimer = null; 73 + } 74 + 75 + await this.sendReport(track, isPlaying, currentTimeMs, durationMs); 76 + this.lastReportTime = now; 77 + this.lastReportedState = stateFingerprint; 78 + } 79 + 80 + private pendingState: { track: Track; isPlaying: boolean; currentTimeMs: number; durationMs: number } | null = null; 81 + 82 + private scheduleReport( 83 + track: Track, 84 + isPlaying: boolean, 85 + currentTimeMs: number, 86 + durationMs: number 87 + ): void { 88 + // always update pending state so timer fires with latest values 89 + this.pendingState = { track, isPlaying, currentTimeMs, durationMs }; 90 + 91 + if (this.reportTimer !== null) { 92 + return; // timer already running 93 + } 94 + 95 + this.reportTimer = window.setTimeout(async () => { 96 + this.reportTimer = null; 97 + if (this.pendingState) { 98 + await this.sendReport( 99 + this.pendingState.track, 100 + this.pendingState.isPlaying, 101 + this.pendingState.currentTimeMs, 102 + this.pendingState.durationMs 103 + ); 104 + this.pendingState = null; 105 + this.lastReportTime = Date.now(); 106 + } 107 + }, REPORT_DEBOUNCE_MS); 108 + } 109 + 110 + private async sendReport( 111 + track: Track, 112 + isPlaying: boolean, 113 + currentTimeMs: number, 114 + durationMs: number 115 + ): Promise<void> { 116 + try { 117 + await fetch(`${API_URL}/now-playing/`, { 118 + method: 'POST', 119 + headers: { 'Content-Type': 'application/json' }, 120 + credentials: 'include', 121 + body: JSON.stringify({ 122 + track_id: track.id, 123 + file_id: track.file_id, 124 + track_name: track.title, 125 + artist_name: track.artist, 126 + album_name: track.album?.title ?? null, 127 + duration_ms: Math.round(durationMs), 128 + progress_ms: Math.round(currentTimeMs), 129 + is_playing: isPlaying, 130 + image_url: track.image_url ?? track.artist_avatar_url ?? null 131 + }) 132 + }); 133 + } catch (error) { 134 + // fail silently - now-playing is best-effort 135 + console.debug('failed to report now-playing:', error); 136 + } 137 + } 138 + 139 + /** 140 + * clear now-playing state when playback stops. 141 + */ 142 + async clear(): Promise<void> { 143 + if (!browser) { 144 + return; 145 + } 146 + 147 + // skip if not authenticated (auth uses HttpOnly cookies) 148 + if (!auth.isAuthenticated) { 149 + return; 150 + } 151 + 152 + // clear any pending report 153 + if (this.reportTimer !== null) { 154 + window.clearTimeout(this.reportTimer); 155 + this.reportTimer = null; 156 + } 157 + 158 + this.lastReportedState = null; 159 + 160 + try { 161 + await fetch(`${API_URL}/now-playing/`, { 162 + method: 'DELETE', 163 + credentials: 'include' 164 + }); 165 + } catch (error) { 166 + console.debug('failed to clear now-playing:', error); 167 + } 168 + } 169 + } 170 + 171 + export const nowPlaying = new NowPlayingService();