audio streaming app plyr.fm

feat: remove jams feature flag — available to all users (#965)

Jams no longer require the per-user "jams" flag. All authenticated
users can now create and join shared listening rooms.

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

authored by zzstoatzz.io

Claude Opus 4.6 and committed by
GitHub
c6661b0d 70651c0c

+13 -88
-1
backend/src/backend/_internal/feature_flags.py
··· 12 12 # known flags - add new flags here for documentation 13 13 KNOWN_FLAGS = frozenset( 14 14 { 15 - "jams", # enable shared listening rooms 16 15 "lossless-uploads", # enable AIFF/FLAC upload support 17 16 "pds-audio-uploads", # enable PDS audio blob uploads 18 17 "vibe-search", # enable semantic vibe search in Cmd+K
+1 -35
backend/src/backend/api/jams.py
··· 14 14 ) 15 15 from pydantic import BaseModel, Field 16 16 from sqlalchemy import select 17 - from sqlalchemy.ext.asyncio import AsyncSession 18 17 19 - from backend._internal import Session, has_flag, jam_service, require_auth 18 + from backend._internal import Session, jam_service, require_auth 20 19 from backend._internal.auth.session import get_session 21 - from backend.models import get_db 22 20 from backend.models.jam import JamParticipant 23 21 from backend.utilities.database import db_session 24 22 25 23 logger = logging.getLogger(__name__) 26 24 27 25 router = APIRouter(prefix="/jams", tags=["jams"]) 28 - 29 - JAMS_FLAG = "jams" 30 - 31 26 32 27 # ── request/response models ─────────────────────────────────────── 33 28 ··· 64 59 participants: list[dict[str, Any]] = Field(default_factory=list) 65 60 66 61 67 - # ── flag check helper ────────────────────────────────────────────── 68 - 69 - 70 - async def _require_jams_flag(session: Session, db: AsyncSession) -> None: 71 - """raise 403 if user doesn't have the jams flag.""" 72 - if not await has_flag(db, session.did, JAMS_FLAG): 73 - raise HTTPException(status_code=403, detail="jams feature not enabled") 74 - 75 - 76 62 # ── REST endpoints ───────────────────────────────────────────────── 77 63 78 64 79 65 @router.post("/", response_model=JamResponse) 80 66 async def create_jam( 81 67 body: CreateJamRequest, 82 - db: Annotated[AsyncSession, Depends(get_db)], 83 68 session: Session = Depends(require_auth), 84 69 ) -> JamResponse: 85 70 """create a new jam.""" 86 - await _require_jams_flag(session, db) 87 71 result = await jam_service.create_jam( 88 72 host_did=session.did, 89 73 name=body.name, ··· 97 81 98 82 @router.get("/active", response_model=JamResponse | None) 99 83 async def get_active_jam( 100 - db: Annotated[AsyncSession, Depends(get_db)], 101 84 session: Session = Depends(require_auth), 102 85 ) -> JamResponse | None: 103 86 """get the user's current active jam.""" 104 - await _require_jams_flag(session, db) 105 87 result = await jam_service.get_active_jam(session.did) 106 88 if not result: 107 89 return None ··· 111 93 @router.get("/{code}", response_model=JamResponse) 112 94 async def get_jam( 113 95 code: str, 114 - db: Annotated[AsyncSession, Depends(get_db)], 115 96 session: Session = Depends(require_auth), 116 97 ) -> JamResponse: 117 98 """get jam details by code.""" 118 - await _require_jams_flag(session, db) 119 99 result = await jam_service.get_jam_by_code(code) 120 100 if not result: 121 101 raise HTTPException(status_code=404, detail="jam not found") ··· 125 105 @router.post("/{code}/join", response_model=JamResponse) 126 106 async def join_jam( 127 107 code: str, 128 - db: Annotated[AsyncSession, Depends(get_db)], 129 108 session: Session = Depends(require_auth), 130 109 ) -> JamResponse: 131 110 """join a jam.""" 132 - await _require_jams_flag(session, db) 133 111 result = await jam_service.join_jam(code, session.did) 134 112 if not result: 135 113 raise HTTPException(status_code=404, detail="jam not found or not active") ··· 139 117 @router.post("/{code}/leave") 140 118 async def leave_jam( 141 119 code: str, 142 - db: Annotated[AsyncSession, Depends(get_db)], 143 120 session: Session = Depends(require_auth), 144 121 ) -> dict[str, bool]: 145 122 """leave a jam.""" 146 - await _require_jams_flag(session, db) 147 123 jam = await jam_service.get_jam_by_code(code) 148 124 if not jam: 149 125 raise HTTPException(status_code=404, detail="jam not found") ··· 156 132 @router.post("/{code}/end") 157 133 async def end_jam( 158 134 code: str, 159 - db: Annotated[AsyncSession, Depends(get_db)], 160 135 session: Session = Depends(require_auth), 161 136 ) -> dict[str, bool]: 162 137 """end a jam (host only).""" 163 - await _require_jams_flag(session, db) 164 138 jam = await jam_service.get_jam_by_code(code) 165 139 if not jam: 166 140 raise HTTPException(status_code=404, detail="jam not found") ··· 174 148 async def jam_command( 175 149 code: str, 176 150 body: CommandRequest, 177 - db: Annotated[AsyncSession, Depends(get_db)], 178 151 session: Session = Depends(require_auth), 179 152 ) -> dict[str, Any]: 180 153 """send a playback command to the jam.""" 181 - await _require_jams_flag(session, db) 182 154 jam = await jam_service.get_jam_by_code(code) 183 155 if not jam: 184 156 raise HTTPException(status_code=404, detail="jam not found") ··· 228 200 if not session: 229 201 await ws.close(code=4001, reason="invalid session") 230 202 return 231 - 232 - # verify flag 233 - async with db_session() as db: 234 - if not await has_flag(db, session.did, JAMS_FLAG): 235 - await ws.close(code=4003, reason="jams feature not enabled") 236 - return 237 203 238 204 # look up jam 239 205 jam = await jam_service.get_jam_by_code(code)
-32
backend/tests/api/test_jams.py
··· 10 10 from sqlalchemy.ext.asyncio import AsyncSession 11 11 12 12 from backend._internal import Session 13 - from backend._internal.feature_flags import enable_flag 14 13 from backend._internal.jams import JamService, jam_service 15 14 from backend.main import app 16 15 from backend.models import Artist ··· 65 64 db_session.add(artist) 66 65 await db_session.flush() 67 66 68 - # enable jams flag for the test user 69 - await enable_flag(db_session, "did:test:host", "jams") 70 67 await db_session.commit() 71 68 72 69 yield app ··· 83 80 display_name="Test Joiner", 84 81 ) 85 82 db_session.add(artist) 86 - await enable_flag(db_session, "did:test:joiner", "jams") 87 83 await db_session.commit() 88 84 return "did:test:joiner" 89 85 ··· 483 479 484 480 assert active_response.status_code == 200 485 481 assert active_response.json()["code"] == second_code 486 - 487 - 488 - async def test_flag_gating(test_app: FastAPI, db_session: AsyncSession) -> None: 489 - """test that users without the jams flag get 403.""" 490 - from backend._internal import require_auth 491 - 492 - # create a user without the flag 493 - no_flag_artist = Artist( 494 - did="did:test:noflag", 495 - handle="test.noflag", 496 - display_name="No Flag", 497 - ) 498 - db_session.add(no_flag_artist) 499 - await db_session.commit() 500 - 501 - async def mock_noflag_auth() -> Session: 502 - return MockSession(did="did:test:noflag") 503 - 504 - app.dependency_overrides[require_auth] = mock_noflag_auth 505 - 506 - async with AsyncClient( 507 - transport=ASGITransport(app=test_app), base_url="http://test" 508 - ) as client: 509 - response = await client.post("/jams/", json={}) 510 - 511 - assert response.status_code == 403 512 - assert "not enabled" in response.json()["detail"] 513 482 514 483 515 484 async def test_get_active_jam_none(test_app: FastAPI, db_session: AsyncSession) -> None: ··· 1447 1416 display_name="Test Third", 1448 1417 ) 1449 1418 db_session.add(third_artist) 1450 - await enable_flag(db_session, third_did, "jams") 1451 1419 await db_session.commit() 1452 1420 1453 1421 async with AsyncClient(
+11 -17
frontend/src/lib/components/Queue.svelte
··· 2 2 import { queue } from '$lib/queue.svelte'; 3 3 import { player } from '$lib/player.svelte'; 4 4 import { goToIndex } from '$lib/playback.svelte'; 5 - import { auth } from '$lib/auth.svelte'; 6 5 import { jam } from '$lib/jam.svelte'; 7 6 import { toast } from '$lib/toast.svelte'; 8 - import { JAMS_FLAG } from '$lib/config'; 9 7 import type { Track, JamParticipant } from '$lib/types'; 10 8 11 9 let draggedIndex = $state<number | null>(null); ··· 17 15 let touchCurrentY = $state(0); 18 16 let touchDragElement = $state<HTMLElement | null>(null); 19 17 let queueTracksElement = $state<HTMLElement | null>(null); 20 - 21 - const canJam = $derived(auth.user?.enabled_flags?.includes(JAMS_FLAG) ?? false); 22 18 23 19 async function startJam() { 24 20 const trackIds = queue.tracks.map((t) => t.file_id); ··· 219 215 {:else} 220 216 <h2>queue</h2> 221 217 <div class="queue-actions"> 222 - {#if canJam} 223 218 <button 224 - class="jam-btn" 225 - onclick={startJam} 226 - title="start a jam" 227 - > 228 - <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 229 - <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path> 230 - <circle cx="9" cy="7" r="4"></circle> 231 - <path d="M23 21v-2a4 4 0 0 0-3-3.87"></path> 232 - <path d="M16 3.13a4 4 0 0 1 0 7.75"></path> 233 - </svg> 234 - </button> 235 - {/if} 219 + class="jam-btn" 220 + onclick={startJam} 221 + title="start a jam" 222 + > 223 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 224 + <path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path> 225 + <circle cx="9" cy="7" r="4"></circle> 226 + <path d="M23 21v-2a4 4 0 0 0-3-3.87"></path> 227 + <path d="M16 3.13a4 4 0 0 1 0 7.75"></path> 228 + </svg> 229 + </button> 236 230 <button 237 231 class="shuffle-btn" 238 232 class:active={queue.shuffle}
-1
frontend/src/lib/config.ts
··· 4 4 5 5 export const PDS_AUDIO_UPLOADS_FLAG = 'pds-audio-uploads'; 6 6 export const VIBE_SEARCH_FLAG = 'vibe-search'; 7 - export const JAMS_FLAG = 'jams'; 8 7 9 8 /** 10 9 * generate atprotofans support URL for an artist.
+1 -2
frontend/src/routes/+layout.svelte
··· 22 22 import { player } from '$lib/player.svelte'; 23 23 import { queue } from '$lib/queue.svelte'; 24 24 import { jam } from '$lib/jam.svelte'; 25 - import { JAMS_FLAG } from '$lib/config'; 26 25 import { search } from '$lib/search.svelte'; 27 26 import { browser } from '$app/environment'; 28 27 let { children } = $props<{ children: any }>(); ··· 67 66 68 67 // check for active jam first — if rejoining, jam owns the queue state 69 68 let joinedJam = false; 70 - if (!jam.active && auth.user?.enabled_flags?.includes(JAMS_FLAG)) { 69 + if (!jam.active) { 71 70 const activeJam = await jam.fetchActive(); 72 71 if (activeJam) { 73 72 await jam.join(activeJam.code);