Activating an episode now opens a scope picker (episode / season / whole show) and plays the chosen queue back-to-back in one mpv instance, with only the first item honoring its resume position. A new mpv `vo` driver setting (CLI flag, env var, config file, and settings TUI) lets users opt into terminal-based vos like sixel/kitty/tct, which auto-enable mpv's terminal input so playback keys still work. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
144 lines
4.9 KiB
Rust
144 lines
4.9 KiB
Rust
use anyhow::{Context, Result};
|
|
use clap::Parser;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::path::{Path, PathBuf};
|
|
|
|
#[derive(Parser, Debug, Clone)]
|
|
#[command(name = "emby-client", about = "TUI Emby client with libmpv playback")]
|
|
pub struct CliArgs {
|
|
/// Emby server URL (e.g. http://emby.home.local)
|
|
#[arg(long, env = "EMBY_SERVER")]
|
|
pub server: Option<String>,
|
|
|
|
/// Emby username
|
|
#[arg(short, long, env = "EMBY_USER")]
|
|
pub user: Option<String>,
|
|
|
|
/// Emby password
|
|
#[arg(short, long, env = "EMBY_PASSWORD")]
|
|
pub password: Option<String>,
|
|
|
|
/// Preferred audio language(s), comma-separated ISO 639 codes (e.g. "jpn,eng").
|
|
/// Forwarded to mpv's `alang`.
|
|
#[arg(long, env = "EMBY_AUDIO_LANG")]
|
|
pub audio_lang: Option<String>,
|
|
|
|
/// Preferred subtitle language(s), comma-separated ISO 639 codes (e.g. "eng,jpn").
|
|
/// Forwarded to mpv's `slang`.
|
|
#[arg(long, env = "EMBY_SUBTITLE_LANG")]
|
|
pub subtitle_lang: Option<String>,
|
|
|
|
/// mpv video output driver (e.g. `gpu`, `gpu-next`, `sixel`, `kitty`,
|
|
/// `drm`, `tct`). Forwarded to mpv's `vo`. Leave unset for mpv's default.
|
|
#[arg(long, env = "EMBY_VIDEO_OUTPUT")]
|
|
pub video_output: Option<String>,
|
|
|
|
/// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml
|
|
#[arg(short, long)]
|
|
pub config: Option<PathBuf>,
|
|
|
|
/// Install a desktop entry for the current executable and exit
|
|
#[arg(long)]
|
|
pub install_desktop: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
|
pub struct FileConfig {
|
|
pub server: Option<String>,
|
|
pub user: Option<String>,
|
|
pub password: Option<String>,
|
|
pub audio_lang: Option<String>,
|
|
pub subtitle_lang: Option<String>,
|
|
pub video_output: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct Config {
|
|
pub server: String,
|
|
pub user: String,
|
|
pub password: String,
|
|
pub audio_lang: Option<String>,
|
|
pub subtitle_lang: Option<String>,
|
|
/// mpv `vo` driver, e.g. `gpu`, `sixel`, `kitty`, `drm`, `tct`. `None`
|
|
/// means we don't touch the property and mpv picks its default.
|
|
pub video_output: Option<String>,
|
|
}
|
|
|
|
pub fn load(args: &CliArgs) -> Result<(Option<Config>, Option<PathBuf>)> {
|
|
let (file_cfg, save_target) = read_file_config(args.config.as_deref())?;
|
|
|
|
let server = args.server.clone().or(file_cfg.server);
|
|
let user = args.user.clone().or(file_cfg.user);
|
|
let password = args.password.clone().or(file_cfg.password);
|
|
let audio_lang = args.audio_lang.clone().or(file_cfg.audio_lang);
|
|
let subtitle_lang = args.subtitle_lang.clone().or(file_cfg.subtitle_lang);
|
|
let video_output = args.video_output.clone().or(file_cfg.video_output);
|
|
|
|
let cfg = match (server, user, password) {
|
|
(Some(server), Some(user), Some(password)) => Some(Config {
|
|
server: server.trim_end_matches('/').to_string(),
|
|
user,
|
|
password,
|
|
audio_lang,
|
|
subtitle_lang,
|
|
video_output,
|
|
}),
|
|
_ => None,
|
|
};
|
|
|
|
Ok((cfg, Some(save_target)))
|
|
}
|
|
|
|
fn read_file_config(explicit: Option<&Path>) -> Result<(FileConfig, PathBuf)> {
|
|
let default_target = default_save_path();
|
|
let candidates: Vec<PathBuf> = if let Some(p) = explicit {
|
|
vec![p.to_path_buf()]
|
|
} else {
|
|
let mut v = vec![PathBuf::from("config.toml")];
|
|
if let Some(dirs) = directories::ProjectDirs::from("", "", "emby-client") {
|
|
v.push(dirs.config_dir().join("config.toml"));
|
|
}
|
|
v
|
|
};
|
|
let mut save_target = explicit
|
|
.map(|p| p.to_path_buf())
|
|
.unwrap_or(default_target);
|
|
for path in &candidates {
|
|
if path.exists() {
|
|
let text = std::fs::read_to_string(path)
|
|
.with_context(|| format!("reading config {}", path.display()))?;
|
|
let cfg: FileConfig = toml::from_str(&text)
|
|
.with_context(|| format!("parsing config {}", path.display()))?;
|
|
save_target = path.clone();
|
|
return Ok((cfg, save_target));
|
|
}
|
|
}
|
|
Ok((FileConfig::default(), save_target))
|
|
}
|
|
|
|
fn default_save_path() -> PathBuf {
|
|
if let Some(dirs) = directories::ProjectDirs::from("", "", "emby-client") {
|
|
dirs.config_dir().join("config.toml")
|
|
} else {
|
|
PathBuf::from("config.toml")
|
|
}
|
|
}
|
|
|
|
pub fn save(path: &Path, cfg: &Config) -> Result<()> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("creating {}", parent.display()))?;
|
|
}
|
|
let file_cfg = FileConfig {
|
|
server: Some(cfg.server.clone()),
|
|
user: Some(cfg.user.clone()),
|
|
password: Some(cfg.password.clone()),
|
|
audio_lang: cfg.audio_lang.clone(),
|
|
subtitle_lang: cfg.subtitle_lang.clone(),
|
|
video_output: cfg.video_output.clone(),
|
|
};
|
|
let text = toml::to_string_pretty(&file_cfg).context("serializing config")?;
|
|
std::fs::write(path, text).with_context(|| format!("writing {}", path.display()))?;
|
|
Ok(())
|
|
}
|