fix: resolve gated status server-side, show lock icon, guard queue adds (#640)

* fix: resolve gated status server-side, show lock icon for inaccessible content

- add `gated: bool` field to TrackResponse that resolves access at serialization
- backend checks if viewer is owner or supporter before returning tracks
- add `get_supported_artists()` helper for batch atprotofans API checks
- change frontend icon from heart to lock for gated content
- lock only shows when content is actually inaccessible to the viewer

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

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

* docs: add R2_PRIVATE_BUCKET to configuration docs

* fix: update portal page to use lock icon for gated tracks

* feat: show toast when non-supporter tries to queue gated track

adds sync guard function that uses server-resolved gated status
to show toast without network call when adding to queue.

🤖 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 058cede3 44d2260b

Changed files
+109 -14
backend
src
backend
docs
backend
local-development
frontend
src
lib
routes
portal
track
+2 -1
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 + from backend._internal.atprotofans import get_supported_artists, validate_supporter 36 36 37 37 __all__ = [ 38 38 "DeveloperToken", ··· 52 52 "get_pending_dev_token", 53 53 "get_pending_scope_upgrade", 54 54 "get_session", 55 + "get_supported_artists", 55 56 "handle_oauth_callback", 56 57 "list_developer_tokens", 57 58 "notification_service",
+28
backend/src/backend/_internal/atprotofans.py
··· 14 14 see: https://atprotofans.leaflet.pub/3mabsmts3rs2b 15 15 """ 16 16 17 + import asyncio 18 + 17 19 import httpx 18 20 import logfire 19 21 from pydantic import BaseModel ··· 91 93 exc_info=True, 92 94 ) 93 95 return SupporterValidation(valid=False) 96 + 97 + 98 + async def get_supported_artists( 99 + supporter_did: str, 100 + artist_dids: set[str], 101 + timeout: float = 5.0, 102 + ) -> set[str]: 103 + """batch check which artists a user supports. 104 + 105 + args: 106 + supporter_did: DID of the potential supporter 107 + artist_dids: set of artist DIDs to check 108 + timeout: request timeout per check 109 + 110 + returns: 111 + set of artist DIDs the user supports 112 + """ 113 + if not artist_dids: 114 + return set() 115 + 116 + async def check_one(artist_did: str) -> str | None: 117 + result = await validate_supporter(supporter_did, artist_did, timeout) 118 + return artist_did if result.valid else None 119 + 120 + results = await asyncio.gather(*[check_one(did) for did in artist_dids]) 121 + return {did for did in results if did is not None}
+18 -1
backend/src/backend/api/tracks/listing.py
··· 12 12 from sqlalchemy.orm import selectinload 13 13 14 14 from backend._internal import Session as AuthSession 15 - from backend._internal import get_optional_session, require_auth 15 + from backend._internal import get_optional_session, get_supported_artists, require_auth 16 16 from backend.config import settings 17 17 from backend.models import ( 18 18 Artist, ··· 233 233 await asyncio.gather(*[resolve_image(t) for t in tracks_needing_images]) 234 234 await db.commit() 235 235 236 + # resolve supporter status for gated content 237 + viewer_did = session.did if session else None 238 + supported_artist_dids: set[str] = set() 239 + if viewer_did: 240 + # collect artist DIDs with gated tracks (excluding viewer's own tracks) 241 + gated_artist_dids = { 242 + t.artist_did 243 + for t in tracks 244 + if t.support_gate and t.artist_did != viewer_did 245 + } 246 + if gated_artist_dids: 247 + supported_artist_dids = await get_supported_artists( 248 + viewer_did, gated_artist_dids 249 + ) 250 + 236 251 # fetch all track responses concurrently with like status and counts 237 252 track_responses = await asyncio.gather( 238 253 *[ ··· 243 258 like_counts, 244 259 comment_counts, 245 260 track_tags=track_tags, 261 + viewer_did=viewer_did, 262 + supported_artist_dids=supported_artist_dids, 246 263 ) 247 264 for track in tracks 248 265 ]
+16
backend/src/backend/schemas.py
··· 72 72 ) 73 73 copyright_match: str | None = None # "Title by Artist" of primary match 74 74 support_gate: dict[str, Any] | None = None # supporter gating config 75 + gated: bool = False # true if track is gated AND viewer lacks access 75 76 76 77 @classmethod 77 78 async def from_track( ··· 83 84 comment_counts: dict[int, int] | None = None, 84 85 copyright_info: dict[int, CopyrightInfo] | None = None, 85 86 track_tags: dict[int, set[str]] | None = None, 87 + viewer_did: str | None = None, 88 + supported_artist_dids: set[str] | None = None, 86 89 ) -> "TrackResponse": 87 90 """build track response from Track model. 88 91 ··· 94 97 comment_counts: optional dict of track_id -> comment_count 95 98 copyright_info: optional dict of track_id -> CopyrightInfo 96 99 track_tags: optional dict of track_id -> set of tag names 100 + viewer_did: optional DID of the viewer (for gated content resolution) 101 + supported_artist_dids: optional set of artist DIDs the viewer supports 97 102 """ 98 103 # check if user has liked this track 99 104 is_liked = liked_track_ids is not None and track.id in liked_track_ids ··· 137 142 # get tags for this track 138 143 tags = track_tags.get(track.id, set()) if track_tags else set() 139 144 145 + # resolve gated status for viewer 146 + # gated = true only if track has support_gate AND viewer lacks access 147 + gated = False 148 + if track.support_gate: 149 + is_owner = viewer_did and viewer_did == track.artist_did 150 + is_supporter = ( 151 + supported_artist_dids and track.artist_did in supported_artist_dids 152 + ) 153 + gated = not (is_owner or is_supporter) 154 + 140 155 return cls( 141 156 id=track.id, 142 157 title=track.title, ··· 162 177 copyright_flagged=copyright_flagged, 163 178 copyright_match=copyright_match, 164 179 support_gate=track.support_gate, 180 + gated=gated, 165 181 )
+3 -1
docs/backend/configuration.md
··· 27 27 28 28 # storage settings (cloudflare r2) 29 29 settings.storage.backend # from STORAGE_BACKEND 30 - settings.storage.r2_bucket # from R2_BUCKET (audio files) 30 + settings.storage.r2_bucket # from R2_BUCKET (public audio files) 31 + settings.storage.r2_private_bucket # from R2_PRIVATE_BUCKET (gated audio files) 31 32 settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files) 32 33 settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL 33 34 settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files) ··· 84 85 # storage 85 86 STORAGE_BACKEND=r2 # or "filesystem" 86 87 R2_BUCKET=your-audio-bucket 88 + R2_PRIVATE_BUCKET=your-private-audio-bucket # for supporter-gated content 87 89 R2_IMAGE_BUCKET=your-image-bucket 88 90 R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com 89 91 R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
+1
docs/local-development/setup.md
··· 53 53 # storage (r2 or filesystem) 54 54 STORAGE_BACKEND=filesystem # or "r2" for cloudflare r2 55 55 R2_BUCKET=audio-dev 56 + R2_PRIVATE_BUCKET=audio-private-dev # for supporter-gated content 56 57 R2_IMAGE_BUCKET=images-dev 57 58 R2_ENDPOINT_URL=<your-r2-endpoint> 58 59 R2_PUBLIC_BUCKET_URL=<your-r2-public-url>
+7 -5
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 + import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; 11 11 12 12 interface Props { 13 13 track: Track; ··· 75 75 76 76 function addToQueue(e: Event) { 77 77 e.stopPropagation(); 78 + if (!guardGatedTrack(track, isAuthenticated)) return; 78 79 queue.addTracks([track]); 79 80 toast.success(`queued ${track.title}`, 1800); 80 81 } 81 82 82 83 function handleQueue() { 84 + if (!guardGatedTrack(track, isAuthenticated)) return; 83 85 queue.addTracks([track]); 84 86 toast.success(`queued ${track.title}`, 1800); 85 87 } ··· 137 139 return; 138 140 } 139 141 // use playTrack for gated content checks, fall back to onPlay for non-gated 140 - if (track.support_gate) { 142 + if (track.gated) { 141 143 await playTrack(track); 142 144 } else { 143 145 onPlay(track); 144 146 } 145 147 }} 146 148 > 147 - <div class="track-image-wrapper" class:gated={track.support_gate}> 149 + <div class="track-image-wrapper" class:gated={track.gated}> 148 150 {#if track.image_url && !trackImageError} 149 151 <SensitiveImage src={track.image_url}> 150 152 <div class="track-image"> ··· 184 186 </svg> 185 187 </div> 186 188 {/if} 187 - {#if track.support_gate} 189 + {#if track.gated} 188 190 <div class="gated-badge" title="supporters only"> 189 191 <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"/> 192 + <path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/> 191 193 </svg> 192 194 </div> 193 195 {/if}
+1 -1
frontend/src/lib/components/player/Player.svelte
··· 264 264 } 265 265 266 266 // for gated tracks, check authorization first 267 - if (track.support_gate) { 267 + if (track.gated) { 268 268 const response = await fetch(`${API_URL}/audio/${file_id}`, { 269 269 method: 'HEAD', 270 270 credentials: 'include'
+29 -2
frontend/src/lib/playback.svelte.ts
··· 25 25 */ 26 26 async function checkAccess(track: Track): Promise<GatedCheckResult> { 27 27 // non-gated tracks are always allowed 28 - if (!track.support_gate) { 28 + if (!track.gated) { 29 29 return { allowed: true }; 30 30 } 31 31 ··· 67 67 } 68 68 69 69 /** 70 - * show appropriate toast for denied access. 70 + * show appropriate toast for denied access (from HEAD request). 71 71 */ 72 72 function showDeniedToast(result: GatedCheckResult): void { 73 73 if (result.requiresAuth) { ··· 80 80 } else { 81 81 toast.info('this track is for supporters only'); 82 82 } 83 + } 84 + 85 + /** 86 + * show toast for gated track (using server-resolved status). 87 + */ 88 + function showGatedToast(track: Track, isAuthenticated: boolean): void { 89 + if (!isAuthenticated) { 90 + toast.info('sign in to play supporter-only tracks'); 91 + } else if (track.artist_did) { 92 + toast.info('this track is for supporters only', 5000, { 93 + label: 'become a supporter', 94 + href: getAtprotofansSupportUrl(track.artist_did) 95 + }); 96 + } else { 97 + toast.info('this track is for supporters only'); 98 + } 99 + } 100 + 101 + /** 102 + * check if track is accessible using server-resolved gated status. 103 + * shows toast if denied. no network call - instant feedback. 104 + * use this for queue adds and other non-playback operations. 105 + */ 106 + export function guardGatedTrack(track: Track, isAuthenticated: boolean): boolean { 107 + if (!track.gated) return true; 108 + showGatedToast(track, isAuthenticated); 109 + return false; 83 110 } 84 111 85 112 /**
+1
frontend/src/lib/types.ts
··· 56 56 copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged 57 57 copyright_match?: string | null; // "Title by Artist" of primary match 58 58 support_gate?: SupportGate | null; // if set, track requires supporter access 59 + gated?: boolean; // true if track is gated AND viewer lacks access 59 60 } 60 61 61 62 export interface User {
+1 -1
frontend/src/routes/portal/+page.svelte
··· 813 813 {#if track.support_gate} 814 814 <span class="support-gate-badge" title="supporters only"> 815 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"/> 816 + <path d="M18 8h-1V6c0-2.76-2.24-5-5-5S7 3.24 7 6v2H6c-1.1 0-2 .9-2 2v10c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V10c0-1.1-.9-2-2-2zm-6 9c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2zm3.1-9H8.9V6c0-1.71 1.39-3.1 3.1-3.1 1.71 0 3.1 1.39 3.1 3.1v2z"/> 817 817 </svg> 818 818 </span> 819 819 {/if}
+2 -2
frontend/src/routes/track/[id]/+page.svelte
··· 111 111 } else { 112 112 // different track or no track - start this one 113 113 // use playTrack for gated content checks 114 - if (track.support_gate) { 114 + if (track.gated) { 115 115 await playTrack(track); 116 116 } else { 117 117 queue.playNow(track); ··· 209 209 // otherwise start playing and wait for audio to be ready 210 210 // use playTrack for gated content checks 211 211 let played = false; 212 - if (track.support_gate) { 212 + if (track.gated) { 213 213 played = await playTrack(track); 214 214 } else { 215 215 queue.playNow(track);