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

@ -88,6 +88,7 @@ pub struct App {
music_root: PathBuf,
settings_dir: String,
settings_plex_path: String,
settings_source_url: String,
plex_status: String,
db: Database,
card_density: f32,
@ -121,6 +122,9 @@ pub struct App {
// Maintenance (library health) window
show_maintenance: bool,
health_report: Option<crate::maintenance::HealthReport>,
// Statistics window
show_stats: bool,
stats_report: Option<crate::stats::StatsReport>,
}
impl App {
@ -166,6 +170,7 @@ impl App {
.as_deref()
.map(|p| p.display().to_string())
.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>();
@ -208,6 +213,7 @@ impl App {
music_root,
settings_dir,
settings_plex_path: plex_path_str,
settings_source_url: source_url_str,
plex_status: String::new(),
db,
card_density: 1.0,
@ -234,6 +240,8 @@ impl App {
cookies_pick_rx,
show_maintenance: false,
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() {
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() {
self.show_maintenance = !self.show_maintenance;
if self.show_maintenance {
@ -626,6 +645,7 @@ impl App {
self.settings_dir = self.channels_root.display().to_string();
self.settings_plex_path = self.config.plex.library_path
.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.settings_bind_mode =
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) {
if !self.show_settings {
return;
@ -1309,6 +1407,25 @@ impl App {
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.separator();
ui.heading("Plex");
@ -1367,6 +1484,14 @@ impl App {
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.
let new_bind = crate::web::resolve_bind_mode(&self.settings_bind_mode);
let bind_changed = new_bind != self.config.web.bind;
@ -2013,6 +2138,7 @@ impl eframe::App for App {
}
self.settings_window(ctx);
self.maintenance_window(ctx);
self.stats_window(ctx);
self.detail_panel(ctx);
egui::CentralPanel::default().show(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 {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)

View file

@ -20,6 +20,7 @@ mod downloader;
mod library;
mod maintenance;
mod plex;
mod stats;
mod theme;
mod web;
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)]
struct SettingsPayload {
transcode: bool,
/// URL of the source repository, injected by the server for AGPL §13 compliance.
/// Clients MUST NOT send this field; the server ignores it on POST.
#[serde(skip_deserializing, default)]
/// URL of the source repository, shown in the footer for AGPL §13 compliance.
/// Editable via the settings UI; empty string on POST clears it.
#[serde(default)]
source_url: Option<String>,
/// Current binding address and port, sent by server only.
#[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())) };
}
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;
if body.scheduler_interval_hours > 0 {
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()
}
/// `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
/// binaries. Streams output through a regular [`Job`] entry.
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/cookies", get(get_cookies).post(post_cookies).delete(delete_cookies))
.route("/api/scheduler/run", post(post_scheduler_run))
.route("/api/stats", get(get_stats))
.route("/api/ytdlp/update", post(post_ytdlp_update))
.route("/api/music", get(get_music))
.route("/api/login", post(post_login))
@ -1809,6 +1824,7 @@ const HTML_UI: &str = r#"<!DOCTYPE html>
</select>
<span id="hdr-stats"></span>
<button onclick="rescan()" title="Rescan library"></button>
<button onclick="openStats()" title="Library statistics">📊</button>
<button onclick="openMaintenance()" title="Library health">🩺</button>
<button onclick="openSettings()"></button>
<span id="status"></span>
@ -2242,9 +2258,13 @@ async function logout(){try{await fetch('/api/logout',{method:'POST'});location.
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{}
const savedTheme=localStorage.getItem('theme')||'dark';
const srcRow=cur.source_url
?`<div class="settings-hint" style="margin-top:8px">Source code: <a href="${esc(cur.source_url)}" target="_blank">${esc(cur.source_url)}</a> (AGPL-3.0)</div>`
:`<div class="settings-hint" style="margin-top:8px;color:var(--muted)">AGPL-3.0 set <code>web.source_url</code> in config.toml to link source code</div>`;
const srcRow=`<hr style="border-color:var(--border);margin:12px 0">
<div style="font-weight:700;margin-bottom:8px">Source code (AGPL §13)</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">
<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>
@ -2431,9 +2451,11 @@ async function saveSettings(btn){
const schedInterval=parseInt(document.getElementById('cf-sched-interval')?.value||'24',10);
const maxConcurrent=parseInt(document.getElementById('cf-max-concurrent')?.value||'3',10);
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};
if(bindMode)payload.bind_mode=bindMode;
if(plexPath!==undefined)payload.plex_library_path=plexPath;
if(sourceUrl!==undefined)payload.source_url=sourceUrl;
if(pwdCheckbox&&pwdCheckbox.checked){
payload.new_download_password=pwdInput?.value||'';
}
@ -2449,6 +2471,97 @@ async function saveSettings(btn){
}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) ───────────────────────────────── */
async function openMaintenance(){
const bg=document.createElement('div');bg.className='modal-bg';bg.onclick=e=>{if(e.target===bg)bg.remove()};