Fix CodeRabbit review findings

- Set description_node to nil for absent interactiveTabbedHeaderRenderer
  so description_node.as_s does not TypeCastError (PR #5984 finding)
- Validate byline browseId as non-empty string before selecting channel run
- Token-aware view-count/relative-date metadata matching in lockupViewModel
  parser prevents author rows like 'Chicago' from being misread
- linkify_hashtags accepts raw title, HTML-escapes internally, preventing
  false /hashtag/39 links from escaped apostrophes; template passes raw title
- Add regression tests for hashtag apostrophe handling
This commit is contained in:
hibutwhy 2026-08-24 01:02:53 -07:00
parent 1832a12d29
commit ee3711dc8b
6 changed files with 36 additions and 12 deletions

View File

@ -43,4 +43,17 @@ Spectator.describe "Utils" do
expect(decode_date("8 years ago")).to be_close(Time.utc - 8.years, 500.milliseconds)
end
end
describe "linkify_hashtags" do
it "escapes apostrophes without creating false hashtag links" do
result = linkify_hashtags("Rock 'n' Roll `#music`")
expect(result).to_not contain("/hashtag/39")
expect(result).to contain("'")
expect(result).to contain("<a href=\"/hashtag/music\">#music</a>")
end
it "links a plain hashtag" do
expect(linkify_hashtags("hello #music")).to eq("hello <a href=\"/hashtag/music\">#music</a>")
end
end
end

View File

@ -75,7 +75,7 @@ def get_about_info(ucid) : AboutChannel
author_url = "https://www.youtube.com/channel/#{ucid}"
author_thumbnail = phr_vm.try &.dig?("animatedImage", "contentPreviewImageViewModel", "image", "sources", 0, "url").try &.as_s || ""
banner = nil
description_node = JSON.parse(%({"simpleText": ""}))
description_node = nil
tags = [] of String
else
author = ithr["title"]["simpleText"].as_s

View File

@ -386,15 +386,15 @@ def parse_link_endpoint(endpoint : JSON::Any, text : String, video_id : String)
return text
end
def linkify_hashtags(title : String) : String
def linkify_hashtags(raw_title : String) : String
String.build do |str|
pos = 0
title.scan(/#([a-zA-Z0-9_]+)/) do |match|
str << title[pos...match.begin(0)]
str << %(<a href="/hashtag/#{URI.encode_path(match[1])}">#{match[0]}</a>)
raw_title.scan(/#([a-zA-Z0-9_]+)/) do |match|
str << HTML.escape(raw_title[pos...match.begin(0)])
str << %(<a href="/hashtag/#{URI.encode_path(match[1])}">#{HTML.escape(match[0])}</a>)
pos = match.end(0)
end
str << title[pos..]
str << HTML.escape(raw_title[pos..])
end
end

View File

@ -20,14 +20,16 @@ module Invidious::Videos::Parser
end
# Both have "short", so the "long" option shouldn't be required
# Use the first run that has a valid browse endpoint.
# Use the first run that has a non-empty string browseId.
# For collaboration videos, the first run is a plain text summary
# without a link, while subsequent runs contain the channel links.
byline_runs = (related["shortBylineText"]? || related["longBylineText"]?)
.try &.dig?("runs").try &.as_a
channel_info = byline_runs.try &.find do |run|
run.dig?("navigationEndpoint", "browseEndpoint", "browseId")
browse_id = run.dig?("navigationEndpoint", "browseEndpoint", "browseId")
text_id = browse_id.try &.as_s?
!text_id.nil? && !text_id.empty?
end || byline_runs.try &.[0]?
author = channel_info.try &.dig?("text")

View File

@ -94,7 +94,7 @@ we're going to need to do it here in order to allow for translations.
<div class="h-box">
<h1>
<% if video.title.matches?(/#\w/) %>
<%= linkify_hashtags(title) %>
<%= linkify_hashtags(video.title) %>
<% else %>
<%= title %>
<% end %>

View File

@ -642,6 +642,13 @@ private module Parsers
extend self
include BaseParser
private VIEW_COUNT_TOKEN = /\d+\s+views/
private RELATIVE_DATE_TOKEN = /\d+\s+(second|minute|hour|day|week|month|year)s?\s+ago/
private def metadata_part_text(item : JSON::Any) : String?
item.dig?("text", "content").try &.as_s
end
def process(item : JSON::Any, author_fallback : AuthorFallback)
if item_contents = item["lockupViewModel"]?
return self.parse(item_contents, author_fallback)
@ -664,14 +671,16 @@ private module Parsers
# Contains the views of the video and the published time of the video.
# For collaboration videos, the first row contains the author names
# instead, so we scan all rows for the one with view/publish info.
# Token-aware matching prevents author rows (e.g. "Chicago") from
# being mistaken for view-count or relative-date metadata.
metadata_parts = metadata.dig?("metadata", "contentMetadataViewModel", "metadataRows")
.try &.as_a
.compact_map { |row| row.dig?("metadataParts").try &.as_a }
.find { |parts| parts.any? { |item| item.dig?("text", "content").try &.as_s.includes?("views") || item.dig?("text", "content").try &.as_s.includes?("ago") } }
.find { |parts| parts.any? { |item| metadata_part_text(item).try { |t| t.matches?(VIEW_COUNT_TOKEN) || t.matches?(RELATIVE_DATE_TOKEN) } } }
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? && metadata_part_text(item).try(&.matches?(VIEW_COUNT_TOKEN)) }
.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? && metadata_part_text(item).try(&.matches?(RELATIVE_DATE_TOKEN)) }
.try { |item| decode_date(item.dig("text", "content").as_s) } || Time.local
view_count = short_text_to_number(view_count_text || "0")