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 '