Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/v0.1.0-beta3.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 9 additions & 2 deletions src-tauri/src/api/innertube/core/clients.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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}")
Expand Down
7 changes: 6 additions & 1 deletion src-tauri/src/api/innertube/core/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
88 changes: 88 additions & 0 deletions src-tauri/src/api/innertube/core/locale.rs
Original file line number Diff line number Diff line change
@@ -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<String> = 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<String> = 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");
}
}
1 change: 1 addition & 0 deletions src-tauri/src/api/innertube/core/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
56 changes: 45 additions & 11 deletions src-tauri/src/api/innertube/extractors/channel/header.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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::<Vec<_>>()
})
.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<String>, Option<String>) {
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<String> {
header["metadata"]["contentMetadataViewModel"]["metadataRows"]
Expand All @@ -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"]
Expand Down Expand Up @@ -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()
Expand Down
2 changes: 1 addition & 1 deletion src-tauri/src/api/innertube/extractors/player.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading