Commit e5ebcceee0b6
Changed files (8)
tools/arr/commands/lidarr_rename_albums.py
@@ -6,19 +6,13 @@ This script:
2. Checks which artists have albums with files that need renaming
3. Previews the rename changes for each album
4. Asks for confirmation before applying renames
-
-Usage:
- arr lidarr rename-albums <lidarr_url> <api_key>
-
-Example:
- arr lidarr rename-albums http://localhost:8686 your-api-key
"""
from typing import Any, Dict, List
from lib import (
ArrClient,
- create_arr_parser,
+ CommandContext,
get_confirmation_decision,
print_final_summary,
print_item_list,
@@ -52,14 +46,11 @@ def execute_rename(
return client.post("/api/v1/command", payload)
-def main():
- parser = create_arr_parser(
- "Lidarr", "Rename Lidarr albums with confirmation", 8686
- )
- args = parser.parse_args()
-
- # Create client
- client = ArrClient(args.lidarr_url, args.api_key)
+def run(url: str, api_key: str, dry_run: bool, no_confirm: bool):
+ """Execute the lidarr rename-albums command."""
+ # Create client and context
+ client = ArrClient(url, api_key)
+ ctx = CommandContext(dry_run, no_confirm)
print(f"Fetching artists from {client.base_url}...")
all_artists = client.get("/api/v1/artist")
@@ -133,7 +124,7 @@ def main():
f"\nRename {len(rename_preview)} {file_word} "
f"for '{artist_name}'?"
)
- should_rename = get_confirmation_decision(args, prompt)
+ should_rename = get_confirmation_decision(ctx, prompt)
if should_rename:
print("Executing rename...")
@@ -153,12 +144,12 @@ def main():
print("✗ No valid file IDs found")
skipped_count += 1
else:
- if not args.dry_run:
+ if not ctx.dry_run:
print("Skipped")
skipped_count += 1
# Final summary
- if args.dry_run:
+ if ctx.dry_run:
print_section_header("FINAL SUMMARY")
print(
f"\n[DRY RUN] Found {len(artists_with_renames)} artists "
@@ -172,7 +163,3 @@ def main():
skipped_count,
"Renamed",
)
-
-
-if __name__ == "__main__":
- main()
tools/arr/commands/lidarr_retag_albums.py
@@ -6,19 +6,13 @@ This script:
2. Checks which artists have albums with files that need retagging
3. Previews the retag changes for each album
4. Asks for confirmation before applying retags
-
-Usage:
- arr lidarr retag-albums <lidarr_url> <api_key>
-
-Example:
- arr lidarr retag-albums http://localhost:8686 your-api-key
"""
from typing import Any, Dict, List
from lib import (
ArrClient,
- create_arr_parser,
+ CommandContext,
get_confirmation_decision,
print_final_summary,
print_item_list,
@@ -64,14 +58,11 @@ def format_tag_changes(changes: List[Dict[str, Any]]) -> str:
return "\n".join(lines) if lines else " No changes"
-def main():
- parser = create_arr_parser(
- "Lidarr", "Retag Lidarr albums with confirmation", 8686
- )
- args = parser.parse_args()
-
- # Create client
- client = ArrClient(args.lidarr_url, args.api_key)
+def run(url: str, api_key: str, dry_run: bool, no_confirm: bool):
+ """Execute the lidarr retag-albums command."""
+ # Create client and context
+ client = ArrClient(url, api_key)
+ ctx = CommandContext(dry_run, no_confirm)
print(f"Fetching artists from {client.base_url}...")
all_artists = client.get("/api/v1/artist")
@@ -144,7 +135,7 @@ def main():
f"\nRetag {len(retag_preview)} {file_word} "
f"for '{artist_name}'?"
)
- should_retag = get_confirmation_decision(args, prompt)
+ should_retag = get_confirmation_decision(ctx, prompt)
if should_retag:
print("Executing retag...")
@@ -164,12 +155,12 @@ def main():
print("✗ No valid file IDs found")
skipped_count += 1
else:
- if not args.dry_run:
+ if not ctx.dry_run:
print("Skipped")
skipped_count += 1
# Final summary
- if args.dry_run:
+ if ctx.dry_run:
print_section_header("FINAL SUMMARY")
print(
f"\n[DRY RUN] Found {len(artists_with_retags)} artists "
@@ -183,7 +174,3 @@ def main():
skipped_count,
"Retagged",
)
-
-
-if __name__ == "__main__":
- main()
tools/arr/commands/lidarr_update_paths.py
@@ -7,15 +7,8 @@ This script:
contains 'library'
3. Updates paths that need to be moved to
<music_folder>/library/<artist>
-
-Usage:
- arr lidarr update-paths <lidarr_url> <api_key> <music_folder>
-
-Example:
- arr lidarr update-paths http://localhost:8686 your-api-key /data/music
"""
-import argparse
import sys
from pathlib import Path
from typing import Any, Dict, List
@@ -66,30 +59,15 @@ def update_artist_path(
return False
-def main():
- parser = argparse.ArgumentParser(
- description="Update Lidarr artist paths to use library subdirectory"
- )
- parser.add_argument(
- "lidarr_url", help="Lidarr base URL (e.g., http://localhost:8686)"
- )
- parser.add_argument("api_key", help="Lidarr API key")
- parser.add_argument("music_folder", help="Base music folder path")
- parser.add_argument(
- "--dry-run",
- action="store_true",
- help="Show what would be updated without making changes",
- )
-
- args = parser.parse_args()
-
+def run(url: str, api_key: str, music_folder: str, dry_run: bool):
+ """Execute the lidarr update-paths command."""
# Normalize URLs and paths
- base_url = args.lidarr_url.rstrip("/")
- music_folder = Path(args.music_folder).resolve()
- library_folder = music_folder / "library"
+ base_url = url.rstrip("/")
+ music_folder_path = Path(music_folder).resolve()
+ library_folder = music_folder_path / "library"
print(f"Fetching artists from {base_url}...")
- artists = get_all_artists(base_url, args.api_key)
+ artists = get_all_artists(base_url, api_key)
print(f"Found {len(artists)} artists\n")
needs_update = []
@@ -103,7 +81,7 @@ def main():
artist_id = artist.get("id")
# Check if path is directly in music_folder
- if current_path.parent == music_folder:
+ if current_path.parent == music_folder_path:
new_path = library_folder / current_path.name
needs_update.append(
(artist_id, artist_name, current_path, new_path)
@@ -143,7 +121,7 @@ def main():
print(f" ... and {len(unknown_location) - 5} more")
# Perform updates
- if needs_update and not args.dry_run:
+ if needs_update and not dry_run:
print(f"\n{'=' * 80}")
print("UPDATING PATHS")
print("=" * 80)
@@ -154,7 +132,7 @@ def main():
for artist_id, name, old_path, new_path in needs_update:
print(f"\nUpdating {name}...", end=" ")
success = update_artist_path(
- base_url, args.api_key, artist_id, str(new_path)
+ base_url, api_key, artist_id, str(new_path)
)
if success:
print("✓ SUCCESS")
@@ -166,14 +144,10 @@ def main():
print(f"\n{'=' * 80}")
print(f"Results: {success_count} updated, {fail_count} failed")
print("=" * 80)
- elif needs_update and args.dry_run:
+ elif needs_update and dry_run:
print(
"\n[DRY RUN] No changes were made. "
"Remove --dry-run to apply updates."
)
else:
print("\nNo artists need updating!")
-
-
-if __name__ == "__main__":
- main()
tools/arr/commands/radarr_rename.py
@@ -6,19 +6,13 @@ This script:
2. Checks which movies have files that need renaming
3. Previews the rename changes for each movie
4. Asks for confirmation before applying renames
-
-Usage:
- arr radarr rename <radarr_url> <api_key>
-
-Example:
- arr radarr rename http://localhost:7878 your-api-key
"""
from typing import Any, Dict, List
from lib import (
ArrClient,
- create_arr_parser,
+ CommandContext,
get_confirmation_decision,
print_final_summary,
print_item_list,
@@ -39,14 +33,11 @@ def execute_rename(client: ArrClient, movie_id: int) -> Dict[str, Any]:
return client.post("/api/v3/command", payload)
-def main():
- parser = create_arr_parser(
- "Radarr", "Rename Radarr movies with confirmation", 7878
- )
- args = parser.parse_args()
-
- # Create client
- client = ArrClient(args.radarr_url, args.api_key)
+def run(url: str, api_key: str, dry_run: bool, no_confirm: bool):
+ """Execute the radarr rename command."""
+ # Create client and context
+ client = ArrClient(url, api_key)
+ ctx = CommandContext(dry_run, no_confirm)
print(f"Fetching movies from {client.base_url}...")
all_movies = client.get("/api/v3/movie")
@@ -112,7 +103,7 @@ def main():
f"\nRename {len(rename_preview)} {file_word} "
f"for '{movie_title}'?"
)
- should_rename = get_confirmation_decision(args, prompt)
+ should_rename = get_confirmation_decision(ctx, prompt)
if should_rename:
print("Executing rename...")
@@ -124,12 +115,12 @@ def main():
print("✗ Failed to queue rename command")
skipped_count += 1
else:
- if not args.dry_run:
+ if not ctx.dry_run:
print("Skipped")
skipped_count += 1
# Final summary
- if args.dry_run:
+ if ctx.dry_run:
print_section_header("FINAL SUMMARY")
print(
f"\n[DRY RUN] Found {len(movies_with_renames)} movies "
@@ -143,7 +134,3 @@ def main():
skipped_count,
"Renamed",
)
-
-
-if __name__ == "__main__":
- main()
tools/arr/commands/sonarr_rename.py
@@ -6,19 +6,13 @@ This script:
2. Checks which series have episodes that need renaming
3. Previews the rename changes for each series
4. Asks for confirmation before applying renames
-
-Usage:
- arr sonarr rename <sonarr_url> <api_key>
-
-Example:
- arr sonarr rename http://localhost:8989 your-api-key
"""
from typing import Any, Dict, List
from lib import (
ArrClient,
- create_arr_parser,
+ CommandContext,
get_confirmation_decision,
print_final_summary,
print_item_list,
@@ -45,14 +39,11 @@ def execute_rename(
return client.post("/api/v3/command", payload)
-def main():
- parser = create_arr_parser(
- "Sonarr", "Rename Sonarr series episodes with confirmation", 8989
- )
- args = parser.parse_args()
-
- # Create client
- client = ArrClient(args.sonarr_url, args.api_key)
+def run(url: str, api_key: str, dry_run: bool, no_confirm: bool):
+ """Execute the sonarr rename command."""
+ # Create client and context
+ client = ArrClient(url, api_key)
+ ctx = CommandContext(dry_run, no_confirm)
print(f"Fetching series from {client.base_url}...")
all_series = client.get("/api/v3/series")
@@ -118,7 +109,7 @@ def main():
f"\nRename {len(rename_preview)} episodes "
f"for '{series_title}'?"
)
- should_rename = get_confirmation_decision(args, prompt)
+ should_rename = get_confirmation_decision(ctx, prompt)
if should_rename:
print("Executing rename...")
@@ -138,12 +129,12 @@ def main():
print("✗ No valid file IDs found")
skipped_count += 1
else:
- if not args.dry_run:
+ if not ctx.dry_run:
print("Skipped")
skipped_count += 1
# Final summary
- if args.dry_run:
+ if ctx.dry_run:
print_section_header("FINAL SUMMARY")
print(
f"\n[DRY RUN] Found {len(series_with_renames)} series "
@@ -157,7 +148,3 @@ def main():
skipped_count,
"Renamed",
)
-
-
-if __name__ == "__main__":
- main()
tools/arr/arr
@@ -4,114 +4,125 @@ arr - Unified CLI for managing *arr services (Sonarr, Radarr, Lidarr).
This tool provides a consistent interface for common operations across
the *arr media management stack.
-
-Usage:
- arr <service> <command> [options]
-
-Services:
- sonarr Manage Sonarr TV series
- radarr Manage Radarr movies
- lidarr Manage Lidarr music
-
-Commands vary by service. Use 'arr <service> --help' for details.
-
-Examples:
- arr sonarr rename http://localhost:8989 your-api-key
- arr radarr rename http://localhost:7878 your-api-key --dry-run
- arr lidarr rename-albums http://localhost:8686 your-api-key
- arr lidarr retag-albums http://localhost:8686 your-api-key
- arr lidarr update-paths http://localhost:8686 your-api-key /old /new
"""
import sys
from pathlib import Path
+import click
+
# Add the arr package directory to Python path
ARR_DIR = Path(__file__).parent.resolve()
sys.path.insert(0, str(ARR_DIR))
-def print_usage():
- """Print usage information."""
- print(__doc__)
- sys.exit(1)
+@click.group()
+def cli():
+ """Unified CLI for managing *arr services (Sonarr, Radarr, Lidarr).
+
+ This tool provides a consistent interface for common operations across
+ the *arr media management stack.
+ """
+ pass
-def main():
- """Main entry point for the arr CLI."""
- if len(sys.argv) < 2:
- print_usage()
+@cli.group()
+def sonarr():
+ """Manage Sonarr TV series."""
+ pass
- service = sys.argv[1].lower()
- # Map services to their command modules
- if service == "sonarr":
- if len(sys.argv) < 3:
- print("Usage: arr sonarr <command> [options]")
- print("\nCommands:")
- print(" rename Rename series episodes")
- sys.exit(1)
+@cli.group()
+def radarr():
+ """Manage Radarr movies."""
+ pass
- command = sys.argv[2].lower()
- if command == "rename":
- # Remove 'sonarr rename' from argv and run the script
- sys.argv = [sys.argv[0]] + sys.argv[3:]
- from commands import sonarr_rename
- sonarr_rename.main()
- else:
- print(f"Unknown sonarr command: {command}")
- sys.exit(1)
- elif service == "radarr":
- if len(sys.argv) < 3:
- print("Usage: arr radarr <command> [options]")
- print("\nCommands:")
- print(" rename Rename movies")
- sys.exit(1)
+@cli.group()
+def lidarr():
+ """Manage Lidarr music."""
+ pass
- command = sys.argv[2].lower()
- if command == "rename":
- sys.argv = [sys.argv[0]] + sys.argv[3:]
- from commands import radarr_rename
- radarr_rename.main()
- else:
- print(f"Unknown radarr command: {command}")
- sys.exit(1)
- elif service == "lidarr":
- if len(sys.argv) < 3:
- print("Usage: arr lidarr <command> [options]")
- print("\nCommands:")
- print(" rename-albums Rename albums")
- print(" retag-albums Retag albums metadata")
- print(" update-paths Update library paths")
- sys.exit(1)
+@sonarr.command()
+@click.argument("url")
+@click.argument("api_key")
+@click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes")
+@click.option("--no-confirm", "--yolo", is_flag=True, help="Skip interactive confirmation (use with caution)")
+def rename(url, api_key, dry_run, no_confirm):
+ """Rename series episodes.
- command = sys.argv[2].lower()
- if command == "rename-albums":
- sys.argv = [sys.argv[0]] + sys.argv[3:]
- from commands import lidarr_rename_albums
- lidarr_rename_albums.main()
- elif command == "retag-albums":
- sys.argv = [sys.argv[0]] + sys.argv[3:]
- from commands import lidarr_retag_albums
- lidarr_retag_albums.main()
- elif command == "update-paths":
- sys.argv = [sys.argv[0]] + sys.argv[3:]
- from commands import lidarr_update_paths
- lidarr_update_paths.main()
- else:
- print(f"Unknown lidarr command: {command}")
- sys.exit(1)
+ Examples:
+ arr sonarr rename http://localhost:8989 your-api-key
+ arr sonarr rename http://localhost:8989 your-api-key --dry-run
+ """
+ from commands import sonarr_rename
+ sonarr_rename.run(url, api_key, dry_run, no_confirm)
- elif service in ["-h", "--help", "help"]:
- print_usage()
- else:
- print(f"Unknown service: {service}")
- print("\nAvailable services: sonarr, radarr, lidarr")
- sys.exit(1)
+@radarr.command()
+@click.argument("url")
+@click.argument("api_key")
+@click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes")
+@click.option("--no-confirm", "--yolo", is_flag=True, help="Skip interactive confirmation (use with caution)")
+def rename(url, api_key, dry_run, no_confirm):
+ """Rename movies.
+
+ Examples:
+ arr radarr rename http://localhost:7878 your-api-key
+ arr radarr rename http://localhost:7878 your-api-key --dry-run
+ """
+ from commands import radarr_rename
+ radarr_rename.run(url, api_key, dry_run, no_confirm)
+
+
+@lidarr.command("rename-albums")
+@click.argument("url")
+@click.argument("api_key")
+@click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes")
+@click.option("--no-confirm", "--yolo", is_flag=True, help="Skip interactive confirmation (use with caution)")
+def lidarr_rename_albums(url, api_key, dry_run, no_confirm):
+ """Rename albums.
+
+ Examples:
+ arr lidarr rename-albums http://localhost:8686 your-api-key
+ arr lidarr rename-albums http://localhost:8686 your-api-key --dry-run
+ """
+ from commands import lidarr_rename_albums
+ lidarr_rename_albums.run(url, api_key, dry_run, no_confirm)
+
+
+@lidarr.command("retag-albums")
+@click.argument("url")
+@click.argument("api_key")
+@click.option("--dry-run", is_flag=True, help="Show what would be changed without making changes")
+@click.option("--no-confirm", "--yolo", is_flag=True, help="Skip interactive confirmation (use with caution)")
+def lidarr_retag_albums(url, api_key, dry_run, no_confirm):
+ """Retag albums metadata.
+
+ Examples:
+ arr lidarr retag-albums http://localhost:8686 your-api-key
+ arr lidarr retag-albums http://localhost:8686 your-api-key --dry-run
+ """
+ from commands import lidarr_retag_albums
+ lidarr_retag_albums.run(url, api_key, dry_run, no_confirm)
+
+
+@lidarr.command("update-paths")
+@click.argument("url")
+@click.argument("api_key")
+@click.argument("music_folder")
+@click.option("--dry-run", is_flag=True, help="Show what would be updated without making changes")
+def lidarr_update_paths(url, api_key, music_folder, dry_run):
+ """Update library paths.
+
+ Examples:
+ arr lidarr update-paths http://localhost:8686 your-api-key /data/music
+ arr lidarr update-paths http://localhost:8686 your-api-key /data/music --dry-run
+ """
+ from commands import lidarr_update_paths
+ lidarr_update_paths.run(url, api_key, music_folder, dry_run)
if __name__ == "__main__":
- main()
+ cli()
tools/arr/default.nix
@@ -14,6 +14,7 @@ python3.pkgs.buildPythonApplication {
nativeBuildInputs = [ makeWrapper ];
propagatedBuildInputs = with python3.pkgs; [
+ click
requests
];
tools/arr/lib.py
@@ -6,7 +6,6 @@ Provides common functionality for API interaction, user confirmation,
and output formatting across all *arr stack scripts.
"""
-import argparse
import sys
from typing import Any, Dict, List, Optional
@@ -102,43 +101,19 @@ def ask_confirmation(prompt: str) -> bool:
print("Please answer 'y' or 'n'")
-def create_arr_parser(
- service_name: str, description: str, default_port: int
-) -> argparse.ArgumentParser:
- """
- Create a standard argument parser for *arr scripts.
+class CommandContext:
+ """Context object for command execution with common options."""
- Args:
- service_name: Name of the service (e.g., "Sonarr", "Radarr")
- description: Description for the script
- default_port: Default port for the service
+ def __init__(self, dry_run: bool = False, no_confirm: bool = False):
+ """
+ Initialize command context.
- Returns:
- Configured ArgumentParser instance
- """
- parser = argparse.ArgumentParser(description=description)
- parser.add_argument(
- f"{service_name.lower()}_url",
- metavar="url",
- help=(
- f"{service_name} base URL "
- f"(e.g., http://localhost:{default_port})"
- ),
- )
- parser.add_argument("api_key", help=f"{service_name} API key")
- parser.add_argument(
- "--dry-run",
- action="store_true",
- help="Show what would be changed without making changes",
- )
- parser.add_argument(
- "--no-confirm",
- "--yolo",
- action="store_true",
- dest="no_confirm",
- help="Skip interactive confirmation (use with caution)",
- )
- return parser
+ Args:
+ dry_run: If True, show changes without applying them
+ no_confirm: If True, skip interactive confirmations
+ """
+ self.dry_run = dry_run
+ self.no_confirm = no_confirm
def print_separator(char: str = "=", width: int = 80) -> None:
@@ -177,22 +152,22 @@ def print_item_list(
def get_confirmation_decision(
- args: argparse.Namespace, prompt: str
+ ctx: CommandContext, prompt: str
) -> bool:
"""
Determine whether to proceed based on dry-run, no-confirm, or user input.
Args:
- args: Parsed command-line arguments
+ ctx: Command context with dry_run and no_confirm flags
prompt: Confirmation prompt to show user
Returns:
True if should proceed, False otherwise
"""
- if args.dry_run:
+ if ctx.dry_run:
print("\n[DRY RUN] Skipping actual operation")
return False
- elif args.no_confirm:
+ elif ctx.no_confirm:
print("\n[NO CONFIRM] Proceeding with operation...")
return True
else: