diff --git a/src/app.rs b/src/app.rs index 4a739cb..ebb7bc5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -101,6 +101,8 @@ pub struct App { settings_bind_mode: String, settings_password_enabled: bool, settings_password_input: String, + settings_cookies_input: String, + settings_cookies_status: String, // Maintenance (library health) window show_maintenance: bool, health_report: Option, @@ -193,6 +195,8 @@ impl App { settings_bind_mode: crate::web::bind_mode_of(&config_bind).to_string(), settings_password_enabled: password_set, settings_password_input: String::new(), + settings_cookies_input: String::new(), + settings_cookies_status: String::new(), show_maintenance: false, health_report: None, } @@ -574,6 +578,13 @@ impl App { self.settings_password_enabled = self.config.web.download_password.is_some(); self.settings_password_input.clear(); + self.settings_cookies_input.clear(); + let (exists, n) = crate::web::cookies_status(); + self.settings_cookies_status = if exists { + format!("{n} cookie(s) loaded") + } else { + "no cookies.txt".to_string() + }; } } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -1079,6 +1090,33 @@ impl App { } }); ui.end_row(); + + ui.label("Cookies:"); + ui.vertical(|ui| { + ui.label(egui::RichText::new(&self.settings_cookies_status).small().weak()); + ui.add( + egui::TextEdit::multiline(&mut self.settings_cookies_input) + .desired_rows(3) + .desired_width(300.0) + .hint_text("paste Netscape cookies.txt…"), + ); + if ui.button("Update cookies").clicked() { + match crate::web::write_cookies(&self.settings_cookies_input) { + Ok(n) => { + self.settings_cookies_status = format!("{n} cookie(s) loaded"); + self.settings_cookies_input.clear(); + self.status = format!("Cookies updated ({n} entries)"); + } + Err(e) => self.status = format!("Cookies error: {e}"), + } + } + ui.label( + egui::RichText::new("Export via a browser extension, then paste.") + .small() + .weak(), + ); + }); + ui.end_row(); }); ui.add_space(8.0); diff --git a/src/downloader.rs b/src/downloader.rs index d1597c4..c2f7469 100644 --- a/src/downloader.rs +++ b/src/downloader.rs @@ -164,6 +164,10 @@ impl Downloader { .arg("--write-thumbnail") .arg("--write-description") .arg("--write-info-json") + // Don't write channel/playlist-level metafiles (avatar, info.json, + // description). They land as "Title [CHANNEL_ID].ext" files that match + // the per-video naming pattern and show up as phantom videos. + .arg("--no-write-playlist-metafiles") .arg("--remux-video") .arg("mkv") .arg("--embed-metadata") @@ -257,7 +261,23 @@ impl Downloader { } } - let ok = child.wait().map(|s| s.success()).unwrap_or(false); + let ok = match child.wait() { + Ok(status) if status.success() => true, + // yt-dlp exits 101 when it stops early because of --break-on-existing + // (it reached an already-archived video). That's the normal "nothing + // new to download" outcome for a channel re-check, not a failure. + Ok(status) => { + if status.code() == Some(101) { + let _ = tx.send(Msg::Line( + "(up to date — stopped at already-downloaded content)".to_string(), + )); + true + } else { + false + } + } + Err(_) => false, + }; let _ = tx.send(Msg::Finished(ok)); }); diff --git a/src/web.rs b/src/web.rs index ce43c33..5922ee9 100644 --- a/src/web.rs +++ b/src/web.rs @@ -366,6 +366,53 @@ pub fn bind_mode_of(addr: &str) -> &'static str { } } +// ── Cookies ───────────────────────────────────────────────────────────────────── + +/// Path to the `cookies.txt` yt-dlp reads, resolved against the process working +/// directory (the same place `config.toml` lives and where the downloader's +/// relative `--cookies cookies.txt` resolves). +pub fn cookies_path() -> PathBuf { + std::env::current_dir() + .unwrap_or_else(|_| PathBuf::from(".")) + .join("cookies.txt") +} + +/// Count cookie entries (Netscape lines with 7 tab-separated fields). +fn count_cookies(text: &str) -> usize { + text.lines().filter(|l| l.split('\t').count() >= 7).count() +} + +/// Whether a cookies file exists and how many cookie entries it holds. +pub fn cookies_status() -> (bool, usize) { + match std::fs::read_to_string(cookies_path()) { + Ok(s) => (true, count_cookies(&s)), + Err(_) => (false, 0), + } +} + +/// Validate that `text` looks like a Netscape cookie jar and write it to +/// [`cookies_path`]. Returns the number of cookie entries written, or an error +/// message if the content doesn't look like a cookies.txt. +pub fn write_cookies(text: &str) -> Result { + if text.trim().is_empty() { + return Err("no cookies provided".to_string()); + } + let has_cookie_line = text.lines().any(|l| l.split('\t').count() >= 7); + let has_header = text.trim_start().starts_with("# Netscape") + || text.trim_start().starts_with("# HTTP Cookie File"); + if !has_cookie_line && !has_header { + return Err( + "doesn't look like a Netscape cookies.txt (expected tab-separated fields)".to_string(), + ); + } + let mut content = text.to_string(); + if !content.ends_with('\n') { + content.push('\n'); + } + std::fs::write(cookies_path(), &content).map_err(|e| e.to_string())?; + Ok(count_cookies(&content)) +} + pub fn hash_password(password: &str) -> Option { use rand::thread_rng; let salt = SaltString::generate(thread_rng()); @@ -922,6 +969,25 @@ async fn post_maintenance_repair( (StatusCode::ACCEPTED, "repair queued").into_response() } +/// `GET /api/cookies` — report whether a cookies file exists and its entry count. +async fn get_cookies() -> impl IntoResponse { + let (exists, count) = cookies_status(); + Json(serde_json::json!({ "exists": exists, "cookies": count })) +} + +#[derive(Deserialize)] +struct CookiesBody { + cookies: String, +} + +/// `POST /api/cookies` — replace cookies.txt with pasted Netscape-format content. +async fn post_cookies(Json(body): Json) -> impl IntoResponse { + match write_cookies(&body.cookies) { + Ok(count) => Json(serde_json::json!({ "ok": true, "cookies": count })).into_response(), + Err(e) => (StatusCode::BAD_REQUEST, e).into_response(), + } +} + // ── Entry point ─────────────────────────────────────────────────────────────── pub fn run(config: Config) -> ! { @@ -992,6 +1058,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .route("/api/maintenance/scan", get(get_maintenance_scan)) .route("/api/maintenance/remove", post(post_maintenance_remove)) .route("/api/maintenance/repair/:id", post(post_maintenance_repair)) + .route("/api/cookies", get(get_cookies).post(post_cookies)) .route("/api/login", post(post_login)) .route("/api/logout", post(post_logout)) .nest_service("/files", ServeDir::new(&channels_root)) @@ -1542,6 +1609,8 @@ async function openSettings(){
Change requires restart. Access from: tailscale, LAN, or all interfaces.
`:'' const logoutBtn=cur.download_password_required?``:''; + let ck={exists:false,cookies:0};try{ck=await(await api('/api/cookies')).json()}catch{} + const cookiesStatus=ck.exists?`${ck.cookies} cookie(s) loaded`:'no cookies.txt'; const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()}; bg.innerHTML=` ${bindRows} +
+ +
${cookiesStatus}
+ + +
Export with a browser extension like "Get cookies.txt LOCALLY", then paste. Refresh when downloads start hitting captchas.
+
${srcRow}
${logoutBtn} @@ -1575,6 +1651,20 @@ async function openSettings(){ document.body.appendChild(bg); } +async function saveCookies(btn){ + const t=document.getElementById('cf-cookies').value; + if(!t.trim()){setStatus('Paste cookies first');return} + btn.disabled=true; + try{ + const r=await fetch('/api/cookies',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({cookies:t})}); + if(!r.ok)throw new Error(await r.text()); + const d=await r.json(); + document.getElementById('cf-cookies').value=''; + document.getElementById('cookies-status').textContent=d.cookies+' cookie(s) loaded'; + setStatus('Cookies updated ('+d.cookies+' entries)'); + }catch(e){setStatus('Cookies error: '+e.message)}finally{btn.disabled=false} +} + async function saveSettings(btn){ const transcode=document.getElementById('cf-transcode').checked; const bindMode=document.getElementById('cf-bind')?.value;