feat: add offline mode foundation with auto-download liked tracks (#610)

* feat: add offline mode foundation with auto-download liked tracks

- add storage.ts with Cache API + IndexedDB for offline audio storage
- add GET /audio/{file_id}/url endpoint returning direct R2 URLs for caching
- add auto_download_liked preference (stored in localStorage, device-specific)
- add settings toggle that bulk-downloads all liked tracks when enabled
- auto-download new tracks when liking (if preference enabled)
- Player component checks for cached audio before streaming
- fix TLFM reauth notice to show correct message

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

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

* fix: require auth for audio URL endpoint

adds authentication to GET /audio/{file_id}/url to prevent
unauthenticated enumeration of audio URLs.

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

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

* fix: improve IndexedDB robustness

- close database connections after each operation (fixes connection leak)
- deduplicate concurrent downloads using in-flight promise map
- verify cache entry exists in isDownloaded() and clean up stale metadata

🤖 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 a8d74c40 425372b9

+50 -1
backend/src/backend/api/audio.py
··· 1 1 """audio streaming endpoint.""" 2 2 3 3 import logfire 4 - from fastapi import APIRouter, HTTPException 4 + from fastapi import APIRouter, Depends, HTTPException 5 5 from fastapi.responses import RedirectResponse 6 + from pydantic import BaseModel 6 7 from sqlalchemy import func, select 7 8 9 + from backend._internal import Session, require_auth 8 10 from backend.models import Track 9 11 from backend.storage import storage 10 12 from backend.utilities.database import db_session 11 13 12 14 router = APIRouter(prefix="/audio", tags=["audio"]) 15 + 16 + 17 + class AudioUrlResponse(BaseModel): 18 + """response containing direct R2 URL for offline caching.""" 19 + 20 + url: str 21 + file_id: str 22 + file_type: str | None 13 23 14 24 15 25 @router.get("/{file_id}") ··· 60 70 if not url: 61 71 raise HTTPException(status_code=404, detail="audio file not found") 62 72 return RedirectResponse(url=url) 73 + 74 + 75 + @router.get("/{file_id}/url") 76 + async def get_audio_url( 77 + file_id: str, 78 + session: Session = Depends(require_auth), 79 + ) -> AudioUrlResponse: 80 + """return direct R2 URL for offline caching. 81 + 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. 85 + 86 + used for offline mode - frontend fetches from R2 and stores locally. 87 + """ 88 + async with db_session() as db: 89 + result = await db.execute( 90 + select(Track.r2_url, Track.file_type) 91 + .where(Track.file_id == file_id) 92 + .order_by(Track.r2_url.is_not(None).desc(), Track.created_at.desc()) 93 + .limit(1) 94 + ) 95 + track_data = result.first() 96 + 97 + if not track_data: 98 + raise HTTPException(status_code=404, detail="audio file not found") 99 + 100 + r2_url, file_type = track_data 101 + 102 + # if we have a cached r2_url, return it 103 + if r2_url and r2_url.startswith("http"): 104 + return AudioUrlResponse(url=r2_url, file_id=file_id, file_type=file_type) 105 + 106 + # otherwise, resolve it 107 + url = await storage.get_url(file_id, file_type="audio", extension=file_type) 108 + if not url: 109 + raise HTTPException(status_code=404, detail="audio file not found") 110 + 111 + return AudioUrlResponse(url=url, file_id=file_id, file_type=file_type)
+136
backend/tests/api/test_audio.py
··· 7 7 from httpx import ASGITransport, AsyncClient 8 8 from sqlalchemy.ext.asyncio import AsyncSession 9 9 10 + from backend._internal import Session, require_auth 10 11 from backend.main import app 11 12 from backend.models import Artist, Track 13 + 14 + 15 + @pytest.fixture 16 + def mock_session() -> Session: 17 + """create a mock session for authenticated endpoints.""" 18 + return Session( 19 + session_id="test-session-id", 20 + did="did:plc:testuser123", 21 + handle="testuser.bsky.social", 22 + oauth_session={ 23 + "access_token": "test-access-token", 24 + "refresh_token": "test-refresh-token", 25 + "dpop_key": {}, 26 + }, 27 + ) 12 28 13 29 14 30 @pytest.fixture ··· 146 162 147 163 assert response.status_code == 404 148 164 assert response.json()["detail"] == "audio file not found" 165 + 166 + 167 + # tests for /audio/{file_id}/url endpoint (offline caching, requires auth) 168 + 169 + 170 + async def test_get_audio_url_with_cached_url( 171 + test_app: FastAPI, test_track_with_r2_url: Track, mock_session: Session 172 + ): 173 + """test that /url endpoint returns cached r2_url as JSON.""" 174 + mock_storage = MagicMock() 175 + mock_storage.get_url = AsyncMock() 176 + 177 + test_app.dependency_overrides[require_auth] = lambda: mock_session 178 + 179 + try: 180 + with patch("backend.api.audio.storage", mock_storage): 181 + async with AsyncClient( 182 + transport=ASGITransport(app=test_app), base_url="http://test" 183 + ) as client: 184 + response = await client.get( 185 + f"/audio/{test_track_with_r2_url.file_id}/url" 186 + ) 187 + 188 + assert response.status_code == 200 189 + data = response.json() 190 + assert data["url"] == test_track_with_r2_url.r2_url 191 + assert data["file_id"] == test_track_with_r2_url.file_id 192 + assert data["file_type"] == test_track_with_r2_url.file_type 193 + # should NOT call get_url when r2_url is cached 194 + mock_storage.get_url.assert_not_called() 195 + finally: 196 + test_app.dependency_overrides.pop(require_auth, None) 197 + 198 + 199 + async def test_get_audio_url_without_cached_url( 200 + test_app: FastAPI, test_track_without_r2_url: Track, mock_session: Session 201 + ): 202 + """test that /url endpoint calls storage.get_url when r2_url is None.""" 203 + expected_url = "https://cdn.example.com/audio/test456.flac" 204 + 205 + mock_storage = MagicMock() 206 + mock_storage.get_url = AsyncMock(return_value=expected_url) 207 + 208 + test_app.dependency_overrides[require_auth] = lambda: mock_session 209 + 210 + try: 211 + with patch("backend.api.audio.storage", mock_storage): 212 + async with AsyncClient( 213 + transport=ASGITransport(app=test_app), base_url="http://test" 214 + ) as client: 215 + response = await client.get( 216 + f"/audio/{test_track_without_r2_url.file_id}/url" 217 + ) 218 + 219 + assert response.status_code == 200 220 + data = response.json() 221 + assert data["url"] == expected_url 222 + assert data["file_id"] == test_track_without_r2_url.file_id 223 + assert data["file_type"] == test_track_without_r2_url.file_type 224 + 225 + mock_storage.get_url.assert_called_once_with( 226 + test_track_without_r2_url.file_id, 227 + file_type="audio", 228 + extension=test_track_without_r2_url.file_type, 229 + ) 230 + finally: 231 + test_app.dependency_overrides.pop(require_auth, None) 232 + 233 + 234 + async def test_get_audio_url_not_found(test_app: FastAPI, mock_session: Session): 235 + """test that /url endpoint returns 404 for nonexistent track.""" 236 + test_app.dependency_overrides[require_auth] = lambda: mock_session 237 + 238 + try: 239 + async with AsyncClient( 240 + transport=ASGITransport(app=test_app), base_url="http://test" 241 + ) as client: 242 + response = await client.get("/audio/nonexistent/url") 243 + 244 + assert response.status_code == 404 245 + assert response.json()["detail"] == "audio file not found" 246 + finally: 247 + test_app.dependency_overrides.pop(require_auth, None) 248 + 249 + 250 + async def test_get_audio_url_storage_returns_none( 251 + test_app: FastAPI, test_track_without_r2_url: Track, mock_session: Session 252 + ): 253 + """test that /url endpoint returns 404 when storage.get_url returns None.""" 254 + mock_storage = MagicMock() 255 + mock_storage.get_url = AsyncMock(return_value=None) 256 + 257 + test_app.dependency_overrides[require_auth] = lambda: mock_session 258 + 259 + try: 260 + with patch("backend.api.audio.storage", mock_storage): 261 + async with AsyncClient( 262 + transport=ASGITransport(app=test_app), base_url="http://test" 263 + ) as client: 264 + response = await client.get( 265 + f"/audio/{test_track_without_r2_url.file_id}/url" 266 + ) 267 + 268 + assert response.status_code == 404 269 + assert response.json()["detail"] == "audio file not found" 270 + finally: 271 + test_app.dependency_overrides.pop(require_auth, None) 272 + 273 + 274 + async def test_get_audio_url_requires_auth(test_app: FastAPI): 275 + """test that /url endpoint returns 401 without authentication.""" 276 + # ensure no auth override 277 + test_app.dependency_overrides.pop(require_auth, None) 278 + 279 + async with AsyncClient( 280 + transport=ASGITransport(app=test_app), base_url="http://test" 281 + ) as client: 282 + response = await client.get("/audio/somefile/url") 283 + 284 + assert response.status_code == 401
+3 -1
frontend/src/lib/components/AddToMenu.svelte
··· 9 9 trackTitle: string; 10 10 trackUri?: string; 11 11 trackCid?: string; 12 + fileId?: string; 12 13 initialLiked?: boolean; 13 14 disabled?: boolean; 14 15 disabledReason?: string; ··· 23 24 trackTitle, 24 25 trackUri, 25 26 trackCid, 27 + fileId, 26 28 initialLiked = false, 27 29 disabled = false, 28 30 disabledReason, ··· 100 102 101 103 try { 102 104 const success = liked 103 - ? await likeTrack(trackId) 105 + ? await likeTrack(trackId, fileId) 104 106 : await unlikeTrack(trackId); 105 107 106 108 if (!success) {
+3 -2
frontend/src/lib/components/LikeButton.svelte
··· 5 5 interface Props { 6 6 trackId: number; 7 7 trackTitle: string; 8 + fileId?: string; 8 9 initialLiked?: boolean; 9 10 disabled?: boolean; 10 11 disabledReason?: string; 11 12 onLikeChange?: (_liked: boolean) => void; 12 13 } 13 14 14 - let { trackId, trackTitle, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 15 + let { trackId, trackTitle, fileId, initialLiked = false, disabled = false, disabledReason, onLikeChange }: Props = $props(); 15 16 16 17 let liked = $state(initialLiked); 17 18 let loading = $state(false); ··· 34 35 35 36 try { 36 37 const success = liked 37 - ? await likeTrack(trackId) 38 + ? await likeTrack(trackId, fileId) 38 39 : await unlikeTrack(trackId); 39 40 40 41 if (!success) {
+3 -1
frontend/src/lib/components/TrackActionsMenu.svelte
··· 9 9 trackTitle: string; 10 10 trackUri?: string; 11 11 trackCid?: string; 12 + fileId?: string; 12 13 initialLiked: boolean; 13 14 shareUrl: string; 14 15 onQueue: () => void; ··· 22 23 trackTitle, 23 24 trackUri, 24 25 trackCid, 26 + fileId, 25 27 initialLiked, 26 28 shareUrl, 27 29 onQueue, ··· 99 101 100 102 try { 101 103 const success = liked 102 - ? await likeTrack(trackId) 104 + ? await likeTrack(trackId, fileId) 103 105 : await unlikeTrack(trackId); 104 106 105 107 if (!success) {
+7
frontend/src/lib/components/TrackItem.svelte
··· 284 284 trackTitle={track.title} 285 285 trackUri={track.atproto_record_uri} 286 286 trackCid={track.atproto_record_cid} 287 + fileId={track.file_id} 287 288 initialLiked={track.is_liked || false} 288 289 disabled={!track.atproto_record_uri} 289 290 disabledReason={!track.atproto_record_uri ? "track's record is unavailable" : undefined} ··· 316 317 trackTitle={track.title} 317 318 trackUri={track.atproto_record_uri} 318 319 trackCid={track.atproto_record_cid} 320 + fileId={track.file_id} 319 321 initialLiked={track.is_liked || false} 320 322 shareUrl={shareUrl} 321 323 onQueue={handleQueue} ··· 710 712 background: var(--bg-tertiary); 711 713 border-color: var(--accent); 712 714 color: var(--accent); 715 + } 716 + 717 + .action-button:disabled { 718 + opacity: 0.6; 719 + cursor: not-allowed; 713 720 } 714 721 715 722 .action-button svg {
+56 -7
frontend/src/lib/components/player/Player.svelte
··· 5 5 import { moderation } from '$lib/moderation.svelte'; 6 6 import { preferences } from '$lib/preferences.svelte'; 7 7 import { API_URL } from '$lib/config'; 8 + import { getCachedAudioUrl } from '$lib/storage'; 8 9 import { onMount } from 'svelte'; 9 10 import { page } from '$app/stores'; 10 11 import TrackInfo from './TrackInfo.svelte'; ··· 238 239 ); 239 240 }); 240 241 242 + // get audio source URL - checks local cache first, falls back to network 243 + async function getAudioSource(file_id: string): Promise<string> { 244 + try { 245 + const cachedUrl = await getCachedAudioUrl(file_id); 246 + if (cachedUrl) { 247 + return cachedUrl; 248 + } 249 + } catch (err) { 250 + console.error('failed to check audio cache:', err); 251 + } 252 + return `${API_URL}/audio/${file_id}`; 253 + } 254 + 255 + // track blob URLs we've created so we can revoke them 256 + let currentBlobUrl: string | null = null; 257 + 258 + function cleanupBlobUrl() { 259 + if (currentBlobUrl) { 260 + URL.revokeObjectURL(currentBlobUrl); 261 + currentBlobUrl = null; 262 + } 263 + } 264 + 241 265 // handle track changes - load new audio when track changes 242 266 let previousTrackId = $state<number | null>(null); 243 267 let isLoadingTrack = $state(false); ··· 247 271 248 272 // only load new track if it actually changed 249 273 if (player.currentTrack.id !== previousTrackId) { 250 - previousTrackId = player.currentTrack.id; 274 + const trackToLoad = player.currentTrack; 275 + previousTrackId = trackToLoad.id; 251 276 player.resetPlayCount(); 252 277 isLoadingTrack = true; 253 278 254 - player.audioElement.src = `${API_URL}/audio/${player.currentTrack.file_id}`; 255 - player.audioElement.load(); 279 + // cleanup previous blob URL before loading new track 280 + cleanupBlobUrl(); 256 281 257 - // wait for audio to be ready before allowing playback 258 - player.audioElement.addEventListener('loadeddata', () => { 259 - isLoadingTrack = false; 260 - }, { once: true }); 282 + // 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 287 + if (src.startsWith('blob:')) { 288 + URL.revokeObjectURL(src); 289 + } 290 + return; 291 + } 292 + 293 + // track if this is a blob URL so we can revoke it later 294 + if (src.startsWith('blob:')) { 295 + currentBlobUrl = src; 296 + } 297 + 298 + player.audioElement.src = src; 299 + player.audioElement.load(); 300 + 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 + }); 261 310 } 262 311 }); 263 312
+22 -3
frontend/src/lib/preferences.svelte.ts
··· 23 23 show_liked_on_profile: boolean; 24 24 support_url: string | null; 25 25 ui_settings: UiSettings; 26 + auto_download_liked: boolean; 26 27 } 27 28 28 29 const DEFAULT_PREFERENCES: Preferences = { ··· 36 37 show_sensitive_artwork: false, 37 38 show_liked_on_profile: false, 38 39 support_url: null, 39 - ui_settings: {} 40 + ui_settings: {}, 41 + auto_download_liked: false 40 42 }; 41 43 42 44 class PreferencesManager { ··· 92 94 return this.data?.ui_settings ?? DEFAULT_PREFERENCES.ui_settings; 93 95 } 94 96 97 + get autoDownloadLiked(): boolean { 98 + return this.data?.auto_download_liked ?? DEFAULT_PREFERENCES.auto_download_liked; 99 + } 100 + 101 + setAutoDownloadLiked(enabled: boolean): void { 102 + if (browser) { 103 + localStorage.setItem('autoDownloadLiked', enabled ? '1' : '0'); 104 + } 105 + if (this.data) { 106 + this.data = { ...this.data, auto_download_liked: enabled }; 107 + } 108 + } 109 + 95 110 setTheme(theme: Theme): void { 96 111 if (browser) { 97 112 localStorage.setItem('theme', theme); ··· 134 149 }); 135 150 if (response.ok) { 136 151 const data = await response.json(); 152 + // auto_download_liked is stored locally since it's device-specific 153 + const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1'; 137 154 this.data = { 138 155 accent_color: data.accent_color ?? null, 139 156 auto_advance: data.auto_advance ?? DEFAULT_PREFERENCES.auto_advance, ··· 145 162 show_sensitive_artwork: data.show_sensitive_artwork ?? DEFAULT_PREFERENCES.show_sensitive_artwork, 146 163 show_liked_on_profile: data.show_liked_on_profile ?? DEFAULT_PREFERENCES.show_liked_on_profile, 147 164 support_url: data.support_url ?? DEFAULT_PREFERENCES.support_url, 148 - ui_settings: data.ui_settings ?? DEFAULT_PREFERENCES.ui_settings 165 + ui_settings: data.ui_settings ?? DEFAULT_PREFERENCES.ui_settings, 166 + auto_download_liked: storedAutoDownload 149 167 }; 150 168 } else { 151 - this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme }; 169 + const storedAutoDownload = localStorage.getItem('autoDownloadLiked') === '1'; 170 + this.data = { ...DEFAULT_PREFERENCES, theme: currentTheme, auto_download_liked: storedAutoDownload }; 152 171 } 153 172 // apply theme after fetching 154 173 if (browser) {
+328
frontend/src/lib/storage.ts
··· 1 + /** 2 + * offline storage layer for plyr.fm 3 + * 4 + * uses Cache API for audio bytes (large binary files, quota-managed) 5 + * uses IndexedDB for download metadata (queryable, persistent) 6 + * 7 + * this module bypasses the service worker entirely - we fetch directly 8 + * from R2 and store in Cache API from the main thread. this avoids 9 + * iOS PWA issues with service worker + redirects + range requests. 10 + */ 11 + 12 + /* eslint-disable no-undef */ 13 + 14 + import { API_URL } from './config'; 15 + 16 + // cache name for audio files 17 + const AUDIO_CACHE_NAME = 'plyr-audio-v1'; 18 + 19 + // IndexedDB config 20 + const DB_NAME = 'plyr-offline'; 21 + const DB_VERSION = 1; 22 + const DOWNLOADS_STORE = 'downloads'; 23 + 24 + // in-flight downloads (prevents duplicate concurrent downloads) 25 + const activeDownloads = new Map<string, Promise<void>>(); 26 + 27 + export interface DownloadRecord { 28 + file_id: string; 29 + size: number; 30 + downloaded_at: number; 31 + file_type: string | null; 32 + } 33 + 34 + // IndexedDB helpers 35 + 36 + function openDatabase(): Promise<IDBDatabase> { 37 + return new Promise((resolve, reject) => { 38 + const request = indexedDB.open(DB_NAME, DB_VERSION); 39 + 40 + request.onerror = () => reject(request.error); 41 + request.onsuccess = () => resolve(request.result); 42 + 43 + request.onupgradeneeded = (event) => { 44 + const db = (event.target as IDBOpenDBRequest).result; 45 + 46 + if (!db.objectStoreNames.contains(DOWNLOADS_STORE)) { 47 + const store = db.createObjectStore(DOWNLOADS_STORE, { keyPath: 'file_id' }); 48 + store.createIndex('downloaded_at', 'downloaded_at', { unique: false }); 49 + } 50 + }; 51 + }); 52 + } 53 + 54 + /** 55 + * run a transaction against IndexedDB, properly closing the connection after 56 + */ 57 + async function withDatabase<T>( 58 + mode: IDBTransactionMode, 59 + operation: (store: IDBObjectStore) => IDBRequest<T> 60 + ): Promise<T> { 61 + const db = await openDatabase(); 62 + try { 63 + return await new Promise<T>((resolve, reject) => { 64 + const tx = db.transaction(DOWNLOADS_STORE, mode); 65 + const store = tx.objectStore(DOWNLOADS_STORE); 66 + const request = operation(store); 67 + 68 + request.onerror = () => reject(request.error); 69 + request.onsuccess = () => resolve(request.result); 70 + }); 71 + } finally { 72 + db.close(); 73 + } 74 + } 75 + 76 + async function getDownloadRecord(file_id: string): Promise<DownloadRecord | null> { 77 + const result = await withDatabase('readonly', (store) => store.get(file_id)); 78 + return result || null; 79 + } 80 + 81 + async function setDownloadRecord(record: DownloadRecord): Promise<void> { 82 + await withDatabase('readwrite', (store) => store.put(record)); 83 + } 84 + 85 + async function deleteDownloadRecord(file_id: string): Promise<void> { 86 + await withDatabase('readwrite', (store) => store.delete(file_id)); 87 + } 88 + 89 + /** 90 + * get all download records from IndexedDB 91 + */ 92 + export async function getAllDownloads(): Promise<DownloadRecord[]> { 93 + return withDatabase('readonly', (store) => store.getAll()); 94 + } 95 + 96 + // Cache API helpers 97 + 98 + /** 99 + * get the cache key for an audio file 100 + * Cache API requires http/https URLs, so we use a fake but valid URL 101 + */ 102 + function getCacheKey(file_id: string): string { 103 + return `https://plyr.fm/_offline/${file_id}`; 104 + } 105 + 106 + /** 107 + * check if audio is cached locally 108 + * verifies both metadata (IndexedDB) and actual audio (Cache API) exist 109 + */ 110 + export async function isDownloaded(file_id: string): Promise<boolean> { 111 + const record = await getDownloadRecord(file_id); 112 + if (!record) return false; 113 + 114 + // also verify the cache entry exists (handles edge case where cache was 115 + // cleared but IndexedDB wasn't) 116 + try { 117 + const cache = await caches.open(AUDIO_CACHE_NAME); 118 + const cached = await cache.match(getCacheKey(file_id)); 119 + if (!cached) { 120 + // cache is gone but metadata exists - clean up the stale record 121 + await deleteDownloadRecord(file_id); 122 + return false; 123 + } 124 + return true; 125 + } catch { 126 + return false; 127 + } 128 + } 129 + 130 + /** 131 + * get cached audio as a blob URL for playback 132 + * returns null if not cached 133 + */ 134 + export async function getCachedAudioUrl(file_id: string): Promise<string | null> { 135 + try { 136 + const cache = await caches.open(AUDIO_CACHE_NAME); 137 + const response = await cache.match(getCacheKey(file_id)); 138 + 139 + if (!response) { 140 + return null; 141 + } 142 + 143 + const blob = await response.blob(); 144 + return URL.createObjectURL(blob); 145 + } catch (error) { 146 + console.error('failed to get cached audio:', error); 147 + return null; 148 + } 149 + } 150 + 151 + /** 152 + * download audio file and cache it locally 153 + * 154 + * 1. fetch presigned URL from backend 155 + * 2. fetch audio from R2 directly 156 + * 3. store in Cache API 157 + * 4. record in IndexedDB 158 + * 159 + * if a download for this file_id is already in progress, returns 160 + * the existing promise (prevents duplicate downloads from concurrent calls) 161 + */ 162 + export async function downloadAudio( 163 + file_id: string, 164 + onProgress?: (loaded: number, total: number) => void 165 + ): Promise<void> { 166 + // if already downloading this file, return existing promise 167 + const existing = activeDownloads.get(file_id); 168 + if (existing) return existing; 169 + 170 + const downloadPromise = (async () => { 171 + try { 172 + await performDownload(file_id, onProgress); 173 + } finally { 174 + activeDownloads.delete(file_id); 175 + } 176 + })(); 177 + 178 + activeDownloads.set(file_id, downloadPromise); 179 + return downloadPromise; 180 + } 181 + 182 + async function performDownload( 183 + file_id: string, 184 + onProgress?: (loaded: number, total: number) => void 185 + ): Promise<void> { 186 + // 1. get presigned URL from backend 187 + const urlResponse = await fetch(`${API_URL}/audio/${file_id}/url`, { 188 + credentials: 'include' 189 + }); 190 + 191 + if (!urlResponse.ok) { 192 + throw new Error(`failed to get audio URL: ${urlResponse.status}`); 193 + } 194 + 195 + const { url, file_type } = await urlResponse.json(); 196 + 197 + // 2. fetch audio from R2 198 + const audioResponse = await fetch(url); 199 + 200 + if (!audioResponse.ok) { 201 + throw new Error(`failed to fetch audio: ${audioResponse.status}`); 202 + } 203 + 204 + // get total size for progress tracking 205 + const contentLength = audioResponse.headers.get('content-length'); 206 + const total = contentLength ? parseInt(contentLength, 10) : 0; 207 + 208 + // read the response as a blob, tracking progress if callback provided 209 + let blob: Blob; 210 + 211 + if (onProgress && audioResponse.body && total > 0) { 212 + const reader = audioResponse.body.getReader(); 213 + const chunks: BlobPart[] = []; 214 + let loaded = 0; 215 + 216 + while (true) { 217 + const { done, value } = await reader.read(); 218 + if (done) break; 219 + 220 + chunks.push(value); 221 + loaded += value.length; 222 + onProgress(loaded, total); 223 + } 224 + 225 + blob = new Blob(chunks, { 226 + type: audioResponse.headers.get('content-type') || 'audio/mpeg' 227 + }); 228 + } else { 229 + blob = await audioResponse.blob(); 230 + } 231 + 232 + // 3. store in Cache API 233 + const cache = await caches.open(AUDIO_CACHE_NAME); 234 + const cacheResponse = new Response(blob, { 235 + headers: { 236 + 'content-type': blob.type, 237 + 'content-length': blob.size.toString() 238 + } 239 + }); 240 + await cache.put(getCacheKey(file_id), cacheResponse); 241 + 242 + // 4. record in IndexedDB 243 + await setDownloadRecord({ 244 + file_id, 245 + size: blob.size, 246 + downloaded_at: Date.now(), 247 + file_type 248 + }); 249 + } 250 + 251 + /** 252 + * remove downloaded audio from cache and IndexedDB 253 + */ 254 + export async function removeDownload(file_id: string): Promise<void> { 255 + // remove from Cache API 256 + const cache = await caches.open(AUDIO_CACHE_NAME); 257 + await cache.delete(getCacheKey(file_id)); 258 + 259 + // remove from IndexedDB 260 + await deleteDownloadRecord(file_id); 261 + } 262 + 263 + /** 264 + * get storage usage estimate 265 + * returns bytes used and quota 266 + */ 267 + export async function getStorageUsage(): Promise<{ used: number; quota: number }> { 268 + if ('storage' in navigator && 'estimate' in navigator.storage) { 269 + const estimate = await navigator.storage.estimate(); 270 + return { 271 + used: estimate.usage || 0, 272 + quota: estimate.quota || 0 273 + }; 274 + } 275 + 276 + // fallback: sum up our download records 277 + const downloads = await getAllDownloads(); 278 + const used = downloads.reduce((sum, d) => sum + d.size, 0); 279 + 280 + return { used, quota: 0 }; 281 + } 282 + 283 + /** 284 + * clear all downloaded audio 285 + */ 286 + export async function clearAllDownloads(): Promise<void> { 287 + // clear Cache API 288 + await caches.delete(AUDIO_CACHE_NAME); 289 + 290 + // clear IndexedDB 291 + await withDatabase('readwrite', (store) => store.clear()); 292 + } 293 + 294 + /** 295 + * download all liked tracks that aren't already cached 296 + * returns the number of tracks that were downloaded 297 + */ 298 + export async function downloadAllLikedTracks(): Promise<number> { 299 + // fetch liked tracks from API 300 + const response = await fetch(`${API_URL}/tracks/liked`, { 301 + credentials: 'include' 302 + }); 303 + 304 + if (!response.ok) { 305 + throw new Error(`failed to fetch liked tracks: ${response.status}`); 306 + } 307 + 308 + const data = await response.json(); 309 + const tracks = data.tracks as { file_id: string }[]; 310 + 311 + let downloadedCount = 0; 312 + 313 + // download each track that isn't already cached 314 + for (const track of tracks) { 315 + try { 316 + const alreadyDownloaded = await isDownloaded(track.file_id); 317 + if (!alreadyDownloaded) { 318 + await downloadAudio(track.file_id); 319 + downloadedCount++; 320 + } 321 + } catch (err) { 322 + console.error(`failed to download track ${track.file_id}:`, err); 323 + // continue with other tracks 324 + } 325 + } 326 + 327 + return downloadedCount; 328 + }
+18 -1
frontend/src/lib/tracks.svelte.ts
··· 1 1 import { API_URL } from './config'; 2 2 import type { Track } from './types'; 3 + import { preferences } from './preferences.svelte'; 4 + import { downloadAudio, isDownloaded } from './storage'; 3 5 4 6 interface TracksApiResponse { 5 7 tracks: Track[]; ··· 131 133 export const tracksCache = new TracksCache(); 132 134 133 135 // like/unlike track functions 134 - export async function likeTrack(trackId: number): Promise<boolean> { 136 + export async function likeTrack(trackId: number, fileId?: string): Promise<boolean> { 135 137 try { 136 138 const response = await fetch(`${API_URL}/tracks/${trackId}/like`, { 137 139 method: 'POST', ··· 144 146 145 147 // invalidate cache so next fetch gets updated like status 146 148 tracksCache.invalidate(); 149 + 150 + // auto-download if preference is enabled and file_id provided 151 + if (fileId && preferences.autoDownloadLiked) { 152 + try { 153 + const alreadyDownloaded = await isDownloaded(fileId); 154 + if (!alreadyDownloaded) { 155 + // download in background, don't await 156 + downloadAudio(fileId).catch((err) => { 157 + console.error('auto-download failed:', err); 158 + }); 159 + } 160 + } catch (err) { 161 + console.error('failed to check/download:', err); 162 + } 163 + } 147 164 148 165 return true; 149 166 } catch (e) {
+8 -2
frontend/src/routes/+layout.ts
··· 23 23 show_sensitive_artwork: false, 24 24 show_liked_on_profile: false, 25 25 support_url: null, 26 - ui_settings: {} 26 + ui_settings: {}, 27 + auto_download_liked: false 27 28 }; 28 29 29 30 export async function load({ fetch, data }: LoadEvent): Promise<LayoutData> { ··· 54 55 }); 55 56 if (prefsResponse.ok) { 56 57 const prefsData = await prefsResponse.json(); 58 + // auto_download_liked is stored locally, not on server 59 + const storedAutoDownload = typeof localStorage !== 'undefined' 60 + ? localStorage.getItem('autoDownloadLiked') === '1' 61 + : false; 57 62 preferences = { 58 63 accent_color: prefsData.accent_color ?? null, 59 64 auto_advance: prefsData.auto_advance ?? true, ··· 65 70 show_sensitive_artwork: prefsData.show_sensitive_artwork ?? false, 66 71 show_liked_on_profile: prefsData.show_liked_on_profile ?? false, 67 72 support_url: prefsData.support_url ?? null, 68 - ui_settings: prefsData.ui_settings ?? {} 73 + ui_settings: prefsData.ui_settings ?? {}, 74 + auto_download_liked: storedAutoDownload 69 75 }; 70 76 } 71 77 } catch (e) {
+40 -1
frontend/src/routes/settings/+page.svelte
··· 24 24 let backgroundImageUrl = $derived(preferences.uiSettings.background_image_url ?? ''); 25 25 let backgroundTile = $derived(preferences.uiSettings.background_tile ?? false); 26 26 let usePlayingArtwork = $derived(preferences.uiSettings.use_playing_artwork_as_background ?? false); 27 + let autoDownloadLiked = $derived(preferences.autoDownloadLiked); 27 28 // developer token state 28 29 let creatingToken = $state(false); 29 30 let developerToken = $state<string | null>(null); ··· 190 191 queue.setAutoAdvance(value); 191 192 localStorage.setItem('autoAdvance', value ? '1' : '0'); 192 193 await preferences.update({ auto_advance: value }); 194 + } 195 + 196 + function handleAutoDownloadToggle(enabled: boolean) { 197 + preferences.setAutoDownloadLiked(enabled); 198 + 199 + if (enabled) { 200 + // start downloading existing liked tracks in background (non-blocking) 201 + toast.success('downloading liked tracks in background...'); 202 + import('$lib/storage').then(({ downloadAllLikedTracks }) => { 203 + downloadAllLikedTracks().then((count) => { 204 + if (count > 0) { 205 + toast.success(`downloaded ${count} track${count === 1 ? '' : 's'} for offline`); 206 + } else { 207 + toast.success('all liked tracks already downloaded'); 208 + } 209 + }).catch((err) => { 210 + console.error('failed to download liked tracks:', err); 211 + toast.error('failed to download some tracks'); 212 + }); 213 + }); 214 + } else { 215 + toast.success('auto-download disabled'); 216 + } 193 217 } 194 218 195 219 // preferences ··· 515 539 <span class="toggle-slider"></span> 516 540 </label> 517 541 </div> 542 + 543 + <div class="setting-row"> 544 + <div class="setting-info"> 545 + <h3>auto-download liked</h3> 546 + <p>automatically download tracks for offline playback when you like them</p> 547 + </div> 548 + <label class="toggle-switch"> 549 + <input 550 + type="checkbox" 551 + checked={autoDownloadLiked} 552 + onchange={(e) => handleAutoDownloadToggle((e.target as HTMLInputElement).checked)} 553 + /> 554 + <span class="toggle-slider"></span> 555 + </label> 556 + </div> 518 557 </div> 519 558 </section> 520 559 ··· 603 642 <circle cx="12" cy="12" r="10" /> 604 643 <path d="M12 16v-4M12 8h.01" /> 605 644 </svg> 606 - <span>toggle on to connect teal.fm scrobbling</span> 645 + <span>authorization expired — disable and re-enable to reconnect</span> 607 646 </div> 608 647 {/if} 609 648 </div>