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