diff --git a/spec/invidious/utils_spec.cr b/spec/invidious/utils_spec.cr index 7c2c27114..c576d5670 100644 --- a/spec/invidious/utils_spec.cr +++ b/spec/invidious/utils_spec.cr @@ -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("#music") + end + + it "links a plain hashtag" do + expect(linkify_hashtags("hello #music")).to eq("hello #music") + end + end end diff --git a/src/invidious/channels/about.cr b/src/invidious/channels/about.cr index 6213bdea8..765a196bb 100644 --- a/src/invidious/channels/about.cr +++ b/src/invidious/channels/about.cr @@ -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 diff --git a/src/invidious/helpers/utils.cr b/src/invidious/helpers/utils.cr index f0efa245f..8c3168619 100644 --- a/src/invidious/helpers/utils.cr +++ b/src/invidious/helpers/utils.cr @@ -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 << %(#{match[0]}) + raw_title.scan(/#([a-zA-Z0-9_]+)/) do |match| + str << HTML.escape(raw_title[pos...match.begin(0)]) + str << %(#{HTML.escape(match[0])}) pos = match.end(0) end - str << title[pos..] + str << HTML.escape(raw_title[pos..]) end end diff --git a/src/invidious/videos/parser.cr b/src/invidious/videos/parser.cr index 3fc5e34eb..4dab5a489 100644 --- a/src/invidious/videos/parser.cr +++ b/src/invidious/videos/parser.cr @@ -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") diff --git a/src/invidious/views/watch.ecr b/src/invidious/views/watch.ecr index 266065fbc..4788ab31a 100644 --- a/src/invidious/views/watch.ecr +++ b/src/invidious/views/watch.ecr @@ -94,7 +94,7 @@ we're going to need to do it here in order to allow for translations.

<% if video.title.matches?(/#\w/) %> - <%= linkify_hashtags(title) %> + <%= linkify_hashtags(video.title) %> <% else %> <%= title %> <% end %> diff --git a/src/invidious/yt_backend/extractors.cr b/src/invidious/yt_backend/extractors.cr index 9c813522e..f235d4e51 100644 --- a/src/invidious/yt_backend/extractors.cr +++ b/src/invidious/yt_backend/extractors.cr @@ -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")