Add multi-target mpv config support (Phase 1)

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 <noreply@anthropic.com>
This commit is contained in:
2025-12-15 01:18:57 +01:00
parent 1aa806ed90
commit b2b2f7b71e
4 changed files with 378 additions and 76 deletions

View File

@@ -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<Vec<String>>) -> 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<Vec<String>>) -> 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<Vec<String>>) -> 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());

View File

@@ -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<String>, path: impl Into<PathBuf>) -> 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<TargetConfig>,
/// List of repositories to manage
#[serde(default)]
pub repos: Vec<RepoEntry>,
@@ -66,6 +115,10 @@ pub struct RepoEntry {
#[serde(skip_serializing_if = "Option::is_none")]
pub scripts: Option<Vec<String>>,
/// Optional: install only to specific targets (None = all enabled targets)
#[serde(skip_serializing_if = "Option::is_none")]
pub targets: Option<Vec<String>>,
/// 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<String>) -> 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<String>) -> Self {
self.rev = Some(rev.into());
@@ -179,6 +247,28 @@ impl Config {
pub fn enabled_repos(&self) -> impl Iterator<Item = &RepoEntry> {
self.repos.iter().filter(|r| !r.disabled)
}
/// Get all enabled targets
pub fn enabled_targets(&self) -> impl Iterator<Item = &TargetConfig> {
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)]

View File

@@ -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::<Vec<_>>()
} 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::<Vec<_>>()
} 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<usize> {
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::<usize>(), end.trim().parse::<usize>()) {
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::<usize>() {
if n >= 1 && n <= max {
let idx = n - 1; // Convert to 0-based
if !result.contains(&idx) {
result.push(idx);
}
}
}
}
result
}

View File

@@ -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<DetectedTarget> {
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::*;