Channel videos: duration badge + view count on every card (TV-parity)

TV episode rows show runtime; channel video cards showed nothing per video. The
InnerTube lockup already carries both — now we capture duration ('12:34') + an
approximate view count (parsed from '2.6M views') in innertube_parse_video_items,
remember them on youtube_channel_videos (new duration/view_count cols + migration),
and render a duration badge bottom-right of the thumb + 'N views' in the card meta.
The background stream backfills them onto the recent (yt-dlp) videos too, so the
whole catalog gets them. Live-validated (MrBeast: 32:08 / 50M, 30/30). 65 tests green.
This commit is contained in:
BoulderBadgeDad 2026-06-17 18:36:47 -07:00
parent 66f3f97460
commit c7b5a93290
6 changed files with 95 additions and 26 deletions

View file

@ -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

View file

@ -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()

View file

@ -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)
);

View file

@ -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,

View file

@ -942,11 +942,13 @@
var still = ep.still_url
? '<img class="vd-ep-still" src="' + esc(ep.still_url) + '" alt="" loading="lazy" onerror="this.style.display=\'none\'">'
: '';
var dur = ep.yt_duration ? '<span class="vd-ep-dur">' + esc(ep.yt_duration) + '</span>' : '';
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 '<div class="vd-ep vd-ep--yt" data-vd-ep-key="' + key + '" data-vd-yt-vid="' + esc(ep.youtube_id) + '">' +
'<div class="vd-ep-thumb">' + still + '<span class="vd-ep-thumb-ic">▶</span></div>' +
'<div class="vd-ep-thumb">' + still + '<span class="vd-ep-thumb-ic">▶</span>' + dur + '</div>' +
'<div class="vd-ep-info"><div class="vd-ep-top"><span class="vd-ep-title">' +
esc(ep.title || 'Untitled') + '</span>' +
(meta.length ? '<span class="vd-ep-rt">' + esc(meta.join(' · ')) + '</span>' : '') + '</div>' +
@ -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;
}

View file

@ -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; }