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:
parent
59996735f3
commit
4d83999edd
5 changed files with 743 additions and 193 deletions
|
|
@ -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:160–188`): 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:404–415`): 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:492–498`): 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:230–236`):
|
|
||||||
- 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:176–186`):
|
|
||||||
- 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:339–350`)
|
|
||||||
- **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:464–479`)
|
|
||||||
- **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:484–527`)
|
|
||||||
- **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.
|
||||||
|
|
|
||||||
197
src/app.rs
197
src/app.rs
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
231
src/stats.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
125
src/web.rs
125
src/web.rs
|
|
@ -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 "Source" 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()};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue