diff --git a/core/video/youtube.py b/core/video/youtube.py index c755c339..cdff43ab 100644 --- a/core/video/youtube.py +++ b/core/video/youtube.py @@ -530,11 +530,50 @@ def _lockup_thumb(lk): return None +_DUR_RE = re.compile(r"^\d{1,2}:\d{2}(?::\d{2})?$") # 12:34 / 1:02:03 (the overlay badge) +_VIEWS_RE = re.compile(r"([\d.,]+)\s*([KMB]?)\s+views?", re.I) # "2.6M views", "1,234 views" +_VIEW_MULT = {"": 1, "K": 1e3, "M": 1e6, "B": 1e9} + + +def _json_all_strings(obj, acc): + if isinstance(obj, dict): + for v in obj.values(): + _json_all_strings(v, acc) + elif isinstance(obj, list): + for v in obj: + _json_all_strings(v, acc) + elif isinstance(obj, str): + acc.append(obj) + return acc + + +def parse_view_count(text): + """'2.6M views' → 2600000, '1,234 views' → 1234, else None (approximate).""" + m = _VIEWS_RE.search(text or "") + if not m: + return None + try: + return int(float(m.group(1).replace(",", "")) * _VIEW_MULT.get(m.group(2).upper(), 1)) + except ValueError: + return None + + +def _lockup_duration(lk): + """The duration overlay badge ('12:34') from a lockupViewModel, or None.""" + return next((s for s in _json_all_strings(lk, []) if _DUR_RE.match(s)), None) + + +def _lockup_views(lk): + """Approximate view count parsed from the lockup's metadata text, or None.""" + return next((v for v in (parse_view_count(s) for s in _json_content_strings(lk.get("metadata"), [])) + if v is not None), None) + + def innertube_parse_video_items(obj): """Full per-video metadata for VIDEO lockups in an InnerTube browse/continuation - response: [{youtube_id, title, thumbnail_url, relative}]. Same source as - innertube_parse_videos but keeps title + thumbnail so the channel page can list - the whole catalog (not just date them).""" + response: [{youtube_id, title, thumbnail_url, duration, view_count, relative}]. + Same source as innertube_parse_videos but keeps the title/thumbnail/duration/ + views so the channel page can list the whole catalog (not just date them).""" out, seen = [], set() for lk in _json_find_all(obj, "lockupViewModel", []): if not isinstance(lk, dict) or lk.get("contentType") != "LOCKUP_CONTENT_TYPE_VIDEO": @@ -544,8 +583,8 @@ def innertube_parse_video_items(obj): continue seen.add(vid) rel = next((t for t in _json_content_strings(lk.get("metadata"), []) if _REL_RE.search(t)), None) - out.append({"youtube_id": vid, "title": _lockup_title(lk), - "thumbnail_url": _lockup_thumb(lk), "relative": rel}) + out.append({"youtube_id": vid, "title": _lockup_title(lk), "thumbnail_url": _lockup_thumb(lk), + "duration": _lockup_duration(lk), "view_count": _lockup_views(lk), "relative": rel}) return out diff --git a/database/video_database.py b/database/video_database.py index 52666a5e..8330a67f 100644 --- a/database/video_database.py +++ b/database/video_database.py @@ -112,6 +112,9 @@ _COLUMN_MIGRATIONS = [ # which source produced a channel's dates — NULL on legacy (pre-InnerTube) rows # so they re-enrich once and upgrade to the full InnerTube catalog. ("youtube_channel_enrichment", "method", "TEXT"), + # per-video duration + approximate view count on the remembered catalog + ("youtube_channel_videos", "duration", "TEXT"), + ("youtube_channel_videos", "view_count", "INTEGER"), ] @@ -2093,17 +2096,20 @@ class VideoDatabase: """Remember a channel's videos (id/title/thumbnail). Upsert — refreshes title/thumbnail, never deletes (older pages stay remembered).""" cid = str(channel_id or "").strip() - rows = [(cid, v.get("youtube_id"), v.get("title"), v.get("thumbnail_url")) + rows = [(cid, v.get("youtube_id"), v.get("title"), v.get("thumbnail_url"), + v.get("duration"), v.get("view_count")) for v in (videos or []) if isinstance(v, dict) and v.get("youtube_id")] if not cid or not rows: return 0 conn = self._get_connection() try: conn.executemany( - "INSERT INTO youtube_channel_videos (channel_id, youtube_id, title, thumbnail_url) " - "VALUES (?,?,?,?) ON CONFLICT(channel_id, youtube_id) DO UPDATE SET " + "INSERT INTO youtube_channel_videos (channel_id, youtube_id, title, thumbnail_url, " + "duration, view_count) VALUES (?,?,?,?,?,?) ON CONFLICT(channel_id, youtube_id) DO UPDATE SET " "title=COALESCE(excluded.title, title), " "thumbnail_url=COALESCE(excluded.thumbnail_url, thumbnail_url), " + "duration=COALESCE(excluded.duration, duration), " + "view_count=COALESCE(excluded.view_count, view_count), " "cached_at=CURRENT_TIMESTAMP", rows) conn.commit() return len(rows) @@ -2119,14 +2125,14 @@ class VideoDatabase: conn = self._get_connection() try: rows = conn.execute( - "SELECT v.youtube_id, v.title, v.thumbnail_url, d.published_at " + "SELECT v.youtube_id, v.title, v.thumbnail_url, v.duration, v.view_count, d.published_at " "FROM youtube_channel_videos v " "LEFT JOIN youtube_video_dates d ON d.youtube_id = v.youtube_id " "WHERE v.channel_id=? " "ORDER BY (d.published_at IS NULL), d.published_at DESC, v.rowid", (cid,)).fetchall() - return [{"youtube_id": r["youtube_id"], "title": r["title"], - "thumbnail_url": r["thumbnail_url"], "published_at": r["published_at"]} + return [{"youtube_id": r["youtube_id"], "title": r["title"], "thumbnail_url": r["thumbnail_url"], + "duration": r["duration"], "view_count": r["view_count"], "published_at": r["published_at"]} for r in rows] finally: conn.close() diff --git a/database/video_schema.sql b/database/video_schema.sql index 2a5627f7..172c6e9c 100644 --- a/database/video_schema.sql +++ b/database/video_schema.sql @@ -288,6 +288,8 @@ CREATE TABLE IF NOT EXISTS youtube_channel_videos ( youtube_id TEXT NOT NULL, title TEXT, thumbnail_url TEXT, + duration TEXT, -- overlay badge, e.g. "12:34" + view_count INTEGER, -- approximate (parsed from "2.6M views") cached_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (channel_id, youtube_id) ); diff --git a/tests/test_video_youtube.py b/tests/test_video_youtube.py index 1068158c..605c3f59 100644 --- a/tests/test_video_youtube.py +++ b/tests/test_video_youtube.py @@ -458,34 +458,45 @@ def test_innertube_channel_dates_guards(): assert yt.innertube_channel_dates("UCx", post=lambda p: (_ for _ in ()).throw(RuntimeError())) == {} -def _lk_item_thumb(vid, rel, thumb): - """_lk_item plus a contentImage so thumbnail extraction can be exercised.""" +def _lk_item_thumb(vid, rel, thumb, dur=None): + """_lk_item plus a contentImage (+ optional duration overlay badge).""" item = _lk_item(vid, rel) lk = item["richItemRenderer"]["content"]["lockupViewModel"] - lk["contentImage"] = {"thumbnailViewModel": {"image": {"sources": [{"url": thumb}]}}} + lk["contentImage"] = {"thumbnailViewModel": {"image": {"sources": [{"url": thumb}]}, + "overlays": ([{"thumbnailOverlayBadgeViewModel": {"thumbnailBadges": [ + {"thumbnailBadgeViewModel": {"text": dur}}]}}] if dur else [])}} return item -def test_innertube_parse_video_items_keeps_title_and_thumbnail(): +def test_parse_view_count(): + assert yt.parse_view_count("2.6M views") == 2_600_000 + assert yt.parse_view_count("1,234 views") == 1234 + assert yt.parse_view_count("987 views") == 987 + assert yt.parse_view_count("1.2B views") == 1_200_000_000 + assert yt.parse_view_count("no views here") is None and yt.parse_view_count("") is None + + +def test_innertube_parse_video_items_keeps_title_thumb_duration_views(): page = _page([ - _lk_item_thumb("v1", "2 years ago", "https://i.ytimg.com/vi/v1/hq.jpg"), - _lk_item("v2", "3 months ago"), # no thumbnail → None + _lk_item_thumb("v1", "2 years ago", "https://i.ytimg.com/vi/v1/hq.jpg", dur="12:34"), + _lk_item("v2", "3 months ago"), # no thumbnail/duration → None _lk_item("p1", "1 day ago", ctype="LOCKUP_CONTENT_TYPE_PLAYLIST"), # not a video → skip ]) items = yt.innertube_parse_video_items(page) assert [it["youtube_id"] for it in items] == ["v1", "v2"] # playlist filtered out - assert items[0] == {"youtube_id": "v1", "title": "Title v1", - "thumbnail_url": "https://i.ytimg.com/vi/v1/hq.jpg", "relative": "2 years ago"} - assert items[1]["title"] == "Title v2" and items[1]["thumbnail_url"] is None + # _lk_item's metadata carries "100K views" → parsed; v1 also has a duration badge + assert items[0] == {"youtube_id": "v1", "title": "Title v1", "thumbnail_url": "https://i.ytimg.com/vi/v1/hq.jpg", + "duration": "12:34", "view_count": 100_000, "relative": "2 years ago"} + assert items[1]["thumbnail_url"] is None and items[1]["duration"] is None and items[1]["view_count"] == 100_000 def test_innertube_channel_videos_page_converts_and_pages(): now = date(2026, 6, 17) - page1 = _page([_lk_item_thumb("v1", "1 year ago", "t1"), _lk_item("v2", "2 years ago")], token="TOK") + page1 = _page([_lk_item_thumb("v1", "1 year ago", "t1", dur="5:00"), _lk_item("v2", "2 years ago")], token="TOK") got = yt.innertube_channel_videos_page("UCxxxx", now=now, post=lambda p: page1) assert got["continuation"] == "TOK" - assert got["videos"][0] == {"youtube_id": "v1", "title": "Title v1", - "thumbnail_url": "t1", "published_at": "2025-06-17"} + assert got["videos"][0] == {"youtube_id": "v1", "title": "Title v1", "thumbnail_url": "t1", + "duration": "5:00", "view_count": 100_000, "published_at": "2025-06-17"} assert got["videos"][1]["published_at"] == "2024-06-17" # a continuation request carries the token (not browseId) and ends when none returned end = yt.innertube_channel_videos_page("UCxxxx", continuation="TOK", now=now, diff --git a/webui/static/video/video-detail.js b/webui/static/video/video-detail.js index 1ec72133..04ed957e 100644 --- a/webui/static/video/video-detail.js +++ b/webui/static/video/video-detail.js @@ -942,11 +942,13 @@ var still = ep.still_url ? '' : ''; + var dur = ep.yt_duration ? '' + esc(ep.yt_duration) + '' : ''; var meta = []; + if (ep.view_count) { var yc = window.VideoYoutube; meta.push((yc ? yc.compactCount(ep.view_count) : ep.view_count) + ' views'); } if (ep.air_date) meta.push(fmtDate(ep.air_date)); var wished = !!ep.owned; return '
' + - '
' + still + '
' + + '
' + still + '' + dur + '
' + '
' + esc(ep.title || 'Untitled') + '' + (meta.length ? '' + esc(meta.join(' · ')) + '' : '') + '
' + @@ -1331,7 +1333,8 @@ var eps = vids.map(function (v, i) { return { episode_number: i + 1, title: v.title, overview: v.description || '', air_date: v.published_at, owned: !!v.wished, has_still: false, - still_url: ytProx(v.thumbnail_url), youtube_id: v.youtube_id }; + still_url: ytProx(v.thumbnail_url), youtube_id: v.youtube_id, + yt_duration: v.duration || '', view_count: v.view_count || 0 }; }); var wishedN = eps.filter(function (e) { return e.owned; }).length; // No upload dates (flat listing omits them) → a single "All Videos" @@ -1378,8 +1381,10 @@ (resp.videos || []).forEach(function (v) { if (!v.youtube_id) return; var ex = byId[v.youtube_id]; - if (ex) { // already shown → backfill a missing date + if (ex) { // already shown → backfill missing fields if (!ex.published_at && v.published_at) { ex.published_at = v.published_at; changed = true; } + if (!ex.duration && v.duration) { ex.duration = v.duration; changed = true; } + if (!ex.view_count && v.view_count) { ex.view_count = v.view_count; changed = true; } } else { // older video → add it byId[v.youtube_id] = v; data._channel.videos.push(v); changed = true; } diff --git a/webui/static/video/video-side.css b/webui/static/video/video-side.css index 42cbf445..d9529368 100644 --- a/webui/static/video/video-side.css +++ b/webui/static/video/video-side.css @@ -564,6 +564,12 @@ body[data-side="video"] .dashboard-header-sweep { transition: all 0.25s ease; text-shadow: 0 2px 10px rgba(0,0,0,0.7); } .vd-ep:hover .vd-ep-thumb-ic { opacity: 1; transform: scale(1); } .vd-ep:hover .vd-ep-still { filter: brightness(0.7); transition: filter 0.25s ease; } +/* YouTube duration badge — bottom-right of the thumb (like the real player) */ +.vd-ep-dur { + position: absolute; z-index: 2; right: 5px; bottom: 5px; + padding: 1px 5px; border-radius: 4px; font-size: 11px; font-weight: 700; line-height: 1.4; + color: #fff; background: rgba(0, 0, 0, 0.82); letter-spacing: 0.02em; +} .vd-ep-info { min-width: 0; } .vd-ep-top { display: flex; align-items: baseline; gap: 12px; margin-bottom: 5px; } .vd-ep-title { font-size: 15.5px; font-weight: 700; color: #fff; }