From 636e64ed3e02aef5b73668a61aaa37259b36c864 Mon Sep 17 00:00:00 2001 From: Nesrine Gharbi Date: Sat, 19 Sep 2026 18:46:10 +0000 Subject: [PATCH] fix(parser): handle collab lockup metadata rows --- .../lockup_view_model_parser_spec.cr | 99 +++++++++++++++++++ src/invidious/yt_backend/extractors.cr | 7 +- 2 files changed, 105 insertions(+), 1 deletion(-) create mode 100644 spec/invidious/yt_backend/lockup_view_model_parser_spec.cr diff --git a/spec/invidious/yt_backend/lockup_view_model_parser_spec.cr b/spec/invidious/yt_backend/lockup_view_model_parser_spec.cr new file mode 100644 index 000000000..878fba4d3 --- /dev/null +++ b/spec/invidious/yt_backend/lockup_view_model_parser_spec.cr @@ -0,0 +1,99 @@ +require "../../parsers_helper.cr" + +Spectator.describe "LockupViewModelParser channel videos" do + def lockup_video(metadata_rows : Array(JSON::Any)) : JSON::Any + JSON.parse({ + "lockupViewModel" => { + "contentType" => "LOCKUP_CONTENT_TYPE_VIDEO", + "contentId" => "video123", + "contentImage" => { + "thumbnailViewModel" => { + "image" => { + "sources" => [{"url" => "https://example.invalid/thumb.jpg"}], + }, + "overlays" => [{ + "thumbnailBottomOverlayViewModel" => { + "badges" => [{ + "thumbnailBadgeViewModel" => {"text" => "12:34"}, + }], + }, + }], + }, + }, + "metadata" => { + "lockupMetadataViewModel" => { + "title" => {"content" => "Collaboration video"}, + "metadata" => { + "contentMetadataViewModel" => { + "metadataRows" => metadata_rows.map(&.raw), + }, + }, + }, + }, + }, + }.to_json) + end + + it "parses a normal first-row metadata layout" do + rows = [ + JSON.parse({ + "metadataParts" => [ + {"text" => {"content" => "1.5M views"}}, + {"text" => {"content" => "3 days ago"}}, + ], + }.to_json), + ] + + result = parse_item(lockup_video(rows), "Example", "UC123") + expect(result).to be_a(SearchVideo) + + video = result.as(SearchVideo) + expect(video.views).to eq(1_500_000) + expect(video.published).to be_close(Time.utc - 3.days, 2.seconds) + expect(video.length_seconds).to eq(754) + end + + it "parses metadata after a collaboration author row" do + rows = [ + JSON.parse({ + "metadataParts" => [ + {"text" => {"content" => "Example and Collaborator"}}, + ], + }.to_json), + JSON.parse({ + "metadataParts" => [ + {"text" => {"content" => "1.5M views"}}, + {"text" => {"content" => "3 days ago"}}, + ], + }.to_json), + ] + + result = parse_item(lockup_video(rows), "Example", "UC123") + expect(result).to be_a(SearchVideo) + + video = result.as(SearchVideo) + expect(video.views).to eq(1_500_000) + expect(video.published).to be_close(Time.utc - 3.days, 2.seconds) + expect(video.author).to eq("Example") + expect(video.ucid).to eq("UC123") + end + + it "skips metadata rows that do not contain metadataParts" do + rows = [ + JSON.parse("{}"), + JSON.parse({ + "metadataParts" => [ + {"text" => {"content" => "1.5M views"}}, + {"text" => {"content" => "3 days ago"}}, + ], + }.to_json), + ] + + result = parse_item(lockup_video(rows), "Example", "UC123") + expect(result).to be_a(SearchVideo) + + video = result.as(SearchVideo) + expect(video.views).to eq(1_500_000) + expect(video.published).to be_close(Time.utc - 3.days, 2.seconds) + end +end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b2226e74d..5d05c3f20 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -662,7 +662,12 @@ 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 + # Collaboration videos can prepend author rows before the actual video metadata, + # so collect metadata parts from every row and skip rows without metadataParts. + metadata_rows = metadata.dig?("metadata", "contentMetadataViewModel", "metadataRows").try &.as_a + metadata_parts = metadata_rows.try &.compact_map { |row| + row["metadataParts"]?.try &.as_a + }.try &.flatten 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