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>
321 lines
12 KiB
Rust
321 lines
12 KiB
Rust
//! 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` (0–51; 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(),
|
||
}
|
||
}
|
||
}
|