From b2b2f7b71e10781c8f7d11cfbd68f74826e3f322 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Mon, 15 Dec 2025 01:18:57 +0100 Subject: [PATCH] Add multi-target mpv config support (Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core infrastructure for managing multiple mpv config folders: Config changes (config.rs): - Add TargetConfig struct for target definitions - Add targets field to Config (list of target configs) - Add targets field to RepoEntry (optional, defaults to all) - Add should_install_to() for per-repo target filtering - Add enabled_targets(), find_target(), add_target() helpers Path detection (paths.rs): - Add DetectedTarget struct for discovered configs - Add detect_mpv_configs() to find mpv, jellyfin-mpv-shim, celluloid, flatpak - Check for existing scripts in each detected folder First-run setup (main.rs): - Detect all mpv config folders on first run - Interactive selection of which targets to manage - Save selected targets to config Install command (install.rs): - Install scripts to multiple targets - Per-repo target filtering via targets field - Show target labels when installing to multiple targets 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/commands/install.rs | 124 +++++++++++++++++++++++------------ src/config.rs | 90 +++++++++++++++++++++++++ src/main.rs | 141 ++++++++++++++++++++++++++++++---------- src/paths.rs | 99 ++++++++++++++++++++++++++++ 4 files changed, 378 insertions(+), 76 deletions(-) diff --git a/src/commands/install.rs b/src/commands/install.rs index c40423d..cfd0090 100644 --- a/src/commands/install.rs +++ b/src/commands/install.rs @@ -1,6 +1,6 @@ use colored::Colorize; -use crate::config::Config; +use crate::config::{Config, TargetConfig}; use crate::error::Result; use crate::paths::Paths; use crate::repo::{Repository, ScriptDiscovery}; @@ -23,14 +23,17 @@ pub fn execute(force: bool, repos_filter: Option>) -> Result<()> { return Ok(()); } - let installer = ScriptInstaller::new( - paths.mpv_scripts_dir.clone(), - paths.mpv_script_opts_dir.clone(), - paths.mpv_fonts_dir.clone(), - paths.mpv_shaders_dir.clone(), - paths.repos_dir.clone(), - config.settings.use_symlinks, - ); + // Check if we have any targets configured + let targets: Vec<&TargetConfig> = config.enabled_targets().collect(); + if targets.is_empty() { + println!("{}", "No targets configured.".yellow()); + println!( + "Run {} to set up targets, or delete {} to reconfigure.", + "mpv-mgr".cyan(), + paths.config_file.display().to_string().dimmed() + ); + return Ok(()); + } let mut total_repos = 0; let mut total_scripts = 0; @@ -85,46 +88,79 @@ pub fn execute(force: bool, repos_filter: Option>) -> Result<()> { continue; } - // Install each script - for script in &scripts { - let display_name = if script.is_multi_file { - format!("{}/", script.name) + // Install to each target this repo should go to + let repo_targets: Vec<&TargetConfig> = targets + .iter() + .filter(|t| entry.should_install_to(&t.name)) + .copied() + .collect(); + + if repo_targets.is_empty() { + println!(" {}", "No matching targets".dimmed()); + continue; + } + + for target in &repo_targets { + // Create installer for this target + let installer = ScriptInstaller::new( + target.scripts_dir(), + target.script_opts_dir(), + target.fonts_dir(), + target.shaders_dir(), + paths.repos_dir.clone(), + config.settings.use_symlinks, + ); + + // Ensure target directories exist + std::fs::create_dir_all(target.scripts_dir()).ok(); + + let target_label = if repo_targets.len() > 1 { + format!("[{}] ", target.name).dimmed() } else { - script.repo_path.display().to_string() + "".to_string().normal() }; - print!(" {} {}", "Installing".green(), display_name); + // Install each script + for script in &scripts { + let display_name = if script.is_multi_file { + format!("{}/", script.name) + } else { + script.repo_path.display().to_string() + }; - match installer.install(script, entry.rename.as_deref()) { - Ok(result) => { - print!( - " -> {}", - result.script_path.file_name().unwrap().to_string_lossy().cyan() - ); + print!(" {}{}Installing {}", target_label, "".normal(), display_name.normal()); - // Show assets if any were installed - let asset_count = result.script_opts_count + result.fonts_count + result.shaders_count; - if asset_count > 0 { - let mut parts = Vec::new(); - if result.script_opts_count > 0 { - parts.push(format!("{} conf", result.script_opts_count)); + match installer.install(script, entry.rename.as_deref()) { + Ok(result) => { + print!( + " -> {}", + result.script_path.file_name().unwrap().to_string_lossy().cyan() + ); + + // Show assets if any were installed + let asset_count = result.script_opts_count + result.fonts_count + result.shaders_count; + if asset_count > 0 { + let mut parts = Vec::new(); + if result.script_opts_count > 0 { + parts.push(format!("{} conf", result.script_opts_count)); + } + if result.fonts_count > 0 { + parts.push(format!("{} font", result.fonts_count)); + } + if result.shaders_count > 0 { + parts.push(format!("{} shader", result.shaders_count)); + } + print!(" {}", format!("(+{})", parts.join(", ")).dimmed()); + total_assets += asset_count; } - if result.fonts_count > 0 { - parts.push(format!("{} font", result.fonts_count)); - } - if result.shaders_count > 0 { - parts.push(format!("{} shader", result.shaders_count)); - } - print!(" {}", format!("(+{})", parts.join(", ")).dimmed()); - total_assets += asset_count; + + println!(); + total_scripts += 1; + } + Err(e) => { + println!(" {}", "failed".red()); + eprintln!(" {}: {}", "Error".red(), e); } - - println!(); - total_scripts += 1; - } - Err(e) => { - println!(" {}", "failed".red()); - eprintln!(" {}: {}", "Error".red(), e); } } } @@ -168,6 +204,10 @@ pub fn execute(force: bool, repos_filter: Option>) -> Result<()> { print!(" + {} asset(s)", total_assets.to_string().cyan()); } + if targets.len() > 1 { + print!(" to {} target(s)", targets.len().to_string().cyan()); + } + println!(); } else if failed_repos.is_empty() { println!("{}", "No scripts were installed.".yellow()); diff --git a/src/config.rs b/src/config.rs index 31dcd93..d7ac19c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -3,6 +3,51 @@ use std::path::{Path, PathBuf}; use crate::error::{MpvMgrError, Result}; +/// Target mpv configuration (e.g., mpv, jellyfin-mpv-shim) +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct TargetConfig { + /// Target name (e.g., "mpv", "jellyfin-mpv-shim") + pub name: String, + + /// Base path to the mpv config folder + pub path: PathBuf, + + /// Whether this target is enabled + #[serde(default = "default_true")] + pub enabled: bool, +} + +impl TargetConfig { + /// Create a new target config + pub fn new(name: impl Into, path: impl Into) -> Self { + Self { + name: name.into(), + path: path.into(), + enabled: true, + } + } + + /// Get the scripts directory for this target + pub fn scripts_dir(&self) -> PathBuf { + self.path.join("scripts") + } + + /// Get the script-opts directory for this target + pub fn script_opts_dir(&self) -> PathBuf { + self.path.join("script-opts") + } + + /// Get the fonts directory for this target + pub fn fonts_dir(&self) -> PathBuf { + self.path.join("fonts") + } + + /// Get the shaders directory for this target + pub fn shaders_dir(&self) -> PathBuf { + self.path.join("shaders") + } +} + /// Main configuration structure #[derive(Debug, Serialize, Deserialize, Default)] pub struct Config { @@ -10,6 +55,10 @@ pub struct Config { #[serde(default)] pub settings: Settings, + /// Target mpv configurations to manage + #[serde(default)] + pub targets: Vec, + /// List of repositories to manage #[serde(default)] pub repos: Vec, @@ -66,6 +115,10 @@ pub struct RepoEntry { #[serde(skip_serializing_if = "Option::is_none")] pub scripts: Option>, + /// Optional: install only to specific targets (None = all enabled targets) + #[serde(skip_serializing_if = "Option::is_none")] + pub targets: Option>, + /// Optional: disable this repo without removing #[serde(default, skip_serializing_if = "is_false")] pub disabled: bool, @@ -83,10 +136,25 @@ impl RepoEntry { rev: None, rename: None, scripts: None, + targets: None, disabled: false, } } + /// Set the targets for this repo + pub fn with_targets(mut self, targets: Vec) -> Self { + self.targets = Some(targets); + self + } + + /// Check if this repo should be installed to a specific target + pub fn should_install_to(&self, target_name: &str) -> bool { + match &self.targets { + None => true, // Install to all targets if not specified + Some(targets) => targets.iter().any(|t| t == target_name), + } + } + /// Set the revision (branch/tag/commit) pub fn with_rev(mut self, rev: impl Into) -> Self { self.rev = Some(rev.into()); @@ -179,6 +247,28 @@ impl Config { pub fn enabled_repos(&self) -> impl Iterator { self.repos.iter().filter(|r| !r.disabled) } + + /// Get all enabled targets + pub fn enabled_targets(&self) -> impl Iterator { + self.targets.iter().filter(|t| t.enabled) + } + + /// Find a target by name + pub fn find_target(&self, name: &str) -> Option<&TargetConfig> { + self.targets.iter().find(|t| t.name == name) + } + + /// Add a target to the config + pub fn add_target(&mut self, target: TargetConfig) { + if !self.targets.iter().any(|t| t.name == target.name) { + self.targets.push(target); + } + } + + /// Check if config has any targets configured + pub fn has_targets(&self) -> bool { + !self.targets.is_empty() + } } #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index bddbccd..6eef852 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,6 @@ use clap::Parser; use colored::Colorize; -use mpv_mgr::{cli::Cli, commands, error::Result, paths::Paths}; +use mpv_mgr::{cli::Cli, commands, config::Config, error::Result, paths::{detect_mpv_configs, Paths}}; use std::io::{self, Write}; fn main() { @@ -49,14 +49,15 @@ fn run() -> Result<()> { Ok(()) } -/// Check if this is the first run and offer to import existing scripts +/// Check if this is the first run and set up targets fn check_first_run(command: &commands::Commands) -> Result<()> { - // Only check for status, list, or install commands (not add, import, etc.) + // Only check for certain commands let should_check = matches!( command, commands::Commands::Status | commands::Commands::List { .. } | commands::Commands::Install { .. } + | commands::Commands::Browse { .. } ); if !should_check { @@ -70,52 +71,124 @@ fn check_first_run(command: &commands::Commands) -> Result<()> { return Ok(()); } - // Check if there are existing scripts in mpv directory - if !paths.mpv_scripts_dir.exists() { + // Detect mpv config folders + let detected = detect_mpv_configs(); + + if detected.is_empty() { + // No mpv configs found - create default config + println!("{}", "Welcome to mpv-mgr!".green().bold()); + println!(); + println!("{}", "No mpv configuration folders detected.".yellow()); + println!("Creating default configuration..."); + + paths.ensure_directories()?; + Config::default().save(&paths.config_file)?; return Ok(()); } - let existing_scripts: Vec<_> = std::fs::read_dir(&paths.mpv_scripts_dir)? - .filter_map(|e| e.ok()) - .filter(|e| !e.file_name().to_string_lossy().starts_with('.')) - .collect(); - - if existing_scripts.is_empty() { - return Ok(()); - } - - // First run with existing scripts - offer to import + // Show welcome and detected targets println!("{}", "Welcome to mpv-mgr!".green().bold()); println!(); - println!( - "Detected {} existing script(s) in your mpv directory.", - existing_scripts.len().to_string().cyan() - ); + println!("{}", "Detected mpv configuration folders:".bold()); println!(); - print!("Would you like to scan and import them now? [Y/n] "); + + for (i, target) in detected.iter().enumerate() { + let script_info = if target.has_scripts { + format!("({} scripts)", target.script_count).cyan().to_string() + } else { + "(empty)".dimmed().to_string() + }; + + println!( + " {:2}. {} {} {}", + i + 1, + target.name.cyan(), + target.path.display().to_string().dimmed(), + script_info + ); + } + + println!(); + println!("{}", "Select targets to manage (e.g., 1 2 3 or 1-3 or 'all'):".bold()); + print!("> "); io::stdout().flush()?; let mut input = String::new(); io::stdin().read_line(&mut input)?; + let input = input.trim(); - if !input.trim().eq_ignore_ascii_case("n") { - println!(); - commands::import::execute()?; - println!(); + // Parse selection + let selected_indices = if input.eq_ignore_ascii_case("all") || input.is_empty() { + (0..detected.len()).collect::>() } else { - println!(); - println!( - "You can run {} later to import existing scripts.", - "mpv-mgr import".cyan() - ); - println!(); + parse_target_selection(input, detected.len()) + }; + + if selected_indices.is_empty() { + println!("{}", "No targets selected. Using all detected targets.".yellow()); } - // Create empty config to prevent showing welcome again - if !paths.config_file.exists() { - paths.ensure_directories()?; - mpv_mgr::config::Config::default().save(&paths.config_file)?; + // Create config with selected targets + let mut config = Config::default(); + + let indices_to_use = if selected_indices.is_empty() { + (0..detected.len()).collect::>() + } else { + selected_indices + }; + + for i in indices_to_use { + if let Some(target) = detected.get(i) { + config.add_target(target.to_target_config()); + println!(" {} {}", "✓".green(), target.name.cyan()); + } } + // Save config + paths.ensure_directories()?; + config.save(&paths.config_file)?; + + println!(); + println!( + "{} Configuration saved. Run {} to browse and add scripts.", + "Done!".green().bold(), + "mpv-mgr browse -i".cyan() + ); + println!(); + Ok(()) } + +/// Parse target selection input (e.g., "1 2 3", "1-3", "all") +fn parse_target_selection(input: &str, max: usize) -> Vec { + let mut result = Vec::new(); + + for part in input.split(|c| c == ',' || c == ' ') { + let part = part.trim(); + if part.is_empty() { + continue; + } + + if let Some((start, end)) = part.split_once('-') { + if let (Ok(s), Ok(e)) = (start.trim().parse::(), end.trim().parse::()) { + for i in s..=e { + if i >= 1 && i <= max { + let idx = i - 1; // Convert to 0-based + if !result.contains(&idx) { + result.push(idx); + } + } + } + } + } else if let Ok(n) = part.parse::() { + if n >= 1 && n <= max { + let idx = n - 1; // Convert to 0-based + if !result.contains(&idx) { + result.push(idx); + } + } + } + } + + result +} diff --git a/src/paths.rs b/src/paths.rs index 603fae9..f40f570 100644 --- a/src/paths.rs +++ b/src/paths.rs @@ -1,5 +1,6 @@ use std::path::PathBuf; +use crate::config::TargetConfig; use crate::error::{MpvMgrError, Result}; /// Manages all paths used by mpv-mgr following XDG conventions @@ -88,6 +89,104 @@ fn sanitize_repo_name(identifier: &str) -> String { .replace(':', "_") } +/// Known mpv configuration locations to auto-detect +const KNOWN_MPV_CONFIGS: &[(&str, &str)] = &[ + ("mpv", "mpv"), + ("jellyfin-mpv-shim", "jellyfin-mpv-shim"), + ("celluloid", "celluloid"), + ("gnome-mpv", "gnome-mpv"), +]; + +/// Flatpak mpv config locations +const FLATPAK_MPV_CONFIGS: &[(&str, &str)] = &[ + ("mpv-flatpak", ".var/app/io.mpv.Mpv/config/mpv"), +]; + +/// Detected mpv config folder +#[derive(Debug, Clone)] +pub struct DetectedTarget { + /// Display name for the target + pub name: String, + /// Path to the config folder + pub path: PathBuf, + /// Whether this folder has existing scripts + pub has_scripts: bool, + /// Number of existing scripts found + pub script_count: usize, +} + +impl DetectedTarget { + /// Convert to a TargetConfig + pub fn to_target_config(&self) -> TargetConfig { + TargetConfig::new(&self.name, &self.path) + } +} + +/// Detect all mpv configuration folders on the system +pub fn detect_mpv_configs() -> Vec { + let mut targets = Vec::new(); + + // Get config base directory + let config_base = match dirs::config_dir() { + Some(dir) => dir, + None => return targets, + }; + + // Check known XDG config locations + for (name, subdir) in KNOWN_MPV_CONFIGS { + let path = config_base.join(subdir); + if path.exists() && path.is_dir() { + let (has_scripts, script_count) = check_scripts_dir(&path); + targets.push(DetectedTarget { + name: name.to_string(), + path, + has_scripts, + script_count, + }); + } + } + + // Check Flatpak locations + if let Some(home) = dirs::home_dir() { + for (name, subdir) in FLATPAK_MPV_CONFIGS { + let path = home.join(subdir); + if path.exists() && path.is_dir() { + let (has_scripts, script_count) = check_scripts_dir(&path); + targets.push(DetectedTarget { + name: name.to_string(), + path, + has_scripts, + script_count, + }); + } + } + } + + targets +} + +/// Check if a mpv config folder has a scripts directory with scripts +fn check_scripts_dir(mpv_path: &PathBuf) -> (bool, usize) { + let scripts_dir = mpv_path.join("scripts"); + if !scripts_dir.exists() || !scripts_dir.is_dir() { + return (false, 0); + } + + let count = std::fs::read_dir(&scripts_dir) + .map(|entries| { + entries + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name().to_string_lossy().to_string(); + !name.starts_with('.') && (name.ends_with(".lua") || name.ends_with(".js") || e.path().is_dir()) + }) + .count() + }) + .unwrap_or(0); + + (count > 0, count) +} + #[cfg(test)] mod tests { use super::*;