audio streaming app plyr.fm
38
fork

Configure Feed

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

feat(frontend): replace audio file from track edit form (#1312)

UI for the new PUT /tracks/{id}/audio endpoint shipped in #1311. Lets
artists swap a track's audio without deleting + re-uploading (and losing
likes / comments / plays / the URL).

how it's wired:
- uploader.replaceAudio(trackId, file, title, onComplete) mirrors the
existing uploader.upload XHR + SSE flow but PUTs to a track-specific
endpoint with just the file. progress is surfaced via the same toast
pattern as the initial upload.
- portal/+page.svelte edit form gains an "audio file" section next to
the existing "artwork" section. picker → "replace audio" button →
picker clears immediately and the SSE flow continues in the toast.
- Player.svelte's track-load $effect now also fires on file_id change,
not just track id change. so when the currently-playing track gets a
new audio file, the <audio> element src reloads in place. on
successful replace, we fetch the fresh track row and reassign
player.currentTrack so the effect picks up the new file_id.

deliberately separate from the metadata "save changes" flow because
the upload + transcode + PDS write can take 30s+ and has its own SSE
progress; conflating it with the fast PATCH would block the form.

manual smoke depends on backend being deployed to staging.

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

authored by

nate nowack
Claude Opus 4 (1M context)
and committed by
GitHub
3b0352e2 f069e543

+388 -4
+9 -2
frontend/src/lib/components/player/Player.svelte
··· 313 313 314 314 // handle track changes - load new audio when track changes 315 315 let previousTrackId = $state<number | null>(null); 316 + // also tracked so an in-place audio replace (same track id, new file_id) 317 + // triggers a fresh load — see PUT /tracks/{id}/audio in the portal edit form. 318 + let previousFileId = $state<string | null>(null); 316 319 let isLoadingTrack = $state(false); 317 320 318 321 $effect(() => { 319 322 if (!player.currentTrack || !player.audioElement) return; 320 323 321 - // only load new track if it actually changed 322 - if (player.currentTrack.id !== previousTrackId) { 324 + // reload when either the track id changes (navigation/queue advance) or 325 + // the file_id changes for the same track (audio replaced from edit form). 326 + const trackChanged = player.currentTrack.id !== previousTrackId; 327 + const fileChanged = player.currentTrack.file_id !== previousFileId; 328 + if (trackChanged || fileChanged) { 323 329 const trackToLoad = player.currentTrack; 324 330 325 331 // update tracking state 326 332 previousTrackId = trackToLoad.id; 333 + previousFileId = trackToLoad.file_id; 327 334 player.resetPlayCount(); 328 335 isLoadingTrack = true; 329 336
+150
frontend/src/lib/uploader.svelte.ts
··· 295 295 xhr.timeout = 300000; 296 296 xhr.send(formData); 297 297 } 298 + 299 + /** 300 + * Replace the audio bytes for an existing track via PUT /tracks/{id}/audio. 301 + * 302 + * Mirrors the upload() flow (XHR upload → SSE progress) but: 303 + * - PUTs to a track-specific endpoint instead of POSTing 304 + * - Sends only the file (no metadata — the track keeps its title, tags, etc.) 305 + * - On completion, calls onComplete with the new file_id so the caller can 306 + * refresh local caches and the player. 307 + */ 308 + replaceAudio( 309 + trackId: number, 310 + file: File, 311 + title: string, 312 + onComplete?: (_result: { trackId: number; atprotoCid: string | null }) => void 313 + ): void { 314 + if (!browser) return; 315 + 316 + const taskId = crypto.randomUUID(); 317 + const fileSizeMB = file.size / 1024 / 1024; 318 + const isMobile = isMobileDevice(); 319 + 320 + if (isMobile && fileSizeMB > MOBILE_LARGE_FILE_THRESHOLD_MB) { 321 + toast.info(`replacing audio: ${Math.round(fileSizeMB)}MB on mobile - ensure stable connection`, 5000); 322 + } 323 + 324 + const startMessage = fileSizeMB > 10 325 + ? `replacing audio for "${title}"... (large file)` 326 + : `replacing audio for "${title}"...`; 327 + const toastId = toast.info(startMessage, 0); 328 + 329 + let lastProgressPercent = 0; 330 + const formData = new FormData(); 331 + formData.append('file', file); 332 + 333 + const xhr = new XMLHttpRequest(); 334 + xhr.open('PUT', `${API_URL}/tracks/${trackId}/audio`); 335 + xhr.withCredentials = true; 336 + 337 + let uploadComplete = false; 338 + 339 + xhr.upload.addEventListener('progress', (e) => { 340 + if (e.lengthComputable && !uploadComplete) { 341 + const percent = Math.round((e.loaded / e.total) * 100); 342 + lastProgressPercent = percent; 343 + toast.update(toastId, `uploading new audio... ${percent}%`); 344 + } 345 + }); 346 + 347 + xhr.addEventListener('load', () => { 348 + if (xhr.status >= 200 && xhr.status < 300) { 349 + try { 350 + uploadComplete = true; 351 + const result = JSON.parse(xhr.responseText); 352 + const upload_id = result.upload_id; 353 + 354 + const task: UploadTask = { 355 + id: taskId, 356 + upload_id, 357 + file, 358 + title, 359 + toastId, 360 + xhr 361 + }; 362 + this.activeUploads.set(taskId, task); 363 + 364 + const eventSource = new EventSource(`${API_URL}/tracks/uploads/${upload_id}/progress`); 365 + task.eventSource = eventSource; 366 + 367 + eventSource.onmessage = (event) => { 368 + const update = JSON.parse(event.data); 369 + 370 + if (update.message && update.status === 'processing') { 371 + const serverProgress = update.server_progress_pct; 372 + if (serverProgress !== undefined && serverProgress !== null && serverProgress > 0) { 373 + toast.update(task.toastId, `${update.message} (${Math.round(serverProgress)}%)`); 374 + } else { 375 + toast.update(task.toastId, update.message); 376 + } 377 + } 378 + 379 + if (update.status === 'completed') { 380 + eventSource.close(); 381 + toast.dismiss(task.toastId); 382 + this.activeUploads.delete(taskId); 383 + 384 + toast.success(`audio for "${title}" replaced`, 5000); 385 + tracksCache.invalidate(); 386 + tracksCache.fetch(true); 387 + 388 + onComplete?.({ 389 + trackId, 390 + atprotoCid: update.atproto_cid ?? null 391 + }); 392 + } 393 + 394 + if (update.status === 'failed') { 395 + eventSource.close(); 396 + toast.dismiss(task.toastId); 397 + this.activeUploads.delete(taskId); 398 + toast.error(update.error || 'audio replace failed'); 399 + } 400 + }; 401 + 402 + eventSource.onerror = () => { 403 + eventSource.close(); 404 + toast.dismiss(task.toastId); 405 + this.activeUploads.delete(taskId); 406 + toast.error('lost connection during audio replace'); 407 + }; 408 + } catch { 409 + toast.dismiss(toastId); 410 + toast.error('failed to parse server response'); 411 + } 412 + } else { 413 + toast.dismiss(toastId); 414 + let errorMsg = `audio replace failed (${xhr.status} ${xhr.statusText})`; 415 + try { 416 + const error = JSON.parse(xhr.responseText); 417 + errorMsg = error.detail || errorMsg; 418 + } catch { 419 + if (xhr.status === 0) { 420 + errorMsg = buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile); 421 + } else if (xhr.status === 413) { 422 + errorMsg = 'file too large: please use a smaller file'; 423 + } else if (xhr.status === 403) { 424 + errorMsg = "you can only replace audio on your own tracks"; 425 + } else if (xhr.status === 404) { 426 + errorMsg = 'track not found'; 427 + } else if (xhr.status >= 500) { 428 + errorMsg = 'server error: please try again in a moment'; 429 + } 430 + } 431 + toast.error(errorMsg); 432 + } 433 + }); 434 + 435 + xhr.addEventListener('error', () => { 436 + toast.dismiss(toastId); 437 + toast.error(buildNetworkErrorMessage(lastProgressPercent, fileSizeMB, isMobile)); 438 + }); 439 + 440 + xhr.addEventListener('timeout', () => { 441 + toast.dismiss(toastId); 442 + toast.error(buildTimeoutErrorMessage(lastProgressPercent, fileSizeMB, isMobile)); 443 + }); 444 + 445 + xhr.timeout = 300000; 446 + xhr.send(formData); 447 + } 298 448 } 299 449 300 450 export const uploader = new UploaderState();
+228 -1
frontend/src/routes/portal/+page.svelte
··· 13 13 import PdsBackfillControl from '$lib/components/PdsBackfillControl.svelte'; 14 14 import type { Track, FeaturedArtist, AlbumSummary, Playlist } from '$lib/types'; 15 15 import SensitiveImage from '$lib/components/SensitiveImage.svelte'; 16 - import { API_URL } from '$lib/config'; 16 + import { API_URL, getServerConfig } from '$lib/config'; 17 17 import { toast } from '$lib/toast.svelte'; 18 18 import { auth } from '$lib/auth.svelte'; 19 + import { uploader } from '$lib/uploader.svelte'; 20 + import { player } from '$lib/player.svelte'; 19 21 import { preferences } from '$lib/preferences.svelte'; import { getReturnUrl, clearReturnUrl } from '$lib/utils/return-url'; 22 + 23 + // supported audio formats — matches backend AudioFormat enum + AlbumUploadForm 24 + const AUDIO_FILE_INPUT_ACCEPT = '.mp3,.wav,.m4a,.aiff,.aif,.flac,audio/mpeg,audio/wav,audio/mp4,audio/aiff,audio/x-aiff,audio/flac'; 20 25 let loading = $state(true); 21 26 let error = $state(''); 22 27 let tracks = $state<Track[]>([]); ··· 34 39 let editImageFile = $state<File | null>(null); 35 40 let editImagePreviewUrl = $state<string | null>(null); 36 41 let editRemoveImage = $state(false); 42 + // audio replace state — separate flow from metadata edit because the 43 + // upload + transcode + PDS write can take 30s+ and has its own SSE progress 44 + // (surfaced via toast, not inline). 45 + let editAudioFile = $state<File | null>(null); 37 46 let editSupportGate = $state(false); 38 47 let editUnlisted = $state(false); 39 48 let hasUnresolvedEditFeaturesInput = $state(false); ··· 446 455 editRemoveImage = false; 447 456 editSupportGate = false; 448 457 editUnlisted = false; 458 + editAudioFile = null; 449 459 recommendedTags = []; 450 460 loadingRecommendedTags = false; 451 461 recommendedTagsTrackId = null; 452 462 } 453 463 464 + async function selectAudioReplacement(file: File) { 465 + // validate against the same upload-size limit as the upload form. fetch 466 + // dynamically because the limit comes from server config. 467 + try { 468 + const config = await getServerConfig(); 469 + const sizeMB = file.size / (1024 * 1024); 470 + if (sizeMB > config.max_upload_size_mb) { 471 + toast.error(`audio file exceeds ${config.max_upload_size_mb}MB limit`); 472 + return; 473 + } 474 + } catch { 475 + // config fetch failed — fall back to letting the server enforce 476 + } 477 + editAudioFile = file; 478 + } 479 + 480 + function replaceAudio(track: typeof tracks[0]) { 481 + if (!editAudioFile) return; 482 + const file = editAudioFile; 483 + const trackId = track.id; 484 + const trackTitle = track.title; 485 + 486 + // kick off the background upload+SSE flow. progress and outcome are 487 + // surfaced via toast — same pattern as the initial upload form. 488 + uploader.replaceAudio(trackId, file, trackTitle, async () => { 489 + // refresh local tracks so the row reflects the new file_id and r2_url 490 + await loadMyTracks(); 491 + 492 + // if the user is currently playing this track, fetch the fresh row 493 + // and assign it to player.currentTrack — Player.svelte's $effect now 494 + // watches file_id, so a reassign with the new file_id reloads the 495 + // <audio> element src in place. 496 + if (player.currentTrack?.id === trackId) { 497 + try { 498 + const resp = await fetch(`${API_URL}/tracks/${trackId}`, { 499 + credentials: 'include' 500 + }); 501 + if (resp.ok) { 502 + const fresh = await resp.json(); 503 + player.currentTrack = { ...player.currentTrack, ...fresh }; 504 + } 505 + } catch { 506 + // best effort — next track navigation will pick up the new src 507 + } 508 + } 509 + }); 510 + 511 + // clear the picker immediately; the SSE flow continues in the toast. 512 + // keep the rest of the edit form open in case the user has other 513 + // unsaved metadata changes. 514 + editAudioFile = null; 515 + } 516 + 454 517 455 518 async function saveTrackEdit(trackId: number) { 456 519 const formData = new FormData(); ··· 1002 1065 {track.image_url || editImagePreviewUrl ? 'replace' : 'upload'} 1003 1066 </label> 1004 1067 {/if} 1068 + </div> 1069 + </div> 1070 + <div class="edit-field-group"> 1071 + <span class="edit-label">audio file</span> 1072 + <div class="audio-replace-editor"> 1073 + {#if editAudioFile} 1074 + <div class="audio-selected"> 1075 + <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1076 + <path d="M9 18V5l12-2v13"></path> 1077 + <circle cx="6" cy="18" r="3"></circle> 1078 + <circle cx="18" cy="16" r="3"></circle> 1079 + </svg> 1080 + <span class="audio-filename">{editAudioFile.name}</span> 1081 + <button 1082 + type="button" 1083 + class="audio-clear-btn" 1084 + onclick={() => { editAudioFile = null; }} 1085 + title="discard selection" 1086 + > 1087 + <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1088 + <line x1="18" y1="6" x2="6" y2="18"></line> 1089 + <line x1="6" y1="6" x2="18" y2="18"></line> 1090 + </svg> 1091 + </button> 1092 + </div> 1093 + <button 1094 + type="button" 1095 + class="audio-replace-btn" 1096 + onclick={() => replaceAudio(track)} 1097 + > 1098 + replace audio 1099 + </button> 1100 + {:else} 1101 + <div class="audio-current"> 1102 + <span class="audio-current-label">current: {track.file_type}</span> 1103 + </div> 1104 + <label class="audio-upload-btn"> 1105 + <input 1106 + type="file" 1107 + accept={AUDIO_FILE_INPUT_ACCEPT} 1108 + onchange={(e) => { 1109 + const target = e.target as HTMLInputElement; 1110 + const file = target.files?.[0]; 1111 + if (file) { 1112 + void selectAudioReplacement(file); 1113 + } 1114 + target.value = ''; 1115 + }} 1116 + /> 1117 + <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> 1118 + <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path> 1119 + <polyline points="17 8 12 3 7 8"></polyline> 1120 + <line x1="12" y1="3" x2="12" y2="15"></line> 1121 + </svg> 1122 + choose new file 1123 + </label> 1124 + {/if} 1125 + <p class="audio-replace-hint"> 1126 + likes, comments, plays, and the track URL all stay the same. updates the audio file only. 1127 + </p> 1005 1128 </div> 1006 1129 </div> 1007 1130 {#if atprotofansEligible || track.support_gate} ··· 2516 2639 2517 2640 .artwork-upload-btn input { 2518 2641 display: none; 2642 + } 2643 + 2644 + .audio-replace-editor { 2645 + display: flex; 2646 + flex-wrap: wrap; 2647 + align-items: center; 2648 + gap: 0.6rem; 2649 + } 2650 + 2651 + .audio-current { 2652 + flex: 1 1 auto; 2653 + min-width: 0; 2654 + } 2655 + 2656 + .audio-current-label { 2657 + font-size: var(--text-sm); 2658 + color: var(--text-muted); 2659 + } 2660 + 2661 + .audio-selected { 2662 + display: inline-flex; 2663 + align-items: center; 2664 + gap: 0.5rem; 2665 + padding: 0.4rem 0.65rem; 2666 + background: color-mix(in srgb, var(--accent) 8%, transparent); 2667 + border: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); 2668 + border-radius: var(--radius-md); 2669 + color: var(--accent); 2670 + font-size: var(--text-sm); 2671 + max-width: 100%; 2672 + min-width: 0; 2673 + flex: 1 1 auto; 2674 + } 2675 + 2676 + .audio-filename { 2677 + overflow: hidden; 2678 + text-overflow: ellipsis; 2679 + white-space: nowrap; 2680 + min-width: 0; 2681 + } 2682 + 2683 + .audio-clear-btn { 2684 + display: inline-flex; 2685 + align-items: center; 2686 + justify-content: center; 2687 + padding: 0.15rem; 2688 + background: transparent; 2689 + border: none; 2690 + color: var(--accent); 2691 + opacity: 0.7; 2692 + cursor: pointer; 2693 + transition: opacity 0.15s; 2694 + margin-left: auto; 2695 + } 2696 + 2697 + .audio-clear-btn:hover { 2698 + opacity: 1; 2699 + } 2700 + 2701 + .audio-upload-btn { 2702 + display: inline-flex; 2703 + align-items: center; 2704 + gap: 0.4rem; 2705 + padding: 0.5rem 0.85rem; 2706 + background: transparent; 2707 + border: 1px solid var(--accent); 2708 + border-radius: var(--radius-full); 2709 + color: var(--accent); 2710 + font-size: var(--text-sm); 2711 + font-weight: 500; 2712 + cursor: pointer; 2713 + transition: all 0.15s; 2714 + margin-left: auto; 2715 + } 2716 + 2717 + .audio-upload-btn:hover { 2718 + background: color-mix(in srgb, var(--accent) 12%, transparent); 2719 + } 2720 + 2721 + .audio-upload-btn input { 2722 + display: none; 2723 + } 2724 + 2725 + .audio-replace-btn { 2726 + padding: 0.5rem 0.95rem; 2727 + background: var(--accent); 2728 + border: 1px solid var(--accent); 2729 + border-radius: var(--radius-full); 2730 + color: var(--bg); 2731 + font-size: var(--text-sm); 2732 + font-weight: 600; 2733 + cursor: pointer; 2734 + transition: filter 0.15s; 2735 + } 2736 + 2737 + .audio-replace-btn:hover { 2738 + filter: brightness(1.1); 2739 + } 2740 + 2741 + .audio-replace-hint { 2742 + flex-basis: 100%; 2743 + margin: 0.35rem 0 0; 2744 + font-size: var(--text-xs); 2745 + color: var(--text-muted); 2519 2746 } 2520 2747 2521 2748 .edit-input:focus {
+1 -1
loq.toml
··· 156 156 157 157 [[rules]] 158 158 path = "frontend/src/routes/portal/+page.svelte" 159 - max_lines = 3489 159 + max_lines = 3716 160 160 161 161 [[rules]] 162 162 path = "frontend/src/routes/settings/+page.svelte"