From 2235b0b6f8187a627bbfdc4a6a15185fb8340b30 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:33:05 +0300 Subject: [PATCH 1/6] feat(innertube): send the content language outside player requests --- src-tauri/src/api/innertube/core/clients.rs | 11 ++- src-tauri/src/api/innertube/core/http.rs | 7 +- src-tauri/src/api/innertube/core/locale.rs | 88 +++++++++++++++++++ src-tauri/src/api/innertube/core/mod.rs | 1 + .../src/api/innertube/extractors/player.rs | 2 +- .../src/api/innertube/transport/request.rs | 20 +++-- src-tauri/src/commands/db.rs | 7 +- 7 files changed, 124 insertions(+), 12 deletions(-) create mode 100644 src-tauri/src/api/innertube/core/locale.rs diff --git a/src-tauri/src/api/innertube/core/clients.rs b/src-tauri/src/api/innertube/core/clients.rs index 0aafb293..c9a18f21 100644 --- a/src-tauri/src/api/innertube/core/clients.rs +++ b/src-tauri/src/api/innertube/core/clients.rs @@ -90,10 +90,17 @@ impl YouTubeClient { /// deterministic regardless of where the user is. #[must_use] pub fn context(&self, visitor_data: Option<&str>, gl: Option<&str>) -> Value { + self.context_in(visitor_data, gl, &super::locale::content_language()) + } + + /// [`Self::context`] in a given host language. Player requests use English, + /// which every playability rule reads. + #[must_use] + pub fn context_in(&self, visitor_data: Option<&str>, gl: Option<&str>, hl: &str) -> Value { let mut client = json!({ "clientName": self.name, "clientVersion": self.version, - "hl": "en", + "hl": hl, "gl": gl.unwrap_or("US"), // Times YouTube writes out ("Scheduled for 7:00 PM") are rendered in // this offset, so it is the device's own. @@ -137,7 +144,7 @@ impl YouTubeClient { visitor_data: Option<&str>, po_token: Option<&str>, ) -> Value { - let mut context = self.context(visitor_data, None); + let mut context = self.context_in(visitor_data, None, "en"); if self.is_embedded { context["thirdParty"] = json!({ "embedUrl": format!("https://www.youtube.com/watch?v={video_id}") diff --git a/src-tauri/src/api/innertube/core/http.rs b/src-tauri/src/api/innertube/core/http.rs index ebf4c868..4f542fcd 100644 --- a/src-tauri/src/api/innertube/core/http.rs +++ b/src-tauri/src/api/innertube/core/http.rs @@ -115,7 +115,12 @@ impl InnertubeClient { false } else { let visitor = self.fetch_visitor_data().await; - ensure_context(payload, client, visitor.as_deref()) + let language = if endpoint == "player" { + "en".to_string() + } else { + super::locale::content_language() + }; + ensure_context(payload, client, visitor.as_deref(), &language) }; let visitor = body_visitor(payload).map(ToOwned::to_owned); match send(http, &spec, payload, visitor.as_deref()).await { diff --git a/src-tauri/src/api/innertube/core/locale.rs b/src-tauri/src/api/innertube/core/locale.rs new file mode 100644 index 00000000..49947c22 --- /dev/null +++ b/src-tauri/src/api/innertube/core/locale.rs @@ -0,0 +1,88 @@ +//! The language the site writes its text in (`hl`): titles stay as uploaded, +//! but dates, counts, labels and auto-translated titles follow it. Player +//! requests always use English, which every playability rule is written for. + +use std::sync::RwLock; + +/// The setting naming the content language; empty means English. +pub const CONTENT_LANGUAGE_SETTING: &str = "content_language"; + +static CONTENT_LANGUAGE: RwLock = RwLock::new(String::new()); + +/// The host language the site accepts for a language tag, as Flow for Android +/// normalises it: the bare language, Brazilian and European Portuguese and the +/// three Chinese variants told apart, and English for anything unusable. +#[must_use] +pub fn normalize_host_language(tag: &str) -> String { + let tag = tag.trim().replace('_', "-"); + let mut subtags = tag.split('-').filter(|part| !part.is_empty()); + let language = subtags.next().unwrap_or_default().to_ascii_lowercase(); + let rest: Vec = subtags.map(str::to_ascii_lowercase).collect(); + let has = |value: &str| rest.iter().any(|part| part == value); + if language.is_empty() + || language == "und" + || language == "system" + || language == "kab" + || !language.chars().all(|c| c.is_ascii_alphabetic()) + { + return "en".to_string(); + } + match language.as_str() { + "pt" if has("br") => "pt-BR".to_string(), + "zh" if has("hk") => "zh-HK".to_string(), + "zh" if has("hant") || has("tw") || has("mo") => "zh-TW".to_string(), + "zh" => "zh-CN".to_string(), + _ => language, + } +} + +/// Set the content language from the saved setting; empty or missing is English. +pub fn set_content_language(setting: Option<&str>) { + let language = setting + .filter(|value| !value.trim().is_empty()) + .map_or_else(|| "en".to_string(), normalize_host_language); + *CONTENT_LANGUAGE + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) = language; +} + +#[must_use] +pub fn content_language() -> String { + let language = CONTENT_LANGUAGE + .read() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .clone(); + if language.is_empty() { + "en".to_string() + } else { + language + } +} + +/// Whether the site's text is English, which the English-only readers assume. +#[must_use] +pub fn is_english() -> bool { + content_language() == "en" +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn host_languages_follow_flow_for_android() { + assert_eq!(normalize_host_language("system"), "en"); + assert_eq!( + normalize_host_language("en-DE-u-fw-mon-ms-metric-mu-celsius"), + "en" + ); + assert_eq!(normalize_host_language("pt-BR"), "pt-BR"); + assert_eq!(normalize_host_language("pt-PT"), "pt"); + assert_eq!(normalize_host_language("zh-Hant-TW-u-nu-hanidec"), "zh-TW"); + assert_eq!(normalize_host_language("zh-Hant-HK"), "zh-HK"); + assert_eq!(normalize_host_language("zh"), "zh-CN"); + assert_eq!(normalize_host_language("kab"), "en"); + assert_eq!(normalize_host_language("und"), "en"); + assert_eq!(normalize_host_language("de_AT"), "de"); + } +} diff --git a/src-tauri/src/api/innertube/core/mod.rs b/src-tauri/src/api/innertube/core/mod.rs index 2155278e..090329be 100644 --- a/src-tauri/src/api/innertube/core/mod.rs +++ b/src-tauri/src/api/innertube/core/mod.rs @@ -4,6 +4,7 @@ pub mod context; pub mod gate_tracker; pub(crate) mod hidden_webview; pub mod http; +pub mod locale; pub(crate) mod playback_session; pub(crate) mod player_js; pub(crate) mod player_requests; diff --git a/src-tauri/src/api/innertube/extractors/player.rs b/src-tauri/src/api/innertube/extractors/player.rs index 08d92afc..04f2b8a3 100644 --- a/src-tauri/src/api/innertube/extractors/player.rs +++ b/src-tauri/src/api/innertube/extractors/player.rs @@ -1723,7 +1723,7 @@ impl InnertubeClient { let _priority = playback_priority().start_metadata().await; let visitor_data = self.fetch_visitor_data().await; let mut payload = serde_json::json!({ - "context": clients::VISIONOS.context(visitor_data.as_deref(), None), + "context": clients::VISIONOS.context_in(visitor_data.as_deref(), None, "en"), "videoId": video_id, "contentCheckOk": true, "racyCheckOk": true, diff --git a/src-tauri/src/api/innertube/transport/request.rs b/src-tauri/src/api/innertube/transport/request.rs index ad1d8c42..179402a3 100644 --- a/src-tauri/src/api/innertube/transport/request.rs +++ b/src-tauri/src/api/innertube/transport/request.rs @@ -94,16 +94,24 @@ pub fn body_visitor(payload: &Value) -> Option<&str> { .filter(|value| !value.is_empty()) } -/// Give `payload` this client's context, carrying `visitor`, unless the caller -/// built its own. Returns whether the context was added here. -pub fn ensure_context(payload: &mut Value, client: &YouTubeClient, visitor: Option<&str>) -> bool { +/// Give `payload` this client's context in `language`, carrying `visitor`, +/// unless the caller built its own. Returns whether the context was added here. +pub fn ensure_context( + payload: &mut Value, + client: &YouTubeClient, + visitor: Option<&str>, + language: &str, +) -> bool { let Some(object) = payload.as_object_mut() else { return false; }; if object.contains_key("context") { return false; } - object.insert("context".to_string(), client.context(visitor, None)); + object.insert( + "context".to_string(), + client.context_in(visitor, None, language), + ); true } @@ -187,12 +195,12 @@ mod tests { #[test] fn context_is_added_only_when_missing() { let mut body = json!({ "browseId": "FEwhat_to_watch" }); - assert!(ensure_context(&mut body, &WEB, Some("v1"))); + assert!(ensure_context(&mut body, &WEB, Some("v1"), "en")); assert_eq!(body_visitor(&body), Some("v1")); assert_eq!(body["context"]["client"]["platform"], "DESKTOP"); let mut own = json!({ "context": { "client": { "clientName": "X" } } }); - assert!(!ensure_context(&mut own, &WEB, Some("v1"))); + assert!(!ensure_context(&mut own, &WEB, Some("v1"), "en")); assert_eq!(body_visitor(&own), None); strip_visitor(&mut body); diff --git a/src-tauri/src/commands/db.rs b/src-tauri/src/commands/db.rs index d653c45a..f68797c0 100644 --- a/src-tauri/src/commands/db.rs +++ b/src-tauri/src/commands/db.rs @@ -1,4 +1,4 @@ -use crate::api::innertube::core::remote_decoder; +use crate::api::innertube::core::{locale, remote_decoder}; use crate::api::innertube::extractors::audio_preference; use crate::db::settings; use crate::db::watch_history::{self, WatchHistoryRecord}; @@ -94,9 +94,10 @@ pub async fn set_setting( } /// Settings the backend acts on itself, applied at startup and whenever saved. -pub(crate) const BACKEND_SETTINGS: [&str; 2] = [ +pub(crate) const BACKEND_SETTINGS: [&str; 3] = [ remote_decoder::SETTING_KEY, audio_preference::STABLE_VOLUME_SETTING, + locale::CONTENT_LANGUAGE_SETTING, ]; pub(crate) fn apply_backend_setting(key: &str, value: Option<&str>) { @@ -105,6 +106,8 @@ pub(crate) fn apply_backend_setting(key: &str, value: Option<&str>) { remote_decoder::set_enabled(enabled); } else if key == audio_preference::STABLE_VOLUME_SETTING { audio_preference::set_stable_volume(enabled); + } else if key == locale::CONTENT_LANGUAGE_SETTING { + locale::set_content_language(value); } } From 7a91be24ce0e63de556a031a7429d774e04154da Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:33:05 +0300 Subject: [PATCH 2/6] feat(innertube): read upload ages and counts in the content language --- src-tauri/src/api/innertube/json/age.rs | 159 ++- .../src/api/innertube/json/age_patterns.rs | 1183 +++++++++++++++++ src-tauri/src/api/innertube/json/count.rs | 88 +- src-tauri/src/api/innertube/json/mod.rs | 1 + 4 files changed, 1425 insertions(+), 6 deletions(-) create mode 100644 src-tauri/src/api/innertube/json/age_patterns.rs diff --git a/src-tauri/src/api/innertube/json/age.rs b/src-tauri/src/api/innertube/json/age.rs index 9611efa2..cc3b8ea4 100644 --- a/src-tauri/src/api/innertube/json/age.rs +++ b/src-tauri/src/api/innertube/json/age.rs @@ -1,6 +1,8 @@ //! Relative upload ages as feeds write them: "3 days ago", "Streamed 5 hours ago", and the //! compact "2d ago" that related-video and lockup cards use. +use super::age_patterns::{AgePatterns, PATTERNS}; + const SECOND_MS: u64 = 1_000; const MINUTE_MS: u64 = 60 * SECOND_MS; const HOUR_MS: u64 = 60 * MINUTE_MS; @@ -76,10 +78,93 @@ impl RelativeAge { /// The age `text` names, or none when it names no age ("Premieres 10/12/26", "1.2M views"). /// -/// A compact age only counts when "ago" follows it, so "4K", "21M" or "2m views" never read as -/// ages. +/// Read as English first, then in the content language when that is another one. #[must_use] pub fn relative_age(text: &str) -> Option { + english_age(text).or_else(|| { + let language = crate::api::innertube::core::locale::content_language(); + (language != "en") + .then(|| relative_age_in(text, &language)) + .flatten() + }) +} + +/// The age `text` names in `language`, by that language's unit words. +#[must_use] +pub fn relative_age_in(text: &str, language: &str) -> Option { + let patterns = patterns_for(language)?; + let lower = text.to_lowercase(); + if let Some((unit, _, value)) = patterns + .special + .iter() + .find(|(_, phrase, _)| phrase_in(&lower, &phrase.to_lowercase(), patterns.separator)) + { + return Some(RelativeAge { + value: *value, + unit: *unit, + }); + } + let unit = patterns.units.iter().find_map(|(unit, words)| { + words + .iter() + .any(|word| phrase_in(&lower, &word.to_lowercase(), patterns.separator)) + .then_some(*unit) + })?; + // "vor 6 Tagen", "8 か月前": the text's number, or one when it names none ("a week"). + let value = lower + .split(|c: char| !c.is_ascii_digit()) + .find(|digits| !digits.is_empty()) + .and_then(|digits| digits.parse().ok()) + .unwrap_or(1); + Some(RelativeAge { value, unit }) +} + +/// The table for a host language: its own, else its base language's. +fn patterns_for(language: &str) -> Option<&'static AgePatterns> { + let tag = language.replace('_', "-"); + let base = tag.split('-').next().unwrap_or_default(); + let aliases: &[&str] = match tag.as_str() { + "pt-BR" => &["pt"], + "pt" => &["pt-PT", "pt"], + "he" => &["iw"], + "nb" | "nn" => &["no"], + "tl" => &["fil"], + "zh" => &["zh-CN"], + _ => &[], + }; + std::iter::once(tag.as_str()) + .chain(aliases.iter().copied()) + .chain(std::iter::once(base)) + .find_map(|candidate| { + PATTERNS + .iter() + .find(|patterns| patterns.language.eq_ignore_ascii_case(candidate)) + }) +} + +/// Whether `phrase` stands in `text` as a word: bounded by spaces or digits, +/// or anywhere for languages written without spaces. +fn phrase_in(text: &str, phrase: &str, separator: &str) -> bool { + if phrase.is_empty() { + return false; + } + if separator.is_empty() { + return text.contains(phrase); + } + let bound = |c: Option| { + c.is_none_or(|c| c.is_whitespace() || c.is_ascii_digit() || c == '\u{202f}') + }; + text.match_indices(phrase).any(|(start, matched)| { + bound(text[..start].chars().next_back()) + && bound(text[start + matched.len()..].chars().next()) + }) +} + +/// "3 days ago", "Streamed 5 hours ago" and the compact "2d ago". +/// +/// A compact age only counts when "ago" follows it, so "4K", "21M" or "2m views" never read as +/// ages. +fn english_age(text: &str) -> Option { let lower = text.to_lowercase(); let words: Vec<&str> = lower .split_whitespace() @@ -127,6 +212,76 @@ pub fn relative_age_ms(text: &str) -> Option { mod tests { use super::*; + #[test] + fn ages_are_read_in_the_languages_the_site_writes() { + let days = |value| { + Some(RelativeAge { + value, + unit: AgeUnit::Day, + }) + }; + assert_eq!(relative_age_in("vor 6 Tagen", "de"), days(6)); + assert_eq!( + relative_age_in("il y a 20 heures", "fr"), + Some(RelativeAge { + value: 20, + unit: AgeUnit::Hour + }) + ); + assert_eq!( + relative_age_in("il y a 2 semaines", "fr"), + Some(RelativeAge { + value: 2, + unit: AgeUnit::Week + }) + ); + assert_eq!( + relative_age_in("8 か月前", "ja"), + Some(RelativeAge { + value: 8, + unit: AgeUnit::Month + }) + ); + assert_eq!( + relative_age_in("hace 16 años", "es"), + Some(RelativeAge { + value: 16, + unit: AgeUnit::Year + }) + ); + assert_eq!( + relative_age_in("4 месяца назад", "ru"), + Some(RelativeAge { + value: 4, + unit: AgeUnit::Month + }) + ); + assert_eq!(relative_age_in("5 дней назад", "ru"), days(5)); + assert_eq!( + relative_age_in("há 2 horas", "pt-BR"), + Some(RelativeAge { + value: 2, + unit: AgeUnit::Hour + }) + ); + assert_eq!(relative_age_in("לפני יומיים", "he"), days(2)); + assert_eq!(relative_age_in("3天前", "zh-CN"), days(3)); + } + + #[test] + fn a_word_inside_another_is_not_a_unit() { + // Spanish "mes" (month) must not match inside "meses"'s neighbours, nor "an" inside "dans". + assert_eq!( + relative_age_in("dans 3 jours", "fr"), + Some(RelativeAge { + value: 3, + unit: AgeUnit::Day + }) + ); + assert_eq!(relative_age_in("Premiere am 12.10.2026", "de"), None); + assert_eq!(relative_age_in("621.747 Aufrufe", "de"), None); + } + fn age(value: u64, unit: AgeUnit) -> RelativeAge { RelativeAge { value, unit } } diff --git a/src-tauri/src/api/innertube/json/age_patterns.rs b/src-tauri/src/api/innertube/json/age_patterns.rs new file mode 100644 index 00000000..ce9486d0 --- /dev/null +++ b/src-tauri/src/api/innertube/json/age_patterns.rs @@ -0,0 +1,1183 @@ +//! How each language writes an upload's age, unit by unit. +//! +//! Taken from `NewPipe Extractor` v0.26.5 (`org.schabi.newpipe.extractor.timeago.patterns`, +//! `TeamNewPipe`, GPL-3.0), which builds the tables from the site's own strings. Each +//! language names its unit words and the separator between words, empty for +//! languages written without spaces. + +use super::age::AgeUnit; + +pub struct AgePatterns { + pub language: &'static str, + pub separator: &'static str, + pub units: &'static [(AgeUnit, &'static [&'static str])], + /// Whole phrases that name an amount too, such as Hebrew's dual "two days". + pub special: &'static [(AgeUnit, &'static str, u64)], +} + +pub static PATTERNS: &[AgePatterns] = &[ + AgePatterns { + language: "af", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekonde", "sekondes"]), + (AgeUnit::Minute, &["minute", "minuut"]), + (AgeUnit::Hour, &["ure", "uur"]), + (AgeUnit::Day, &["dae", "dag"]), + (AgeUnit::Week, &["week", "weke"]), + (AgeUnit::Month, &["maand", "maande"]), + (AgeUnit::Year, &["jaar"]), + ], + special: &[], + }, + AgePatterns { + language: "am", + separator: " ", + units: &[ + (AgeUnit::Second, &["ሰኮንዶች", "ሴኮንድ"]), + (AgeUnit::Minute, &["ደቂቃ", "ደቂቃዎች"]), + (AgeUnit::Hour, &["ሰዓት", "ሰዓቶች"]), + (AgeUnit::Day, &["ቀን", "ቀኖች"]), + (AgeUnit::Week, &["ሳምንታት", "ሳምንት"]), + (AgeUnit::Month, &["ወራት", "ወር"]), + (AgeUnit::Year, &["ዓመታት", "ዓመት"]), + ], + special: &[], + }, + AgePatterns { + language: "ar", + separator: " ", + units: &[ + (AgeUnit::Second, &["ثانية", "ثانيتين", "ثوانٍ"]), + (AgeUnit::Minute, &["دقائق", "دقيقة", "دقيقتين"]), + (AgeUnit::Hour, &["ساعات", "ساعة", "ساعتين"]), + (AgeUnit::Day, &["أيام", "يوم", "يومين"]), + (AgeUnit::Week, &["أسابيع", "أسبوع", "أسبوعين"]), + (AgeUnit::Month, &["أشهر", "شهر", "شهرين", "شهرًا"]), + (AgeUnit::Year, &["سنة", "سنتين", "سنوات"]), + ], + special: &[], + }, + AgePatterns { + language: "az", + separator: " ", + units: &[ + (AgeUnit::Second, &["saniyə"]), + (AgeUnit::Minute, &["dəqiqə"]), + (AgeUnit::Hour, &["saat"]), + (AgeUnit::Day, &["gün"]), + (AgeUnit::Week, &["həftə"]), + (AgeUnit::Month, &["ay"]), + (AgeUnit::Year, &["il"]), + ], + special: &[], + }, + AgePatterns { + language: "be", + separator: " ", + units: &[ + (AgeUnit::Second, &["секунд", "секунду", "секунды"]), + (AgeUnit::Minute, &["хвілін", "хвіліну", "хвіліны"]), + (AgeUnit::Hour, &["гадзін", "гадзіну", "гадзіны"]), + (AgeUnit::Day, &["дзень", "дзён", "дня", "дні"]), + (AgeUnit::Week, &["тыдзень", "тыдня", "тыдні"]), + (AgeUnit::Month, &["месяц", "месяца", "месяцы", "месяцаў"]), + (AgeUnit::Year, &["год", "года", "гады", "гадоў"]), + ], + special: &[], + }, + AgePatterns { + language: "bg", + separator: " ", + units: &[ + (AgeUnit::Second, &["секунда", "секунди"]), + (AgeUnit::Minute, &["минута", "минути"]), + (AgeUnit::Hour, &["час", "часа"]), + (AgeUnit::Day, &["ден", "дни"]), + (AgeUnit::Week, &["седмица", "седмици"]), + (AgeUnit::Month, &["месец", "месеца"]), + (AgeUnit::Year, &["година", "години"]), + ], + special: &[], + }, + AgePatterns { + language: "bn", + separator: " ", + units: &[ + (AgeUnit::Second, &["সেকেন্ড"]), + (AgeUnit::Minute, &["মিনিট"]), + (AgeUnit::Hour, &["ঘণ্টা"]), + (AgeUnit::Day, &["দিন"]), + (AgeUnit::Week, &["সপ্তাহ"]), + (AgeUnit::Month, &["মাস"]), + (AgeUnit::Year, &["বছর"]), + ], + special: &[], + }, + AgePatterns { + language: "bs", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekundi", "sekunde", "sekundu"]), + (AgeUnit::Minute, &["minuta", "minute", "minutu"]), + (AgeUnit::Hour, &["h", "sat", "sata", "sati"]), + (AgeUnit::Day, &["dan", "dana"]), + (AgeUnit::Week, &["sedm."]), + (AgeUnit::Month, &["mj.", "mjesec", "mjeseca", "mjeseci"]), + (AgeUnit::Year, &["godina", "godine", "godinu"]), + ], + special: &[], + }, + AgePatterns { + language: "ca", + separator: " ", + units: &[ + (AgeUnit::Second, &["segon", "segons"]), + (AgeUnit::Minute, &["minut", "minuts"]), + (AgeUnit::Hour, &["hora", "hores"]), + (AgeUnit::Day, &["dia", "dies"]), + (AgeUnit::Week, &["setmana", "setmanes"]), + (AgeUnit::Month, &["mes", "mesos"]), + (AgeUnit::Year, &["any", "anys"]), + ], + special: &[], + }, + AgePatterns { + language: "cs", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekundami", "sekundou"]), + (AgeUnit::Minute, &["minutami", "minutou"]), + (AgeUnit::Hour, &["hodinami", "hodinou"]), + (AgeUnit::Day, &["dny", "včera"]), + (AgeUnit::Week, &["týdnem", "týdny"]), + (AgeUnit::Month, &["měsícem", "měsíci"]), + (AgeUnit::Year, &["rokem", "roky", "lety"]), + ], + special: &[], + }, + AgePatterns { + language: "da", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekund", "sekunder"]), + (AgeUnit::Minute, &["minut", "minutter"]), + (AgeUnit::Hour, &["time", "timer"]), + (AgeUnit::Day, &["dag", "dage"]), + (AgeUnit::Week, &["uge", "uger"]), + (AgeUnit::Month, &["måned", "måneder"]), + (AgeUnit::Year, &["år"]), + ], + special: &[], + }, + AgePatterns { + language: "de", + separator: " ", + units: &[ + (AgeUnit::Second, &["Sekunde", "Sekunden"]), + (AgeUnit::Minute, &["Minute", "Minuten"]), + (AgeUnit::Hour, &["Stunde", "Stunden"]), + (AgeUnit::Day, &["Tag", "Tagen"]), + (AgeUnit::Week, &["Woche", "Wochen"]), + (AgeUnit::Month, &["Monat", "Monaten"]), + (AgeUnit::Year, &["Jahr", "Jahren"]), + ], + special: &[], + }, + AgePatterns { + language: "el", + separator: " ", + units: &[ + (AgeUnit::Second, &["δευτερόλεπτα", "δευτερόλεπτο"]), + (AgeUnit::Minute, &["λεπτά", "λεπτό"]), + (AgeUnit::Hour, &["ώρα", "ώρες"]), + (AgeUnit::Day, &["ημέρα", "ημέρες"]), + (AgeUnit::Week, &["εβδομάδα", "εβδομάδες"]), + (AgeUnit::Month, &["μήνα", "μήνες"]), + (AgeUnit::Year, &["χρόνια", "χρόνο"]), + ], + special: &[], + }, + AgePatterns { + language: "en", + separator: " ", + units: &[ + (AgeUnit::Second, &["second", "seconds", "sec"]), + (AgeUnit::Minute, &["minute", "minutes", "min"]), + (AgeUnit::Hour, &["hour", "hours", "h"]), + (AgeUnit::Day, &["day", "days", "d"]), + (AgeUnit::Week, &["week", "weeks", "w"]), + (AgeUnit::Month, &["month", "months", "mo"]), + (AgeUnit::Year, &["year", "years", "y"]), + ], + special: &[], + }, + AgePatterns { + language: "en-GB", + separator: " ", + units: &[ + (AgeUnit::Second, &["second", "seconds", "sec"]), + (AgeUnit::Minute, &["minute", "minutes", "min"]), + (AgeUnit::Hour, &["hour", "hours", "hr"]), + (AgeUnit::Day, &["day", "days"]), + (AgeUnit::Week, &["week", "weeks", "wk"]), + (AgeUnit::Month, &["month", "months", "mo"]), + (AgeUnit::Year, &["year", "years", "yr"]), + ], + special: &[], + }, + AgePatterns { + language: "es", + separator: " ", + units: &[ + (AgeUnit::Second, &["segundo", "segundos"]), + (AgeUnit::Minute, &["minuto", "minutos"]), + (AgeUnit::Hour, &["hora", "horas"]), + (AgeUnit::Day, &["día", "días"]), + (AgeUnit::Week, &["semana", "semanas"]), + (AgeUnit::Month, &["mes", "meses"]), + (AgeUnit::Year, &["año", "años"]), + ], + special: &[], + }, + AgePatterns { + language: "es-419", + separator: " ", + units: &[ + (AgeUnit::Second, &["segundo", "segundos"]), + (AgeUnit::Minute, &["minuto", "minutos"]), + (AgeUnit::Hour, &["hora", "horas"]), + (AgeUnit::Day, &["día", "días"]), + (AgeUnit::Week, &["semana", "semanas"]), + (AgeUnit::Month, &["mes", "meses"]), + (AgeUnit::Year, &["año", "años"]), + ], + special: &[], + }, + AgePatterns { + language: "es-US", + separator: " ", + units: &[ + (AgeUnit::Second, &["segundo", "segundos"]), + (AgeUnit::Minute, &["minuto", "minutos"]), + (AgeUnit::Hour, &["hora", "horas"]), + (AgeUnit::Day, &["día", "días"]), + (AgeUnit::Week, &["semana", "semanas"]), + (AgeUnit::Month, &["mes", "meses"]), + (AgeUnit::Year, &["año", "años"]), + ], + special: &[], + }, + AgePatterns { + language: "et", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekund", "sekundit"]), + (AgeUnit::Minute, &["minut", "minutit"]), + (AgeUnit::Hour, &["tund", "tundi"]), + (AgeUnit::Day, &["päev", "päeva"]), + (AgeUnit::Week, &["nädal", "nädalat"]), + (AgeUnit::Month, &["kuu", "kuud"]), + (AgeUnit::Year, &["aasta", "aastat"]), + ], + special: &[], + }, + AgePatterns { + language: "eu", + separator: " ", + units: &[ + (AgeUnit::Second, &["segundo"]), + (AgeUnit::Minute, &["minutu"]), + (AgeUnit::Hour, &["ordu", "ordubete"]), + (AgeUnit::Day, &["egun"]), + (AgeUnit::Week, &["aste", "astebete"]), + (AgeUnit::Month, &["hilabete"]), + (AgeUnit::Year, &["urte", "urtebete"]), + ], + special: &[], + }, + AgePatterns { + language: "fa", + separator: " ", + units: &[ + (AgeUnit::Second, &["ثانیه"]), + (AgeUnit::Minute, &["دقیقه"]), + (AgeUnit::Hour, &["ساعت"]), + (AgeUnit::Day, &["روز"]), + (AgeUnit::Week, &["هفته"]), + (AgeUnit::Month, &["ماه"]), + (AgeUnit::Year, &["سال"]), + ], + special: &[], + }, + AgePatterns { + language: "fi", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekunti", "sekuntia"]), + (AgeUnit::Minute, &["minuutti", "minuuttia"]), + (AgeUnit::Hour, &["tunti", "tuntia"]), + (AgeUnit::Day, &["päivä", "päivää"]), + (AgeUnit::Week, &["viikko", "viikkoa"]), + (AgeUnit::Month, &["kuukausi", "kuukautta"]), + (AgeUnit::Year, &["vuosi", "vuotta"]), + ], + special: &[], + }, + AgePatterns { + language: "fil", + separator: " ", + units: &[ + (AgeUnit::Second, &["segundo"]), + (AgeUnit::Minute, &["minuto"]), + (AgeUnit::Hour, &["oras"]), + (AgeUnit::Day, &["araw"]), + (AgeUnit::Week, &["linggo"]), + (AgeUnit::Month, &["buwan"]), + (AgeUnit::Year, &["taon"]), + ], + special: &[], + }, + AgePatterns { + language: "fr", + separator: " ", + units: &[ + (AgeUnit::Second, &["seconde", "secondes"]), + (AgeUnit::Minute, &["minute", "minutes"]), + (AgeUnit::Hour, &["heure", "heures"]), + (AgeUnit::Day, &["jour", "jours"]), + (AgeUnit::Week, &["semaine", "semaines"]), + (AgeUnit::Month, &["mois"]), + (AgeUnit::Year, &["an", "ans"]), + ], + special: &[], + }, + AgePatterns { + language: "fr-CA", + separator: " ", + units: &[ + (AgeUnit::Second, &["seconde", "secondes"]), + (AgeUnit::Minute, &["minute", "minutes"]), + (AgeUnit::Hour, &["heures", "heure"]), + (AgeUnit::Day, &["jour", "jours"]), + (AgeUnit::Week, &["semaine", "semaines"]), + (AgeUnit::Month, &["mois"]), + (AgeUnit::Year, &["an", "ans"]), + ], + special: &[], + }, + AgePatterns { + language: "gl", + separator: " ", + units: &[ + (AgeUnit::Second, &["segundo", "segundos"]), + (AgeUnit::Minute, &["minuto", "minutos"]), + (AgeUnit::Hour, &["hora", "horas"]), + (AgeUnit::Day, &["día", "días"]), + (AgeUnit::Week, &["semana", "semanas"]), + (AgeUnit::Month, &["mes", "meses"]), + (AgeUnit::Year, &["ano", "anos"]), + ], + special: &[], + }, + AgePatterns { + language: "gu", + separator: " ", + units: &[ + (AgeUnit::Second, &["સેકંડ"]), + (AgeUnit::Minute, &["મિનિટ"]), + (AgeUnit::Hour, &["કલાક"]), + (AgeUnit::Day, &["દિવસ"]), + (AgeUnit::Week, &["અઠવાડિયા"]), + (AgeUnit::Month, &["મહિના"]), + (AgeUnit::Year, &["વર્ષ"]), + ], + special: &[], + }, + AgePatterns { + language: "hi", + separator: " ", + units: &[ + (AgeUnit::Second, &["सेकंड"]), + (AgeUnit::Minute, &["मिनट"]), + (AgeUnit::Hour, &["घंटा", "घंटे"]), + (AgeUnit::Day, &["दिन"]), + (AgeUnit::Week, &["सप्ताह", "हफ़्ते"]), + (AgeUnit::Month, &["महीना", "महीने"]), + (AgeUnit::Year, &["वर्ष"]), + ], + special: &[], + }, + AgePatterns { + language: "hr", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekunde", "sekundi", "sekundu"]), + (AgeUnit::Minute, &["minuta", "minute", "minutu"]), + (AgeUnit::Hour, &["sat", "sata", "sati"]), + (AgeUnit::Day, &["dan", "dana"]), + (AgeUnit::Week, &["tjedan", "tjedna"]), + (AgeUnit::Month, &["mjesec", "mjeseca", "mjeseci"]), + (AgeUnit::Year, &["godina", "godine", "godinu"]), + ], + special: &[], + }, + AgePatterns { + language: "hu", + separator: " ", + units: &[ + (AgeUnit::Second, &["másodperce"]), + (AgeUnit::Minute, &["perce"]), + (AgeUnit::Hour, &["órája"]), + (AgeUnit::Day, &["napja"]), + (AgeUnit::Week, &["hete"]), + (AgeUnit::Month, &["hónapja"]), + (AgeUnit::Year, &["éve"]), + ], + special: &[], + }, + AgePatterns { + language: "hy", + separator: " ", + units: &[ + (AgeUnit::Second, &["վայրկյան"]), + (AgeUnit::Minute, &["րոպե"]), + (AgeUnit::Hour, &["ժամ"]), + (AgeUnit::Day, &["օր"]), + (AgeUnit::Week, &["շաբաթ"]), + (AgeUnit::Month, &["ամիս"]), + (AgeUnit::Year, &["տարի"]), + ], + special: &[], + }, + AgePatterns { + language: "id", + separator: " ", + units: &[ + (AgeUnit::Second, &["detik"]), + (AgeUnit::Minute, &["menit"]), + (AgeUnit::Hour, &["jam"]), + (AgeUnit::Day, &["hari"]), + (AgeUnit::Week, &["minggu"]), + (AgeUnit::Month, &["bulan"]), + (AgeUnit::Year, &["tahun"]), + ], + special: &[], + }, + AgePatterns { + language: "is", + separator: " ", + units: &[ + ( + AgeUnit::Second, + &["sekúndu", "sekúndum", "second", "seconds"], + ), + (AgeUnit::Minute, &["mínútu", "mínútum", "minute", "minutes"]), + ( + AgeUnit::Hour, + &["klukkustund", "klukkustundum", "hour", "hours"], + ), + (AgeUnit::Day, &["degi", "dögum", "day", "days"]), + (AgeUnit::Week, &["viku", "vikum", "week", "weeks"]), + (AgeUnit::Month, &["mánuði", "mánuðum"]), + (AgeUnit::Year, &["ári", "árum"]), + ], + special: &[], + }, + AgePatterns { + language: "it", + separator: " ", + units: &[ + (AgeUnit::Second, &["secondi", "secondo"]), + (AgeUnit::Minute, &["minuti", "minuto"]), + (AgeUnit::Hour, &["ora", "ore"]), + (AgeUnit::Day, &["giorni", "giorno"]), + (AgeUnit::Week, &["settimana", "settimane"]), + (AgeUnit::Month, &["mese", "mesi"]), + (AgeUnit::Year, &["anni", "anno"]), + ], + special: &[], + }, + AgePatterns { + language: "iw", + separator: " ", + units: &[ + (AgeUnit::Second, &["שניות", "שנייה"]), + (AgeUnit::Minute, &["דקה", "דקות"]), + (AgeUnit::Hour, &["שעה", "שעות"]), + (AgeUnit::Day, &["יום", "ימים"]), + (AgeUnit::Week, &["שבוע", "שבועות"]), + (AgeUnit::Month, &["חודש", "חודשים"]), + (AgeUnit::Year, &["שנה", "שנים"]), + ], + special: &[ + (AgeUnit::Hour, "שעתיים", 2), + (AgeUnit::Day, "יומיים", 2), + (AgeUnit::Week, "שבועיים", 2), + (AgeUnit::Month, "חודשיים", 2), + (AgeUnit::Year, "שנתיים", 2), + ], + }, + AgePatterns { + language: "ja", + separator: "", + units: &[ + (AgeUnit::Second, &["秒前"]), + (AgeUnit::Minute, &["分前"]), + (AgeUnit::Hour, &["時間前"]), + (AgeUnit::Day, &["日前"]), + (AgeUnit::Week, &["週間前"]), + (AgeUnit::Month, &["か月前"]), + (AgeUnit::Year, &["年前"]), + ], + special: &[], + }, + AgePatterns { + language: "ka", + separator: " ", + units: &[ + (AgeUnit::Second, &["წამის"]), + (AgeUnit::Minute, &["წუთის"]), + (AgeUnit::Hour, &["საათის"]), + (AgeUnit::Day, &["დღის"]), + (AgeUnit::Week, &["კვირის"]), + (AgeUnit::Month, &["თვის"]), + (AgeUnit::Year, &["წლის"]), + ], + special: &[], + }, + AgePatterns { + language: "kk", + separator: " ", + units: &[ + (AgeUnit::Second, &["секунд"]), + (AgeUnit::Minute, &["минут"]), + (AgeUnit::Hour, &["сағат"]), + (AgeUnit::Day, &["күн"]), + (AgeUnit::Week, &["апта"]), + (AgeUnit::Month, &["ай"]), + (AgeUnit::Year, &["жыл"]), + ], + special: &[], + }, + AgePatterns { + language: "km", + separator: "", + units: &[ + (AgeUnit::Second, &["វិនាទី\u{200b}មុន", "១វិនាទីមុន"]), + (AgeUnit::Minute, &["នាទីមុន", "១នាទីមុន"]), + (AgeUnit::Hour, &["ម៉ោង\u{200b}មុន", "១ម៉ោង\u{200b}មុន"]), + (AgeUnit::Day, &["ថ្ងៃមុន", "១ថ្ងៃ\u{200b}មុន"]), + ( + AgeUnit::Week, + &[ + "ស\u{200b}ប្តា\u{200b}ហ៍\u{200b}មុន", + "១ស\u{200b}ប្តា\u{200b}ហ៍\u{200b}មុន", + ], + ), + (AgeUnit::Month, &["ខែមុន", "១ខែមុន"]), + (AgeUnit::Year, &["ឆ្នាំ\u{200b}មុន", "១ឆ្នាំមុន"]), + ], + special: &[], + }, + AgePatterns { + language: "kn", + separator: " ", + units: &[ + (AgeUnit::Second, &["ಸೆಕೆಂಡುಗಳ", "ಸೆಕೆಂಡ್"]), + (AgeUnit::Minute, &["ನಿಮಿಷಗಳ", "ನಿಮಿಷದ"]), + (AgeUnit::Hour, &["ಗಂಟೆಗಳ", "ಗಂಟೆಯ"]), + (AgeUnit::Day, &["ದಿನಗಳ", "ದಿನದ"]), + (AgeUnit::Week, &["ವಾರಗಳ", "ವಾರದ"]), + (AgeUnit::Month, &["ತಿಂಗಳ", "ತಿಂಗಳುಗಳ"]), + (AgeUnit::Year, &["ವರ್ಷಗಳ", "ವರ್ಷದ"]), + ], + special: &[], + }, + AgePatterns { + language: "ko", + separator: "", + units: &[ + (AgeUnit::Second, &["초"]), + (AgeUnit::Minute, &["분"]), + (AgeUnit::Hour, &["시간"]), + (AgeUnit::Day, &["일"]), + (AgeUnit::Week, &["주"]), + (AgeUnit::Month, &["개월"]), + (AgeUnit::Year, &["년"]), + ], + special: &[], + }, + AgePatterns { + language: "ky", + separator: " ", + units: &[ + (AgeUnit::Second, &["секунд"]), + (AgeUnit::Minute, &["мүнөт"]), + (AgeUnit::Hour, &["саат"]), + (AgeUnit::Day, &["күн"]), + (AgeUnit::Week, &["апта"]), + (AgeUnit::Month, &["ай"]), + (AgeUnit::Year, &["жыл"]), + ], + special: &[], + }, + AgePatterns { + language: "lo", + separator: "", + units: &[ + (AgeUnit::Second, &["ວິນາທີກ່ອນນີ້"]), + ( + AgeUnit::Minute, + &["ນາທີກ່ອນນີ້", "ນາ\u{200b}ທີ\u{200b}ກ່ອນ\u{200b}ນີ້"], + ), + (AgeUnit::Hour, &["ຊົ່ວ\u{200b}ໂມງ\u{200b}ກ່ອນ\u{200b}ນີ້"]), + (AgeUnit::Day, &["ມື້ກ່ອນນີ້"]), + (AgeUnit::Week, &["ອາ\u{200b}ທິດ\u{200b}ກ່ອນ\u{200b}ນີ້"]), + (AgeUnit::Month, &["ເດືອນ\u{200b}ກ່ອນ\u{200b}ນີ້"]), + (AgeUnit::Year, &["ປີ\u{200b}ກ່ອນ\u{200b}ນີ້"]), + ], + special: &[], + }, + AgePatterns { + language: "lt", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekundes", "sekundę", "sekundžių"]), + (AgeUnit::Minute, &["minutes", "minutę", "minučių"]), + (AgeUnit::Hour, &["valandas", "valandą", "valandų"]), + (AgeUnit::Day, &["dienas", "dieną"]), + (AgeUnit::Week, &["savaites", "savaitę"]), + (AgeUnit::Month, &["mėnesius", "mėnesių", "mėnesį"]), + (AgeUnit::Year, &["metus", "metų"]), + ], + special: &[], + }, + AgePatterns { + language: "lv", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekundes", "sekundēm"]), + (AgeUnit::Minute, &["minūtes", "minūtēm", "minūtes"]), + (AgeUnit::Hour, &["stundas", "stundām"]), + (AgeUnit::Day, &["dienas", "dienām"]), + (AgeUnit::Week, &["nedēļas", "nedēļām"]), + (AgeUnit::Month, &["mēneša", "mēnešiem"]), + (AgeUnit::Year, &["gada", "gadiem"]), + ], + special: &[], + }, + AgePatterns { + language: "mk", + separator: " ", + units: &[ + (AgeUnit::Second, &["секунда", "секунди"]), + (AgeUnit::Minute, &["минута", "минути"]), + (AgeUnit::Hour, &["час", "часа"]), + (AgeUnit::Day, &["ден", "дена"]), + (AgeUnit::Week, &["недела", "недели"]), + (AgeUnit::Month, &["месец", "месеци"]), + (AgeUnit::Year, &["година", "години"]), + ], + special: &[], + }, + AgePatterns { + language: "ml", + separator: "", + units: &[ + (AgeUnit::Second, &["സെക്കന്റ്", "സെക്കൻഡ്"]), + (AgeUnit::Minute, &["മിനിറ്റ്"]), + (AgeUnit::Hour, &["മണിക്കൂർ"]), + (AgeUnit::Day, &["ദിവസം"]), + (AgeUnit::Week, &["ആഴ്ച", "ആഴ്\u{200c}ച"]), + (AgeUnit::Month, &["മാസം"]), + (AgeUnit::Year, &["വർഷം"]), + ], + special: &[], + }, + AgePatterns { + language: "mn", + separator: " ", + units: &[ + (AgeUnit::Second, &["секундын"]), + (AgeUnit::Minute, &["минутын"]), + (AgeUnit::Hour, &["цагийн"]), + (AgeUnit::Day, &["өдрийн"]), + (AgeUnit::Week, &["долоо", "хоногийн"]), + (AgeUnit::Month, &["сарын"]), + (AgeUnit::Year, &["жилийн"]), + ], + special: &[], + }, + AgePatterns { + language: "mr", + separator: "", + units: &[ + (AgeUnit::Second, &["सेकंदांपूर्वी", "सेकंदापूर्वी"]), + (AgeUnit::Minute, &["मिनिटांपूर्वी", "मिनिटापूर्वी"]), + (AgeUnit::Hour, &["तासांपूर्वी", "तासापूर्वी"]), + (AgeUnit::Day, &["दिवसांपूर्वी", "दिवसापूर्वी"]), + (AgeUnit::Week, &["आठवड्यांपूर्वी", "आठवड्यापूर्वी"]), + (AgeUnit::Month, &["महिन्यांपूर्वी", "महिन्यापूर्वी"]), + (AgeUnit::Year, &["वर्षांपूर्वी", "वर्षापूर्वी"]), + ], + special: &[], + }, + AgePatterns { + language: "ms", + separator: " ", + units: &[ + (AgeUnit::Second, &["saat"]), + (AgeUnit::Minute, &["minit"]), + (AgeUnit::Hour, &["jam"]), + (AgeUnit::Day, &["hari"]), + (AgeUnit::Week, &["minggu"]), + (AgeUnit::Month, &["bulan"]), + (AgeUnit::Year, &["tahun"]), + ], + special: &[], + }, + AgePatterns { + language: "my", + separator: " ", + units: &[ + (AgeUnit::Second, &["စက္ကန့်"]), + (AgeUnit::Minute, &["မိနစ်"]), + (AgeUnit::Hour, &["နာရီ"]), + (AgeUnit::Day, &["ရက်"]), + (AgeUnit::Week, &["ပတ်"]), + (AgeUnit::Month, &["လ"]), + (AgeUnit::Year, &["နှစ်"]), + ], + special: &[], + }, + AgePatterns { + language: "ne", + separator: " ", + units: &[ + (AgeUnit::Second, &["सेकेन्ड"]), + (AgeUnit::Minute, &["मिनेट"]), + (AgeUnit::Hour, &["घन्टा"]), + (AgeUnit::Day, &["दिन"]), + (AgeUnit::Week, &["हप्ता"]), + (AgeUnit::Month, &["महिना"]), + (AgeUnit::Year, &["वर्ष"]), + ], + special: &[], + }, + AgePatterns { + language: "nl", + separator: " ", + units: &[ + (AgeUnit::Second, &["seconde", "seconden"]), + (AgeUnit::Minute, &["minuten", "minuut"]), + (AgeUnit::Hour, &["uur"]), + (AgeUnit::Day, &["dag", "dagen"]), + (AgeUnit::Week, &["week", "weken"]), + (AgeUnit::Month, &["maand", "maanden"]), + (AgeUnit::Year, &["jaar"]), + ], + special: &[], + }, + AgePatterns { + language: "no", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekund", "sekunder"]), + (AgeUnit::Minute, &["minutt", "minutter"]), + (AgeUnit::Hour, &["time", "timer"]), + (AgeUnit::Day, &["dag", "dager"]), + (AgeUnit::Week, &["uke", "uker"]), + (AgeUnit::Month, &["md."]), + (AgeUnit::Year, &["år"]), + ], + special: &[], + }, + AgePatterns { + language: "pa", + separator: " ", + units: &[ + (AgeUnit::Second, &["ਸਕਿੰਟ"]), + (AgeUnit::Minute, &["ਮਿੰਟ"]), + (AgeUnit::Hour, &["ਘੰਟਾ", "ਘੰਟੇ"]), + (AgeUnit::Day, &["ਦਿਨ"]), + (AgeUnit::Week, &["ਹਫ਼ਤਾ", "ਹਫ਼ਤੇ"]), + (AgeUnit::Month, &["ਮਹੀਨਾ", "ਮਹੀਨੇ"]), + (AgeUnit::Year, &["ਸਾਲ"]), + ], + special: &[], + }, + AgePatterns { + language: "pl", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekund", "sekundy", "sekundę"]), + (AgeUnit::Minute, &["minut", "minuty", "minutę"]), + (AgeUnit::Hour, &["godzin", "godziny", "godzinę"]), + (AgeUnit::Day, &["dni", "dzień"]), + (AgeUnit::Week, &["tydzień", "tygodnie"]), + (AgeUnit::Month, &["miesiąc", "miesiące", "miesięcy"]), + (AgeUnit::Year, &["lat", "lata", "rok"]), + ], + special: &[], + }, + AgePatterns { + language: "pt", + separator: " ", + units: &[ + (AgeUnit::Second, &["segundo", "segundos"]), + (AgeUnit::Minute, &["minuto", "minutos"]), + (AgeUnit::Hour, &["hora", "horas"]), + (AgeUnit::Day, &["dia", "dias"]), + (AgeUnit::Week, &["semana", "semanas"]), + (AgeUnit::Month, &["meses", "mês"]), + (AgeUnit::Year, &["ano", "anos"]), + ], + special: &[], + }, + AgePatterns { + language: "pt-PT", + separator: " ", + units: &[ + (AgeUnit::Second, &["segundo", "segundos"]), + (AgeUnit::Minute, &["minuto", "minutos"]), + (AgeUnit::Hour, &["hora", "horas"]), + (AgeUnit::Day, &["dia", "dias"]), + (AgeUnit::Week, &["semana", "semanas"]), + (AgeUnit::Month, &["meses", "mês"]), + (AgeUnit::Year, &["ano", "anos"]), + ], + special: &[], + }, + AgePatterns { + language: "ro", + separator: " ", + units: &[ + (AgeUnit::Second, &["secunde", "secundă"]), + (AgeUnit::Minute, &["minut", "minute"]), + (AgeUnit::Hour, &["ore", "oră"]), + (AgeUnit::Day, &["zi", "zile"]), + (AgeUnit::Week, &["săptămâni", "săptămână"]), + (AgeUnit::Month, &["luni", "lună"]), + (AgeUnit::Year, &["an", "ani"]), + ], + special: &[], + }, + AgePatterns { + language: "ru", + separator: " ", + units: &[ + ( + AgeUnit::Second, + &["секунд", "секунду", "секунды", "только что"], + ), + (AgeUnit::Minute, &["минут", "минуту", "минуты"]), + (AgeUnit::Hour, &["час", "часа", "часов"]), + (AgeUnit::Day, &["день", "дней", "дня"]), + (AgeUnit::Week, &["Неделю", "недели"]), + (AgeUnit::Month, &["месяц", "месяца", "месяцев"]), + (AgeUnit::Year, &["Год", "года", "лет"]), + ], + special: &[], + }, + AgePatterns { + language: "si", + separator: " ", + units: &[ + (AgeUnit::Second, &["තත්පර"]), + (AgeUnit::Minute, &["මිනිත්තු"]), + (AgeUnit::Hour, &["පැය"]), + (AgeUnit::Day, &["දින"]), + (AgeUnit::Week, &["සති"]), + (AgeUnit::Month, &["මාස"]), + (AgeUnit::Year, &["වසර"]), + ], + special: &[], + }, + AgePatterns { + language: "sk", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekundami", "sekundou"]), + (AgeUnit::Minute, &["minútami", "minútou"]), + (AgeUnit::Hour, &["hodinami", "hodinou"]), + (AgeUnit::Day, &["dňami", "dňom"]), + (AgeUnit::Week, &["týždňami", "týždňom"]), + (AgeUnit::Month, &["mesiacmi", "mesiacom"]), + (AgeUnit::Year, &["rokmi", "rokom"]), + ], + special: &[], + }, + AgePatterns { + language: "sl", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekundama", "sekundami", "sekundo"]), + (AgeUnit::Minute, &["minutama", "minutami", "minuto"]), + (AgeUnit::Hour, &["urama", "urami", "uro"]), + (AgeUnit::Day, &["dnem", "dnevi", "dnevoma"]), + (AgeUnit::Week, &["tedni", "tednom", "tednoma"]), + (AgeUnit::Month, &["mesecem", "mesecema", "meseci"]), + (AgeUnit::Year, &["leti", "letom", "letoma"]), + ], + special: &[], + }, + AgePatterns { + language: "sq", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekonda", "sekondë"]), + (AgeUnit::Minute, &["minuta", "minutë"]), + (AgeUnit::Hour, &["orë"]), + (AgeUnit::Day, &["ditë"]), + (AgeUnit::Week, &["javë"]), + (AgeUnit::Month, &["muaj"]), + (AgeUnit::Year, &["vit", "vjet"]), + ], + special: &[], + }, + AgePatterns { + language: "sr", + separator: " ", + units: &[ + (AgeUnit::Second, &["секунде", "секунди"]), + (AgeUnit::Minute, &["минута"]), + (AgeUnit::Hour, &["сат", "сата", "сати"]), + ( + AgeUnit::Day, + &[ + "Пре 1 дан", + "Пре 2 дана", + "Пре 3 дана", + "Пре 4 дана", + "Пре 5 дана", + "Пре 6 дана", + ], + ), + (AgeUnit::Week, &["недеље", "недељу"]), + (AgeUnit::Month, &["месец", "месеца", "месеци"]), + (AgeUnit::Year, &["година", "године", "годину"]), + ], + special: &[], + }, + AgePatterns { + language: "sr-Latn", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekunde", "sekundi"]), + (AgeUnit::Minute, &["minuta"]), + (AgeUnit::Hour, &["sat", "sati", "sata"]), + ( + AgeUnit::Day, + &[ + "Pre 1 dan", + "Pre 2 dana", + "Pre 3 dana", + "Pre 4 dana", + "Pre 5 dana", + "Pre 6 dana", + ], + ), + (AgeUnit::Week, &["nedelja", "nedelje", "nedelju"]), + (AgeUnit::Month, &["mesec", "meseci", "meseca"]), + (AgeUnit::Year, &["godine", "godina", "godinu"]), + ], + special: &[], + }, + AgePatterns { + language: "sv", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekund", "sekunder"]), + (AgeUnit::Minute, &["minut", "minuter"]), + (AgeUnit::Hour, &["timmar", "timme"]), + (AgeUnit::Day, &["dag", "dagar"]), + (AgeUnit::Week, &["vecka", "veckor"]), + (AgeUnit::Month, &["månad", "månader"]), + (AgeUnit::Year, &["år"]), + ], + special: &[], + }, + AgePatterns { + language: "sw", + separator: " ", + units: &[ + (AgeUnit::Second, &["sekunde"]), + (AgeUnit::Minute, &["dakika"]), + (AgeUnit::Hour, &["saa"]), + (AgeUnit::Day, &["siku"]), + (AgeUnit::Week, &["wiki"]), + (AgeUnit::Month, &["Mwezi", "miezi"]), + (AgeUnit::Year, &["Miaka", "Mwaka"]), + ], + special: &[], + }, + AgePatterns { + language: "ta", + separator: " ", + units: &[ + (AgeUnit::Second, &["வினாடி", "வினாடிகளுக்கு"]), + (AgeUnit::Minute, &["நிமிடங்கள்", "நிமிடம்"]), + (AgeUnit::Hour, &["மணிநேரத்திற்கு"]), + (AgeUnit::Day, &["நாட்களுக்கு", "நாளுக்கு"]), + (AgeUnit::Week, &["வாரங்களுக்கு", "வாரம்"]), + (AgeUnit::Month, &["மாதங்கள்", "மாதம்"]), + (AgeUnit::Year, &["ஆண்டு", "ஆண்டுகளுக்கு"]), + ], + special: &[], + }, + AgePatterns { + language: "te", + separator: " ", + units: &[ + (AgeUnit::Second, &["సెకను", "సెకన్ల"]), + (AgeUnit::Minute, &["నిమిషం", "నిమిషాల"]), + (AgeUnit::Hour, &["గంట", "గంటల"]), + (AgeUnit::Day, &["రోజు", "రోజుల"]), + (AgeUnit::Week, &["వారం", "వారాల"]), + (AgeUnit::Month, &["నెల", "నెలల"]), + (AgeUnit::Year, &["సంవత్సరం", "సంవత్సరాల"]), + ], + special: &[], + }, + AgePatterns { + language: "th", + separator: "", + units: &[ + (AgeUnit::Second, &["วินาทีที่ผ่านมา"]), + (AgeUnit::Minute, &["นาทีที่ผ่านมา"]), + (AgeUnit::Hour, &["ชั่วโมงที่ผ่านมา"]), + (AgeUnit::Day, &["วันที่ผ่านมา"]), + (AgeUnit::Week, &["สัปดาห์ที่ผ่านมา"]), + (AgeUnit::Month, &["เดือนที่ผ่านมา"]), + (AgeUnit::Year, &["ปีที่ผ่านมา"]), + ], + special: &[], + }, + AgePatterns { + language: "tr", + separator: " ", + units: &[ + (AgeUnit::Second, &["saniye"]), + (AgeUnit::Minute, &["dakika"]), + (AgeUnit::Hour, &["saat"]), + (AgeUnit::Day, &["gün"]), + (AgeUnit::Week, &["hafta"]), + (AgeUnit::Month, &["ay"]), + (AgeUnit::Year, &["yıl"]), + ], + special: &[], + }, + AgePatterns { + language: "uk", + separator: " ", + units: &[ + (AgeUnit::Second, &["секунд", "секунди", "секунду"]), + (AgeUnit::Minute, &["хвилин", "хвилини", "хвилину"]), + (AgeUnit::Hour, &["годин", "години", "годину"]), + (AgeUnit::Day, &["день", "дні", "днів"]), + (AgeUnit::Week, &["тиждень", "тижні"]), + (AgeUnit::Month, &["місяць", "місяці", "місяців"]), + (AgeUnit::Year, &["роки", "років", "рік"]), + ], + special: &[], + }, + AgePatterns { + language: "ur", + separator: " ", + units: &[ + (AgeUnit::Second, &["سیکنڈ", "سیکنڈز"]), + (AgeUnit::Minute, &["منٹ", "منٹس"]), + (AgeUnit::Hour, &["گھنٹہ", "گھنٹے"]), + (AgeUnit::Day, &["دن"]), + (AgeUnit::Week, &["ہفتہ", "ہفتے"]), + (AgeUnit::Month, &["ماہ"]), + (AgeUnit::Year, &["سال"]), + ], + special: &[], + }, + AgePatterns { + language: "uz", + separator: " ", + units: &[ + (AgeUnit::Second, &["soniya"]), + (AgeUnit::Minute, &["daqiqa"]), + (AgeUnit::Hour, &["soat"]), + (AgeUnit::Day, &["kun"]), + (AgeUnit::Week, &["hafta"]), + (AgeUnit::Month, &["oy"]), + (AgeUnit::Year, &["yil"]), + ], + special: &[], + }, + AgePatterns { + language: "vi", + separator: " ", + units: &[ + (AgeUnit::Second, &["giây"]), + (AgeUnit::Minute, &["phút"]), + (AgeUnit::Hour, &["giờ", "tiếng"]), + (AgeUnit::Day, &["ngày"]), + (AgeUnit::Week, &["tuần"]), + (AgeUnit::Month, &["tháng"]), + (AgeUnit::Year, &["năm"]), + ], + special: &[], + }, + AgePatterns { + language: "zh-CN", + separator: "", + units: &[ + (AgeUnit::Second, &["秒前"]), + (AgeUnit::Minute, &["分钟前"]), + (AgeUnit::Hour, &["小时前"]), + (AgeUnit::Day, &["天前"]), + (AgeUnit::Week, &["周前"]), + (AgeUnit::Month, &["个月前"]), + (AgeUnit::Year, &["年前"]), + ], + special: &[], + }, + AgePatterns { + language: "zh-HK", + separator: "", + units: &[ + (AgeUnit::Second, &["秒前"]), + (AgeUnit::Minute, &["分鐘前"]), + (AgeUnit::Hour, &["小時前"]), + (AgeUnit::Day, &["天前"]), + (AgeUnit::Week, &["週前"]), + (AgeUnit::Month, &["個月前"]), + (AgeUnit::Year, &["年前"]), + ], + special: &[], + }, + AgePatterns { + language: "zh-TW", + separator: "", + units: &[ + (AgeUnit::Second, &["秒前"]), + (AgeUnit::Minute, &["分鐘前"]), + (AgeUnit::Hour, &["小時前"]), + (AgeUnit::Day, &["天前"]), + (AgeUnit::Week, &["週前"]), + (AgeUnit::Month, &["個月前"]), + (AgeUnit::Year, &["年前"]), + ], + special: &[], + }, + AgePatterns { + language: "zu", + separator: " ", + units: &[ + (AgeUnit::Second, &["amasekhondi", "isekhondi"]), + (AgeUnit::Minute, &["amaminithi", "iminithi"]), + (AgeUnit::Hour, &["amahora", "ihora"]), + (AgeUnit::Day, &["izinsuku", "usuku"]), + (AgeUnit::Week, &["amaviki", "iviki"]), + (AgeUnit::Month, &["inyanga", "izinyanga"]), + (AgeUnit::Year, &["iminyaka", "unyaka"]), + ], + special: &[], + }, +]; diff --git a/src-tauri/src/api/innertube/json/count.rs b/src-tauri/src/api/innertube/json/count.rs index 1dd76bdb..9b005f4c 100644 --- a/src-tauri/src/api/innertube/json/count.rs +++ b/src-tauri/src/api/innertube/json/count.rs @@ -1,13 +1,78 @@ //! Counts as cards write them: "1,234,567 views", "1.2M", "13K watching". -/// The count `text` starts with, or none when it holds no count. -/// +/// The count `text` starts with, or none when it holds no count. English text +/// is read with its K, M and B; other languages by [`localized_count`]. +#[must_use] +pub fn parse_count(text: &str) -> Option { + if crate::api::innertube::core::locale::is_english() { + english_count(text) + } else { + localized_count(text) + } +} + +/// CJK multipliers written straight after the number ("587万", "1.3億", "3천"). +const CJK_MULTIPLIERS: [(char, f64); 7] = [ + ('千', 1e3), + ('万', 1e4), + ('萬', 1e4), + ('億', 1e8), + ('亿', 1e8), + ('천', 1e3), + ('만', 1e4), +]; + +fn is_group_mark(c: char) -> bool { + matches!(c, ',' | '.' | '\'' | ' ' | '\u{a0}' | '\u{202f}') +} + +/// A count in a language other than English. Exact counts ("621.747 Aufrufe", +/// "22 391 vues", "5,874,003回視聴") are read whatever their grouping marks, +/// and CJK multipliers are applied. An abbreviated count in any other language +/// ("23 Mio.", "220 k", "6,3 млн") has at most three digits and words this +/// reader does not know, so a number that short is no count at all rather than +/// a wrong one. +#[must_use] +pub fn localized_count(text: &str) -> Option { + let chars: Vec = text.chars().collect(); + let start = chars.iter().position(char::is_ascii_digit)?; + let mut end = start; + while end < chars.len() + && (chars[end].is_ascii_digit() + || (is_group_mark(chars[end]) && chars.get(end + 1).is_some_and(char::is_ascii_digit))) + { + end += 1; + } + if matches!(chars.get(end), Some('/' | ':')) + || (start > 0 && matches!(chars[start - 1], '/' | ':')) + { + return None; + } + let token: String = chars[start..end].iter().collect(); + let next = chars[end..].iter().find(|c| !c.is_whitespace()).copied(); + if let Some((_, multiplier)) = CJK_MULTIPLIERS.iter().find(|(mark, _)| Some(*mark) == next) { + let value = read_number(&token.replace(['\'', ' ', '\u{a0}', '\u{202f}'], ""))?; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + return Some((value * multiplier).round() as u64); + } + // Grouped thousands: one to three digits, then groups of exactly three. + // "12.10.2026" is a date, and "6,3" a decimal with its multiplier unread. + let mut groups = token.split(is_group_mark); + let grouped = groups + .next() + .is_some_and(|first| (1..=3).contains(&first.len()) || !token.contains(is_group_mark)) + && groups.all(|group| group.len() == 3); + let digits: String = token.chars().filter(char::is_ascii_digit).collect(); + (grouped && digits.len() >= 4) + .then(|| digits.parse().ok()) + .flatten() +} + /// The number is read with its grouping or decimal mark, then a K, M or B /// directly after it, which must end a word, so "1.2M" is a million and the "m" /// of "min" or "PM" is not. A number joined to a slash or colon is a date or a /// time ("10/12/26", "7:00"), never a count. -#[must_use] -pub fn parse_count(text: &str) -> Option { +fn english_count(text: &str) -> Option { let chars: Vec = text.chars().collect(); let start = chars.iter().position(char::is_ascii_digit)?; let mut end = start; @@ -65,6 +130,21 @@ fn read_number(token: &str) -> Option { mod tests { use super::*; + #[test] + fn exact_counts_are_read_in_every_grouping_and_abbreviations_left_unknown() { + assert_eq!(localized_count("621.747 Aufrufe"), Some(621_747)); + assert_eq!(localized_count("22\u{202f}391\u{a0}vues"), Some(22_391)); + assert_eq!(localized_count("621\u{a0}747 просмотров"), Some(621_747)); + assert_eq!(localized_count("5,874,003回視聴"), Some(5_874_003)); + assert_eq!(localized_count("587万回視聴"), Some(5_870_000)); + assert_eq!(localized_count("1.3億"), Some(130_000_000)); + assert_eq!(localized_count("23\u{a0}Mio."), None); + assert_eq!(localized_count("220\u{a0}k"), None); + assert_eq!(localized_count("6,3\u{a0}млн"), None); + assert_eq!(localized_count("3,8\u{a0}mil"), None); + assert_eq!(localized_count("Premiere am 12.10.2026, 19:00"), None); + } + #[test] fn exact_and_compact_counts() { assert_eq!(parse_count("1,234,567 views"), Some(1_234_567)); diff --git a/src-tauri/src/api/innertube/json/mod.rs b/src-tauri/src/api/innertube/json/mod.rs index 319aff33..4f6ab431 100644 --- a/src-tauri/src/api/innertube/json/mod.rs +++ b/src-tauri/src/api/innertube/json/mod.rs @@ -1,6 +1,7 @@ //! Readers for values inside `InnerTube` responses. pub mod age; +mod age_patterns; pub mod continuation; pub mod count; pub mod duration; From b80e722d22d4544a4c4600285f1cb02cc7dffb8f Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:33:05 +0300 Subject: [PATCH 3/6] fix(innertube): read lockup ages and channel counts outside English --- .../innertube/extractors/channel/header.rs | 56 +++++++++++++++---- .../src/api/innertube/renderers/lockup.rs | 35 ++++++++++-- 2 files changed, 75 insertions(+), 16 deletions(-) diff --git a/src-tauri/src/api/innertube/extractors/channel/header.rs b/src-tauri/src/api/innertube/extractors/channel/header.rs index 4efbb305..b9af81de 100644 --- a/src-tauri/src/api/innertube/extractors/channel/header.rs +++ b/src-tauri/src/api/innertube/extractors/channel/header.rs @@ -15,6 +15,42 @@ fn page_header(page: &Value) -> &Value { &page["header"]["pageHeaderRenderer"]["content"]["pageHeaderViewModel"] } +/// The counts row after the handle, part by part: subscribers, then videos. +fn counts_row(header: &Value) -> Vec { + header["metadata"]["contentMetadataViewModel"]["metadataRows"] + .as_array() + .into_iter() + .flatten() + .map(|row| { + row["metadataParts"] + .as_array() + .into_iter() + .flatten() + .filter_map(|part| text_of(&part["text"])) + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) + .collect::>() + }) + .find(|parts| parts.first().is_some_and(|first| !first.starts_with('@'))) + .unwrap_or_default() +} + +/// The subscriber and video counts as written. English names them; other +/// languages are read by position, subscribers first, and a lone part is +/// shown as the subscriber count without being read as a number. +fn counts(rows: &[String], header: &Value) -> (Option, Option) { + if crate::api::innertube::core::locale::is_english() { + return ( + rows.iter().find(|row| mentions(row, "subscriber")).cloned(), + rows.iter() + .find(|row| mentions(row, "video") && !row.starts_with('@')) + .cloned(), + ); + } + let row = counts_row(header); + (row.first().cloned(), row.get(1).cloned()) +} + /// The header's metadata rows: "@handle", "16.9M subscribers", "7.9K videos". fn metadata_rows(header: &Value) -> Vec { header["metadata"]["contentMetadataViewModel"]["metadataRows"] @@ -38,11 +74,10 @@ pub fn parse_header(page: &Value, requested_id: &str) -> ChannelDetails { let legacy = &page["header"]["c4TabbedHeaderRenderer"]; let metadata = &page["metadata"]["channelMetadataRenderer"]; let rows = metadata_rows(header); - let subscriber_count_text = rows - .iter() - .find(|row| mentions(row, "subscriber")) - .cloned() - .or_else(|| text_of(&legacy["subscriberCountText"])); + let (subscribers, videos) = counts(&rows, header); + // A lone count outside English may be the video count; it is shown, not read. + let read_subscribers = crate::api::innertube::core::locale::is_english() || videos.is_some(); + let subscriber_count_text = subscribers.or_else(|| text_of(&legacy["subscriberCountText"])); let title = &header["title"]["dynamicTextViewModel"]["text"]; ChannelDetails { id: metadata["externalId"] @@ -87,13 +122,12 @@ pub fn parse_header(page: &Value, requested_id: &str) -> ChannelDetails { url.strip_prefix("//") .map_or(url.clone(), |rest| format!("https://{rest}")) }), - subscriber_count: subscriber_count_text.as_deref().and_then(parse_count), + subscriber_count: subscriber_count_text + .as_deref() + .filter(|_| read_subscribers) + .and_then(parse_count), subscriber_count_text, - video_count_text: rows - .iter() - .find(|row| mentions(row, "video") && !row.starts_with('@')) - .cloned() - .or_else(|| text_of(&legacy["videosCountText"])), + video_count_text: videos.or_else(|| text_of(&legacy["videosCountText"])), verified: title["attachmentRuns"].to_string().contains("CHECK_CIRCLE") || legacy["badges"] .to_string() diff --git a/src-tauri/src/api/innertube/renderers/lockup.rs b/src-tauri/src/api/innertube/renderers/lockup.rs index 9d66c340..c3b2821a 100644 --- a/src-tauri/src/api/innertube/renderers/lockup.rs +++ b/src-tauri/src/api/innertube/renderers/lockup.rs @@ -17,10 +17,32 @@ use crate::models::video::{RelatedContentItem, VideoSummary}; /// One metadata part of a lockup. struct Part<'a> { text: &'a str, + /// The part spelt out for screen readers. Outside English it is the only + /// full form of a compact age ("il y a 2 sem." is "il y a 2 semaines"). + label: Option<&'a str>, has_icon: bool, is_verified: bool, } +impl Part<'_> { + fn is_age(&self) -> bool { + relative_age(self.text).is_some() + || self + .label + .is_some_and(|label| relative_age(label).is_some()) + } + + /// The age as the card shows it: the text when it reads as one, else its full form. + fn age_text(&self) -> &str { + match self.label { + Some(label) if relative_age(self.text).is_none() && relative_age(label).is_some() => { + label + } + _ => self.text, + } + } +} + fn metadata(lockup: &Value) -> &Value { &lockup["metadata"]["lockupMetadataViewModel"] } @@ -39,6 +61,7 @@ fn parts(row: &Value) -> Vec> { .filter_map(|part| { Some(Part { text: part["text"]["content"].as_str()?, + label: part["accessibilityLabel"].as_str(), has_icon: part.get("leadingIcon").is_some(), is_verified: part["text"]["attachmentRuns"] .as_array() @@ -197,18 +220,20 @@ pub fn lockup_item(lockup: &Value) -> Option { let channel = all_rows .first() .and_then(|row| row.first()) - .filter(|part| !part.has_icon && relative_age(part.text).is_none()); + .filter(|part| !part.has_icon && !part.is_age()); let published = all_parts() - .find(|part| relative_age(part.text).is_some() || is_upcoming_text(part.text)) - .map(|part| part.text.to_string()); + .find(|part| part.is_age() || is_upcoming_text(part.text)) + .map(|part| part.age_text().to_string()); // Rows after the first hold the counts; the first row is the byline. let views = all_parts() .find(|part| part.has_icon) .or_else(|| { + // An abbreviated count in another language ("23 Mio.") reads as no + // number, but is still the count the card shows. all_rows.iter().skip(1).flatten().find(|part| { - relative_age(part.text).is_none() + !part.is_age() && !is_upcoming_text(part.text) - && parse_count(part.text).is_some() + && part.text.chars().any(|c| c.is_ascii_digit()) }) }) .map(|part| part.text.to_string()); From f7695fad38d287315a2845a29598f8b33dd6403d Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:33:05 +0300 Subject: [PATCH 4/6] test(innertube): check six content languages against real captures --- src-tauri/tests/innertube_capture.rs | 33 +++++- src-tauri/tests/innertube_locale.rs | 157 +++++++++++++++++++++++++++ 2 files changed, 188 insertions(+), 2 deletions(-) create mode 100644 src-tauri/tests/innertube_locale.rs diff --git a/src-tauri/tests/innertube_capture.rs b/src-tauri/tests/innertube_capture.rs index b2e9b2eb..147613bf 100644 --- a/src-tauri/tests/innertube_capture.rs +++ b/src-tauri/tests/innertube_capture.rs @@ -8,7 +8,7 @@ use std::path::PathBuf; use flow_desktop_lib::api::innertube::InnertubeClient; -use flow_desktop_lib::api::innertube::core::clients; +use flow_desktop_lib::api::innertube::core::{clients, locale}; use flow_desktop_lib::api::innertube::json::continuation::{ continuation_item_lists, last_continuation_token, }; @@ -40,6 +40,8 @@ fn trim(value: &mut Value) { } } +/// Content languages the locale tests read captures in. +const LOCALE_LANGUAGES: [&str; 6] = ["de", "fr", "ja", "es", "ru", "pt-BR"]; /// An artist with albums, singles, videos, featured playlists and related artists. const MUSIC_ARTIST: &str = "UClYV6hHlupm_S_ObS1W-DYw"; /// A podcast whose playlist page lists episodes without a header. @@ -47,8 +49,9 @@ const PODCAST: &str = "PLYWnygDGxPqWiCM5_kihM28jfWiQTNYDa"; /// A YouTube Music request as the web client sends it, signed out. async fn music_post(endpoint: &str, mut body: Value) -> Value { + let hl = flow_desktop_lib::api::innertube::core::locale::content_language(); body["context"] = json!({ "client": { - "clientName": "WEB_REMIX", "clientVersion": "1.20260708.01.00", "hl": "en", "gl": "US" + "clientName": "WEB_REMIX", "clientVersion": "1.20260708.01.00", "hl": hl, "gl": "US" } }); flow_desktop_lib::api::http::shared_client() .post(format!( @@ -337,4 +340,30 @@ async fn capture_innertube_fixtures() { .await .expect("channel search page 2"); save("channel_search_page2", page2); + + // The same surfaces in other content languages, for tests/innertube_locale.rs. + for language in LOCALE_LANGUAGES { + locale::set_content_language(Some(language)); + let mut search = json!({ "query": "minecraft" }); + let page = client + .post_innertube("search", &clients::WEB, &mut search) + .await + .expect("search"); + save(&format!("locale_search_{language}"), page); + let mut next = json!({ "videoId": "dQw4w9WgXcQ" }); + let page = client + .post_innertube("next", &clients::WEB, &mut next) + .await + .expect("next"); + save(&format!("locale_next_{language}"), page); + let mut channel = json!({ "browseId": LTT }); + let page = client + .post_innertube("browse", &clients::WEB, &mut channel) + .await + .expect("channel"); + save(&format!("locale_channel_{language}"), page); + let page = music_post("search", json!({ "query": "the weeknd" })).await; + save(&format!("locale_music_search_{language}"), page); + } + locale::set_content_language(None); } diff --git a/src-tauri/tests/innertube_locale.rs b/src-tauri/tests/innertube_locale.rs new file mode 100644 index 00000000..02ffd5de --- /dev/null +++ b/src-tauri/tests/innertube_locale.rs @@ -0,0 +1,157 @@ +//! Responses in other content languages: dates, counts, channel headers and +//! music search read from real captures (`cargo test --test innertube_capture +//! -- --ignored` writes them). One test sets the process-wide language in turn, +//! so it lives in its own binary, away from the tests that assume English. + +use std::path::PathBuf; + +use flow_desktop_lib::api::innertube::core::locale; +use flow_desktop_lib::api::innertube::extractors::channel::parse_header; +use flow_desktop_lib::api::innertube::extractors::search::parse_innertube_search; +use flow_desktop_lib::api::innertube::extractors::watch_next; +use flow_desktop_lib::api::innertube::json::age::relative_age; +use flow_desktop_lib::api::innertube::music::search::parse_summary; +use flow_desktop_lib::api::innertube::renderers::lockup::related_to_video; +use flow_desktop_lib::models::music::YTItem; +use serde_json::Value; + +const LANGUAGES: [&str; 6] = ["de", "fr", "ja", "es", "ru", "pt-BR"]; + +fn fixture(name: &str) -> Option { + let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/innertube") + .join(format!("{name}.json")); + match std::fs::read(&path) { + Ok(bytes) => Some(serde_json::from_slice(&bytes).expect("capture is JSON")), + Err(_) if std::env::var_os("FLOW_REQUIRE_FIXTURES").is_some() => { + panic!("missing capture {}", path.display()) + } + Err(_) => { + eprintln!("skipped: {} is missing", path.display()); + None + } + } +} + +/// At least four in five of `items` pass `test`, and there are enough to say so. +fn mostly(items: &[T], test: impl Fn(&T) -> bool, what: &str, language: &str) { + let passed = items.iter().filter(|item| test(item)).count(); + assert!( + items.len() >= 5 && passed * 5 >= items.len() * 4, + "{language}: {what} {passed}/{}", + items.len() + ); +} + +fn check(language: &str) { + if let Some(page) = fixture(&format!("locale_search_{language}")) { + let videos = parse_innertube_search(&page).items; + let dated: Vec<_> = videos + .iter() + .filter_map(|video| video.published_text.as_deref()) + .collect(); + mostly( + &dated, + |text| relative_age(text).is_some(), + "search ages", + language, + ); + let counted: Vec = videos.iter().filter_map(|video| video.view_count).collect(); + assert!( + counted.len() * 2 >= videos.len(), + "{language}: search counts {}/{}", + counted.len(), + videos.len() + ); + assert!( + counted.iter().all(|count| *count >= 1000), + "{language}: a small count is a misread abbreviation" + ); + } + if let Some(next) = fixture(&format!("locale_next_{language}")) { + let related: Vec<_> = watch_next::related_page(&next) + .items + .into_iter() + .filter(|item| item.item_type == "video") + .map(related_to_video) + .collect(); + let dated: Vec<_> = related + .iter() + .filter_map(|video| video.published_text.as_deref()) + .collect(); + mostly( + &dated, + |text| relative_age(text).is_some(), + "related ages", + language, + ); + mostly( + &related, + |video| video.view_count_text.is_some(), + "related view texts", + language, + ); + assert!( + related + .iter() + .filter_map(|video| video.view_count) + .all(|count| count >= 1000), + "{language}: a related count was misread" + ); + } + if let Some(page) = fixture(&format!("locale_channel_{language}")) { + let header = parse_header(&page, "UCXuqSBlHAE6Xw-yeJA0Tunw"); + assert!( + header + .handle + .as_deref() + .is_some_and(|handle| handle.starts_with('@')), + "{language}: handle" + ); + let subscribers = header.subscriber_count_text.expect("subscriber count text"); + let videos = header.video_count_text.expect("video count text"); + assert_ne!( + subscribers, videos, + "{language}: subscribers and videos are told apart" + ); + let kinds: Vec<&str> = header.tabs.iter().map(|tab| tab.kind.as_str()).collect(); + for kind in ["home", "videos", "shorts", "playlists", "posts"] { + assert!(kinds.contains(&kind), "{language}: {kind} tab in {kinds:?}"); + } + } + if let Some(page) = fixture(&format!("locale_music_search_{language}")) { + let summary = parse_summary(&page); + assert!( + summary + .summaries + .iter() + .any(|section| section.is_top_result), + "{language}: top result" + ); + assert!(!summary.filters.is_empty(), "{language}: offered filters"); + let items: Vec<&YTItem> = summary + .summaries + .iter() + .flat_map(|section| §ion.items) + .collect(); + assert!( + items.iter().any(|item| matches!(item, YTItem::Episode(_))), + "{language}: episodes" + ); + assert!( + items + .iter() + .any(|item| matches!(item, YTItem::Artist(artist) if !artist.is_profile)), + "{language}: artists" + ); + } +} + +#[test] +fn every_content_language_reads_dates_counts_and_structure() { + for language in LANGUAGES { + locale::set_content_language(Some(language)); + check(language); + } + locale::set_content_language(None); +} From aea30166c55cc12c925a0403de605b20dc4dff83 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:33:05 +0300 Subject: [PATCH 5/6] feat(settings): choose the content language --- src/components/settings/tabs/ContentTab.tsx | 9 ++++++ src/lib/contentLanguages.ts | 32 +++++++++++++++++++++ src/lib/i18n/strings.json | 5 +++- src/lib/settings/schema.ts | 2 ++ 4 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 src/lib/contentLanguages.ts diff --git a/src/components/settings/tabs/ContentTab.tsx b/src/components/settings/tabs/ContentTab.tsx index 22855369..5c03ea20 100644 --- a/src/components/settings/tabs/ContentTab.tsx +++ b/src/components/settings/tabs/ContentTab.tsx @@ -14,6 +14,7 @@ import { } from '../../../lib/deepFlow'; import { useAppSettingsStore } from '../../../store/useAppSettingsStore'; import { REGION_OPTIONS } from '../../../lib/regionOptions'; +import { CONTENT_LANGUAGE_OPTIONS } from '../../../lib/contentLanguages'; import { GRID_COLUMN_OPTIONS, GRID_COLUMNS_DEFAULT } from '../../../lib/useGridColumns'; import { BlockedKeywordsPanel } from '../BlockedKeywordsPanel'; @@ -46,6 +47,7 @@ export function ContentTab() { const [notificationInterval, setNotificationInterval] = usePreference(SETTINGS.NOTIFICATION_CHECK_INTERVAL, '360'); const [regionPicker, setRegionPicker] = useBoolPref(SETTINGS.SHOW_REGION_PICKER_IN_EXPLORE, true); const [trendingRegion, setTrendingRegion] = usePreference(SETTINGS.TRENDING_REGION, 'US'); + const [contentLanguage, setContentLanguage] = usePreference(SETTINGS.CONTENT_LANGUAGE, ''); const [deepFlowExpireHours, setDeepFlowExpireHours] = usePreference(SETTINGS.DEEP_FLOW_EXPIRE_HOURS, '4'); const [deepFlowSaveHistory, setDeepFlowSaveHistory] = useBoolPref(SETTINGS.DEEP_FLOW_SAVE_HISTORY, false); @@ -191,6 +193,13 @@ export function ContentTab() { + ); diff --git a/src/lib/contentLanguages.ts b/src/lib/contentLanguages.ts new file mode 100644 index 00000000..3fdfcb29 --- /dev/null +++ b/src/lib/contentLanguages.ts @@ -0,0 +1,32 @@ +/** + * Languages the site can write its text in, by the host language Flow sends. Each has + * an upload-date table on the backend; regional variants other than Portuguese and + * Chinese collapse to their language, as the site's own picker does. + */ +export const CONTENT_LANGUAGE_OPTIONS: { value: string; label: string }[] = [ + { value: "af", label: "Afrikaans" }, { value: "am", label: "አማርኛ" }, { value: "ar", label: "العربية" }, + { value: "az", label: "Azərbaycan" }, { value: "be", label: "Беларуская" }, { value: "bg", label: "Български" }, + { value: "bn", label: "বাংলা" }, { value: "bs", label: "Bosanski" }, { value: "ca", label: "Català" }, + { value: "cs", label: "Čeština" }, { value: "da", label: "Dansk" }, { value: "de", label: "Deutsch" }, + { value: "el", label: "Ελληνικά" }, { value: "es", label: "Español" }, { value: "et", label: "Eesti" }, + { value: "eu", label: "Euskara" }, { value: "fa", label: "فارسی" }, { value: "fi", label: "Suomi" }, + { value: "fil", label: "Filipino" }, { value: "fr", label: "Français" }, { value: "gl", label: "Galego" }, + { value: "gu", label: "ગુજરાતી" }, { value: "hi", label: "हिन्दी" }, { value: "hr", label: "Hrvatski" }, + { value: "hu", label: "Magyar" }, { value: "hy", label: "Հայերեն" }, { value: "id", label: "Bahasa Indonesia" }, + { value: "is", label: "Íslenska" }, { value: "it", label: "Italiano" }, { value: "iw", label: "עברית" }, + { value: "ja", label: "日本語" }, { value: "ka", label: "ქართული" }, { value: "kk", label: "Қазақ" }, + { value: "km", label: "ខ្មែរ" }, { value: "kn", label: "ಕನ್ನಡ" }, { value: "ko", label: "한국어" }, + { value: "ky", label: "Кыргызча" }, { value: "lo", label: "ລາວ" }, { value: "lt", label: "Lietuvių" }, + { value: "lv", label: "Latviešu" }, { value: "mk", label: "Македонски" }, { value: "ml", label: "മലയാളം" }, + { value: "mn", label: "Монгол" }, { value: "mr", label: "मराठी" }, { value: "ms", label: "Bahasa Melayu" }, + { value: "my", label: "မြန်မာ" }, { value: "ne", label: "नेपाली" }, { value: "nl", label: "Nederlands" }, + { value: "no", label: "Norsk" }, { value: "pa", label: "ਪੰਜਾਬੀ" }, { value: "pl", label: "Polski" }, + { value: "pt-BR", label: "Português (Brasil)" }, { value: "pt", label: "Português (Portugal)" }, + { value: "ro", label: "Română" }, { value: "ru", label: "Русский" }, { value: "si", label: "සිංහල" }, + { value: "sk", label: "Slovenčina" }, { value: "sl", label: "Slovenščina" }, { value: "sq", label: "Shqip" }, + { value: "sr", label: "Српски" }, { value: "sv", label: "Svenska" }, { value: "sw", label: "Kiswahili" }, + { value: "ta", label: "தமிழ்" }, { value: "te", label: "తెలుగు" }, { value: "th", label: "ไทย" }, + { value: "tr", label: "Türkçe" }, { value: "uk", label: "Українська" }, { value: "ur", label: "اردو" }, + { value: "uz", label: "Oʻzbek" }, { value: "vi", label: "Tiếng Việt" }, { value: "zh-CN", label: "中文 (简体)" }, + { value: "zh-HK", label: "中文 (香港)" }, { value: "zh-TW", label: "中文 (繁體)" }, { value: "zu", label: "isiZulu" }, +]; diff --git a/src/lib/i18n/strings.json b/src/lib/i18n/strings.json index 7a0fd7d6..8d8019aa 100644 --- a/src/lib/i18n/strings.json +++ b/src/lib/i18n/strings.json @@ -1556,5 +1556,8 @@ "subscriptions_failed_retry": "Try again", "search_role_profile": "Profile", "settings_video_loudness": "Normalize Volume Across Videos", - "settings_video_loudness_desc": "Turns down videos mastered louder than YouTube's target, so one video is not much louder than the next." + "settings_video_loudness_desc": "Turns down videos mastered louder than YouTube's target, so one video is not much louder than the next.", + "settings_content_language": "Content Language", + "settings_content_language_desc": "The language YouTube writes dates, view counts, labels and its translated titles in. Pages you open next follow it.", + "settings_content_language_default": "English (default)" } diff --git a/src/lib/settings/schema.ts b/src/lib/settings/schema.ts index 6340460f..36a586c7 100644 --- a/src/lib/settings/schema.ts +++ b/src/lib/settings/schema.ts @@ -95,6 +95,7 @@ export const SETTINGS = { NOTIFICATION_CHECK_INTERVAL: "notif_check_interval_minutes", SHOW_REGION_PICKER_IN_EXPLORE: "show_region_picker_in_explore", TRENDING_REGION: "trending_region", + CONTENT_LANGUAGE: "content_language", DEEP_FLOW_ACTIVE: "deep_flow_active", DEEP_FLOW_ACTIVATED_AT: "deep_flow_activated_at", DEEP_FLOW_EXPIRE_HOURS: "deep_flow_expire_hours", @@ -299,6 +300,7 @@ export const SETTING_DEFINITIONS = [ str(SETTINGS.NOTIFICATION_CHECK_INTERVAL, "content", "360", "wired", ["15", "30", "60", "180", "360", "720", "1440"]), bool(SETTINGS.SHOW_REGION_PICKER_IN_EXPLORE, "content", true, "wired"), str(SETTINGS.TRENDING_REGION, "content", "US", "wired"), + str(SETTINGS.CONTENT_LANGUAGE, "content", "", "wired"), // Session state, not a preference: exporting it can permanently mute history recording // on the restoring device (its paired activated_at timestamp is internal too). bool(SETTINGS.DEEP_FLOW_ACTIVE, "content", false, "wired", "internal"), From 6e37c1ff8a925125c4dec814800056ca9d429c81 Mon Sep 17 00:00:00 2001 From: AE <98699388+A-EDev@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:33:05 +0300 Subject: [PATCH 6/6] docs(changelog): note the content language setting --- changelog/v0.1.0-beta3.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/changelog/v0.1.0-beta3.txt b/changelog/v0.1.0-beta3.txt index fc1bb3fa..1eb0c964 100644 --- a/changelog/v0.1.0-beta3.txt +++ b/changelog/v0.1.0-beta3.txt @@ -56,6 +56,7 @@ INTERFACE - When there is nothing new left, the home feed says so and offers a Refresh button instead of retrying forever. - Video cards show a verified tick next to channels, YouTube's own badges such as 4K, CC and New, a Members only label, and an Upcoming badge on premieres instead of a waiting count read as views. - A Short found in search or a channel opens in the Shorts player, and collaboration videos open the lead channel instead of none. Related cards show channel avatars. +- A new Content language setting has YouTube write dates, view counts, labels and its translated titles in one of 75 languages. Upload dates are read in that language, so Home still knows how new each video is. Playback stays as it was. PLAYER - Hovering the seek bar shows a preview frame of that moment, and the most-replayed graph above it shows which parts people watch again.