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:
parent
74b3efd990
commit
1ef3fe56c6
10 changed files with 1126 additions and 89 deletions
47
Cargo.lock
generated
47
Cargo.lock
generated
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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`.
|
||||||
|
|
|
||||||
226
src/app.rs
226
src/app.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,41 +154,82 @@ impl Downloader {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
let url_for_thread = url.clone();
|
let mut cmd = Command::new("yt-dlp");
|
||||||
thread::spawn(move || {
|
cmd.arg("--newline")
|
||||||
let mut cmd = Command::new("yt-dlp");
|
.arg("--no-color")
|
||||||
cmd.arg("--newline")
|
.arg("--cookies")
|
||||||
.arg("--no-color")
|
.arg("cookies.txt")
|
||||||
.arg("--cookies")
|
.arg("--write-subs")
|
||||||
.arg("cookies.txt")
|
.arg("--write-auto-subs")
|
||||||
.arg("--write-subs")
|
.arg("--write-thumbnail")
|
||||||
.arg("--write-auto-subs")
|
.arg("--write-description")
|
||||||
.arg("--write-thumbnail")
|
.arg("--write-info-json")
|
||||||
.arg("--write-description")
|
.arg("--remux-video")
|
||||||
.arg("--write-info-json")
|
.arg("mkv")
|
||||||
.arg("--remux-video")
|
.arg("--embed-metadata")
|
||||||
.arg("mkv")
|
.arg("--embed-info-json")
|
||||||
.arg("--embed-metadata")
|
.arg("--embed-chapters")
|
||||||
.arg("--embed-info-json")
|
.arg("--xattrs")
|
||||||
.arg("--embed-chapters")
|
.arg("--sponsorblock-mark")
|
||||||
.arg("--xattrs")
|
.arg("all")
|
||||||
.arg("--sponsorblock-mark")
|
.arg("--extractor-args")
|
||||||
.arg("all")
|
.arg("youtube:player_client=web")
|
||||||
.arg("--extractor-args")
|
.arg("--progress")
|
||||||
.arg("youtube:player_client=web")
|
.arg("--break-on-existing")
|
||||||
.arg("--progress")
|
.arg("--download-archive")
|
||||||
.arg("--break-on-existing")
|
.arg(archive_path.display().to_string())
|
||||||
.arg("--download-archive")
|
.arg("--impersonate")
|
||||||
.arg(archive_path.display().to_string())
|
.arg("Chrome-146:Macos-26")
|
||||||
.arg("--impersonate")
|
.arg("-o")
|
||||||
.arg("Chrome-146:Macos-26")
|
.arg(&out_arg)
|
||||||
.arg("-o")
|
.arg(&url);
|
||||||
.arg(&out_arg)
|
|
||||||
.arg(&url_for_thread)
|
|
||||||
.stdin(Stdio::null())
|
|
||||||
.stdout(Stdio::piped())
|
|
||||||
.stderr(Stdio::piped());
|
|
||||||
|
|
||||||
|
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())
|
||||||
|
.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) => {
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
234
src/maintenance.rs
Normal 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
|
||||||
|
}
|
||||||
560
src/web.rs
560
src/web.rs
|
|
@ -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,14 +730,13 @@ 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);
|
||||||
} else {
|
} else {
|
||||||
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');
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue