Add full-scan mode, persist password in DB, fix channel re-check URLs, SRT subs, cookie clear, UI fixes

- Full-scan download toggle: omits --break-on-existing so all unarchived
  videos are fetched regardless of order, filling gaps in partial archives
- Filter "Aborting remaining downloads" from job logs (break-on-existing noise)
- Password hash moved from config.toml to SQLite settings table; survives
  git checkouts and rebuilds. config.toml and yt-offline.db added to .gitignore;
  config auto-generated on first run if absent
- Channel re-check URLs derived from folder name (/@handle or /channel/UCxxx)
  instead of info.json's canonical channel_url, preventing duplicate UCxxx folders
- SRT subtitles detected by library scanner and served via /api/sub-vtt/* endpoint
  that converts to WebVTT on the fly; browser <track> element can now play them
- Cookie clear button in both desktop and web UI (DELETE /api/cookies)
- Per-job X close button on finished download jobs in desktop GUI
- Fix widget ID clash when multiple download jobs are shown (push_id per job)
- Watched videos excluded from Continue Watching list and badge count
- settings table added to SQLite schema (key/value store)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-21 05:49:55 -07:00
parent 5242b06ee5
commit 1d72069913
10 changed files with 450 additions and 127 deletions

6
.gitignore vendored
View file

@ -16,3 +16,9 @@
# Sensitive: YouTube session cookies — never commit # Sensitive: YouTube session cookies — never commit
cookies.txt cookies.txt
# User-specific runtime config (bind address, password hash, etc.) — never commit
config.toml
# Runtime database (watched status, positions, password hash) — never commit
yt-offline.db

106
Cargo.lock generated
View file

@ -223,6 +223,27 @@ dependencies = [
"libloading", "libloading",
] ]
[[package]]
name = "ashpd"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f3f79755c74fd155000314eb349864caa787c6592eace6c6882dad873d9c39"
dependencies = [
"enumflags2",
"futures-channel",
"futures-util",
"rand 0.9.4",
"raw-window-handle",
"serde",
"serde_repr",
"tokio",
"url",
"wayland-backend",
"wayland-client",
"wayland-protocols",
"zbus 5.15.0",
]
[[package]] [[package]]
name = "async-broadcast" name = "async-broadcast"
version = "0.7.2" version = "0.7.2"
@ -903,6 +924,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2 0.6.2",
"libc",
"objc2 0.6.4", "objc2 0.6.4",
] ]
@ -2330,6 +2353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.11.1",
"block2 0.6.2",
"objc2 0.6.4", "objc2 0.6.4",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-core-graphics", "objc2-core-graphics",
@ -2626,7 +2650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [ dependencies = [
"base64ct", "base64ct",
"rand_core", "rand_core 0.6.4",
"subtle", "subtle",
] ]
@ -2718,6 +2742,12 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "pollster"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2f3a9f18d041e6d0e102a0a46750538147e5e8992d3b4873aaafee2520b00ce3"
[[package]] [[package]]
name = "potential_utf" name = "potential_utf"
version = "0.1.5" version = "0.1.5"
@ -2850,8 +2880,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
dependencies = [ dependencies = [
"libc", "libc",
"rand_chacha", "rand_chacha 0.3.1",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea"
dependencies = [
"rand_chacha 0.9.0",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@ -2861,7 +2901,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
dependencies = [ dependencies = [
"ppv-lite86", "ppv-lite86",
"rand_core", "rand_core 0.6.4",
]
[[package]]
name = "rand_chacha"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
dependencies = [
"ppv-lite86",
"rand_core 0.9.5",
] ]
[[package]] [[package]]
@ -2873,6 +2923,15 @@ dependencies = [
"getrandom 0.2.17", "getrandom 0.2.17",
] ]
[[package]]
name = "rand_core"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
dependencies = [
"getrandom 0.3.4",
]
[[package]] [[package]]
name = "raw-window-handle" name = "raw-window-handle"
version = "0.6.2" version = "0.6.2"
@ -2912,6 +2971,30 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rfd"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef2bee61e6cffa4635c72d7d81a84294e28f0930db0ddcb0f66d10244674ebed"
dependencies = [
"ashpd",
"block2 0.6.2",
"dispatch2",
"js-sys",
"log",
"objc2 0.6.4",
"objc2-app-kit 0.3.2",
"objc2-core-foundation",
"objc2-foundation 0.3.2",
"pollster",
"raw-window-handle",
"urlencoding",
"wasm-bindgen",
"wasm-bindgen-futures",
"web-sys",
"windows-sys 0.59.0",
]
[[package]] [[package]]
name = "rusqlite" name = "rusqlite"
version = "0.31.0" version = "0.31.0"
@ -3477,6 +3560,7 @@ dependencies = [
"signal-hook-registry", "signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"tracing",
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
@ -3736,8 +3820,15 @@ dependencies = [
"idna", "idna",
"percent-encoding", "percent-encoding",
"serde", "serde",
"serde_derive",
] ]
[[package]]
name = "urlencoding"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@ -4848,7 +4939,8 @@ dependencies = [
"eframe", "eframe",
"image", "image",
"notify-rust", "notify-rust",
"rand", "rand 0.8.6",
"rfd",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",
@ -4883,7 +4975,7 @@ dependencies = [
"hex", "hex",
"nix", "nix",
"ordered-stream", "ordered-stream",
"rand", "rand 0.8.6",
"serde", "serde",
"serde_repr", "serde_repr",
"sha1", "sha1",
@ -4922,6 +5014,7 @@ dependencies = [
"rustix 1.1.4", "rustix 1.1.4",
"serde", "serde",
"serde_repr", "serde_repr",
"tokio",
"tracing", "tracing",
"uds_windows", "uds_windows",
"uuid", "uuid",
@ -5136,6 +5229,7 @@ dependencies = [
"endi", "endi",
"enumflags2", "enumflags2",
"serde", "serde",
"url",
"winnow 1.0.2", "winnow 1.0.2",
"zvariant_derive 5.11.0", "zvariant_derive 5.11.0",
"zvariant_utils 3.3.1", "zvariant_utils 3.3.1",

View file

@ -19,6 +19,9 @@ tokio-stream = "0.1"
tower-http = { version = "0.5", features = ["cors", "fs"] } tower-http = { version = "0.5", features = ["cors", "fs"] }
argon2 = "0.5" argon2 = "0.5"
rand = "0.8" rand = "0.8"
# File-picker dialog for the desktop GUI. xdg-portal backend keeps it pure-Rust
# (zbus, no GTK/libdbus build dep), consistent with notify-rust above.
rfd = { version = "0.15", default-features = false, features = ["xdg-portal", "tokio"] }
[profile.release] [profile.release]
opt-level = 2 opt-level = 2

View file

@ -1,18 +0,0 @@
[backup]
directory = "/mnt/InannaBeloved/youtube-backup/channels"
[player]
command = "mpv"
browser = "firefox"
[ui]
theme = "dark"
[scheduler]
enabled = false
interval_hours = 24
[web]
port = 8081
transcode = false
# source_url = "https://codeberg.org/anassaeneroi/yt-offline" # required for AGPL §13

View file

@ -70,6 +70,7 @@ pub struct App {
show_downloads: bool, show_downloads: bool,
show_settings: bool, show_settings: bool,
dl_url: String, dl_url: String,
dl_full_scan: bool,
textures: HashMap<PathBuf, Option<egui::TextureHandle>>, textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
thumb_request_tx: Sender<PathBuf>, thumb_request_tx: Sender<PathBuf>,
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>, thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
@ -103,6 +104,9 @@ pub struct App {
settings_password_input: String, settings_password_input: String,
settings_cookies_input: String, settings_cookies_input: String,
settings_cookies_status: String, settings_cookies_status: String,
// File picker → chosen cookies.txt path arrives here from a dialog thread.
cookies_pick_tx: Sender<PathBuf>,
cookies_pick_rx: Receiver<PathBuf>,
// Maintenance (library health) window // Maintenance (library health) window
show_maintenance: bool, show_maintenance: bool,
health_report: Option<crate::maintenance::HealthReport>, health_report: Option<crate::maintenance::HealthReport>,
@ -141,7 +145,9 @@ impl App {
let browser = config.player.browser.clone(); let browser = config.player.browser.clone();
let config_bind = config.web.bind.clone(); let config_bind = config.web.bind.clone();
let password_set = config.web.download_password.is_some(); let password_set = db.get_setting("password_hash").ok().flatten().is_some();
let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::<PathBuf>();
let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>(); let (thumb_request_tx, thumb_request_rx) = std::sync::mpsc::channel::<PathBuf>();
let (thumb_result_tx, thumb_result_rx) = let (thumb_result_tx, thumb_result_rx) =
@ -169,6 +175,7 @@ impl App {
show_downloads: false, show_downloads: false,
show_settings: false, show_settings: false,
dl_url: String::new(), dl_url: String::new(),
dl_full_scan: false,
textures: HashMap::new(), textures: HashMap::new(),
thumb_request_tx, thumb_request_tx,
thumb_result_rx, thumb_result_rx,
@ -197,6 +204,8 @@ impl App {
settings_password_input: String::new(), settings_password_input: String::new(),
settings_cookies_input: String::new(), settings_cookies_input: String::new(),
settings_cookies_status: String::new(), settings_cookies_status: String::new(),
cookies_pick_tx,
cookies_pick_rx,
show_maintenance: false, show_maintenance: false,
health_report: None, health_report: None,
} }
@ -488,11 +497,11 @@ impl App {
fn run_scheduled_check(&mut self) { fn run_scheduled_check(&mut self) {
let mut count = 0; let mut count = 0;
let urls: Vec<String> = self.library.iter() let urls: Vec<String> = self.library.iter()
.filter_map(|ch| ch.meta.as_ref()?.channel_url.clone()) .map(|ch| crate::downloader::check_url_for_folder(&ch.name))
.collect(); .collect();
for url in urls { for url in urls {
let kind = detect_url_kind(&url); let kind = detect_url_kind(&url);
self.downloader.start(url, &kind); self.downloader.start(url, &kind, false);
count += 1; count += 1;
} }
self.status = format!("Scheduled check: started {} channel downloads", count); self.status = format!("Scheduled check: started {} channel downloads", count);
@ -576,7 +585,7 @@ impl App {
self.settings_bind_mode = self.settings_bind_mode =
crate::web::bind_mode_of(&self.config.web.bind).to_string(); crate::web::bind_mode_of(&self.config.web.bind).to_string();
self.settings_password_enabled = self.settings_password_enabled =
self.config.web.download_password.is_some(); self.db.get_setting("password_hash").ok().flatten().is_some();
self.settings_password_input.clear(); self.settings_password_input.clear();
self.settings_cookies_input.clear(); self.settings_cookies_input.clear();
let (exists, n) = crate::web::cookies_status(); let (exists, n) = crate::web::cookies_status();
@ -637,24 +646,17 @@ impl App {
let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name) let mut pending_ch_download: Option<(String, String)> = None; // (url, channel_name)
for i in 0..self.library.len() { for i in 0..self.library.len() {
let (name, total, has_playlists, size_bytes, channel_url, url_inferred) = { let (name, total, has_playlists, size_bytes, channel_url) = {
let ch = &self.library[i]; let ch = &self.library[i];
let meta_url = ch.meta.as_ref().and_then(|m| m.channel_url.clone()); // Always derive the check URL from the folder name so yt-dlp
let (url, inferred) = if let Some(u) = meta_url { // writes to the existing folder, not a new UCxxx one.
(Some(u), false) let url = crate::downloader::check_url_for_folder(&ch.name);
} else {
let folder_url = ch.path.file_name()
.and_then(|n| n.to_str())
.map(|n| format!("https://www.youtube.com/@{n}"));
(folder_url, true)
};
( (
ch.name.clone(), ch.name.clone(),
ch.total_videos(), ch.total_videos(),
!ch.playlists.is_empty(), !ch.playlists.is_empty(),
Self::channel_total_size(ch), Self::channel_total_size(ch),
url, url,
inferred,
) )
}; };
@ -680,12 +682,9 @@ impl App {
let url_for_menu = channel_url.clone(); let url_for_menu = channel_url.clone();
let name_for_menu = name.clone(); let name_for_menu = name.clone();
resp.context_menu(|ui| { resp.context_menu(|ui| {
if let Some(ref url) = url_for_menu { {
let mut btn = ui.button("⬇ Check for new videos"); let url = &url_for_menu;
if url_inferred { if ui.button("⬇ Check for new videos").clicked() {
btn = btn.on_hover_text(format!("URL inferred from folder name:\n{url}"));
}
if btn.clicked() {
pending_ch_download = Some((url.clone(), name_for_menu.clone())); pending_ch_download = Some((url.clone(), name_for_menu.clone()));
ui.close_menu(); ui.close_menu();
} }
@ -719,7 +718,7 @@ impl App {
// Process deferred right-click download action // Process deferred right-click download action
if let Some((url, ch_name)) = pending_ch_download { if let Some((url, ch_name)) = pending_ch_download {
let kind = detect_url_kind(&url); let kind = detect_url_kind(&url);
self.downloader.start(url, &kind); self.downloader.start(url, &kind, self.dl_full_scan);
self.status = format!("Checking {} for new videos…", ch_name); self.status = format!("Checking {} for new videos…", ch_name);
} }
}); });
@ -760,11 +759,13 @@ impl App {
} }
} }
ui.checkbox(&mut self.dl_full_scan, "Full scan (check every video, fills gaps)");
let ready = !self.dl_url.trim().is_empty(); let ready = !self.dl_url.trim().is_empty();
if ui.add_enabled(ready, egui::Button::new("⬇ Start download")).clicked() { if ui.add_enabled(ready, egui::Button::new("⬇ Start download")).clicked() {
let url = self.dl_url.trim().to_string(); let url = self.dl_url.trim().to_string();
let dest = dest_preview.clone(); let dest = dest_preview.clone();
self.downloader.start(url, &kind); self.downloader.start(url, &kind, self.dl_full_scan);
self.status = format!("Downloading: {dest}"); self.status = format!("Downloading: {dest}");
} }
@ -782,17 +783,29 @@ impl App {
if self.downloader.jobs.is_empty() { if self.downloader.jobs.is_empty() {
ui.label(egui::RichText::new("Nothing queued yet.").weak()); ui.label(egui::RichText::new("Nothing queued yet.").weak());
} }
let mut remove_job: Option<usize> = None;
egui::ScrollArea::vertical().show(ui, |ui| { egui::ScrollArea::vertical().show(ui, |ui| {
for job in self.downloader.jobs.iter().rev() { let n = self.downloader.jobs.len();
for i in (0..n).rev() {
let job = &self.downloader.jobs[i];
let (text, color) = match job.state { let (text, color) = match job.state {
JobState::Running => ("running", egui::Color32::from_rgb(230, 200, 60)), JobState::Running => ("running", egui::Color32::from_rgb(230, 200, 60)),
JobState::Done => ("done", egui::Color32::from_rgb(110, 200, 110)), JobState::Done => ("done", egui::Color32::from_rgb(110, 200, 110)),
JobState::Failed => ("failed", egui::Color32::from_rgb(220, 110, 110)), JobState::Failed => ("failed", egui::Color32::from_rgb(220, 110, 110)),
}; };
let finished = job.state != JobState::Running;
ui.push_id(i, |ui| {
ui.group(|ui| { ui.group(|ui| {
ui.horizontal(|ui| { ui.horizontal(|ui| {
ui.colored_label(color, text); ui.colored_label(color, text);
ui.label(&job.label); ui.label(&job.label);
if finished {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("").clicked() {
remove_job = Some(i);
}
});
}
}); });
ui.label(egui::RichText::new(&job.url).small().weak()); ui.label(egui::RichText::new(&job.url).small().weak());
if job.state == JobState::Running { if job.state == JobState::Running {
@ -808,14 +821,18 @@ impl App {
.auto_shrink([false, true]) .auto_shrink([false, true])
.stick_to_bottom(true) .stick_to_bottom(true)
.show(ui, |ui| { .show(ui, |ui| {
for line in &job.log { for line in &self.downloader.jobs[i].log {
ui.label(egui::RichText::new(line).small().monospace()); ui.label(egui::RichText::new(line).small().monospace());
} }
}); });
}); });
}); });
});
} }
}); });
if let Some(i) = remove_job {
self.downloader.remove_job(i);
}
}); });
} }
@ -1061,15 +1078,16 @@ impl App {
ui.vertical(|ui| { ui.vertical(|ui| {
ui.checkbox(&mut self.settings_password_enabled, "require for web downloads"); ui.checkbox(&mut self.settings_password_enabled, "require for web downloads");
if self.settings_password_enabled { if self.settings_password_enabled {
let hint = if self.settings_password_input.is_empty() {
"leave blank to keep current"
} else {
"set a password"
};
ui.add( ui.add(
egui::TextEdit::singleline(&mut self.settings_password_input) egui::TextEdit::singleline(&mut self.settings_password_input)
.password(true) .password(true)
.desired_width(220.0) .desired_width(220.0)
.hint_text(if self.config.web.download_password.is_some() { .hint_text(hint),
"leave blank to keep current"
} else {
"set a password"
}),
); );
} }
}); });
@ -1094,12 +1112,36 @@ impl App {
ui.label("Cookies:"); ui.label("Cookies:");
ui.vertical(|ui| { ui.vertical(|ui| {
ui.label(egui::RichText::new(&self.settings_cookies_status).small().weak()); ui.label(egui::RichText::new(&self.settings_cookies_status).small().weak());
if ui.button("📁 Choose cookies.txt…").clicked() {
// Run the file dialog off the UI thread; the chosen
// path comes back via cookies_pick_rx (polled in update()).
let tx = self.cookies_pick_tx.clone();
std::thread::spawn(move || {
if let Ok(rt) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
let picked = rt.block_on(async {
rfd::AsyncFileDialog::new()
.set_title("Select cookies.txt")
.add_filter("cookies", &["txt"])
.pick_file()
.await
});
if let Some(f) = picked {
let _ = tx.send(f.path().to_path_buf());
}
}
});
}
ui.label(egui::RichText::new("…or paste below").small().weak());
ui.add( ui.add(
egui::TextEdit::multiline(&mut self.settings_cookies_input) egui::TextEdit::multiline(&mut self.settings_cookies_input)
.desired_rows(3) .desired_rows(3)
.desired_width(300.0) .desired_width(300.0)
.hint_text("paste Netscape cookies.txt…"), .hint_text("paste Netscape cookies.txt…"),
); );
ui.horizontal(|ui| {
if ui.button("Update cookies").clicked() { if ui.button("Update cookies").clicked() {
match crate::web::write_cookies(&self.settings_cookies_input) { match crate::web::write_cookies(&self.settings_cookies_input) {
Ok(n) => { Ok(n) => {
@ -1110,6 +1152,16 @@ impl App {
Err(e) => self.status = format!("Cookies error: {e}"), Err(e) => self.status = format!("Cookies error: {e}"),
} }
} }
if ui.button("Clear cookies").clicked() {
match crate::web::clear_cookies() {
Ok(()) => {
self.settings_cookies_status = "no cookies.txt".to_string();
self.status = "Cookies cleared".to_string();
}
Err(e) => self.status = format!("Error clearing cookies: {e}"),
}
}
});
ui.label( ui.label(
egui::RichText::new("Export via a browser extension, then paste.") egui::RichText::new("Export via a browser extension, then paste.")
.small() .small()
@ -1134,15 +1186,26 @@ impl App {
let bind_changed = new_bind != self.config.web.bind; let bind_changed = new_bind != self.config.web.bind;
self.config.web.bind = new_bind; self.config.web.bind = new_bind;
// Apply the download-password setting. // Apply the download-password setting to the database.
if !self.settings_password_enabled { let pwd_result = if !self.settings_password_enabled {
self.config.web.download_password = None; self.db.set_setting("password_hash", None)
} else if !self.settings_password_input.is_empty() { } else if !self.settings_password_input.is_empty() {
match crate::web::hash_password(&self.settings_password_input) { match crate::web::hash_password(&self.settings_password_input) {
Some(hash) => self.config.web.download_password = Some(hash), Some(hash) => {
None => self.status = "Error hashing password".to_string(), let r = self.db.set_setting("password_hash", Some(&hash));
}
self.settings_password_input.clear(); self.settings_password_input.clear();
r
}
None => {
self.status = "Error hashing password".to_string();
Ok(())
}
}
} else {
Ok(())
};
if let Err(e) = pwd_result {
self.status = format!("DB error: {e}");
} }
match self.config.save(&self.config_path) { match self.config.save(&self.config_path) {
@ -1589,6 +1652,20 @@ impl eframe::App for App {
self.textures.insert(path, handle); self.textures.insert(path, handle);
} }
// A cookies.txt was chosen in the file dialog — validate and install it.
while let Ok(path) = self.cookies_pick_rx.try_recv() {
match std::fs::read_to_string(&path) {
Ok(content) => match crate::web::write_cookies(&content) {
Ok(n) => {
self.settings_cookies_status = format!("{n} cookie(s) loaded");
self.status = format!("Cookies imported ({n} entries)");
}
Err(e) => self.status = format!("Cookies error: {e}"),
},
Err(e) => self.status = format!("Could not read {}: {e}", path.display()),
}
}
self.top_bar(ctx); self.top_bar(ctx);
self.channel_panel(ctx); self.channel_panel(ctx);
if self.show_downloads { if self.show_downloads {

View file

@ -88,9 +88,6 @@ pub struct WebSection {
pub transcode: bool, pub transcode: bool,
/// Public URL to the source repository, shown in the web UI per AGPL §13. /// Public URL to the source repository, shown in the web UI per AGPL §13.
pub source_url: Option<String>, pub source_url: Option<String>,
/// Optional plaintext password required for downloads via web UI.
/// If set, users must provide this password to queue downloads.
pub download_password: Option<String>,
} }
impl Default for WebSection { impl Default for WebSection {
@ -100,7 +97,6 @@ impl Default for WebSection {
bind: default_web_bind(), bind: default_web_bind(),
transcode: false, transcode: false,
source_url: None, source_url: None,
download_password: None,
} }
} }
} }

View file

@ -8,6 +8,7 @@
//! |---|---|---| //! |---|---|---|
//! | `watched` | `video_id` (PK), `watched_at` | Records videos the user has marked watched | //! | `watched` | `video_id` (PK), `watched_at` | Records videos the user has marked watched |
//! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions | //! | `positions` | `video_id` (PK), `position_secs`, `updated_at` | Stores resume positions |
//! | `settings` | `key` (PK), `value` | Persistent app settings (password hash, etc.) |
use rusqlite::{Connection, Result}; use rusqlite::{Connection, Result};
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
@ -45,11 +46,36 @@ impl Database {
video_id TEXT PRIMARY KEY, video_id TEXT PRIMARY KEY,
position_secs REAL NOT NULL, position_secs REAL NOT NULL,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);", );",
)?; )?;
Ok(()) Ok(())
} }
pub fn get_setting(&self, key: &str) -> Result<Option<String>> {
let mut stmt = self.conn.prepare("SELECT value FROM settings WHERE key = ?1")?;
let mut rows = stmt.query([key])?;
Ok(rows.next()?.map(|r| r.get(0)).transpose()?)
}
pub fn set_setting(&self, key: &str, value: Option<&str>) -> Result<()> {
match value {
Some(v) => {
self.conn.execute(
"INSERT OR REPLACE INTO settings (key, value) VALUES (?1, ?2)",
[key, v],
)?;
}
None => {
self.conn.execute("DELETE FROM settings WHERE key = ?1", [key])?;
}
}
Ok(())
}
pub fn set_watched(&self, video_id: &str, watched: bool) -> Result<()> { pub fn set_watched(&self, video_id: &str, watched: bool) -> Result<()> {
if watched { if watched {
self.conn.execute( self.conn.execute(

View file

@ -39,6 +39,21 @@ pub enum UrlKind {
Unknown, Unknown,
} }
/// Build a YouTube URL from a library folder name that yt-dlp will resolve to
/// the same folder it already downloaded to.
///
/// Folder names that look like a channel ID (`UC` + 22 chars) use the
/// `/channel/` form; everything else is treated as a handle and gets `/@`.
/// This avoids the mismatch where info.json's canonical `channel_url` field
/// points to `/channel/UCxxx` and yt-dlp then creates a second folder.
pub fn check_url_for_folder(folder_name: &str) -> String {
if folder_name.starts_with("UC") && folder_name.len() == 24 {
format!("https://www.youtube.com/channel/{folder_name}")
} else {
format!("https://www.youtube.com/@{folder_name}")
}
}
/// Classify a YouTube URL into a [`UrlKind`] by inspecting its path. /// Classify a YouTube URL into a [`UrlKind`] by inspecting its path.
pub fn detect_url_kind(url: &str) -> UrlKind { pub fn detect_url_kind(url: &str) -> UrlKind {
if url.contains("playlist?list=") { if url.contains("playlist?list=") {
@ -132,7 +147,13 @@ impl Downloader {
/// ///
/// The output path template is derived from `kind` so that channels, /// The output path template is derived from `kind` so that channels,
/// playlists, and individual videos land in the right sub-directories. /// playlists, and individual videos land in the right sub-directories.
pub fn start(&mut self, url: String, kind: &UrlKind) { ///
/// When `full_scan` is false (default / incremental mode) `--break-on-existing`
/// is passed so yt-dlp stops as soon as it hits the first already-archived
/// video — fast for routine channel checks. When `full_scan` is true the
/// flag is omitted so every video is checked individually against the
/// download archive; slower, but correctly fills gaps in the history.
pub fn start(&mut self, url: String, kind: &UrlKind, full_scan: bool) {
let archive_path = self.channels_root.join("archive.txt"); let archive_path = self.channels_root.join("archive.txt");
let (out_arg, label) = match kind { let (out_arg, label) = match kind {
@ -178,9 +199,11 @@ impl Downloader {
.arg("all") .arg("all")
.arg("--extractor-args") .arg("--extractor-args")
.arg("youtube:player_client=web") .arg("youtube:player_client=web")
.arg("--progress") .arg("--progress");
.arg("--break-on-existing") if !full_scan {
.arg("--download-archive") cmd.arg("--break-on-existing");
}
cmd.arg("--download-archive")
.arg(archive_path.display().to_string()) .arg(archive_path.display().to_string())
.arg("--impersonate") .arg("--impersonate")
.arg("Chrome-146:Macos-26") .arg("Chrome-146:Macos-26")
@ -254,6 +277,12 @@ impl Downloader {
if let Some(stdout) = child.stdout.take() { if let Some(stdout) = child.stdout.take() {
for line in BufReader::new(stdout).lines().map_while(Result::ok) { for line in BufReader::new(stdout).lines().map_while(Result::ok) {
// Suppress the alarming "Aborting remaining downloads" line that
// yt-dlp emits for --break-on-existing; we replace it with a
// friendlier message when we detect exit code 101 below.
if line.trim() == "Aborting remaining downloads" {
continue;
}
if let Some(p) = parse_progress(&line) { if let Some(p) = parse_progress(&line) {
let _ = tx.send(Msg::Progress(p)); let _ = tx.send(Msg::Progress(p));
} }

View file

@ -213,8 +213,10 @@ fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<R
if !path.is_file() { continue; } if !path.is_file() { continue; }
let file_name = entry.file_name().to_string_lossy().into_owned(); let file_name = entry.file_name().to_string_lossy().into_owned();
// Subtitles have stems like "Title [id].en.vtt" — strip the .vtt and trailing .lang // Subtitles: "Title [id].en.vtt" or "Title [id].en.srt" — strip ext then .lang
if let Some(sub_stem) = file_name.strip_suffix(".vtt") { let sub_stem = file_name.strip_suffix(".vtt")
.or_else(|| file_name.strip_suffix(".srt"));
if let Some(sub_stem) = sub_stem {
if let Some(dot) = sub_stem.rfind('.') { if let Some(dot) = sub_stem.rfind('.') {
let lang = sub_stem[dot + 1..].to_string(); let lang = sub_stem[dot + 1..].to_string();
let video_stem = sub_stem[..dot].to_string(); let video_stem = sub_stem[..dot].to_string();

View file

@ -163,6 +163,10 @@ struct SubtitleInfo {
#[derive(Deserialize)] #[derive(Deserialize)]
struct StartDownloadRequest { struct StartDownloadRequest {
url: String, url: String,
/// When true, omits `--break-on-existing` so every video is checked
/// individually — slower but fills gaps in partially-archived channels.
#[serde(default)]
full_scan: bool,
} }
/// Response body for `GET /api/progress`. /// Response body for `GET /api/progress`.
@ -368,6 +372,23 @@ pub fn bind_mode_of(addr: &str) -> &'static str {
// ── Cookies ───────────────────────────────────────────────────────────────────── // ── Cookies ─────────────────────────────────────────────────────────────────────
/// Convert SubRip (SRT) subtitle text to WebVTT.
///
/// The only structural differences are the `WEBVTT` header and the timestamp
/// decimal separator (SRT uses `,`, VTT uses `.`).
fn srt_to_vtt(srt: &str) -> String {
let mut out = String::from("WEBVTT\n\n");
for line in srt.lines() {
if line.contains("-->") {
out.push_str(&line.replace(',', "."));
} else {
out.push_str(line);
}
out.push('\n');
}
out
}
/// Path to the `cookies.txt` yt-dlp reads, resolved against the process working /// 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 /// directory (the same place `config.toml` lives and where the downloader's
/// relative `--cookies cookies.txt` resolves). /// relative `--cookies cookies.txt` resolves).
@ -463,7 +484,7 @@ fn is_authed(state: &WebState, headers: &HeaderMap) -> bool {
/// Whether a download/access password is configured. /// Whether a download/access password is configured.
fn password_required(state: &WebState) -> bool { fn password_required(state: &WebState) -> bool {
state.config.lock().unwrap().web.download_password.is_some() state.db.lock().unwrap().get_setting("password_hash").ok().flatten().is_some()
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@ -476,7 +497,7 @@ async fn post_login(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
Json(body): Json<LoginRequest>, Json(body): Json<LoginRequest>,
) -> Response { ) -> Response {
let hash = state.config.lock().unwrap().web.download_password.clone(); let hash = state.db.lock().unwrap().get_setting("password_hash").ok().flatten();
let Some(hash) = hash else { let Some(hash) = hash else {
// No password configured; nothing to authenticate against. // No password configured; nothing to authenticate against.
return (StatusCode::OK, "no password set").into_response(); return (StatusCode::OK, "no password set").into_response();
@ -558,7 +579,14 @@ async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let thumb_url = v.thumb_path.as_deref().and_then(|p| file_url(root, p)); let thumb_url = v.thumb_path.as_deref().and_then(|p| file_url(root, p));
let subtitles: Vec<SubtitleInfo> = v.subtitles.iter() let subtitles: Vec<SubtitleInfo> = v.subtitles.iter()
.filter_map(|s| { .filter_map(|s| {
let url = file_url(root, &s.path)?; let is_srt = s.path.extension().and_then(|e| e.to_str()) == Some("srt");
let url = if is_srt {
// Route SRT through the on-the-fly conversion endpoint.
let rel = s.path.strip_prefix(root).ok()?;
Some(format!("/api/sub-vtt/{}", rel.display()))
} else {
file_url(root, &s.path)
}?;
Some(SubtitleInfo { Some(SubtitleInfo {
lang: s.lang.clone(), lang: s.lang.clone(),
label: lang_label(&s.lang), label: lang_label(&s.lang),
@ -619,7 +647,7 @@ async fn post_download(
return (StatusCode::BAD_REQUEST, "empty URL").into_response(); return (StatusCode::BAD_REQUEST, "empty URL").into_response();
} }
let kind = detect_url_kind(&url); let kind = detect_url_kind(&url);
state.downloader.lock().unwrap().start(url, &kind); state.downloader.lock().unwrap().start(url, &kind, body.full_scan);
(StatusCode::ACCEPTED, "ok").into_response() (StatusCode::ACCEPTED, "ok").into_response()
} }
@ -643,7 +671,9 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let port = cfg.web.port; let port = cfg.web.port;
let current_bind = cfg.web.bind.clone(); let current_bind = cfg.web.bind.clone();
let available_binds = get_available_binds(port); let available_binds = get_available_binds(port);
let download_password_required = cfg.web.download_password.is_some(); drop(cfg);
let download_password_required =
state.db.lock().unwrap().get_setting("password_hash").ok().flatten().is_some();
Json(SettingsPayload { Json(SettingsPayload {
transcode: state.transcode.load(Ordering::Relaxed), transcode: state.transcode.load(Ordering::Relaxed),
source_url, source_url,
@ -668,18 +698,6 @@ async fn post_settings(
cfg.web.bind = new_addr; cfg.web.bind = new_addr;
} }
if let Some(new_pwd) = &body.new_download_password {
if new_pwd.is_empty() {
cfg.web.download_password = None;
} else if let Some(hashed) = hash_password(new_pwd) {
cfg.web.download_password = Some(hashed);
} else {
return (StatusCode::INTERNAL_SERVER_ERROR, "failed to hash password").into_response();
}
// Password changed: drop all existing sessions so they must re-authenticate.
state.sessions.lock().unwrap().clear();
}
if let Err(e) = cfg.save(&state.config_path) { if let Err(e) = cfg.save(&state.config_path) {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response(); return (StatusCode::INTERNAL_SERVER_ERROR, format!("save failed: {e}")).into_response();
} }
@ -687,8 +705,26 @@ async fn post_settings(
let current_bind = cfg.web.bind.clone(); let current_bind = cfg.web.bind.clone();
let port = cfg.web.port; let port = cfg.web.port;
let available_binds = get_available_binds(port); let available_binds = get_available_binds(port);
let download_password_required = cfg.web.download_password.is_some();
drop(cfg); drop(cfg);
if let Some(new_pwd) = &body.new_download_password {
let db = state.db.lock().unwrap();
let result = if new_pwd.is_empty() {
db.set_setting("password_hash", None)
} else if let Some(hashed) = hash_password(new_pwd) {
db.set_setting("password_hash", Some(&hashed))
} else {
return (StatusCode::INTERNAL_SERVER_ERROR, "failed to hash password").into_response();
};
if let Err(e) = result {
return (StatusCode::INTERNAL_SERVER_ERROR, format!("db error: {e}")).into_response();
}
// Password changed: drop all existing sessions so they must re-authenticate.
state.sessions.lock().unwrap().clear();
}
let download_password_required =
state.db.lock().unwrap().get_setting("password_hash").ok().flatten().is_some();
Json(SettingsPayload { Json(SettingsPayload {
transcode: body.transcode, transcode: body.transcode,
source_url, source_url,
@ -700,6 +736,31 @@ async fn post_settings(
}).into_response() }).into_response()
} }
/// `GET /api/sub-vtt/*path` — serve an SRT subtitle file as WebVTT.
///
/// The path is relative to the channels root. The file must be within the
/// channels root (path traversal is rejected with 403).
async fn get_sub_vtt(
State(state): State<Arc<WebState>>,
Path(rel): Path<String>,
) -> Response {
let path = state.channels_root.join(&rel);
// Reject path traversal outside the library.
let ok = match (state.channels_root.canonicalize(), path.canonicalize()) {
(Ok(root), Ok(p)) => p.starts_with(root),
_ => false,
};
if !ok {
return StatusCode::FORBIDDEN.into_response();
}
let content = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
};
let vtt = srt_to_vtt(&content);
([(header::CONTENT_TYPE, "text/vtt; charset=utf-8")], vtt).into_response()
}
async fn get_transcode( async fn get_transcode(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
Path(id): Path<String>, Path(id): Path<String>,
@ -969,6 +1030,15 @@ async fn post_maintenance_repair(
(StatusCode::ACCEPTED, "repair queued").into_response() (StatusCode::ACCEPTED, "repair queued").into_response()
} }
/// Delete cookies.txt, removing all stored session cookies.
pub fn clear_cookies() -> Result<(), String> {
let p = cookies_path();
if p.exists() {
std::fs::remove_file(&p).map_err(|e| e.to_string())?;
}
Ok(())
}
/// `GET /api/cookies` — report whether a cookies file exists and its entry count. /// `GET /api/cookies` — report whether a cookies file exists and its entry count.
async fn get_cookies() -> impl IntoResponse { async fn get_cookies() -> impl IntoResponse {
let (exists, count) = cookies_status(); let (exists, count) = cookies_status();
@ -988,6 +1058,14 @@ async fn post_cookies(Json(body): Json<CookiesBody>) -> impl IntoResponse {
} }
} }
/// `DELETE /api/cookies` — remove cookies.txt entirely.
async fn delete_cookies() -> impl IntoResponse {
match clear_cookies() {
Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e).into_response(),
}
}
// ── Entry point ─────────────────────────────────────────────────────────────── // ── Entry point ───────────────────────────────────────────────────────────────
pub fn run(config: Config) -> ! { pub fn run(config: Config) -> ! {
@ -1055,10 +1133,11 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
.route("/api/metadata/:id", get(get_metadata)) .route("/api/metadata/:id", get(get_metadata))
.route("/api/settings", get(get_settings).post(post_settings)) .route("/api/settings", get(get_settings).post(post_settings))
.route("/api/transcode/:id", get(get_transcode)) .route("/api/transcode/:id", get(get_transcode))
.route("/api/sub-vtt/*path", get(get_sub_vtt))
.route("/api/maintenance/scan", get(get_maintenance_scan)) .route("/api/maintenance/scan", get(get_maintenance_scan))
.route("/api/maintenance/remove", post(post_maintenance_remove)) .route("/api/maintenance/remove", post(post_maintenance_remove))
.route("/api/maintenance/repair/:id", post(post_maintenance_repair)) .route("/api/maintenance/repair/:id", post(post_maintenance_repair))
.route("/api/cookies", get(get_cookies).post(post_cookies)) .route("/api/cookies", get(get_cookies).post(post_cookies).delete(delete_cookies))
.route("/api/login", post(post_login)) .route("/api/login", post(post_login))
.route("/api/logout", post(post_logout)) .route("/api/logout", post(post_logout))
.nest_service("/files", ServeDir::new(&channels_root)) .nest_service("/files", ServeDir::new(&channels_root))
@ -1274,6 +1353,7 @@ const HTML_UI: &str = r#"<!DOCTYPE html>
<footer> <footer>
<input type="url" id="dl-url" placeholder="YouTube URL…" onkeydown="if(event.key==='Enter')previewDownload()"> <input type="url" id="dl-url" placeholder="YouTube URL…" onkeydown="if(event.key==='Enter')previewDownload()">
<button class="primary" onclick="previewDownload()"> Download</button> <button class="primary" onclick="previewDownload()"> Download</button>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;white-space:nowrap;cursor:pointer" title="Check every video individually instead of stopping at the first already-archived one. Slower but fills gaps."><input type="checkbox" id="dl-full-scan"> Full scan</label>
<span id="agpl-notice" style="font-size:10px;color:var(--muted);margin-left:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span> <span id="agpl-notice" style="font-size:10px;color:var(--muted);margin-left:auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis"></span>
</footer> </footer>
@ -1308,7 +1388,7 @@ function setStatus(s){document.getElementById('status').textContent=s}
function renderSidebar(){ function renderSidebar(){
const el=document.getElementById('sidebar'); const el=document.getElementById('sidebar');
const allVids=library.flatMap(ch=>[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]); const allVids=library.flatMap(ch=>[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]);
const contVids=allVids.filter(v=>v.resume_pos&&v.resume_pos>5); const contVids=allVids.filter(v=>v.resume_pos&&v.resume_pos>5&&!v.watched);
const total=library.reduce((s,c)=>s+c.total_videos,0); const total=library.reduce((s,c)=>s+c.total_videos,0);
let h=`<div class="sidebar-label">Library</div>`; let h=`<div class="sidebar-label">Library</div>`;
if(contVids.length)h+=`<div class="ch-item${showContinue?' active':''}" onclick="setContinue()"> Continue (${contVids.length})</div>`; if(contVids.length)h+=`<div class="ch-item${showContinue?' active':''}" onclick="setContinue()"> Continue (${contVids.length})</div>`;
@ -1340,7 +1420,7 @@ function currentVideos(){
if(showContinue){ if(showContinue){
for(const ch of library) for(const ch of library)
for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)]) for(const v of[...ch.videos,...ch.playlists.flatMap(p=>p.videos)])
if(v.resume_pos&&v.resume_pos>5&&(!q||v.title.toLowerCase().includes(q)||v.id.includes(q))) if(v.resume_pos&&v.resume_pos>5&&!v.watched&&(!q||v.title.toLowerCase().includes(q)||v.id.includes(q)))
vids.push({...v,channel:ch.name}); vids.push({...v,channel:ch.name});
vids.sort((a,b)=>(b.resume_pos||0)-(a.resume_pos||0)); vids.sort((a,b)=>(b.resume_pos||0)-(a.resume_pos||0));
return vids; return vids;
@ -1442,15 +1522,22 @@ async function previewDownload(){
setStatus(''); setStatus('');
} }
} }
function fullScan(){return document.getElementById('dl-full-scan')?.checked||false}
async function confirmDownload(url,btn){ async function confirmDownload(url,btn){
if(btn)btn.closest('.modal-bg').remove(); if(btn)btn.closest('.modal-bg').remove();
try{ try{
await api('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})}); await api('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,full_scan:fullScan()})});
document.getElementById('dl-url').value='';setStatus('Download queued') document.getElementById('dl-url').value='';setStatus('Download queued')
}catch(e){setStatus('Error: '+e.message)} }catch(e){setStatus('Error: '+e.message)}
} }
async function downloadChannel(url){try{await api('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});setStatus('Checking for new videos')}catch(e){setStatus('Error: '+e.message)}} async function downloadChannel(url){try{await api('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url,full_scan:fullScan()})});setStatus('Checking for new videos')}catch(e){setStatus('Error: '+e.message)}}
async function downloadChannelByIdx(i){await downloadChannel(channelUrls[i]||'https://www.youtube.com/@'+library[i].name)} function checkUrlForChannel(name){
// Mirror check_url_for_folder in downloader.rs: UC+22 chars = channel ID, else handle
return(/^UC.{22}$/.test(name))
?'https://www.youtube.com/channel/'+name
:'https://www.youtube.com/@'+name;
}
async function downloadChannelByIdx(i){await downloadChannel(checkUrlForChannel(library[i].name))}
/* ── Rescan ─────────────────────────────────────────────────────── */ /* ── Rescan ─────────────────────────────────────────────────────── */
async function rescan(){try{await api('/api/rescan',{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}} async function rescan(){try{await api('/api/rescan',{method:'POST'});await loadLibrary()}catch(e){setStatus('Error: '+e.message)}}
@ -1637,9 +1724,13 @@ async function openSettings(){
<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px"> <div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Cookies (cookies.txt)</label> <label>Cookies (cookies.txt)</label>
<div class="settings-hint" id="cookies-status">${cookiesStatus}</div> <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> <input type="file" id="cf-cookies-file" accept=".txt,text/plain" onchange="loadCookieFile(this)" style="font-size:11px;color:var(--muted)">
<textarea id="cf-cookies" placeholder="…or 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>
<div style="display:flex;gap:6px">
<button onclick="saveCookies(this)">Update cookies</button> <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> <button onclick="clearCookies(this)" style="color:var(--muted)">Clear</button>
</div>
<div class="settings-hint">Choose a file or paste (e.g. from "Get cookies.txt LOCALLY"). Refresh when downloads start hitting captchas.</div>
</div> </div>
${srcRow} ${srcRow}
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px"> <div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
@ -1651,6 +1742,14 @@ async function openSettings(){
document.body.appendChild(bg); document.body.appendChild(bg);
} }
function loadCookieFile(input){
const f=input.files&&input.files[0];if(!f)return;
const r=new FileReader();
r.onload=()=>{document.getElementById('cf-cookies').value=r.result;setStatus('Loaded '+f.name+' click Update cookies to save')};
r.onerror=()=>setStatus('Could not read file');
r.readAsText(f);
}
async function saveCookies(btn){ async function saveCookies(btn){
const t=document.getElementById('cf-cookies').value; const t=document.getElementById('cf-cookies').value;
if(!t.trim()){setStatus('Paste cookies first');return} if(!t.trim()){setStatus('Paste cookies first');return}
@ -1664,6 +1763,15 @@ async function saveCookies(btn){
setStatus('Cookies updated ('+d.cookies+' entries)'); setStatus('Cookies updated ('+d.cookies+' entries)');
}catch(e){setStatus('Cookies error: '+e.message)}finally{btn.disabled=false} }catch(e){setStatus('Cookies error: '+e.message)}finally{btn.disabled=false}
} }
async function clearCookies(btn){
if(!confirm('Remove cookies.txt? Downloads requiring login will fail until you add new cookies.'))return;
btn.disabled=true;
try{
await api('/api/cookies',{method:'DELETE'});
document.getElementById('cookies-status').textContent='no cookies.txt';
setStatus('Cookies cleared');
}catch(e){setStatus('Error: '+e.message)}finally{btn.disabled=false}
}
async function saveSettings(btn){ async function saveSettings(btn){
const transcode=document.getElementById('cf-transcode').checked; const transcode=document.getElementById('cf-transcode').checked;