Compare commits
2 Commits
2fcc17877d
...
ba6045a2b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ba6045a2b1 | ||
|
|
93463bce3a |
@@ -16,13 +16,9 @@ App.favorites = App.favorites || {};
|
|||||||
const raw = localStorage.getItem(FAVORITES_KEY);
|
const raw = localStorage.getItem(FAVORITES_KEY);
|
||||||
const parsed = raw ? JSON.parse(raw) : [];
|
const parsed = raw ? JSON.parse(raw) : [];
|
||||||
if (!Array.isArray(parsed)) return [];
|
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:
|
// 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
|
// `favoriteDate` didn't exist before sorting needed it. There's no
|
||||||
// way to recover when an old favorite was actually saved, so it
|
// way to recover when an old favorite was actually saved, so it
|
||||||
// gets now: they sort together, as one batch, at the point the
|
// 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 now = new Date().toISOString();
|
||||||
const items = parsed.map((item) => {
|
const items = parsed.map((item) => {
|
||||||
if (!item || typeof item !== 'object') return 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);
|
const clean = Object.assign({}, item);
|
||||||
delete clean.meta;
|
clean.favoriteDate = now;
|
||||||
if (!clean.favoriteDate) clean.favoriteDate = now;
|
|
||||||
repaired = true;
|
repaired = true;
|
||||||
return clean;
|
return clean;
|
||||||
});
|
});
|
||||||
@@ -103,6 +98,18 @@ App.favorites = App.favorites || {};
|
|||||||
return video.key || meta.key || video.id || meta.id || video.url || meta.url || null;
|
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) {
|
App.favorites.normalize = function(video) {
|
||||||
const key = App.favorites.getKey(video);
|
const key = App.favorites.getKey(video);
|
||||||
if (!key) return null;
|
if (!key) return null;
|
||||||
@@ -110,9 +117,7 @@ App.favorites = App.favorites || {};
|
|||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
id: video.id || null,
|
id: video.id || null,
|
||||||
// The page/source URL (e.g. the YouTube watch URL), not a resolved
|
// The page/source URL (e.g. the YouTube watch URL).
|
||||||
// CDN media URL -- those expire, so favorites must always re-resolve
|
|
||||||
// via the server at play time instead of caching a stream link.
|
|
||||||
url: video.url || (meta && meta.url) || '',
|
url: video.url || (meta && meta.url) || '',
|
||||||
title: video.title || '',
|
title: video.title || '',
|
||||||
thumb: video.thumb || '',
|
thumb: video.thumb || '',
|
||||||
@@ -122,13 +127,10 @@ App.favorites = App.favorites || {};
|
|||||||
isLive: !!(video.isLive || (meta && meta.isLive)),
|
isLive: !!(video.isLive || (meta && meta.isLive)),
|
||||||
// When it was saved. An import carries the date the other client
|
// When it was saved. An import carries the date the other client
|
||||||
// recorded; anything saved here is saved now.
|
// recorded; anything saved here is saved now.
|
||||||
favoriteDate: video.favoriteDate || new Date().toISOString()
|
favoriteDate: video.favoriteDate || new Date().toISOString(),
|
||||||
// No `meta` field: persisting resolved formats would freeze their
|
// The formats the video was saved with, so it plays without being
|
||||||
// (expiring) CDN URLs into localStorage. Leaving it unset makes a
|
// resolved again. Only when there are some.
|
||||||
// favorite look like a fresh, unresolved listing item again, so
|
meta: formatsOf(video, meta)
|
||||||
// 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).
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -251,7 +253,7 @@ App.favorites = App.favorites || {};
|
|||||||
'uploader', 'duration', 'isLive', 'favoriteDate'];
|
'uploader', 'duration', 'isLive', 'favoriteDate'];
|
||||||
|
|
||||||
const sameEntry = function(a, b) {
|
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(a[field] === undefined || a[field] === null ? '' : a[field]) ===
|
||||||
String(b[field] === undefined || b[field] === null ? '' : b[field]));
|
String(b[field] === undefined || b[field] === null ? '' : b[field]));
|
||||||
};
|
};
|
||||||
@@ -499,9 +501,6 @@ App.favorites = App.favorites || {};
|
|||||||
card.onclick = () => {
|
card.onclick = () => {
|
||||||
if (card.classList.contains('is-loading')) return;
|
if (card.classList.contains('is-loading')) return;
|
||||||
card.classList.add('is-loading');
|
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 });
|
App.player.open(item, { originEl: card });
|
||||||
};
|
};
|
||||||
const favoriteBtn = card.querySelector('.favorite-btn');
|
const favoriteBtn = card.querySelector('.favorite-btn');
|
||||||
@@ -525,9 +524,6 @@ App.favorites = App.favorites || {};
|
|||||||
showInfoBtn.onclick = (event) => {
|
showInfoBtn.onclick = (event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
App.videos.closeAllMenus();
|
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);
|
App.ui.openInfo(item);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2767,6 +2767,39 @@ App.videos = App.videos || {};
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Some providers (rule34video among them) hand the listing their media
|
||||||
|
// formats themselves, and yt-dlp can't extract their pages. A favorite keeps
|
||||||
|
// no formats, so for those /api/resolve comes back empty -- ask the
|
||||||
|
// providers again instead, the way the listing did: search the channel for
|
||||||
|
// the title and take the item that is this video.
|
||||||
|
const formatsFromProvider = async function(video) {
|
||||||
|
const idHead = String(video.id || '').split(':')[0];
|
||||||
|
const channelId = (idHead && idHead !== String(video.id) ? idHead : String(video.channel || '')).toLowerCase();
|
||||||
|
if (!channelId) return null;
|
||||||
|
const servers = (App.storage.getConfig().servers || []).map((entry) => Object.keys(entry)[0]);
|
||||||
|
const wanted = App.favorites && App.favorites.urlKey ? App.favorites.urlKey(video.url) : video.url;
|
||||||
|
const same = (item) => item && (item.id === video.id ||
|
||||||
|
(item.url && (App.favorites && App.favorites.urlKey ? App.favorites.urlKey(item.url) : item.url) === wanted));
|
||||||
|
for (const server of servers) {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/api/videos', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ channel: channelId, query: video.title || '', page: 1, perPage: 24, server })
|
||||||
|
});
|
||||||
|
if (!response.ok) continue;
|
||||||
|
const data = await response.json();
|
||||||
|
const found = (data && Array.isArray(data.items) ? data.items : []).find(same);
|
||||||
|
if (found && hasFormats(found)) {
|
||||||
|
return { url: found.url, formats: found.formats, http_headers: found.http_headers || {}, isLive: !!found.isLive };
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
// Next server.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
App.videos.ensureFormats = function(video) {
|
App.videos.ensureFormats = function(video) {
|
||||||
if (!video || typeof video !== 'object') return Promise.resolve(null);
|
if (!video || typeof video !== 'object') return Promise.resolve(null);
|
||||||
if (hasFormats(video.meta)) return Promise.resolve(video.meta);
|
if (hasFormats(video.meta)) return Promise.resolve(video.meta);
|
||||||
@@ -2793,13 +2826,14 @@ App.videos = App.videos || {};
|
|||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ url: video.url })
|
body: JSON.stringify({ url: video.url })
|
||||||
});
|
});
|
||||||
if (!response.ok) return null;
|
if (response.ok) {
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
return hasFormats(data) ? data : null;
|
if (hasFormats(data)) return data;
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Best-effort: playback still works through the proxy.
|
// Best-effort: playback still works through the proxy.
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
return await formatsFromProvider(video);
|
||||||
})();
|
})();
|
||||||
metaCache.set(cacheKey, promise);
|
metaCache.set(cacheKey, promise);
|
||||||
}
|
}
|
||||||
|
|||||||
77
tests/smoke_favorite_formats.py
Normal file
77
tests/smoke_favorite_formats.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""A favorite whose site yt-dlp can't extract still gets its formats.
|
||||||
|
|
||||||
|
Providers like rule34video hand the listing their formats; a favorite keeps
|
||||||
|
none, and /api/resolve comes back empty for them. ensureFormats must then ask
|
||||||
|
the providers again and take the item that is this video.
|
||||||
|
|
||||||
|
backend/main.py &
|
||||||
|
.venv/bin/python tests/smoke_favorite_formats.py
|
||||||
|
"""
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from playwright.sync_api import sync_playwright
|
||||||
|
|
||||||
|
BASE = "http://127.0.0.1:5000/"
|
||||||
|
SERVER = "https://a.fixture.test"
|
||||||
|
FAV = {"key": "rule34video:4511669", "id": "rule34video:4511669",
|
||||||
|
"url": "https://rule34video.com/video/4511669/sindel-cassie-sonya/",
|
||||||
|
"title": "Sindel, Cassie, Sonya", "thumb": "", "channel": "Rule34video",
|
||||||
|
"uploader": "Nyl", "duration": 510, "isLive": False,
|
||||||
|
"favoriteDate": "2026-11-02T22:00:05.356Z"}
|
||||||
|
STATUS = {"id": "a", "name": "a", "online": True, "channels": [
|
||||||
|
{"id": "rule34video", "name": "Rule34video", "description": "x", "options": []}]}
|
||||||
|
FMT = [{"url": "https://rule34video.com/get_file/1/x/4511669_720p.mp4/", "height": 720, "ext": "mp4"}]
|
||||||
|
asked = []
|
||||||
|
|
||||||
|
|
||||||
|
def videos(route):
|
||||||
|
body = json.loads(route.request.post_data or "{}")
|
||||||
|
asked.append(body)
|
||||||
|
items = [{"id": "rule34video:1", "url": "https://rule34video.com/video/1/other/", "formats": []},
|
||||||
|
{"id": FAV["id"], "url": FAV["url"], "formats": FMT}]
|
||||||
|
route.fulfill(status=200, content_type="application/json", body=json.dumps({"items": items}))
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
failed = 0
|
||||||
|
with sync_playwright() as p:
|
||||||
|
b = p.chromium.launch(args=["--no-sandbox", "--disable-gpu"])
|
||||||
|
page = b.new_page()
|
||||||
|
page.route(f"{SERVER}/api/status", lambda r: r.fulfill(
|
||||||
|
status=200, content_type="application/json", body=json.dumps(STATUS)))
|
||||||
|
page.route("**/api/resolve", lambda r: r.fulfill(
|
||||||
|
status=200, content_type="application/json",
|
||||||
|
body='{"formats":[],"http_headers":{},"isLive":false,"url":null}'))
|
||||||
|
page.route("**/api/videos", videos)
|
||||||
|
page.goto(BASE, wait_until="domcontentloaded")
|
||||||
|
page.evaluate("""([s, f]) => {
|
||||||
|
localStorage.setItem('config', JSON.stringify({servers: [{[s]: {}}]}));
|
||||||
|
localStorage.setItem('favorites', JSON.stringify([f]));
|
||||||
|
}""", [SERVER, FAV])
|
||||||
|
page.goto(BASE, wait_until="load")
|
||||||
|
meta = page.evaluate("""async (f) => {
|
||||||
|
const v = Object.assign({}, f);
|
||||||
|
const m = await App.videos.ensureFormats(v);
|
||||||
|
return { m, attached: !!v.meta };
|
||||||
|
}""", FAV)
|
||||||
|
ok = bool(meta["m"]) and meta["m"]["formats"] == FMT and meta["attached"]
|
||||||
|
print(f" [{'PASS' if ok else 'FAIL'}] favorite gets the provider's formats {meta}")
|
||||||
|
failed += 0 if ok else 1
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Reference in New Issue
Block a user