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 from backend._internal.notifications import notification_service 33 from backend._internal.now_playing import now_playing_service 34 from backend._internal.queue import queue_service 35 - from backend._internal.atprotofans import validate_supporter 36 37 __all__ = [ 38 "DeveloperToken", ··· 52 "get_pending_dev_token", 53 "get_pending_scope_upgrade", 54 "get_session", 55 "handle_oauth_callback", 56 "list_developer_tokens", 57 "notification_service",
··· 32 from backend._internal.notifications import notification_service 33 from backend._internal.now_playing import now_playing_service 34 from backend._internal.queue import queue_service 35 + from backend._internal.atprotofans import get_supported_artists, validate_supporter 36 37 __all__ = [ 38 "DeveloperToken", ··· 52 "get_pending_dev_token", 53 "get_pending_scope_upgrade", 54 "get_session", 55 + "get_supported_artists", 56 "handle_oauth_callback", 57 "list_developer_tokens", 58 "notification_service",
+28
backend/src/backend/_internal/atprotofans.py
··· 14 see: https://atprotofans.leaflet.pub/3mabsmts3rs2b 15 """ 16 17 import httpx 18 import logfire 19 from pydantic import BaseModel ··· 91 exc_info=True, 92 ) 93 return SupporterValidation(valid=False)
··· 14 see: https://atprotofans.leaflet.pub/3mabsmts3rs2b 15 """ 16 17 + import asyncio 18 + 19 import httpx 20 import logfire 21 from pydantic import BaseModel ··· 93 exc_info=True, 94 ) 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 from sqlalchemy.orm import selectinload 13 14 from backend._internal import Session as AuthSession 15 - from backend._internal import get_optional_session, require_auth 16 from backend.config import settings 17 from backend.models import ( 18 Artist, ··· 233 await asyncio.gather(*[resolve_image(t) for t in tracks_needing_images]) 234 await db.commit() 235 236 # fetch all track responses concurrently with like status and counts 237 track_responses = await asyncio.gather( 238 *[ ··· 243 like_counts, 244 comment_counts, 245 track_tags=track_tags, 246 ) 247 for track in tracks 248 ]
··· 12 from sqlalchemy.orm import selectinload 13 14 from backend._internal import Session as AuthSession 15 + from backend._internal import get_optional_session, get_supported_artists, require_auth 16 from backend.config import settings 17 from backend.models import ( 18 Artist, ··· 233 await asyncio.gather(*[resolve_image(t) for t in tracks_needing_images]) 234 await db.commit() 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 + 251 # fetch all track responses concurrently with like status and counts 252 track_responses = await asyncio.gather( 253 *[ ··· 258 like_counts, 259 comment_counts, 260 track_tags=track_tags, 261 + viewer_did=viewer_did, 262 + supported_artist_dids=supported_artist_dids, 263 ) 264 for track in tracks 265 ]
+16
backend/src/backend/schemas.py
··· 72 ) 73 copyright_match: str | None = None # "Title by Artist" of primary match 74 support_gate: dict[str, Any] | None = None # supporter gating config 75 76 @classmethod 77 async def from_track( ··· 83 comment_counts: dict[int, int] | None = None, 84 copyright_info: dict[int, CopyrightInfo] | None = None, 85 track_tags: dict[int, set[str]] | None = None, 86 ) -> "TrackResponse": 87 """build track response from Track model. 88 ··· 94 comment_counts: optional dict of track_id -> comment_count 95 copyright_info: optional dict of track_id -> CopyrightInfo 96 track_tags: optional dict of track_id -> set of tag names 97 """ 98 # check if user has liked this track 99 is_liked = liked_track_ids is not None and track.id in liked_track_ids ··· 137 # get tags for this track 138 tags = track_tags.get(track.id, set()) if track_tags else set() 139 140 return cls( 141 id=track.id, 142 title=track.title, ··· 162 copyright_flagged=copyright_flagged, 163 copyright_match=copyright_match, 164 support_gate=track.support_gate, 165 )
··· 72 ) 73 copyright_match: str | None = None # "Title by Artist" of primary match 74 support_gate: dict[str, Any] | None = None # supporter gating config 75 + gated: bool = False # true if track is gated AND viewer lacks access 76 77 @classmethod 78 async def from_track( ··· 84 comment_counts: dict[int, int] | None = None, 85 copyright_info: dict[int, CopyrightInfo] | None = None, 86 track_tags: dict[int, set[str]] | None = None, 87 + viewer_did: str | None = None, 88 + supported_artist_dids: set[str] | None = None, 89 ) -> "TrackResponse": 90 """build track response from Track model. 91 ··· 97 comment_counts: optional dict of track_id -> comment_count 98 copyright_info: optional dict of track_id -> CopyrightInfo 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 102 """ 103 # check if user has liked this track 104 is_liked = liked_track_ids is not None and track.id in liked_track_ids ··· 142 # get tags for this track 143 tags = track_tags.get(track.id, set()) if track_tags else set() 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 + 155 return cls( 156 id=track.id, 157 title=track.title, ··· 177 copyright_flagged=copyright_flagged, 178 copyright_match=copyright_match, 179 support_gate=track.support_gate, 180 + gated=gated, 181 )
+3 -1
docs/backend/configuration.md
··· 27 28 # storage settings (cloudflare r2) 29 settings.storage.backend # from STORAGE_BACKEND 30 - settings.storage.r2_bucket # from R2_BUCKET (audio files) 31 settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files) 32 settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL 33 settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files) ··· 84 # storage 85 STORAGE_BACKEND=r2 # or "filesystem" 86 R2_BUCKET=your-audio-bucket 87 R2_IMAGE_BUCKET=your-image-bucket 88 R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com 89 R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
··· 27 28 # storage settings (cloudflare r2) 29 settings.storage.backend # from STORAGE_BACKEND 30 + settings.storage.r2_bucket # from R2_BUCKET (public audio files) 31 + settings.storage.r2_private_bucket # from R2_PRIVATE_BUCKET (gated audio files) 32 settings.storage.r2_image_bucket # from R2_IMAGE_BUCKET (image files) 33 settings.storage.r2_endpoint_url # from R2_ENDPOINT_URL 34 settings.storage.r2_public_bucket_url # from R2_PUBLIC_BUCKET_URL (audio files) ··· 85 # storage 86 STORAGE_BACKEND=r2 # or "filesystem" 87 R2_BUCKET=your-audio-bucket 88 + R2_PRIVATE_BUCKET=your-private-audio-bucket # for supporter-gated content 89 R2_IMAGE_BUCKET=your-image-bucket 90 R2_ENDPOINT_URL=https://xxx.r2.cloudflarestorage.com 91 R2_PUBLIC_BUCKET_URL=https://pub-xxx.r2.dev # for audio files
+1
docs/local-development/setup.md
··· 53 # storage (r2 or filesystem) 54 STORAGE_BACKEND=filesystem # or "r2" for cloudflare r2 55 R2_BUCKET=audio-dev 56 R2_IMAGE_BUCKET=images-dev 57 R2_ENDPOINT_URL=<your-r2-endpoint> 58 R2_PUBLIC_BUCKET_URL=<your-r2-public-url>
··· 53 # storage (r2 or filesystem) 54 STORAGE_BACKEND=filesystem # or "r2" for cloudflare r2 55 R2_BUCKET=audio-dev 56 + R2_PRIVATE_BUCKET=audio-private-dev # for supporter-gated content 57 R2_IMAGE_BUCKET=images-dev 58 R2_ENDPOINT_URL=<your-r2-endpoint> 59 R2_PUBLIC_BUCKET_URL=<your-r2-public-url>
+7 -5
frontend/src/lib/components/TrackItem.svelte
··· 7 import type { Track } from '$lib/types'; 8 import { queue } from '$lib/queue.svelte'; 9 import { toast } from '$lib/toast.svelte'; 10 - import { playTrack } from '$lib/playback.svelte'; 11 12 interface Props { 13 track: Track; ··· 75 76 function addToQueue(e: Event) { 77 e.stopPropagation(); 78 queue.addTracks([track]); 79 toast.success(`queued ${track.title}`, 1800); 80 } 81 82 function handleQueue() { 83 queue.addTracks([track]); 84 toast.success(`queued ${track.title}`, 1800); 85 } ··· 137 return; 138 } 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 } 145 }} 146 > 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"> ··· 184 </svg> 185 </div> 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}
··· 7 import type { Track } from '$lib/types'; 8 import { queue } from '$lib/queue.svelte'; 9 import { toast } from '$lib/toast.svelte'; 10 + import { playTrack, guardGatedTrack } from '$lib/playback.svelte'; 11 12 interface Props { 13 track: Track; ··· 75 76 function addToQueue(e: Event) { 77 e.stopPropagation(); 78 + if (!guardGatedTrack(track, isAuthenticated)) return; 79 queue.addTracks([track]); 80 toast.success(`queued ${track.title}`, 1800); 81 } 82 83 function handleQueue() { 84 + if (!guardGatedTrack(track, isAuthenticated)) return; 85 queue.addTracks([track]); 86 toast.success(`queued ${track.title}`, 1800); 87 } ··· 139 return; 140 } 141 // use playTrack for gated content checks, fall back to onPlay for non-gated 142 + if (track.gated) { 143 await playTrack(track); 144 } else { 145 onPlay(track); 146 } 147 }} 148 > 149 + <div class="track-image-wrapper" class:gated={track.gated}> 150 {#if track.image_url && !trackImageError} 151 <SensitiveImage src={track.image_url}> 152 <div class="track-image"> ··· 186 </svg> 187 </div> 188 {/if} 189 + {#if track.gated} 190 <div class="gated-badge" title="supporters only"> 191 <svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"> 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"/> 193 </svg> 194 </div> 195 {/if}
+1 -1
frontend/src/lib/components/player/Player.svelte
··· 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'
··· 264 } 265 266 // for gated tracks, check authorization first 267 + if (track.gated) { 268 const response = await fetch(`${API_URL}/audio/${file_id}`, { 269 method: 'HEAD', 270 credentials: 'include'
+29 -2
frontend/src/lib/playback.svelte.ts
··· 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 ··· 67 } 68 69 /** 70 - * show appropriate toast for denied access. 71 */ 72 function showDeniedToast(result: GatedCheckResult): void { 73 if (result.requiresAuth) { ··· 80 } else { 81 toast.info('this track is for supporters only'); 82 } 83 } 84 85 /**
··· 25 */ 26 async function checkAccess(track: Track): Promise<GatedCheckResult> { 27 // non-gated tracks are always allowed 28 + if (!track.gated) { 29 return { allowed: true }; 30 } 31 ··· 67 } 68 69 /** 70 + * show appropriate toast for denied access (from HEAD request). 71 */ 72 function showDeniedToast(result: GatedCheckResult): void { 73 if (result.requiresAuth) { ··· 80 } else { 81 toast.info('this track is for supporters only'); 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; 110 } 111 112 /**
+1
frontend/src/lib/types.ts
··· 56 copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged 57 copyright_match?: string | null; // "Title by Artist" of primary match 58 support_gate?: SupportGate | null; // if set, track requires supporter access 59 } 60 61 export interface User {
··· 56 copyright_flagged?: boolean | null; // null = not scanned, false = clear, true = flagged 57 copyright_match?: string | null; // "Title by Artist" of primary match 58 support_gate?: SupportGate | null; // if set, track requires supporter access 59 + gated?: boolean; // true if track is gated AND viewer lacks access 60 } 61 62 export interface User {
+1 -1
frontend/src/routes/portal/+page.svelte
··· 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}
··· 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="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 </svg> 818 </span> 819 {/if}
+2 -2
frontend/src/routes/track/[id]/+page.svelte
··· 111 } else { 112 // different track or no track - start this one 113 // use playTrack for gated content checks 114 - if (track.support_gate) { 115 await playTrack(track); 116 } else { 117 queue.playNow(track); ··· 209 // otherwise start playing and wait for audio to be ready 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);
··· 111 } else { 112 // different track or no track - start this one 113 // use playTrack for gated content checks 114 + if (track.gated) { 115 await playTrack(track); 116 } else { 117 queue.playNow(track); ··· 209 // otherwise start playing and wait for audio to be ready 210 // use playTrack for gated content checks 211 let played = false; 212 + if (track.gated) { 213 played = await playTrack(track); 214 } else { 215 queue.playNow(track);