Add statistics view, editable source_url, rewrite security audit

Statistics
- New `stats` module computes totals, top-N channels, per-year upload
  histogram, and per-week download activity from the in-memory library
  + watched/positions data.
- GET /api/stats endpoint returns the full report as JSON.
- Web UI: 📊 button in the header opens a stats modal with summary
  tiles, two CSS bar charts (weeks + years), and side-by-side top-N
  tables.
- Desktop GUI: 📊 Stats toggle in the top bar opens a window with
  the same data, rendered via egui rects (stdlib calendar math so
  there's no chrono dep).
- Bar chart helper `draw_bars` is reusable across stats sections.

Source URL (AGPL §13)
- `web.source_url` is now editable in both Settings UIs — was
  previously documented-only and required hand-editing config.toml.
- Settings POST persists it and the footer link updates immediately.
- Default config.toml ships with the Codeberg URL set.

SECURITY_AUDIT.md
- Re-audited the codebase as of commit 5999673; rewrote the file to
  document the threat model and which adversary each defense addresses.
- Resolved-from-2026-05-17 column shows what landed: session expiry,
  Secure cookie, rate-limit, body cap, CSP, X-Frame-Options,
  X-Content-Type-Options, safeUrl(), SHA-256 verify, chmod 0600 db.
- Documented accepted risks (plain-HTTP LAN, cookies.txt umask,
  job log stderr verbatim).
- Future-hardening checklist preserved for posterity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Luna 2026-05-23 05:34:29 -07:00
parent 59996735f3
commit 4d83999edd
5 changed files with 743 additions and 193 deletions

View file

@ -1,213 +1,221 @@
# Security Audit — yt-offline # Security Audit — yt-offline
**Date:** 2026-05-17 **Date:** 2026-05-23 (re-audit after security hardening commit `5999673`)
**Scope:** Rust codebase + dependencies **Earlier audit:** 2026-05-17 (commit pre-dating session auth, CSP, SHA-256 verify, etc.)
**Threat Model:** Self-hosted personal YouTube archiving tool; single user accessing via localhost + LAN **Scope:** Rust codebase + embedded web UI + dependencies
**Threat model:** Self-hosted personal archiving tool. Primary deployment is
single-user localhost; secondary is LAN / Tailscale access (potentially
multiple devices owned by the same user); tertiary is public HTTP via
reverse proxy with TLS.
--- ---
## Summary ## Summary
✅ **Overall Risk: LOW** **Overall risk: LOW** for localhost and Tailscale deployments.
⚠️ **MEDIUM** for plain-HTTP LAN exposure (mitigated by SameSite=Strict cookie
+ CSP; defense-in-depth, no transport encryption).
No critical vulnerabilities found. The codebase follows security best practices for command execution, database access, and file serving. One informational warning on a transitive dependency (low risk). The hardening commit landed the recommendations of the prior audit plus a
broader set found during code review (see commit `5999673`). All findings
below are now either resolved or accepted-risk-with-documentation.
---
## Threat model
| Adversary | Capability | In scope? |
|---|---|---|
| Local user on same machine | Reads files owned by `luna` | Yes — DB chmod, cookies.txt |
| LAN attacker on same subnet | Sees HTTP traffic, can connect to bound ports | Yes — bind policy, auth, CSP |
| Remote attacker via Tailscale tailnet | Owned by same user, but still adversarial in principle | Yes |
| Public internet attacker | Reaches server through misconfigured reverse proxy | Out of scope (not the intended deployment), but defenses still apply |
| Browser-side XSS via injected video metadata | Compromised channel name / description from yt-dlp | Yes — primary XSS surface |
| Supply-chain attack on bundled binaries | Malicious yt-dlp / deno served from GitHub | Yes — SHA-256 verify |
The tool **does not** defend against an attacker with shell access to the
machine, an attacker who can write to the channels directory, or compromise
of upstream YouTube cookies the user has chosen to import.
--- ---
## Findings ## Findings
### ✅ Command Injection — SECURE ### ✅ Command injection — SECURE
All external process invocations use `std::process::Command` or `tokio::process::Command` with `.arg()` calls, bypassing shell interpretation: All external process invocations use `std::process::Command` /
`tokio::process::Command` with separate `.arg()` calls; no shell
interpretation. Sites audited:
- **yt-dlp invocations** (`src/downloader.rs:160188`): User URL passed as a clean argument, not shell-interpreted. Safe even with special characters. - yt-dlp downloads: `src/downloader.rs:start`, `start_music`, `repair`
- **ffmpeg invocations** (`src/web.rs:404415`): Similarly safe, using separate `.arg()` calls. - yt-dlp preview: `src/web.rs:get_preview` (now goes through `apply_cookie_flags` and respects bundled-binary setting)
- **Preview handler** (`src/web.rs:492498`): yt-dlp preview command also uses `.arg()`, not vulnerable to command injection. - yt-dlp bundled install: `src/ytdlp_bin.rs:install_command` runs `bash -c` with a static script whose only interpolated values are paths under `~/.local/share/yt-offline/` and constant GitHub URLs — no user input flows in.
- ffmpeg transcoder: `src/web.rs:get_transcode` (kill_on_drop set; child terminates when stream is dropped).
**No shell escaping needed; no `format!()` in command strings.** Path interpolation in the install script uses single quotes around
`{dir_str}`. The path is derived from `$HOME` via `std::env::var_os`. A
malicious `$HOME` containing a single quote would break the script but
isn't a real attack vector (an attacker who controls `$HOME` already
controls the process).
### ✅ SQL injection — SECURE
All queries in `src/database.rs` use parameterized statements (`?1` /
`rusqlite::params!`). No string concatenation in SQL. Re-verified after
the `settings` table was added.
### ✅ Path traversal — SECURE
- `/files/*` and `/music-files/*`: served by `tower-http`'s `ServeDir`, which rejects `..` and refuses to follow symlinks out of the served root.
- `/api/sub-vtt/*path`: canonicalises both root and target and rejects mismatched prefixes (`src/web.rs:get_sub_vtt`).
- `/api/transcode/:id`, `/api/chapters/:id`, `/api/metadata/:id`, `/api/description/:id`: all resolve through `library::find_video`, which only returns files indexed by the scanner — direct path injection is impossible.
- `maintenance::remove_files`: now canonicalises the parent + filename so missing-file cases produce accurate verdicts, and refuses any target outside the channels root (`src/maintenance.rs:is_within`).
### ✅ XSS in embedded web UI — DEFENDED
- All user-influenced strings (channel names, video titles, descriptions, uploader names, IDs) are escaped via `esc()` before HTML interpolation.
- URLs in `src=` attributes go through `safeUrl()`, which strips `javascript:`, `vbscript:`, `data:text:`, and `data:application:` schemes (`src/web.rs`).
- `Content-Security-Policy` middleware caps blast radius if a single `esc()` site is ever missed: `default-src 'self'; script-src 'self' 'unsafe-inline'; ...; object-src 'none'; frame-ancestors 'none'`.
- `X-Content-Type-Options: nosniff`, `X-Frame-Options: DENY`, `Referrer-Policy: no-referrer` set globally.
**Known limitation:** the embedded UI uses inline `<script>`, so `'unsafe-inline'` is required in `script-src`. This means a successful XSS injection could execute arbitrary inline JS, but injection itself is blocked at the input-handling layer and CSP still prevents remote script loading or framing.
### ✅ Authentication & sessions — HARDENED
- Argon2-hashed password in the `settings` table (never in `config.toml`).
- 256-bit `rand::thread_rng()` (CSPRNG) session tokens, hex-encoded.
- `Mutex<HashMap<String, Instant>>` of issued tokens, lazily pruned past 30-day TTL — no unbounded growth.
- `Set-Cookie` includes `HttpOnly`, `SameSite=Strict`, `Path=/`, `Max-Age=2592000`. `Secure` is added when `X-Forwarded-Proto: https` is present (so reverse-proxied deployments get it; plain-HTTP LAN does not, which is correct since browsers would otherwise refuse to send the cookie).
- All routes layered behind `auth_middleware` when a password is configured. `/api/login` and unauthenticated `GET /` (serves login page) are the only exceptions.
- `password_required` is cached in an `AtomicBool` so the auth middleware doesn't hit SQLite on every static-file fetch.
### ✅ Login brute-force resistance — RATE LIMITED
- Argon2 verification (~100 ms) imposes a per-attempt cost.
- Plus per-IP rate limiter: 5 failures within the lockout window → 60 s lockout returning HTTP 429 (`src/web.rs:post_login`).
- Successful login resets the counter for that IP.
- Lockout entries garbage-collected on each request.
### ✅ Bundled-binary supply chain — VERIFIED
- `yt-dlp_linux` (or `_macos` / `.exe`) is fetched over HTTPS from the official GitHub release.
- The matching `SHA2-256SUMS` file is fetched from the same release and the binary's SHA-256 is compared. Mismatch aborts the install and deletes the partial binary.
- `deno` is fetched over HTTPS; its SHA-256 is printed in the install log for visual inspection (deno doesn't ship a single SHA256SUMS-equivalent manifest in a stable location). A future improvement would fetch `.sha256sum` per-asset where available.
- Installer sanity-checks that `curl`, `unzip`, and `sha256sum` are present before starting.
- Every bundled-mode launch re-applies `+x` defensively via `ytdlp_bin::ensure_bundled_executable` so a stripped exec bit can't permanently break downloads.
### ✅ Unsafe code — NONE
`grep -rn "unsafe" src/` returns zero results.
### ✅ Body size limits — ENFORCED
A 4 MiB `DefaultBodyLimit` layer is attached to every route. Legitimate
payloads (cookies.txt, JSON settings) are all well under 64 KiB; anything
above 4 MiB is rejected before allocation.
### ⚠️ Plaintext HTTP on LAN — ACCEPTED RISK
When bound to `0.0.0.0` or a LAN IP, the cookie and traffic travel
unencrypted over the network. Defenses:
- `SameSite=Strict` prevents cross-site form submissions from triggering authenticated requests.
- CSP `frame-ancestors 'none'` prevents click-jacking.
- The session cookie is not marked `Secure` because the browser would refuse to send it back on HTTP, breaking login entirely. This is a deliberate trade-off and is documented.
**Mitigation if you need stronger guarantees:** put a reverse proxy with TLS in front (Caddy, nginx, Traefik) and set the `X-Forwarded-Proto` header. The app will then issue cookies with the `Secure` attribute automatically.
### ⚠️ `cookies.txt` and `yt-offline.db` file permissions
- `yt-offline.db`: now `chmod 0600` at open time on Unix (`src/database.rs`). Contains the Argon2 password hash and resume positions. Other local users on the same machine cannot read it.
- `cookies.txt`: not auto-chmodded because the user pastes / file-picks it in, and our `write_cookies()` uses `std::fs::write` which respects the user's umask. **Recommendation in README:** `chmod 600 cookies.txt` after import. (Tracking issue for future automation.)
### ⚠️ Job log echoes yt-dlp stderr verbatim
yt-dlp's stderr is captured into `Job::log` (`src/downloader.rs:spawn_job`).
For routine failures this is fine, but auth-related errors can mention
cookie file paths or impersonation profile names. In a strictly local
deployment this is acceptable; if the UI is shared with someone else,
review logs before sharing.
### ⚠️ TOCTOU on `get_sub_vtt`
After the path-traversal check passes, the file is opened. A symlink swap
between check and read would let a local attacker substitute a different
file — but the attacker would already need write access to the channels
root, at which point they own the data anyway. Accepted.
### ⚠️ Dependency notes
- `paste` v1.0.15 (transitive via egui) is unmaintained. Procedural macro,
no runtime exposure. Same finding as 2026-05-17 audit; nothing changed
upstream. Re-check periodically.
- `cargo audit` was not re-run for this audit; recommended before
publishing release artifacts.
--- ---
### ✅ SQL Injection — SECURE ## Changes since the 2026-05-17 audit
All database queries in `src/database.rs` use **parameterized statements**: | Item | Status |
|---|---|
| Bind defaults to `127.0.0.1` | ✅ already in 2026-05-17 |
| `Content-Security-Policy` header | ✅ added (`security_headers` middleware) |
| Optional HTTP password auth | ✅ added (Argon2 + sessions + rate limit) |
| Filter yt-dlp stderr | ⚠️ still echoed (accepted) |
| chmod 600 on cookies.txt | ⚠️ documented; not automated |
| chmod 600 on yt-offline.db | ✅ added (`Database::open`) |
| Session token TTL + GC | ✅ added |
| `Secure` cookie flag for HTTPS | ✅ added (gated on `X-Forwarded-Proto`) |
| Login rate limiting | ✅ added (5 / 60 s per IP) |
| Body size cap | ✅ added (4 MiB) |
| Bundled binary verification | ✅ added (SHA-256 + sanity checks) |
| `safeUrl()` for `src=` attributes | ✅ added |
| Path traversal in maintenance::remove_files | ✅ hardened (parent-canonicalize fallback) |
```rust ---
// Correct — parameter substitution
self.conn.execute(
"INSERT OR REPLACE INTO watched (video_id) VALUES (?1)",
[video_id],
)?;
// Correct — rusqlite::params! macro ## Recommendations for future hardening
self.conn.execute(
"INSERT OR REPLACE INTO positions (video_id, position_secs) VALUES (?1, ?2)", These are nice-to-haves; none are blocking the current deployment.
rusqlite::params![video_id, position_secs],
)?; 1. **Auto-`chmod 600` cookies.txt on write.** Reduces user error.
2. **Filter `--cookies` paths from job log.** Trivial regex strip before
buffering into `Job::log`.
3. **Optional `data:` URL scheme allow-list for `<img>` only.** Useful if
we ever start showing yt-dlp-supplied thumbnails as inline data URLs.
4. **`cargo audit` in CI.** Once cross-compile CI exists.
5. **Periodic deno SHA-256 pinning.** Currently we print the hash; a
future enhancement could check it against a small in-repo manifest
updated by a maintainer.
6. **Account lockout escalation.** Current scheme resets fully after 60 s.
Consider exponential back-off (60 s → 5 min → 15 min) for repeated
abuse from the same IP across longer windows.
7. **TLS terminator in-process.** Optional `rustls` integration so the
app can serve HTTPS without a reverse proxy. Currently considered
out of scope.
---
## Verification commands
```bash
# Build + tests pass
cargo build --release
cargo test
# No unsafe blocks
grep -rn 'unsafe' src/
# Dependencies up to date
cargo update --dry-run
cargo audit # requires the cargo-audit binary
``` ```
**No string concatenation in SQL strings.** Video IDs come from path parameters and are bound safely.
---
### ✅ Path Traversal — SECURE
**File serving** (`src/web.rs:687`):
- Uses `tower_http::services::ServeDir::new(&channels_root)` — a well-audited crate that prevents `..` traversal.
**Info.json access** (`src/web.rs:230236`):
- Videos are looked up **only** from the scanned library (`find_video_info_path`).
- Cannot directly request arbitrary files by path; must exist in the library.
**URL path encoding** (`src/web.rs:176186`):
- Correct percent-encoding of URL segments.
- Path components are filtered to `Component::Normal` (rejects `..` and symlinks).
---
### ✅ Unsafe Code — NONE FOUND
**Result of search:** `grep -r "unsafe"` across all `.rs` files returns **zero unsafe blocks**.
All memory safety is guaranteed by Rust's type system. No C FFI or raw pointers.
---
### ⚠️ Dependency Warning (LOW RISK)
**Crate:** `paste` v1.0.15
**Status:** Unmaintained since 2024-10-07
**Severity:** Warning (not an error)
**Dependency Chain:**
```
paste 1.0.15
← egui-wgpu (GUI rendering)
← eframe (GUI framework)
← yt-offline (your app)
```
**Impact:**
- `paste` is a procedural macro for code generation; it does not process untrusted input.
- The crate is used only at compile-time for the GUI stack.
- No security-critical functionality in `paste`.
- **Mitigation:** Monitor for a replacement in the egui/eframe ecosystem. No action needed now.
---
## Input Validation
### Download URLs
- **Handler:** `post_download()` (`src/web.rs:339350`)
- **Validation:** Trimmed and checked for empty; passed directly to yt-dlp via `.arg()`.
- **Risk:** Low — yt-dlp validates URLs server-side and fails gracefully on malformed input.
### Video IDs
- **Source:** Extracted from URL paths and library scans.
- **Usage:** Passed to SQL via parameterized queries and used to look up files in the library.
- **Risk:** Low — parameterized queries eliminate injection; library lookup prevents arbitrary file access.
### Position values
- **Handler:** `post_resume()` (`src/web.rs:464479`)
- **Validation:** Numeric (f64) — deserialized by serde, bounds-checked (position > 3.0 filter).
- **Risk:** Low — stored in SQLite as REAL, no string manipulation.
### Preview query (yt-dlp URL)
- **Handler:** `get_preview()` (`src/web.rs:484527`)
- **Validation:** Trimmed and checked for empty.
- **Risk:** Low — passed to yt-dlp via `.arg()`, no shell injection.
---
## Data Exposure
### Watched status & resume positions
- **Storage:** SQLite database (`yt-offline.db`), local filesystem.
- **Access:** Only available to the user running the app (same UID).
- **No network exposure:** Positions are stored server-side; browser caches position locally.
- **Risk:** Low — local-only; no credentials or PII stored.
### Download logs
- **Stored in:** Job objects, kept in RAM and shown in the UI.
- **Leakage risk:** stderr from yt-dlp is visible in the web UI; sensitive auth errors could be logged.
- **Mitigation:** Consider filtering `--cookies` error messages in production. Currently acceptable for personal use.
### yt-dlp output (preview, metadata)
- **Handler:** Parses JSON from yt-dlp; returns filtered fields to the browser.
- **Risk:** Low — only public YouTube metadata (title, duration, view count).
---
## Configuration & Secrets
### `cookies.txt`
- **Location:** Current working directory (hardcoded in `downloader.rs`).
- **Risk:** File is world-readable by default. **Recommendation:** Ensure restrictive file permissions (`chmod 600 cookies.txt`).
### `config.toml`
- **Contains:** Directory paths, port number, browser name, `source_url`.
- **Risk:** Low — no secrets stored. `source_url` is read-only from browser.
- **Sensitive data:** Only the `backup.directory` path could leak file structure; this is expected behavior.
---
## Network Security
### Web server scope
- **Binding:** Configured via `web.port` in `config.toml` (default 8080).
- **Current binding:** Likely `127.0.0.1:8080` (assumed; verify with netstat).
- **Risk:** If bound to `0.0.0.0`, anyone on the network can access. **Recommendation:** Bind to `127.0.0.1` or use a reverse proxy with authentication.
### No HTTPS
- **Status:** Web UI is served over HTTP.
- **Risk:** Medium if exposed to untrusted networks. **Recommendation:** Use a reverse proxy (nginx, Caddy) with TLS for remote access.
### No authentication
- **Status:** No login required; access is "security by obscurity" (port number).
- **Risk:** Medium if the port is discovered. **Recommendation:** Add optional HTTP Basic Auth or reverse proxy authentication for LAN access.
---
## Recommendations (Priority Order)
### HIGH
1. ✅ **Web server binding** — **FIXED**
- Added `web.bind` config option (default `127.0.0.1`).
- 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
2. **File permissions on `cookies.txt`** — Remind users to `chmod 600 cookies.txt`.
- Add note to README.
3. **TLS + authentication for remote access** — If exposing to LAN/internet:
- Use a reverse proxy (nginx, Caddy) with mTLS or Basic Auth.
- Or add built-in HTTP Basic Auth via an optional config field.
4. **Filter sensitive logs** — Consider not echoing yt-dlp stderr if it contains auth errors.
- Current: Acceptable for personal use. Not a blocker.
### LOW
5. **Update `paste` crate dependency chain** — Monitor egui/eframe for a replacement.
- Not urgent; no security impact in your use case.
6. **Add Content-Security-Policy headers** — Prevent XSS if web UI is exposed to untrusted networks.
- Current: Low risk (browser only, no JavaScript vulnerabilities found).
---
## Conclusion
The codebase demonstrates **strong security practices** for a personal self-hosted tool:
- ✅ No injection vulnerabilities (command, SQL).
- ✅ No unsafe code.
- ✅ Path traversal protection in place.
- ✅ Parameterized database queries.
- ✅ Safe process spawning.
**No blockers for personal use.** For public/LAN exposure, implement the HIGH-priority recommendations.
--- ---
**Auditor:** Claude Code **Auditor:** Claude Code
**Audit Method:** Manual code review + cargo audit **Audit method:** Manual code review of every `src/*.rs` plus the embedded
**Status:** Complete HTML/JS UI; targeted greps for command/SQL/path patterns; verification of
hardening claims against current source.
**Status:** Complete.

View file

@ -88,6 +88,7 @@ pub struct App {
music_root: PathBuf, music_root: PathBuf,
settings_dir: String, settings_dir: String,
settings_plex_path: String, settings_plex_path: String,
settings_source_url: String,
plex_status: String, plex_status: String,
db: Database, db: Database,
card_density: f32, card_density: f32,
@ -121,6 +122,9 @@ pub struct App {
// Maintenance (library health) window // Maintenance (library health) window
show_maintenance: bool, show_maintenance: bool,
health_report: Option<crate::maintenance::HealthReport>, health_report: Option<crate::maintenance::HealthReport>,
// Statistics window
show_stats: bool,
stats_report: Option<crate::stats::StatsReport>,
} }
impl App { impl App {
@ -166,6 +170,7 @@ impl App {
.as_deref() .as_deref()
.map(|p| p.display().to_string()) .map(|p| p.display().to_string())
.unwrap_or_default(); .unwrap_or_default();
let source_url_str = config.web.source_url.clone().unwrap_or_default();
let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::<PathBuf>(); let (cookies_pick_tx, cookies_pick_rx) = std::sync::mpsc::channel::<PathBuf>();
@ -208,6 +213,7 @@ impl App {
music_root, music_root,
settings_dir, settings_dir,
settings_plex_path: plex_path_str, settings_plex_path: plex_path_str,
settings_source_url: source_url_str,
plex_status: String::new(), plex_status: String::new(),
db, db,
card_density: 1.0, card_density: 1.0,
@ -234,6 +240,8 @@ impl App {
cookies_pick_rx, cookies_pick_rx,
show_maintenance: false, show_maintenance: false,
health_report: None, health_report: None,
show_stats: false,
stats_report: None,
} }
} }
@ -613,6 +621,17 @@ 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_stats, "📊 Stats").clicked() {
self.show_stats = !self.show_stats;
if self.show_stats {
self.stats_report = Some(crate::stats::build(
&self.library,
&self.watched,
&self.resume_positions,
crate::stats::now_unix(),
));
}
}
if ui.selectable_label(self.show_maintenance, "🩺 Maintenance").clicked() { if ui.selectable_label(self.show_maintenance, "🩺 Maintenance").clicked() {
self.show_maintenance = !self.show_maintenance; self.show_maintenance = !self.show_maintenance;
if self.show_maintenance { if self.show_maintenance {
@ -626,6 +645,7 @@ impl App {
self.settings_dir = self.channels_root.display().to_string(); self.settings_dir = self.channels_root.display().to_string();
self.settings_plex_path = self.config.plex.library_path self.settings_plex_path = self.config.plex.library_path
.as_deref().map(|p| p.display().to_string()).unwrap_or_default(); .as_deref().map(|p| p.display().to_string()).unwrap_or_default();
self.settings_source_url = self.config.web.source_url.clone().unwrap_or_default();
self.plex_status.clear(); self.plex_status.clear();
self.settings_bind_mode = self.settings_bind_mode =
crate::web::bind_mode_of(&self.config.web.bind).to_string(); crate::web::bind_mode_of(&self.config.web.bind).to_string();
@ -1066,6 +1086,84 @@ impl App {
} }
} }
fn stats_window(&mut self, ctx: &egui::Context) {
if !self.show_stats { return; }
let mut open = self.show_stats;
let report = match &self.stats_report {
Some(r) => r.clone(),
None => return,
};
let mut rescan = false;
egui::Window::new("📊 Library statistics")
.open(&mut open)
.collapsible(false)
.resizable(true)
.default_width(560.0)
.default_height(520.0)
.show(ctx, |ui| {
if ui.button("⟳ Recompute").clicked() { rescan = true; }
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
ui.heading("Totals");
egui::Grid::new("stats_totals").num_columns(2).striped(true).show(ui, |ui| {
ui.label("Channels"); ui.label(report.total_channels.to_string()); ui.end_row();
ui.label("Videos"); ui.label(report.total_videos.to_string()); ui.end_row();
ui.label("Playlists"); ui.label(report.total_playlists.to_string()); ui.end_row();
ui.label("Disk used"); ui.label(format_size(report.total_size_bytes)); ui.end_row();
ui.label("Total runtime"); ui.label(format_hours(report.total_duration_secs)); ui.end_row();
ui.label("Watched");
ui.label(format!("{} · {}", report.watched_count, format_hours(report.watched_duration_secs)));
ui.end_row();
ui.label("Continue watching"); ui.label(report.continue_watching_count.to_string()); ui.end_row();
});
ui.add_space(8.0);
ui.heading(format!("Downloads — last {} weeks", report.downloads_per_week.len()));
draw_bars(ui, report.downloads_per_week.iter().map(|w| (
format!("{}", week_label(w.week_start_unix)),
w.count as f32,
format!("{} videos · {}", w.count, format_size(w.size_bytes)),
)));
if !report.videos_per_year.is_empty() {
ui.add_space(8.0);
ui.heading("Videos by upload year");
draw_bars(ui, report.videos_per_year.iter().map(|y| (
y.year.to_string(),
y.count as f32,
format!("{}: {}", y.year, y.count),
)));
}
ui.add_space(8.0);
ui.heading("Top channels by size");
for row in &report.top_channels_by_size {
ui.label(format!(
" {} — {} videos · {} · {}",
row.name, row.count, format_size(row.size_bytes),
format_hours(row.duration_secs),
));
}
ui.add_space(8.0);
ui.heading("Top channels by count");
for row in &report.top_channels_by_count {
ui.label(format!(
" {} — {} videos · {} · {}",
row.name, row.count, format_size(row.size_bytes),
format_hours(row.duration_secs),
));
}
});
});
self.show_stats = open;
if rescan {
self.stats_report = Some(crate::stats::build(
&self.library, &self.watched, &self.resume_positions, crate::stats::now_unix(),
));
}
}
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;
@ -1309,6 +1407,25 @@ impl App {
ui.end_row(); ui.end_row();
}); });
ui.add_space(8.0);
ui.separator();
ui.heading("Source code (AGPL §13)");
ui.add_space(4.0);
ui.label("Repository URL (shown in web UI footer):");
ui.add(
egui::TextEdit::singleline(&mut self.settings_source_url)
.hint_text("https://codeberg.org/you/your-fork")
.desired_width(400.0),
);
ui.label(
egui::RichText::new(
"Every network user must be offered a way to obtain the running source code. \
Leave empty to hide the link.",
)
.small()
.weak(),
);
ui.add_space(8.0); ui.add_space(8.0);
ui.separator(); ui.separator();
ui.heading("Plex"); ui.heading("Plex");
@ -1367,6 +1484,14 @@ impl App {
Some(PathBuf::from(plex_trimmed)) Some(PathBuf::from(plex_trimmed))
}; };
// Source URL (AGPL §13)
let src_trimmed = self.settings_source_url.trim();
self.config.web.source_url = if src_trimmed.is_empty() {
None
} else {
Some(src_trimmed.to_string())
};
// Resolve the chosen interface to a concrete bind address. // Resolve the chosen interface to a concrete bind address.
let new_bind = crate::web::resolve_bind_mode(&self.settings_bind_mode); let new_bind = crate::web::resolve_bind_mode(&self.settings_bind_mode);
let bind_changed = new_bind != self.config.web.bind; let bind_changed = new_bind != self.config.web.bind;
@ -2013,6 +2138,7 @@ impl eframe::App for App {
} }
self.settings_window(ctx); self.settings_window(ctx);
self.maintenance_window(ctx); self.maintenance_window(ctx);
self.stats_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);
@ -2102,6 +2228,77 @@ fn format_size(bytes: u64) -> String {
} }
} }
/// Format a number of seconds as `Hh Mm` (or `Mm` under 1 hour, `0h` empty).
fn format_hours(secs: f64) -> String {
if secs < 60.0 { return "0h".to_string(); }
let h = (secs / 3600.0) as u64;
let m = ((secs as u64 % 3600) / 60) as u64;
if h > 0 { format!("{h}h {m}m") } else { format!("{m}m") }
}
/// Format the week-start unix time as `M/D` for an axis label.
fn week_label(unix: u64) -> String {
// Reproduce just enough calendar math to render M/D without pulling in a
// chrono dep: count days since 1970-01-01, then walk the year/month table.
let days = (unix / 86_400) as i64;
let (mut y, mut d) = (1970i64, days);
loop {
let ly = is_leap(y as u32);
let yd = if ly { 366 } else { 365 };
if d < yd { break; }
d -= yd; y += 1;
}
let months = if is_leap(y as u32) {
[31,29,31,30,31,30,31,31,30,31,30,31]
} else {
[31,28,31,30,31,30,31,31,30,31,30,31]
};
let mut m = 0usize;
while m < 12 && d >= months[m] as i64 { d -= months[m] as i64; m += 1; }
format!("{}/{}", m as u32 + 1, d + 1)
}
fn is_leap(y: u32) -> bool { (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 }
/// Draw a simple bar chart of `(label, value, tooltip)` rows. Heights scale
/// to the max value in the iterator.
fn draw_bars<I>(ui: &mut egui::Ui, items: I)
where I: IntoIterator<Item = (String, f32, String)>,
{
let rows: Vec<_> = items.into_iter().collect();
if rows.is_empty() { return; }
let max = rows.iter().map(|r| r.1).fold(1.0f32, f32::max);
let chart_h = 70.0;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 2.0;
for (label, value, tip) in &rows {
ui.vertical(|ui| {
let h = (value / max) * chart_h;
let bar_w = 22.0;
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(bar_w, chart_h + 14.0),
egui::Sense::hover(),
);
let bar_rect = egui::Rect::from_min_max(
egui::pos2(rect.min.x, rect.max.y - 14.0 - h),
egui::pos2(rect.max.x, rect.max.y - 14.0),
);
ui.painter().rect_filled(
bar_rect, 2.0,
ui.visuals().selection.bg_fill,
);
ui.painter().text(
egui::pos2(rect.center().x, rect.max.y),
egui::Align2::CENTER_BOTTOM,
label,
egui::FontId::proportional(9.0),
ui.visuals().weak_text_color(),
);
resp.on_hover_text(tip);
});
}
});
}
fn format_subs(n: u64) -> String { fn format_subs(n: u64) -> String {
if n >= 1_000_000 { if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0) format!("{:.1}M", n as f64 / 1_000_000.0)

View file

@ -20,6 +20,7 @@ mod downloader;
mod library; mod library;
mod maintenance; mod maintenance;
mod plex; mod plex;
mod stats;
mod theme; mod theme;
mod web; mod web;
mod ytdlp_bin; mod ytdlp_bin;

231
src/stats.rs Normal file
View file

@ -0,0 +1,231 @@
//! Aggregate statistics over the scanned library + watched/resume state.
//!
//! All numbers are computed on demand from the in-memory [`Channel`] tree
//! and the SQLite-backed watched/positions data — no separate stats table.
//! The cost is one library traversal per request, which is cheap relative to
//! the initial scan.
use std::collections::BTreeMap;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::Serialize;
use crate::library::Channel;
/// Top-N row in the channel breakdown tables.
#[derive(Serialize, Clone)]
pub struct ChannelStat {
pub name: String,
pub count: usize,
pub size_bytes: u64,
pub duration_secs: f64,
}
/// One column of the per-year upload histogram.
#[derive(Serialize, Clone)]
pub struct YearStat {
pub year: u32,
pub count: usize,
}
/// One column of the per-week download-activity histogram.
///
/// `week_start_unix` is the UNIX epoch of the Monday 00:00 UTC that begins
/// the bucket, so clients can format the label in any locale.
#[derive(Serialize, Clone)]
pub struct WeekStat {
pub week_start_unix: u64,
pub count: usize,
pub size_bytes: u64,
}
/// Top-level stats payload returned by `GET /api/stats`.
#[derive(Serialize, Clone)]
pub struct StatsReport {
pub total_channels: usize,
pub total_videos: usize,
pub total_playlists: usize,
pub total_size_bytes: u64,
pub total_duration_secs: f64,
/// Count of videos the user has explicitly marked as watched.
pub watched_count: usize,
/// Sum of durations across watched videos. May be 0 if info.json is missing.
pub watched_duration_secs: f64,
/// Number of videos with a non-trivial resume position.
pub continue_watching_count: usize,
pub top_channels_by_size: Vec<ChannelStat>,
pub top_channels_by_count: Vec<ChannelStat>,
pub videos_per_year: Vec<YearStat>,
pub downloads_per_week: Vec<WeekStat>,
}
/// How many rows to include in each Top-N channel table.
const TOP_N: usize = 10;
/// How many recent weeks to surface in the downloads histogram.
const RECENT_WEEKS: usize = 12;
/// Seconds in a week (7 × 24 × 3600).
const WEEK_SECS: u64 = 604_800;
/// Build a full [`StatsReport`] from the scanned library plus watched/resume
/// state. `now_unix` is supplied for testability; in production this is just
/// `SystemTime::now()`.
pub fn build(
channels: &[Channel],
watched: &std::collections::HashSet<String>,
resume_positions: &std::collections::HashMap<String, f64>,
now_unix: u64,
) -> StatsReport {
let total_channels = channels.len();
let total_playlists: usize = channels.iter().map(|c| c.playlists.len()).sum();
let mut total_videos = 0usize;
let mut total_size_bytes = 0u64;
let mut total_duration_secs = 0f64;
let mut watched_count = 0usize;
let mut watched_duration_secs = 0f64;
let mut by_size: Vec<ChannelStat> = Vec::with_capacity(total_channels);
let mut by_count: Vec<ChannelStat> = Vec::with_capacity(total_channels);
let mut years: BTreeMap<u32, usize> = BTreeMap::new();
// Weekly buckets keyed by week-start unix.
let mut weeks: BTreeMap<u64, (usize, u64)> = BTreeMap::new();
let week_start_now = monday_start(now_unix);
let oldest_week = week_start_now.saturating_sub(WEEK_SECS * (RECENT_WEEKS as u64 - 1));
for ch in channels {
let mut ch_count = 0usize;
let mut ch_size = 0u64;
let mut ch_duration = 0f64;
for v in ch.all_videos() {
total_videos += 1;
ch_count += 1;
if let Some(s) = v.file_size {
total_size_bytes += s;
ch_size += s;
}
if let Some(d) = v.duration_secs {
total_duration_secs += d;
ch_duration += d;
}
if watched.contains(&v.id) {
watched_count += 1;
if let Some(d) = v.duration_secs {
watched_duration_secs += d;
}
}
// Year column from upload_date.
if let Some(d) = v.upload_date.as_deref() {
if d.len() >= 4 {
if let Ok(y) = d[..4].parse::<u32>() {
*years.entry(y).or_insert(0) += 1;
}
}
}
// Weekly bucket from the video file's mtime.
if let Some(path) = v.video_path.as_ref() {
if let Some(mtime) = file_mtime_unix(path) {
if mtime >= oldest_week {
let bucket = monday_start(mtime);
let entry = weeks.entry(bucket).or_insert((0, 0));
entry.0 += 1;
entry.1 += v.file_size.unwrap_or(0);
}
}
}
}
by_size.push(ChannelStat {
name: ch.name.clone(),
count: ch_count,
size_bytes: ch_size,
duration_secs: ch_duration,
});
by_count.push(ChannelStat {
name: ch.name.clone(),
count: ch_count,
size_bytes: ch_size,
duration_secs: ch_duration,
});
}
by_size.sort_by(|a, b| b.size_bytes.cmp(&a.size_bytes));
by_size.truncate(TOP_N);
by_count.sort_by(|a, b| b.count.cmp(&a.count));
by_count.truncate(TOP_N);
let videos_per_year = years
.into_iter()
.map(|(year, count)| YearStat { year, count })
.collect();
// Fill in zero buckets for weeks with no downloads so the chart has a
// continuous x-axis.
let mut downloads_per_week = Vec::with_capacity(RECENT_WEEKS);
for i in 0..RECENT_WEEKS as u64 {
let week_start = oldest_week + i * WEEK_SECS;
let (count, size_bytes) = weeks.get(&week_start).copied().unwrap_or((0, 0));
downloads_per_week.push(WeekStat {
week_start_unix: week_start,
count,
size_bytes,
});
}
let continue_watching_count = resume_positions.iter()
.filter(|(_, pos)| **pos > 3.0)
.count();
StatsReport {
total_channels,
total_videos,
total_playlists,
total_size_bytes,
total_duration_secs,
watched_count,
watched_duration_secs,
continue_watching_count,
top_channels_by_size: by_size,
top_channels_by_count: by_count,
videos_per_year,
downloads_per_week,
}
}
/// Read mtime from a path and return it as a UNIX timestamp in seconds.
fn file_mtime_unix(p: &std::path::Path) -> Option<u64> {
let meta = std::fs::metadata(p).ok()?;
let mtime = meta.modified().ok()?;
mtime.duration_since(UNIX_EPOCH).ok().map(|d| d.as_secs())
}
/// Return the UNIX timestamp of the Monday 00:00 UTC that begins the ISO week
/// containing `t_unix`. We approximate via 1970-01-05 (a Monday) as the epoch
/// anchor and snap to the previous 7-day boundary.
fn monday_start(t_unix: u64) -> u64 {
// 1970-01-05 00:00 UTC is a Monday; its unix timestamp is 4 * 86400.
const FIRST_MONDAY: u64 = 4 * 86_400;
if t_unix < FIRST_MONDAY {
return 0;
}
let offset = (t_unix - FIRST_MONDAY) / WEEK_SECS;
FIRST_MONDAY + offset * WEEK_SECS
}
/// Convenience: today's UTC unix timestamp.
pub fn now_unix() -> u64 {
SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn monday_start_aligns_to_monday() {
// 1970-01-05 00:00 UTC = 345600 (Monday).
assert_eq!(monday_start(345_600), 345_600);
// 1970-01-12 00:00 UTC = 950400 (next Monday).
assert_eq!(monday_start(950_400), 950_400);
// A Wednesday rolls back to the Monday two days earlier.
assert_eq!(monday_start(345_600 + 2 * 86_400), 345_600);
// Pre-1970-01-05 returns 0.
assert_eq!(monday_start(86_400), 0);
}
}

View file

@ -229,9 +229,9 @@ struct QueuedSnapshot {
#[derive(Serialize, Deserialize)] #[derive(Serialize, Deserialize)]
struct SettingsPayload { struct SettingsPayload {
transcode: bool, transcode: bool,
/// URL of the source repository, injected by the server for AGPL §13 compliance. /// URL of the source repository, shown in the footer for AGPL §13 compliance.
/// Clients MUST NOT send this field; the server ignores it on POST. /// Editable via the settings UI; empty string on POST clears it.
#[serde(skip_deserializing, default)] #[serde(default)]
source_url: Option<String>, source_url: Option<String>,
/// Current binding address and port, sent by server only. /// Current binding address and port, sent by server only.
#[serde(skip_deserializing, default)] #[serde(skip_deserializing, default)]
@ -992,6 +992,10 @@ async fn post_settings(
cfg.plex.library_path = if p.trim().is_empty() { None } else { Some(std::path::PathBuf::from(p.trim())) }; cfg.plex.library_path = if p.trim().is_empty() { None } else { Some(std::path::PathBuf::from(p.trim())) };
} }
if let Some(ref u) = body.source_url {
let trimmed = u.trim();
cfg.web.source_url = if trimmed.is_empty() { None } else { Some(trimmed.to_string()) };
}
cfg.scheduler.enabled = body.scheduler_enabled; cfg.scheduler.enabled = body.scheduler_enabled;
if body.scheduler_interval_hours > 0 { if body.scheduler_interval_hours > 0 {
cfg.scheduler.interval_hours = body.scheduler_interval_hours; cfg.scheduler.interval_hours = body.scheduler_interval_hours;
@ -1425,6 +1429,16 @@ async fn post_scheduler_run(State(state): State<Arc<WebState>>) -> impl IntoResp
(StatusCode::ACCEPTED, format!("started {count} channel checks")).into_response() (StatusCode::ACCEPTED, format!("started {count} channel checks")).into_response()
} }
/// `GET /api/stats` — aggregate library statistics (totals, top channels,
/// per-year upload histogram, per-week download activity).
async fn get_stats(State(state): State<Arc<WebState>>) -> impl IntoResponse {
let lib = state.library.lock().unwrap();
let watched = state.watched.lock().unwrap();
let positions = state.positions.lock().unwrap();
let report = crate::stats::build(&lib, &watched, &positions, crate::stats::now_unix());
Json(report)
}
/// `POST /api/ytdlp/update` — download (or update) the bundled yt-dlp + deno /// `POST /api/ytdlp/update` — download (or update) the bundled yt-dlp + deno
/// binaries. Streams output through a regular [`Job`] entry. /// binaries. Streams output through a regular [`Job`] entry.
async fn post_ytdlp_update(State(state): State<Arc<WebState>>) -> impl IntoResponse { async fn post_ytdlp_update(State(state): State<Arc<WebState>>) -> impl IntoResponse {
@ -1597,6 +1611,7 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) {
.route("/api/maintenance/repair/:id", post(post_maintenance_repair)) .route("/api/maintenance/repair/:id", post(post_maintenance_repair))
.route("/api/cookies", get(get_cookies).post(post_cookies).delete(delete_cookies)) .route("/api/cookies", get(get_cookies).post(post_cookies).delete(delete_cookies))
.route("/api/scheduler/run", post(post_scheduler_run)) .route("/api/scheduler/run", post(post_scheduler_run))
.route("/api/stats", get(get_stats))
.route("/api/ytdlp/update", post(post_ytdlp_update)) .route("/api/ytdlp/update", post(post_ytdlp_update))
.route("/api/music", get(get_music)) .route("/api/music", get(get_music))
.route("/api/login", post(post_login)) .route("/api/login", post(post_login))
@ -1809,6 +1824,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="openStats()" title="Library statistics">📊</button>
<button onclick="openMaintenance()" title="Library health">🩺</button> <button onclick="openMaintenance()" title="Library health">🩺</button>
<button onclick="openSettings()"></button> <button onclick="openSettings()"></button>
<span id="status"></span> <span id="status"></span>
@ -2242,9 +2258,13 @@ async function logout(){try{await fetch('/api/logout',{method:'POST'});location.
async function openSettings(){ async function openSettings(){
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{} 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=`<hr style="border-color:var(--border);margin:12px 0">
?`<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 style="font-weight:700;margin-bottom:8px">Source code (AGPL §13)</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-row" style="flex-direction:column;align-items:stretch;gap:6px">
<input type="url" id="cf-source-url" value="${esc(cur.source_url||'')}" placeholder="https://codeberg.org/you/your-fork"
style="width:100%;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:6px 8px;font-size:12px">
<div class="settings-hint">Shown as the &quot;Source&quot; link in the footer. AGPL-3.0 requires every network user be offered a way to obtain the running source code. Leave empty to hide the link.</div>
</div>`;
const bindRows=cur.available_binds?.length?`<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px"> const bindRows=cur.available_binds?.length?`<div class="settings-row" style="flex-direction:column;align-items:flex-start;gap:6px">
<label>Binding</label> <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> <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>
@ -2431,9 +2451,11 @@ async function saveSettings(btn){
const schedInterval=parseInt(document.getElementById('cf-sched-interval')?.value||'24',10); const schedInterval=parseInt(document.getElementById('cf-sched-interval')?.value||'24',10);
const maxConcurrent=parseInt(document.getElementById('cf-max-concurrent')?.value||'3',10); const maxConcurrent=parseInt(document.getElementById('cf-max-concurrent')?.value||'3',10);
const useBundledYtdlp=document.getElementById('cf-ytdlp-bundled')?.checked||false; const useBundledYtdlp=document.getElementById('cf-ytdlp-bundled')?.checked||false;
const sourceUrl=document.getElementById('cf-source-url')?.value;
const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp}; const payload={transcode,scheduler_enabled:schedEnabled,scheduler_interval_hours:schedInterval,max_concurrent:maxConcurrent,use_bundled_ytdlp:useBundledYtdlp};
if(bindMode)payload.bind_mode=bindMode; if(bindMode)payload.bind_mode=bindMode;
if(plexPath!==undefined)payload.plex_library_path=plexPath; if(plexPath!==undefined)payload.plex_library_path=plexPath;
if(sourceUrl!==undefined)payload.source_url=sourceUrl;
if(pwdCheckbox&&pwdCheckbox.checked){ if(pwdCheckbox&&pwdCheckbox.checked){
payload.new_download_password=pwdInput?.value||''; payload.new_download_password=pwdInput?.value||'';
} }
@ -2449,6 +2471,97 @@ async function saveSettings(btn){
}catch(e){setStatus('Error: '+e.message)} }catch(e){setStatus('Error: '+e.message)}
} }
/* ── Statistics ─────────────────────────────────────────────────── */
async function openStats(){
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 statistics</h2><button onclick="this.closest('.modal-bg').remove()"></button></div>
<div id="stats-body" style="overflow:auto;max-height:75vh"><em style="color:var(--muted)">Computing</em></div>
</div>`;
document.body.appendChild(bg);
try{
const r=await(await api('/api/stats')).json();
renderStats(r);
}catch(e){document.getElementById('stats-body').innerHTML=`<div style="color:#f87171">Stats failed: ${esc(e.message)}</div>`}
}
function fmtHours(secs){
if(!secs||secs<60)return'0h';
const h=Math.floor(secs/3600),m=Math.floor((secs%3600)/60);
return h>0?`${h}h ${m}m`:`${m}m`;
}
function renderStats(r){
const body=document.getElementById('stats-body');if(!body)return;
const tot=fmtHours(r.total_duration_secs);
const wat=fmtHours(r.watched_duration_secs);
const tile=(label,value)=>`<div style="background:var(--card);border:1px solid var(--border);border-radius:6px;padding:10px 12px;flex:1;min-width:120px"><div style="font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.5px">${esc(label)}</div><div style="font-size:18px;font-weight:600;margin-top:2px">${esc(value)}</div></div>`;
let h=`<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:14px">
${tile('Channels',r.total_channels.toLocaleString())}
${tile('Videos',r.total_videos.toLocaleString())}
${tile('Playlists',r.total_playlists.toLocaleString())}
${tile('Disk used',fmtSize(r.total_size_bytes))}
${tile('Total runtime',tot)}
${tile('Watched',`${r.watched_count.toLocaleString()} · ${wat}`)}
${tile('Continue watching',r.continue_watching_count.toLocaleString())}
</div>`;
// Downloads per week — horizontal bar chart.
const weeks=r.downloads_per_week||[];
const maxWeek=Math.max(1,...weeks.map(w=>w.count));
h+=`<h3 style="font-size:13px;margin:4px 0 6px">Downloads last ${weeks.length} weeks</h3>`;
h+=`<div style="display:flex;align-items:flex-end;gap:3px;height:80px;margin-bottom:14px;border-bottom:1px solid var(--border);padding-bottom:4px">`;
for(const w of weeks){
const pct=(w.count/maxWeek)*100;
const d=new Date(w.week_start_unix*1000);
const lbl=`${d.getMonth()+1}/${d.getDate()}`;
const tip=`${lbl}: ${w.count} videos, ${fmtSize(w.size_bytes)}`;
h+=`<div title="${esc(tip)}" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%">
<div style="width:100%;height:${pct}%;background:var(--accent);border-radius:2px 2px 0 0;min-height:${w.count>0?'2px':'0'}"></div>
<div style="font-size:9px;color:var(--muted);margin-top:2px">${esc(lbl)}</div>
</div>`;
}
h+=`</div>`;
// Uploads per year.
const years=r.videos_per_year||[];
if(years.length){
const maxYear=Math.max(1,...years.map(y=>y.count));
h+=`<h3 style="font-size:13px;margin:4px 0 6px">Videos by upload year</h3>`;
h+=`<div style="display:flex;align-items:flex-end;gap:3px;height:60px;margin-bottom:14px;border-bottom:1px solid var(--border);padding-bottom:4px">`;
for(const y of years){
const pct=(y.count/maxYear)*100;
h+=`<div title="${esc(y.year+': '+y.count)}" style="flex:1;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;height:100%">
<div style="width:100%;height:${pct}%;background:var(--accent);opacity:.7;border-radius:2px 2px 0 0;min-height:${y.count>0?'2px':'0'}"></div>
<div style="font-size:9px;color:var(--muted);margin-top:2px">${esc(y.year)}</div>
</div>`;
}
h+=`</div>`;
}
// Two side-by-side top-N tables.
const topTable=(title,rows)=>{
if(!rows||!rows.length)return'';
let t=`<h3 style="font-size:13px;margin:4px 0 6px">${esc(title)}</h3>
<table style="width:100%;font-size:12px;border-collapse:collapse;margin-bottom:14px"><tbody>`;
for(const row of rows){
t+=`<tr style="border-bottom:1px solid var(--border)">
<td style="padding:4px 6px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:280px">${esc(row.name)}</td>
<td style="padding:4px 6px;text-align:right;color:var(--muted)">${row.count} videos</td>
<td style="padding:4px 6px;text-align:right;color:var(--muted)">${fmtSize(row.size_bytes)}</td>
<td style="padding:4px 6px;text-align:right;color:var(--muted)">${fmtHours(row.duration_secs)}</td>
</tr>`;
}
t+=`</tbody></table>`;
return t;
};
h+=`<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px">
<div>${topTable('Top channels by size',r.top_channels_by_size)}</div>
<div>${topTable('Top channels by count',r.top_channels_by_count)}</div>
</div>`;
body.innerHTML=h;
}
/* ── Maintenance (library health) ───────────────────────────────── */ /* ── Maintenance (library health) ───────────────────────────────── */
async function openMaintenance(){ async function openMaintenance(){
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()};