Commit e5ebcceee0b6

Vincent Demeester <vincent@sbr.pm>
2025-11-28 12:26:16
refactor(arr): Add comprehensive --help support with click
- Enable built-in help at all command levels for better discoverability - Simplify CLI implementation by replacing manual argparse with click - Reduce codebase complexity while improving user experience Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent aae05dc
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: