Updated the ytmusic cookies situation

This commit is contained in:
Connor Johnstone
2026-03-20 13:38:49 -04:00
parent c8e78606b1
commit fed86c9e85
10 changed files with 722 additions and 15 deletions

View File

@@ -242,3 +242,25 @@ pub async fn save_config(config: &AppConfig) -> Result<AppConfig, ApiError> {
}
resp.json().await.map_err(|e| ApiError(e.to_string()))
}
// --- YouTube Auth ---
pub async fn get_ytauth_status() -> Result<YtAuthStatus, ApiError> {
get_json(&format!("{BASE}/ytauth/status")).await
}
pub async fn ytauth_login_start() -> Result<serde_json::Value, ApiError> {
post_empty(&format!("{BASE}/ytauth/login-start")).await
}
pub async fn ytauth_login_stop() -> Result<serde_json::Value, ApiError> {
post_empty(&format!("{BASE}/ytauth/login-stop")).await
}
pub async fn ytauth_refresh() -> Result<serde_json::Value, ApiError> {
post_empty(&format!("{BASE}/ytauth/refresh")).await
}
pub async fn ytauth_clear_cookies() -> Result<(), ApiError> {
delete(&format!("{BASE}/ytauth/cookies")).await
}

View File

@@ -3,17 +3,20 @@ use web_sys::HtmlSelectElement;
use yew::prelude::*;
use crate::api;
use crate::types::AppConfig;
use crate::types::{AppConfig, YtAuthStatus};
#[function_component(SettingsPage)]
pub fn settings_page() -> Html {
let config = use_state(|| None::<AppConfig>);
let error = use_state(|| None::<String>);
let message = use_state(|| None::<String>);
let ytauth = use_state(|| None::<YtAuthStatus>);
let ytauth_loading = use_state(|| false);
{
let config = config.clone();
let error = error.clone();
let ytauth = ytauth.clone();
use_effect_with((), move |_| {
wasm_bindgen_futures::spawn_local(async move {
match api::get_config().await {
@@ -21,6 +24,11 @@ pub fn settings_page() -> Html {
Err(e) => error.set(Some(e.0)),
}
});
wasm_bindgen_futures::spawn_local(async move {
if let Ok(status) = api::get_ytauth_status().await {
ytauth.set(Some(status));
}
});
});
}
@@ -68,6 +76,195 @@ pub fn settings_page() -> Html {
return html! { <p class="loading">{ "Loading configuration..." }</p> };
};
// Build YouTube auth card HTML outside the main html! macro
let ytauth_html = {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
if let Some(ref status) = *ytauth {
if status.login_session_active {
let vnc_url = status.vnc_url.clone().unwrap_or_default();
let on_done = {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
ytauth_loading.set(true);
wasm_bindgen_futures::spawn_local(async move {
match api::ytauth_login_stop().await {
Ok(_) => {
message.set(Some("YouTube login complete! Cookies exported.".into()));
if let Ok(s) = api::get_ytauth_status().await {
ytauth.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
ytauth_loading.set(false);
});
})
};
html! {
<>
<p class="text-sm">{ "Log into YouTube in the browser below, then click Done." }</p>
if !vnc_url.is_empty() {
<p class="text-sm"><a href={vnc_url.clone()} target="_blank">{ "Open login window" }</a></p>
<iframe src={vnc_url} style="width:100%;height:500px;border:1px solid var(--border);border-radius:var(--radius);margin:0.5rem 0;" />
}
<button class="btn btn-primary" onclick={on_done} disabled={*ytauth_loading}>
{ if *ytauth_loading { "Finishing..." } else { "Done \u{2014} I've logged in" } }
</button>
</>
}
} else if status.authenticated {
let age_text = status.cookie_age_hours
.map(|h| format!("cookies {h:.0}h old"))
.unwrap_or_else(|| "authenticated".into());
let on_refresh = {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let message = message.clone();
let error = error.clone();
ytauth_loading.set(true);
wasm_bindgen_futures::spawn_local(async move {
match api::ytauth_refresh().await {
Ok(_) => {
message.set(Some("Cookies refreshed".into()));
if let Ok(s) = api::get_ytauth_status().await {
ytauth.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
ytauth_loading.set(false);
});
})
};
let on_clear = {
let ytauth = ytauth.clone();
let message = message.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let ytauth = ytauth.clone();
let message = message.clone();
let error = error.clone();
wasm_bindgen_futures::spawn_local(async move {
match api::ytauth_clear_cookies().await {
Ok(_) => {
message.set(Some("YouTube auth cleared".into()));
if let Ok(s) = api::get_ytauth_status().await {
ytauth.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
});
})
};
html! {
<>
<p>
<span class="badge badge-success">{ "Authenticated" }</span>
<span class="text-muted text-sm" style="margin-left: 0.5rem;">{ age_text }</span>
</p>
if status.refresh_enabled {
<p class="text-sm text-muted">{ "Auto-refresh is enabled" }</p>
}
<div style="display:flex;gap:0.5rem;margin-top:0.5rem;">
<button class="btn btn-sm btn-secondary" onclick={on_refresh} disabled={*ytauth_loading}>
{ if *ytauth_loading { "Refreshing..." } else { "Refresh Now" } }
</button>
<button class="btn btn-sm btn-danger" onclick={on_clear}>
{ "Clear Authentication" }
</button>
</div>
</>
}
} else {
let on_start = {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let error = error.clone();
Callback::from(move |_: MouseEvent| {
let ytauth = ytauth.clone();
let ytauth_loading = ytauth_loading.clone();
let error = error.clone();
ytauth_loading.set(true);
wasm_bindgen_futures::spawn_local(async move {
match api::ytauth_login_start().await {
Ok(_) => {
if let Ok(s) = api::get_ytauth_status().await {
ytauth.set(Some(s));
}
}
Err(e) => error.set(Some(e.0)),
}
ytauth_loading.set(false);
});
})
};
html! {
<>
<p class="text-muted text-sm">
{ "Authenticate with YouTube for higher download rate limits (~2000/hr vs ~300/hr) and access to age-restricted content. " }
{ "This launches a browser where you log into your Google account." }
</p>
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0.75rem 0;">
<p class="text-sm" style="margin:0;">
<strong style="color: var(--warning);">{ "Warning: " }</strong>
{ "YouTube may permanently suspend accounts that are used with third-party download tools. " }
<strong>{ "Use a throwaway Google account" }</strong>
{ " \u{2014} do not log in with your primary account." }
</p>
</div>
<button class="btn btn-primary" onclick={on_start} disabled={*ytauth_loading}>
{ if *ytauth_loading { "Starting..." } else { "Authenticate YouTube" } }
</button>
</>
}
}
} else {
html! { <p class="text-muted text-sm">{ "Loading..." }</p> }
}
};
let ytdlp_version_html = if let Some(ref status) = *ytauth {
let version = status.ytdlp_version.clone().unwrap_or_else(|| "not found".into());
if status.ytdlp_update_available {
let latest = status.ytdlp_latest.clone().unwrap_or_default();
html! {
<div class="card" style="border-color: var(--warning); background: rgba(234, 179, 8, 0.08); margin: 0 0 0.75rem 0; padding: 0.5rem 0.75rem;">
<p class="text-sm" style="margin:0;">
<strong style="color: var(--warning);">{ "yt-dlp update available: " }</strong>
{ format!("{version} \u{2192} {latest}") }
<span class="text-muted">{ " \u{2014} run " }</span>
<code>{ "pip install -U yt-dlp" }</code>
</p>
</div>
}
} else {
html! {
<p class="text-muted text-sm" style="margin: 0 0 0.75rem 0;">
{ format!("yt-dlp {version}") }
<span style="color: var(--success); margin-left: 0.3rem;">{ "\u{2713} up to date" }</span>
</p>
}
}
} else {
html! {}
};
html! {
<div>
<div class="page-header">
@@ -211,18 +408,6 @@ pub fn settings_page() -> Html {
})}
</select>
</div>
<div class="form-group">
<label>{ "Cookies Path (optional)" }</label>
<input type="text" value={c.download.cookies_path.clone().unwrap_or_default()}
placeholder="~/.config/shanty/cookies.txt"
oninput={let config = config.clone(); Callback::from(move |e: InputEvent| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut cfg = (*config).clone().unwrap();
let v = input.value();
cfg.download.cookies_path = if v.is_empty() { None } else { Some(v) };
config.set(Some(cfg));
})} />
</div>
<div class="form-group">
<label>{ "Rate Limit (requests/hour, guest)" }</label>
<input type="number" value={c.download.rate_limit.to_string()}
@@ -249,6 +434,13 @@ pub fn settings_page() -> Html {
</div>
</div>
// YouTube Authentication
<div class="card">
<h3>{ "YouTube Authentication" }</h3>
{ ytdlp_version_html }
{ ytauth_html }
</div>
// Indexing
<div class="card">
<h3>{ "Indexing" }</h3>

View File

@@ -160,6 +160,24 @@ pub struct TrackResult {
pub score: u8,
}
// --- YouTube Auth ---
#[derive(Debug, Clone, PartialEq, Deserialize)]
pub struct YtAuthStatus {
pub authenticated: bool,
pub cookie_age_hours: Option<f64>,
pub cookie_count: Option<i64>,
pub refresh_enabled: bool,
pub login_session_active: bool,
pub vnc_url: Option<String>,
#[serde(default)]
pub ytdlp_version: Option<String>,
#[serde(default)]
pub ytdlp_latest: Option<String>,
#[serde(default)]
pub ytdlp_update_available: bool,
}
// --- Downloads ---
#[derive(Debug, Clone, PartialEq, Deserialize)]
@@ -303,6 +321,19 @@ pub struct DownloadConfigFe {
pub rate_limit: u32,
#[serde(default)]
pub rate_limit_auth: u32,
#[serde(default)]
pub cookie_refresh_enabled: bool,
#[serde(default = "default_cookie_refresh_hours")]
pub cookie_refresh_hours: u32,
#[serde(default = "default_vnc_port")]
pub vnc_port: u16,
}
fn default_cookie_refresh_hours() -> u32 {
6
}
fn default_vnc_port() -> u16 {
6080
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]