YouTube dates: proxy is opt-in (public instances are dead); yt-dlp is the default
Live-tested the proxy against real channels — it does not work: Invidious public APIs are 401/403 (auth-walled/disabled), and the one reachable Piped instance returns EMPTY video lists for every channel (MrBeast/LTT/Veritasium all 0). Also fixed a real bug: _harvest bailed on an empty first page instead of following the nextpage token. So the proxy is now OPT-IN via a youtube_proxy_instances setting (empty by default → skipped entirely, no wasted dead calls). The default path is RSS + the parallelized yt-dlp fallback, which actually works (your log: 9/36/25 dates cached). Power users can point the setting at a live instance if they find one.
This commit is contained in:
parent
964b9bfdd7
commit
a83df05ce2
3 changed files with 55 additions and 11 deletions
|
|
@ -316,7 +316,9 @@ def _proxy_get(url, fetch):
|
||||||
|
|
||||||
|
|
||||||
def _harvest(kind, base, cid, pages, fetch):
|
def _harvest(kind, base, cid, pages, fetch):
|
||||||
"""Walk one instance's channel pages, accumulating {video_id: date}."""
|
"""Walk one instance's channel pages, accumulating {video_id: date}. Follows
|
||||||
|
pagination even when an early page is empty-but-has-a-next-token (some Piped
|
||||||
|
instances return an empty first page), stopping when there's no token left."""
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
out, token = {}, None
|
out, token = {}, None
|
||||||
for i in range(max(1, pages)):
|
for i in range(max(1, pages)):
|
||||||
|
|
@ -327,11 +329,10 @@ def _harvest(kind, base, cid, pages, fetch):
|
||||||
url = base + "/api/v1/channels/" + cid + "/videos" + \
|
url = base + "/api/v1/channels/" + cid + "/videos" + \
|
||||||
(("?continuation=" + quote(token, safe="")) if token else "")
|
(("?continuation=" + quote(token, safe="")) if token else "")
|
||||||
obj = _proxy_get(url, fetch)
|
obj = _proxy_get(url, fetch)
|
||||||
page = parse_proxy_dates(obj if isinstance(obj, dict) else {})
|
if not isinstance(obj, dict):
|
||||||
if not page and i == 0:
|
break
|
||||||
return {} # instance gave nothing → caller tries next
|
out.update(parse_proxy_dates(obj))
|
||||||
out.update(page)
|
token = obj.get("nextpage") if kind == "piped" else obj.get("continuation")
|
||||||
token = (obj.get("nextpage") if kind == "piped" else obj.get("continuation")) if isinstance(obj, dict) else None
|
|
||||||
if not token:
|
if not token:
|
||||||
break
|
break
|
||||||
return out
|
return out
|
||||||
|
|
|
||||||
|
|
@ -46,6 +46,30 @@ class YoutubeDateEnricher:
|
||||||
from database.video_database import VideoDatabase
|
from database.video_database import VideoDatabase
|
||||||
return VideoDatabase()
|
return VideoDatabase()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _proxy_instances(db):
|
||||||
|
"""Parse the optional youtube_proxy_instances setting into [(kind, url), …].
|
||||||
|
Empty (the default) → proxy skipped entirely. Format is comma/newline
|
||||||
|
separated 'kind|url' (kind inferred from the url if omitted)."""
|
||||||
|
try:
|
||||||
|
raw = db.get_setting("youtube_proxy_instances") or ""
|
||||||
|
except Exception:
|
||||||
|
return []
|
||||||
|
out = []
|
||||||
|
for part in raw.replace("\n", ",").split(","):
|
||||||
|
part = part.strip()
|
||||||
|
if not part:
|
||||||
|
continue
|
||||||
|
if "|" in part:
|
||||||
|
kind, url = part.split("|", 1)
|
||||||
|
kind, url = kind.strip().lower(), url.strip()
|
||||||
|
else:
|
||||||
|
url = part
|
||||||
|
kind = "invidious" if "invidious" in url or "/api/v1" in url else "piped"
|
||||||
|
if url.startswith("http"):
|
||||||
|
out.append((kind, url.rstrip("/")))
|
||||||
|
return out
|
||||||
|
|
||||||
def enqueue(self, channel_id, title=None):
|
def enqueue(self, channel_id, title=None):
|
||||||
"""Queue a followed channel for full date enrichment (deduped; starts the
|
"""Queue a followed channel for full date enrichment (deduped; starts the
|
||||||
worker thread on first use)."""
|
worker thread on first use)."""
|
||||||
|
|
@ -119,12 +143,18 @@ class YoutubeDateEnricher:
|
||||||
self._current = self._titles.get(cid) or cid
|
self._current = self._titles.get(cid) or cid
|
||||||
logger.debug("Enriching dates for %s (%s)", self._current, cid)
|
logger.debug("Enriching dates for %s (%s)", self._current, cid)
|
||||||
|
|
||||||
|
# Bulk no-key proxy (Piped/Invidious) is OPT-IN via a setting — the public
|
||||||
|
# instances are unreliable/API-disabled, so by default we skip straight to
|
||||||
|
# the yt-dlp path. Power users can point youtube_proxy_instances at a live
|
||||||
|
# instance ("piped|https://…, invidious|https://…").
|
||||||
dates = {}
|
dates = {}
|
||||||
try:
|
instances = self._proxy_instances(db)
|
||||||
dates = yt.proxy_channel_dates(cid) or {}
|
if instances:
|
||||||
except Exception:
|
try:
|
||||||
logger.debug("proxy date fetch failed for %s", cid, exc_info=True)
|
dates = yt.proxy_channel_dates(cid, instances=instances) or {}
|
||||||
logger.debug("proxy returned %d dates for %s", len(dates), cid)
|
except Exception:
|
||||||
|
logger.debug("proxy date fetch failed for %s", cid, exc_info=True)
|
||||||
|
logger.debug("proxy returned %d dates for %s", len(dates), cid)
|
||||||
if dates:
|
if dates:
|
||||||
db.cache_video_dates([{"youtube_id": k, "published_at": v} for k, v in dates.items()])
|
db.cache_video_dates([{"youtube_id": k, "published_at": v} for k, v in dates.items()])
|
||||||
for k, v in dates.items(): # per-item INFO, like the other workers
|
for k, v in dates.items(): # per-item INFO, like the other workers
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ def test_enrich_caches_proxy_dates_and_marks_done(db, monkeypatch):
|
||||||
lambda cid, *a, **k: {"v1": "2024-06-01", "v2": "2023-02-02"})
|
lambda cid, *a, **k: {"v1": "2024-06-01", "v2": "2023-02-02"})
|
||||||
# if proxy covers everything, the per-video fallback must NOT run
|
# if proxy covers everything, the per-video fallback must NOT run
|
||||||
monkeypatch.setattr(yt, "video_detail", lambda vid: (_ for _ in ()).throw(AssertionError("no fallback")))
|
monkeypatch.setattr(yt, "video_detail", lambda vid: (_ for _ in ()).throw(AssertionError("no fallback")))
|
||||||
|
db.set_setting("youtube_proxy_instances", "piped|https://example.test") # proxy is opt-in
|
||||||
db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "X"}, [{"youtube_id": "v1", "title": "A"}])
|
db.add_videos_to_wishlist({"youtube_id": "UCx", "title": "X"}, [{"youtube_id": "v1", "title": "A"}])
|
||||||
|
|
||||||
e = YoutubeDateEnricher(db_factory=lambda: db)
|
e = YoutubeDateEnricher(db_factory=lambda: db)
|
||||||
|
|
@ -62,3 +63,15 @@ def test_enricher_stats_shape_and_pause():
|
||||||
assert s["current_item"] is None and "progress" in s
|
assert s["current_item"] is None and "progress" in s
|
||||||
e.pause(); assert e.stats()["paused"] is True
|
e.pause(); assert e.stats()["paused"] is True
|
||||||
e.resume(); assert e.stats()["paused"] is False
|
e.resume(); assert e.stats()["paused"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_proxy_instances_setting_parsing(db):
|
||||||
|
e = YoutubeDateEnricher(db_factory=lambda: db)
|
||||||
|
assert e._proxy_instances(db) == [] # unset → proxy off by default
|
||||||
|
db.set_setting("youtube_proxy_instances",
|
||||||
|
"piped|https://a.test, https://invidious.b.test/, junk, https://c.test")
|
||||||
|
got = e._proxy_instances(db)
|
||||||
|
assert ("piped", "https://a.test") in got
|
||||||
|
assert ("invidious", "https://invidious.b.test") in got # kind inferred + trailing / stripped
|
||||||
|
assert ("piped", "https://c.test") in got
|
||||||
|
assert all(u.startswith("http") for _, u in got) # 'junk' dropped
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue