Add web themes, network binding, UI auth, library maintenance; optimize + fix rescan

Features:
- Port all 7 desktop themes (Dracula, Trans, Emo variants, etc.) to the web UI
- Configurable bind interface (localhost/Tailscale/LAN/all) with detected IPs,
  settable from both the web UI and desktop GUI
- Optional password that gates the whole UI and all API access via session
  cookies (Argon2), with a login page, logout, and middleware; settable from
  both UIs
- Library maintenance: scan for duplicate video IDs and missing assets
  (thumbnail/info.json/description), remove duplicates (scan-and-confirm, with
  out-of-library path guard), and re-fetch missing sidecars via yt-dlp.
  Surfaced in both the web UI and a desktop GUI window.

Optimizations / cleanup:
- Cache has_chapters at scan time (library.rs) instead of re-reading and
  parsing every info.json on each /api/library request
- Use the cached channel size in get_library
- Pool a single SQLite connection in WebState instead of opening one per request
- Remove a 1.2 GB stray nested src/yt-offline duplicate tree

Fixes:
- Rescan no longer clears all thumbnail textures (caused thumbnails to unload);
  prune only removed entries and bump library_generation so the card grid
  refreshes correctly

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-21 02:58:09 -07:00
parent 74b3efd990
commit 1ef3fe56c6
10 changed files with 1126 additions and 89 deletions

47
Cargo.lock generated
View file

@ -184,6 +184,18 @@ dependencies = [
"x11rb", "x11rb",
] ]
[[package]]
name = "argon2"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
dependencies = [
"base64ct",
"blake2",
"cpufeatures",
"password-hash",
]
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@ -483,6 +495,12 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.6.0" version = "0.6.0"
@ -510,6 +528,15 @@ version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block" name = "block"
version = "0.1.6" version = "0.1.6"
@ -860,6 +887,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer",
"crypto-common", "crypto-common",
"subtle",
] ]
[[package]] [[package]]
@ -2591,6 +2619,17 @@ dependencies = [
"windows-link 0.2.1", "windows-link 0.2.1",
] ]
[[package]]
name = "password-hash"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
dependencies = [
"base64ct",
"rand_core",
"subtle",
]
[[package]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@ -3251,6 +3290,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "syn" name = "syn"
version = "1.0.109" version = "1.0.109"
@ -4798,10 +4843,12 @@ dependencies = [
name = "yt-offline" name = "yt-offline"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"argon2",
"axum", "axum",
"eframe", "eframe",
"image", "image",
"notify-rust", "notify-rust",
"rand",
"rusqlite", "rusqlite",
"serde", "serde",
"serde_json", "serde_json",

View file

@ -17,6 +17,8 @@ tokio = { version = "1", features = ["full"] }
tokio-util = { version = "0.7", features = ["io"] } tokio-util = { version = "0.7", features = ["io"] }
tokio-stream = "0.1" tokio-stream = "0.1"
tower-http = { version = "0.5", features = ["cors", "fs"] } tower-http = { version = "0.5", features = ["cors", "fs"] }
argon2 = "0.5"
rand = "0.8"
[profile.release] [profile.release]
opt-level = 2 opt-level = 2

View file

@ -170,9 +170,10 @@ paste 1.0.15
## Recommendations (Priority Order) ## Recommendations (Priority Order)
### HIGH ### HIGH
1. **Verify web server binding** — Ensure `axum` binds to `127.0.0.1` only, not `0.0.0.0`. 1. ✅ **Web server binding** — **FIXED**
- Check: `src/web.rs:618` where the listener is created. - Added `web.bind` config option (default `127.0.0.1`).
- Mitigation: Add a config option for `bind_addr` or document localhost-only setup. - Set in `config.toml` to `127.0.0.1` for localhost-only access.
- Users can change to `0.0.0.0` if they understand the security implications.
### MEDIUM ### MEDIUM
2. **File permissions on `cookies.txt`** — Remind users to `chmod 600 cookies.txt`. 2. **File permissions on `cookies.txt`** — Remind users to `chmod 600 cookies.txt`.

View file

@ -97,6 +97,13 @@ pub struct App {
// Web server // Web server
web_server_running: bool, web_server_running: bool,
web_server_shutdown: Option<Sender<()>>, web_server_shutdown: Option<Sender<()>>,
// Settings-window scratch state (not persisted directly)
settings_bind_mode: String,
settings_password_enabled: bool,
settings_password_input: String,
// Maintenance (library health) window
show_maintenance: bool,
health_report: Option<crate::maintenance::HealthReport>,
} }
impl App { impl App {
@ -131,6 +138,8 @@ impl App {
let resume_positions = db.get_positions().unwrap_or_default(); let resume_positions = db.get_positions().unwrap_or_default();
let browser = config.player.browser.clone(); let browser = config.player.browser.clone();
let config_bind = config.web.bind.clone();
let password_set = config.web.download_password.is_some();
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) =
@ -181,6 +190,11 @@ impl App {
library_generation: 0, library_generation: 0,
web_server_running: false, web_server_running: false,
web_server_shutdown: None, web_server_shutdown: None,
settings_bind_mode: crate::web::bind_mode_of(&config_bind).to_string(),
settings_password_enabled: password_set,
settings_password_input: String::new(),
show_maintenance: false,
health_report: None,
} }
} }
@ -189,7 +203,20 @@ impl App {
self.sidebar_view = SidebarView::All; self.sidebar_view = SidebarView::All;
self.selected_video = None; self.selected_video = None;
self.desc_cache.clear(); self.desc_cache.clear();
self.textures.clear(); // Keep already-decoded thumbnail textures across rescans — they're keyed
// by file path and don't change. Drop only entries whose thumbnail is no
// longer in the library so removed videos don't leak GPU textures. This
// avoids the full thumbnail reload flicker on every rescan.
let valid: HashSet<PathBuf> = self
.library
.iter()
.flat_map(|c| c.videos.iter().chain(c.playlists.iter().flat_map(|p| p.videos.iter())))
.filter_map(|v| v.thumb_path.clone())
.collect();
self.textures.retain(|p, _| valid.contains(p));
self.thumb_pending.retain(|p| valid.contains(p));
// Bump the generation so the card cache recomputes against the new library.
self.library_generation += 1;
self.status = format!( self.status = format!(
"Rescanned: {} channels, {} videos", "Rescanned: {} channels, {} videos",
self.library.len(), self.library.len(),
@ -531,10 +558,22 @@ impl App {
if ui.selectable_label(self.show_downloads, dl_label).clicked() { if ui.selectable_label(self.show_downloads, dl_label).clicked() {
self.show_downloads = !self.show_downloads; self.show_downloads = !self.show_downloads;
} }
if ui.selectable_label(self.show_maintenance, "🩺 Maintenance").clicked() {
self.show_maintenance = !self.show_maintenance;
if self.show_maintenance {
self.health_report =
Some(crate::maintenance::scan(&self.channels_root, &self.library));
}
}
if ui.selectable_label(self.show_settings, "⚙ Settings").clicked() { if ui.selectable_label(self.show_settings, "⚙ Settings").clicked() {
self.show_settings = !self.show_settings; self.show_settings = !self.show_settings;
if self.show_settings { if self.show_settings {
self.settings_dir = self.channels_root.display().to_string(); self.settings_dir = self.channels_root.display().to_string();
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.settings_password_input.clear();
} }
} }
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
@ -769,6 +808,128 @@ impl App {
}); });
} }
fn maintenance_window(&mut self, ctx: &egui::Context) {
if !self.show_maintenance {
return;
}
let mut open = self.show_maintenance;
let report = self.health_report.clone().unwrap_or_default();
// Actions are collected during rendering and applied after the closure
// to avoid borrowing `self` while the report is borrowed immutably.
let mut to_remove: Vec<PathBuf> = Vec::new();
let mut to_repair: Vec<String> = Vec::new();
let mut rescan_health = false;
egui::Window::new("🩺 Library health")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(620.0)
.default_height(500.0)
.show(ctx, |ui| {
if ui.button("⟳ Rescan health").clicked() {
rescan_health = true;
}
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading(format!("Duplicates ({})", report.duplicates.len()));
if report.duplicates.is_empty() {
ui.label(egui::RichText::new("No duplicate video IDs found.").weak());
}
for g in &report.duplicates {
ui.group(|ui| {
ui.label(
egui::RichText::new(format!("{} [{}]", g.title, g.id)).strong(),
);
for c in &g.copies {
let size = c
.file_size
.map(format_size)
.unwrap_or_else(|| "no video".to_string());
let tag = if c.recommended_keep { "✓ keep" } else { "✗ remove" };
ui.label(format!(
" {} · {} · {} files — {}",
c.location, size, c.files.len(), tag
));
}
if ui.button("🗑 Remove non-recommended copies").clicked() {
for c in &g.copies {
if !c.recommended_keep {
to_remove.extend(c.files.iter().cloned());
}
}
}
});
}
ui.add_space(10.0);
ui.heading(format!("Missing assets ({})", report.missing.len()));
if report.missing.is_empty() {
ui.label(
egui::RichText::new(
"Every video has its thumbnail, metadata, and description.",
)
.weak(),
);
}
if report.missing.len() > 1
&& ui
.button(format!("⬇ Fetch all missing ({})", report.missing.len()))
.clicked()
{
for m in &report.missing {
to_repair.push(m.id.clone());
}
}
for m in &report.missing {
ui.horizontal(|ui| {
let need: Vec<&str> = [
m.missing_thumbnail.then_some("thumbnail"),
m.missing_info.then_some("metadata"),
m.missing_description.then_some("description"),
]
.into_iter()
.flatten()
.collect();
ui.label(format!("{} — missing {}", m.title, need.join(", ")));
if ui.button("⬇ Fetch").clicked() {
to_repair.push(m.id.clone());
}
});
}
});
});
self.show_maintenance = open;
// ── Apply collected actions ────────────────────────────────────────
let mut changed = false;
if !to_remove.is_empty() {
let (removed, errors) =
crate::maintenance::remove_files(&self.channels_root, &to_remove);
self.status = if errors.is_empty() {
format!("Removed {removed} file(s)")
} else {
format!("Removed {removed} file(s), {} error(s)", errors.len())
};
changed = true;
}
if !to_repair.is_empty() {
for id in &to_repair {
if let Some((dir, stem)) = crate::maintenance::locate(&self.library, id) {
self.downloader.repair(id, &dir, &stem);
}
}
self.status = format!("Queued {} repair(s) — see Downloads", to_repair.len());
self.show_downloads = true;
}
if changed || rescan_health {
self.rescan();
self.health_report =
Some(crate::maintenance::scan(&self.channels_root, &self.library));
}
}
fn settings_window(&mut self, ctx: &egui::Context) { fn settings_window(&mut self, ctx: &egui::Context) {
if !self.show_settings { if !self.show_settings {
return; return;
@ -864,6 +1025,45 @@ impl App {
); );
ui.end_row(); ui.end_row();
ui.label("Bind interface:");
let binds = crate::web::get_available_binds(self.config.web.port);
let selected_label = binds
.iter()
.find(|b| b.id == self.settings_bind_mode)
.map(|b| b.label.clone())
.unwrap_or_else(|| "Localhost only".to_string());
egui::ComboBox::from_id_salt("bind_combo")
.selected_text(selected_label)
.show_ui(ui, |ui| {
for b in &binds {
if ui
.selectable_label(self.settings_bind_mode == b.id, &b.label)
.clicked()
{
self.settings_bind_mode = b.id.clone();
}
}
});
ui.end_row();
ui.label("Download password:");
ui.vertical(|ui| {
ui.checkbox(&mut self.settings_password_enabled, "require for web downloads");
if self.settings_password_enabled {
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"
}),
);
}
});
ui.end_row();
ui.label("Web server:"); ui.label("Web server:");
ui.horizontal(|ui| { ui.horizontal(|ui| {
if self.web_server_running { if self.web_server_running {
@ -890,6 +1090,23 @@ impl App {
let new_dir = PathBuf::from(&self.settings_dir); let new_dir = PathBuf::from(&self.settings_dir);
let dir_changed = new_dir != self.config.backup.directory; let dir_changed = new_dir != self.config.backup.directory;
self.config.backup.directory = new_dir.clone(); self.config.backup.directory = new_dir.clone();
// Resolve the chosen interface to a concrete bind address.
let new_bind = crate::web::resolve_bind_mode(&self.settings_bind_mode);
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;
} 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(),
}
self.settings_password_input.clear();
}
match self.config.save(&self.config_path) { match self.config.save(&self.config_path) {
Ok(_) => self.status = "Settings saved.".to_string(), Ok(_) => self.status = "Settings saved.".to_string(),
Err(e) => self.status = format!("Error saving config: {e}"), Err(e) => self.status = format!("Error saving config: {e}"),
@ -900,6 +1117,12 @@ impl App {
let _ = std::fs::create_dir_all(&self.channels_root); let _ = std::fs::create_dir_all(&self.channels_root);
self.rescan(); self.rescan();
} }
// Re-bind a running server so the new interface takes effect now.
if bind_changed && self.web_server_running {
self.stop_web_server();
self.start_web_server();
self.status = "Settings saved. Web server re-bound.".to_string();
}
} }
ui.label( ui.label(
egui::RichText::new("Theme previews immediately; other changes apply on save.") egui::RichText::new("Theme previews immediately; other changes apply on save.")
@ -1334,6 +1557,7 @@ impl eframe::App for App {
self.downloads_panel(ctx); self.downloads_panel(ctx);
} }
self.settings_window(ctx); self.settings_window(ctx);
self.maintenance_window(ctx);
self.detail_panel(ctx); self.detail_panel(ctx);
egui::CentralPanel::default().show(ctx, |ui| { egui::CentralPanel::default().show(ctx, |ui| {
self.video_list(ctx, ui); self.video_list(ctx, ui);

View file

@ -42,6 +42,8 @@ impl Default for PlayerSection {
} }
/// `[ui]` table — egui desktop theme. /// `[ui]` table — egui desktop theme.
///
/// Available themes: `dark`, `light`, `dracula`, `trans`, `emo-nocturnal`, `emo-coffin`, `emo-scene-queen`.
#[derive(Debug, Serialize, Deserialize, Clone)] #[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UiSection { pub struct UiSection {
#[serde(default = "default_theme")] #[serde(default = "default_theme")]
@ -86,6 +88,9 @@ 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 {
@ -95,6 +100,7 @@ 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

@ -133,7 +133,6 @@ 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) { pub fn start(&mut self, url: String, kind: &UrlKind) {
let (tx, rx) = channel();
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 {
@ -155,8 +154,6 @@ impl Downloader {
), ),
}; };
let url_for_thread = url.clone();
thread::spawn(move || {
let mut cmd = Command::new("yt-dlp"); let mut cmd = Command::new("yt-dlp");
cmd.arg("--newline") cmd.arg("--newline")
.arg("--no-color") .arg("--no-color")
@ -185,11 +182,54 @@ impl Downloader {
.arg("Chrome-146:Macos-26") .arg("Chrome-146:Macos-26")
.arg("-o") .arg("-o")
.arg(&out_arg) .arg(&out_arg)
.arg(&url_for_thread) .arg(&url);
.stdin(Stdio::null())
self.spawn_job(cmd, url, label);
}
/// Re-fetch missing sidecar assets (thumbnail, info.json, description,
/// subtitles) for a single video without re-downloading the video itself.
///
/// `dir`/`stem` come from the existing file on disk so the fetched sidecars
/// land with exactly the same filename stem and associate with the video.
pub fn repair(&mut self, video_id: &str, dir: &std::path::Path, stem: &str) {
let _ = std::fs::create_dir_all(dir);
// Escape literal `%` so yt-dlp doesn't treat it as an output field.
let safe_stem = stem.replace('%', "%%");
let out_arg = format!("{}/{}.%(ext)s", dir.display(), safe_stem);
let url = format!("https://www.youtube.com/watch?v={video_id}");
let label = format!("repair {stem}");
let mut cmd = Command::new("yt-dlp");
cmd.arg("--newline")
.arg("--no-color")
.arg("--skip-download")
.arg("--cookies")
.arg("cookies.txt")
.arg("--write-thumbnail")
.arg("--write-info-json")
.arg("--write-description")
.arg("--write-subs")
.arg("--write-auto-subs")
.arg("--extractor-args")
.arg("youtube:player_client=web")
.arg("--impersonate")
.arg("Chrome-146:Macos-26")
.arg("-o")
.arg(&out_arg)
.arg(&url);
self.spawn_job(cmd, url, label);
}
/// Spawn `cmd` on a background thread, streaming its output into a new [`Job`].
fn spawn_job(&mut self, mut cmd: Command, url: String, label: String) {
let (tx, rx) = channel();
cmd.stdin(Stdio::null())
.stdout(Stdio::piped()) .stdout(Stdio::piped())
.stderr(Stdio::piped()); .stderr(Stdio::piped());
thread::spawn(move || {
let mut child = match cmd.spawn() { let mut child = match cmd.spawn() {
Ok(child) => child, Ok(child) => child,
Err(err) => { Err(err) => {

View file

@ -50,6 +50,9 @@ pub struct Video {
pub has_live_chat: bool, pub has_live_chat: bool,
/// Duration read from `info.json`; `None` if the sidecar is missing. /// Duration read from `info.json`; `None` if the sidecar is missing.
pub duration_secs: Option<f64>, pub duration_secs: Option<f64>,
/// Whether `info.json` lists a non-empty `chapters` array. Cached at scan
/// time so the web layer needn't re-read and parse the sidecar per request.
pub has_chapters: bool,
/// Size of the video file on disk; `None` if the video file is missing. /// Size of the video file on disk; `None` if the video file is missing.
pub file_size: Option<u64>, pub file_size: Option<u64>,
} }
@ -255,11 +258,19 @@ fn collect_raw_videos(entries: impl Iterator<Item = std::fs::DirEntry>) -> Vec<R
fn enrich(raws: Vec<RawVideo>) -> Vec<Video> { fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
let mut videos: Vec<Video> = raws.into_iter().map(|raw| { let mut videos: Vec<Video> = raws.into_iter().map(|raw| {
let duration_secs = raw.info_path.as_ref().and_then(|p| { // Parse info.json once for both duration and chapter presence.
let text = std::fs::read_to_string(p).ok()?; let (duration_secs, has_chapters) = raw.info_path.as_ref()
let val: serde_json::Value = serde_json::from_str(&text).ok()?; .and_then(|p| std::fs::read_to_string(p).ok())
val.get("duration").and_then(|v| v.as_f64()) .and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
}); .map(|val| {
let dur = val.get("duration").and_then(|v| v.as_f64());
let chap = val.get("chapters")
.and_then(|c| c.as_array())
.map(|a| !a.is_empty())
.unwrap_or(false);
(dur, chap)
})
.unwrap_or((None, false));
let file_size = raw.video_path.as_ref() let file_size = raw.video_path.as_ref()
.and_then(|p| std::fs::metadata(p).ok()) .and_then(|p| std::fs::metadata(p).ok())
.map(|m| m.len()); .map(|m| m.len());
@ -274,6 +285,7 @@ fn enrich(raws: Vec<RawVideo>) -> Vec<Video> {
subtitles: raw.subtitles, subtitles: raw.subtitles,
has_live_chat: raw.has_live_chat, has_live_chat: raw.has_live_chat,
duration_secs, duration_secs,
has_chapters,
file_size, file_size,
} }
}).collect(); }).collect();

View file

@ -18,6 +18,7 @@ mod config;
mod database; mod database;
mod downloader; mod downloader;
mod library; mod library;
mod maintenance;
mod theme; mod theme;
mod web; mod web;

234
src/maintenance.rs Normal file
View file

@ -0,0 +1,234 @@
//! Library health scanning and repair.
//!
//! Two kinds of problems are detected over the scanned library:
//!
//! * **Duplicates** — the same YouTube video ID stored more than once (either
//! under different titles in one folder, or across folders). Each duplicate
//! group lists every copy with a "score" so the UI can recommend keeping the
//! most complete one and removing the rest.
//! * **Missing assets** — a downloaded video lacking its thumbnail, `info.json`,
//! or `.description` sidecar. These can be re-fetched from YouTube with yt-dlp
//! (subtitles are fetched alongside, since their absence isn't a reliable
//! signal — many videos legitimately have none).
//!
//! Deletion is never automatic: [`scan`] only reports, and [`remove_files`]
//! refuses any path outside the library root.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::library::{Channel, Video};
/// One stored copy of a video (its on-disk files plus a completeness score).
#[derive(Serialize, Clone)]
pub struct DuplicateCopy {
/// Human-readable directory the copy lives in, relative to the library root.
pub location: String,
/// Every file belonging to this copy (video + all sidecars).
pub files: Vec<PathBuf>,
/// Size of the video file in bytes, if present.
pub file_size: Option<u64>,
/// Whether an actual video file (not just sidecars) is present.
pub has_video: bool,
/// True for the copy the UI recommends keeping (best score in the group).
pub recommended_keep: bool,
}
/// A set of copies that all share one video ID.
#[derive(Serialize, Clone)]
pub struct DuplicateGroup {
pub id: String,
pub title: String,
pub copies: Vec<DuplicateCopy>,
}
/// A video that is missing one or more sidecar assets.
#[derive(Serialize, Clone)]
pub struct MissingAssets {
pub id: String,
pub title: String,
/// Directory containing the video, relative to the library root.
pub location: String,
pub missing_thumbnail: bool,
pub missing_info: bool,
pub missing_description: bool,
}
/// The full result of a [`scan`].
#[derive(Serialize, Clone, Default)]
pub struct HealthReport {
pub duplicates: Vec<DuplicateGroup>,
pub missing: Vec<MissingAssets>,
}
/// The directory a video lives in, inferred from whichever path is known.
fn video_dir(v: &Video) -> Option<PathBuf> {
v.video_path
.as_ref()
.or(v.info_path.as_ref())
.or(v.thumb_path.as_ref())
.or(v.description_path.as_ref())
.or(v.subtitles.first().map(|s| &s.path))
.and_then(|p| p.parent().map(Path::to_path_buf))
}
/// Display a path relative to `root` (falls back to the full path).
fn rel(root: &Path, p: &Path) -> String {
p.strip_prefix(root).unwrap_or(p).display().to_string()
}
/// Every file on disk whose name begins with `<stem>.` in `dir`.
///
/// This captures the video plus all sidecars (thumbnail, info.json, description,
/// subtitles, live_chat.json, …) without listing each suffix explicitly. The
/// trailing dot prevents matching a different video whose stem is a prefix.
fn files_for_stem(dir: &Path, stem: &str) -> Vec<PathBuf> {
let prefix = format!("{stem}.");
let mut out = Vec::new();
if let Ok(entries) = std::fs::read_dir(dir) {
for e in entries.flatten() {
if e.file_name().to_string_lossy().starts_with(&prefix) {
out.push(e.path());
}
}
}
out.sort();
out
}
/// Scan the library for duplicates and missing assets.
pub fn scan(root: &Path, channels: &[Channel]) -> HealthReport {
// Collect every video together with its source channel name.
let mut all: Vec<&Video> = Vec::new();
for ch in channels {
all.extend(ch.videos.iter());
for pl in &ch.playlists {
all.extend(pl.videos.iter());
}
}
// ── Duplicates: group by video ID ──────────────────────────────────────
let mut by_id: BTreeMap<&str, Vec<&Video>> = BTreeMap::new();
for v in &all {
by_id.entry(v.id.as_str()).or_default().push(v);
}
let mut duplicates = Vec::new();
for (id, vids) in &by_id {
if vids.len() < 2 {
continue;
}
// Build a copy per video, then drop phantoms with no locatable files
// (e.g. a stem known only by a leftover live_chat.json).
let mut copies: Vec<DuplicateCopy> = vids
.iter()
.filter_map(|v| {
let dir = video_dir(v)?;
let files = files_for_stem(&dir, &v.stem);
if files.is_empty() {
return None;
}
Some(DuplicateCopy {
location: rel(root, &dir),
files,
file_size: v.file_size,
has_video: v.video_path.is_some(),
recommended_keep: false,
})
})
.collect();
if copies.len() < 2 {
continue; // not a real duplicate once phantoms are removed
}
// Score each copy: a present video file dominates, then file size,
// then the number of sidecar files. The highest score is kept.
let best_idx = copies
.iter()
.enumerate()
.max_by_key(|(_, c)| (c.has_video, c.file_size.unwrap_or(0), c.files.len()))
.map(|(i, _)| i)
.unwrap_or(0);
copies[best_idx].recommended_keep = true;
let title = vids
.iter()
.find(|v| v.video_path.is_some())
.unwrap_or(&vids[0])
.title
.clone();
duplicates.push(DuplicateGroup {
id: id.to_string(),
title,
copies,
});
}
// ── Missing assets: per video, only for ones with an actual video file ──
// Dedup by ID so a duplicate group isn't reported many times here.
let mut seen = std::collections::HashSet::new();
let mut missing = Vec::new();
for v in &all {
if v.video_path.is_none() || !seen.insert(v.id.as_str()) {
continue;
}
let missing_thumbnail = v.thumb_path.is_none();
let missing_info = v.info_path.is_none();
let missing_description = v.description_path.is_none();
if missing_thumbnail || missing_info || missing_description {
missing.push(MissingAssets {
id: v.id.clone(),
title: v.title.clone(),
location: video_dir(v).as_deref().map(|d| rel(root, d)).unwrap_or_default(),
missing_thumbnail,
missing_info,
missing_description,
});
}
}
HealthReport { duplicates, missing }
}
/// True if `target` resolves to a location inside `root`.
fn is_within(root: &Path, target: &Path) -> bool {
match (root.canonicalize(), target.canonicalize()) {
(Ok(r), Ok(t)) => t.starts_with(r),
_ => false,
}
}
/// Delete the given files, refusing any path that escapes `root`.
///
/// Returns the number of files removed and a list of human-readable errors
/// (including refusals for out-of-bounds paths).
pub fn remove_files(root: &Path, paths: &[PathBuf]) -> (usize, Vec<String>) {
let mut removed = 0;
let mut errors = Vec::new();
for p in paths {
if !is_within(root, p) {
errors.push(format!("refused (outside library): {}", p.display()));
continue;
}
match std::fs::remove_file(p) {
Ok(()) => removed += 1,
Err(e) => errors.push(format!("{}: {e}", p.display())),
}
}
(removed, errors)
}
/// Look up a video's directory and filename stem by ID, for repair targeting.
/// Returns `(dir, stem)` of the first matching copy with a known location.
pub fn locate(channels: &[Channel], id: &str) -> Option<(PathBuf, String)> {
for ch in channels {
for v in ch.videos.iter().chain(ch.playlists.iter().flat_map(|p| p.videos.iter())) {
if v.id == id {
if let Some(dir) = video_dir(v) {
return Some((dir, v.stem.clone()));
}
}
}
}
None
}

View file

@ -29,19 +29,23 @@ use std::sync::{Arc, Mutex};
use axum::{ use axum::{
body::{Body, Bytes}, body::{Body, Bytes},
extract::{Path, Query, State}, extract::{Path, Query, Request, State},
http::{header, StatusCode}, http::{header, HeaderMap, StatusCode},
middleware::{self, Next},
response::{IntoResponse, Response}, response::{IntoResponse, Response},
routing::{get, post}, routing::{get, post},
Json, Router, Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier};
use argon2::password_hash::SaltString;
use crate::config::Config; use crate::config::Config;
use crate::database::Database; use crate::database::Database;
use crate::downloader::{detect_url_kind, Downloader, JobState}; use crate::downloader::{detect_url_kind, Downloader, JobState};
use crate::library; use crate::library;
use crate::maintenance;
// ── Shared state ────────────────────────────────────────────────────────────── // ── Shared state ──────────────────────────────────────────────────────────────
@ -66,12 +70,16 @@ pub struct WebState {
pub watched: Mutex<HashSet<String>>, pub watched: Mutex<HashSet<String>>,
/// Last known playback position per video ID in seconds (persisted in SQLite). /// Last known playback position per video ID in seconds (persisted in SQLite).
pub positions: Mutex<HashMap<String, f64>>, pub positions: Mutex<HashMap<String, f64>>,
pub db_path: PathBuf, /// Shared SQLite connection, opened once at startup instead of per request.
pub db: Mutex<Database>,
pub channels_root: PathBuf, pub channels_root: PathBuf,
pub config_path: PathBuf, pub config_path: PathBuf,
pub config: Mutex<Config>, pub config: Mutex<Config>,
/// Whether to transcode MKV→mp4 on the fly for playback (requires ffmpeg). /// Whether to transcode MKV→mp4 on the fly for playback (requires ffmpeg).
pub transcode: AtomicBool, pub transcode: AtomicBool,
/// Active session tokens. Non-empty only when a password is set; a valid
/// `session` cookie matching one of these grants access to the gated UI/API.
pub sessions: Mutex<HashSet<String>>,
} }
impl WebState { impl WebState {
@ -170,6 +178,28 @@ struct SettingsPayload {
/// Clients MUST NOT send this field; the server ignores it on POST. /// Clients MUST NOT send this field; the server ignores it on POST.
#[serde(skip_deserializing, default)] #[serde(skip_deserializing, default)]
source_url: Option<String>, source_url: Option<String>,
/// Current binding address and port, sent by server only.
#[serde(skip_deserializing, default)]
current_bind: Option<String>,
/// List of available bind options, sent by server only.
#[serde(skip_deserializing, default)]
available_binds: Option<Vec<BindOption>>,
/// Selected bind mode (localhost, tailscale, lan, all). Clients can send this on POST to change.
#[serde(skip_deserializing, default)]
bind_mode: Option<String>,
/// Whether a password is required for downloads (sent by server only).
#[serde(skip_deserializing, default)]
download_password_required: bool,
/// New plaintext password to set for downloads. Clients send this on POST; server does not return it.
#[serde(skip_serializing, default)]
new_download_password: Option<String>,
}
#[derive(Serialize, Deserialize, Clone)]
pub struct BindOption {
pub id: String,
pub label: String,
pub address: String,
} }
// Build a `/files/<rel>` URL from an absolute path, percent-encoding each segment. // Build a `/files/<rel>` URL from an absolute path, percent-encoding each segment.
@ -249,6 +279,211 @@ fn find_video_path(library: &[library::Channel], id: &str) -> Option<PathBuf> {
None None
} }
fn detect_tailscale_ip() -> Option<String> {
if std::path::Path::new("/proc/net/if_inet6").exists() || std::path::Path::new("/etc/tailscale").exists() {
if let Ok(output) = std::process::Command::new("hostname")
.arg("-I")
.output() {
let ip_str = String::from_utf8_lossy(&output.stdout);
ip_str
.split_whitespace()
.find(|ip| ip.starts_with("100."))
.map(|s| s.to_string())
} else {
None
}
} else {
None
}
}
fn detect_lan_ip() -> Option<String> {
if let Ok(output) = std::process::Command::new("hostname")
.arg("-I")
.output() {
let ip_str = String::from_utf8_lossy(&output.stdout);
ip_str
.split_whitespace()
.find(|ip| !ip.starts_with("127.") && !ip.starts_with("100."))
.map(|s| s.to_string())
} else {
None
}
}
pub fn get_available_binds(port: u16) -> Vec<BindOption> {
let mut opts = vec![
BindOption {
id: "localhost".to_string(),
label: "Localhost only".to_string(),
address: format!("127.0.0.1:{port}"),
},
];
if let Some(ts_ip) = detect_tailscale_ip() {
opts.push(BindOption {
id: "tailscale".to_string(),
label: format!("Tailscale ({})", ts_ip),
address: format!("{ts_ip}:{port}"),
});
}
if let Some(lan_ip) = detect_lan_ip() {
if lan_ip != "127.0.0.1" {
opts.push(BindOption {
id: "lan".to_string(),
label: format!("LAN ({})", lan_ip),
address: format!("{lan_ip}:{port}"),
});
}
}
opts.push(BindOption {
id: "all".to_string(),
label: "All interfaces (0.0.0.0)".to_string(),
address: format!("0.0.0.0:{port}"),
});
opts
}
pub fn resolve_bind_mode(mode: &str) -> String {
match mode {
"tailscale" => detect_tailscale_ip().unwrap_or_else(|| "127.0.0.1".to_string()),
"lan" => detect_lan_ip().unwrap_or_else(|| "127.0.0.1".to_string()),
"all" => "0.0.0.0".to_string(),
_ => "127.0.0.1".to_string(),
}
}
/// Infer the bind-mode id (`localhost`/`tailscale`/`lan`/`all`) from a stored bind address.
pub fn bind_mode_of(addr: &str) -> &'static str {
match addr {
"127.0.0.1" | "localhost" => "localhost",
"0.0.0.0" => "all",
a if a.starts_with("100.") => "tailscale",
_ => "lan",
}
}
pub fn hash_password(password: &str) -> Option<String> {
use rand::thread_rng;
let salt = SaltString::generate(thread_rng());
let argon2 = Argon2::default();
argon2
.hash_password(password.as_bytes(), &salt)
.ok()
.map(|hash| hash.to_string())
}
fn verify_password(password: &str, hash: &str) -> bool {
if let Ok(parsed_hash) = PasswordHash::new(hash) {
Argon2::default()
.verify_password(password.as_bytes(), &parsed_hash)
.is_ok()
} else {
false
}
}
// ── Session auth ────────────────────────────────────────────────────────────────
/// Generate a 256-bit random session token, hex-encoded.
fn generate_session_token() -> String {
use rand::RngCore;
let mut bytes = [0u8; 32];
rand::thread_rng().fill_bytes(&mut bytes);
bytes.iter().map(|b| format!("{:02x}", b)).collect()
}
/// Extract the `session` cookie value from request headers, if present.
fn session_token_from_headers(headers: &HeaderMap) -> Option<String> {
let cookie = headers.get(header::COOKIE)?.to_str().ok()?;
cookie
.split(';')
.filter_map(|p| p.trim().strip_prefix("session="))
.next()
.map(|s| s.to_string())
}
/// True if the request carries a valid session cookie.
fn is_authed(state: &WebState, headers: &HeaderMap) -> bool {
match session_token_from_headers(headers) {
Some(token) => state.sessions.lock().unwrap().contains(&token),
None => false,
}
}
/// Whether a download/access password is configured.
fn password_required(state: &WebState) -> bool {
state.config.lock().unwrap().web.download_password.is_some()
}
#[derive(Deserialize)]
struct LoginRequest {
password: String,
}
/// `POST /api/login` — verify the password and issue a session cookie.
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 Some(hash) = hash else {
// No password configured; nothing to authenticate against.
return (StatusCode::OK, "no password set").into_response();
};
if !verify_password(&body.password, &hash) {
return (StatusCode::UNAUTHORIZED, "invalid password").into_response();
}
let token = generate_session_token();
state.sessions.lock().unwrap().insert(token.clone());
let cookie = format!("session={token}; HttpOnly; SameSite=Strict; Path=/; Max-Age=2592000");
([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response()
}
/// `POST /api/logout` — invalidate the current session and clear the cookie.
async fn post_logout(
State(state): State<Arc<WebState>>,
headers: HeaderMap,
) -> Response {
if let Some(token) = session_token_from_headers(&headers) {
state.sessions.lock().unwrap().remove(&token);
}
let cookie = "session=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0";
([(header::SET_COOKIE, cookie)], StatusCode::OK).into_response()
}
/// Middleware gating every route behind a session cookie when a password is set.
/// With no password configured, all requests pass through unchanged (preserves
/// the localhost-only default). `/api/login` is always reachable so users can
/// authenticate; unauthenticated `GET /` is served a login page instead of the app.
async fn auth_middleware(
State(state): State<Arc<WebState>>,
req: Request,
next: Next,
) -> Response {
if !password_required(&state) {
return next.run(req).await;
}
let path = req.uri().path();
if path == "/api/login" {
return next.run(req).await;
}
if is_authed(&state, req.headers()) {
return next.run(req).await;
}
if path == "/" {
return (
[(header::CONTENT_TYPE, "text/html; charset=utf-8")],
LOGIN_HTML,
)
.into_response();
}
(StatusCode::UNAUTHORIZED, "authentication required").into_response()
}
// ── Handlers ────────────────────────────────────────────────────────────────── // ── Handlers ──────────────────────────────────────────────────────────────────
async fn get_index() -> impl IntoResponse { async fn get_index() -> impl IntoResponse {
@ -284,12 +519,6 @@ async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
}) })
}) })
.collect(); .collect();
let has_chapters = v.info_path.as_ref()
.and_then(|p| std::fs::read_to_string(p).ok())
.and_then(|t| serde_json::from_str::<serde_json::Value>(&t).ok())
.and_then(|val| val.get("chapters").cloned())
.map(|c| c.as_array().map(|a| !a.is_empty()).unwrap_or(false))
.unwrap_or(false);
let resume_pos = positions.get(&v.id).copied().filter(|&p| p > 3.0); let resume_pos = positions.get(&v.id).copied().filter(|&p| p > 3.0);
VideoInfo { VideoInfo {
id: v.id.clone(), id: v.id.clone(),
@ -302,20 +531,16 @@ async fn get_library(State(state): State<Arc<WebState>>) -> impl IntoResponse {
video_url, video_url,
thumb_url, thumb_url,
subtitles, subtitles,
has_chapters, has_chapters: v.has_chapters,
resume_pos, resume_pos,
} }
}; };
let channels = lib.iter().map(|ch| { let channels = lib.iter().map(|ch| {
let size_bytes: u64 = ch.videos.iter()
.chain(ch.playlists.iter().flat_map(|p| p.videos.iter()))
.filter_map(|v| v.file_size)
.sum();
ChannelInfo { ChannelInfo {
name: ch.name.clone(), name: ch.name.clone(),
total_videos: ch.total_videos(), total_videos: ch.total_videos(),
size_bytes, size_bytes: ch.total_size_cached,
subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count), subscriber_count: ch.meta.as_ref().and_then(|m| m.subscriber_count),
uploader: ch.meta.as_ref().and_then(|m| m.uploader.clone()), uploader: ch.meta.as_ref().and_then(|m| m.uploader.clone()),
channel_url: ch.meta.as_ref().and_then(|m| m.channel_url.clone()), channel_url: ch.meta.as_ref().and_then(|m| m.channel_url.clone()),
@ -340,6 +565,8 @@ async fn post_download(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
Json(body): Json<StartDownloadRequest>, Json(body): Json<StartDownloadRequest>,
) -> impl IntoResponse { ) -> impl IntoResponse {
// Access control is handled centrally by auth_middleware; reaching here
// means the request is authenticated (or no password is configured).
let url = body.url.trim().to_string(); let url = body.url.trim().to_string();
if url.is_empty() { if url.is_empty() {
return (StatusCode::BAD_REQUEST, "empty URL").into_response(); return (StatusCode::BAD_REQUEST, "empty URL").into_response();
@ -353,10 +580,7 @@ async fn post_watched(
State(state): State<Arc<WebState>>, State(state): State<Arc<WebState>>,
Path(video_id): Path<String>, Path(video_id): Path<String>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let db = match Database::open(&state.db_path) { let db = state.db.lock().unwrap();
Ok(d) => d,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let mut watched = state.watched.lock().unwrap(); let mut watched = state.watched.lock().unwrap();
let now_watched = !watched.contains(&video_id); let now_watched = !watched.contains(&video_id);
if let Err(e) = db.set_watched(&video_id, now_watched) { if let Err(e) = db.set_watched(&video_id, now_watched) {
@ -367,10 +591,20 @@ async fn post_watched(
} }
async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse { async fn get_settings(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let source_url = state.config.lock().unwrap().web.source_url.clone(); let cfg = state.config.lock().unwrap();
let source_url = cfg.web.source_url.clone();
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();
Json(SettingsPayload { Json(SettingsPayload {
transcode: state.transcode.load(Ordering::Relaxed), transcode: state.transcode.load(Ordering::Relaxed),
source_url, source_url,
current_bind: Some(format!("{}:{}", current_bind, port)),
available_binds: Some(available_binds),
bind_mode: None,
download_password_required,
new_download_password: None,
}) })
} }
@ -381,12 +615,42 @@ async fn post_settings(
state.transcode.store(body.transcode, Ordering::Relaxed); state.transcode.store(body.transcode, Ordering::Relaxed);
let mut cfg = state.config.lock().unwrap(); let mut cfg = state.config.lock().unwrap();
cfg.web.transcode = body.transcode; cfg.web.transcode = body.transcode;
if let Some(new_mode) = &body.bind_mode {
let new_addr = resolve_bind_mode(new_mode);
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();
} }
let source_url = cfg.web.source_url.clone(); let source_url = cfg.web.source_url.clone();
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); drop(cfg);
Json(SettingsPayload { transcode: body.transcode, source_url }).into_response() Json(SettingsPayload {
transcode: body.transcode,
source_url,
current_bind: Some(format!("{}:{}", current_bind, port)),
available_binds: Some(available_binds),
bind_mode: None,
download_password_required,
new_download_password: None,
}).into_response()
} }
async fn get_transcode( async fn get_transcode(
@ -466,7 +730,7 @@ async fn post_resume(
Path(video_id): Path<String>, Path(video_id): Path<String>,
Json(body): Json<ResumeBody>, Json(body): Json<ResumeBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
if let Ok(db) = Database::open(&state.db_path) { let db = state.db.lock().unwrap();
if body.position > 3.0 { if body.position > 3.0 {
let _ = db.set_position(&video_id, body.position); let _ = db.set_position(&video_id, body.position);
state.positions.lock().unwrap().insert(video_id, body.position); state.positions.lock().unwrap().insert(video_id, body.position);
@ -474,7 +738,6 @@ async fn post_resume(
let _ = db.clear_position(&video_id); let _ = db.clear_position(&video_id);
state.positions.lock().unwrap().remove(&video_id); state.positions.lock().unwrap().remove(&video_id);
} }
}
(StatusCode::OK, "ok") (StatusCode::OK, "ok")
} }
@ -611,15 +874,54 @@ async fn get_description(
async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse { async fn post_rescan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let new_lib = library::scan_channels(&state.channels_root); let new_lib = library::scan_channels(&state.channels_root);
// Refresh watched from DB after rescan // Refresh watched from DB after rescan
if let Ok(db) = Database::open(&state.db_path) { if let Ok(w) = state.db.lock().unwrap().get_watched() {
if let Ok(w) = db.get_watched() {
*state.watched.lock().unwrap() = w; *state.watched.lock().unwrap() = w;
} }
}
*state.library.lock().unwrap() = new_lib; *state.library.lock().unwrap() = new_lib;
(StatusCode::OK, "rescanned") (StatusCode::OK, "rescanned")
} }
/// `GET /api/maintenance/scan` — report duplicate videos and missing assets.
async fn get_maintenance_scan(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let lib = state.library.lock().unwrap();
let report = maintenance::scan(&state.channels_root, &lib);
Json(report)
}
#[derive(Deserialize)]
struct RemoveRequest {
paths: Vec<PathBuf>,
}
/// `POST /api/maintenance/remove` — delete the listed duplicate files.
/// Paths outside the library root are refused.
async fn post_maintenance_remove(
State(state): State<Arc<WebState>>,
Json(body): Json<RemoveRequest>,
) -> impl IntoResponse {
let (removed, errors) = maintenance::remove_files(&state.channels_root, &body.paths);
// Refresh the library so the removed copies disappear from the UI.
let new_lib = library::scan_channels(&state.channels_root);
*state.library.lock().unwrap() = new_lib;
Json(serde_json::json!({ "removed": removed, "errors": errors }))
}
/// `POST /api/maintenance/repair/:id` — re-fetch missing sidecars for one video.
async fn post_maintenance_repair(
State(state): State<Arc<WebState>>,
Path(id): Path<String>,
) -> impl IntoResponse {
let target = {
let lib = state.library.lock().unwrap();
maintenance::locate(&lib, &id)
};
let Some((dir, stem)) = target else {
return (StatusCode::NOT_FOUND, "video not found in library").into_response();
};
state.downloader.lock().unwrap().repair(&id, &dir, &stem);
(StatusCode::ACCEPTED, "repair queued").into_response()
}
// ── Entry point ─────────────────────────────────────────────────────────────── // ── Entry point ───────────────────────────────────────────────────────────────
pub fn run(config: Config) -> ! { pub fn run(config: Config) -> ! {
@ -648,9 +950,10 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
.join("config.toml"); .join("config.toml");
let library = library::scan_channels(&channels_root); let library = library::scan_channels(&channels_root);
let db = Database::open(&db_path); let db = Database::open(&db_path)
let watched = db.as_ref().ok().and_then(|d| d.get_watched().ok()).unwrap_or_default(); .unwrap_or_else(|_| Database::open_in_memory().expect("in-memory db failed"));
let positions = db.as_ref().ok().and_then(|d| d.get_positions().ok()).unwrap_or_default(); let watched = db.get_watched().unwrap_or_default();
let positions = db.get_positions().unwrap_or_default();
let downloader = Downloader::new(channels_root.clone(), config.player.browser.clone()); let downloader = Downloader::new(channels_root.clone(), config.player.browser.clone());
let transcode = AtomicBool::new(config.web.transcode); let transcode = AtomicBool::new(config.web.transcode);
@ -662,11 +965,12 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
downloader: Mutex::new(downloader), downloader: Mutex::new(downloader),
watched: Mutex::new(watched), watched: Mutex::new(watched),
positions: Mutex::new(positions), positions: Mutex::new(positions),
db_path, db: Mutex::new(db),
channels_root: channels_root.clone(), channels_root: channels_root.clone(),
config_path, config_path,
config: Mutex::new(config), config: Mutex::new(config),
transcode, transcode,
sessions: Mutex::new(HashSet::new()),
}); });
let app = Router::new() let app = Router::new()
@ -685,10 +989,15 @@ 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/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/login", post(post_login))
.route("/api/logout", post(post_logout))
.nest_service("/files", ServeDir::new(&channels_root)) .nest_service("/files", ServeDir::new(&channels_root))
.layer(middleware::from_fn_with_state(state.clone(), auth_middleware))
.with_state(state); .with_state(state);
let bind_addr = &config.web.bind;
let listener = match tokio::net::TcpListener::bind(format!("{bind_addr}:{port}")).await { let listener = match tokio::net::TcpListener::bind(format!("{bind_addr}:{port}")).await {
Ok(l) => l, Ok(l) => l,
Err(e) => { Err(e) => {
@ -711,6 +1020,45 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
// ── Embedded UI ─────────────────────────────────────────────────────────────── // ── Embedded UI ───────────────────────────────────────────────────────────────
/// Standalone login page served at `GET /` when a password is set and the
/// request is unauthenticated. Posts to `/api/login` and reloads on success.
const LOGIN_HTML: &str = r#"<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>yt-offline Sign in</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
body{background:#1a1a2e;color:#eee;font:14px/1.5 system-ui,sans-serif;display:flex;align-items:center;justify-content:center;height:100vh}
.box{background:#16213e;border:1px solid #334;border-radius:8px;padding:28px;width:300px;display:flex;flex-direction:column;gap:12px}
h1{font-size:1.1em;text-align:center}
input{background:#0f3460;border:1px solid #334;color:#eee;padding:9px 11px;border-radius:4px;font-size:14px}
button{background:#e94560;border:none;color:#fff;padding:9px;border-radius:4px;cursor:pointer;font-size:14px;font-weight:600}
.err{color:#f87171;font-size:12px;text-align:center;min-height:16px}
</style>
</head>
<body>
<div class="box">
<h1>yt-offline</h1>
<input type="password" id="pwd" placeholder="Password" autofocus onkeydown="if(event.key==='Enter')login()">
<button onclick="login()">Sign in</button>
<div class="err" id="err"></div>
</div>
<script>
'use strict';
async function login(){
const pwd=document.getElementById('pwd').value;
const err=document.getElementById('err');
err.textContent='';
try{
const r=await fetch('/api/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({password:pwd})});
if(r.ok){location.reload()}else{err.textContent='Invalid password'}
}catch{err.textContent='Connection error'}
}
</script>
</body>
</html>"#;
const HTML_UI: &str = r#"<!DOCTYPE html> const HTML_UI: &str = r#"<!DOCTYPE html>
<html lang="en"> <html lang="en">
<head> <head>
@ -722,6 +1070,11 @@ const HTML_UI: &str = r#"<!DOCTYPE html>
.theme-solarized{--bg:#002b36;--panel:#073642;--card:#004052;--accent:#268bd2;--text:#839496;--muted:#586e75;--border:#144} .theme-solarized{--bg:#002b36;--panel:#073642;--card:#004052;--accent:#268bd2;--text:#839496;--muted:#586e75;--border:#144}
.theme-nord{--bg:#2e3440;--panel:#3b4252;--card:#434c5e;--accent:#88c0d0;--text:#eceff4;--muted:#9aa;--border:#4c566a} .theme-nord{--bg:#2e3440;--panel:#3b4252;--card:#434c5e;--accent:#88c0d0;--text:#eceff4;--muted:#9aa;--border:#4c566a}
.theme-amoled{--bg:#000;--panel:#0a0a0a;--card:#111;--accent:#e94560;--text:#eee;--muted:#666;--border:#222} .theme-amoled{--bg:#000;--panel:#0a0a0a;--card:#111;--accent:#e94560;--text:#eee;--muted:#666;--border:#222}
.theme-dracula{--bg:#282a36;--panel:#282a36;--card:#343746;--accent:#bd93f9;--text:#f8f8f2;--muted:#6272a4;--border:#44475a}
.theme-trans{--bg:#e8f7fd;--panel:#fef0f4;--card:#fce8f2;--accent:#55cdfc;--text:#cc0066;--muted:#888;--border:#f7a8b8}
.theme-emo-nocturnal{--bg:#0a0a0a;--panel:#0d0d0d;--card:#1a1a1a;--accent:#ff0090;--text:#e8e8e8;--muted:#888;--border:#2a2a2a}
.theme-emo-coffin{--bg:#0d0009;--panel:#110010;--card:#1a0018;--accent:#cc2222;--text:#c0c0c0;--muted:#666;--border:#3a0030}
.theme-emo-scene-queen{--bg:#080818;--panel:#0a0a1e;--card:#111128;--accent:#39ff14;--text:#c8c8ff;--muted:#666;--border:#222244}
*{box-sizing:border-box;margin:0;padding:0} *{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--text);font:14px/1.5 system-ui,sans-serif;display:flex;flex-direction:column;height:100vh;overflow:hidden} body{background:var(--bg);color:var(--text);font:14px/1.5 system-ui,sans-serif;display:flex;flex-direction:column;height:100vh;overflow:hidden}
header{background:var(--panel);padding:8px 12px;display:flex;gap:8px;align-items:center;border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap} header{background:var(--panel);padding:8px 12px;display:flex;gap:8px;align-items:center;border-bottom:1px solid var(--border);flex-shrink:0;flex-wrap:wrap}
@ -830,6 +1183,7 @@ const HTML_UI: &str = r#"<!DOCTYPE html>
</select> </select>
<span id="hdr-stats"></span> <span id="hdr-stats"></span>
<button onclick="rescan()" title="Rescan library"></button> <button onclick="rescan()" title="Rescan library"></button>
<button onclick="openMaintenance()" title="Library health">🩺</button>
<button onclick="openSettings()"></button> <button onclick="openSettings()"></button>
<span id="status"></span> <span id="status"></span>
</header> </header>
@ -1022,9 +1376,11 @@ async function previewDownload(){
} }
} }
async function confirmDownload(url,btn){ async function confirmDownload(url,btn){
btn.closest('.modal-bg').remove(); if(btn)btn.closest('.modal-bg').remove();
try{await api('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});document.getElementById('dl-url').value='';setStatus('Download queued')} try{
catch(e){setStatus('Error: '+e.message)} await api('/api/download',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({url})});
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 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 downloadChannelByIdx(i){await downloadChannel(channelUrls[i]||'https://www.youtube.com/@'+library[i].name)}
@ -1166,15 +1522,26 @@ async function showMetadata(id){
function switchMetaTab(tab,which){tab.parentElement.querySelectorAll('.meta-tab').forEach(t=>t.classList.remove('active'));tab.classList.add('active');document.getElementById('meta-summary').style.display=which==='summary'?'':'none';document.getElementById('meta-raw').style.display=which==='raw'?'':'none'} function switchMetaTab(tab,which){tab.parentElement.querySelectorAll('.meta-tab').forEach(t=>t.classList.remove('active'));tab.classList.add('active');document.getElementById('meta-summary').style.display=which==='summary'?'':'none';document.getElementById('meta-raw').style.display=which==='raw'?'':'none'}
/* ── Settings ───────────────────────────────────────────────────── */ /* ── Settings ───────────────────────────────────────────────────── */
const THEMES=[['dark','Dark'],['light','Light'],['solarized','Solarized'],['nord','Nord'],['amoled','AMOLED']]; const THEMES=[['dark','Dark'],['light','Light'],['dracula','Dracula'],['trans','Trans'],['emo-nocturnal','Emo: Nocturnal'],['emo-coffin','Emo: Coffin'],['emo-scene-queen','Emo: Scene Queen'],['solarized','Solarized'],['nord','Nord'],['amoled','AMOLED']];
function applyTheme(t){document.body.className=t==='dark'?'':'theme-'+t;localStorage.setItem('theme',t)} function applyTheme(t){document.body.className=t==='dark'?'':'theme-'+t;localStorage.setItem('theme',t)}
async function logout(){try{await fetch('/api/logout',{method:'POST'});location.reload()}catch{}}
async function openSettings(){ async function openSettings(){
let cur={transcode:false,source_url:null};try{cur=await(await api('/api/settings')).json()}catch{} let cur={transcode:false,source_url:null,current_bind:null,available_binds:[],download_password_required:false};try{cur=await(await api('/api/settings')).json()}catch{}
const savedTheme=localStorage.getItem('theme')||'dark'; const savedTheme=localStorage.getItem('theme')||'dark';
const srcRow=cur.source_url const srcRow=cur.source_url
?`<div class="settings-hint" style="margin-top:8px">Source code: <a href="${esc(cur.source_url)}" target="_blank">${esc(cur.source_url)}</a> (AGPL-3.0)</div>` ?`<div class="settings-hint" style="margin-top:8px">Source code: <a href="${esc(cur.source_url)}" target="_blank">${esc(cur.source_url)}</a> (AGPL-3.0)</div>`
:`<div class="settings-hint" style="margin-top:8px;color:var(--muted)">AGPL-3.0 set <code>web.source_url</code> in config.toml to link source code</div>`; :`<div class="settings-hint" style="margin-top:8px;color:var(--muted)">AGPL-3.0 set <code>web.source_url</code> in config.toml to link source code</div>`;
const bindRows=cur.available_binds?.length?`<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Binding</label>
<div style="font-size:12px;color:var(--muted);margin-bottom:4px">Current: <code style="background:var(--bg);padding:2px 4px;border-radius:2px">${esc(cur.current_bind||'unknown')}</code></div>
<select id="cf-bind" style="background:var(--card);color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:4px;width:100%">
${cur.available_binds.map(b=>`<option value="${esc(b.id)}">${esc(b.label)}</option>`).join('')}
</select>
<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>`:'';
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()}; 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"> bg.innerHTML=`<div class="modal" style="max-width:420px">
<div class="modal-hdr"><h2>Settings</h2></div> <div class="modal-hdr"><h2>Settings</h2></div>
@ -1189,8 +1556,18 @@ async function openSettings(){
${THEMES.map(([id,label])=>`<option value="${id}"${savedTheme===id?' selected':''}>${label}</option>`).join('')} ${THEMES.map(([id,label])=>`<option value="${id}"${savedTheme===id?' selected':''}>${label}</option>`).join('')}
</select> </select>
</div> </div>
<div class="settings-row">
<label for="cf-download-pwd">Require password to access UI</label>
<input type="checkbox" id="cf-download-pwd" ${cur.download_password_required?'checked':''} onchange="document.getElementById('cf-pwd-input').style.display=this.checked?'flex':'none'">
</div>
<div id="cf-pwd-input" style="display:${cur.download_password_required?'flex':'none'};flex-direction:column;gap:4px;margin-bottom:10px">
<input type="password" id="cf-download-password" placeholder="New password (leave empty to disable)" style="background:var(--bg);color:var(--text);border:1px solid var(--border);padding:6px 8px;border-radius:4px">
<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}
${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">
${logoutBtn}
<button onclick="this.closest('.modal-bg').remove()">Cancel</button> <button onclick="this.closest('.modal-bg').remove()">Cancel</button>
<button class="primary" onclick="saveSettings(this)">Save</button> <button class="primary" onclick="saveSettings(this)">Save</button>
</div> </div>
@ -1200,12 +1577,105 @@ async function openSettings(){
async function saveSettings(btn){ async function saveSettings(btn){
const transcode=document.getElementById('cf-transcode').checked; const transcode=document.getElementById('cf-transcode').checked;
const bindMode=document.getElementById('cf-bind')?.value;
const pwdCheckbox=document.getElementById('cf-download-pwd');
const pwdInput=document.getElementById('cf-download-password');
const payload={transcode};
if(bindMode)payload.bind_mode=bindMode;
if(pwdCheckbox&&pwdCheckbox.checked){
payload.new_download_password=pwdInput?.value||'';
}
const settingPwd=pwdCheckbox&&pwdCheckbox.checked&&pwdInput?.value;
try{ try{
await api('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({transcode})}); await api('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
setStatus('Saved.');btn.closest('.modal-bg').remove();await loadLibrary(); btn.closest('.modal-bg').remove();
if(settingPwd){setStatus('Password set signing in again');location.reload();return}
let msg='Saved.';
if(bindMode)msg='Settings saved. Restart required for binding change.';
if(pwdCheckbox&&pwdCheckbox.checked&&!pwdInput?.value)msg='Password disabled. '+msg;
setStatus(msg);await loadLibrary();
}catch(e){setStatus('Error: '+e.message)} }catch(e){setStatus('Error: '+e.message)}
} }
/* ── Maintenance (library health) ───────────────────────────────── */
async function openMaintenance(){
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:760px;width:100%">
<div class="modal-hdr"><h2>Library health</h2><button onclick="this.closest('.modal-bg').remove()"></button></div>
<div id="maint-body" style="overflow:auto;max-height:75vh"><em style="color:var(--muted)">Scanning</em></div>
</div>`;
document.body.appendChild(bg);
try{
const r=await(await api('/api/maintenance/scan')).json();
renderMaintenance(r);
}catch(e){document.getElementById('maint-body').innerHTML=`<div style="color:#f87171">Scan failed: ${esc(e.message)}</div>`}
}
function renderMaintenance(r){
const body=document.getElementById('maint-body');if(!body)return;
const dups=r.duplicates||[],miss=r.missing||[];
let h='';
h+=`<h3 style="font-size:13px;margin:4px 0 8px">Duplicates (${dups.length})</h3>`;
if(!dups.length){h+='<div style="color:var(--muted);font-size:12px;margin-bottom:12px">No duplicate video IDs found.</div>'}
else{
for(const g of dups){
h+=`<div style="border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:8px">
<div style="font-weight:600;font-size:12px;margin-bottom:6px">${esc(g.title)} <span style="color:var(--muted)">[${esc(g.id)}]</span></div>`;
g.copies.forEach((c,i)=>{
const tag=c.recommended_keep?'<span style="color:#4ade80">keep</span>':'<span style="color:#f87171">remove</span>';
const size=c.file_size?fmtSize(c.file_size):'no video';
h+=`<label style="display:flex;align-items:center;gap:8px;font-size:12px;padding:3px 0">
<input type="checkbox" class="dup-chk" data-files='${esc(JSON.stringify(c.files))}' ${c.recommended_keep?'':'checked'}>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(c.location||'(unknown)')} · ${size} · ${c.files.length} files</span>
${tag}
</label>`;
});
h+=`</div>`;
}
h+=`<button class="primary" onclick="removeDuplicates(this)">🗑 Remove checked copies</button>`;
}
h+=`<h3 style="font-size:13px;margin:16px 0 8px">Missing assets (${miss.length})</h3>`;
if(!miss.length){h+='<div style="color:var(--muted);font-size:12px">Every video has its thumbnail, metadata, and description.</div>'}
else{
if(miss.length>1)h+=`<button onclick="repairAll(this)" style="margin-bottom:8px"> Fetch all missing (${miss.length})</button>`;
for(const m of miss){
const need=[m.missing_thumbnail?'thumbnail':null,m.missing_info?'metadata':null,m.missing_description?'description':null].filter(Boolean).join(', ');
h+=`<div style="display:flex;align-items:center;gap:8px;font-size:12px;padding:4px 0;border-bottom:1px solid var(--border)">
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(m.title)} <span style="color:var(--muted)"> missing ${need}</span></span>
<button onclick="repairVideo('${esc(m.id)}',this)"> Fetch</button>
</div>`;
}
}
body.innerHTML=h;
}
async function removeDuplicates(btn){
const chks=[...document.querySelectorAll('.dup-chk:checked')];
let paths=[];
for(const c of chks){try{paths=paths.concat(JSON.parse(c.dataset.files))}catch{}}
if(!paths.length){setStatus('Nothing selected.');return}
if(!confirm(`Delete ${paths.length} file(s)? This cannot be undone.`))return;
btn.disabled=true;
try{
const r=await(await api('/api/maintenance/remove',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({paths})})).json();
setStatus(`Removed ${r.removed} file(s)`+(r.errors&&r.errors.length?`, ${r.errors.length} error(s)`:''));
await loadLibrary();
const fresh=await(await api('/api/maintenance/scan')).json();renderMaintenance(fresh);
}catch(e){setStatus('Error: '+e.message)}finally{btn.disabled=false}
}
async function repairVideo(id,btn){
if(btn){btn.disabled=true;btn.textContent=' Queued'}
try{await api('/api/maintenance/repair/'+encodeURIComponent(id),{method:'POST'});setStatus('Repair queued see Downloads')}
catch(e){setStatus('Error: '+e.message);if(btn){btn.disabled=false;btn.textContent=' Fetch'}}
}
async function repairAll(btn){
btn.disabled=true;
const buttons=[...document.querySelectorAll('#maint-body button')].filter(b=>b.textContent.includes('Fetch')&&b!==btn);
for(const b of buttons){const id=b.getAttribute('onclick')?.match(/repairVideo\('([^']+)'/)?.[1];if(id)await repairVideo(id,b)}
setStatus('All repairs queued see Downloads');
}
/* ── Jobs ───────────────────────────────────────────────────────── */ /* ── Jobs ───────────────────────────────────────────────────────── */
function renderJobs(jobs){ function renderJobs(jobs){
const el=document.getElementById('jobs'); const el=document.getElementById('jobs');