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:
parent
66f3f97460
commit
c7b5a93290
6 changed files with 95 additions and 26 deletions
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue