feat(yarr): restructure into workspace with separate API and CLI crates
This commit is contained in:
50
yarr-cli/Cargo.toml
Normal file
50
yarr-cli/Cargo.toml
Normal file
@@ -0,0 +1,50 @@
|
||||
[package]
|
||||
name = "yarr"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
description = "A TUI client for Sonarr"
|
||||
authors = ["yarr contributors"]
|
||||
repository = "https://github.com/user/yarr"
|
||||
|
||||
[[bin]]
|
||||
name = "yarr"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
# API client library
|
||||
yarr-api = { path = "../yarr-api" }
|
||||
|
||||
# CLI dependencies
|
||||
clap = { version = "4.5", features = ["derive", "env"] }
|
||||
clap_complete = "4.5"
|
||||
|
||||
# Error handling
|
||||
error-stack = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
|
||||
# Async runtime
|
||||
tokio = { workspace = true }
|
||||
|
||||
# Logging
|
||||
tracing = { workspace = true }
|
||||
tracing-subscriber = { workspace = true }
|
||||
|
||||
# TUI dependencies
|
||||
ratatui = { version = "0.28", features = ["crossterm"] }
|
||||
crossterm = "0.28"
|
||||
|
||||
# Serialization
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
|
||||
# Date/time handling
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
|
||||
# Async utilities
|
||||
futures = "0.3"
|
||||
|
||||
# Configuration
|
||||
config = "0.14"
|
||||
toml = "0.8"
|
||||
dirs = "5.0"
|
||||
1
yarr-cli/sonarr.json
Normal file
1
yarr-cli/sonarr.json
Normal file
File diff suppressed because one or more lines are too long
93
yarr-cli/src/cli.rs
Normal file
93
yarr-cli/src/cli.rs
Normal file
@@ -0,0 +1,93 @@
|
||||
use clap::{Args, Parser, Subcommand};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Parser)]
|
||||
#[command(name = "yarr")]
|
||||
#[command(about = "A TUI for managing Sonarr")]
|
||||
#[command(version)]
|
||||
pub struct Cli {
|
||||
/// Path to config file
|
||||
#[arg(short, long, global = true)]
|
||||
pub config: Option<PathBuf>,
|
||||
|
||||
/// Sonarr URL (overrides config file)
|
||||
#[arg(long, global = true, env = "YARR_SONARR_URL")]
|
||||
pub sonarr_url: Option<String>,
|
||||
|
||||
/// Sonarr API key (overrides config file)
|
||||
#[arg(long, global = true, env = "YARR_SONARR_API_KEY")]
|
||||
pub sonarr_api_key: Option<String>,
|
||||
|
||||
#[command(subcommand)]
|
||||
pub command: Option<Commands>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum Commands {
|
||||
/// Add a new series
|
||||
Add(AddArgs),
|
||||
|
||||
/// List series
|
||||
List(ListArgs),
|
||||
|
||||
/// Start the TUI interface (default)
|
||||
Tui,
|
||||
|
||||
/// Generate shell completions
|
||||
Completions {
|
||||
/// The shell to generate completions for
|
||||
#[arg(value_enum)]
|
||||
shell: clap_complete::Shell,
|
||||
},
|
||||
|
||||
/// Configuration management
|
||||
Config(ConfigArgs),
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct AddArgs {
|
||||
/// Name of the series to add
|
||||
#[arg(short, long)]
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ListArgs {
|
||||
/// Show only monitored series
|
||||
#[arg(short, long)]
|
||||
pub monitored: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct ConfigArgs {
|
||||
#[command(subcommand)]
|
||||
pub action: ConfigAction,
|
||||
}
|
||||
|
||||
#[derive(Debug, Subcommand)]
|
||||
pub enum ConfigAction {
|
||||
/// Show current configuration
|
||||
Show,
|
||||
|
||||
/// Create a sample config file
|
||||
Init {
|
||||
/// Path where to create the config file
|
||||
#[arg(short, long)]
|
||||
path: Option<PathBuf>,
|
||||
},
|
||||
|
||||
/// Show possible config file locations
|
||||
Paths,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub fn generate_completions(shell: clap_complete::Shell) {
|
||||
let mut command = <Cli as clap::CommandFactory>::command();
|
||||
clap_complete::generate(
|
||||
shell,
|
||||
&mut command,
|
||||
env!("CARGO_BIN_NAME"),
|
||||
&mut std::io::stdout(),
|
||||
);
|
||||
}
|
||||
}
|
||||
219
yarr-cli/src/config.rs
Normal file
219
yarr-cli/src/config.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
use config::{Config, ConfigError, Environment, File};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct AppConfig {
|
||||
pub sonarr: SonarrConfig,
|
||||
pub ui: UiConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SonarrConfig {
|
||||
pub url: String,
|
||||
pub api_key: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct UiConfig {
|
||||
pub keybind_mode: KeybindMode,
|
||||
pub show_help: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum KeybindMode {
|
||||
Normal,
|
||||
Vim,
|
||||
}
|
||||
|
||||
impl Default for KeybindMode {
|
||||
fn default() -> Self {
|
||||
Self::Normal
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for UiConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
keybind_mode: KeybindMode::default(),
|
||||
show_help: true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for AppConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
sonarr: SonarrConfig {
|
||||
url: "http://localhost:8989".to_string(),
|
||||
api_key: String::new(),
|
||||
},
|
||||
ui: UiConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppConfig {
|
||||
/// Load configuration from various sources with the following priority:
|
||||
/// 1. Command line arguments (highest priority)
|
||||
/// 2. Environment variables
|
||||
/// 3. Config file
|
||||
/// 4. Default values (lowest priority)
|
||||
pub fn load(
|
||||
config_path: Option<PathBuf>,
|
||||
sonarr_url: Option<String>,
|
||||
sonarr_api_key: Option<String>,
|
||||
) -> Result<Self, ConfigError> {
|
||||
let mut builder = Config::builder();
|
||||
|
||||
// Start with default config
|
||||
builder = builder.add_source(Config::try_from(&AppConfig::default())?);
|
||||
|
||||
// Add config file if it exists
|
||||
if let Some(path) = config_path {
|
||||
if path.exists() {
|
||||
builder = builder.add_source(File::from(path));
|
||||
}
|
||||
} else {
|
||||
// Try to load from default locations
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
let yarr_config = config_dir.join("yarr").join("config.toml");
|
||||
if yarr_config.exists() {
|
||||
builder = builder.add_source(File::from(yarr_config));
|
||||
}
|
||||
}
|
||||
|
||||
// Also try current directory
|
||||
let local_config = std::env::current_dir()
|
||||
.map(|dir| dir.join("yarr.toml"))
|
||||
.unwrap_or_else(|_| PathBuf::from("yarr.toml"));
|
||||
|
||||
if local_config.exists() {
|
||||
builder = builder.add_source(File::from(local_config));
|
||||
}
|
||||
}
|
||||
|
||||
// Add environment variables with YARR_ prefix
|
||||
builder = builder.add_source(
|
||||
Environment::with_prefix("YARR")
|
||||
.try_parsing(true)
|
||||
.separator("_"),
|
||||
);
|
||||
|
||||
// Override with command line arguments if provided
|
||||
let mut config = builder.build()?.try_deserialize::<AppConfig>()?;
|
||||
|
||||
if let Some(url) = sonarr_url {
|
||||
config.sonarr.url = url;
|
||||
}
|
||||
|
||||
if let Some(api_key) = sonarr_api_key {
|
||||
config.sonarr.api_key = api_key;
|
||||
}
|
||||
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
/// Create a sample config file
|
||||
pub fn create_sample_config(path: &PathBuf) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let sample_config = AppConfig {
|
||||
sonarr: SonarrConfig {
|
||||
url: "http://localhost:8989".to_string(),
|
||||
api_key: "your-api-key-here".to_string(),
|
||||
},
|
||||
ui: UiConfig {
|
||||
keybind_mode: KeybindMode::Normal,
|
||||
show_help: true,
|
||||
},
|
||||
};
|
||||
|
||||
let toml_content = toml::to_string_pretty(&sample_config)?;
|
||||
|
||||
// Create directory if it doesn't exist
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
std::fs::write(path, toml_content)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Validate the configuration
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
if self.sonarr.url.is_empty() {
|
||||
return Err("Sonarr URL is required".to_string());
|
||||
}
|
||||
|
||||
if self.sonarr.api_key.is_empty() {
|
||||
return Err("Sonarr API key is required".to_string());
|
||||
}
|
||||
|
||||
// Validate URL format
|
||||
if !self.sonarr.url.starts_with("http://") && !self.sonarr.url.starts_with("https://") {
|
||||
return Err("Sonarr URL must start with http:// or https://".to_string());
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Get the default config file paths
|
||||
pub fn get_default_config_paths() -> Vec<PathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
// Current directory
|
||||
if let Ok(current_dir) = std::env::current_dir() {
|
||||
paths.push(current_dir.join("yarr.toml"));
|
||||
}
|
||||
|
||||
// User config directory
|
||||
if let Some(config_dir) = dirs::config_dir() {
|
||||
paths.push(config_dir.join("yarr").join("config.toml"));
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::env;
|
||||
|
||||
#[test]
|
||||
fn test_default_config() {
|
||||
let config = AppConfig::default();
|
||||
assert_eq!(config.sonarr.url, "http://localhost:8989");
|
||||
assert_eq!(config.sonarr.api_key, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_validation() {
|
||||
let mut config = AppConfig::default();
|
||||
|
||||
// Should fail validation with empty API key
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Should fail with invalid URL
|
||||
config.sonarr.api_key = "test-key".to_string();
|
||||
config.sonarr.url = "invalid-url".to_string();
|
||||
assert!(config.validate().is_err());
|
||||
|
||||
// Should pass with valid config
|
||||
config.sonarr.url = "https://example.com".to_string();
|
||||
assert!(config.validate().is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_config_override() {
|
||||
// Test that CLI args override config
|
||||
let config = AppConfig::load(
|
||||
None,
|
||||
Some("https://cli-url.com".to_string()),
|
||||
Some("cli-api-key".to_string()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(config.sonarr.url, "https://cli-url.com");
|
||||
assert_eq!(config.sonarr.api_key, "cli-api-key");
|
||||
}
|
||||
}
|
||||
170
yarr-cli/src/main.rs
Normal file
170
yarr-cli/src/main.rs
Normal file
@@ -0,0 +1,170 @@
|
||||
#[deny(warnings)]
|
||||
mod cli;
|
||||
mod config;
|
||||
mod tui;
|
||||
|
||||
use crate::config::AppConfig;
|
||||
use clap::Parser;
|
||||
use std::process;
|
||||
use yarr_api::SonarrClient;
|
||||
|
||||
#[tokio::main]
|
||||
pub async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
// Parse command line arguments
|
||||
let args = cli::Cli::parse();
|
||||
|
||||
// Handle config subcommands first
|
||||
if let Some(cli::Commands::Config(config_cmd)) = &args.command {
|
||||
handle_config_command(config_cmd, &args)?;
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Handle completions
|
||||
if let Some(cli::Commands::Completions { shell }) = &args.command {
|
||||
cli::Cli::generate_completions(*shell);
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
let config = match AppConfig::load(
|
||||
args.config.clone(),
|
||||
args.sonarr_url.clone(),
|
||||
args.sonarr_api_key.clone(),
|
||||
) {
|
||||
Ok(config) => config,
|
||||
Err(e) => {
|
||||
eprintln!("Error loading configuration: {}", e);
|
||||
eprintln!("Run 'yarr config init' to create a sample config file");
|
||||
process::exit(1);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate configuration
|
||||
if let Err(e) = config.validate() {
|
||||
eprintln!("Configuration error: {}", e);
|
||||
eprintln!("Run 'yarr config show' to see current configuration");
|
||||
eprintln!("Run 'yarr config init' to create a sample config file");
|
||||
process::exit(1);
|
||||
}
|
||||
|
||||
// Create Sonarr client
|
||||
let client = SonarrClient::new(config.sonarr.url.clone(), config.sonarr.api_key.clone());
|
||||
|
||||
// Handle different commands
|
||||
match args.command {
|
||||
Some(cli::Commands::Add(add_args)) => {
|
||||
handle_add_command(&client, add_args).await?;
|
||||
}
|
||||
Some(cli::Commands::List(list_args)) => {
|
||||
handle_list_command(&client, list_args).await?;
|
||||
}
|
||||
Some(cli::Commands::Tui) | None => {
|
||||
// Default to TUI mode
|
||||
tui::run_app(client, config).await?;
|
||||
}
|
||||
Some(cli::Commands::Completions { .. }) | Some(cli::Commands::Config(_)) => {
|
||||
// Already handled above
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_config_command(
|
||||
config_cmd: &cli::ConfigArgs,
|
||||
args: &cli::Cli,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
match &config_cmd.action {
|
||||
cli::ConfigAction::Show => {
|
||||
// Load and display current configuration
|
||||
let config = AppConfig::load(
|
||||
args.config.clone(),
|
||||
args.sonarr_url.clone(),
|
||||
args.sonarr_api_key.clone(),
|
||||
)?;
|
||||
println!("Current configuration:");
|
||||
println!("Sonarr URL: {}", config.sonarr.url);
|
||||
println!(
|
||||
"Sonarr API Key: {}",
|
||||
if config.sonarr.api_key.is_empty() {
|
||||
"(not set)"
|
||||
} else {
|
||||
"***masked***"
|
||||
}
|
||||
);
|
||||
}
|
||||
cli::ConfigAction::Init { path } => {
|
||||
let config_path = if let Some(path) = path {
|
||||
path.clone()
|
||||
} else {
|
||||
// Use default config location
|
||||
let paths = AppConfig::get_default_config_paths();
|
||||
if let Some(default_path) = paths.get(1) {
|
||||
// Prefer user config directory over current directory
|
||||
default_path.clone()
|
||||
} else {
|
||||
std::env::current_dir()?.join("yarr.toml")
|
||||
}
|
||||
};
|
||||
|
||||
AppConfig::create_sample_config(&config_path)?;
|
||||
println!("Sample configuration created at: {}", config_path.display());
|
||||
println!("Please edit the file to set your Sonarr URL and API key.");
|
||||
}
|
||||
cli::ConfigAction::Paths => {
|
||||
println!("Configuration file search order:");
|
||||
let paths = AppConfig::get_default_config_paths();
|
||||
for (i, path) in paths.iter().enumerate() {
|
||||
let exists = if path.exists() { " (exists)" } else { "" };
|
||||
println!(" {}. {}{}", i + 1, path.display(), exists);
|
||||
}
|
||||
println!("\nEnvironment variables:");
|
||||
println!(" YARR_SONARR_URL");
|
||||
println!(" YARR_SONARR_API_KEY");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_add_command(
|
||||
_client: &SonarrClient,
|
||||
_add_args: cli::AddArgs,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Add command not yet implemented");
|
||||
// TODO: Implement series addition
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_list_command(
|
||||
client: &SonarrClient,
|
||||
list_args: cli::ListArgs,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
println!("Fetching series list...");
|
||||
|
||||
match client.get_series().await {
|
||||
Ok(series_list) => {
|
||||
let filtered_series: Vec<_> = if list_args.monitored {
|
||||
series_list.into_iter().filter(|s| s.monitored).collect()
|
||||
} else {
|
||||
series_list
|
||||
};
|
||||
|
||||
if filtered_series.is_empty() {
|
||||
println!("No series found");
|
||||
} else {
|
||||
println!("\nSeries ({}):", filtered_series.len());
|
||||
for series in filtered_series {
|
||||
let status = if series.monitored { "✓" } else { "✗" };
|
||||
let title = series.title.as_deref().unwrap_or("Unknown");
|
||||
println!(" {} {} ({})", status, title, series.status);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Failed to fetch series: {}", e);
|
||||
process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
1659
yarr-cli/src/tui.rs
Normal file
1659
yarr-cli/src/tui.rs
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user