Add cookie paste UI, treat break-on-existing as success, skip playlist metafiles

- Update cookies.txt by pasting Netscape-format contents in web UI settings and
  the desktop GUI settings window (shared write/validation in web.rs)
- Treat yt-dlp exit code 101 (--break-on-existing reached an archived video) as
  a successful "up to date" outcome instead of a failed job
- Add --no-write-playlist-metafiles so channel avatars/info aren't written as
  phantom "Title [CHANNEL_ID]" files

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-21 03:47:53 -07:00
parent 1ef3fe56c6
commit 3d317797ec
3 changed files with 149 additions and 1 deletions

View file

@ -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<crate::maintenance::HealthReport>,
@ -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);

View file

@ -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));
});

View file

@ -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<usize, String> {
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<String> {
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<CookiesBody>) -> 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(){
<div class="settings-hint">Change requires restart. Access from: tailscale, LAN, or all interfaces.</div>
</div>`:''
const logoutBtn=cur.download_password_required?`<button onclick="logout()">Log out</button>`:'';
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=`<div class="modal" style="max-width:420px">
<div class="modal-hdr"><h2>Settings</h2></div>
@ -1565,6 +1634,13 @@ async function openSettings(){
<div class="settings-hint">Gates the whole UI and all API access. Leave empty to disable on save; changing it logs out other sessions.</div>
</div>
${bindRows}
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Cookies (cookies.txt)</label>
<div class="settings-hint" id="cookies-status">${cookiesStatus}</div>
<textarea id="cf-cookies" placeholder="Paste Netscape-format cookies.txt here…" style="width:100%;height:70px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-family:monospace;font-size:11px;resize:vertical"></textarea>
<button onclick="saveCookies(this)">Update cookies</button>
<div class="settings-hint">Export with a browser extension like "Get cookies.txt LOCALLY", then paste. Refresh when downloads start hitting captchas.</div>
</div>
${srcRow}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
${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;