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.
This commit is contained in:
Alison Moura 2026-08-14 11:08:56 -03:00
parent 6865cf208e
commit d7f990ebd7
2 changed files with 140 additions and 1 deletions

View File

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

View File

@ -661,8 +661,17 @@ private module Parsers
metadata = item_contents.dig("metadata", "lockupMetadataViewModel") metadata = item_contents.dig("metadata", "lockupMetadataViewModel")
title = metadata.dig("title", "content").as_s title = metadata.dig("title", "content").as_s
# Contains the views of the video and the published time of the video. # 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") } 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 .try &.dig("text", "content").as_s