diff --git a/frontend/js/favorites.js b/frontend/js/favorites.js index 64cce58..79ba011 100644 --- a/frontend/js/favorites.js +++ b/frontend/js/favorites.js @@ -16,13 +16,9 @@ App.favorites = App.favorites || {}; const raw = localStorage.getItem(FAVORITES_KEY); const parsed = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) return []; - // Two things are repaired on the way in, and written back once if + // One thing is repaired on the way in, and written back once if // anything changed, so the fix happens exactly one time: // - // `meta`, from older versions, is a blob of resolved formats whose - // URLs are signed and long expired -- dropped so no code path can - // reach for one; everything re-resolves from `url` at play time. - // // `favoriteDate` didn't exist before sorting needed it. There's no // way to recover when an old favorite was actually saved, so it // gets now: they sort together, as one batch, at the point the @@ -31,10 +27,9 @@ App.favorites = App.favorites || {}; const now = new Date().toISOString(); const items = parsed.map((item) => { if (!item || typeof item !== 'object') return item; - if (!item.meta && item.favoriteDate) return item; + if (item.favoriteDate) return item; const clean = Object.assign({}, item); - delete clean.meta; - if (!clean.favoriteDate) clean.favoriteDate = now; + clean.favoriteDate = now; repaired = true; return clean; }); @@ -103,6 +98,18 @@ App.favorites = App.favorites || {}; return video.key || meta.key || video.id || meta.id || video.url || meta.url || null; }; + const formatsOf = function(video, meta) { + const src = Array.isArray(video.formats) && video.formats.length ? video + : (meta && Array.isArray(meta.formats) && meta.formats.length ? meta : null); + if (!src) return undefined; + return { + url: src.url || video.url || '', + formats: src.formats, + http_headers: src.http_headers || {}, + isLive: !!(src.isLive || video.isLive) + }; + }; + App.favorites.normalize = function(video) { const key = App.favorites.getKey(video); if (!key) return null; @@ -110,9 +117,7 @@ App.favorites = App.favorites || {}; return { key, id: video.id || null, - // The page/source URL (e.g. the YouTube watch URL), not a resolved - // CDN media URL -- those expire, so favorites must always re-resolve - // via the server at play time instead of caching a stream link. + // The page/source URL (e.g. the YouTube watch URL). url: video.url || (meta && meta.url) || '', title: video.title || '', thumb: video.thumb || '', @@ -122,13 +127,10 @@ App.favorites = App.favorites || {}; isLive: !!(video.isLive || (meta && meta.isLive)), // When it was saved. An import carries the date the other client // recorded; anything saved here is saved now. - favoriteDate: video.favoriteDate || new Date().toISOString() - // No `meta` field: persisting resolved formats would freeze their - // (expiring) CDN URLs into localStorage. Leaving it unset makes a - // favorite look like a fresh, unresolved listing item again, so - // playback/download/info all re-resolve through the backend from - // `url` -- see resolveStreamSources' no-formats fallback, which the - // backend resolves live via yt-dlp (main.py stream_video). + favoriteDate: video.favoriteDate || new Date().toISOString(), + // The formats the video was saved with, so it plays without being + // resolved again. Only when there are some. + meta: formatsOf(video, meta) }; }; @@ -251,7 +253,7 @@ App.favorites = App.favorites || {}; 'uploader', 'duration', 'isLive', 'favoriteDate']; const sameEntry = function(a, b) { - return ENTRY_FIELDS.every((field) => + return JSON.stringify(a.meta || null) === JSON.stringify(b.meta || null) && ENTRY_FIELDS.every((field) => String(a[field] === undefined || a[field] === null ? '' : a[field]) === String(b[field] === undefined || b[field] === null ? '' : b[field])); }; @@ -499,9 +501,6 @@ App.favorites = App.favorites || {}; card.onclick = () => { if (card.classList.contains('is-loading')) return; card.classList.add('is-loading'); - // Ignore any stale `meta` a favorite saved before this fix may - // still carry in localStorage -- always re-resolve from `item` - // (id/url) so playback never reuses an expired stream URL. App.player.open(item, { originEl: card }); }; const favoriteBtn = card.querySelector('.favorite-btn'); @@ -525,9 +524,6 @@ App.favorites = App.favorites || {}; showInfoBtn.onclick = (event) => { event.stopPropagation(); App.videos.closeAllMenus(); - // Favorites deliberately store no resolved metadata; the - // panel opens on what the entry holds and resolves the rest - // itself. App.ui.openInfo(item); }; } diff --git a/tests/smoke_favorite_formats.py b/tests/smoke_favorite_formats.py index 2369ff9..2d67356 100644 --- a/tests/smoke_favorite_formats.py +++ b/tests/smoke_favorite_formats.py @@ -61,6 +61,14 @@ def main(): ok = bool(asked) and asked[0]["channel"] == "rule34video" and asked[0]["server"] == SERVER print(f" [{'PASS' if ok else 'FAIL'}] asked the right channel and server {asked[:1]}") failed += 0 if ok else 1 + saved = page.evaluate("""(item) => { + localStorage.setItem('favorites', '[]'); + App.favorites.toggle(Object.assign({}, item, { formats: [{ url: 'https://x/y.mp4', height: 720 }] })); + return App.favorites.getAll()[0]; + }""", FAV) + ok = bool(saved) and bool(saved.get("meta")) and saved["meta"]["formats"][0]["url"] == "https://x/y.mp4" + print(f" [{'PASS' if ok else 'FAIL'}] a saved favorite keeps its formats") + failed += 0 if ok else 1 b.close() return 1 if failed else 0