audio streaming app plyr.fm
38
fork

Configure Feed

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

feat: unlisted tracks — exclude from discovery feeds (#1267)

Adds an `unlisted` boolean to Track (default false) that lets artists
opt specific tracks out of the latest, top, and for-you feeds while
keeping them accessible everywhere else (direct link, artist profile,
albums, playlists, search).

Backend:
- migration: add `unlisted` column (boolean, NOT NULL, DEFAULT false)
- list_tracks (GET /tracks/): filter unlisted on the discovery feed
(no artist_did param). artist profile pages (has artist_did) show all
tracks including unlisted
- list_top_tracks (GET /tracks/top): exclude unlisted
- get_for_you_feed (GET /for-you/): exclude unlisted from candidate
pool at the artist-did lookup step
- upload_track (POST /tracks/): new `unlisted` form field
- update_track_metadata (PATCH /tracks/{id}): new `unlisted` form field
- TrackResponse schema: includes `unlisted` in the response

Frontend:
- types.ts: Track type includes `unlisted?: boolean`
- uploader.svelte.ts: accepts `unlisted` param, appends to FormData
- upload page: checkbox "unlisted — won't appear in feeds" with hint
- portal track editor: same checkbox, pre-filled from track state

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

authored by

nate nowack
Claude Opus 4.6 (1M context)
and committed by
GitHub
2daffd52 05fb4d93

+118 -9
+30
backend/alembic/versions/2026_04_10_104404_9eda586624d0_add_unlisted_column_to_tracks.py
··· 1 + """add unlisted column to tracks 2 + 3 + Revision ID: 9eda586624d0 4 + Revises: 4e5638f6576a 5 + Create Date: 2026-04-10 10:44:04.227029 6 + 7 + """ 8 + 9 + import sqlalchemy as sa 10 + 11 + from alembic import op 12 + 13 + # revision identifiers, used by Alembic. 14 + revision: str = "9eda586624d0" 15 + down_revision: str | None = "4e5638f6576a" 16 + branch_labels: str | None = None 17 + depends_on: str | None = None 18 + 19 + 20 + def upgrade() -> None: 21 + """Add unlisted boolean column to tracks table.""" 22 + op.add_column( 23 + "tracks", 24 + sa.Column("unlisted", sa.Boolean(), nullable=False, server_default="false"), 25 + ) 26 + 27 + 28 + def downgrade() -> None: 29 + """Remove unlisted column from tracks table.""" 30 + op.drop_column("tracks", "unlisted")
+4 -1
backend/src/backend/api/for_you.py
··· 308 308 top_ids = [tid for tid, _ in top_slice] 309 309 artist_rows = ( 310 310 await db.execute( 311 - select(Track.id, Track.artist_did).where(Track.id.in_(top_ids)) 311 + select(Track.id, Track.artist_did).where( 312 + Track.id.in_(top_ids), 313 + Track.unlisted == False, # noqa: E712 314 + ) 312 315 ) 313 316 ).all() 314 317 artist_by_track = {row.id: row.artist_did for row in artist_rows}
+6 -3
backend/src/backend/api/tracks/listing.py
··· 137 137 # filter by artist if provided 138 138 if artist_did: 139 139 stmt = stmt.where(Track.artist_did == artist_did) 140 + else: 141 + # discovery feed: exclude unlisted tracks (artist pages show all) 142 + stmt = stmt.where(Track.unlisted == False) # noqa: E712 140 143 141 144 # filter out tracks with hidden tags 142 145 # when filter_hidden_tags is None (default), auto-decide: ··· 361 364 362 365 top_track_ids = [tid for tid, _ in top_tracks_with_counts] 363 366 364 - # fetch tracks with relationships 367 + # fetch tracks with relationships, excluding unlisted 365 368 stmt = ( 366 369 select(Track) 367 370 .join(Artist) 368 371 .options(selectinload(Track.artist), selectinload(Track.album_rel)) 369 - .where(Track.id.in_(top_track_ids)) 372 + .where(Track.id.in_(top_track_ids), Track.unlisted == False) # noqa: E712 370 373 ) 371 374 result = await db.execute(stmt) 372 375 tracks_by_id = {track.id: track for track in result.scalars().all()} 373 376 374 - # preserve order from top_track_ids 377 + # preserve order from top_track_ids (unlisted tracks silently dropped) 375 378 tracks = [tracks_by_id[tid] for tid in top_track_ids if tid in tracks_by_id] 376 379 377 380 # get authenticated user's liked tracks (scoped to current track IDs only)
+8
backend/src/backend/api/tracks/mutations.py
··· 174 174 str | None, 175 175 Form(description="Set to 'true' to remove artwork"), 176 176 ] = None, 177 + unlisted: Annotated[ 178 + str | None, 179 + Form(description="Set to 'true' to exclude from feeds, 'false' to include"), 180 + ] = None, 177 181 ) -> TrackResponse: 178 182 """Update track metadata (only by owner).""" 179 183 result = await db.execute( ··· 235 239 ) from e 236 240 except ValueError as e: 237 241 raise HTTPException(status_code=400, detail=str(e)) from e 242 + 243 + # handle unlisted toggle 244 + if unlisted is not None: 245 + track.unlisted = unlisted.lower() == "true" 238 246 239 247 # track album changes for list sync 240 248 old_album_id = track.album_id
+11
backend/src/backend/api/tracks/uploads.py
··· 103 103 # auto-apply recommended genre tags after classification 104 104 auto_tag: bool = False 105 105 106 + # visibility: unlisted tracks don't appear in discovery feeds 107 + unlisted: bool = False 108 + 106 109 107 110 @dataclass 108 111 class AudioInfo: ··· 719 722 image_url=image_url, 720 723 thumbnail_url=thumbnail_url, 721 724 support_gate=ctx.support_gate, 725 + unlisted=ctx.unlisted, 722 726 audio_storage=audio_storage, 723 727 pds_blob_cid=pds_result.cid if pds_result else None, 724 728 pds_blob_size=pds_result.size if pds_result else None, ··· 996 1000 str | None, 997 1001 Form(description="auto-apply recommended genre tags after classification"), 998 1002 ] = None, 1003 + unlisted: Annotated[ 1004 + str | None, 1005 + Form( 1006 + description="set to 'true' to exclude from discovery feeds (latest, top, for-you)" 1007 + ), 1008 + ] = None, 999 1009 file: UploadFile = File(...), 1000 1010 image: UploadFile | None = File(None), 1001 1011 ) -> UploadStartResponse: ··· 1140 1150 image_content_type=image_content_type, 1141 1151 support_gate=parsed_support_gate, 1142 1152 auto_tag=auto_tag == "true", 1153 + unlisted=unlisted == "true", 1143 1154 ) 1144 1155 background_tasks.add_task(_process_upload_background, ctx) 1145 1156 except Exception:
+7
backend/src/backend/models/track.py
··· 101 101 image_url: Mapped[str | None] = mapped_column(String, nullable=True) 102 102 thumbnail_url: Mapped[str | None] = mapped_column(String, nullable=True) 103 103 104 + # visibility — unlisted tracks don't appear in discovery feeds (latest, 105 + # top, for-you) but are still accessible via direct link, artist profile, 106 + # album pages, playlists, and search 107 + unlisted: Mapped[bool] = mapped_column( 108 + nullable=False, default=False, server_default="false" 109 + ) 110 + 104 111 # notification tracking 105 112 notification_sent: Mapped[bool] = mapped_column( 106 113 nullable=False, default=False, server_default="false"
+2
backend/src/backend/schemas.py
··· 122 122 description: str | None = None # track description (liner notes, show notes) 123 123 audio_storage: str = "r2" # "r2" | "pds" | "both" 124 124 pds_blob_cid: str | None = None # CID if stored on user's PDS 125 + unlisted: bool = False # excluded from discovery feeds 125 126 126 127 @classmethod 127 128 async def from_track( ··· 233 234 original_file_type=track.original_file_type, 234 235 audio_storage=track.audio_storage, 235 236 pds_blob_cid=track.pds_blob_cid, 237 + unlisted=track.unlisted, 236 238 )
+1
frontend/src/lib/types.ts
··· 64 64 description?: string | null; // track description (liner notes, show notes) 65 65 audio_storage?: 'r2' | 'pds' | 'both'; // where audio is stored 66 66 pds_blob_cid?: string | null; // CID if stored on user's PDS 67 + unlisted?: boolean; // excluded from discovery feeds 67 68 } 68 69 69 70 export interface LinkedAccount {
+5 -1
frontend/src/lib/uploader.svelte.ts
··· 81 81 onSuccess?: (_result?: UploadResult) => void, 82 82 callbacks?: UploadProgressCallback, 83 83 label?: string, 84 - albumId?: string 84 + albumId?: string, 85 + unlisted?: boolean 85 86 ): void { 86 87 const taskId = crypto.randomUUID(); 87 88 const fileSizeMB = file.size / 1024 / 1024; ··· 129 130 } 130 131 if (description) { 131 132 formData.append('description', description); 133 + } 134 + if (unlisted) { 135 + formData.append('unlisted', 'true'); 132 136 } 133 137 134 138 const xhr = new XMLHttpRequest();
+19
frontend/src/routes/portal/+page.svelte
··· 35 35 let editImagePreviewUrl = $state<string | null>(null); 36 36 let editRemoveImage = $state(false); 37 37 let editSupportGate = $state(false); 38 + let editUnlisted = $state(false); 38 39 let hasUnresolvedEditFeaturesInput = $state(false); 39 40 let recommendedTags = $state<{name: string; score: number}[]>([]); 40 41 let loadingRecommendedTags = $state(false); ··· 405 406 editFeaturedArtists = track.features || []; 406 407 editTags = track.tags || []; 407 408 editSupportGate = track.support_gate !== null && track.support_gate !== undefined; 409 + editUnlisted = track.unlisted ?? false; 408 410 fetchRecommendedTags(track.id); 409 411 } 410 412 ··· 443 445 editImagePreviewUrl = null; 444 446 editRemoveImage = false; 445 447 editSupportGate = false; 448 + editUnlisted = false; 446 449 recommendedTags = []; 447 450 loadingRecommendedTags = false; 448 451 recommendedTagsTrackId = null; ··· 469 472 } else { 470 473 formData.append('support_gate', 'null'); 471 474 } 475 + formData.append('unlisted', editUnlisted ? 'true' : 'false'); 472 476 // handle artwork: remove, replace, or leave unchanged 473 477 if (editRemoveImage) { 474 478 formData.append('remove_image', 'true'); ··· 1017 1021 {/if} 1018 1022 </div> 1019 1023 {/if} 1024 + <div class="edit-field-group"> 1025 + <span class="edit-label">visibility</span> 1026 + <label class="toggle-row"> 1027 + <input 1028 + type="checkbox" 1029 + bind:checked={editUnlisted} 1030 + /> 1031 + <span>unlisted — won't appear in feeds</span> 1032 + </label> 1033 + {#if editUnlisted} 1034 + <p class="field-hint"> 1035 + this track won't show up in the latest, top, or for-you feeds. it's still accessible via direct link, your profile, albums, playlists, and search. 1036 + </p> 1037 + {/if} 1038 + </div> 1020 1039 </div> 1021 1040 <div class="edit-actions"> 1022 1041 <button
+21
frontend/src/routes/upload/+page.svelte
··· 61 61 let attestedRights = $state(false); 62 62 let supportGated = $state(false); 63 63 let autoTag = $state(false); 64 + let trackUnlisted = $state(false); 64 65 65 66 // albums for selection 66 67 let albums = $state<AlbumSummary[]>([]); ··· 125 126 const isGated = supportGated; 126 127 const shouldAutoTag = autoTag; 127 128 const uploadDescription = description; 129 + const isUnlisted = trackUnlisted; 128 130 129 131 const clearForm = () => { 130 132 title = ""; ··· 137 139 attestedRights = false; 138 140 supportGated = false; 139 141 autoTag = false; 142 + trackUnlisted = false; 140 143 141 144 const fileInput = document.getElementById( 142 145 "file-input", ··· 167 170 }, 168 171 onError: () => {}, 169 172 }, 173 + undefined, // label 174 + undefined, // albumId 175 + isUnlisted, 170 176 ); 171 177 } 172 178 ··· 407 413 want to offer exclusive tracks to supporters? <a href="https://atprotofans.com" target="_blank" rel="noopener">set up atprotofans</a>, then enable it in your <a href="/portal">portal</a> 408 414 </span> 409 415 </div> 416 + {/if} 417 + </div> 418 + 419 + <div class="form-group"> 420 + <label class="checkbox-label"> 421 + <input 422 + type="checkbox" 423 + bind:checked={trackUnlisted} 424 + /> 425 + <span class="checkbox-text">unlisted — won't appear in feeds</span> 426 + </label> 427 + {#if trackUnlisted} 428 + <p class="field-hint"> 429 + this track won't show up in the latest, top, or for-you feeds. it's still accessible via direct link, your profile, albums, playlists, and search. 430 + </p> 410 431 {/if} 411 432 </div> 412 433
+4 -4
loq.toml
··· 40 40 41 41 [[rules]] 42 42 path = "backend/src/backend/api/tracks/listing.py" 43 - max_lines = 581 43 + max_lines = 584 44 44 45 45 [[rules]] 46 46 path = "backend/src/backend/api/tracks/mutations.py" ··· 48 48 49 49 [[rules]] 50 50 path = "backend/src/backend/api/tracks/uploads.py" 51 - max_lines = 1223 51 + max_lines = 1234 52 52 53 53 [[rules]] 54 54 path = "backend/src/backend/config.py" ··· 164 164 165 165 [[rules]] 166 166 path = "frontend/src/routes/portal/+page.svelte" 167 - max_lines = 3470 167 + max_lines = 3489 168 168 169 169 [[rules]] 170 170 path = "frontend/src/routes/settings/+page.svelte" ··· 184 184 185 185 [[rules]] 186 186 path = "frontend/src/routes/upload/+page.svelte" 187 - max_lines = 798 187 + max_lines = 819 188 188 189 189 [[rules]] 190 190 path = "services/moderation/src/admin.rs"