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:
parent
5242b06ee5
commit
1d72069913
10 changed files with 450 additions and 127 deletions
6
.gitignore
vendored
6
.gitignore
vendored
|
|
@ -16,3 +16,9 @@
|
|||
|
||||
# Sensitive: YouTube session cookies — never commit
|
||||
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
106
Cargo.lock
generated
|
|
@ -223,6 +223,27 @@ dependencies = [
|
|||
"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]]
|
||||
name = "async-broadcast"
|
||||
version = "0.7.2"
|
||||
|
|
@ -903,6 +924,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2 0.6.2",
|
||||
"libc",
|
||||
"objc2 0.6.4",
|
||||
]
|
||||
|
||||
|
|
@ -2330,6 +2353,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.11.1",
|
||||
"block2 0.6.2",
|
||||
"objc2 0.6.4",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
|
|
@ -2626,7 +2650,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"rand_core",
|
||||
"rand_core 0.6.4",
|
||||
"subtle",
|
||||
]
|
||||
|
||||
|
|
@ -2718,6 +2742,12 @@ dependencies = [
|
|||
"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]]
|
||||
name = "potential_utf"
|
||||
version = "0.1.5"
|
||||
|
|
@ -2850,8 +2880,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha",
|
||||
"rand_core",
|
||||
"rand_chacha 0.3.1",
|
||||
"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]]
|
||||
|
|
@ -2861,7 +2901,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88"
|
||||
dependencies = [
|
||||
"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]]
|
||||
|
|
@ -2873,6 +2923,15 @@ dependencies = [
|
|||
"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]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
|
|
@ -2912,6 +2971,30 @@ version = "1.1.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
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]]
|
||||
name = "rusqlite"
|
||||
version = "0.31.0"
|
||||
|
|
@ -3477,6 +3560,7 @@ dependencies = [
|
|||
"signal-hook-registry",
|
||||
"socket2",
|
||||
"tokio-macros",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
|
|
@ -3736,8 +3820,15 @@ dependencies = [
|
|||
"idna",
|
||||
"percent-encoding",
|
||||
"serde",
|
||||
"serde_derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "urlencoding"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
|
@ -4848,7 +4939,8 @@ dependencies = [
|
|||
"eframe",
|
||||
"image",
|
||||
"notify-rust",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"rfd",
|
||||
"rusqlite",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -4883,7 +4975,7 @@ dependencies = [
|
|||
"hex",
|
||||
"nix",
|
||||
"ordered-stream",
|
||||
"rand",
|
||||
"rand 0.8.6",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"sha1",
|
||||
|
|
@ -4922,6 +5014,7 @@ dependencies = [
|
|||
"rustix 1.1.4",
|
||||
"serde",
|
||||
"serde_repr",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"uds_windows",
|
||||
"uuid",
|
||||
|
|
@ -5136,6 +5229,7 @@ dependencies = [
|
|||
"endi",
|
||||
"enumflags2",
|
||||
"serde",
|
||||
"url",
|
||||
"winnow 1.0.2",
|
||||
"zvariant_derive 5.11.0",
|
||||
"zvariant_utils 3.3.1",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ tokio-stream = "0.1"
|
|||
tower-http = { version = "0.5", features = ["cors", "fs"] }
|
||||
argon2 = "0.5"
|
||||
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]
|
||||
opt-level = 2
|
||||
|
|
|
|||
18
config.toml
18
config.toml
|
|
@ -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
|
||||
149
src/app.rs
149
src/app.rs
|
|
@ -70,6 +70,7 @@ pub struct App {
|
|||
show_downloads: bool,
|
||||
show_settings: bool,
|
||||
dl_url: String,
|
||||
dl_full_scan: bool,
|
||||
textures: HashMap<PathBuf, Option<egui::TextureHandle>>,
|
||||
thumb_request_tx: Sender<PathBuf>,
|
||||
thumb_result_rx: Receiver<(PathBuf, Option<egui::ColorImage>)>,
|
||||
|
|
@ -103,6 +104,9 @@ pub struct App {
|
|||
settings_password_input: String,
|
||||
settings_cookies_input: 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
|
||||
show_maintenance: bool,
|
||||
health_report: Option<crate::maintenance::HealthReport>,
|
||||
|
|
@ -141,7 +145,9 @@ impl App {
|
|||
|
||||
let browser = config.player.browser.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_result_tx, thumb_result_rx) =
|
||||
|
|
@ -169,6 +175,7 @@ impl App {
|
|||
show_downloads: false,
|
||||
show_settings: false,
|
||||
dl_url: String::new(),
|
||||
dl_full_scan: false,
|
||||
textures: HashMap::new(),
|
||||
thumb_request_tx,
|
||||
thumb_result_rx,
|
||||
|
|
@ -197,6 +204,8 @@ impl App {
|
|||
settings_password_input: String::new(),
|
||||
settings_cookies_input: String::new(),
|
||||
settings_cookies_status: String::new(),
|
||||
cookies_pick_tx,
|
||||
cookies_pick_rx,
|
||||
show_maintenance: false,
|
||||
health_report: None,
|
||||
}
|
||||
|
|
@ -488,11 +497,11 @@ impl App {
|
|||
fn run_scheduled_check(&mut self) {
|
||||
let mut count = 0;
|
||||
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();
|
||||
for url in urls {
|
||||
let kind = detect_url_kind(&url);
|
||||
self.downloader.start(url, &kind);
|
||||
self.downloader.start(url, &kind, false);
|
||||
count += 1;
|
||||
}
|
||||
self.status = format!("Scheduled check: started {} channel downloads", count);
|
||||
|
|
@ -576,7 +585,7 @@ impl App {
|
|||
self.settings_bind_mode =
|
||||
crate::web::bind_mode_of(&self.config.web.bind).to_string();
|
||||
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_cookies_input.clear();
|
||||
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)
|
||||
|
||||
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 meta_url = ch.meta.as_ref().and_then(|m| m.channel_url.clone());
|
||||
let (url, inferred) = if let Some(u) = meta_url {
|
||||
(Some(u), false)
|
||||
} 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)
|
||||
};
|
||||
// Always derive the check URL from the folder name so yt-dlp
|
||||
// writes to the existing folder, not a new UCxxx one.
|
||||
let url = crate::downloader::check_url_for_folder(&ch.name);
|
||||
(
|
||||
ch.name.clone(),
|
||||
ch.total_videos(),
|
||||
!ch.playlists.is_empty(),
|
||||
Self::channel_total_size(ch),
|
||||
url,
|
||||
inferred,
|
||||
)
|
||||
};
|
||||
|
||||
|
|
@ -680,12 +682,9 @@ impl App {
|
|||
let url_for_menu = channel_url.clone();
|
||||
let name_for_menu = name.clone();
|
||||
resp.context_menu(|ui| {
|
||||
if let Some(ref url) = url_for_menu {
|
||||
let mut btn = ui.button("⬇ Check for new videos");
|
||||
if url_inferred {
|
||||
btn = btn.on_hover_text(format!("URL inferred from folder name:\n{url}"));
|
||||
}
|
||||
if btn.clicked() {
|
||||
{
|
||||
let url = &url_for_menu;
|
||||
if ui.button("⬇ Check for new videos").clicked() {
|
||||
pending_ch_download = Some((url.clone(), name_for_menu.clone()));
|
||||
ui.close_menu();
|
||||
}
|
||||
|
|
@ -719,7 +718,7 @@ impl App {
|
|||
// Process deferred right-click download action
|
||||
if let Some((url, ch_name)) = pending_ch_download {
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
|
@ -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();
|
||||
if ui.add_enabled(ready, egui::Button::new("⬇ Start download")).clicked() {
|
||||
let url = self.dl_url.trim().to_string();
|
||||
let dest = dest_preview.clone();
|
||||
self.downloader.start(url, &kind);
|
||||
self.downloader.start(url, &kind, self.dl_full_scan);
|
||||
self.status = format!("Downloading: {dest}");
|
||||
}
|
||||
|
||||
|
|
@ -782,17 +783,29 @@ impl App {
|
|||
if self.downloader.jobs.is_empty() {
|
||||
ui.label(egui::RichText::new("Nothing queued yet.").weak());
|
||||
}
|
||||
let mut remove_job: Option<usize> = None;
|
||||
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 {
|
||||
JobState::Running => ("running", egui::Color32::from_rgb(230, 200, 60)),
|
||||
JobState::Done => ("done", egui::Color32::from_rgb(110, 200, 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.horizontal(|ui| {
|
||||
ui.colored_label(color, text);
|
||||
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());
|
||||
if job.state == JobState::Running {
|
||||
|
|
@ -808,14 +821,18 @@ impl App {
|
|||
.auto_shrink([false, true])
|
||||
.stick_to_bottom(true)
|
||||
.show(ui, |ui| {
|
||||
for line in &job.log {
|
||||
for line in &self.downloader.jobs[i].log {
|
||||
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.checkbox(&mut self.settings_password_enabled, "require for web downloads");
|
||||
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(
|
||||
egui::TextEdit::singleline(&mut self.settings_password_input)
|
||||
.password(true)
|
||||
.desired_width(220.0)
|
||||
.hint_text(if self.config.web.download_password.is_some() {
|
||||
"leave blank to keep current"
|
||||
} else {
|
||||
"set a password"
|
||||
}),
|
||||
.hint_text(hint),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
@ -1094,12 +1112,36 @@ impl App {
|
|||
ui.label("Cookies:");
|
||||
ui.vertical(|ui| {
|
||||
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(
|
||||
egui::TextEdit::multiline(&mut self.settings_cookies_input)
|
||||
.desired_rows(3)
|
||||
.desired_width(300.0)
|
||||
.hint_text("paste Netscape cookies.txt…"),
|
||||
);
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Update cookies").clicked() {
|
||||
match crate::web::write_cookies(&self.settings_cookies_input) {
|
||||
Ok(n) => {
|
||||
|
|
@ -1110,6 +1152,16 @@ impl App {
|
|||
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(
|
||||
egui::RichText::new("Export via a browser extension, then paste.")
|
||||
.small()
|
||||
|
|
@ -1134,15 +1186,26 @@ impl App {
|
|||
let bind_changed = new_bind != self.config.web.bind;
|
||||
self.config.web.bind = new_bind;
|
||||
|
||||
// Apply the download-password setting.
|
||||
if !self.settings_password_enabled {
|
||||
self.config.web.download_password = None;
|
||||
// Apply the download-password setting to the database.
|
||||
let pwd_result = if !self.settings_password_enabled {
|
||||
self.db.set_setting("password_hash", None)
|
||||
} else if !self.settings_password_input.is_empty() {
|
||||
match crate::web::hash_password(&self.settings_password_input) {
|
||||
Some(hash) => self.config.web.download_password = Some(hash),
|
||||
None => self.status = "Error hashing password".to_string(),
|
||||
}
|
||||
Some(hash) => {
|
||||
let r = self.db.set_setting("password_hash", Some(&hash));
|
||||
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) {
|
||||
|
|
@ -1589,6 +1652,20 @@ impl eframe::App for App {
|
|||
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.channel_panel(ctx);
|
||||
if self.show_downloads {
|
||||
|
|
|
|||
|
|
@ -88,9 +88,6 @@ pub struct WebSection {
|
|||
pub transcode: bool,
|
||||
/// Public URL to the source repository, shown in the web UI per AGPL §13.
|
||||
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 {
|
||||
|
|
@ -100,7 +97,6 @@ impl Default for WebSection {
|
|||
bind: default_web_bind(),
|
||||
transcode: false,
|
||||
source_url: None,
|
||||
download_password: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
//! |---|---|---|
|
||||
//! | `watched` | `video_id` (PK), `watched_at` | Records videos the user has marked watched |
|
||||
//! | `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 std::collections::{HashMap, HashSet};
|
||||
|
|
@ -45,11 +46,36 @@ impl Database {
|
|||
video_id TEXT PRIMARY KEY,
|
||||
position_secs REAL NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
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<()> {
|
||||
if watched {
|
||||
self.conn.execute(
|
||||
|
|
|
|||
|
|
@ -39,6 +39,21 @@ pub enum UrlKind {
|
|||
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.
|
||||
pub fn detect_url_kind(url: &str) -> UrlKind {
|
||||
if url.contains("playlist?list=") {
|
||||
|
|
@ -132,7 +147,13 @@ impl Downloader {
|
|||
///
|
||||
/// The output path template is derived from `kind` so that channels,
|
||||
/// 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 (out_arg, label) = match kind {
|
||||
|
|
@ -178,9 +199,11 @@ impl Downloader {
|
|||
.arg("all")
|
||||
.arg("--extractor-args")
|
||||
.arg("youtube:player_client=web")
|
||||
.arg("--progress")
|
||||
.arg("--break-on-existing")
|
||||
.arg("--download-archive")
|
||||
.arg("--progress");
|
||||
if !full_scan {
|
||||
cmd.arg("--break-on-existing");
|
||||
}
|
||||
cmd.arg("--download-archive")
|
||||
.arg(archive_path.display().to_string())
|
||||
.arg("--impersonate")
|
||||
.arg("Chrome-146:Macos-26")
|
||||
|
|
@ -254,6 +277,12 @@ impl Downloader {
|
|||
|
||||
if let Some(stdout) = child.stdout.take() {
|
||||
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) {
|
||||
let _ = tx.send(Msg::Progress(p));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,8 +213,10 @@ fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<R
|
|||
if !path.is_file() { continue; }
|
||||
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
|
||||
if let Some(sub_stem) = file_name.strip_suffix(".vtt") {
|
||||
// Subtitles: "Title [id].en.vtt" or "Title [id].en.srt" — strip ext then .lang
|
||||
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('.') {
|
||||
let lang = sub_stem[dot + 1..].to_string();
|
||||
let video_stem = sub_stem[..dot].to_string();
|
||||
|
|
|
|||
160
src/web.rs
160
src/web.rs
|
|
@ -163,6 +163,10 @@ struct SubtitleInfo {
|
|||
#[derive(Deserialize)]
|
||||
struct StartDownloadRequest {
|
||||
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`.
|
||||
|
|
@ -368,6 +372,23 @@ pub fn bind_mode_of(addr: &str) -> &'static str {
|
|||
|
||||
// ── 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
|
||||
/// directory (the same place `config.toml` lives and where the downloader's
|
||||
/// relative `--cookies cookies.txt` resolves).
|
||||
|
|
@ -463,7 +484,7 @@ fn is_authed(state: &WebState, headers: &HeaderMap) -> bool {
|
|||
|
||||
/// Whether a download/access password is configured.
|
||||
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)]
|
||||
|
|
@ -476,7 +497,7 @@ async fn post_login(
|
|||
State(state): State<Arc<WebState>>,
|
||||
Json(body): Json<LoginRequest>,
|
||||
) -> 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 {
|
||||
// No password configured; nothing to authenticate against.
|
||||
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 subtitles: Vec<SubtitleInfo> = v.subtitles.iter()
|
||||
.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 {
|
||||
lang: s.lang.clone(),
|
||||
label: lang_label(&s.lang),
|
||||
|
|
@ -619,7 +647,7 @@ async fn post_download(
|
|||
return (StatusCode::BAD_REQUEST, "empty URL").into_response();
|
||||
}
|
||||
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()
|
||||
}
|
||||
|
||||
|
|
@ -643,7 +671,9 @@ async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
|
|||
let port = cfg.web.port;
|
||||
let current_bind = cfg.web.bind.clone();
|
||||
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 {
|
||||
transcode: state.transcode.load(Ordering::Relaxed),
|
||||
source_url,
|
||||
|
|
@ -668,18 +698,6 @@ async fn post_settings(
|
|||
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) {
|
||||
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 port = cfg.web.port;
|
||||
let available_binds = get_available_binds(port);
|
||||
let download_password_required = cfg.web.download_password.is_some();
|
||||
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 {
|
||||
transcode: body.transcode,
|
||||
source_url,
|
||||
|
|
@ -700,6 +736,31 @@ async fn post_settings(
|
|||
}).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(
|
||||
State(state): State<Arc<WebState>>,
|
||||
Path(id): Path<String>,
|
||||
|
|
@ -969,6 +1030,15 @@ async fn post_maintenance_repair(
|
|||
(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.
|
||||
async fn get_cookies() -> impl IntoResponse {
|
||||
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 ───────────────────────────────────────────────────────────────
|
||||
|
||||
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/settings", get(get_settings).post(post_settings))
|
||||
.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/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/cookies", get(get_cookies).post(post_cookies).delete(delete_cookies))
|
||||
.route("/api/login", post(post_login))
|
||||
.route("/api/logout", post(post_logout))
|
||||
.nest_service("/files", ServeDir::new(&channels_root))
|
||||
|
|
@ -1274,6 +1353,7 @@ const HTML_UI: &str = r#"<!DOCTYPE html>
|
|||
<footer>
|
||||
<input type="url" id="dl-url" placeholder="YouTube URL…" onkeydown="if(event.key==='Enter')previewDownload()">
|
||||
<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>
|
||||
</footer>
|
||||
|
||||
|
|
@ -1308,7 +1388,7 @@ function setStatus(s){document.getElementById('status').textContent=s}
|
|||
function renderSidebar(){
|
||||
const el=document.getElementById('sidebar');
|
||||
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);
|
||||
let h=`<div class="sidebar-label">Library</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){
|
||||
for(const ch of library)
|
||||
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.sort((a,b)=>(b.resume_pos||0)-(a.resume_pos||0));
|
||||
return vids;
|
||||
|
|
@ -1442,15 +1522,22 @@ async function previewDownload(){
|
|||
setStatus('');
|
||||
}
|
||||
}
|
||||
function fullScan(){return document.getElementById('dl-full-scan')?.checked||false}
|
||||
async function confirmDownload(url,btn){
|
||||
if(btn)btn.closest('.modal-bg').remove();
|
||||
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…')
|
||||
}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 downloadChannelByIdx(i){await downloadChannel(channelUrls[i]||'https://www.youtube.com/@'+library[i].name)}
|
||||
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)}}
|
||||
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 ─────────────────────────────────────────────────────── */
|
||||
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">
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
${srcRow}
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end;margin-top:12px">
|
||||
|
|
@ -1651,6 +1742,14 @@ async function openSettings(){
|
|||
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){
|
||||
const t=document.getElementById('cf-cookies').value;
|
||||
if(!t.trim()){setStatus('Paste cookies first');return}
|
||||
|
|
@ -1664,6 +1763,15 @@ async function saveCookies(btn){
|
|||
setStatus('Cookies updated ('+d.cookies+' entries)');
|
||||
}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){
|
||||
const transcode=document.getElementById('cf-transcode').checked;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue