feat: teal.fm scrobbling integration (#467)

* checkpoint

* checkpoint

* feat: teal.fm scrobbling integration

adds teal.fm scrobbling support - when users enable the toggle in settings
and re-authenticate, their plays are recorded to their PDS using teal's
ATProto lexicons.

changes:
- add TealSettings config class for configurable namespaces (TEAL_PLAY_COLLECTION,
TEAL_STATUS_COLLECTION, TEAL_ENABLED env vars)
- fix play count fetch missing credentials: 'include' (root cause of scrobbles
not triggering)
- add preferences.fetch() after login to ensure teal toggle state is current
- add unit tests for env var overrides (proves we can adapt when teal graduates
from alpha namespace)

the teal namespace defaults to fm.teal.alpha.* but can be changed via env vars
when teal.fm updates their lexicons.

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

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

* refactor: code quality improvements and bug fixes

- walrus operators throughout auth and playback code
- extract bearer token parsing into utilities/auth.py
- move DeveloperTokenInfo to top of file with field_validator for truncation
- fix now-playing firing every 1s (update lastReportedState in scheduled reports)
- use settings.frontend.url/domain for origin URLs (environment-aware)
- move teal.fm scrobbling setting from gear menu to portal "Your Data" section

🤖 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 cf53ab67 31b4621a

+9
AGENTS.md
··· 2 2 3 3 **music streaming on AT Protocol** 4 4 5 + ## Reminders 6 + - i am already hot-reloading the backend and frontend. i might also have ngrok exposing 8001 7 + - check the justfiles. there's a root one, one for the backend, one for the frontend, and one for the transcoder etc 8 + 5 9 ## 🚨 Critical Rules & Workflows 6 10 * **Read `STATUS.md` First:** Always check for active tasks and known issues. This file is NEVER tracked in git. 7 11 * **Workflow:** ··· 20 24 * **Observability:** Logfire. 21 25 * **`just` use the justfiles!** 22 26 * **use MCPs** for access to external systems, review docs/tools when needed 27 + 28 + ### Neon Serverless Postgres 29 + - `plyr-prd` (cold-butterfly-11920742) - production (us-east-1) 30 + - `plyr-stg` (frosty-math-37367092) - staging (us-west-2) 31 + - `plyr-dev` (muddy-flower-98795112) - development (us-east-2) 23 32 24 33 ## 💻 Development Commands 25 34 * **Backend:** `just backend run`
+37
backend/alembic/versions/2025_12_03_172719_d4e6457a0fe3_add_enable_teal_scrobbling_preference.py
··· 1 + """add enable_teal_scrobbling preference 2 + 3 + Revision ID: d4e6457a0fe3 4 + Revises: 0d634c0a7259 5 + Create Date: 2025-12-03 17:27:19.016378 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 = "d4e6457a0fe3" 17 + down_revision: str | Sequence[str] | None = "0d634c0a7259" 18 + branch_labels: str | Sequence[str] | None = None 19 + depends_on: str | Sequence[str] | None = None 20 + 21 + 22 + def upgrade() -> None: 23 + """Upgrade schema.""" 24 + op.add_column( 25 + "user_preferences", 26 + sa.Column( 27 + "enable_teal_scrobbling", 28 + sa.Boolean(), 29 + server_default=sa.text("false"), 30 + nullable=False, 31 + ), 32 + ) 33 + 34 + 35 + def downgrade() -> None: 36 + """Downgrade schema.""" 37 + op.drop_column("user_preferences", "enable_teal_scrobbling")
+193
backend/src/backend/_internal/atproto/teal.py
··· 1 + """teal.fm record creation for scrobbling integration.""" 2 + 3 + import logging 4 + from datetime import UTC, datetime 5 + from typing import Any 6 + 7 + from backend._internal import Session as AuthSession 8 + from backend._internal.atproto.records import _make_pds_request 9 + from backend.config import settings 10 + 11 + logger = logging.getLogger(__name__) 12 + 13 + 14 + def build_teal_play_record( 15 + track_name: str, 16 + artist_name: str, 17 + duration: int | None = None, 18 + album_name: str | None = None, 19 + origin_url: str | None = None, 20 + ) -> dict[str, Any]: 21 + """build a teal.fm play record for scrobbling. 22 + 23 + args: 24 + track_name: track title 25 + artist_name: primary artist name 26 + duration: track duration in seconds 27 + album_name: optional album/release name 28 + origin_url: optional URL to the track on plyr.fm 29 + 30 + returns: 31 + record dict ready for ATProto 32 + """ 33 + now = datetime.now(UTC) 34 + 35 + record: dict[str, Any] = { 36 + "$type": settings.teal.play_collection, 37 + "trackName": track_name, 38 + "artists": [{"artistName": artist_name}], 39 + "musicServiceBaseDomain": "plyr.fm", 40 + "submissionClientAgent": "plyr.fm/1.0", 41 + "playedTime": now.isoformat().replace("+00:00", "Z"), 42 + } 43 + 44 + if duration: 45 + record["duration"] = duration 46 + if album_name: 47 + record["releaseName"] = album_name 48 + if origin_url: 49 + record["originUrl"] = origin_url 50 + 51 + return record 52 + 53 + 54 + def build_teal_status_record( 55 + track_name: str, 56 + artist_name: str, 57 + duration: int | None = None, 58 + album_name: str | None = None, 59 + origin_url: str | None = None, 60 + ) -> dict[str, Any]: 61 + """build a teal.fm actor status record (now playing). 62 + 63 + args: 64 + track_name: track title 65 + artist_name: primary artist name 66 + duration: track duration in seconds 67 + album_name: optional album/release name 68 + origin_url: optional URL to the track on plyr.fm 69 + 70 + returns: 71 + record dict ready for ATProto 72 + """ 73 + now = datetime.now(UTC) 74 + # expiry defaults to 10 minutes from now 75 + expiry = datetime.fromtimestamp(now.timestamp() + 600, UTC) 76 + 77 + # build the playView item 78 + item: dict[str, Any] = { 79 + "trackName": track_name, 80 + "artists": [{"artistName": artist_name}], 81 + "musicServiceBaseDomain": "plyr.fm", 82 + "submissionClientAgent": "plyr.fm/1.0", 83 + "playedTime": now.isoformat().replace("+00:00", "Z"), 84 + } 85 + 86 + if duration: 87 + item["duration"] = duration 88 + if album_name: 89 + item["releaseName"] = album_name 90 + if origin_url: 91 + item["originUrl"] = origin_url 92 + 93 + record: dict[str, Any] = { 94 + "$type": settings.teal.status_collection, 95 + "time": now.isoformat().replace("+00:00", "Z"), 96 + "expiry": expiry.isoformat().replace("+00:00", "Z"), 97 + "item": item, 98 + } 99 + 100 + return record 101 + 102 + 103 + async def create_teal_play_record( 104 + auth_session: AuthSession, 105 + track_name: str, 106 + artist_name: str, 107 + duration: int | None = None, 108 + album_name: str | None = None, 109 + origin_url: str | None = None, 110 + ) -> str: 111 + """create a teal.fm play record (scrobble) on the user's PDS. 112 + 113 + args: 114 + auth_session: authenticated user session with teal scopes 115 + track_name: track title 116 + artist_name: primary artist name 117 + duration: track duration in seconds 118 + album_name: optional album/release name 119 + origin_url: optional URL to the track on plyr.fm 120 + 121 + returns: 122 + record URI 123 + 124 + raises: 125 + ValueError: if session is invalid 126 + Exception: if record creation fails 127 + """ 128 + record = build_teal_play_record( 129 + track_name=track_name, 130 + artist_name=artist_name, 131 + duration=duration, 132 + album_name=album_name, 133 + origin_url=origin_url, 134 + ) 135 + 136 + payload = { 137 + "repo": auth_session.did, 138 + "collection": settings.teal.play_collection, 139 + "record": record, 140 + } 141 + 142 + result = await _make_pds_request( 143 + auth_session, "POST", "com.atproto.repo.createRecord", payload 144 + ) 145 + return result["uri"] 146 + 147 + 148 + async def update_teal_status( 149 + auth_session: AuthSession, 150 + track_name: str, 151 + artist_name: str, 152 + duration: int | None = None, 153 + album_name: str | None = None, 154 + origin_url: str | None = None, 155 + ) -> str: 156 + """update the user's teal.fm status (now playing). 157 + 158 + uses putRecord with rkey "self" as per the lexicon spec. 159 + 160 + args: 161 + auth_session: authenticated user session with teal scopes 162 + track_name: track title 163 + artist_name: primary artist name 164 + duration: track duration in seconds 165 + album_name: optional album/release name 166 + origin_url: optional URL to the track on plyr.fm 167 + 168 + returns: 169 + record URI 170 + 171 + raises: 172 + ValueError: if session is invalid 173 + Exception: if record creation fails 174 + """ 175 + record = build_teal_status_record( 176 + track_name=track_name, 177 + artist_name=artist_name, 178 + duration=duration, 179 + album_name=album_name, 180 + origin_url=origin_url, 181 + ) 182 + 183 + payload = { 184 + "repo": auth_session.did, 185 + "collection": settings.teal.status_collection, 186 + "rkey": "self", 187 + "record": record, 188 + } 189 + 190 + result = await _make_pds_request( 191 + auth_session, "POST", "com.atproto.repo.putRecord", payload 192 + ) 193 + return result["uri"]
+68 -6
backend/src/backend/_internal/auth.py
··· 15 15 16 16 from backend._internal.oauth_stores import PostgresStateStore 17 17 from backend.config import settings 18 - from backend.models import ExchangeToken, PendingDevToken, UserSession 18 + from backend.models import ExchangeToken, PendingDevToken, UserPreferences, UserSession 19 19 from backend.utilities.database import db_session 20 20 21 21 logger = logging.getLogger(__name__) ··· 69 69 _state_store = PostgresStateStore() 70 70 _session_store = MemorySessionStore() 71 71 72 - # OAuth client 72 + # OAuth clients - base client for normal auth, teal client for users who want scrobbling 73 73 oauth_client = OAuthClient( 74 74 client_id=settings.atproto.client_id, 75 75 redirect_uri=settings.atproto.redirect_uri, ··· 77 77 state_store=_state_store, 78 78 session_store=_session_store, 79 79 ) 80 + 81 + oauth_client_with_teal = OAuthClient( 82 + client_id=settings.atproto.client_id, 83 + redirect_uri=settings.atproto.redirect_uri, 84 + scope=settings.atproto.resolved_scope_with_teal( 85 + settings.teal.play_collection, settings.teal.status_collection 86 + ), 87 + state_store=_state_store, 88 + session_store=_session_store, 89 + ) 90 + 91 + 92 + def get_oauth_client_for_scope(scope: str) -> OAuthClient: 93 + """get the appropriate OAuth client for a given scope string.""" 94 + if settings.teal.play_collection in scope: 95 + return oauth_client_with_teal 96 + return oauth_client 97 + 80 98 81 99 # encryption for sensitive OAuth data at rest 82 100 # CRITICAL: encryption key must be configured and stable across restarts ··· 211 229 await db.commit() 212 230 213 231 232 + async def _check_teal_preference(did: str) -> bool: 233 + """check if user has enabled teal.fm scrobbling.""" 234 + async with db_session() as db: 235 + result = await db.execute( 236 + select(UserPreferences.enable_teal_scrobbling).where( 237 + UserPreferences.did == did 238 + ) 239 + ) 240 + pref = result.scalar_one_or_none() 241 + return pref is True 242 + 243 + 214 244 async def start_oauth_flow(handle: str) -> tuple[str, str]: 215 - """start OAuth flow and return (auth_url, state).""" 245 + """start OAuth flow and return (auth_url, state). 246 + 247 + uses extended scope if user has enabled teal.fm scrobbling. 248 + """ 249 + from backend._internal.atproto.handles import resolve_handle 250 + 216 251 try: 217 - auth_url, state = await oauth_client.start_authorization(handle) 252 + # resolve handle to DID to check preferences 253 + resolved = await resolve_handle(handle) 254 + if resolved: 255 + did = resolved["did"] 256 + wants_teal = await _check_teal_preference(did) 257 + client = oauth_client_with_teal if wants_teal else oauth_client 258 + logger.info(f"starting OAuth for {handle} (did={did}, teal={wants_teal})") 259 + else: 260 + # fallback to base client if resolution fails 261 + # (OAuth flow will resolve handle again internally) 262 + client = oauth_client 263 + logger.info(f"starting OAuth for {handle} (resolution failed, using base)") 264 + 265 + auth_url, state = await client.start_authorization(handle) 218 266 return auth_url, state 219 267 except Exception as e: 220 268 raise HTTPException( ··· 226 274 async def handle_oauth_callback( 227 275 code: str, state: str, iss: str 228 276 ) -> tuple[str, str, dict]: 229 - """handle OAuth callback and return (did, handle, oauth_session).""" 277 + """handle OAuth callback and return (did, handle, oauth_session). 278 + 279 + uses the appropriate OAuth client based on stored state's scope. 280 + """ 230 281 try: 231 - oauth_session = await oauth_client.handle_callback( 282 + # look up stored state to determine which scope was used 283 + if stored_state := await _state_store.get_state(state): 284 + client = get_oauth_client_for_scope(stored_state.scope) 285 + logger.info( 286 + f"callback using client for scope: {stored_state.scope[:50]}..." 287 + ) 288 + else: 289 + # fallback to base client (state might have been cleaned up) 290 + client = oauth_client 291 + logger.warning(f"state {state[:8]}... not found, using base client") 292 + 293 + oauth_session = await client.handle_callback( 232 294 code=code, 233 295 state=state, 234 296 iss=iss,
+23 -17
backend/src/backend/api/auth.py
··· 4 4 5 5 from fastapi import APIRouter, Depends, HTTPException, Query, Request 6 6 from fastapi.responses import JSONResponse, RedirectResponse 7 - from pydantic import BaseModel 7 + from pydantic import BaseModel, field_validator 8 8 from starlette.responses import Response 9 9 10 10 from backend._internal import ( ··· 34 34 35 35 did: str 36 36 handle: str 37 + 38 + 39 + class DeveloperTokenInfo(BaseModel): 40 + """info about a developer token (without the actual token).""" 41 + 42 + session_id: str 43 + name: str | None 44 + created_at: str # ISO format 45 + expires_at: str | None # ISO format or null for never 46 + 47 + @field_validator("session_id", mode="before") 48 + @classmethod 49 + def truncate_session_id(cls, v: str) -> str: 50 + """truncate to 8-char prefix for safe display.""" 51 + return v[:8] if len(v) > 8 else v 52 + 53 + 54 + class DeveloperTokenListResponse(BaseModel): 55 + """response model for listing developer tokens.""" 56 + 57 + tokens: list[DeveloperTokenInfo] 37 58 38 59 39 60 @router.get("/start") ··· 206 227 ) 207 228 208 229 209 - class DeveloperTokenInfo(BaseModel): 210 - """info about a developer token (without the actual token).""" 211 - 212 - session_id: str # first 8 chars only for identification 213 - name: str | None 214 - created_at: str # ISO format 215 - expires_at: str | None # ISO format or null for never 216 - 217 - 218 - class DeveloperTokenListResponse(BaseModel): 219 - """response model for listing developer tokens.""" 220 - 221 - tokens: list[DeveloperTokenInfo] 222 - 223 - 224 230 @router.get("/developer-tokens") 225 231 async def get_developer_tokens( 226 232 session: Session = Depends(require_auth), ··· 231 237 return DeveloperTokenListResponse( 232 238 tokens=[ 233 239 DeveloperTokenInfo( 234 - session_id=t.session_id[:8], # only show prefix for identification 240 + session_id=t.session_id, 235 241 name=t.token_name, 236 242 created_at=t.created_at.isoformat(), 237 243 expires_at=t.expires_at.isoformat() if t.expires_at else None,
+4 -4
backend/src/backend/api/now_playing.py
··· 58 58 track_url: str 59 59 image_url: str | None 60 60 61 - # service identifier for Piper 62 - service_base_url: str = "plyr.fm" 61 + # service identifier for Piper (domain extracted from frontend URL) 62 + service_base_url: str 63 63 64 64 65 65 @router.post("/") ··· 153 153 file_id=state.file_id, 154 154 track_url=state.track_url, 155 155 image_url=state.image_url, 156 - service_base_url="plyr.fm", 156 + service_base_url=settings.frontend.domain, 157 157 ) 158 158 159 159 ··· 184 184 file_id=state.file_id, 185 185 track_url=state.track_url, 186 186 image_url=state.image_url, 187 - service_base_url="plyr.fm", 187 + service_base_url=settings.frontend.domain, 188 188 )
+30
backend/src/backend/api/preferences.py
··· 8 8 from sqlalchemy.ext.asyncio import AsyncSession 9 9 10 10 from backend._internal import Session, require_auth 11 + from backend.config import settings 11 12 from backend.models import UserPreferences, get_db 12 13 from backend.utilities.tags import DEFAULT_HIDDEN_TAGS 13 14 ··· 21 22 auto_advance: bool 22 23 allow_comments: bool 23 24 hidden_tags: list[str] 25 + enable_teal_scrobbling: bool 26 + # indicates if user needs to re-login to activate teal scrobbling 27 + teal_needs_reauth: bool = False 24 28 25 29 26 30 class PreferencesUpdate(BaseModel): ··· 30 34 auto_advance: bool | None = None 31 35 allow_comments: bool | None = None 32 36 hidden_tags: list[str] | None = None 37 + enable_teal_scrobbling: bool | None = None 38 + 39 + 40 + def _has_teal_scope(session: Session) -> bool: 41 + """check if session has teal.fm scopes.""" 42 + if not session.oauth_session: 43 + return False 44 + scope = session.oauth_session.get("scope", "") 45 + return settings.teal.play_collection in scope 33 46 34 47 35 48 @router.get("/") ··· 54 67 await db.commit() 55 68 await db.refresh(prefs) 56 69 70 + # check if user wants teal but doesn't have the scope 71 + has_scope = _has_teal_scope(session) 72 + teal_needs_reauth = prefs.enable_teal_scrobbling and not has_scope 73 + 57 74 return PreferencesResponse( 58 75 accent_color=prefs.accent_color, 59 76 auto_advance=prefs.auto_advance, 60 77 allow_comments=prefs.allow_comments, 61 78 hidden_tags=prefs.hidden_tags or [], 79 + enable_teal_scrobbling=prefs.enable_teal_scrobbling, 80 + teal_needs_reauth=teal_needs_reauth, 62 81 ) 63 82 64 83 ··· 88 107 hidden_tags=update.hidden_tags 89 108 if update.hidden_tags is not None 90 109 else list(DEFAULT_HIDDEN_TAGS), 110 + enable_teal_scrobbling=update.enable_teal_scrobbling 111 + if update.enable_teal_scrobbling is not None 112 + else False, 91 113 ) 92 114 db.add(prefs) 93 115 else: ··· 100 122 prefs.allow_comments = update.allow_comments 101 123 if update.hidden_tags is not None: 102 124 prefs.hidden_tags = update.hidden_tags 125 + if update.enable_teal_scrobbling is not None: 126 + prefs.enable_teal_scrobbling = update.enable_teal_scrobbling 103 127 104 128 await db.commit() 105 129 await db.refresh(prefs) 106 130 131 + # check if user wants teal but doesn't have the scope 132 + has_scope = _has_teal_scope(session) 133 + teal_needs_reauth = prefs.enable_teal_scrobbling and not has_scope 134 + 107 135 return PreferencesResponse( 108 136 accent_color=prefs.accent_color, 109 137 auto_advance=prefs.auto_advance, 110 138 allow_comments=prefs.allow_comments, 111 139 hidden_tags=prefs.hidden_tags or [], 140 + enable_teal_scrobbling=prefs.enable_teal_scrobbling, 141 + teal_needs_reauth=teal_needs_reauth, 112 142 )
+98 -16
backend/src/backend/api/tracks/playback.py
··· 1 1 """Track detail and playback endpoints.""" 2 2 3 + import asyncio 4 + import logging 3 5 from typing import Annotated 4 6 5 - from fastapi import Cookie, Depends, HTTPException, Request 7 + from fastapi import BackgroundTasks, Cookie, Depends, HTTPException, Request 6 8 from sqlalchemy import select 7 9 from sqlalchemy.ext.asyncio import AsyncSession 8 10 from sqlalchemy.orm import selectinload 9 11 12 + from backend._internal.atproto.teal import create_teal_play_record, update_teal_status 10 13 from backend._internal.auth import get_session 11 - from backend.models import Artist, Track, TrackLike, get_db 14 + from backend.config import settings 15 + from backend.models import Artist, Track, TrackLike, UserPreferences, get_db 12 16 from backend.schemas import TrackResponse 13 17 from backend.utilities.aggregations import get_like_counts, get_track_tags 18 + from backend.utilities.auth import get_session_id_from_request 14 19 15 20 from .router import router 21 + 22 + logger = logging.getLogger(__name__) 16 23 17 24 18 25 @router.get("/{track_id}") ··· 24 31 ) -> TrackResponse: 25 32 """Get a specific track.""" 26 33 liked_track_ids: set[int] | None = None 27 - session_id = session_id_cookie or request.headers.get("authorization", "").replace( 28 - "Bearer ", "" 29 - ) 30 34 if ( 31 - session_id 35 + (session_id := get_session_id_from_request(request, session_id_cookie)) 32 36 and (auth_session := await get_session(session_id)) 33 37 and await db.scalar( 34 38 select(TrackLike.track_id).where( ··· 44 48 .options(selectinload(Track.artist), selectinload(Track.album_rel)) 45 49 .where(Track.id == track_id) 46 50 ) 47 - track = result.scalar_one_or_none() 48 - 49 - if not track: 51 + if not (track := result.scalar_one_or_none()): 50 52 raise HTTPException(status_code=404, detail="track not found") 51 53 52 - like_counts = await get_like_counts(db, [track_id]) 53 - track_tags = await get_track_tags(db, [track_id]) 54 + like_counts, track_tags = await asyncio.gather( 55 + get_like_counts(db, [track_id]), 56 + get_track_tags(db, [track_id]), 57 + ) 54 58 55 59 return await TrackResponse.from_track( 56 60 track, ··· 60 64 ) 61 65 62 66 67 + async def _scrobble_to_teal( 68 + session_id: str, 69 + track_id: int, 70 + track_title: str, 71 + artist_name: str, 72 + duration: int | None, 73 + album_name: str | None, 74 + ) -> None: 75 + """scrobble a play to teal.fm (creates play record + updates status).""" 76 + if not (auth_session := await get_session(session_id)): 77 + logger.warning(f"teal scrobble failed: session {session_id[:8]}... not found") 78 + return 79 + 80 + origin_url = f"{settings.frontend.url}/track/{track_id}" 81 + 82 + try: 83 + # create play record (scrobble) 84 + play_uri = await create_teal_play_record( 85 + auth_session=auth_session, 86 + track_name=track_title, 87 + artist_name=artist_name, 88 + duration=duration, 89 + album_name=album_name, 90 + origin_url=origin_url, 91 + ) 92 + logger.info(f"teal play record created: {play_uri}") 93 + 94 + # update status (now playing) 95 + status_uri = await update_teal_status( 96 + auth_session=auth_session, 97 + track_name=track_title, 98 + artist_name=artist_name, 99 + duration=duration, 100 + album_name=album_name, 101 + origin_url=origin_url, 102 + ) 103 + logger.info(f"teal status updated: {status_uri}") 104 + 105 + except Exception as e: 106 + logger.error(f"teal scrobble failed for track {track_id}: {e}", exc_info=True) 107 + 108 + 63 109 @router.post("/{track_id}/play") 64 110 async def increment_play_count( 65 - track_id: int, db: Annotated[AsyncSession, Depends(get_db)] 111 + track_id: int, 112 + db: Annotated[AsyncSession, Depends(get_db)], 113 + request: Request, 114 + background_tasks: BackgroundTasks, 115 + session_id_cookie: Annotated[str | None, Cookie(alias="session_id")] = None, 66 116 ) -> dict: 67 - """Increment play count for a track (called after 30 seconds of playback).""" 68 - result = await db.execute(select(Track).where(Track.id == track_id)) 69 - track = result.scalar_one_or_none() 117 + """Increment play count for a track (called after 30 seconds of playback). 118 + 119 + If user has teal.fm scrobbling enabled and has the required scopes, 120 + also writes play record to their PDS. 121 + """ 122 + # load track with artist info 123 + result = await db.execute( 124 + select(Track) 125 + .options(selectinload(Track.artist), selectinload(Track.album_rel)) 126 + .where(Track.id == track_id) 127 + ) 70 128 71 - if not track: 129 + if not (track := result.scalar_one_or_none()): 72 130 raise HTTPException(status_code=404, detail="track not found") 73 131 74 132 track.play_count += 1 75 133 await db.commit() 134 + 135 + # check if user wants teal scrobbling 136 + if ( 137 + (session_id := get_session_id_from_request(request, session_id_cookie)) 138 + and (auth_session := await get_session(session_id)) 139 + and ( 140 + prefs := await db.scalar( 141 + select(UserPreferences).where(UserPreferences.did == auth_session.did) 142 + ) 143 + ) 144 + and prefs.enable_teal_scrobbling 145 + ): 146 + # check if session has teal scopes 147 + scope = auth_session.oauth_session.get("scope", "") 148 + if settings.teal.play_collection in scope: 149 + background_tasks.add_task( 150 + _scrobble_to_teal, 151 + session_id=session_id, 152 + track_id=track_id, 153 + track_title=track.title, 154 + artist_name=track.artist.display_name or track.artist.handle, 155 + duration=track.duration, 156 + album_name=track.album_rel.title if track.album_rel else None, 157 + ) 76 158 77 159 return {"play_count": track.play_count}
+55
backend/src/backend/config.py
··· 102 102 103 103 @computed_field 104 104 @property 105 + def domain(self) -> str: 106 + """extract domain from frontend URL (e.g., 'plyr.fm', 'stg.plyr.fm').""" 107 + from urllib.parse import urlparse 108 + 109 + parsed = urlparse(self.url) 110 + return parsed.netloc or "plyr.fm" 111 + 112 + @computed_field 113 + @property 105 114 def resolved_cors_origin_regex(self) -> str: 106 115 """Resolved CORS origin regex pattern.""" 107 116 if self.cors_origin_regex is not None: ··· 355 364 356 365 return f"atproto {' '.join(scopes)}" 357 366 367 + def resolved_scope_with_teal(self, teal_play: str, teal_status: str) -> str: 368 + """OAuth scope including teal.fm scrobbling permissions. 369 + 370 + args: 371 + teal_play: teal.fm play collection NSID (e.g., fm.teal.alpha.feed.play) 372 + teal_status: teal.fm status collection NSID (e.g., fm.teal.alpha.actor.status) 373 + """ 374 + base = self.resolved_scope 375 + teal_scopes = [f"repo:{teal_play}", f"repo:{teal_status}"] 376 + return f"{base} {' '.join(teal_scopes)}" 377 + 378 + 379 + class TealSettings(AppSettingsSection): 380 + """teal.fm integration settings for scrobbling. 381 + 382 + teal.fm is a music scrobbling service built on ATProto. when users enable 383 + scrobbling, plyr.fm writes play records to their PDS using teal's lexicons. 384 + 385 + these namespaces may change as teal.fm evolves from alpha to stable. 386 + configure via environment variables to adapt without code changes. 387 + """ 388 + 389 + model_config = SettingsConfigDict( 390 + env_prefix="TEAL_", 391 + env_file=".env", 392 + case_sensitive=False, 393 + extra="ignore", 394 + ) 395 + 396 + enabled: bool = Field( 397 + default=True, 398 + description="Enable teal.fm scrobbling feature. When False, the toggle is hidden and no scrobbles are sent.", 399 + ) 400 + play_collection: str = Field( 401 + default="fm.teal.alpha.feed.play", 402 + description="Lexicon NSID for teal.fm play records (scrobbles)", 403 + ) 404 + status_collection: str = Field( 405 + default="fm.teal.alpha.actor.status", 406 + description="Lexicon NSID for teal.fm actor status (now playing)", 407 + ) 408 + 358 409 359 410 class ObservabilitySettings(AppSettingsSection): 360 411 """Observability configuration.""" ··· 488 539 moderation: ModerationSettings = Field( 489 540 default_factory=ModerationSettings, 490 541 description="Moderation service settings", 542 + ) 543 + teal: TealSettings = Field( 544 + default_factory=TealSettings, 545 + description="teal.fm scrobbling integration settings", 491 546 ) 492 547 493 548
+3 -1
backend/src/backend/main.py
··· 235 235 "client_name": settings.app.name, 236 236 "client_uri": client_uri, 237 237 "redirect_uris": [settings.atproto.redirect_uri], 238 - "scope": settings.atproto.resolved_scope, 238 + "scope": settings.atproto.resolved_scope_with_teal( 239 + settings.teal.play_collection, settings.teal.status_collection 240 + ), 239 241 "grant_types": ["authorization_code", "refresh_token"], 240 242 "response_types": ["code"], 241 243 "token_endpoint_auth_method": "none",
+7
backend/src/backend/models/preferences.py
··· 39 39 server_default=text("'[\"ai\"]'::jsonb"), 40 40 ) 41 41 42 + # teal.fm scrobbling integration 43 + # when enabled, plays are written to user's PDS as fm.teal.alpha.feed.play records 44 + # requires re-login to grant teal scopes after enabling 45 + enable_teal_scrobbling: Mapped[bool] = mapped_column( 46 + Boolean, nullable=False, default=False, server_default=text("false") 47 + ) 48 + 42 49 # metadata 43 50 created_at: Mapped[datetime] = mapped_column( 44 51 DateTime(timezone=True),
+20
backend/src/backend/utilities/auth.py
··· 1 + """authentication utilities.""" 2 + 3 + from fastapi import Request 4 + 5 + 6 + def get_session_id_from_request( 7 + request: Request, session_id_cookie: str | None = None 8 + ) -> str | None: 9 + """extract session ID from cookie or authorization header. 10 + 11 + checks cookie first (browser requests), then falls back to bearer token 12 + in authorization header (SDK/CLI clients). 13 + """ 14 + if session_id_cookie: 15 + return session_id_cookie 16 + 17 + if authorization := request.headers.get("authorization"): 18 + return authorization.removeprefix("Bearer ") 19 + 20 + return None
+3 -2
backend/tests/api/test_now_playing.py
··· 8 8 from sqlalchemy.ext.asyncio import AsyncSession 9 9 10 10 from backend._internal import Session, now_playing_service 11 + from backend.config import settings 11 12 from backend.main import app 12 13 from backend.models import Artist 13 14 ··· 153 154 assert data["file_id"] == "test-file-abc" 154 155 assert data["track_url"] == "https://plyr.fm/track/123" 155 156 assert data["image_url"] == "https://example.com/cover.jpg" 156 - assert data["service_base_url"] == "plyr.fm" 157 + assert data["service_base_url"] == settings.frontend.domain 157 158 158 159 159 160 async def test_get_now_playing_by_handle_returns_204_when_not_playing( ··· 256 257 assert data["track_name"] == "Test Track" 257 258 assert data["artist_name"] == "Test Artist" 258 259 assert data["is_playing"] is True 259 - assert data["service_base_url"] == "plyr.fm" 260 + assert data["service_base_url"] == settings.frontend.domain 260 261 261 262 262 263 async def test_get_now_playing_by_did_returns_204_when_not_playing(
+152
backend/tests/api/test_preferences.py
··· 1 + """tests for preferences api endpoints.""" 2 + 3 + from collections.abc import AsyncGenerator 4 + 5 + import pytest 6 + from httpx import ASGITransport, AsyncClient 7 + from sqlalchemy.ext.asyncio import AsyncSession 8 + 9 + from backend._internal import Session, require_auth 10 + from backend.config import settings 11 + from backend.main import app 12 + 13 + 14 + class MockSession(Session): 15 + """mock session for auth bypass in tests.""" 16 + 17 + def __init__( 18 + self, did: str = "did:test:user123", scope: str = "atproto transition:generic" 19 + ): 20 + self.did = did 21 + self.handle = "testuser.bsky.social" 22 + self.session_id = "test_session_id" 23 + self.access_token = "test_token" 24 + self.refresh_token = "test_refresh" 25 + self.oauth_session = { 26 + "did": did, 27 + "handle": "testuser.bsky.social", 28 + "pds_url": "https://test.pds", 29 + "authserver_iss": "https://auth.test", 30 + "scope": scope, 31 + "access_token": "test_token", 32 + "refresh_token": "test_refresh", 33 + "dpop_private_key_pem": "fake_key", 34 + "dpop_authserver_nonce": "", 35 + "dpop_pds_nonce": "", 36 + } 37 + 38 + 39 + @pytest.fixture 40 + def mock_session() -> MockSession: 41 + """default mock session without teal scopes.""" 42 + return MockSession() 43 + 44 + 45 + @pytest.fixture 46 + def mock_session_with_teal() -> MockSession: 47 + """mock session with teal scopes.""" 48 + return MockSession( 49 + scope=settings.atproto.resolved_scope_with_teal( 50 + settings.teal.play_collection, settings.teal.status_collection 51 + ) 52 + ) 53 + 54 + 55 + @pytest.fixture 56 + async def client_no_teal( 57 + db_session: AsyncSession, 58 + mock_session: MockSession, 59 + ) -> AsyncGenerator[AsyncClient, None]: 60 + """authenticated client without teal scopes.""" 61 + app.dependency_overrides[require_auth] = lambda: mock_session 62 + 63 + async with AsyncClient( 64 + transport=ASGITransport(app=app), 65 + base_url="http://test", 66 + ) as client: 67 + yield client 68 + 69 + app.dependency_overrides.clear() 70 + 71 + 72 + @pytest.fixture 73 + async def client_with_teal( 74 + db_session: AsyncSession, 75 + mock_session_with_teal: MockSession, 76 + ) -> AsyncGenerator[AsyncClient, None]: 77 + """authenticated client with teal scopes.""" 78 + app.dependency_overrides[require_auth] = lambda: mock_session_with_teal 79 + 80 + async with AsyncClient( 81 + transport=ASGITransport(app=app), 82 + base_url="http://test", 83 + ) as client: 84 + yield client 85 + 86 + app.dependency_overrides.clear() 87 + 88 + 89 + async def test_get_preferences_includes_teal_fields( 90 + client_no_teal: AsyncClient, 91 + ): 92 + """should return teal scrobbling fields in preferences response.""" 93 + response = await client_no_teal.get("/preferences/") 94 + assert response.status_code == 200 95 + 96 + data = response.json() 97 + assert "enable_teal_scrobbling" in data 98 + assert "teal_needs_reauth" in data 99 + # default should be disabled 100 + assert data["enable_teal_scrobbling"] is False 101 + 102 + 103 + async def test_update_teal_scrobbling_preference( 104 + client_no_teal: AsyncClient, 105 + ): 106 + """should update teal scrobbling preference.""" 107 + # enable teal scrobbling 108 + response = await client_no_teal.post( 109 + "/preferences/", 110 + json={"enable_teal_scrobbling": True}, 111 + ) 112 + assert response.status_code == 200 113 + 114 + data = response.json() 115 + assert data["enable_teal_scrobbling"] is True 116 + # should need reauth since session doesn't have teal scopes 117 + assert data["teal_needs_reauth"] is True 118 + 119 + 120 + async def test_teal_needs_reauth_false_when_disabled( 121 + client_no_teal: AsyncClient, 122 + ): 123 + """should not need reauth when teal scrobbling is disabled.""" 124 + response = await client_no_teal.post( 125 + "/preferences/", 126 + json={"enable_teal_scrobbling": False}, 127 + ) 128 + assert response.status_code == 200 129 + 130 + data = response.json() 131 + assert data["enable_teal_scrobbling"] is False 132 + assert data["teal_needs_reauth"] is False 133 + 134 + 135 + async def test_teal_no_reauth_needed_with_scope( 136 + client_with_teal: AsyncClient, 137 + ): 138 + """should not need reauth when teal is enabled and scope is present.""" 139 + # first enable teal 140 + await client_with_teal.post( 141 + "/preferences/", 142 + json={"enable_teal_scrobbling": True}, 143 + ) 144 + 145 + # then get preferences 146 + response = await client_with_teal.get("/preferences/") 147 + assert response.status_code == 200 148 + 149 + data = response.json() 150 + assert data["enable_teal_scrobbling"] is True 151 + # should NOT need reauth since session has teal scopes 152 + assert data["teal_needs_reauth"] is False
+142
backend/tests/test_teal_scrobbling.py
··· 1 + """tests for teal.fm scrobbling integration.""" 2 + 3 + from backend._internal.atproto.teal import ( 4 + build_teal_play_record, 5 + build_teal_status_record, 6 + ) 7 + from backend.config import TealSettings, settings 8 + 9 + 10 + class TestBuildTealPlayRecord: 11 + """tests for build_teal_play_record.""" 12 + 13 + def test_builds_minimal_record(self): 14 + """should build record with required fields only.""" 15 + record = build_teal_play_record( 16 + track_name="Test Track", 17 + artist_name="Test Artist", 18 + ) 19 + 20 + assert record["$type"] == settings.teal.play_collection 21 + assert record["trackName"] == "Test Track" 22 + assert record["artists"] == [{"artistName": "Test Artist"}] 23 + assert record["musicServiceBaseDomain"] == "plyr.fm" 24 + assert record["submissionClientAgent"] == "plyr.fm/1.0" 25 + assert "playedTime" in record 26 + 27 + def test_includes_optional_fields(self): 28 + """should include optional fields when provided.""" 29 + record = build_teal_play_record( 30 + track_name="Test Track", 31 + artist_name="Test Artist", 32 + duration=180, 33 + album_name="Test Album", 34 + origin_url="https://plyr.fm/track/123", 35 + ) 36 + 37 + assert record["duration"] == 180 38 + assert record["releaseName"] == "Test Album" 39 + assert record["originUrl"] == "https://plyr.fm/track/123" 40 + 41 + 42 + class TestBuildTealStatusRecord: 43 + """tests for build_teal_status_record.""" 44 + 45 + def test_builds_status_record(self): 46 + """should build status record with item.""" 47 + record = build_teal_status_record( 48 + track_name="Now Playing", 49 + artist_name="Cool Artist", 50 + ) 51 + 52 + assert record["$type"] == settings.teal.status_collection 53 + assert "time" in record 54 + assert "expiry" in record 55 + assert "item" in record 56 + 57 + item = record["item"] 58 + assert item["trackName"] == "Now Playing" 59 + assert item["artists"] == [{"artistName": "Cool Artist"}] 60 + 61 + def test_expiry_is_after_time(self): 62 + """should set expiry after time.""" 63 + record = build_teal_status_record( 64 + track_name="Test", 65 + artist_name="Test", 66 + ) 67 + 68 + # expiry should be 10 minutes after time 69 + assert record["expiry"] > record["time"] 70 + 71 + 72 + class TestTealSettings: 73 + """tests for teal.fm settings configuration.""" 74 + 75 + def test_default_play_collection(self): 76 + """should have correct default teal play collection.""" 77 + assert settings.teal.play_collection == "fm.teal.alpha.feed.play" 78 + 79 + def test_default_status_collection(self): 80 + """should have correct default teal status collection.""" 81 + assert settings.teal.status_collection == "fm.teal.alpha.actor.status" 82 + 83 + def test_default_enabled(self): 84 + """should be enabled by default.""" 85 + assert settings.teal.enabled is True 86 + 87 + def test_resolved_scope_with_teal(self): 88 + """should include teal scopes in extended scope.""" 89 + scope = settings.atproto.resolved_scope_with_teal( 90 + settings.teal.play_collection, settings.teal.status_collection 91 + ) 92 + 93 + assert "fm.teal.alpha.feed.play" in scope 94 + assert "fm.teal.alpha.actor.status" in scope 95 + # should also include base scopes 96 + assert settings.atproto.track_collection in scope 97 + 98 + def test_env_override_play_collection(self, monkeypatch): 99 + """should allow overriding play collection via environment variable. 100 + 101 + this proves we can adapt when teal.fm changes namespaces 102 + (e.g., from alpha to stable: fm.teal.feed.play). 103 + """ 104 + monkeypatch.setenv("TEAL_PLAY_COLLECTION", "fm.teal.feed.play") 105 + 106 + # create fresh settings to pick up env var 107 + teal = TealSettings() 108 + assert teal.play_collection == "fm.teal.feed.play" 109 + 110 + def test_env_override_status_collection(self, monkeypatch): 111 + """should allow overriding status collection via environment variable.""" 112 + monkeypatch.setenv("TEAL_STATUS_COLLECTION", "fm.teal.actor.status") 113 + 114 + teal = TealSettings() 115 + assert teal.status_collection == "fm.teal.actor.status" 116 + 117 + def test_env_override_enabled(self, monkeypatch): 118 + """should allow disabling teal integration via environment variable.""" 119 + monkeypatch.setenv("TEAL_ENABLED", "false") 120 + 121 + teal = TealSettings() 122 + assert teal.enabled is False 123 + 124 + def test_scope_uses_configured_collections(self, monkeypatch): 125 + """should use configured collections in OAuth scope. 126 + 127 + when teal.fm graduates from alpha, we can update via env vars 128 + without code changes. 129 + """ 130 + monkeypatch.setenv("TEAL_PLAY_COLLECTION", "fm.teal.feed.play") 131 + monkeypatch.setenv("TEAL_STATUS_COLLECTION", "fm.teal.actor.status") 132 + 133 + teal = TealSettings() 134 + scope = settings.atproto.resolved_scope_with_teal( 135 + teal.play_collection, teal.status_collection 136 + ) 137 + 138 + assert "fm.teal.feed.play" in scope 139 + assert "fm.teal.actor.status" in scope 140 + # alpha namespaces should NOT be in scope when overridden 141 + assert "fm.teal.alpha.feed.play" not in scope 142 + assert "fm.teal.alpha.actor.status" not in scope
+8 -6
frontend/src/lib/now-playing.svelte.ts
··· 95 95 this.reportTimer = window.setTimeout(async () => { 96 96 this.reportTimer = null; 97 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 - ); 98 + const { track, isPlaying, currentTimeMs, durationMs } = this.pendingState; 99 + await this.sendReport(track, isPlaying, currentTimeMs, durationMs); 104 100 this.pendingState = null; 105 101 this.lastReportTime = Date.now(); 102 + // update fingerprint so we don't re-report the same state 103 + this.lastReportedState = JSON.stringify({ 104 + trackId: track.id, 105 + isPlaying, 106 + progressBucket: Math.floor(currentTimeMs / 5000) 107 + }); 106 108 } 107 109 }, REPORT_DEBOUNCE_MS); 108 110 }
+2 -1
frontend/src/lib/player.svelte.ts
··· 38 38 this.playCountedForTrack = this.currentTrack.id; 39 39 40 40 fetch(`${API_URL}/tracks/${this.currentTrack.id}/play`, { 41 - method: 'POST' 41 + method: 'POST', 42 + credentials: 'include' 42 43 }).catch(err => { 43 44 console.error('failed to increment play count:', err); 44 45 });
+16 -2
frontend/src/lib/preferences.svelte.ts
··· 11 11 allow_comments: boolean; 12 12 hidden_tags: string[]; 13 13 theme: Theme; 14 + enable_teal_scrobbling: boolean; 15 + teal_needs_reauth: boolean; 14 16 } 15 17 16 18 const DEFAULT_PREFERENCES: Preferences = { ··· 18 20 auto_advance: true, 19 21 allow_comments: true, 20 22 hidden_tags: ['ai'], 21 - theme: 'dark' 23 + theme: 'dark', 24 + enable_teal_scrobbling: false, 25 + teal_needs_reauth: false 22 26 }; 23 27 24 28 class PreferencesManager { ··· 50 54 return this.data?.theme ?? DEFAULT_PREFERENCES.theme; 51 55 } 52 56 57 + get enableTealScrobbling(): boolean { 58 + return this.data?.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling; 59 + } 60 + 61 + get tealNeedsReauth(): boolean { 62 + return this.data?.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth; 63 + } 64 + 53 65 setTheme(theme: Theme): void { 54 66 if (browser) { 55 67 localStorage.setItem('theme', theme); ··· 93 105 auto_advance: data.auto_advance ?? DEFAULT_PREFERENCES.auto_advance, 94 106 allow_comments: data.allow_comments ?? DEFAULT_PREFERENCES.allow_comments, 95 107 hidden_tags: data.hidden_tags ?? DEFAULT_PREFERENCES.hidden_tags, 96 - theme: data.theme ?? DEFAULT_PREFERENCES.theme 108 + theme: data.theme ?? DEFAULT_PREFERENCES.theme, 109 + enable_teal_scrobbling: data.enable_teal_scrobbling ?? DEFAULT_PREFERENCES.enable_teal_scrobbling, 110 + teal_needs_reauth: data.teal_needs_reauth ?? DEFAULT_PREFERENCES.teal_needs_reauth 97 111 }; 98 112 } else { 99 113 this.data = { ...DEFAULT_PREFERENCES };
+6 -2
frontend/src/routes/+layout.ts
··· 15 15 auto_advance: true, 16 16 allow_comments: true, 17 17 hidden_tags: ['ai'], 18 - theme: 'dark' 18 + theme: 'dark', 19 + enable_teal_scrobbling: false, 20 + teal_needs_reauth: false 19 21 }; 20 22 21 23 export async function load({ fetch }: LoadEvent): Promise<LayoutData> { ··· 48 50 auto_advance: data.auto_advance ?? true, 49 51 allow_comments: data.allow_comments ?? true, 50 52 hidden_tags: data.hidden_tags ?? ['ai'], 51 - theme: data.theme ?? 'dark' 53 + theme: data.theme ?? 'dark', 54 + enable_teal_scrobbling: data.enable_teal_scrobbling ?? false, 55 + teal_needs_reauth: data.teal_needs_reauth ?? false 52 56 }; 53 57 } 54 58 } catch (e) {
+60 -1
frontend/src/routes/portal/+page.svelte
··· 34 34 let avatarUrl = $state(''); 35 35 // derive from preferences store 36 36 let allowComments = $derived(preferences.allowComments); 37 + let enableTealScrobbling = $derived(preferences.enableTealScrobbling); 38 + let tealNeedsReauth = $derived(preferences.tealNeedsReauth); 37 39 let savingProfile = $state(false); 38 40 let profileSuccess = $state(''); 39 41 let profileError = $state(''); ··· 95 97 developerToken = data.session_id; 96 98 toast.success('developer token created - save it now!'); 97 99 } else { 98 - // regular login - initialize auth 100 + // regular login - initialize auth and refresh preferences 99 101 await auth.initialize(); 102 + await preferences.fetch(); 100 103 } 101 104 } 102 105 } catch (_e) { ··· 200 203 try { 201 204 await preferences.update({ allow_comments: enabled }); 202 205 toast.success(enabled ? 'comments enabled on your tracks' : 'comments disabled'); 206 + } catch (_e) { 207 + console.error('failed to save preference:', _e); 208 + toast.error('failed to update preference'); 209 + } 210 + } 211 + 212 + async function saveTealScrobbling(enabled: boolean) { 213 + try { 214 + await preferences.update({ enable_teal_scrobbling: enabled }); 215 + await preferences.fetch(); // refetch to get updated teal_needs_reauth status 216 + toast.success(enabled ? 'teal.fm scrobbling enabled' : 'teal.fm scrobbling disabled'); 203 217 } catch (_e) { 204 218 console.error('failed to save preference:', _e); 205 219 toast.error('failed to update preference'); ··· 996 1010 997 1011 <section class="data-section"> 998 1012 <h2>your data</h2> 1013 + 1014 + <div class="data-control"> 1015 + <div class="control-info"> 1016 + <h3>teal.fm scrobbling</h3> 1017 + <p class="control-description"> 1018 + track your listens as <a href="https://pdsls.dev/at://{auth.user?.did}/fm.teal.alpha.feed.play" target="_blank" rel="noopener">fm.teal.alpha.feed.play</a> records 1019 + </p> 1020 + </div> 1021 + <label class="toggle-switch"> 1022 + <input 1023 + type="checkbox" 1024 + aria-label="Enable teal.fm scrobbling" 1025 + checked={enableTealScrobbling} 1026 + onchange={(e) => saveTealScrobbling((e.target as HTMLInputElement).checked)} 1027 + /> 1028 + <span class="toggle-slider"></span> 1029 + <span class="toggle-label">{enableTealScrobbling ? 'enabled' : 'disabled'}</span> 1030 + </label> 1031 + </div> 1032 + {#if tealNeedsReauth} 1033 + <div class="reauth-notice"> 1034 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1035 + <circle cx="12" cy="12" r="10" /> 1036 + <path d="M12 16v-4M12 8h.01" /> 1037 + </svg> 1038 + <span>please log out and back in to connect teal.fm</span> 1039 + </div> 1040 + {/if} 999 1041 1000 1042 <div class="data-control"> 1001 1043 <div class="control-info"> ··· 2076 2118 2077 2119 .control-description a { 2078 2120 color: var(--accent); 2121 + } 2122 + 2123 + .reauth-notice { 2124 + display: flex; 2125 + align-items: center; 2126 + gap: 0.5rem; 2127 + padding: 0.6rem 0.75rem; 2128 + background: color-mix(in srgb, var(--accent) 12%, transparent); 2129 + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); 2130 + border-radius: 6px; 2131 + color: var(--accent); 2132 + font-size: 0.8rem; 2133 + margin-top: -0.5rem; 2134 + } 2135 + 2136 + .reauth-notice svg { 2137 + flex-shrink: 0; 2079 2138 } 2080 2139 2081 2140 .export-btn {