feat: supporter-gated content with atprotofans validation (#637)

* feat: add supporter-gated content infrastructure

backend support for atprotofans content gating:

- create private R2 buckets (audio-private-dev/staging/prod)
- add R2_PRIVATE_BUCKET and PRESIGNED_URL_EXPIRY_SECONDS settings
- implement save_gated() and generate_presigned_url() in R2Storage
- add supportGate field to fm.plyr.track lexicon
- add support_gate JSONB column to tracks table
- add atprotofans validation helper (_internal/atprotofans.py)
- update audio endpoint to check supporter status for gated tracks
- 401 if not authenticated
- 402 if not a supporter
- presigned URL redirect if valid supporter

the supportGate object starts with type: "any" (any support unlocks),
with room to grow for tiers (recurring, minimum amounts, etc.)

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* feat: add support_gate parameter to upload flow

enable artists to upload supporter-gated tracks:

- add support_gate form parameter to upload endpoint
- validate support_gate JSON structure (must have type: "any")
- require atprotofans to be enabled in settings to use gating
- use save_gated() for gated tracks (private R2 bucket)
- store support_gate in Track model and ATProto record
- gated tracks use API endpoint URL instead of direct R2 URL

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* feat(frontend): add UI for supporter-gated tracks

- TrackItem: show heart badge overlay on gated tracks with dimmed image
- Player: detect 401/402 on gated content, show toast with supporter CTA
- Upload: add "supporters only" toggle when artist has atprotofans enabled
- Types: add SupportGate interface and support_gate field to Track

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: complete supporter-gated content implementation

- fix presigned URL generation with SigV4 signature (was returning 401)
- add playback helper to check gated access BEFORE modifying queue state
(clicking locked track no longer interrupts current playback)
- add support_gate toggle to track edit UI in portal
- centralize atprotofans support URL generation in config.ts
- add 7 regression tests for gated content access control

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: migrate audio to private bucket when enabling support_gate

when enabling support_gate on an existing track, the audio file must be
migrated from the public bucket to the private bucket. otherwise the
original public r2_url remains accessible, bypassing the paywall.

- add R2Storage.migrate_to_private_bucket() - copies file then deletes original
- add migrate_track_to_private_bucket background task
- schedule migration in PATCH endpoint when support_gate is enabled on
a track that has an r2_url (indicating it's in the public bucket)

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* refactor: unify audio bucket migration to single move_audio method

- R2Storage.move_audio(file_id, extension, to_private) handles both directions
- move_track_audio background task replaces separate migrate_to_private/public
- PATCH endpoint schedules move when toggling support_gate in either direction:
- enabling gate on public track โ†’ move to private
- disabling gate on private track โ†’ move to public

๐Ÿค– Generated with [Claude Code](https://claude.com/claude-code)

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

* fix: sync ATProto record when toggling support_gate

- include support_gate changes in metadata_changed check
- pass support_gate to build_track_record
- use backend API URL for gated tracks (r2_url is None)
- fix upload page: link to /portal not /settings for atprotofans setup

๐Ÿค– 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 zzstoatzz.io Claude Opus 4.5 and committed by GitHub 99c02c16 17247f59

Changed files
+1627 -132
backend
frontend
lexicons
+1
backend/.env.example
··· 32 32 R2_ENDPOINT_URL=https://8feb33b5fb57ce2bc093bc6f4141f40a.r2.cloudflarestorage.com 33 33 R2_PUBLIC_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev 34 34 R2_PUBLIC_IMAGE_BUCKET_URL=https://pub-154b70b3121149eda0cf1ccbae78cb33.r2.dev 35 + R2_PRIVATE_BUCKET=audio-private-dev # private bucket for supporter-gated audio 35 36 MAX_UPLOAD_SIZE_MB=1536 # max audio upload size (default: 1536MB / 1.5GB - supports 2-hour WAV) 36 37 37 38 # atproto
+37
backend/alembic/versions/2025_12_22_190115_9ee155c078ed_add_support_gate_to_tracks.py
··· 1 + """add support_gate to tracks 2 + 3 + Revision ID: 9ee155c078ed 4 + Revises: f2380236c97b 5 + Create Date: 2025-12-22 19:01:15.063270 6 + 7 + """ 8 + 9 + from collections.abc import Sequence 10 + 11 + import sqlalchemy as sa 12 + from sqlalchemy.dialects import postgresql 13 + 14 + from alembic import op 15 + 16 + # revision identifiers, used by Alembic. 17 + revision: str = "9ee155c078ed" 18 + down_revision: str | Sequence[str] | None = "f2380236c97b" 19 + branch_labels: str | Sequence[str] | None = None 20 + depends_on: str | Sequence[str] | None = None 21 + 22 + 23 + def upgrade() -> None: 24 + """Add support_gate column for supporter-gated content.""" 25 + op.add_column( 26 + "tracks", 27 + sa.Column( 28 + "support_gate", 29 + postgresql.JSONB(astext_type=sa.Text()), 30 + nullable=True, 31 + ), 32 + ) 33 + 34 + 35 + def downgrade() -> None: 36 + """Remove support_gate column.""" 37 + op.drop_column("tracks", "support_gate")
+2
backend/src/backend/_internal/__init__.py
··· 32 32 from backend._internal.notifications import notification_service 33 33 from backend._internal.now_playing import now_playing_service 34 34 from backend._internal.queue import queue_service 35 + from backend._internal.atprotofans import validate_supporter 35 36 36 37 __all__ = [ 37 38 "DeveloperToken", ··· 64 65 "start_oauth_flow", 65 66 "start_oauth_flow_with_scopes", 66 67 "update_session_tokens", 68 + "validate_supporter", 67 69 ]
+9 -2
backend/src/backend/_internal/atproto/records/fm_plyr/track.py
··· 20 20 duration: int | None = None, 21 21 features: list[dict] | None = None, 22 22 image_url: str | None = None, 23 + support_gate: dict | None = None, 23 24 ) -> dict[str, Any]: 24 25 """Build a track record dict for ATProto. 25 26 26 27 args: 27 28 title: track title 28 29 artist: artist name 29 - audio_url: R2 URL for audio file 30 + audio_url: R2 URL for audio file (placeholder for gated tracks) 30 31 file_type: file extension (mp3, wav, etc) 31 32 album: optional album name 32 33 duration: optional duration in seconds 33 34 features: optional list of featured artists [{did, handle, display_name, avatar_url}] 34 35 image_url: optional cover art image URL 36 + support_gate: optional gating config (e.g., {"type": "any"}) 35 37 36 38 returns: 37 39 record dict ready for ATProto ··· 64 66 # validate image URL comes from allowed origin 65 67 settings.storage.validate_image_url(image_url) 66 68 record["imageUrl"] = image_url 69 + if support_gate: 70 + record["supportGate"] = support_gate 67 71 68 72 return record 69 73 ··· 78 82 duration: int | None = None, 79 83 features: list[dict] | None = None, 80 84 image_url: str | None = None, 85 + support_gate: dict | None = None, 81 86 ) -> tuple[str, str]: 82 87 """Create a track record on the user's PDS using the configured collection. 83 88 ··· 85 90 auth_session: authenticated user session 86 91 title: track title 87 92 artist: artist name 88 - audio_url: R2 URL for audio file 93 + audio_url: R2 URL for audio file (placeholder URL for gated tracks) 89 94 file_type: file extension (mp3, wav, etc) 90 95 album: optional album name 91 96 duration: optional duration in seconds 92 97 features: optional list of featured artists [{did, handle, display_name, avatar_url}] 93 98 image_url: optional cover art image URL 99 + support_gate: optional gating config (e.g., {"type": "any"}) 94 100 95 101 returns: 96 102 tuple of (record_uri, record_cid) ··· 108 114 duration=duration, 109 115 features=features, 110 116 image_url=image_url, 117 + support_gate=support_gate, 111 118 ) 112 119 113 120 payload = {
+93
backend/src/backend/_internal/atprotofans.py
··· 1 + """atprotofans integration for supporter validation. 2 + 3 + atprotofans is a creator support platform on ATProto. this module provides 4 + server-side validation of supporter relationships for content gating. 5 + 6 + the validation uses the three-party model: 7 + - supporter: has com.atprotofans.supporter record in their PDS 8 + - creator: has com.atprotofans.supporterProof record in their PDS 9 + - broker: has com.atprotofans.brokerProof record (atprotofans service) 10 + 11 + for direct atprotofans contributions (not via platform registration), 12 + the signer is the artist's own DID. 13 + 14 + see: https://atprotofans.leaflet.pub/3mabsmts3rs2b 15 + """ 16 + 17 + import httpx 18 + import logfire 19 + from pydantic import BaseModel 20 + 21 + 22 + class SupporterValidation(BaseModel): 23 + """result of validating supporter status.""" 24 + 25 + valid: bool 26 + profile: dict | None = None 27 + 28 + 29 + async def validate_supporter( 30 + supporter_did: str, 31 + artist_did: str, 32 + timeout: float = 5.0, 33 + ) -> SupporterValidation: 34 + """validate if a user supports an artist via atprotofans. 35 + 36 + for direct atprotofans contributions, the signer is the artist's DID. 37 + 38 + args: 39 + supporter_did: DID of the potential supporter 40 + artist_did: DID of the artist (also used as signer) 41 + timeout: request timeout in seconds 42 + 43 + returns: 44 + SupporterValidation with valid=True if supporter, valid=False otherwise 45 + """ 46 + url = "https://atprotofans.com/xrpc/com.atprotofans.validateSupporter" 47 + params = { 48 + "supporter": supporter_did, 49 + "subject": artist_did, 50 + "signer": artist_did, # for direct contributions, signer = artist 51 + } 52 + 53 + with logfire.span( 54 + "atprotofans.validate_supporter", 55 + supporter_did=supporter_did, 56 + artist_did=artist_did, 57 + ): 58 + try: 59 + async with httpx.AsyncClient(timeout=timeout) as client: 60 + response = await client.get(url, params=params) 61 + 62 + if response.status_code != 200: 63 + logfire.warn( 64 + "atprotofans validation failed", 65 + status_code=response.status_code, 66 + response_text=response.text[:200], 67 + ) 68 + return SupporterValidation(valid=False) 69 + 70 + data = response.json() 71 + is_valid = data.get("valid", False) 72 + 73 + logfire.info( 74 + "atprotofans validation result", 75 + valid=is_valid, 76 + has_profile=data.get("profile") is not None, 77 + ) 78 + 79 + return SupporterValidation( 80 + valid=is_valid, 81 + profile=data.get("profile"), 82 + ) 83 + 84 + except httpx.TimeoutException: 85 + logfire.warn("atprotofans validation timeout") 86 + return SupporterValidation(valid=False) 87 + except Exception as e: 88 + logfire.error( 89 + "atprotofans validation error", 90 + error=str(e), 91 + exc_info=True, 92 + ) 93 + return SupporterValidation(valid=False)
+57
backend/src/backend/_internal/background_tasks.py
··· 865 865 logfire.info("scheduled pds comment update", comment_id=comment_id) 866 866 867 867 868 + async def move_track_audio(track_id: int, to_private: bool) -> None: 869 + """move a track's audio file between public and private buckets. 870 + 871 + called when support_gate is toggled on an existing track. 872 + 873 + args: 874 + track_id: database ID of the track 875 + to_private: if True, move to private bucket; if False, move to public 876 + """ 877 + from backend.models import Track 878 + from backend.storage import storage 879 + 880 + async with db_session() as db: 881 + result = await db.execute(select(Track).where(Track.id == track_id)) 882 + track = result.scalar_one_or_none() 883 + 884 + if not track: 885 + logger.warning(f"move_track_audio: track {track_id} not found") 886 + return 887 + 888 + if not track.file_id or not track.file_type: 889 + logger.warning( 890 + f"move_track_audio: track {track_id} missing file_id/file_type" 891 + ) 892 + return 893 + 894 + result_url = await storage.move_audio( 895 + file_id=track.file_id, 896 + extension=track.file_type, 897 + to_private=to_private, 898 + ) 899 + 900 + # update r2_url: None for private, public URL for public 901 + if to_private: 902 + # moved to private - result_url is None on success, None on failure 903 + # we check by verifying the file was actually moved (no error logged) 904 + track.r2_url = None 905 + await db.commit() 906 + logger.info(f"moved track {track_id} to private bucket") 907 + elif result_url: 908 + # moved to public - result_url is the public URL 909 + track.r2_url = result_url 910 + await db.commit() 911 + logger.info(f"moved track {track_id} to public bucket") 912 + else: 913 + logger.error(f"failed to move track {track_id}") 914 + 915 + 916 + async def schedule_move_track_audio(track_id: int, to_private: bool) -> None: 917 + """schedule a track audio move via docket.""" 918 + docket = get_docket() 919 + await docket.add(move_track_audio)(track_id, to_private) 920 + direction = "private" if to_private else "public" 921 + logfire.info(f"scheduled track audio move to {direction}", track_id=track_id) 922 + 923 + 868 924 # collection of all background task functions for docket registration 869 925 background_tasks = [ 870 926 scan_copyright, ··· 878 934 pds_create_comment, 879 935 pds_delete_comment, 880 936 pds_update_comment, 937 + move_track_audio, 881 938 ]
+127 -19
backend/src/backend/api/audio.py
··· 1 1 """audio streaming endpoint.""" 2 2 3 3 import logfire 4 - from fastapi import APIRouter, Depends, HTTPException 4 + from fastapi import APIRouter, Depends, HTTPException, Request, Response 5 5 from fastapi.responses import RedirectResponse 6 6 from pydantic import BaseModel 7 7 from sqlalchemy import func, select 8 8 9 - from backend._internal import Session, require_auth 9 + from backend._internal import Session, get_optional_session, validate_supporter 10 10 from backend.models import Track 11 11 from backend.storage import storage 12 12 from backend.utilities.database import db_session ··· 22 22 file_type: str | None 23 23 24 24 25 + @router.head("/{file_id}") 25 26 @router.get("/{file_id}") 26 - async def stream_audio(file_id: str): 27 + async def stream_audio( 28 + file_id: str, 29 + request: Request, 30 + session: Session | None = Depends(get_optional_session), 31 + ): 27 32 """stream audio file by redirecting to R2 CDN URL. 28 33 29 - looks up track to get cached r2_url and file extension, 30 - eliminating the need to probe multiple formats. 34 + for public tracks: redirects to R2 CDN URL. 35 + for gated tracks: validates supporter status and returns presigned URL. 36 + 37 + HEAD requests are used for pre-flight auth checks - they return 38 + 200/401/402 status without redirecting to avoid CORS issues. 31 39 32 40 images are served directly via R2 URLs stored in the image_url field, 33 41 not through this endpoint. 34 42 """ 35 - # look up track to get r2_url and file_type 43 + is_head_request = request.method == "HEAD" 44 + # look up track to get r2_url, file_type, support_gate, and artist_did 36 45 async with db_session() as db: 37 46 # check for duplicates (multiple tracks with same file_id) 38 47 count_result = await db.execute( ··· 50 59 count=count, 51 60 ) 52 61 53 - # get the best track: prefer non-null r2_url, then newest 62 + # get the track with gating info 54 63 result = await db.execute( 55 - select(Track.r2_url, Track.file_type) 64 + select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did) 56 65 .where(Track.file_id == file_id) 57 66 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) 58 67 .limit(1) 59 68 ) 60 69 track_data = result.first() 70 + r2_url, file_type, support_gate, artist_did = track_data 61 71 62 - r2_url, file_type = track_data 72 + # check if track is gated 73 + if support_gate is not None: 74 + return await _handle_gated_audio( 75 + file_id=file_id, 76 + file_type=file_type, 77 + artist_did=artist_did, 78 + session=session, 79 + is_head_request=is_head_request, 80 + ) 63 81 64 - # if we have a valid r2_url cached, use it directly (zero HEADs) 82 + # public track - use cached r2_url if available 65 83 if r2_url and r2_url.startswith("http"): 66 84 return RedirectResponse(url=r2_url) 67 85 ··· 72 90 return RedirectResponse(url=url) 73 91 74 92 93 + async def _handle_gated_audio( 94 + file_id: str, 95 + file_type: str, 96 + artist_did: str, 97 + session: Session | None, 98 + is_head_request: bool = False, 99 + ) -> RedirectResponse | Response: 100 + """handle streaming for supporter-gated content. 101 + 102 + validates that the user is authenticated and either: 103 + - is the artist who uploaded the track, OR 104 + - supports the artist via atprotofans 105 + before returning a presigned URL for the private bucket. 106 + 107 + for HEAD requests (used for pre-flight auth checks), returns 200 status 108 + without redirecting to avoid CORS issues with cross-origin redirects. 109 + """ 110 + # must be authenticated to access gated content 111 + if not session: 112 + raise HTTPException( 113 + status_code=401, 114 + detail="authentication required for supporter-gated content", 115 + ) 116 + 117 + # artist can always play their own gated tracks 118 + if session.did == artist_did: 119 + logfire.info( 120 + "serving gated content to owner", 121 + file_id=file_id, 122 + artist_did=artist_did, 123 + ) 124 + else: 125 + # validate supporter status via atprotofans 126 + validation = await validate_supporter( 127 + supporter_did=session.did, 128 + artist_did=artist_did, 129 + ) 130 + 131 + if not validation.valid: 132 + raise HTTPException( 133 + status_code=402, 134 + detail="this track requires supporter access", 135 + headers={"X-Support-Required": "true"}, 136 + ) 137 + 138 + # for HEAD requests, just return 200 to confirm access 139 + # (avoids CORS issues with cross-origin redirects) 140 + if is_head_request: 141 + return Response(status_code=200) 142 + 143 + # authorized - generate presigned URL for private bucket 144 + if session.did != artist_did: 145 + logfire.info( 146 + "serving gated content to supporter", 147 + file_id=file_id, 148 + supporter_did=session.did, 149 + artist_did=artist_did, 150 + ) 151 + 152 + url = await storage.generate_presigned_url(file_id=file_id, extension=file_type) 153 + return RedirectResponse(url=url) 154 + 155 + 75 156 @router.get("/{file_id}/url") 76 157 async def get_audio_url( 77 158 file_id: str, 78 - session: Session = Depends(require_auth), 159 + session: Session | None = Depends(get_optional_session), 79 160 ) -> AudioUrlResponse: 80 - """return direct R2 URL for offline caching. 161 + """return direct URL for audio file. 81 162 82 - unlike the streaming endpoint which returns a 307 redirect, 83 - this returns the URL as JSON so the frontend can fetch and 84 - cache the audio directly via the Cache API. 163 + for public tracks: returns R2 CDN URL for offline caching. 164 + for gated tracks: returns presigned URL after supporter validation. 85 165 86 - used for offline mode - frontend fetches from R2 and stores locally. 166 + used for offline mode - frontend fetches and caches locally. 87 167 """ 88 168 async with db_session() as db: 89 169 result = await db.execute( 90 - select(Track.r2_url, Track.file_type) 170 + select(Track.r2_url, Track.file_type, Track.support_gate, Track.artist_did) 91 171 .where(Track.file_id == file_id) 92 172 .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) 93 173 .limit(1) ··· 97 177 if not track_data: 98 178 raise HTTPException(status_code=404, detail="audio file not found") 99 179 100 - r2_url, file_type = track_data 180 + r2_url, file_type, support_gate, artist_did = track_data 101 181 102 - # if we have a cached r2_url, return it 182 + # check if track is gated 183 + if support_gate is not None: 184 + # must be authenticated 185 + if not session: 186 + raise HTTPException( 187 + status_code=401, 188 + detail="authentication required for supporter-gated content", 189 + ) 190 + 191 + # artist can always access their own gated tracks 192 + if session.did != artist_did: 193 + # validate supporter status 194 + validation = await validate_supporter( 195 + supporter_did=session.did, 196 + artist_did=artist_did, 197 + ) 198 + 199 + if not validation.valid: 200 + raise HTTPException( 201 + status_code=402, 202 + detail="this track requires supporter access", 203 + headers={"X-Support-Required": "true"}, 204 + ) 205 + 206 + # return presigned URL 207 + url = await storage.generate_presigned_url(file_id=file_id, extension=file_type) 208 + return AudioUrlResponse(url=url, file_id=file_id, file_type=file_type) 209 + 210 + # public track - return cached r2_url if available 103 211 if r2_url and r2_url.startswith("http"): 104 212 return AudioUrlResponse(url=r2_url, file_id=file_id, file_type=file_type) 105 213
+64 -4
backend/src/backend/api/tracks/mutations.py
··· 1 1 """Track mutation endpoints (delete/update/restore).""" 2 2 3 3 import contextlib 4 + import json 4 5 import logging 5 6 from datetime import UTC, datetime 6 7 from typing import Annotated 8 + from urllib.parse import urljoin 7 9 8 10 import logfire 9 11 from fastapi import Depends, File, Form, HTTPException, UploadFile ··· 23 25 update_record, 24 26 ) 25 27 from backend._internal.atproto.tid import datetime_to_tid 26 - from backend._internal.background_tasks import schedule_album_list_sync 28 + from backend._internal.background_tasks import ( 29 + schedule_album_list_sync, 30 + schedule_move_track_audio, 31 + ) 27 32 from backend.config import settings 28 33 from backend.models import Artist, Tag, Track, TrackTag, get_db 29 34 from backend.schemas import TrackResponse ··· 170 175 album: Annotated[str | None, Form()] = None, 171 176 features: Annotated[str | None, Form()] = None, 172 177 tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 178 + support_gate: Annotated[ 179 + str | None, 180 + Form(description="JSON object for supporter gating, or 'null' to remove"), 181 + ] = None, 173 182 image: UploadFile | None = File(None), 174 183 ) -> TrackResponse: 175 184 """Update track metadata (only by owner).""" ··· 196 205 track.title = title 197 206 title_changed = True 198 207 208 + # handle support_gate update 209 + # track migration direction: None = no move, True = to private, False = to public 210 + move_to_private: bool | None = None 211 + if support_gate is not None: 212 + was_gated = track.support_gate is not None 213 + if support_gate.lower() == "null" or support_gate == "": 214 + # removing gating - need to move file back to public if it was gated 215 + if was_gated and track.r2_url is None: 216 + move_to_private = False 217 + track.support_gate = None 218 + else: 219 + try: 220 + parsed_gate = json.loads(support_gate) 221 + if not isinstance(parsed_gate, dict): 222 + raise ValueError("support_gate must be a JSON object") 223 + if "type" not in parsed_gate: 224 + raise ValueError("support_gate must have a 'type' field") 225 + if parsed_gate["type"] not in ("any",): 226 + raise ValueError( 227 + f"unsupported support_gate type: {parsed_gate['type']}" 228 + ) 229 + # enabling gating - need to move file to private if it was public 230 + if not was_gated and track.r2_url is not None: 231 + move_to_private = True 232 + track.support_gate = parsed_gate 233 + except json.JSONDecodeError as e: 234 + raise HTTPException( 235 + status_code=400, detail=f"invalid support_gate JSON: {e}" 236 + ) from e 237 + except ValueError as e: 238 + raise HTTPException(status_code=400, detail=str(e)) from e 239 + 199 240 # track album changes for list sync 200 241 old_album_id = track.album_id 201 242 await apply_album_update(db, track, album) ··· 252 293 updated_tags.add(tag_name) 253 294 254 295 # always update ATProto record if any metadata changed 296 + support_gate_changed = move_to_private is not None 255 297 metadata_changed = ( 256 - title_changed or album is not None or features is not None or image_changed 298 + title_changed 299 + or album is not None 300 + or features is not None 301 + or image_changed 302 + or support_gate_changed 257 303 ) 258 304 if track.atproto_record_uri and metadata_changed: 259 305 try: ··· 281 327 if new_album_id: 282 328 await schedule_album_list_sync(auth_session.session_id, new_album_id) 283 329 330 + # move audio file between buckets if support_gate was toggled 331 + if move_to_private is not None: 332 + await schedule_move_track_audio(track.id, to_private=move_to_private) 333 + 284 334 # build track_tags dict for response 285 335 # if tags were updated, use updated_tags; otherwise query for existing 286 336 if tags is not None: ··· 304 354 Exception: if ATProto record update fails 305 355 """ 306 356 record_uri = track.atproto_record_uri 307 - audio_url = track.r2_url 308 - if not record_uri or not audio_url: 357 + if not record_uri: 309 358 return 310 359 360 + # for gated tracks, use the API endpoint URL instead of r2_url 361 + # (r2_url is None for private bucket tracks) 362 + if track.support_gate is not None: 363 + backend_url = settings.atproto.redirect_uri.rsplit("/", 2)[0] 364 + audio_url = urljoin(backend_url + "/", f"audio/{track.file_id}") 365 + else: 366 + audio_url = track.r2_url 367 + if not audio_url: 368 + return 369 + 311 370 updated_record = build_track_record( 312 371 title=track.title, 313 372 artist=track.artist.display_name, ··· 317 376 duration=track.duration, 318 377 features=track.features if track.features else None, 319 378 image_url=image_url_override or await track.get_image_url(), 379 + support_gate=track.support_gate, 320 380 ) 321 381 322 382 result = await update_record(
+107 -23
backend/src/backend/api/tracks/uploads.py
··· 37 37 from backend._internal.image import ImageFormat 38 38 from backend._internal.jobs import job_service 39 39 from backend.config import settings 40 - from backend.models import Artist, Tag, Track, TrackTag 40 + from backend.models import Artist, Tag, Track, TrackTag, UserPreferences 41 41 from backend.models.job import JobStatus, JobType 42 42 from backend.storage import storage 43 43 from backend.utilities.audio import extract_duration ··· 75 75 image_path: str | None = None 76 76 image_filename: str | None = None 77 77 image_content_type: str | None = None 78 + 79 + # supporter-gated content (e.g., {"type": "any"}) 80 + support_gate: dict | None = None 78 81 79 82 80 83 async def _get_or_create_tag( ··· 119 122 upload_id: str, 120 123 file_path: str, 121 124 filename: str, 125 + *, 126 + gated: bool = False, 122 127 ) -> str | None: 123 - """save audio file to storage, returning file_id or None on failure.""" 128 + """save audio file to storage, returning file_id or None on failure. 129 + 130 + args: 131 + upload_id: job tracking ID 132 + file_path: path to temp file 133 + filename: original filename 134 + gated: if True, save to private bucket (no public URL) 135 + """ 136 + message = "uploading to private storage..." if gated else "uploading to storage..." 124 137 await job_service.update_progress( 125 138 upload_id, 126 139 JobStatus.PROCESSING, 127 - "uploading to storage...", 140 + message, 128 141 phase="upload", 129 142 progress_pct=0.0, 130 143 ) 131 144 try: 132 145 async with R2ProgressTracker( 133 146 job_id=upload_id, 134 - message="uploading to storage...", 147 + message=message, 135 148 phase="upload", 136 149 ) as tracker: 137 150 with open(file_path, "rb") as file_obj: 138 - file_id = await storage.save( 139 - file_obj, filename, progress_callback=tracker.on_progress 140 - ) 151 + if gated: 152 + file_id = await storage.save_gated( 153 + file_obj, filename, progress_callback=tracker.on_progress 154 + ) 155 + else: 156 + file_id = await storage.save( 157 + file_obj, filename, progress_callback=tracker.on_progress 158 + ) 141 159 142 160 await job_service.update_progress( 143 161 upload_id, 144 162 JobStatus.PROCESSING, 145 - "uploading to storage...", 163 + message, 146 164 phase="upload", 147 165 progress_pct=100.0, 148 166 ) 149 - logfire.info("storage.save completed", file_id=file_id) 167 + logfire.info("storage.save completed", file_id=file_id, gated=gated) 150 168 return file_id 151 169 152 170 except Exception as e: ··· 255 273 with open(ctx.file_path, "rb") as f: 256 274 duration = extract_duration(f) 257 275 258 - # save audio to storage 276 + # validate gating requirements if support_gate is set 277 + is_gated = ctx.support_gate is not None 278 + if is_gated: 279 + async with db_session() as db: 280 + prefs_result = await db.execute( 281 + select(UserPreferences).where( 282 + UserPreferences.did == ctx.artist_did 283 + ) 284 + ) 285 + prefs = prefs_result.scalar_one_or_none() 286 + if not prefs or prefs.support_url != "atprotofans": 287 + await job_service.update_progress( 288 + ctx.upload_id, 289 + JobStatus.FAILED, 290 + "upload failed", 291 + error="supporter gating requires atprotofans to be enabled in settings", 292 + ) 293 + return 294 + 295 + # save audio to storage (private bucket if gated) 259 296 file_id = await _save_audio_to_storage( 260 - ctx.upload_id, ctx.file_path, ctx.filename 297 + ctx.upload_id, ctx.file_path, ctx.filename, gated=is_gated 261 298 ) 262 299 if not file_id: 263 300 return ··· 279 316 ) 280 317 return 281 318 282 - # get R2 URL 283 - r2_url = await storage.get_url( 284 - file_id, file_type="audio", extension=ext[1:] 285 - ) 286 - if not r2_url: 287 - await job_service.update_progress( 288 - ctx.upload_id, 289 - JobStatus.FAILED, 290 - "upload failed", 291 - error="failed to get public audio URL", 319 + # get R2 URL (only for public tracks - gated tracks have no public URL) 320 + r2_url: str | None = None 321 + if not is_gated: 322 + r2_url = await storage.get_url( 323 + file_id, file_type="audio", extension=ext[1:] 292 324 ) 293 - return 325 + if not r2_url: 326 + await job_service.update_progress( 327 + ctx.upload_id, 328 + JobStatus.FAILED, 329 + "upload failed", 330 + error="failed to get public audio URL", 331 + ) 332 + return 294 333 295 334 # save image if provided 296 335 image_url = None ··· 338 377 phase="atproto", 339 378 ) 340 379 try: 380 + # for gated tracks, use API endpoint URL instead of direct R2 URL 381 + # this ensures playback goes through our auth check 382 + if is_gated: 383 + # use backend URL for gated audio 384 + from urllib.parse import urljoin 385 + 386 + backend_url = settings.atproto.redirect_uri.rsplit("/", 2)[0] 387 + audio_url_for_record = urljoin( 388 + backend_url + "/", f"audio/{file_id}" 389 + ) 390 + else: 391 + # r2_url is guaranteed non-None here - we returned early above if None 392 + assert r2_url is not None 393 + audio_url_for_record = r2_url 394 + 341 395 atproto_result = await create_track_record( 342 396 auth_session=ctx.auth_session, 343 397 title=ctx.title, 344 398 artist=artist.display_name, 345 - audio_url=r2_url, 399 + audio_url=audio_url_for_record, 346 400 file_type=ext[1:], 347 401 album=ctx.album, 348 402 duration=duration, 349 403 features=featured_artists or None, 350 404 image_url=image_url, 405 + support_gate=ctx.support_gate, 351 406 ) 352 407 if not atproto_result: 353 408 raise ValueError("PDS returned no record data") ··· 403 458 atproto_record_cid=atproto_cid, 404 459 image_id=image_id, 405 460 image_url=image_url, 461 + support_gate=ctx.support_gate, 406 462 ) 407 463 408 464 db.add(track) ··· 467 523 album: Annotated[str | None, Form()] = None, 468 524 features: Annotated[str | None, Form()] = None, 469 525 tags: Annotated[str | None, Form(description="JSON array of tag names")] = None, 526 + support_gate: Annotated[ 527 + str | None, 528 + Form(description='JSON object for supporter gating, e.g., {"type": "any"}'), 529 + ] = None, 470 530 file: UploadFile = File(...), 471 531 image: UploadFile | None = File(None), 472 532 ) -> dict: ··· 477 537 album: Optional album name/ID to associate with the track. 478 538 features: Optional JSON array of ATProto handles, e.g., 479 539 ["user1.bsky.social", "user2.bsky.social"]. 540 + support_gate: Optional JSON object for supporter gating. 541 + Requires atprotofans to be enabled in settings. 542 + Example: {"type": "any"} - requires any atprotofans support. 480 543 file: Audio file to upload (required). 481 544 image: Optional image file for track artwork. 482 545 background_tasks: FastAPI background-task runner. ··· 491 554 except ValueError as e: 492 555 raise HTTPException(status_code=400, detail=str(e)) from e 493 556 557 + # parse and validate support_gate if provided 558 + parsed_support_gate: dict | None = None 559 + if support_gate: 560 + try: 561 + parsed_support_gate = json.loads(support_gate) 562 + if not isinstance(parsed_support_gate, dict): 563 + raise ValueError("support_gate must be a JSON object") 564 + if "type" not in parsed_support_gate: 565 + raise ValueError("support_gate must have a 'type' field") 566 + if parsed_support_gate["type"] not in ("any",): 567 + raise ValueError( 568 + f"unsupported support_gate type: {parsed_support_gate['type']}" 569 + ) 570 + except json.JSONDecodeError as e: 571 + raise HTTPException( 572 + status_code=400, detail=f"invalid support_gate JSON: {e}" 573 + ) from e 574 + except ValueError as e: 575 + raise HTTPException(status_code=400, detail=str(e)) from e 576 + 494 577 # validate audio file type upfront 495 578 if not file.filename: 496 579 raise HTTPException(status_code=400, detail="no filename provided") ··· 577 660 image_path=image_path, 578 661 image_filename=image_filename, 579 662 image_content_type=image_content_type, 663 + support_gate=parsed_support_gate, 580 664 ) 581 665 background_tasks.add_task(_process_upload_background, ctx) 582 666 except Exception:
+10
backend/src/backend/config.py
··· 256 256 validation_alias="R2_IMAGE_BUCKET", 257 257 description="R2 bucket name for image files", 258 258 ) 259 + r2_private_bucket: str = Field( 260 + default="", 261 + validation_alias="R2_PRIVATE_BUCKET", 262 + description="R2 private bucket for supporter-gated audio (no public URL)", 263 + ) 264 + presigned_url_expiry_seconds: int = Field( 265 + default=3600, 266 + validation_alias="PRESIGNED_URL_EXPIRY_SECONDS", 267 + description="Expiry time in seconds for presigned URLs (default 1 hour)", 268 + ) 259 269 r2_endpoint_url: str = Field( 260 270 default="", 261 271 validation_alias="R2_ENDPOINT_URL",
+10
backend/src/backend/models/track.py
··· 87 87 nullable=False, default=False, server_default="false" 88 88 ) 89 89 90 + # supporter-gated content (e.g., {"type": "any"} requires any atprotofans support) 91 + support_gate: Mapped[dict | None] = mapped_column( 92 + JSONB, nullable=True, default=None 93 + ) 94 + 95 + @property 96 + def is_gated(self) -> bool: 97 + """check if this track requires supporter access.""" 98 + return self.support_gate is not None 99 + 90 100 @property 91 101 def album(self) -> str | None: 92 102 """get album name from extra (for ATProto compatibility)."""
+4
backend/src/backend/schemas.py
··· 50 50 title: str 51 51 artist: str 52 52 artist_handle: str 53 + artist_did: str 53 54 artist_avatar_url: str | None 54 55 file_id: str 55 56 file_type: str ··· 70 71 None # None = not scanned, False = clear, True = flagged 71 72 ) 72 73 copyright_match: str | None = None # "Title by Artist" of primary match 74 + support_gate: dict[str, Any] | None = None # supporter gating config 73 75 74 76 @classmethod 75 77 async def from_track( ··· 140 142 title=track.title, 141 143 artist=track.artist.display_name, 142 144 artist_handle=track.artist.handle, 145 + artist_did=track.artist_did, 143 146 artist_avatar_url=track.artist.avatar_url, 144 147 file_id=track.file_id, 145 148 file_type=track.file_type, ··· 158 161 tags=tags, 159 162 copyright_flagged=copyright_flagged, 160 163 copyright_match=copyright_match, 164 + support_gate=track.support_gate, 161 165 )
+219
backend/src/backend/storage/r2.py
··· 95 95 96 96 self.audio_bucket_name = settings.storage.r2_bucket 97 97 self.image_bucket_name = settings.storage.r2_image_bucket 98 + self.private_audio_bucket_name = settings.storage.r2_private_bucket 98 99 self.public_audio_bucket_url = settings.storage.r2_public_bucket_url 99 100 self.public_image_bucket_url = settings.storage.r2_public_image_bucket_url 101 + self.presigned_url_expiry = settings.storage.presigned_url_expiry_seconds 100 102 101 103 # sync client for upload (used in background tasks) 102 104 self.client = boto3.client( ··· 439 441 image_bucket=self.image_bucket_name, 440 442 ) 441 443 return False 444 + 445 + async def save_gated( 446 + self, 447 + file: BinaryIO, 448 + filename: str, 449 + progress_callback: Callable[[float], None] | None = None, 450 + ) -> str: 451 + """save supporter-gated audio file to private R2 bucket. 452 + 453 + same as save() but uses the private bucket with no public URL. 454 + files in this bucket are only accessible via presigned URLs. 455 + 456 + args: 457 + file: file-like object to upload 458 + filename: original filename (used to determine media type) 459 + progress_callback: optional callback for upload progress 460 + """ 461 + if not self.private_audio_bucket_name: 462 + raise ValueError("R2_PRIVATE_BUCKET not configured") 463 + 464 + with logfire.span("R2 save_gated", filename=filename): 465 + # compute hash in chunks (constant memory) 466 + file_id = hash_file_chunked(file)[:16] 467 + logfire.info("computed file hash for gated content", file_id=file_id) 468 + 469 + # determine file extension - only audio supported for gated content 470 + ext = Path(filename).suffix.lower() 471 + audio_format = AudioFormat.from_extension(ext) 472 + if not audio_format: 473 + raise ValueError( 474 + f"unsupported audio type for gated content: {ext}. " 475 + f"supported: {AudioFormat.supported_extensions_str()}" 476 + ) 477 + 478 + key = f"audio/{file_id}{ext}" 479 + media_type = audio_format.media_type 480 + 481 + # get file size for progress tracking 482 + file_size = file.seek(0, 2) 483 + file.seek(0) 484 + 485 + logfire.info( 486 + "uploading gated content to private R2", 487 + bucket=self.private_audio_bucket_name, 488 + key=key, 489 + media_type=media_type, 490 + file_size=file_size, 491 + ) 492 + 493 + try: 494 + async with self.async_session.client( 495 + "s3", 496 + endpoint_url=self.endpoint_url, 497 + aws_access_key_id=self.aws_access_key_id, 498 + aws_secret_access_key=self.aws_secret_access_key, 499 + ) as client: 500 + upload_kwargs = { 501 + "Fileobj": file, 502 + "Bucket": self.private_audio_bucket_name, 503 + "Key": key, 504 + "ExtraArgs": {"ContentType": media_type}, 505 + } 506 + 507 + if progress_callback and file_size > 0: 508 + tracker = UploadProgressTracker(file_size, progress_callback) 509 + upload_kwargs["Callback"] = tracker 510 + 511 + await client.upload_fileobj(**upload_kwargs) 512 + except Exception as e: 513 + logfire.error( 514 + "R2 gated upload failed", 515 + error=str(e), 516 + bucket=self.private_audio_bucket_name, 517 + key=key, 518 + exc_info=True, 519 + ) 520 + raise 521 + 522 + logfire.info("R2 gated upload complete", file_id=file_id, key=key) 523 + return file_id 524 + 525 + async def generate_presigned_url( 526 + self, 527 + file_id: str, 528 + extension: str, 529 + expires_in: int | None = None, 530 + ) -> str: 531 + """generate a presigned URL for accessing gated content. 532 + 533 + presigned URLs allow time-limited access to private bucket objects 534 + without exposing credentials. the URL includes a signature that 535 + expires after the specified duration. 536 + 537 + args: 538 + file_id: the file identifier hash 539 + extension: file extension (e.g., "mp3", "flac") 540 + expires_in: optional override for expiry seconds (default from settings) 541 + 542 + returns: 543 + presigned URL string 544 + 545 + raises: 546 + ValueError: if private bucket not configured 547 + """ 548 + if not self.private_audio_bucket_name: 549 + raise ValueError("R2_PRIVATE_BUCKET not configured") 550 + 551 + ext = extension.lstrip(".") 552 + key = f"audio/{file_id}.{ext}" 553 + expiry = expires_in or self.presigned_url_expiry 554 + 555 + with logfire.span( 556 + "R2 generate_presigned_url", 557 + file_id=file_id, 558 + key=key, 559 + expires_in=expiry, 560 + ): 561 + async with self.async_session.client( 562 + "s3", 563 + endpoint_url=self.endpoint_url, 564 + aws_access_key_id=self.aws_access_key_id, 565 + aws_secret_access_key=self.aws_secret_access_key, 566 + config=Config(signature_version="s3v4"), 567 + ) as client: 568 + url = await client.generate_presigned_url( 569 + "get_object", 570 + Params={ 571 + "Bucket": self.private_audio_bucket_name, 572 + "Key": key, 573 + }, 574 + ExpiresIn=expiry, 575 + ) 576 + logfire.info( 577 + "generated presigned URL", 578 + file_id=file_id, 579 + expires_in=expiry, 580 + ) 581 + return url 582 + 583 + async def move_audio( 584 + self, 585 + file_id: str, 586 + extension: str, 587 + *, 588 + to_private: bool, 589 + ) -> str | None: 590 + """move an audio file between public and private buckets. 591 + 592 + copies the file to the destination bucket, then deletes from source. 593 + 594 + args: 595 + file_id: the file identifier hash 596 + extension: file extension (e.g., "mp3", "flac") 597 + to_private: if True, move public->private; if False, move private->public 598 + 599 + returns: 600 + new URL if successful (public URL or None for private), None on failure 601 + 602 + raises: 603 + ValueError: if private bucket not configured 604 + """ 605 + if not self.private_audio_bucket_name: 606 + raise ValueError("R2_PRIVATE_BUCKET not configured") 607 + 608 + ext = extension.lstrip(".") 609 + key = f"audio/{file_id}.{ext}" 610 + 611 + if to_private: 612 + src_bucket = self.audio_bucket_name 613 + dst_bucket = self.private_audio_bucket_name 614 + else: 615 + src_bucket = self.private_audio_bucket_name 616 + dst_bucket = self.audio_bucket_name 617 + 618 + with logfire.span( 619 + "R2 move_audio", 620 + file_id=file_id, 621 + key=key, 622 + to_private=to_private, 623 + ): 624 + try: 625 + async with self.async_session.client( 626 + "s3", 627 + endpoint_url=self.endpoint_url, 628 + aws_access_key_id=self.aws_access_key_id, 629 + aws_secret_access_key=self.aws_secret_access_key, 630 + ) as client: 631 + # copy to destination 632 + await client.copy_object( 633 + CopySource={"Bucket": src_bucket, "Key": key}, 634 + Bucket=dst_bucket, 635 + Key=key, 636 + ) 637 + logfire.info( 638 + "copied audio file", 639 + file_id=file_id, 640 + src=src_bucket, 641 + dst=dst_bucket, 642 + ) 643 + 644 + # delete from source 645 + await client.delete_object(Bucket=src_bucket, Key=key) 646 + logfire.info("deleted from source bucket", file_id=file_id) 647 + 648 + # return public URL if moved to public, None if moved to private 649 + if to_private: 650 + return None 651 + return f"{self.public_audio_bucket_url}/{key}" 652 + 653 + except ClientError as e: 654 + logfire.error( 655 + "R2 move_audio failed", 656 + file_id=file_id, 657 + error=str(e), 658 + exc_info=True, 659 + ) 660 + return None
+218 -3
backend/tests/api/test_audio.py
··· 271 271 test_app.dependency_overrides.pop(require_auth, None) 272 272 273 273 274 - async def test_get_audio_url_requires_auth(test_app: FastAPI): 275 - """test that /url endpoint returns 401 without authentication.""" 274 + async def test_get_audio_url_gated_requires_auth( 275 + test_app: FastAPI, db_session: AsyncSession 276 + ): 277 + """test that /url endpoint returns 401 for gated content without authentication.""" 278 + # create a gated track 279 + artist = Artist( 280 + did="did:plc:gatedartist", 281 + handle="gatedartist.bsky.social", 282 + display_name="Gated Artist", 283 + ) 284 + db_session.add(artist) 285 + await db_session.flush() 286 + 287 + track = Track( 288 + title="Gated Track", 289 + artist_did=artist.did, 290 + file_id="gated-test-file", 291 + file_type="mp3", 292 + r2_url="https://cdn.example.com/audio/gated.mp3", 293 + support_gate={"type": "any"}, 294 + ) 295 + db_session.add(track) 296 + await db_session.commit() 297 + 276 298 # ensure no auth override 277 299 test_app.dependency_overrides.pop(require_auth, None) 278 300 279 301 async with AsyncClient( 280 302 transport=ASGITransport(app=test_app), base_url="http://test" 281 303 ) as client: 282 - response = await client.get("/audio/somefile/url") 304 + response = await client.get(f"/audio/{track.file_id}/url") 305 + 306 + assert response.status_code == 401 307 + assert "authentication required" in response.json()["detail"] 308 + 309 + 310 + # gated content regression tests 311 + 312 + 313 + @pytest.fixture 314 + async def gated_track(db_session: AsyncSession) -> Track: 315 + """create a gated track for testing supporter access.""" 316 + artist = Artist( 317 + did="did:plc:gatedowner", 318 + handle="gatedowner.bsky.social", 319 + display_name="Gated Owner", 320 + ) 321 + db_session.add(artist) 322 + await db_session.flush() 323 + 324 + track = Track( 325 + title="Supporters Only Track", 326 + artist_did=artist.did, 327 + file_id="gated-regression-test", 328 + file_type="mp3", 329 + r2_url=None, # no cached URL - forces presigned URL generation 330 + support_gate={"type": "any"}, 331 + ) 332 + db_session.add(track) 333 + await db_session.commit() 334 + await db_session.refresh(track) 335 + 336 + return track 337 + 338 + 339 + @pytest.fixture 340 + def owner_session() -> Session: 341 + """session for the track owner.""" 342 + return Session( 343 + session_id="owner-session-id", 344 + did="did:plc:gatedowner", 345 + handle="gatedowner.bsky.social", 346 + oauth_session={ 347 + "access_token": "owner-access-token", 348 + "refresh_token": "owner-refresh-token", 349 + "dpop_key": {}, 350 + }, 351 + ) 352 + 353 + 354 + @pytest.fixture 355 + def non_supporter_session() -> Session: 356 + """session for a user who is not a supporter.""" 357 + return Session( 358 + session_id="non-supporter-session-id", 359 + did="did:plc:randomuser", 360 + handle="randomuser.bsky.social", 361 + oauth_session={ 362 + "access_token": "random-access-token", 363 + "refresh_token": "random-refresh-token", 364 + "dpop_key": {}, 365 + }, 366 + ) 367 + 368 + 369 + async def test_gated_stream_requires_auth(test_app: FastAPI, gated_track: Track): 370 + """regression: GET /audio/{file_id} returns 401 for gated content without auth.""" 371 + test_app.dependency_overrides.pop(require_auth, None) 372 + 373 + async with AsyncClient( 374 + transport=ASGITransport(app=test_app), base_url="http://test" 375 + ) as client: 376 + response = await client.get( 377 + f"/audio/{gated_track.file_id}", follow_redirects=False 378 + ) 379 + 380 + assert response.status_code == 401 381 + assert "authentication required" in response.json()["detail"] 382 + 383 + 384 + async def test_gated_head_requires_auth(test_app: FastAPI, gated_track: Track): 385 + """regression: HEAD /audio/{file_id} returns 401 for gated content without auth.""" 386 + test_app.dependency_overrides.pop(require_auth, None) 387 + 388 + async with AsyncClient( 389 + transport=ASGITransport(app=test_app), base_url="http://test" 390 + ) as client: 391 + response = await client.head(f"/audio/{gated_track.file_id}") 283 392 284 393 assert response.status_code == 401 394 + 395 + 396 + async def test_gated_head_owner_allowed( 397 + test_app: FastAPI, gated_track: Track, owner_session: Session 398 + ): 399 + """regression: HEAD /audio/{file_id} returns 200 for track owner.""" 400 + from backend._internal import get_optional_session 401 + 402 + test_app.dependency_overrides[get_optional_session] = lambda: owner_session 403 + 404 + try: 405 + async with AsyncClient( 406 + transport=ASGITransport(app=test_app), base_url="http://test" 407 + ) as client: 408 + response = await client.head(f"/audio/{gated_track.file_id}") 409 + 410 + assert response.status_code == 200 411 + finally: 412 + test_app.dependency_overrides.pop(get_optional_session, None) 413 + 414 + 415 + async def test_gated_stream_owner_redirects( 416 + test_app: FastAPI, gated_track: Track, owner_session: Session 417 + ): 418 + """regression: GET /audio/{file_id} returns 307 redirect for track owner.""" 419 + from backend._internal import get_optional_session 420 + 421 + mock_storage = MagicMock() 422 + mock_storage.generate_presigned_url = AsyncMock( 423 + return_value="https://presigned.example.com/audio/gated.mp3" 424 + ) 425 + 426 + test_app.dependency_overrides[get_optional_session] = lambda: owner_session 427 + 428 + try: 429 + with patch("backend.api.audio.storage", mock_storage): 430 + async with AsyncClient( 431 + transport=ASGITransport(app=test_app), base_url="http://test" 432 + ) as client: 433 + response = await client.get( 434 + f"/audio/{gated_track.file_id}", follow_redirects=False 435 + ) 436 + 437 + assert response.status_code == 307 438 + assert "presigned.example.com" in response.headers["location"] 439 + mock_storage.generate_presigned_url.assert_called_once() 440 + finally: 441 + test_app.dependency_overrides.pop(get_optional_session, None) 442 + 443 + 444 + async def test_gated_head_non_supporter_denied( 445 + test_app: FastAPI, gated_track: Track, non_supporter_session: Session 446 + ): 447 + """regression: HEAD /audio/{file_id} returns 402 for non-supporter.""" 448 + from backend._internal import get_optional_session 449 + 450 + test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session 451 + 452 + # mock validate_supporter to return invalid 453 + mock_validation = MagicMock() 454 + mock_validation.valid = False 455 + 456 + try: 457 + with patch( 458 + "backend.api.audio.validate_supporter", 459 + AsyncMock(return_value=mock_validation), 460 + ): 461 + async with AsyncClient( 462 + transport=ASGITransport(app=test_app), base_url="http://test" 463 + ) as client: 464 + response = await client.head(f"/audio/{gated_track.file_id}") 465 + 466 + assert response.status_code == 402 467 + assert response.headers.get("x-support-required") == "true" 468 + finally: 469 + test_app.dependency_overrides.pop(get_optional_session, None) 470 + 471 + 472 + async def test_gated_stream_non_supporter_denied( 473 + test_app: FastAPI, gated_track: Track, non_supporter_session: Session 474 + ): 475 + """regression: GET /audio/{file_id} returns 402 for non-supporter.""" 476 + from backend._internal import get_optional_session 477 + 478 + test_app.dependency_overrides[get_optional_session] = lambda: non_supporter_session 479 + 480 + # mock validate_supporter to return invalid 481 + mock_validation = MagicMock() 482 + mock_validation.valid = False 483 + 484 + try: 485 + with patch( 486 + "backend.api.audio.validate_supporter", 487 + AsyncMock(return_value=mock_validation), 488 + ): 489 + async with AsyncClient( 490 + transport=ASGITransport(app=test_app), base_url="http://test" 491 + ) as client: 492 + response = await client.get( 493 + f"/audio/{gated_track.file_id}", follow_redirects=False 494 + ) 495 + 496 + assert response.status_code == 402 497 + assert "supporter access" in response.json()["detail"] 498 + finally: 499 + test_app.dependency_overrides.pop(get_optional_session, None)
+3 -2
frontend/src/lib/components/Queue.svelte
··· 1 1 <script lang="ts"> 2 2 import { queue } from '$lib/queue.svelte'; 3 + import { goToIndex } from '$lib/playback.svelte'; 3 4 import type { Track } from '$lib/types'; 4 5 5 6 let draggedIndex = $state<number | null>(null); ··· 167 168 ondragover={(e) => handleDragOver(e, index)} 168 169 ondrop={(e) => handleDrop(e, index)} 169 170 ondragend={handleDragEnd} 170 - onclick={() => queue.goTo(index)} 171 - onkeydown={(e) => e.key === 'Enter' && queue.goTo(index)} 171 + onclick={() => goToIndex(index)} 172 + onkeydown={(e) => e.key === 'Enter' && goToIndex(index)} 172 173 > 173 174 <!-- drag handle for reordering --> 174 175 <button
+113 -40
frontend/src/lib/components/TrackItem.svelte
··· 7 7 import type { Track } from '$lib/types'; 8 8 import { queue } from '$lib/queue.svelte'; 9 9 import { toast } from '$lib/toast.svelte'; 10 + import { playTrack } from '$lib/playback.svelte'; 10 11 11 12 interface Props { 12 13 track: Track; ··· 130 131 {/if} 131 132 <button 132 133 class="track" 133 - onclick={(e) => { 134 + onclick={async (e) => { 134 135 // only play if clicking the track itself, not a link inside 135 136 if (e.target instanceof HTMLAnchorElement || (e.target as HTMLElement).closest('a')) { 136 137 return; 137 138 } 138 - onPlay(track); 139 + // use playTrack for gated content checks, fall back to onPlay for non-gated 140 + if (track.support_gate) { 141 + await playTrack(track); 142 + } else { 143 + onPlay(track); 144 + } 139 145 }} 140 146 > 141 - {#if track.image_url && !trackImageError} 142 - <SensitiveImage src={track.image_url}> 143 - <div class="track-image"> 144 - <img 145 - src={track.image_url} 146 - alt="{track.title} artwork" 147 - width="48" 148 - height="48" 149 - loading={imageLoading} 150 - fetchpriority={imageFetchPriority} 151 - onerror={() => trackImageError = true} 152 - /> 147 + <div class="track-image-wrapper" class:gated={track.support_gate}> 148 + {#if track.image_url && !trackImageError} 149 + <SensitiveImage src={track.image_url}> 150 + <div class="track-image"> 151 + <img 152 + src={track.image_url} 153 + alt="{track.title} artwork" 154 + width="48" 155 + height="48" 156 + loading={imageLoading} 157 + fetchpriority={imageFetchPriority} 158 + onerror={() => trackImageError = true} 159 + /> 160 + </div> 161 + </SensitiveImage> 162 + {:else if track.artist_avatar_url && !avatarError} 163 + <SensitiveImage src={track.artist_avatar_url}> 164 + <a 165 + href="/u/{track.artist_handle}" 166 + class="track-avatar" 167 + > 168 + <img 169 + src={track.artist_avatar_url} 170 + alt={track.artist} 171 + width="48" 172 + height="48" 173 + loading={imageLoading} 174 + fetchpriority={imageFetchPriority} 175 + onerror={() => avatarError = true} 176 + /> 177 + </a> 178 + </SensitiveImage> 179 + {:else} 180 + <div class="track-image-placeholder"> 181 + <svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg"> 182 + <circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" /> 183 + <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> 184 + </svg> 153 185 </div> 154 - </SensitiveImage> 155 - {:else if track.artist_avatar_url && !avatarError} 156 - <SensitiveImage src={track.artist_avatar_url}> 157 - <a 158 - href="/u/{track.artist_handle}" 159 - class="track-avatar" 160 - > 161 - <img 162 - src={track.artist_avatar_url} 163 - alt={track.artist} 164 - width="48" 165 - height="48" 166 - loading={imageLoading} 167 - fetchpriority={imageFetchPriority} 168 - onerror={() => avatarError = true} 169 - /> 170 - </a> 171 - </SensitiveImage> 172 - {:else} 173 - <div class="track-image-placeholder"> 174 - <svg width="24" height="24" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" xmlns="http://www.w3.org/2000/svg"> 175 - <circle cx="8" cy="5" r="3" stroke="currentColor" stroke-width="1.5" fill="none" /> 176 - <path d="M3 14c0-2.5 2-4.5 5-4.5s5 2 5 4.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" /> 177 - </svg> 178 - </div> 179 - {/if} 186 + {/if} 187 + {#if track.support_gate} 188 + <div class="gated-badge" title="supporters only"> 189 + <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 190 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 191 + </svg> 192 + </div> 193 + {/if} 194 + </div> 180 195 <div class="track-info"> 181 196 <div class="track-title">{track.title}</div> 182 197 <div class="track-metadata"> ··· 395 410 align-items: center; 396 411 gap: 0.75rem; 397 412 font-family: inherit; 413 + } 414 + 415 + .track-image-wrapper { 416 + position: relative; 417 + flex-shrink: 0; 418 + width: 48px; 419 + height: 48px; 420 + } 421 + 422 + .track-image-wrapper.gated::after { 423 + content: ''; 424 + position: absolute; 425 + inset: 0; 426 + background: rgba(0, 0, 0, 0.3); 427 + border-radius: 4px; 428 + pointer-events: none; 429 + } 430 + 431 + .gated-badge { 432 + position: absolute; 433 + bottom: -4px; 434 + right: -4px; 435 + width: 18px; 436 + height: 18px; 437 + display: flex; 438 + align-items: center; 439 + justify-content: center; 440 + background: var(--accent); 441 + border: 2px solid var(--bg-secondary); 442 + border-radius: 50%; 443 + color: white; 444 + z-index: 1; 398 445 } 399 446 400 447 .track-image, ··· 750 797 gap: 0.5rem; 751 798 } 752 799 800 + .track-image-wrapper, 753 801 .track-image, 754 802 .track-image-placeholder, 755 803 .track-avatar { 756 804 width: 40px; 757 805 height: 40px; 806 + } 807 + 808 + .gated-badge { 809 + width: 16px; 810 + height: 16px; 811 + bottom: -3px; 812 + right: -3px; 813 + } 814 + 815 + .gated-badge svg { 816 + width: 8px; 817 + height: 8px; 758 818 } 759 819 760 820 .track-title { ··· 790 850 padding: 0.5rem 0.65rem; 791 851 } 792 852 853 + .track-image-wrapper, 793 854 .track-image, 794 855 .track-image-placeholder, 795 856 .track-avatar { 796 857 width: 36px; 797 858 height: 36px; 859 + } 860 + 861 + .gated-badge { 862 + width: 14px; 863 + height: 14px; 864 + bottom: -2px; 865 + right: -2px; 866 + } 867 + 868 + .gated-badge svg { 869 + width: 7px; 870 + height: 7px; 798 871 } 799 872 800 873 .track-title {
+138 -23
frontend/src/lib/components/player/Player.svelte
··· 4 4 import { nowPlaying } from '$lib/now-playing.svelte'; 5 5 import { moderation } from '$lib/moderation.svelte'; 6 6 import { preferences } from '$lib/preferences.svelte'; 7 + import { toast } from '$lib/toast.svelte'; 7 8 import { API_URL } from '$lib/config'; 8 9 import { getCachedAudioUrl } from '$lib/storage'; 9 10 import { onMount } from 'svelte'; ··· 11 12 import TrackInfo from './TrackInfo.svelte'; 12 13 import PlaybackControls from './PlaybackControls.svelte'; 13 14 import type { Track } from '$lib/types'; 15 + 16 + // atprotofans base URL for supporter CTAs 17 + const ATPROTOFANS_URL = 'https://atprotofans.com'; 14 18 15 19 // check if artwork should be shown in media session (respects sensitive content settings) 16 20 function shouldShowArtwork(url: string | null | undefined): boolean { ··· 239 243 ); 240 244 }); 241 245 246 + // gated content error types 247 + interface GatedError { 248 + type: 'gated'; 249 + artistDid: string; 250 + artistHandle: string; 251 + requiresAuth: boolean; 252 + } 253 + 242 254 // get audio source URL - checks local cache first, falls back to network 243 - async function getAudioSource(file_id: string): Promise<string> { 255 + // throws GatedError if the track requires supporter access 256 + async function getAudioSource(file_id: string, track: Track): Promise<string> { 244 257 try { 245 258 const cachedUrl = await getCachedAudioUrl(file_id); 246 259 if (cachedUrl) { ··· 249 262 } catch (err) { 250 263 console.error('failed to check audio cache:', err); 251 264 } 265 + 266 + // for gated tracks, check authorization first 267 + if (track.support_gate) { 268 + const response = await fetch(`${API_URL}/audio/${file_id}`, { 269 + method: 'HEAD', 270 + credentials: 'include' 271 + }); 272 + 273 + if (response.status === 401) { 274 + throw { 275 + type: 'gated', 276 + artistDid: track.artist_did, 277 + artistHandle: track.artist_handle, 278 + requiresAuth: true 279 + } as GatedError; 280 + } 281 + 282 + if (response.status === 402) { 283 + throw { 284 + type: 'gated', 285 + artistDid: track.artist_did, 286 + artistHandle: track.artist_handle, 287 + requiresAuth: false 288 + } as GatedError; 289 + } 290 + } 291 + 252 292 return `${API_URL}/audio/${file_id}`; 253 293 } 254 294 ··· 266 306 let previousTrackId = $state<number | null>(null); 267 307 let isLoadingTrack = $state(false); 268 308 309 + // store previous playback state for restoration on gated errors 310 + let savedPlaybackState = $state<{ 311 + track: Track; 312 + src: string; 313 + currentTime: number; 314 + paused: boolean; 315 + } | null>(null); 316 + 269 317 $effect(() => { 270 318 if (!player.currentTrack || !player.audioElement) return; 271 319 272 320 // only load new track if it actually changed 273 321 if (player.currentTrack.id !== previousTrackId) { 274 322 const trackToLoad = player.currentTrack; 323 + const audioElement = player.audioElement; 324 + 325 + // save current playback state BEFORE changing anything 326 + // (only if we have a playing/paused track to restore to) 327 + if (previousTrackId !== null && audioElement.src && !audioElement.src.startsWith('blob:')) { 328 + const prevTrack = queue.tracks.find((t) => t.id === previousTrackId); 329 + if (prevTrack) { 330 + savedPlaybackState = { 331 + track: prevTrack, 332 + src: audioElement.src, 333 + currentTime: audioElement.currentTime, 334 + paused: audioElement.paused 335 + }; 336 + } 337 + } 338 + 339 + // update tracking state 275 340 previousTrackId = trackToLoad.id; 276 341 player.resetPlayCount(); 277 342 isLoadingTrack = true; ··· 280 345 cleanupBlobUrl(); 281 346 282 347 // async: get audio source (cached or network) 283 - getAudioSource(trackToLoad.file_id).then((src) => { 284 - // check if track is still current (user may have changed tracks during await) 285 - if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) { 286 - // track changed, cleanup if we created a blob URL 348 + getAudioSource(trackToLoad.file_id, trackToLoad) 349 + .then((src) => { 350 + // check if track is still current (user may have changed tracks during await) 351 + if (player.currentTrack?.id !== trackToLoad.id || !player.audioElement) { 352 + // track changed, cleanup if we created a blob URL 353 + if (src.startsWith('blob:')) { 354 + URL.revokeObjectURL(src); 355 + } 356 + return; 357 + } 358 + 359 + // successfully got source - clear saved state 360 + savedPlaybackState = null; 361 + 362 + // track if this is a blob URL so we can revoke it later 287 363 if (src.startsWith('blob:')) { 288 - URL.revokeObjectURL(src); 364 + currentBlobUrl = src; 289 365 } 290 - return; 291 - } 366 + 367 + player.audioElement.src = src; 368 + player.audioElement.load(); 292 369 293 - // track if this is a blob URL so we can revoke it later 294 - if (src.startsWith('blob:')) { 295 - currentBlobUrl = src; 296 - } 370 + // wait for audio to be ready before allowing playback 371 + player.audioElement.addEventListener( 372 + 'loadeddata', 373 + () => { 374 + isLoadingTrack = false; 375 + }, 376 + { once: true } 377 + ); 378 + }) 379 + .catch((err) => { 380 + isLoadingTrack = false; 297 381 298 - player.audioElement.src = src; 299 - player.audioElement.load(); 382 + // handle gated content errors with supporter CTA 383 + if (err && err.type === 'gated') { 384 + const gatedErr = err as GatedError; 300 385 301 - // wait for audio to be ready before allowing playback 302 - player.audioElement.addEventListener( 303 - 'loadeddata', 304 - () => { 305 - isLoadingTrack = false; 306 - }, 307 - { once: true } 308 - ); 309 - }); 386 + if (gatedErr.requiresAuth) { 387 + toast.info('sign in to play supporter-only tracks'); 388 + } else { 389 + // show toast with supporter CTA 390 + const supportUrl = gatedErr.artistDid 391 + ? `${ATPROTOFANS_URL}/${gatedErr.artistDid}` 392 + : `${ATPROTOFANS_URL}/${gatedErr.artistHandle}`; 393 + 394 + toast.info('this track is for supporters only', 5000, { 395 + label: 'become a supporter', 396 + href: supportUrl 397 + }); 398 + } 399 + 400 + // restore previous playback if we had something playing 401 + if (savedPlaybackState && player.audioElement) { 402 + player.currentTrack = savedPlaybackState.track; 403 + previousTrackId = savedPlaybackState.track.id; 404 + player.audioElement.src = savedPlaybackState.src; 405 + player.audioElement.currentTime = savedPlaybackState.currentTime; 406 + if (!savedPlaybackState.paused) { 407 + player.audioElement.play().catch(() => {}); 408 + } 409 + savedPlaybackState = null; 410 + return; 411 + } 412 + 413 + // no previous state to restore - skip to next or stop 414 + if (queue.hasNext) { 415 + queue.next(); 416 + } else { 417 + player.currentTrack = null; 418 + player.paused = true; 419 + } 420 + return; 421 + } 422 + 423 + console.error('failed to load audio:', err); 424 + }); 310 425 } 311 426 }); 312 427
+8
frontend/src/lib/config.ts
··· 2 2 3 3 export const API_URL = PUBLIC_API_URL || 'http://localhost:8001'; 4 4 5 + /** 6 + * generate atprotofans support URL for an artist. 7 + * canonical format: https://atprotofans.com/support/{did} 8 + */ 9 + export function getAtprotofansSupportUrl(did: string): string { 10 + return `https://atprotofans.com/support/${did}`; 11 + } 12 + 5 13 interface ServerConfig { 6 14 max_upload_size_mb: number; 7 15 max_image_size_mb: number;
+160
frontend/src/lib/playback.svelte.ts
··· 1 + /** 2 + * playback helper - guards queue operations with gated content checks. 3 + * 4 + * all playback actions should go through this module to prevent 5 + * gated tracks from interrupting current playback. 6 + */ 7 + 8 + import { browser } from '$app/environment'; 9 + import { queue } from './queue.svelte'; 10 + import { toast } from './toast.svelte'; 11 + import { API_URL, getAtprotofansSupportUrl } from './config'; 12 + import type { Track } from './types'; 13 + 14 + interface GatedCheckResult { 15 + allowed: boolean; 16 + requiresAuth?: boolean; 17 + artistDid?: string; 18 + artistHandle?: string; 19 + } 20 + 21 + /** 22 + * check if a track can be played by the current user. 23 + * returns immediately for non-gated tracks. 24 + * for gated tracks, makes a HEAD request to verify access. 25 + */ 26 + async function checkAccess(track: Track): Promise<GatedCheckResult> { 27 + // non-gated tracks are always allowed 28 + if (!track.support_gate) { 29 + return { allowed: true }; 30 + } 31 + 32 + // gated track - check access via HEAD request 33 + try { 34 + const response = await fetch(`${API_URL}/audio/${track.file_id}`, { 35 + method: 'HEAD', 36 + credentials: 'include' 37 + }); 38 + 39 + if (response.ok) { 40 + return { allowed: true }; 41 + } 42 + 43 + if (response.status === 401) { 44 + return { 45 + allowed: false, 46 + requiresAuth: true, 47 + artistDid: track.artist_did, 48 + artistHandle: track.artist_handle 49 + }; 50 + } 51 + 52 + if (response.status === 402) { 53 + return { 54 + allowed: false, 55 + requiresAuth: false, 56 + artistDid: track.artist_did, 57 + artistHandle: track.artist_handle 58 + }; 59 + } 60 + 61 + // unexpected status - allow and let Player handle any errors 62 + return { allowed: true }; 63 + } catch { 64 + // network error - allow and let Player handle any errors 65 + return { allowed: true }; 66 + } 67 + } 68 + 69 + /** 70 + * show appropriate toast for denied access. 71 + */ 72 + function showDeniedToast(result: GatedCheckResult): void { 73 + if (result.requiresAuth) { 74 + toast.info('sign in to play supporter-only tracks'); 75 + } else if (result.artistDid) { 76 + toast.info('this track is for supporters only', 5000, { 77 + label: 'become a supporter', 78 + href: getAtprotofansSupportUrl(result.artistDid) 79 + }); 80 + } else { 81 + toast.info('this track is for supporters only'); 82 + } 83 + } 84 + 85 + /** 86 + * play a single track now. 87 + * checks gated access before modifying queue state. 88 + * shows toast if access denied - does NOT interrupt current playback. 89 + */ 90 + export async function playTrack(track: Track): Promise<boolean> { 91 + if (!browser) return false; 92 + 93 + const result = await checkAccess(track); 94 + if (!result.allowed) { 95 + showDeniedToast(result); 96 + return false; 97 + } 98 + 99 + queue.playNow(track); 100 + return true; 101 + } 102 + 103 + /** 104 + * set the queue and optionally start playing at a specific index. 105 + * checks gated access for the starting track before modifying queue state. 106 + */ 107 + export async function playQueue(tracks: Track[], startIndex = 0): Promise<boolean> { 108 + if (!browser || tracks.length === 0) return false; 109 + 110 + const startTrack = tracks[startIndex]; 111 + if (!startTrack) return false; 112 + 113 + const result = await checkAccess(startTrack); 114 + if (!result.allowed) { 115 + showDeniedToast(result); 116 + return false; 117 + } 118 + 119 + queue.setQueue(tracks, startIndex); 120 + return true; 121 + } 122 + 123 + /** 124 + * add tracks to queue and optionally start playing. 125 + * if playNow is true, checks gated access for the first added track. 126 + */ 127 + export async function addToQueue(tracks: Track[], playNow = false): Promise<boolean> { 128 + if (!browser || tracks.length === 0) return false; 129 + 130 + if (playNow) { 131 + const result = await checkAccess(tracks[0]); 132 + if (!result.allowed) { 133 + showDeniedToast(result); 134 + return false; 135 + } 136 + } 137 + 138 + queue.addTracks(tracks, playNow); 139 + return true; 140 + } 141 + 142 + /** 143 + * go to a specific index in the queue. 144 + * checks gated access before changing position. 145 + */ 146 + export async function goToIndex(index: number): Promise<boolean> { 147 + if (!browser) return false; 148 + 149 + const track = queue.tracks[index]; 150 + if (!track) return false; 151 + 152 + const result = await checkAccess(track); 153 + if (!result.allowed) { 154 + showDeniedToast(result); 155 + return false; 156 + } 157 + 158 + queue.goTo(index); 159 + return true; 160 + }
+6
frontend/src/lib/types.ts
··· 27 27 tracks: Track[]; 28 28 } 29 29 30 + export interface SupportGate { 31 + type: 'any' | string; 32 + } 33 + 30 34 export interface Track { 31 35 id: number; 32 36 title: string; ··· 36 40 file_type: string; 37 41 artist_handle: string; 38 42 artist_avatar_url?: string; 43 + artist_did?: string; 39 44 r2_url?: string; 40 45 atproto_record_uri?: string; 41 46 atproto_record_cid?: string; ··· 50 55 is_liked?: boolean; 51 56 copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged 52 57 copyright_match?: string | null; // "Title by Artist" of primary match 58 + support_gate?: SupportGate | null; // if set, track requires supporter access 53 59 } 54 60 55 61 export interface User {
+4
frontend/src/lib/uploader.svelte.ts
··· 34 34 features: FeaturedArtist[], 35 35 image: File | null | undefined, 36 36 tags: string[], 37 + supportGated: boolean, 37 38 onSuccess?: () => void, 38 39 callbacks?: UploadProgressCallback 39 40 ): void { ··· 59 60 } 60 61 if (image) { 61 62 formData.append('image', image); 63 + } 64 + if (supportGated) { 65 + formData.append('support_gate', JSON.stringify({ type: 'any' })); 62 66 } 63 67 64 68 const xhr = new XMLHttpRequest();
+7 -4
frontend/src/routes/playlist/[id]/+page.svelte
··· 12 12 import { toast } from "$lib/toast.svelte"; 13 13 import { player } from "$lib/player.svelte"; 14 14 import { queue } from "$lib/queue.svelte"; 15 + import { playQueue } from "$lib/playback.svelte"; 15 16 import { fetchLikedTracks } from "$lib/tracks.svelte"; 16 17 import type { PageData } from "./$types"; 17 18 import type { PlaylistWithTracks, Track } from "$lib/types"; ··· 143 144 queue.playNow(track); 144 145 } 145 146 146 - function playNow() { 147 + async function playNow() { 147 148 if (tracks.length > 0) { 148 - queue.setQueue(tracks); 149 - queue.playNow(tracks[0]); 150 - toast.success(`playing ${playlist.name}`, 1800); 149 + // use playQueue to check gated access on first track before modifying queue 150 + const played = await playQueue(tracks); 151 + if (played) { 152 + toast.success(`playing ${playlist.name}`, 1800); 153 + } 151 154 } 152 155 } 153 156
+70
frontend/src/routes/portal/+page.svelte
··· 28 28 let editFeaturedArtists = $state<FeaturedArtist[]>([]); 29 29 let editTags = $state<string[]>([]); 30 30 let editImageFile = $state<File | null>(null); 31 + let editSupportGate = $state(false); 31 32 let hasUnresolvedEditFeaturesInput = $state(false); 32 33 33 34 // profile editing state ··· 315 316 editAlbum = track.album?.title || ''; 316 317 editFeaturedArtists = track.features || []; 317 318 editTags = track.tags || []; 319 + editSupportGate = track.support_gate !== null && track.support_gate !== undefined; 318 320 } 319 321 320 322 function cancelEdit() { ··· 324 326 editFeaturedArtists = []; 325 327 editTags = []; 326 328 editImageFile = null; 329 + editSupportGate = false; 327 330 } 328 331 329 332 ··· 340 343 } 341 344 // always send tags (empty array clears them) 342 345 formData.append('tags', JSON.stringify(editTags)); 346 + // send support_gate - null to remove, or {type: "any"} to enable 347 + if (editSupportGate) { 348 + formData.append('support_gate', JSON.stringify({ type: 'any' })); 349 + } else { 350 + formData.append('support_gate', 'null'); 351 + } 343 352 if (editImageFile) { 344 353 formData.append('image', editImageFile); 345 354 } ··· 740 749 <p class="file-info">{editImageFile.name} (will replace current)</p> 741 750 {/if} 742 751 </div> 752 + {#if atprotofansEligible || track.support_gate} 753 + <div class="edit-field-group"> 754 + <label class="edit-label">supporter access</label> 755 + <label class="toggle-row"> 756 + <input 757 + type="checkbox" 758 + bind:checked={editSupportGate} 759 + /> 760 + <span>only supporters can play this track</span> 761 + </label> 762 + {#if editSupportGate} 763 + <p class="field-hint"> 764 + only users who support you via <a href="https://atprotofans.com" target="_blank" rel="noopener">atprotofans</a> can play this track 765 + </p> 766 + {/if} 767 + </div> 768 + {/if} 743 769 </div> 744 770 <div class="edit-actions"> 745 771 <button ··· 784 810 <div class="track-info"> 785 811 <div class="track-title"> 786 812 {track.title} 813 + {#if track.support_gate} 814 + <span class="support-gate-badge" title="supporters only"> 815 + <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 816 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 817 + </svg> 818 + </span> 819 + {/if} 787 820 {#if track.copyright_flagged} 788 821 {@const matchText = track.copyright_match ? `potential copyright violation: ${track.copyright_match}` : 'potential copyright violation'} 789 822 {#if track.atproto_record_url} ··· 1604 1637 color: var(--text-secondary); 1605 1638 } 1606 1639 1640 + .toggle-row { 1641 + display: flex; 1642 + align-items: center; 1643 + gap: 0.5rem; 1644 + cursor: pointer; 1645 + font-size: 0.9rem; 1646 + color: var(--text-primary); 1647 + } 1648 + 1649 + .toggle-row input[type="checkbox"] { 1650 + width: 16px; 1651 + height: 16px; 1652 + accent-color: var(--accent); 1653 + } 1654 + 1655 + .field-hint { 1656 + font-size: 0.8rem; 1657 + color: var(--text-tertiary); 1658 + margin-top: 0.25rem; 1659 + } 1660 + 1661 + .field-hint a { 1662 + color: var(--accent); 1663 + text-decoration: none; 1664 + } 1665 + 1666 + .field-hint a:hover { 1667 + text-decoration: underline; 1668 + } 1669 + 1607 1670 .track-title { 1608 1671 font-weight: 600; 1609 1672 font-size: 1rem; ··· 1612 1675 display: flex; 1613 1676 align-items: center; 1614 1677 gap: 0.5rem; 1678 + } 1679 + 1680 + .support-gate-badge { 1681 + display: inline-flex; 1682 + align-items: center; 1683 + color: var(--accent); 1684 + flex-shrink: 0; 1615 1685 } 1616 1686 1617 1687 .copyright-flag {
+20 -4
frontend/src/routes/track/[id]/+page.svelte
··· 12 12 import { checkImageSensitive } from '$lib/moderation.svelte'; 13 13 import { player } from '$lib/player.svelte'; 14 14 import { queue } from '$lib/queue.svelte'; 15 + import { playTrack } from '$lib/playback.svelte'; 15 16 import { auth } from '$lib/auth.svelte'; 16 17 import { toast } from '$lib/toast.svelte'; 17 18 import type { Track } from '$lib/types'; ··· 103 104 window.location.href = '/'; 104 105 } 105 106 106 - function handlePlay() { 107 + async function handlePlay() { 107 108 if (player.currentTrack?.id === track.id) { 108 109 // this track is already loaded - just toggle play/pause 109 110 player.togglePlayPause(); 110 111 } else { 111 112 // different track or no track - start this one 112 - queue.playNow(track); 113 + // use playTrack for gated content checks 114 + if (track.support_gate) { 115 + await playTrack(track); 116 + } else { 117 + queue.playNow(track); 118 + } 113 119 } 114 120 } 115 121 ··· 187 193 return `${minutes}:${seconds.toString().padStart(2, '0')}`; 188 194 } 189 195 190 - function seekToTimestamp(ms: number) { 196 + async function seekToTimestamp(ms: number) { 191 197 const doSeek = () => { 192 198 if (player.audioElement) { 193 199 player.audioElement.currentTime = ms / 1000; ··· 201 207 } 202 208 203 209 // otherwise start playing and wait for audio to be ready 204 - queue.playNow(track); 210 + // use playTrack for gated content checks 211 + let played = false; 212 + if (track.support_gate) { 213 + played = await playTrack(track); 214 + } else { 215 + queue.playNow(track); 216 + played = true; 217 + } 218 + 219 + if (!played) return; // gated - can't seek 220 + 205 221 if (player.audioElement && player.audioElement.readyState >= 1) { 206 222 doSeek(); 207 223 } else {
+2 -2
frontend/src/routes/u/[handle]/+page.svelte
··· 1 1 <script lang="ts"> 2 2 import { fade } from 'svelte/transition'; 3 - import { API_URL } from '$lib/config'; 3 + import { API_URL, getAtprotofansSupportUrl } from '$lib/config'; 4 4 import { browser } from '$app/environment'; 5 5 import type { Analytics, Track, Playlist } from '$lib/types'; 6 6 import { formatDuration } from '$lib/stats.svelte'; ··· 40 40 const supportUrl = $derived(() => { 41 41 if (!artist?.support_url) return null; 42 42 if (artist.support_url === 'atprotofans') { 43 - return `https://atprotofans.com/u/${artist.did}`; 43 + return getAtprotofansSupportUrl(artist.did); 44 44 } 45 45 return artist.support_url; 46 46 });
+7 -4
frontend/src/routes/u/[handle]/album/[slug]/+page.svelte
··· 7 7 import { checkImageSensitive } from '$lib/moderation.svelte'; 8 8 import { player } from '$lib/player.svelte'; 9 9 import { queue } from '$lib/queue.svelte'; 10 + import { playQueue } from '$lib/playback.svelte'; 10 11 import { toast } from '$lib/toast.svelte'; 11 12 import { auth } from '$lib/auth.svelte'; 12 13 import { API_URL } from '$lib/config'; ··· 70 71 queue.playNow(track); 71 72 } 72 73 73 - function playNow() { 74 + async function playNow() { 74 75 if (tracks.length > 0) { 75 - queue.setQueue(tracks); 76 - queue.playNow(tracks[0]); 77 - toast.success(`playing ${albumMetadata.title}`, 1800); 76 + // use playQueue to check gated access on first track before modifying queue 77 + const played = await playQueue(tracks); 78 + if (played) { 79 + toast.success(`playing ${albumMetadata.title}`, 1800); 80 + } 78 81 } 79 82 } 80 83
+114 -2
frontend/src/routes/upload/+page.svelte
··· 6 6 import AlbumSelect from "$lib/components/AlbumSelect.svelte"; 7 7 import WaveLoading from "$lib/components/WaveLoading.svelte"; 8 8 import TagInput from "$lib/components/TagInput.svelte"; 9 - import type { FeaturedArtist, AlbumSummary } from "$lib/types"; 9 + import type { FeaturedArtist, AlbumSummary, Artist } from "$lib/types"; 10 10 import { API_URL, getServerConfig } from "$lib/config"; 11 11 import { uploader } from "$lib/uploader.svelte"; 12 12 import { toast } from "$lib/toast.svelte"; ··· 38 38 let uploadTags = $state<string[]>([]); 39 39 let hasUnresolvedFeaturesInput = $state(false); 40 40 let attestedRights = $state(false); 41 + let supportGated = $state(false); 41 42 42 43 // albums for selection 43 44 let albums = $state<AlbumSummary[]>([]); 45 + 46 + // artist profile for checking atprotofans eligibility 47 + let artistProfile = $state<Artist | null>(null); 44 48 45 49 onMount(async () => { 46 50 // wait for auth to finish loading ··· 53 57 return; 54 58 } 55 59 56 - await loadMyAlbums(); 60 + await Promise.all([loadMyAlbums(), loadArtistProfile()]); 57 61 loading = false; 58 62 }); 59 63 64 + async function loadArtistProfile() { 65 + if (!auth.user) return; 66 + try { 67 + const response = await fetch( 68 + `${API_URL}/artists/by-handle/${auth.user.handle}`, 69 + ); 70 + if (response.ok) { 71 + artistProfile = await response.json(); 72 + } 73 + } catch (_e) { 74 + console.error("failed to load artist profile:", _e); 75 + } 76 + } 77 + 60 78 async function loadMyAlbums() { 61 79 if (!auth.user) return; 62 80 try { ··· 82 100 const uploadFeatures = [...featuredArtists]; 83 101 const uploadImage = imageFile; 84 102 const tagsToUpload = [...uploadTags]; 103 + const isGated = supportGated; 85 104 86 105 const clearForm = () => { 87 106 title = ""; ··· 91 110 featuredArtists = []; 92 111 uploadTags = []; 93 112 attestedRights = false; 113 + supportGated = false; 94 114 95 115 const fileInput = document.getElementById( 96 116 "file-input", ··· 109 129 uploadFeatures, 110 130 uploadImage, 111 131 tagsToUpload, 132 + isGated, 112 133 async () => { 113 134 await loadMyAlbums(); 114 135 }, ··· 286 307 {/if} 287 308 </div> 288 309 310 + <div class="form-group supporter-gating"> 311 + {#if artistProfile?.support_url} 312 + <label class="checkbox-label"> 313 + <input 314 + type="checkbox" 315 + bind:checked={supportGated} 316 + /> 317 + <span class="checkbox-text"> 318 + <svg class="heart-icon" width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 319 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 320 + </svg> 321 + supporters only 322 + </span> 323 + </label> 324 + <p class="gating-note"> 325 + only users who support you via <a href={artistProfile.support_url} target="_blank" rel="noopener">atprotofans</a> can play this track 326 + </p> 327 + {:else} 328 + <div class="gating-disabled"> 329 + <span class="gating-disabled-icon"> 330 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 331 + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> 332 + </svg> 333 + </span> 334 + <span class="gating-disabled-text"> 335 + want to gate tracks for supporters? <a href="https://atprotofans.com" target="_blank" rel="noopener">set up atprotofans</a>, then enable it in your <a href="/portal">portal</a> 336 + </span> 337 + </div> 338 + {/if} 339 + </div> 340 + 289 341 <div class="form-group attestation"> 290 342 <label class="checkbox-label"> 291 343 <input ··· 508 560 } 509 561 510 562 .attestation-note a:hover { 563 + text-decoration: underline; 564 + } 565 + 566 + .supporter-gating { 567 + background: color-mix(in srgb, var(--accent) 8%, var(--bg-primary)); 568 + padding: 1rem; 569 + border-radius: 4px; 570 + border: 1px solid color-mix(in srgb, var(--accent) 20%, var(--border-default)); 571 + } 572 + 573 + .supporter-gating .checkbox-text { 574 + display: inline-flex; 575 + align-items: center; 576 + gap: 0.4rem; 577 + } 578 + 579 + .supporter-gating .heart-icon { 580 + color: var(--accent); 581 + } 582 + 583 + .gating-note { 584 + margin-top: 0.5rem; 585 + margin-left: 2rem; 586 + font-size: 0.8rem; 587 + color: var(--text-tertiary); 588 + line-height: 1.4; 589 + } 590 + 591 + .gating-note a { 592 + color: var(--accent); 593 + text-decoration: none; 594 + } 595 + 596 + .gating-note a:hover { 597 + text-decoration: underline; 598 + } 599 + 600 + .gating-disabled { 601 + display: flex; 602 + align-items: flex-start; 603 + gap: 0.75rem; 604 + color: var(--text-muted); 605 + } 606 + 607 + .gating-disabled-icon { 608 + flex-shrink: 0; 609 + margin-top: 0.1rem; 610 + } 611 + 612 + .gating-disabled-text { 613 + font-size: 0.85rem; 614 + line-height: 1.4; 615 + } 616 + 617 + .gating-disabled-text a { 618 + color: var(--accent); 619 + text-decoration: none; 620 + } 621 + 622 + .gating-disabled-text a:hover { 511 623 text-decoration: underline; 512 624 } 513 625
+17
lexicons/track.json
··· 61 61 "type": "string", 62 62 "format": "datetime", 63 63 "description": "Timestamp when the track was uploaded." 64 + }, 65 + "supportGate": { 66 + "type": "ref", 67 + "ref": "#supportGate", 68 + "description": "If set, this track requires viewer to be a supporter of the artist via atprotofans." 64 69 } 70 + } 71 + } 72 + }, 73 + "supportGate": { 74 + "type": "object", 75 + "description": "Configuration for supporter-gated content.", 76 + "required": ["type"], 77 + "properties": { 78 + "type": { 79 + "type": "string", 80 + "description": "The type of support required to access this content.", 81 + "knownValues": ["any"] 65 82 } 66 83 } 67 84 },