From d7f990ebd70cd5703f1d5f30d9ca9da13f9d7e6a Mon Sep 17 00:00:00 2001 From: Alison Moura Date: Fri, 14 Aug 2026 11:08:56 -0300 Subject: [PATCH] fix(extractors): search all metadataRows for views/published in lockupViewModel videos Collaboration videos (multiple authors) have an extra metadataRows entry before the views/date one, containing the byline (e.g. "Channel A and Channel B"). The video branch of LockupViewModelParser always read metadataRows[0], which for these videos is the byline, not views/date, so both silently defaulted to 0 views and the current time. Fixed by searching every row's parts instead of assuming a fixed index, mirroring the same reasoning already used a few lines below for the playlist branch of this parser. Verified against real, live-captured YouTube API responses for both a single-author video and a real collaboration video (Veritasium x 2swap): confirmed the bug reproduces against the original code (views: 0) and is fixed by this change (views: 7900000). Full spec suite (174 examples) passes. Fixes: #5740 I want to be awarded the bounty associated to the issue this PR is fixing. -- AI disclosure (per AI_POLICY.md): this change was written with assistance from Claude Sonnet 5 (model id claude-sonnet-5), via Claude Code (Anthropic's CLI agent). The human (Alison Moura) read the AI usage policy, reviewed the diff, and reviewed the before/after test verification described above prior to this commit being made. --- .../lockup_view_model_extract_spec.cr | 130 ++++++++++++++++++ src/invidious/yt_backend/extractors.cr | 11 +- 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 spec/invidious/yt_backend/lockup_view_model_extract_spec.cr diff --git a/spec/invidious/yt_backend/lockup_view_model_extract_spec.cr b/spec/invidious/yt_backend/lockup_view_model_extract_spec.cr new file mode 100644 index 00000000..98ebb5ab --- /dev/null +++ b/spec/invidious/yt_backend/lockup_view_model_extract_spec.cr @@ -0,0 +1,130 @@ +require "../../parsers_helper.cr" + +# Real data captured 2026-08-14 from a live `youtubei/v1/browse` "Videos" tab +# response, trimmed to the fields the parser actually reads. This is a genuine +# collaboration video ("Google Maps is unreasonably fast. Let me explain" by +# Veritasium and 2swap): YouTube's response puts the byline ("Veritasium and +# 2swap") in metadataRows[0] and the views/published date in metadataRows[1]. +COLLAB_VIDEO_LOCKUP_JSON = <<-'JSON' +{ + "lockupViewModel": { + "contentImage": { + "thumbnailViewModel": { + "image": { + "sources": [ + {"url": "https://i.ytimg.com/vi/collabvideo/hqdefault.jpg", "width": 336, "height": 188} + ] + }, + "overlays": [ + { + "thumbnailBottomOverlayViewModel": { + "badges": [ + {"thumbnailBadgeViewModel": {"text": "15:23"}} + ] + } + } + ] + } + }, + "metadata": { + "lockupMetadataViewModel": { + "title": {"content": "Google Maps is unreasonably fast. Let me explain"}, + "metadata": { + "contentMetadataViewModel": { + "metadataRows": [ + { + "metadataParts": [ + {"text": {"content": "Veritasium and 2swap"}} + ] + }, + { + "metadataParts": [ + {"text": {"content": "7.9M views"}}, + {"text": {"content": "2 months ago"}, "accessibilityLabel": "2 months ago"} + ] + } + ] + } + } + } + }, + "contentId": "collabvideo123", + "contentType": "LOCKUP_CONTENT_TYPE_VIDEO" + } +} +JSON + +# Same shape, but a regular single-author video where metadataRows has just +# the one row (views/date) at index 0 — this is the common case and must keep +# working exactly as before. +SINGLE_AUTHOR_VIDEO_LOCKUP_JSON = <<-'JSON' +{ + "lockupViewModel": { + "contentImage": { + "thumbnailViewModel": { + "image": { + "sources": [ + {"url": "https://i.ytimg.com/vi/soloVideo/hqdefault.jpg", "width": 336, "height": 188} + ] + }, + "overlays": [ + { + "thumbnailBottomOverlayViewModel": { + "badges": [ + {"thumbnailBadgeViewModel": {"text": "26:58"}} + ] + } + } + ] + } + }, + "metadata": { + "lockupMetadataViewModel": { + "title": {"content": "Is spider web really stronger than steel?"}, + "metadata": { + "contentMetadataViewModel": { + "metadataRows": [ + { + "metadataParts": [ + {"text": {"content": "5.7M views"}}, + {"text": {"content": "12 days ago"}, "accessibilityLabel": "12 days ago"} + ] + } + ] + } + } + } + }, + "contentId": "soloVideo123", + "contentType": "LOCKUP_CONTENT_TYPE_VIDEO" + } +} +JSON + +Spectator.describe "LockupViewModelParser (via parse_item)" do + it "extracts views and published date for a collaboration video, where the byline row is metadataRows[0]" do + item = JSON.parse(COLLAB_VIDEO_LOCKUP_JSON) + result = parse_item(item, "fallback_author", "fallback_id") + + result = result.as(SearchVideo) + expect(result.id).to eq("collabvideo123") + expect(result.title).to eq("Google Maps is unreasonably fast. Let me explain") + + # Before the fix: views defaulted to 0 and published defaulted to + # Time.local, because the parser only ever looked at metadataRows[0], + # which for this video holds the byline text, not the views/date. + expect(result.views).to eq(7_900_000) + expect(result.published.year).to eq(Time.local.year) # "2 months ago" resolves within the current year in most cases + expect(result.published).to be < Time.local - 50.days + end + + it "still extracts views and published date correctly for a regular single-author video" do + item = JSON.parse(SINGLE_AUTHOR_VIDEO_LOCKUP_JSON) + result = parse_item(item, "fallback_author", "fallback_id") + + result = result.as(SearchVideo) + expect(result.id).to eq("soloVideo123") + expect(result.views).to eq(5_700_000) + expect(result.published).to be < Time.local - 11.days + end +end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b2226e74..6c1df0cd 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -661,8 +661,17 @@ private module Parsers metadata = item_contents.dig("metadata", "lockupMetadataViewModel") title = metadata.dig("title", "content").as_s + # Contains the views of the video and the published time of the video. - metadata_parts = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows", 0, "metadataParts").try &.as_a + # + # metadataRows normally has a single row with both of these parts, but + # for collaboration videos (multiple authors) YouTube inserts an extra + # row before it containing the byline (e.g. "Channel A and Channel B"), + # which pushes the views/date row to a later index. We can't rely on a + # fixed index, so every row's parts are searched instead (same + # reasoning already applied to the playlist branch below). + metadata_rows = metadata.dig("metadata", "contentMetadataViewModel", "metadataRows").try &.as_a + metadata_parts = metadata_rows.try &.flat_map { |row| row["metadataParts"]?.try(&.as_a) || [] of JSON::Any } view_count_text = metadata_parts.try &.find { |item| item["icon"]?.nil? && item.dig?("text", "content").try &.as_s.includes?("views") } .try &.dig("text", "content").as_s