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:
parent
1ef3fe56c6
commit
3d317797ec
3 changed files with 149 additions and 1 deletions
38
src/app.rs
38
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<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);
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
});
|
||||
|
||||
|
|
|
|||
90
src/web.rs
90
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<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;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue