catacomb/src/config.rs
Luna 3f611849af docs: rename yt-offline → Catacomb across README, ROADMAP, guides, packaging
Prose + commands updated to the new name: brand reads "Catacomb", while
binary/path/package references are the lowercase `catacomb` (commands, deb/rpm
names, ~/.local/share/catacomb, catacomb.db, catacomb.desktop). Codeberg repo
URLs left pointing at the existing repo. Also folds in the pending doc edits
from earlier in the session.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 03:34:18 -07:00

321 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Application configuration loaded from `config.toml`.
//!
//! Each top-level section maps to a TOML table. Missing sections get sane
//! defaults so existing config files continue to work after upgrades.
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Root configuration object, serialised from/to `config.toml`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Config {
pub backup: BackupSection,
#[serde(default)]
pub player: PlayerSection,
#[serde(default)]
pub ui: UiSection,
#[serde(default)]
pub scheduler: SchedulerSection,
#[serde(default)]
pub web: WebSection,
#[serde(default)]
pub plex: PlexSection,
#[serde(default)]
pub subtitles: SubtitlesSection,
#[serde(default)]
pub convert: ConvertSection,
/// Federation: other catacomb instances whose libraries can be browsed
/// read-only from this one (see [`crate::remote`]). Each is a
/// `[[remote]]` table in config.toml.
#[serde(default, rename = "remote")]
pub remotes: Vec<RemoteSection>,
}
/// One `[[remote]]` entry — a peer catacomb instance to browse read-only.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct RemoteSection {
/// Display name in the UI's remote switcher.
pub name: String,
/// Base URL of the peer's web server, e.g. `http://woofbox:8081` or
/// `https://archive.example`. No trailing slash needed.
pub url: String,
/// Password for the peer, if it has one set. `None`/absent = open peer
/// (e.g. bound to a trusted tailnet). Stored plaintext like `cookies`.
#[serde(default)]
pub password: Option<String>,
}
/// `[convert]` table — global post-download format-conversion defaults.
/// A separate ffmpeg pass runs after the download completes. Per-channel
/// [`crate::download_options::DownloadOptions`] can override `mode`.
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct ConvertSection {
/// What to do after download. One of:
/// - `""` / `"off"`: nothing (keep the downloaded mkv as-is).
/// - `"remux-mp4"`: fast container change mkv→mp4, no re-encode.
/// - `"h264-mp4"`: re-encode video to H.264/AAC mp4 at [`Self::crf`].
/// - `"audio"`: extract audio to [`Self::audio_format`].
#[serde(default)]
pub mode: String,
/// CRF for `h264-mp4` (051; lower = bigger/better). 23 is a sane
/// default if left at 0.
#[serde(default)]
pub crf: u8,
/// x264 preset for `h264-mp4` (ultrafast…veryslow). Empty = "medium".
#[serde(default)]
pub preset: String,
/// Audio format for `audio` mode (mp3 / m4a / opus / flac). Empty =
/// "mp3".
#[serde(default)]
pub audio_format: String,
/// Keep the original downloaded file alongside the converted one
/// (renamed to `<stem>.original.<ext>`). When false, the original is
/// deleted after a successful convert.
#[serde(default)]
pub keep_original: bool,
}
/// `[subtitles]` table — global subtitle download defaults. Per-channel
/// [`crate::download_options::DownloadOptions`] can override any of these.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SubtitlesSection {
/// Master switch. When false, no subtitle flags are emitted at all.
#[serde(default = "default_true")]
pub enabled: bool,
/// Include auto-generated (machine) captions via `--write-auto-subs`.
/// Off-by-default-ish content is noisy; keep it on globally but let
/// channels turn it off.
#[serde(default = "default_true")]
pub auto_generated: bool,
/// Embed subtitle tracks into the video container (`--embed-subs`),
/// in addition to writing sidecar files. Soft subs, toggleable in
/// the player.
#[serde(default)]
pub embed: bool,
/// Convert downloaded subtitles to this format (`--convert-subs`).
/// Empty = keep native. Common: "srt" (Plex-friendly), "vtt", "ass".
#[serde(default)]
pub format: String,
/// Comma-separated language codes (`--sub-langs`). Empty = all
/// available. e.g. "en", "en,ja", "en.*" for all English variants.
#[serde(default)]
pub langs: String,
}
impl Default for SubtitlesSection {
fn default() -> Self {
Self {
enabled: true,
auto_generated: true,
embed: false,
format: String::new(),
langs: String::new(),
}
}
}
/// `[backup]` table — where to store downloaded videos.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct BackupSection {
pub directory: PathBuf,
/// Maximum simultaneous yt-dlp processes. Extra downloads queue and start
/// automatically when a slot opens. Set to 0 for no limit (not recommended).
#[serde(default = "default_max_concurrent")]
pub max_concurrent: usize,
/// If true, use the bundled yt-dlp + deno binaries managed by catacomb
/// (installed under `~/.local/share/catacomb/bin/`). If false, use the
/// `yt-dlp` found on the system PATH.
#[serde(default)]
pub use_bundled_ytdlp: bool,
/// If true and the bundled yt-dlp is in use, spawn the bgutil-pot
/// HTTP server on startup and pass its extractor-args to every
/// yt-dlp invocation. YouTube increasingly requires a per-video
/// Proof-of-Origin token; without one, format URLs come back empty.
///
/// Only effective in tandem with `use_bundled_ytdlp` because the
/// matching Python plugin gets pip-installed into the bundled venv,
/// not the system Python. System-yt-dlp users who want POT support
/// install the plugin and run the server themselves.
#[serde(default)]
pub use_pot_provider: bool,
/// YouTube player clients to try, as a comma-separated list passed to
/// `--extractor-args youtube:player_client=…`. Empty = let yt-dlp pick
/// its defaults (recommended). YouTube's bot-detection cracks down on
/// different clients over time; setting e.g. `tv,mweb` routes around a
/// client that's currently being captcha-walled. Per-channel overrides
/// live in [`crate::download_options::DownloadOptions`].
#[serde(default)]
pub youtube_player_clients: String,
/// SponsorBlock handling for downloaded videos. `"mark"` (the default)
/// chapter-marks segments via `--sponsorblock-mark all` so a player can
/// skip them; `"remove"` cuts them from the file via
/// `--sponsorblock-remove all`; `"off"` disables it. Per-channel
/// overrides live in [`crate::download_options::DownloadOptions`].
#[serde(default = "default_sponsorblock_mode")]
pub sponsorblock_mode: String,
/// Download the comment tree into each video's info.json sidecar via
/// yt-dlp `--write-comments`. Off by default — comment download is slow
/// (yt-dlp paginates through thousands of replies on popular videos).
/// When on, the player's Comments tab is populated. Per-channel overrides
/// live in [`crate::download_options::DownloadOptions`].
#[serde(default)]
pub fetch_comments: bool,
/// Whether the perceptual "similar content" dedup scan is available. On by
/// default; set to `false` to hard-disable fingerprinting in both UIs (the
/// scan button no-ops / the web scan endpoint returns 409). Useful on
/// low-powered machines where the ffmpeg keyframe pass is unwanted.
#[serde(default = "default_true")]
pub dedup_enabled: bool,
}
fn default_max_concurrent() -> usize { 3 }
pub fn default_true() -> bool { true }
fn default_sponsorblock_mode() -> String { "mark".to_string() }
/// `[player]` table — external player and browser cookie source.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct PlayerSection {
#[serde(default = "default_player")]
pub command: String,
#[serde(default = "default_browser")]
pub browser: String,
}
impl Default for PlayerSection {
fn default() -> Self {
Self { command: default_player(), browser: default_browser() }
}
}
/// `[ui]` table — egui desktop theme.
///
/// Available themes: `dark`, `light`, `dracula`, `trans`, `emo-nocturnal`, `emo-coffin`, `emo-scene-queen`.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct UiSection {
#[serde(default = "default_theme")]
pub theme: String,
/// When true, clicking the window close button hides the window
/// into the system tray instead of exiting. The tray menu's Quit
/// item (or Ctrl+Q) still terminates the app. Off by default so
/// users without a working SNI host don't get stuck in an invisible
/// hidden window.
#[serde(default)]
pub minimize_to_tray: bool,
/// Global egui zoom factor — scales the *entire* desktop UI (fonts,
/// spacing, widgets), not just the card grid. 1.0 = native. Persisted
/// here so the chosen size survives restarts and is kept in sync with
/// the built-in Ctrl + / Ctrl - / Ctrl 0 keyboard zoom.
#[serde(default = "default_ui_scale")]
pub ui_scale: f32,
}
fn default_ui_scale() -> f32 { 1.0 }
impl Default for UiSection {
fn default() -> Self {
Self {
theme: default_theme(),
minimize_to_tray: false,
ui_scale: default_ui_scale(),
}
}
}
/// `[scheduler]` table — periodic background download schedule.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SchedulerSection {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_interval_hours")]
pub interval_hours: u32,
}
impl Default for SchedulerSection {
fn default() -> Self {
Self { enabled: false, interval_hours: default_interval_hours() }
}
}
/// `[plex]` table — Plex-compatible TV-show symlink library.
#[derive(Debug, Serialize, Deserialize, Clone, Default)]
pub struct PlexSection {
/// Directory where the Plex symlink tree is written.
/// Leave unset to disable Plex library generation.
pub library_path: Option<PathBuf>,
}
/// `[web]` table — built-in HTTP server settings.
///
/// `source_url` is **required for AGPL §13 compliance**: set it to a URL
/// where the running source code can be obtained (e.g. your Codeberg repo).
/// It is shown as a "Source" link in the web UI footer.
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct WebSection {
#[serde(default = "default_web_port")]
pub port: u16,
#[serde(default = "default_web_bind")]
/// Address to bind the HTTP server to. Defaults to `127.0.0.1` (localhost only).
/// Set to `0.0.0.0` to accept connections from any interface (not recommended).
pub bind: String,
#[serde(default)]
pub transcode: bool,
/// Public URL to the source repository, shown in the web UI per AGPL §13.
pub source_url: Option<String>,
}
impl Default for WebSection {
fn default() -> Self {
Self {
port: default_web_port(),
bind: default_web_bind(),
transcode: false,
source_url: None,
}
}
}
fn default_player() -> String { "mpv".to_string() }
fn default_browser() -> String { "firefox".to_string() }
fn default_theme() -> String { "dark".to_string() }
fn default_interval_hours() -> u32 { 24 }
fn default_web_port() -> u16 { 8080 }
fn default_web_bind() -> String { "127.0.0.1".to_string() }
impl Config {
/// Load and parse `config.toml` from `path`.
pub fn load(path: &Path) -> Result<Self, Box<dyn std::error::Error>> {
let contents = std::fs::read_to_string(path)?;
toml::from_str(&contents).map_err(|e| e.into())
}
/// Serialise the config back to `path` in pretty TOML.
pub fn save(&self, path: &Path) -> Result<(), Box<dyn std::error::Error>> {
let contents = toml::to_string_pretty(self)?;
std::fs::write(path, contents)?;
Ok(())
}
/// Construct a minimal default config pointing `backup.directory` at `dir`.
pub fn default_with_dir(dir: PathBuf) -> Self {
Self {
backup: BackupSection {
directory: dir,
max_concurrent: default_max_concurrent(),
use_bundled_ytdlp: false,
use_pot_provider: false,
youtube_player_clients: String::new(),
sponsorblock_mode: default_sponsorblock_mode(),
fetch_comments: false,
dedup_enabled: true,
},
player: PlayerSection::default(),
ui: UiSection::default(),
scheduler: SchedulerSection::default(),
web: WebSection::default(),
plex: PlexSection::default(),
subtitles: SubtitlesSection::default(),
convert: ConvertSection::default(),
remotes: Vec::new(),
}
}
}