From 6aeb1476b15791cd67ff6e09430a885744838277 Mon Sep 17 00:00:00 2001 From: privacyguy123 <50337995+privacyguy123@users.noreply.github.com> Date: Sat, 19 Sep 2026 19:26:24 +0100 Subject: [PATCH] fix: read video statistics across lockup metadata rows --- spec/invidious/lockup_metadata_rows_spec.cr | 62 +++++++++++++++++++++ src/invidious/yt_backend/extractors.cr | 9 +-- 2 files changed, 67 insertions(+), 4 deletions(-) create mode 100644 spec/invidious/lockup_metadata_rows_spec.cr diff --git a/spec/invidious/lockup_metadata_rows_spec.cr b/spec/invidious/lockup_metadata_rows_spec.cr new file mode 100644 index 000000000..a9de11db0 --- /dev/null +++ b/spec/invidious/lockup_metadata_rows_spec.cr @@ -0,0 +1,62 @@ +require "../parsers_helper.cr" + +private def lockup_video_with_metadata(metadata : String) + JSON.parse(<<-JSON) + { + "lockupViewModel": { + "contentType": "LOCKUP_CONTENT_TYPE_VIDEO", + "contentId": "abcdefghijk", + "contentImage": { + "thumbnailViewModel": { + "image": {"sources": [{"url": "https://i.ytimg.com/vi/abcdefghijk/hqdefault.jpg"}]}, + "overlays": [{"thumbnailBottomOverlayViewModel": { + "badges": [{"thumbnailBadgeViewModel": {"text": "12:34"}}] + }}] + } + }, + "metadata": { + "lockupMetadataViewModel": { + "title": {"content": "A collaboration"}, + "metadata": {"contentMetadataViewModel": #{metadata}} + } + } + } + } + JSON +end + +Spectator.describe "lockup video metadata rows" do + sample({ + "normal first row" => %({"metadataRows":[{"metadataParts":[{"text":{"content":"1.5M views"}},{"text":{"content":"3 days ago"}}]}]}), + "collaborators before video statistics" => %({"metadataRows":[{"metadataParts":[{"text":{"content":"Channel A and Channel B"}}]},{"metadataParts":[{"text":{"content":"1.5M views"}},{"text":{"content":"3 days ago"}}]}]}), + "collaborator names containing statistic substrings" => %({"metadataRows":[{"metadataParts":[{"text":{"content":"Chicago Reviews"}},{"text":{"content":"Tech reviews"}}]},{"metadataParts":[{"text":{"content":"1.5M views"}},{"text":{"content":"3 days ago"}}]}]}), + "views and publication date in separate rows" => %({"metadataRows":[{"metadataParts":[{"text":{"content":"1.5M views"}}]},{"metadataParts":[{"text":{"content":"3 days ago"}}]}]}), + "missing and null metadata parts before statistics" => %({"metadataRows":[{},{"metadataParts":null},{"metadataParts":[{"text":{"content":"1.5M views"}},{"text":{"content":"3 days ago"}}]}]}), + "icon labels before video statistics" => %({"metadataRows":[{"metadataParts":[{"icon":{},"text":{"content":"99 views"}},{"icon":{},"text":{"content":"9 days ago"}}]},{"metadataParts":[{"text":{"content":"1.5M views"}},{"text":{"content":"3 days ago"}}]}]}), + }) do |entry| + it "parses the video statistics and preserves other metadata" do + before = Time.utc - 3.days + video = parse_item(lockup_video_with_metadata(entry[1]), "Channel A", "UCexample").as(SearchVideo) + after = Time.utc - 3.days + + expect(video.views).to eq(1_500_000) + expect(video.published).to be_between(before, after) + expect(video.title).to eq("A collaboration") + expect(video.id).to eq("abcdefghijk") + expect(video.author).to eq("Channel A") + expect(video.ucid).to eq("UCexample") + expect(video.length_seconds).to eq(754) + end + end + + sample({"{}", %({"metadataRows":[]}), %({"metadataRows":null}), %({"metadataRows":[{}, {"metadataParts":null}]})}) do |metadata| + it "keeps unknown statistics defaults" do + before = Time.utc + video = parse_item(lockup_video_with_metadata(metadata)).as(SearchVideo) + after = Time.utc + + expect(video.views).to eq(0) + expect(video.published).to be_between(before, after) + end + end +end diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index b2226e74d..feb849acd 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -661,12 +661,13 @@ 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 put authors before the views and published time. + metadata_rows = metadata.dig?("metadata", "contentMetadataViewModel", "metadataRows").try &.as_a? + metadata_parts = metadata_rows.try &.compact_map(&.dig?("metadataParts").try &.as_a?).flatten - 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.matches?(/\A(?:\d+(?:[.,]\d+)*\s?[kKmMbB]?|No) views\z/) } .try &.dig("text", "content").as_s - published = metadata_parts.try &.find { |item| item["icon"]?.nil? && item.dig?("text", "content").try &.as_s.includes?("ago") } + published = metadata_parts.try &.find { |item| item["icon"]?.nil? && item.dig?("text", "content").try &.as_s.matches?(/(?:\A|\s)\d+ ?[smhdwy]\w* ago\z/) } .try { |item| decode_date(item.dig("text", "content").as_s) } || Time.local view_count = short_text_to_number(view_count_text || "0")