audio streaming app plyr.fm
38
fork

Configure Feed

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

feat: add per-user feature flag infrastructure (#788)

* feat: add transcoder test matrix with CI

- test-matrix.py: tests 5 input formats (AIFF, FLAC, WAV, MP3, M4A) x 3 output formats (MP3, M4A, WAV)
- CI workflow runs on PRs touching transcoder or test scripts
- uses uv inline script dependencies for portability

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

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

* feat: add per-user feature flag infrastructure

adds database-level feature flags for gradual feature rollout:
- `enabled_flags` column on artists table (string array)
- `has_flag()` helper in `_internal/feature_flags.py`
- flags exposed via `/auth/me` response
- admin script for enabling/disabling flags per user

this enables hiding features from users until explicitly enabled,
supporting controlled rollout of experimental features like lossless uploads.

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

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

* refactor: move feature flags to dedicated table

instead of storing flags on the Artist model (which conflates "artist"
with "user"), use a separate feature_flags table with a foreign key to
artists.did.

- new FeatureFlag model with user_did + flag unique constraint
- async helper functions: has_flag, get_user_flags, enable_flag, disable_flag
- /auth/me fetches flags from dedicated table
- admin script updated to use new table-based approach
- migration creates feature_flags table

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

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

* docs: add feature flags documentation

explains the feature flag system: database schema, checking flags in
backend/frontend code, admin script usage, and rollout strategy.

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

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

---------

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

authored by

nate nowack
Claude Opus 4.5
and committed by
GitHub
4b09723f 299ff709

+1000 -1
+63
.github/workflows/test-transcoder.yml
··· 1 + name: transcoder integration tests 2 + 3 + on: 4 + pull_request: 5 + paths: 6 + - "transcoder/**" 7 + - "scripts/transcoder/**" 8 + - "scripts/generate_audio_sample.py" 9 + - ".github/workflows/test-transcoder.yml" 10 + workflow_dispatch: 11 + 12 + permissions: 13 + contents: read 14 + 15 + jobs: 16 + test: 17 + name: transcoder test matrix 18 + runs-on: ubuntu-latest 19 + timeout-minutes: 10 20 + 21 + steps: 22 + - uses: actions/checkout@v4 23 + 24 + - name: install ffmpeg 25 + run: | 26 + sudo apt-get update 27 + sudo apt-get install -y ffmpeg 28 + 29 + - name: install rust toolchain 30 + uses: dtolnay/rust-toolchain@stable 31 + 32 + - name: cache cargo 33 + uses: Swatinem/rust-cache@v2 34 + with: 35 + workspaces: transcoder 36 + 37 + - name: build transcoder 38 + working-directory: transcoder 39 + run: cargo build --release 40 + 41 + - name: install uv 42 + uses: astral-sh/setup-uv@v5 43 + 44 + - name: start transcoder 45 + run: | 46 + TRANSCODER_HOST=127.0.0.1 TRANSCODER_PORT=8082 \ 47 + ./transcoder/target/release/transcoder & 48 + 49 + # wait for health check 50 + for i in {1..30}; do 51 + if curl -s http://127.0.0.1:8082/health > /dev/null; then 52 + echo "transcoder ready" 53 + break 54 + fi 55 + sleep 0.5 56 + done 57 + 58 + - name: run test matrix 59 + run: uv run scripts/transcoder/test-matrix.py -v 60 + 61 + - name: stop transcoder 62 + if: always() 63 + run: pkill -f transcoder || true
+42
backend/alembic/versions/2026_01_24_235203_1a94c1ea171d_add_feature_flags_table.py
··· 1 + """add_feature_flags_table 2 + 3 + Revision ID: 1a94c1ea171d 4 + Revises: add_share_links_tables 5 + Create Date: 2026-01-24 23:52:03.235872 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 = "1a94c1ea171d" 17 + down_revision: str | Sequence[str] | None = "add_share_links_tables" 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.create_table( 25 + "feature_flags", 26 + sa.Column("id", sa.Integer(), nullable=False), 27 + sa.Column("user_did", sa.String(), nullable=False), 28 + sa.Column("flag", sa.String(length=64), nullable=False), 29 + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), 30 + sa.ForeignKeyConstraint(["user_did"], ["artists.did"], ondelete="CASCADE"), 31 + sa.PrimaryKeyConstraint("id"), 32 + sa.UniqueConstraint("user_did", "flag", name="uq_user_flag"), 33 + ) 34 + op.create_index( 35 + op.f("ix_feature_flags_user_did"), "feature_flags", ["user_did"], unique=False 36 + ) 37 + 38 + 39 + def downgrade() -> None: 40 + """Downgrade schema.""" 41 + op.drop_index(op.f("ix_feature_flags_user_did"), table_name="feature_flags") 42 + op.drop_table("feature_flags")
+9
backend/src/backend/_internal/__init__.py
··· 39 39 update_session_tokens, 40 40 ) 41 41 from backend._internal.constellation import get_like_count_safe 42 + from backend._internal.feature_flags import ( 43 + KNOWN_FLAGS, 44 + disable_flag, 45 + enable_flag, 46 + get_user_flags, 47 + has_flag, 48 + ) 42 49 from backend._internal.notifications import notification_service 43 50 from backend._internal.now_playing import now_playing_service 44 51 from backend._internal.queue import queue_service 45 52 from backend._internal.atprotofans import get_supported_artists, validate_supporter 46 53 47 54 __all__ = [ 55 + "KNOWN_FLAGS", 48 56 "DeveloperToken", 49 57 "LinkedAccount", 50 58 "PendingAddAccountData", ··· 71 79 "get_session_group", 72 80 "get_supported_artists", 73 81 "handle_oauth_callback", 82 + "has_flag", 74 83 "list_developer_tokens", 75 84 "notification_service", 76 85 "now_playing_service",
+106
backend/src/backend/_internal/feature_flags.py
··· 1 + """feature flag utilities. 2 + 3 + per-user feature flags stored in a dedicated table. 4 + flags are enabled by admins via script and checked in backend code. 5 + """ 6 + 7 + from sqlalchemy import select 8 + from sqlalchemy.ext.asyncio import AsyncSession 9 + 10 + from backend.models.feature_flag import FeatureFlag 11 + 12 + # known flags - add new flags here for documentation 13 + KNOWN_FLAGS = frozenset( 14 + { 15 + "lossless-uploads", # enable AIFF/FLAC upload support 16 + } 17 + ) 18 + 19 + 20 + async def has_flag(db: AsyncSession, user_did: str, flag: str) -> bool: 21 + """check if a user has a feature flag enabled. 22 + 23 + args: 24 + db: database session 25 + user_did: the user's DID 26 + flag: the flag name (kebab-case, e.g. "lossless-uploads") 27 + 28 + returns: 29 + True if the flag is enabled for this user 30 + """ 31 + result = await db.execute( 32 + select(FeatureFlag).where( 33 + FeatureFlag.user_did == user_did, 34 + FeatureFlag.flag == flag, 35 + ) 36 + ) 37 + return result.scalar_one_or_none() is not None 38 + 39 + 40 + async def get_user_flags(db: AsyncSession, user_did: str) -> list[str]: 41 + """get all enabled flags for a user. 42 + 43 + args: 44 + db: database session 45 + user_did: the user's DID 46 + 47 + returns: 48 + list of enabled flag names 49 + """ 50 + result = await db.execute( 51 + select(FeatureFlag.flag).where(FeatureFlag.user_did == user_did) 52 + ) 53 + return list(result.scalars().all()) 54 + 55 + 56 + async def enable_flag(db: AsyncSession, user_did: str, flag: str) -> bool: 57 + """enable a feature flag for a user. 58 + 59 + args: 60 + db: database session 61 + user_did: the user's DID 62 + flag: the flag name 63 + 64 + returns: 65 + True if flag was newly enabled, False if already enabled 66 + """ 67 + # check if already enabled 68 + existing = await db.execute( 69 + select(FeatureFlag).where( 70 + FeatureFlag.user_did == user_did, 71 + FeatureFlag.flag == flag, 72 + ) 73 + ) 74 + if existing.scalar_one_or_none(): 75 + return False 76 + 77 + # create new flag 78 + db.add(FeatureFlag(user_did=user_did, flag=flag)) 79 + await db.flush() 80 + return True 81 + 82 + 83 + async def disable_flag(db: AsyncSession, user_did: str, flag: str) -> bool: 84 + """disable a feature flag for a user. 85 + 86 + args: 87 + db: database session 88 + user_did: the user's DID 89 + flag: the flag name 90 + 91 + returns: 92 + True if flag was disabled, False if wasn't enabled 93 + """ 94 + result = await db.execute( 95 + select(FeatureFlag).where( 96 + FeatureFlag.user_did == user_did, 97 + FeatureFlag.flag == flag, 98 + ) 99 + ) 100 + flag_record = result.scalar_one_or_none() 101 + if not flag_record: 102 + return False 103 + 104 + await db.delete(flag_record) 105 + await db.flush() 106 + return True
+6
backend/src/backend/api/auth.py
··· 25 25 get_pending_dev_token, 26 26 get_pending_scope_upgrade, 27 27 get_session_group, 28 + get_user_flags, 28 29 handle_oauth_callback, 29 30 list_developer_tokens, 30 31 require_auth, ··· 61 62 did: str 62 63 handle: str 63 64 linked_accounts: list[LinkedAccountResponse] = [] 65 + enabled_flags: list[str] = [] 64 66 65 67 66 68 class DeveloperTokenInfo(BaseModel): ··· 373 375 for artist in result.scalars().all(): 374 376 avatar_map[artist.did] = artist.avatar_url 375 377 378 + # get feature flags for current user from dedicated table 379 + current_user_flags = await get_user_flags(db, session.did) 380 + 376 381 return CurrentUserResponse( 377 382 did=session.did, 378 383 handle=session.handle, ··· 384 389 ) 385 390 for account in linked 386 391 ], 392 + enabled_flags=current_user_flags, 387 393 ) 388 394 389 395
+2
backend/src/backend/models/__init__.py
··· 6 6 from backend.models.database import Base 7 7 from backend.models.sensitive_image import SensitiveImage 8 8 from backend.models.exchange_token import ExchangeToken 9 + from backend.models.feature_flag import FeatureFlag 9 10 from backend.models.job import Job 10 11 from backend.models.oauth_state import OAuthStateModel 11 12 from backend.models.pending_add_account import PendingAddAccount ··· 28 29 "Base", 29 30 "CopyrightScan", 30 31 "ExchangeToken", 32 + "FeatureFlag", 31 33 "Job", 32 34 "OAuthStateModel", 33 35 "PendingAddAccount",
+34
backend/src/backend/models/feature_flag.py
··· 1 + """feature flag model for per-user feature toggles.""" 2 + 3 + from datetime import UTC, datetime 4 + 5 + from sqlalchemy import DateTime, ForeignKey, String, UniqueConstraint 6 + from sqlalchemy.orm import Mapped, mapped_column 7 + 8 + from backend.models.database import Base 9 + 10 + 11 + class FeatureFlag(Base): 12 + """per-user feature flag. 13 + 14 + stores which features are enabled for specific users. 15 + flags are enabled by admins and checked in backend code. 16 + """ 17 + 18 + __tablename__ = "feature_flags" 19 + 20 + id: Mapped[int] = mapped_column(primary_key=True) 21 + user_did: Mapped[str] = mapped_column( 22 + String, 23 + ForeignKey("artists.did", ondelete="CASCADE"), 24 + nullable=False, 25 + index=True, 26 + ) 27 + flag: Mapped[str] = mapped_column(String(64), nullable=False) 28 + created_at: Mapped[datetime] = mapped_column( 29 + DateTime(timezone=True), 30 + default=lambda: datetime.now(UTC), 31 + nullable=False, 32 + ) 33 + 34 + __table_args__ = (UniqueConstraint("user_did", "flag", name="uq_user_flag"),)
+158
docs/backend/feature-flags.md
··· 1 + # feature flags 2 + 3 + per-user feature flags for controlled rollout of experimental features. 4 + 5 + ## overview 6 + 7 + feature flags allow specific features to be enabled for individual users before general availability. this supports: 8 + - **testing** - enable for internal testers first 9 + - **gradual rollout** - expand to users who request access 10 + - **hiding experiments** - features are completely invisible to users without the flag 11 + 12 + flags are stored in a dedicated database table and exposed via the `/auth/me` endpoint. 13 + 14 + ## database schema 15 + 16 + ```sql 17 + CREATE TABLE feature_flags ( 18 + id SERIAL PRIMARY KEY, 19 + user_did VARCHAR NOT NULL REFERENCES artists(did) ON DELETE CASCADE, 20 + flag VARCHAR(64) NOT NULL, 21 + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), 22 + UNIQUE(user_did, flag) 23 + ); 24 + 25 + CREATE INDEX ix_feature_flags_user_did ON feature_flags(user_did); 26 + ``` 27 + 28 + each row represents one flag enabled for one user. the unique constraint prevents duplicate entries. 29 + 30 + ## known flags 31 + 32 + flags are documented in `backend/_internal/feature_flags.py`: 33 + 34 + ```python 35 + KNOWN_FLAGS = frozenset({ 36 + "lossless-uploads", # enable AIFF/FLAC upload support 37 + }) 38 + ``` 39 + 40 + add new flags to `KNOWN_FLAGS` for documentation purposes. 41 + 42 + ## checking flags in code 43 + 44 + ### backend 45 + 46 + ```python 47 + from backend._internal import has_flag, get_user_flags 48 + 49 + # check a specific flag 50 + if await has_flag(db, user_did, "lossless-uploads"): 51 + # feature is enabled for this user 52 + pass 53 + 54 + # get all flags for a user 55 + flags = await get_user_flags(db, user_did) # ["lossless-uploads", ...] 56 + ``` 57 + 58 + ### frontend 59 + 60 + flags are returned in the `/auth/me` response: 61 + 62 + ```typescript 63 + // $lib/state/auth.svelte.ts 64 + const auth = getAuth(); 65 + 66 + if (auth.user?.enabled_flags.includes("lossless-uploads")) { 67 + // show lossless upload UI 68 + } 69 + ``` 70 + 71 + ## api 72 + 73 + ### GET /auth/me 74 + 75 + returns the current user's enabled flags: 76 + 77 + ```json 78 + { 79 + "did": "did:plc:abc123", 80 + "handle": "alice.bsky.social", 81 + "linked_accounts": [...], 82 + "enabled_flags": ["lossless-uploads"] 83 + } 84 + ``` 85 + 86 + ## admin script 87 + 88 + manage flags via the admin script (requires `DATABASE_URL`): 89 + 90 + ```bash 91 + cd backend 92 + 93 + # enable a flag for a user 94 + DATABASE_URL="..." uv run python ../scripts/feature_flag.py enable --user zzstoatzz.io --flag lossless-uploads 95 + 96 + # disable a flag 97 + DATABASE_URL="..." uv run python ../scripts/feature_flag.py disable --user zzstoatzz.io --flag lossless-uploads 98 + 99 + # list flags for a user 100 + DATABASE_URL="..." uv run python ../scripts/feature_flag.py list --user zzstoatzz.io 101 + 102 + # list all users with flags 103 + DATABASE_URL="..." uv run python ../scripts/feature_flag.py list-all 104 + ``` 105 + 106 + users can be specified by handle or DID. 107 + 108 + ## adding a new flag 109 + 110 + 1. **add to KNOWN_FLAGS** in `backend/_internal/feature_flags.py`: 111 + ```python 112 + KNOWN_FLAGS = frozenset({ 113 + "lossless-uploads", 114 + "new-feature", # description of what this enables 115 + }) 116 + ``` 117 + 118 + 2. **check the flag** in backend code where the feature is gated: 119 + ```python 120 + if not await has_flag(db, user_did, "new-feature"): 121 + raise HTTPException(400, "feature not available") 122 + ``` 123 + 124 + 3. **check the flag** in frontend if UI needs to be hidden: 125 + ```typescript 126 + {#if auth.user?.enabled_flags.includes("new-feature")} 127 + <NewFeatureButton /> 128 + {/if} 129 + ``` 130 + 131 + 4. **enable for testers** via the admin script 132 + 133 + ## rollout strategy 134 + 135 + typical progression for a new feature: 136 + 137 + 1. **phase 0**: flag exists in `KNOWN_FLAGS`, no users have it enabled 138 + 2. **phase 1**: enable for internal testers (ourselves) 139 + 3. **phase 2**: enable for users who request access 140 + 4. **phase 3**: general availability (remove flag checks, or add to user preferences) 141 + 142 + ## design decisions 143 + 144 + ### why a separate table? 145 + 146 + considered storing flags as an array column on the Artist model, but: 147 + - "Artist" conflates with "user" - not all users are artists 148 + - separate table is more normalized and future-proof 149 + - easier to query "all users with flag X" 150 + - cleaner foreign key relationship 151 + 152 + ### why not user preferences? 153 + 154 + user preferences are opt-in/opt-out for visible features. feature flags are for features that should be **completely hidden** until enabled by an admin. users can't enable a preference they can't see. 155 + 156 + ### naming convention 157 + 158 + flags use kebab-case: `lossless-uploads`, `beta-ui`, `early-access`
+1 -1
loq.toml
··· 30 30 31 31 [[rules]] 32 32 path = "backend/src/backend/api/auth.py" 33 - max_lines = 706 33 + max_lines = 712 34 34 35 35 [[rules]] 36 36 path = "backend/src/backend/api/lists.py"
+213
scripts/feature_flag.py
··· 1 + #!/usr/bin/env python 2 + """admin script to manage feature flags for users. 3 + 4 + usage (from repo root): 5 + cd backend && uv run python ../scripts/feature_flag.py enable --user <did_or_handle> --flag <flag_name> 6 + cd backend && uv run python ../scripts/feature_flag.py disable --user <did_or_handle> --flag <flag_name> 7 + cd backend && uv run python ../scripts/feature_flag.py list --user <did_or_handle> 8 + cd backend && uv run python ../scripts/feature_flag.py list-all 9 + 10 + environment variables: 11 + DATABASE_URL - database connection string 12 + 13 + examples: 14 + # enable lossless uploads for a user 15 + cd backend && DATABASE_URL="..." uv run python ../scripts/feature_flag.py enable --user did:plc:abc123 --flag lossless-uploads 16 + 17 + # disable a flag 18 + cd backend && DATABASE_URL="..." uv run python ../scripts/feature_flag.py disable --user zzstoatzz.io --flag lossless-uploads 19 + 20 + # list flags for a user 21 + cd backend && DATABASE_URL="..." uv run python ../scripts/feature_flag.py list --user zzstoatzz.io 22 + 23 + # list all users with flags 24 + cd backend && DATABASE_URL="..." uv run python ../scripts/feature_flag.py list-all 25 + """ 26 + 27 + import argparse 28 + import asyncio 29 + import os 30 + import sys 31 + from pathlib import Path 32 + 33 + # add backend/src to path 34 + sys.path.insert(0, str(Path(__file__).parent.parent / "backend" / "src")) 35 + 36 + 37 + def get_database_url() -> str: 38 + """get database URL from environment.""" 39 + url = os.environ.get("DATABASE_URL") 40 + if not url: 41 + print("error: DATABASE_URL required") 42 + print("set DATABASE_URL to your database connection string") 43 + sys.exit(1) 44 + return url 45 + 46 + 47 + async def resolve_user(db, did_or_handle: str): 48 + """resolve a DID or handle to an Artist record.""" 49 + from sqlalchemy import select 50 + 51 + from backend.models import Artist 52 + 53 + # check if it's a DID 54 + if did_or_handle.startswith("did:"): 55 + result = await db.execute(select(Artist).where(Artist.did == did_or_handle)) 56 + else: 57 + # treat as handle 58 + result = await db.execute(select(Artist).where(Artist.handle == did_or_handle)) 59 + 60 + return result.scalar_one_or_none() 61 + 62 + 63 + async def cmd_enable(args) -> None: 64 + """enable a feature flag for a user.""" 65 + from backend._internal import enable_flag, get_user_flags 66 + from backend.utilities.database import db_session 67 + 68 + async with db_session() as db: 69 + artist = await resolve_user(db, args.user) 70 + if not artist: 71 + print(f"error: user not found: {args.user}") 72 + sys.exit(1) 73 + 74 + newly_enabled = await enable_flag(db, artist.did, args.flag) 75 + await db.commit() 76 + 77 + if newly_enabled: 78 + print(f"enabled '{args.flag}' for {artist.handle} ({artist.did})") 79 + else: 80 + print(f"flag '{args.flag}' already enabled for {artist.handle}") 81 + 82 + flags = await get_user_flags(db, artist.did) 83 + print(f"flags: {flags}") 84 + 85 + 86 + async def cmd_disable(args) -> None: 87 + """disable a feature flag for a user.""" 88 + from backend._internal import disable_flag, get_user_flags 89 + from backend.utilities.database import db_session 90 + 91 + async with db_session() as db: 92 + artist = await resolve_user(db, args.user) 93 + if not artist: 94 + print(f"error: user not found: {args.user}") 95 + sys.exit(1) 96 + 97 + was_disabled = await disable_flag(db, artist.did, args.flag) 98 + await db.commit() 99 + 100 + if was_disabled: 101 + print(f"disabled '{args.flag}' for {artist.handle} ({artist.did})") 102 + else: 103 + print(f"flag '{args.flag}' not enabled for {artist.handle}") 104 + 105 + flags = await get_user_flags(db, artist.did) 106 + print(f"flags: {flags}") 107 + 108 + 109 + async def cmd_list(args) -> None: 110 + """list flags for a user.""" 111 + from backend._internal import get_user_flags 112 + from backend.utilities.database import db_session 113 + 114 + async with db_session() as db: 115 + artist = await resolve_user(db, args.user) 116 + if not artist: 117 + print(f"error: user not found: {args.user}") 118 + sys.exit(1) 119 + 120 + flags = await get_user_flags(db, artist.did) 121 + print(f"{artist.handle} ({artist.did}):") 122 + if flags: 123 + for flag in flags: 124 + print(f" - {flag}") 125 + else: 126 + print(" (no flags enabled)") 127 + 128 + 129 + async def cmd_list_all(args) -> None: 130 + """list all users with feature flags.""" 131 + from sqlalchemy import select 132 + 133 + from backend.models import Artist, FeatureFlag 134 + from backend.utilities.database import db_session 135 + 136 + async with db_session() as db: 137 + # find all unique user DIDs with flags 138 + result = await db.execute(select(FeatureFlag.user_did).distinct()) 139 + dids_with_flags = list(result.scalars().all()) 140 + 141 + if not dids_with_flags: 142 + print("no users have feature flags enabled") 143 + return 144 + 145 + # get artist info for each DID 146 + artist_result = await db.execute( 147 + select(Artist).where(Artist.did.in_(dids_with_flags)) 148 + ) 149 + artists_by_did = {a.did: a for a in artist_result.scalars().all()} 150 + 151 + # get all flags grouped by user 152 + flags_result = await db.execute(select(FeatureFlag)) 153 + all_flags = flags_result.scalars().all() 154 + 155 + # group flags by DID 156 + flags_by_did: dict[str, list[str]] = {} 157 + for flag in all_flags: 158 + flags_by_did.setdefault(flag.user_did, []).append(flag.flag) 159 + 160 + print(f"users with feature flags ({len(dids_with_flags)}):") 161 + for did in dids_with_flags: 162 + artist = artists_by_did.get(did) 163 + handle = artist.handle if artist else "(unknown)" 164 + flags = flags_by_did.get(did, []) 165 + print(f"\n{handle} ({did}):") 166 + for flag in flags: 167 + print(f" - {flag}") 168 + 169 + 170 + def main() -> None: 171 + """main entry point.""" 172 + parser = argparse.ArgumentParser(description="manage feature flags") 173 + subparsers = parser.add_subparsers(dest="command", required=True) 174 + 175 + # enable command 176 + enable_parser = subparsers.add_parser("enable", help="enable a flag for a user") 177 + enable_parser.add_argument("--user", required=True, help="user DID or handle") 178 + enable_parser.add_argument( 179 + "--flag", required=True, help="flag name (e.g., lossless-uploads)" 180 + ) 181 + 182 + # disable command 183 + disable_parser = subparsers.add_parser("disable", help="disable a flag for a user") 184 + disable_parser.add_argument("--user", required=True, help="user DID or handle") 185 + disable_parser.add_argument( 186 + "--flag", required=True, help="flag name (e.g., lossless-uploads)" 187 + ) 188 + 189 + # list command 190 + list_parser = subparsers.add_parser("list", help="list flags for a user") 191 + list_parser.add_argument("--user", required=True, help="user DID or handle") 192 + 193 + # list-all command 194 + subparsers.add_parser("list-all", help="list all users with flags") 195 + 196 + args = parser.parse_args() 197 + 198 + # setup database URL 199 + os.environ["DATABASE_URL"] = get_database_url() 200 + 201 + # run command 202 + if args.command == "enable": 203 + asyncio.run(cmd_enable(args)) 204 + elif args.command == "disable": 205 + asyncio.run(cmd_disable(args)) 206 + elif args.command == "list": 207 + asyncio.run(cmd_list(args)) 208 + elif args.command == "list-all": 209 + asyncio.run(cmd_list_all(args)) 210 + 211 + 212 + if __name__ == "__main__": 213 + main()
+366
scripts/transcoder/test-matrix.py
··· 1 + #!/usr/bin/env python3 2 + # /// script 3 + # requires-python = ">=3.11" 4 + # dependencies = ["httpx>=0.28.0"] 5 + # /// 6 + """ 7 + transcoder test matrix - tests format conversion across input/output combinations. 8 + 9 + usage: 10 + # run all tests against local transcoder 11 + uv run scripts/transcoder/test-matrix.py 12 + 13 + # run against production (requires TRANSCODER_AUTH_TOKEN) 14 + uv run scripts/transcoder/test-matrix.py --url https://plyr-transcoder.fly.dev 15 + 16 + # run specific input format only 17 + uv run scripts/transcoder/test-matrix.py --input-format aiff 18 + 19 + # verbose output 20 + uv run scripts/transcoder/test-matrix.py -v 21 + """ 22 + 23 + from __future__ import annotations 24 + 25 + import argparse 26 + import os 27 + import shutil 28 + import subprocess 29 + import sys 30 + import tempfile 31 + import time 32 + from dataclasses import dataclass 33 + from pathlib import Path 34 + 35 + import httpx 36 + 37 + # test matrix configuration 38 + INPUT_FORMATS = ["aiff", "flac", "wav", "mp3", "m4a"] 39 + OUTPUT_FORMATS = ["mp3", "m4a", "wav"] 40 + 41 + # sample generation parameters 42 + SAMPLE_DURATION = 2 # seconds 43 + SAMPLE_RATE = 44100 44 + CHANNELS = 2 45 + 46 + 47 + @dataclass 48 + class TestResult: 49 + input_format: str 50 + output_format: str 51 + success: bool 52 + duration_ms: float 53 + input_size: int 54 + output_size: int 55 + error: str | None = None 56 + 57 + 58 + def generate_sample(output_path: Path, format: str) -> bool: 59 + """generate a test audio sample using ffmpeg.""" 60 + cmd = [ 61 + sys.executable, 62 + "scripts/generate_audio_sample.py", 63 + str(output_path), 64 + "--waveform", 65 + "sine", 66 + "--duration", 67 + str(SAMPLE_DURATION), 68 + "--sample-rate", 69 + str(SAMPLE_RATE), 70 + "--channels", 71 + str(CHANNELS), 72 + "--frequency", 73 + "440", 74 + "--fade-in", 75 + "0.1", 76 + "--fade-out", 77 + "0.1", 78 + "--force", 79 + "--log-level", 80 + "error", 81 + ] 82 + result = subprocess.run(cmd, capture_output=True, text=True) 83 + return result.returncode == 0 84 + 85 + 86 + def transcode_file( 87 + input_path: Path, 88 + target_format: str, 89 + url: str, 90 + auth_token: str | None, 91 + timeout: float = 60.0, 92 + ) -> tuple[bytes | None, str | None]: 93 + """send file to transcoder and return result.""" 94 + headers = {} 95 + if auth_token: 96 + headers["X-Transcoder-Key"] = auth_token 97 + 98 + try: 99 + with open(input_path, "rb") as f: 100 + files = {"file": (input_path.name, f)} 101 + response = httpx.post( 102 + f"{url}/transcode", 103 + params={"target": target_format}, 104 + files=files, 105 + headers=headers, 106 + timeout=timeout, 107 + ) 108 + 109 + if response.status_code == 200: 110 + return response.content, None 111 + else: 112 + return None, f"HTTP {response.status_code}: {response.text[:200]}" 113 + except httpx.TimeoutException: 114 + return None, "timeout" 115 + except Exception as e: 116 + return None, str(e) 117 + 118 + 119 + def verify_audio(file_path: Path) -> bool: 120 + """verify audio file is valid using ffprobe.""" 121 + cmd = [ 122 + "ffprobe", 123 + "-v", 124 + "error", 125 + "-show_entries", 126 + "format=duration", 127 + "-of", 128 + "csv=p=0", 129 + str(file_path), 130 + ] 131 + result = subprocess.run(cmd, capture_output=True, text=True) 132 + if result.returncode != 0: 133 + return False 134 + try: 135 + duration = float(result.stdout.strip()) 136 + # allow some tolerance for duration 137 + return duration > SAMPLE_DURATION * 0.8 138 + except ValueError: 139 + return False 140 + 141 + 142 + def run_test( 143 + input_format: str, 144 + output_format: str, 145 + samples_dir: Path, 146 + url: str, 147 + auth_token: str | None, 148 + verbose: bool = False, 149 + ) -> TestResult: 150 + """run a single transcoding test.""" 151 + input_path = samples_dir / f"test.{input_format}" 152 + 153 + if not input_path.exists(): 154 + return TestResult( 155 + input_format=input_format, 156 + output_format=output_format, 157 + success=False, 158 + duration_ms=0, 159 + input_size=0, 160 + output_size=0, 161 + error=f"input file not found: {input_path}", 162 + ) 163 + 164 + input_size = input_path.stat().st_size 165 + start = time.perf_counter() 166 + 167 + result_bytes, error = transcode_file(input_path, output_format, url, auth_token) 168 + 169 + duration_ms = (time.perf_counter() - start) * 1000 170 + 171 + if error: 172 + return TestResult( 173 + input_format=input_format, 174 + output_format=output_format, 175 + success=False, 176 + duration_ms=duration_ms, 177 + input_size=input_size, 178 + output_size=0, 179 + error=error, 180 + ) 181 + 182 + # write output and verify 183 + output_path = ( 184 + samples_dir / f"output_{input_format}_to_{output_format}.{output_format}" 185 + ) 186 + output_path.write_bytes(result_bytes) 187 + output_size = len(result_bytes) 188 + 189 + if not verify_audio(output_path): 190 + return TestResult( 191 + input_format=input_format, 192 + output_format=output_format, 193 + success=False, 194 + duration_ms=duration_ms, 195 + input_size=input_size, 196 + output_size=output_size, 197 + error="output validation failed", 198 + ) 199 + 200 + return TestResult( 201 + input_format=input_format, 202 + output_format=output_format, 203 + success=True, 204 + duration_ms=duration_ms, 205 + input_size=input_size, 206 + output_size=output_size, 207 + ) 208 + 209 + 210 + def print_matrix( 211 + results: list[TestResult], input_formats: list[str], output_formats: list[str] 212 + ): 213 + """print results as a matrix table.""" 214 + # build lookup 215 + lookup = {(r.input_format, r.output_format): r for r in results} 216 + 217 + # header 218 + col_width = 10 219 + header = ( 220 + "input".ljust(col_width) 221 + + " | " 222 + + " | ".join(f.center(col_width) for f in output_formats) 223 + ) 224 + print("\n" + header) 225 + print("-" * len(header)) 226 + 227 + # rows 228 + for inf in input_formats: 229 + row = inf.ljust(col_width) + " | " 230 + cells = [] 231 + for outf in output_formats: 232 + r = lookup.get((inf, outf)) 233 + if r is None: 234 + cells.append("skip".center(col_width)) 235 + elif r.success: 236 + cells.append(f"{r.duration_ms:.0f}ms".center(col_width)) 237 + else: 238 + cells.append("FAIL".center(col_width)) 239 + row += " | ".join(cells) 240 + print(row) 241 + 242 + 243 + def main(): 244 + parser = argparse.ArgumentParser(description="Transcoder test matrix") 245 + parser.add_argument( 246 + "--url", 247 + default="http://127.0.0.1:8082", 248 + help="Transcoder URL (default: http://127.0.0.1:8082)", 249 + ) 250 + parser.add_argument( 251 + "--input-format", 252 + choices=INPUT_FORMATS, 253 + help="Test only this input format", 254 + ) 255 + parser.add_argument( 256 + "--output-format", 257 + choices=OUTPUT_FORMATS, 258 + help="Test only this output format", 259 + ) 260 + parser.add_argument( 261 + "-v", 262 + "--verbose", 263 + action="store_true", 264 + help="Verbose output", 265 + ) 266 + parser.add_argument( 267 + "--keep-files", 268 + action="store_true", 269 + help="Keep generated test files (useful for debugging)", 270 + ) 271 + args = parser.parse_args() 272 + 273 + # get auth token from environment 274 + auth_token = os.environ.get("TRANSCODER_AUTH_TOKEN") 275 + if "fly.dev" in args.url and not auth_token: 276 + print( 277 + "error: TRANSCODER_AUTH_TOKEN required for production URL", file=sys.stderr 278 + ) 279 + sys.exit(1) 280 + 281 + # check transcoder is running 282 + try: 283 + response = httpx.get(f"{args.url}/health", timeout=5.0) 284 + if response.status_code != 200: 285 + print( 286 + f"error: transcoder health check failed: {response.status_code}", 287 + file=sys.stderr, 288 + ) 289 + sys.exit(1) 290 + except Exception as e: 291 + print(f"error: cannot reach transcoder at {args.url}: {e}", file=sys.stderr) 292 + print("hint: start with `just transcoder run`", file=sys.stderr) 293 + sys.exit(1) 294 + 295 + # determine formats to test 296 + input_formats = [args.input_format] if args.input_format else INPUT_FORMATS 297 + output_formats = [args.output_format] if args.output_format else OUTPUT_FORMATS 298 + 299 + # create temp directory for test files 300 + if args.keep_files: 301 + samples_dir = Path("sandbox/transcoder-test") 302 + samples_dir.mkdir(parents=True, exist_ok=True) 303 + else: 304 + temp_dir = tempfile.mkdtemp(prefix="transcoder-test-") 305 + samples_dir = Path(temp_dir) 306 + 307 + print(f"transcoder: {args.url}") 308 + print(f"test files: {samples_dir}") 309 + print(f"matrix: {len(input_formats)} inputs x {len(output_formats)} outputs") 310 + print() 311 + 312 + # generate input samples 313 + print("generating test samples...") 314 + for fmt in input_formats: 315 + sample_path = samples_dir / f"test.{fmt}" 316 + if args.verbose: 317 + print(f" {fmt}...", end=" ", flush=True) 318 + if generate_sample(sample_path, fmt): 319 + if args.verbose: 320 + print(f"{sample_path.stat().st_size} bytes") 321 + else: 322 + print(f"failed to generate {fmt} sample", file=sys.stderr) 323 + sys.exit(1) 324 + 325 + # run test matrix 326 + print("\nrunning transcoding tests...") 327 + results: list[TestResult] = [] 328 + 329 + for inf in input_formats: 330 + for outf in output_formats: 331 + if args.verbose: 332 + print(f" {inf} -> {outf}...", end=" ", flush=True) 333 + 334 + result = run_test( 335 + inf, outf, samples_dir, args.url, auth_token, args.verbose 336 + ) 337 + results.append(result) 338 + 339 + if args.verbose: 340 + if result.success: 341 + print( 342 + f"{result.duration_ms:.0f}ms ({result.input_size} -> {result.output_size} bytes)" 343 + ) 344 + else: 345 + print(f"FAILED: {result.error}") 346 + 347 + # print matrix summary 348 + print_matrix(results, input_formats, output_formats) 349 + 350 + # print failures 351 + failures = [r for r in results if not r.success] 352 + if failures: 353 + print(f"\n{len(failures)} failures:") 354 + for r in failures: 355 + print(f" {r.input_format} -> {r.output_format}: {r.error}") 356 + 357 + # cleanup 358 + if not args.keep_files: 359 + shutil.rmtree(samples_dir) 360 + 361 + # exit code 362 + sys.exit(0 if not failures else 1) 363 + 364 + 365 + if __name__ == "__main__": 366 + main()