diff --git a/spec/invidious/videos/captions_matching_spec.cr b/spec/invidious/videos/captions_matching_spec.cr new file mode 100644 index 000000000..7ad0abde2 --- /dev/null +++ b/spec/invidious/videos/captions_matching_spec.cr @@ -0,0 +1,68 @@ +require "../../spec_helper" + +Spectator.describe Invidious::Videos::Captions do + describe ".matching" do + it "matches English to English auto-generated ahead of other auto tracks" do + arabic = Invidious::Videos::Captions::Metadata.new("Arabic (auto-generated)", "ar", "http://x", true) + english_auto = Invidious::Videos::Captions::Metadata.new("English (auto-generated)", "en", "http://x", true) + + matched = Invidious::Videos::Captions.matching([arabic, english_auto], ["English"]) + expect(matched.map(&.name)).to eq(["English (auto-generated)"]) + end + + it "prefers a human English track over auto-generated English" do + english_auto = Invidious::Videos::Captions::Metadata.new("English (auto-generated)", "en", "http://x", true) + english = Invidious::Videos::Captions::Metadata.new("English", "en", "http://x", false) + + matched = Invidious::Videos::Captions.matching([english_auto, english], ["English"]) + expect(matched.map(&.name)).to eq(["English", "English (auto-generated)"]) + end + + it "matches regional English names and language codes" do + us = Invidious::Videos::Captions::Metadata.new("English (United States)", "en-US", "http://x", false) + uk = Invidious::Videos::Captions::Metadata.new("English (United Kingdom)", "en-GB", "http://x", false) + german = Invidious::Videos::Captions::Metadata.new("German (Germany)", "de-DE", "http://x", false) + + matched = Invidious::Videos::Captions.matching([german, us, uk], ["English", "en"]) + expect(matched.map(&.name)).to eq(["English (United States)", "English (United Kingdom)"]) + end + + it "matches English preference to a code-labeled en track" do + code_only = Invidious::Videos::Captions::Metadata.new("en", "en", "http://x", true) + arabic = Invidious::Videos::Captions::Metadata.new("ar", "ar", "http://x", true) + + matched = Invidious::Videos::Captions.matching([arabic, code_only], ["English"]) + expect(matched.map(&.name)).to eq(["en"]) + end + + it "matches English preference to a code-labeled en-US track" do + regional = Invidious::Videos::Captions::Metadata.new("en-US", "en-US", "http://x", false) + german = Invidious::Videos::Captions::Metadata.new("de-DE", "de-DE", "http://x", false) + + matched = Invidious::Videos::Captions.matching([german, regional], ["English"]) + expect(matched.map(&.name)).to eq(["en-US"]) + end + + it "prefers en-US over en-GB for English (United States)" do + us = Invidious::Videos::Captions::Metadata.new("en-US", "en-US", "http://x", false) + uk = Invidious::Videos::Captions::Metadata.new("en-GB", "en-GB", "http://x", false) + + matched = Invidious::Videos::Captions.matching([uk, us], ["English (United States)"]) + expect(matched.map(&.name)).to eq(["en-US", "en-GB"]) + end + + it "prefers human base-language English over exact regional auto-generated" do + human = Invidious::Videos::Captions::Metadata.new("English", "en", "http://x", false) + auto_us = Invidious::Videos::Captions::Metadata.new("en-US", "en-US", "http://x", true) + + matched = Invidious::Videos::Captions.matching([auto_us, human], ["English (United States)"]) + expect(matched.map(&.name)).to eq(["English", "en-US"]) + end + + it "ignores blank preference slots" do + arabic = Invidious::Videos::Captions::Metadata.new("Arabic (auto-generated)", "ar", "http://x", true) + matched = Invidious::Videos::Captions.matching([arabic], ["", "", ""]) + expect(matched).to be_empty + end + end +end diff --git a/src/invidious/routes/embed.cr b/src/invidious/routes/embed.cr index 41dc517c2..bde4f2271 100644 --- a/src/invidious/routes/embed.cr +++ b/src/invidious/routes/embed.cr @@ -182,14 +182,7 @@ module Invidious::Routes::Embed captions = video.captions - preferred_captions = captions.select { |caption| - params.preferred_captions.includes?(caption.name) || - params.preferred_captions.includes?(caption.language_code.split("-")[0]) - } - preferred_captions.sort_by! { |caption| - (params.preferred_captions.index(caption.name) || - params.preferred_captions.index(caption.language_code.split("-")[0])).not_nil! - } + preferred_captions = Invidious::Videos::Captions.matching(captions, params.preferred_captions) captions = captions - preferred_captions aspect_ratio = nil diff --git a/src/invidious/routes/watch.cr b/src/invidious/routes/watch.cr index 3c66f1bc0..f26bc6119 100644 --- a/src/invidious/routes/watch.cr +++ b/src/invidious/routes/watch.cr @@ -149,14 +149,7 @@ module Invidious::Routes::Watch captions = video.captions - preferred_captions = captions.select { |caption| - params.preferred_captions.includes?(caption.name) || - params.preferred_captions.includes?(caption.language_code.split("-")[0]) - } - preferred_captions.sort_by! { |caption| - (params.preferred_captions.index(caption.name) || - params.preferred_captions.index(caption.language_code.split("-")[0])).not_nil! - } + preferred_captions = Invidious::Videos::Captions.matching(captions, params.preferred_captions) captions = captions - preferred_captions aspect_ratio = "16:9" diff --git a/src/invidious/videos/caption.cr b/src/invidious/videos/caption.cr index c811cfe1b..bdc498c96 100644 --- a/src/invidious/videos/caption.cr +++ b/src/invidious/videos/caption.cr @@ -86,6 +86,132 @@ module Invidious::Videos end end + # Tracks whose name or language matches the user's caption preferences, + # ranked by preference slot, then human tracks before auto-generated, + # then more-specific language matches ahead of base-language fallbacks. + def self.matching(captions : Array(Metadata), names : Array(String)) : Array(Metadata) + wanted = names.map(&.strip).reject(&.empty?) + return [] of Metadata if wanted.empty? + + selected = captions.select { |caption| matches?(caption, wanted) } + selected.sort_by! { |caption| rank(caption, wanted) } + selected + end + + def self.matches?(caption : Metadata, names : Array(String)) : Bool + names.any? { |name| name_matches?(caption, name) } + end + + # Common LANGUAGES names mapped to ISO 639-1 / BCP-47 codes. + # Auto-generated suffixes are stripped before lookup; regional + # qualifiers keep their more specific code and fall back to the base. + NAME_TO_CODE = { + "arabic" => "ar", + "chinese" => "zh", + "chinese (china)" => "zh-cn", + "chinese (hong kong)" => "zh-hk", + "chinese (simplified)" => "zh-hans", + "chinese (taiwan)" => "zh-tw", + "chinese (traditional)" => "zh-hant", + "dutch" => "nl", + "english" => "en", + "english (united kingdom)" => "en-gb", + "english (united states)" => "en-us", + "filipino" => "fil", + "french" => "fr", + "german" => "de", + "indonesian" => "id", + "italian" => "it", + "japanese" => "ja", + "korean" => "ko", + "portuguese" => "pt", + "portuguese (brazil)" => "pt-br", + "russian" => "ru", + "spanish" => "es", + "spanish (latin america)" => "es-419", + "spanish (mexico)" => "es-mx", + "spanish (spain)" => "es-es", + "turkish" => "tr", + "vietnamese" => "vi", + } + + private def self.name_matches?(caption : Metadata, name : String) : Bool + !match_index(caption, name).nil? + end + + # Tokens from most specific to least: "English (United States)" -> + # "english (united states)", "en-us", "english", "en". + private def self.match_tokens(name : String) : Array(String) + token = name.strip.downcase + return [] of String if token.empty? + + lookup = token + if lookup.ends_with?(" (auto-generated)") + lookup = lookup.rchop(" (auto-generated)") + end + + tokens = [lookup] + if mapped = NAME_TO_CODE[lookup]? + tokens << mapped + end + + base_name = lookup.split(" - ")[0].split(" (")[0].strip + if !base_name.empty? && base_name != lookup + tokens << base_name + if mapped = NAME_TO_CODE[base_name]? + tokens << mapped + end + end + + if lookup.includes?("-") + tokens << lookup.split("-", 2)[0] + elsif lookup.size.in?(2..3) && lookup.chars.all?(&.ascii_letter?) + tokens << lookup + end + + tokens.uniq + end + + private def self.match_index(caption : Metadata, name : String) : Int32? + needles = match_tokens(name) + return nil if needles.empty? + + caption_name = caption.name.strip.downcase + lang = caption.language_code.strip.downcase + base_lang = lang.split("-", 2)[0] + caption_tokens = match_tokens(caption.name) + caption_tokens << lang + caption_tokens << base_lang + + needles.each_with_index do |needle, index| + if caption_name == needle || + caption_name.starts_with?(needle + " (") || + caption_name.starts_with?(needle + " - ") || + lang == needle || + (base_lang == needle && !needle.includes?("-")) || + caption_tokens.includes?(needle) + return index + end + end + + nil + end + + private def self.rank(caption : Metadata, names : Array(String)) : Tuple(Int32, Int32, Int32) + pref_rank = names.size + specificity = Int32::MAX + names.each_with_index do |name, index| + if found = match_index(caption, name) + pref_rank = index + specificity = found + break + end + end + + auto_rank = caption.auto_generated ? 1 : 0 + {pref_rank, auto_rank, specificity} + end + # List of all caption languages available on Youtube. LANGUAGES = { "",