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:
@@ -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());
|
||||
|
||||
@@ -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)]
|
||||
|
||||
141
src/main.rs
141
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::<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
|
||||
}
|
||||
|
||||
99
src/paths.rs
99
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<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::*;
|
||||
|
||||
Reference in New Issue
Block a user