Commit 0f5b1207db22

Vincent Demeester <vincent@sbr.pm>
2025-11-23 22:06:24
refactor(tools): Add shared library for *arr scripts and make targets
- Reduce code duplication by 21% with shared arrlib.py module - Add make targets with passage integration for convenient automation - Enable flexible override of URLs and arguments via EXTRA_ARGS Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent ee47258
tools/arrlib.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""
+Shared library for *arr (Sonarr, Radarr, Lidarr) automation scripts.
+
+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
+
+import requests
+
+
+class ArrClient:
+    """Base client for *arr API interactions."""
+
+    def __init__(self, base_url: str, api_key: str):
+        """
+        Initialize the *arr API client.
+
+        Args:
+            base_url: Base URL of the *arr service
+                      (e.g., http://localhost:8989)
+            api_key: API key for authentication
+        """
+        self.base_url = base_url.rstrip("/")
+        self.api_key = api_key
+        self.headers = {"X-Api-Key": api_key}
+
+    def get(
+        self, endpoint: str, params: Optional[Dict[str, Any]] = None
+    ) -> List[Dict[str, Any]] | Dict[str, Any]:
+        """
+        Make a GET request to the *arr API.
+
+        Args:
+            endpoint: API endpoint path (e.g., /api/v3/series)
+            params: Optional query parameters
+
+        Returns:
+            JSON response data
+
+        Raises:
+            SystemExit: If the request fails
+        """
+        url = f"{self.base_url}{endpoint}"
+
+        try:
+            response = requests.get(url, headers=self.headers, params=params)
+            response.raise_for_status()
+            return response.json()
+        except requests.exceptions.RequestException as e:
+            print(f"Error fetching from {endpoint}: {e}", file=sys.stderr)
+            if params:
+                print(f"  Params: {params}", file=sys.stderr)
+            sys.exit(1)
+
+    def post(
+        self, endpoint: str, payload: Dict[str, Any]
+    ) -> Dict[str, Any]:
+        """
+        Make a POST request to the *arr API.
+
+        Args:
+            endpoint: API endpoint path (e.g., /api/v3/command)
+            payload: JSON payload to send
+
+        Returns:
+            JSON response data (empty dict on failure)
+        """
+        url = f"{self.base_url}{endpoint}"
+        headers = {**self.headers, "Content-Type": "application/json"}
+
+        try:
+            response = requests.post(url, headers=headers, json=payload)
+            response.raise_for_status()
+            return response.json()
+        except requests.exceptions.RequestException as e:
+            print(f"Error posting to {endpoint}: {e}", file=sys.stderr)
+            return {}
+
+
+def ask_confirmation(prompt: str) -> bool:
+    """
+    Ask user for yes/no confirmation.
+
+    Args:
+        prompt: Question to ask the user
+
+    Returns:
+        True if user confirms (y/yes), False otherwise (n/no)
+    """
+    while True:
+        response = input(f"{prompt} (y/n): ").lower().strip()
+        if response in ["y", "yes"]:
+            return True
+        elif response in ["n", "no"]:
+            return False
+        else:
+            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.
+
+    Args:
+        service_name: Name of the service (e.g., "Sonarr", "Radarr")
+        description: Description for the script
+        default_port: Default port for the service
+
+    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
+
+
+def print_separator(char: str = "=", width: int = 80) -> None:
+    """Print a separator line."""
+    print(char * width)
+
+
+def print_section_header(title: str) -> None:
+    """Print a section header with separators."""
+    print("\n" + "=" * 80)
+    print(title)
+    print("=" * 80)
+
+
+def print_item_list(
+    items: List[str], prefix: str, max_display: int = 5
+) -> None:
+    """
+    Print a list of items with optional truncation.
+
+    Args:
+        items: List of item names to display
+        prefix: Prefix message to show before the list
+        max_display: Maximum number of items to show before truncating
+    """
+    if not items:
+        return
+
+    count = len(items)
+    print(f"\n{prefix} ({count} items):")
+    for item in items[:max_display]:
+        print(f"  - {item}")
+    if len(items) > max_display:
+        remaining = len(items) - max_display
+        print(f"  ... and {remaining} more")
+
+
+def get_confirmation_decision(
+    args: argparse.Namespace, prompt: str
+) -> bool:
+    """
+    Determine whether to proceed based on dry-run, no-confirm, or user input.
+
+    Args:
+        args: Parsed command-line arguments
+        prompt: Confirmation prompt to show user
+
+    Returns:
+        True if should proceed, False otherwise
+    """
+    if args.dry_run:
+        print("\n[DRY RUN] Skipping actual operation")
+        return False
+    elif args.no_confirm:
+        print("\n[NO CONFIRM] Proceeding with operation...")
+        return True
+    else:
+        return ask_confirmation(prompt)
+
+
+def print_final_summary(
+    total: int,
+    processed: int,
+    skipped: int,
+    operation: str,
+    queue_note: bool = True,
+) -> None:
+    """
+    Print final summary of operations.
+
+    Args:
+        total: Total items that needed processing
+        processed: Number of items successfully processed
+        skipped: Number of items skipped
+        operation: Name of the operation (e.g., "Renamed", "Retagged")
+        queue_note: Whether to show the queue check note
+    """
+    print_section_header("FINAL SUMMARY")
+    print(f"\nItems processed: {total}")
+    print(f"  - {operation}: {processed}")
+    print(f"  - Skipped: {skipped}")
+
+    if processed > 0 and queue_note:
+        print(
+            f"\nNote: {operation} operations are queued. "
+            "Check the service's queue for progress."
+        )
tools/lidarr-rename-albums.py
@@ -20,131 +20,55 @@ Example:
     ./lidarr-rename-albums.py http://localhost:8686 your-api-key
 """
 
-import argparse
-import sys
 from typing import Any, Dict, List
 
-import requests
-
-
-def get_all_artists(base_url: str, api_key: str) -> List[Dict[str, Any]]:
-    """Fetch all artists from Lidarr API."""
-    url = f"{base_url}/api/v1/artist"
-    headers = {"X-Api-Key": api_key}
-
-    try:
-        response = requests.get(url, headers=headers)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(f"Error fetching artists: {e}", file=sys.stderr)
-        sys.exit(1)
+from arrlib import (
+    ArrClient,
+    create_arr_parser,
+    get_confirmation_decision,
+    print_final_summary,
+    print_item_list,
+    print_section_header,
+)
 
 
 def get_artist_albums(
-    base_url: str, api_key: str, artist_id: int
+    client: ArrClient, artist_id: int
 ) -> List[Dict[str, Any]]:
     """Fetch all albums for a specific artist."""
-    url = f"{base_url}/api/v1/album"
-    headers = {"X-Api-Key": api_key}
-    params = {"artistId": artist_id}
-
-    try:
-        response = requests.get(url, headers=headers, params=params)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error fetching albums for artist {artist_id}: {e}",
-            file=sys.stderr,
-        )
-        return []
+    return client.get("/api/v1/album", params={"artistId": artist_id})
 
 
 def get_rename_preview(
-    base_url: str, api_key: str, artist_id: int
+    client: ArrClient, artist_id: int
 ) -> List[Dict[str, Any]]:
     """Get preview of files that will be renamed for an artist."""
-    url = f"{base_url}/api/v1/rename"
-    headers = {"X-Api-Key": api_key}
-    params = {"artistId": artist_id}
-
-    try:
-        response = requests.get(url, headers=headers, params=params)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error fetching rename preview for artist {artist_id}: {e}",
-            file=sys.stderr,
-        )
-        return []
+    return client.get("/api/v1/rename", params={"artistId": artist_id})
 
 
 def execute_rename(
-    base_url: str, api_key: str, artist_id: int, file_ids: List[int]
+    client: ArrClient, artist_id: int, file_ids: List[int]
 ) -> Dict[str, Any]:
     """Execute rename operation for an artist."""
-    url = f"{base_url}/api/v1/command"
-    headers = {"X-Api-Key": api_key, "Content-Type": "application/json"}
     payload = {
         "name": "RenameFiles",
         "artistId": artist_id,
         "files": file_ids,
     }
-
-    try:
-        response = requests.post(url, headers=headers, json=payload)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error executing rename for artist {artist_id}: {e}",
-            file=sys.stderr,
-        )
-        return {}
-
-
-def ask_confirmation(prompt: str) -> bool:
-    """Ask user for yes/no confirmation."""
-    while True:
-        response = input(f"{prompt} (y/n): ").lower().strip()
-        if response in ["y", "yes"]:
-            return True
-        elif response in ["n", "no"]:
-            return False
-        else:
-            print("Please answer 'y' or 'n'")
+    return client.post("/api/v1/command", payload)
 
 
 def main():
-    parser = argparse.ArgumentParser(
-        description="Rename Lidarr albums with confirmation"
+    parser = create_arr_parser(
+        "Lidarr", "Rename Lidarr albums with confirmation", 8686
     )
-    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(
-        "--dry-run",
-        action="store_true",
-        help="Show what would be renamed without making changes",
-    )
-    parser.add_argument(
-        "--no-confirm",
-        "--yolo",
-        action="store_true",
-        dest="no_confirm",
-        help="Skip interactive confirmation (use with caution)",
-    )
-
     args = parser.parse_args()
 
-    # Normalize URLs
-    base_url = args.lidarr_url.rstrip("/")
+    # Create client
+    client = ArrClient(args.lidarr_url, args.api_key)
 
-    print(f"Fetching artists from {base_url}...")
-    all_artists = get_all_artists(base_url, args.api_key)
+    print(f"Fetching artists from {client.base_url}...")
+    all_artists = client.get("/api/v1/artist")
     print(f"Found {len(all_artists)} artists\n")
 
     artists_with_renames = []
@@ -156,13 +80,11 @@ def main():
         artist_id = artist.get("id")
         artist_name = artist.get("artistName", "Unknown")
 
-        rename_preview = get_rename_preview(
-            base_url, args.api_key, artist_id
-        )
+        rename_preview = get_rename_preview(client, artist_id)
 
         if rename_preview:
             # Get album count for this artist
-            albums = get_artist_albums(base_url, args.api_key, artist_id)
+            albums = get_artist_albums(client, artist_id)
             album_count = len(albums) if albums else 0
 
             artists_with_renames.append(
@@ -172,17 +94,8 @@ def main():
             artists_without_renames.append(artist_name)
 
     # Print summary
-    print("\n" + "=" * 80)
-    print("SUMMARY")
-    print("=" * 80)
-
-    if artists_without_renames:
-        count = len(artists_without_renames)
-        print(f"\n✓ No renames needed ({count} artists):")
-        for name in artists_without_renames[:5]:  # Show first 5
-            print(f"  - {name}")
-        if len(artists_without_renames) > 5:
-            print(f"  ... and {len(artists_without_renames) - 5} more")
+    print_section_header("SUMMARY")
+    print_item_list(artists_without_renames, "✓ No renames needed")
 
     if artists_with_renames:
         count = len(artists_with_renames)
@@ -193,9 +106,7 @@ def main():
         return
 
     # Process each artist that needs renaming
-    print("\n" + "=" * 80)
-    print("RENAME PREVIEW")
-    print("=" * 80)
+    print_section_header("RENAME PREVIEW")
 
     renamed_count = 0
     skipped_count = 0
@@ -222,32 +133,22 @@ def main():
             remaining = len(rename_preview) - display_limit
             print(f"\n  ... and {remaining} more files")
 
-        # Ask for confirmation (unless in dry-run or no-confirm mode)
-        should_rename = False
+        # Ask for confirmation
+        file_word = "file" if len(rename_preview) == 1 else "files"
+        prompt = (
+            f"\nRename {len(rename_preview)} {file_word} "
+            f"for '{artist_name}'?"
+        )
+        should_rename = get_confirmation_decision(args, prompt)
 
-        if args.dry_run:
-            print("\n[DRY RUN] Skipping actual rename")
-        elif args.no_confirm:
-            should_rename = True
-            print("\n[NO CONFIRM] Proceeding with rename...")
-        else:
-            file_word = "file" if len(rename_preview) == 1 else "files"
-            prompt = (
-                f"\nRename {len(rename_preview)} {file_word} "
-                f"for '{artist_name}'?"
-            )
-            should_rename = ask_confirmation(prompt)
-
-        if should_rename and not args.dry_run:
+        if should_rename:
             print("Executing rename...")
             # Extract track file IDs from preview
             file_ids = [item.get("trackFileId") for item in rename_preview]
             file_ids = [fid for fid in file_ids if fid is not None]
 
             if file_ids:
-                result = execute_rename(
-                    base_url, args.api_key, artist_id, file_ids
-                )
+                result = execute_rename(client, artist_id, file_ids)
                 if result:
                     print("✓ Rename command queued successfully")
                     renamed_count += 1
@@ -263,26 +164,20 @@ def main():
             skipped_count += 1
 
     # Final summary
-    print("\n" + "=" * 80)
-    print("FINAL SUMMARY")
-    print("=" * 80)
-
     if args.dry_run:
+        print_section_header("FINAL SUMMARY")
         print(
             f"\n[DRY RUN] Found {len(artists_with_renames)} artists "
             "that need renaming"
         )
         print("No changes were made. Remove --dry-run to apply renames.")
     else:
-        print(f"\nArtists processed: {len(artists_with_renames)}")
-        print(f"  - Renamed: {renamed_count}")
-        print(f"  - Skipped: {skipped_count}")
-
-        if renamed_count > 0:
-            print(
-                "\nNote: Rename operations are queued. "
-                "Check Lidarr's queue for progress."
-            )
+        print_final_summary(
+            len(artists_with_renames),
+            renamed_count,
+            skipped_count,
+            "Renamed",
+        )
 
 
 if __name__ == "__main__":
tools/lidarr-retag-albums.py
@@ -20,101 +20,42 @@ Example:
     ./lidarr-retag-albums.py http://localhost:8686 your-api-key
 """
 
-import argparse
-import sys
 from typing import Any, Dict, List
 
-import requests
-
-
-def get_all_artists(base_url: str, api_key: str) -> List[Dict[str, Any]]:
-    """Fetch all artists from Lidarr API."""
-    url = f"{base_url}/api/v1/artist"
-    headers = {"X-Api-Key": api_key}
-
-    try:
-        response = requests.get(url, headers=headers)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(f"Error fetching artists: {e}", file=sys.stderr)
-        sys.exit(1)
+from arrlib import (
+    ArrClient,
+    create_arr_parser,
+    get_confirmation_decision,
+    print_final_summary,
+    print_item_list,
+    print_section_header,
+)
 
 
 def get_artist_albums(
-    base_url: str, api_key: str, artist_id: int
+    client: ArrClient, artist_id: int
 ) -> List[Dict[str, Any]]:
     """Fetch all albums for a specific artist."""
-    url = f"{base_url}/api/v1/album"
-    headers = {"X-Api-Key": api_key}
-    params = {"artistId": artist_id}
-
-    try:
-        response = requests.get(url, headers=headers, params=params)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error fetching albums for artist {artist_id}: {e}",
-            file=sys.stderr,
-        )
-        return []
+    return client.get("/api/v1/album", params={"artistId": artist_id})
 
 
 def get_retag_preview(
-    base_url: str, api_key: str, artist_id: int
+    client: ArrClient, artist_id: int
 ) -> List[Dict[str, Any]]:
     """Get preview of files that will be retagged for an artist."""
-    url = f"{base_url}/api/v1/retag"
-    headers = {"X-Api-Key": api_key}
-    params = {"artistId": artist_id}
-
-    try:
-        response = requests.get(url, headers=headers, params=params)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error fetching retag preview for artist {artist_id}: {e}",
-            file=sys.stderr,
-        )
-        return []
+    return client.get("/api/v1/retag", params={"artistId": artist_id})
 
 
 def execute_retag(
-    base_url: str, api_key: str, artist_id: int, file_ids: List[int]
+    client: ArrClient, artist_id: int, file_ids: List[int]
 ) -> Dict[str, Any]:
     """Execute retag operation for an artist."""
-    url = f"{base_url}/api/v1/command"
-    headers = {"X-Api-Key": api_key, "Content-Type": "application/json"}
     payload = {
         "name": "RetagFiles",
         "artistId": artist_id,
         "files": file_ids,
     }
-
-    try:
-        response = requests.post(url, headers=headers, json=payload)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error executing retag for artist {artist_id}: {e}",
-            file=sys.stderr,
-        )
-        return {}
-
-
-def ask_confirmation(prompt: str) -> bool:
-    """Ask user for yes/no confirmation."""
-    while True:
-        response = input(f"{prompt} (y/n): ").lower().strip()
-        if response in ["y", "yes"]:
-            return True
-        elif response in ["n", "no"]:
-            return False
-        else:
-            print("Please answer 'y' or 'n'")
+    return client.post("/api/v1/command", payload)
 
 
 def format_tag_changes(changes: List[Dict[str, Any]]) -> str:
@@ -130,33 +71,16 @@ def format_tag_changes(changes: List[Dict[str, Any]]) -> str:
 
 
 def main():
-    parser = argparse.ArgumentParser(
-        description="Retag Lidarr albums with confirmation"
+    parser = create_arr_parser(
+        "Lidarr", "Retag Lidarr albums with confirmation", 8686
     )
-    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(
-        "--dry-run",
-        action="store_true",
-        help="Show what would be retagged without making changes",
-    )
-    parser.add_argument(
-        "--no-confirm",
-        "--yolo",
-        action="store_true",
-        dest="no_confirm",
-        help="Skip interactive confirmation (use with caution)",
-    )
-
     args = parser.parse_args()
 
-    # Normalize URLs
-    base_url = args.lidarr_url.rstrip("/")
+    # Create client
+    client = ArrClient(args.lidarr_url, args.api_key)
 
-    print(f"Fetching artists from {base_url}...")
-    all_artists = get_all_artists(base_url, args.api_key)
+    print(f"Fetching artists from {client.base_url}...")
+    all_artists = client.get("/api/v1/artist")
     print(f"Found {len(all_artists)} artists\n")
 
     artists_with_retags = []
@@ -168,13 +92,11 @@ def main():
         artist_id = artist.get("id")
         artist_name = artist.get("artistName", "Unknown")
 
-        retag_preview = get_retag_preview(
-            base_url, args.api_key, artist_id
-        )
+        retag_preview = get_retag_preview(client, artist_id)
 
         if retag_preview:
             # Get album count for this artist
-            albums = get_artist_albums(base_url, args.api_key, artist_id)
+            albums = get_artist_albums(client, artist_id)
             album_count = len(albums) if albums else 0
 
             artists_with_retags.append(
@@ -184,17 +106,8 @@ def main():
             artists_without_retags.append(artist_name)
 
     # Print summary
-    print("\n" + "=" * 80)
-    print("SUMMARY")
-    print("=" * 80)
-
-    if artists_without_retags:
-        count = len(artists_without_retags)
-        print(f"\n✓ No retags needed ({count} artists):")
-        for name in artists_without_retags[:5]:  # Show first 5
-            print(f"  - {name}")
-        if len(artists_without_retags) > 5:
-            print(f"  ... and {len(artists_without_retags) - 5} more")
+    print_section_header("SUMMARY")
+    print_item_list(artists_without_retags, "✓ No retags needed")
 
     if artists_with_retags:
         count = len(artists_with_retags)
@@ -205,9 +118,7 @@ def main():
         return
 
     # Process each artist that needs retagging
-    print("\n" + "=" * 80)
-    print("RETAG PREVIEW")
-    print("=" * 80)
+    print_section_header("RETAG PREVIEW")
 
     retagged_count = 0
     skipped_count = 0
@@ -233,32 +144,22 @@ def main():
             remaining = len(retag_preview) - display_limit
             print(f"\n  ... and {remaining} more files")
 
-        # Ask for confirmation (unless in dry-run or no-confirm mode)
-        should_retag = False
+        # Ask for confirmation
+        file_word = "file" if len(retag_preview) == 1 else "files"
+        prompt = (
+            f"\nRetag {len(retag_preview)} {file_word} "
+            f"for '{artist_name}'?"
+        )
+        should_retag = get_confirmation_decision(args, prompt)
 
-        if args.dry_run:
-            print("\n[DRY RUN] Skipping actual retag")
-        elif args.no_confirm:
-            should_retag = True
-            print("\n[NO CONFIRM] Proceeding with retag...")
-        else:
-            file_word = "file" if len(retag_preview) == 1 else "files"
-            prompt = (
-                f"\nRetag {len(retag_preview)} {file_word} "
-                f"for '{artist_name}'?"
-            )
-            should_retag = ask_confirmation(prompt)
-
-        if should_retag and not args.dry_run:
+        if should_retag:
             print("Executing retag...")
             # Extract track file IDs from preview
             file_ids = [item.get("trackFileId") for item in retag_preview]
             file_ids = [fid for fid in file_ids if fid is not None]
 
             if file_ids:
-                result = execute_retag(
-                    base_url, args.api_key, artist_id, file_ids
-                )
+                result = execute_retag(client, artist_id, file_ids)
                 if result:
                     print("✓ Retag command queued successfully")
                     retagged_count += 1
@@ -274,26 +175,20 @@ def main():
             skipped_count += 1
 
     # Final summary
-    print("\n" + "=" * 80)
-    print("FINAL SUMMARY")
-    print("=" * 80)
-
     if args.dry_run:
+        print_section_header("FINAL SUMMARY")
         print(
             f"\n[DRY RUN] Found {len(artists_with_retags)} artists "
             "that need retagging"
         )
         print("No changes were made. Remove --dry-run to apply retags.")
     else:
-        print(f"\nArtists processed: {len(artists_with_retags)}")
-        print(f"  - Retagged: {retagged_count}")
-        print(f"  - Skipped: {skipped_count}")
-
-        if retagged_count > 0:
-            print(
-                "\nNote: Retag operations are queued. "
-                "Check Lidarr's queue for progress."
-            )
+        print_final_summary(
+            len(artists_with_retags),
+            retagged_count,
+            skipped_count,
+            "Retagged",
+        )
 
 
 if __name__ == "__main__":
tools/radarr-rename-movies.py
@@ -20,107 +20,42 @@ Example:
     ./radarr-rename-movies.py http://localhost:7878 your-api-key
 """
 
-import argparse
-import sys
 from typing import Any, Dict, List
 
-import requests
-
-
-def get_all_movies(base_url: str, api_key: str) -> List[Dict[str, Any]]:
-    """Fetch all movies from Radarr API."""
-    url = f"{base_url}/api/v3/movie"
-    headers = {"X-Api-Key": api_key}
-
-    try:
-        response = requests.get(url, headers=headers)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(f"Error fetching movies: {e}", file=sys.stderr)
-        sys.exit(1)
+from arrlib import (
+    ArrClient,
+    create_arr_parser,
+    get_confirmation_decision,
+    print_final_summary,
+    print_item_list,
+    print_section_header,
+)
 
 
 def get_rename_preview(
-    base_url: str, api_key: str, movie_id: int
+    client: ArrClient, movie_id: int
 ) -> List[Dict[str, Any]]:
     """Get preview of files that will be renamed for a movie."""
-    url = f"{base_url}/api/v3/rename"
-    headers = {"X-Api-Key": api_key}
-    params = {"movieId": movie_id}
-
-    try:
-        response = requests.get(url, headers=headers, params=params)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error fetching rename preview for movie {movie_id}: {e}",
-            file=sys.stderr,
-        )
-        return []
+    return client.get("/api/v3/rename", params={"movieId": movie_id})
 
 
-def execute_rename(
-    base_url: str, api_key: str, movie_id: int
-) -> Dict[str, Any]:
+def execute_rename(client: ArrClient, movie_id: int) -> Dict[str, Any]:
     """Execute rename operation for a movie."""
-    url = f"{base_url}/api/v3/command"
-    headers = {"X-Api-Key": api_key, "Content-Type": "application/json"}
     payload = {"name": "RenameMovie", "movieIds": [movie_id]}
-
-    try:
-        response = requests.post(url, headers=headers, json=payload)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error executing rename for movie {movie_id}: {e}",
-            file=sys.stderr,
-        )
-        return {}
-
-
-def ask_confirmation(prompt: str) -> bool:
-    """Ask user for yes/no confirmation."""
-    while True:
-        response = input(f"{prompt} (y/n): ").lower().strip()
-        if response in ["y", "yes"]:
-            return True
-        elif response in ["n", "no"]:
-            return False
-        else:
-            print("Please answer 'y' or 'n'")
+    return client.post("/api/v3/command", payload)
 
 
 def main():
-    parser = argparse.ArgumentParser(
-        description="Rename Radarr movies with confirmation"
+    parser = create_arr_parser(
+        "Radarr", "Rename Radarr movies with confirmation", 7878
     )
-    parser.add_argument(
-        "radarr_url", help="Radarr base URL (e.g., http://localhost:7878)"
-    )
-    parser.add_argument("api_key", help="Radarr API key")
-    parser.add_argument(
-        "--dry-run",
-        action="store_true",
-        help="Show what would be renamed without making changes",
-    )
-    parser.add_argument(
-        "--no-confirm",
-        "--yolo",
-        action="store_true",
-        dest="no_confirm",
-        help="Skip interactive confirmation (use with caution)",
-    )
-
     args = parser.parse_args()
 
-    # Normalize URLs
-    base_url = args.radarr_url.rstrip("/")
+    # Create client
+    client = ArrClient(args.radarr_url, args.api_key)
 
-    print(f"Fetching movies from {base_url}...")
-    all_movies = get_all_movies(base_url, args.api_key)
+    print(f"Fetching movies from {client.base_url}...")
+    all_movies = client.get("/api/v3/movie")
     print(f"Found {len(all_movies)} movies\n")
 
     movies_with_renames = []
@@ -136,9 +71,7 @@ def main():
             f"{movie_title} ({year})" if year else movie_title
         )
 
-        rename_preview = get_rename_preview(
-            base_url, args.api_key, movie_id
-        )
+        rename_preview = get_rename_preview(client, movie_id)
 
         if rename_preview:
             movies_with_renames.append(
@@ -148,17 +81,8 @@ def main():
             movies_without_renames.append(display_title)
 
     # Print summary
-    print("\n" + "=" * 80)
-    print("SUMMARY")
-    print("=" * 80)
-
-    if movies_without_renames:
-        count = len(movies_without_renames)
-        print(f"\n✓ No renames needed ({count} movies):")
-        for title in movies_without_renames[:5]:  # Show first 5
-            print(f"  - {title}")
-        if len(movies_without_renames) > 5:
-            print(f"  ... and {len(movies_without_renames) - 5} more")
+    print_section_header("SUMMARY")
+    print_item_list(movies_without_renames, "✓ No renames needed")
 
     if movies_with_renames:
         count = len(movies_with_renames)
@@ -169,9 +93,7 @@ def main():
         return
 
     # Process each movie that needs renaming
-    print("\n" + "=" * 80)
-    print("RENAME PREVIEW")
-    print("=" * 80)
+    print_section_header("RENAME PREVIEW")
 
     renamed_count = 0
     skipped_count = 0
@@ -190,24 +112,17 @@ def main():
             print(f"    FROM: {existing_path}")
             print(f"    TO:   {new_path}")
 
-        # Ask for confirmation (unless in dry-run or no-confirm mode)
-        should_rename = False
+        # Ask for confirmation
+        file_word = "file" if len(rename_preview) == 1 else "files"
+        prompt = (
+            f"\nRename {len(rename_preview)} {file_word} "
+            f"for '{movie_title}'?"
+        )
+        should_rename = get_confirmation_decision(args, prompt)
 
-        if args.dry_run:
-            print("\n[DRY RUN] Skipping actual rename")
-        elif args.no_confirm:
-            should_rename = True
-            print("\n[NO CONFIRM] Proceeding with rename...")
-        else:
-            file_word = "file" if len(rename_preview) == 1 else "files"
-            should_rename = ask_confirmation(
-                f"\nRename {len(rename_preview)} {file_word} "
-                f"for '{movie_title}'?"
-            )
-
-        if should_rename and not args.dry_run:
+        if should_rename:
             print("Executing rename...")
-            result = execute_rename(base_url, args.api_key, movie_id)
+            result = execute_rename(client, movie_id)
             if result:
                 print("✓ Rename command queued successfully")
                 renamed_count += 1
@@ -220,26 +135,20 @@ def main():
             skipped_count += 1
 
     # Final summary
-    print("\n" + "=" * 80)
-    print("FINAL SUMMARY")
-    print("=" * 80)
-
     if args.dry_run:
+        print_section_header("FINAL SUMMARY")
         print(
             f"\n[DRY RUN] Found {len(movies_with_renames)} movies "
             "that need renaming"
         )
         print("No changes were made. Remove --dry-run to apply renames.")
     else:
-        print(f"\nMovies processed: {len(movies_with_renames)}")
-        print(f"  - Renamed: {renamed_count}")
-        print(f"  - Skipped: {skipped_count}")
-
-        if renamed_count > 0:
-            print(
-                "\nNote: Rename operations are queued. "
-                "Check Radarr's queue for progress."
-            )
+        print_final_summary(
+            len(movies_with_renames),
+            renamed_count,
+            skipped_count,
+            "Renamed",
+        )
 
 
 if __name__ == "__main__":
tools/sonarr-rename-series.py
@@ -20,111 +20,48 @@ Example:
     ./sonarr-rename-series.py http://localhost:8989 your-api-key
 """
 
-import argparse
-import sys
 from typing import Any, Dict, List
 
-import requests
-
-
-def get_all_series(base_url: str, api_key: str) -> List[Dict[str, Any]]:
-    """Fetch all series from Sonarr API."""
-    url = f"{base_url}/api/v3/series"
-    headers = {"X-Api-Key": api_key}
-
-    try:
-        response = requests.get(url, headers=headers)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(f"Error fetching series: {e}", file=sys.stderr)
-        sys.exit(1)
+from arrlib import (
+    ArrClient,
+    create_arr_parser,
+    get_confirmation_decision,
+    print_final_summary,
+    print_item_list,
+    print_section_header,
+)
 
 
 def get_rename_preview(
-    base_url: str, api_key: str, series_id: int
+    client: ArrClient, series_id: int
 ) -> List[Dict[str, Any]]:
     """Get preview of files that will be renamed for a series."""
-    url = f"{base_url}/api/v3/rename"
-    headers = {"X-Api-Key": api_key}
-    params = {"seriesId": series_id}
-
-    try:
-        response = requests.get(url, headers=headers, params=params)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error fetching rename preview for series {series_id}: {e}",
-            file=sys.stderr,
-        )
-        return []
+    return client.get("/api/v3/rename", params={"seriesId": series_id})
 
 
 def execute_rename(
-    base_url: str, api_key: str, series_id: int, file_ids: List[int]
+    client: ArrClient, series_id: int, file_ids: List[int]
 ) -> Dict[str, Any]:
     """Execute rename operation for a series."""
-    url = f"{base_url}/api/v3/command"
-    headers = {"X-Api-Key": api_key, "Content-Type": "application/json"}
     payload = {
         "name": "RenameFiles",
         "seriesId": series_id,
         "files": file_ids,
     }
-
-    try:
-        response = requests.post(url, headers=headers, json=payload)
-        response.raise_for_status()
-        return response.json()
-    except requests.exceptions.RequestException as e:
-        print(
-            f"Error executing rename for series {series_id}: {e}",
-            file=sys.stderr,
-        )
-        return {}
-
-
-def ask_confirmation(prompt: str) -> bool:
-    """Ask user for yes/no confirmation."""
-    while True:
-        response = input(f"{prompt} (y/n): ").lower().strip()
-        if response in ["y", "yes"]:
-            return True
-        elif response in ["n", "no"]:
-            return False
-        else:
-            print("Please answer 'y' or 'n'")
+    return client.post("/api/v3/command", payload)
 
 
 def main():
-    parser = argparse.ArgumentParser(
-        description="Rename Sonarr series episodes with confirmation"
+    parser = create_arr_parser(
+        "Sonarr", "Rename Sonarr series episodes with confirmation", 8989
     )
-    parser.add_argument(
-        "sonarr_url", help="Sonarr base URL (e.g., http://localhost:8989)"
-    )
-    parser.add_argument("api_key", help="Sonarr API key")
-    parser.add_argument(
-        "--dry-run",
-        action="store_true",
-        help="Show what would be renamed without making changes",
-    )
-    parser.add_argument(
-        "--no-confirm",
-        "--yolo",
-        action="store_true",
-        dest="no_confirm",
-        help="Skip interactive confirmation (use with caution)",
-    )
-
     args = parser.parse_args()
 
-    # Normalize URLs
-    base_url = args.sonarr_url.rstrip("/")
+    # Create client
+    client = ArrClient(args.sonarr_url, args.api_key)
 
-    print(f"Fetching series from {base_url}...")
-    all_series = get_all_series(base_url, args.api_key)
+    print(f"Fetching series from {client.base_url}...")
+    all_series = client.get("/api/v3/series")
     print(f"Found {len(all_series)} series\n")
 
     series_with_renames = []
@@ -136,9 +73,7 @@ def main():
         series_id = series.get("id")
         series_title = series.get("title", "Unknown")
 
-        rename_preview = get_rename_preview(
-            base_url, args.api_key, series_id
-        )
+        rename_preview = get_rename_preview(client, series_id)
 
         if rename_preview:
             series_with_renames.append(
@@ -148,17 +83,8 @@ def main():
             series_without_renames.append(series_title)
 
     # Print summary
-    print("\n" + "=" * 80)
-    print("SUMMARY")
-    print("=" * 80)
-
-    if series_without_renames:
-        count = len(series_without_renames)
-        print(f"\n✓ No renames needed ({count} series):")
-        for title in series_without_renames[:5]:  # Show first 5
-            print(f"  - {title}")
-        if len(series_without_renames) > 5:
-            print(f"  ... and {len(series_without_renames) - 5} more")
+    print_section_header("SUMMARY")
+    print_item_list(series_without_renames, "✓ No renames needed")
 
     if series_with_renames:
         count = len(series_with_renames)
@@ -169,9 +95,7 @@ def main():
         return
 
     # Process each series that needs renaming
-    print("\n" + "=" * 80)
-    print("RENAME PREVIEW")
-    print("=" * 80)
+    print_section_header("RENAME PREVIEW")
 
     renamed_count = 0
     skipped_count = 0
@@ -195,31 +119,21 @@ def main():
             remaining = len(rename_preview) - display_limit
             print(f"\n  ... and {remaining} more episodes")
 
-        # Ask for confirmation (unless in dry-run or no-confirm mode)
-        should_rename = False
+        # Ask for confirmation
+        prompt = (
+            f"\nRename {len(rename_preview)} episodes "
+            f"for '{series_title}'?"
+        )
+        should_rename = get_confirmation_decision(args, prompt)
 
-        if args.dry_run:
-            print("\n[DRY RUN] Skipping actual rename")
-        elif args.no_confirm:
-            should_rename = True
-            print("\n[NO CONFIRM] Proceeding with rename...")
-        else:
-            prompt = (
-                f"\nRename {len(rename_preview)} episodes "
-                f"for '{series_title}'?"
-            )
-            should_rename = ask_confirmation(prompt)
-
-        if should_rename and not args.dry_run:
+        if should_rename:
             print("Executing rename...")
             # Extract episode file IDs from preview
             file_ids = [item.get("episodeFileId") for item in rename_preview]
             file_ids = [fid for fid in file_ids if fid is not None]
 
             if file_ids:
-                result = execute_rename(
-                    base_url, args.api_key, series_id, file_ids
-                )
+                result = execute_rename(client, series_id, file_ids)
                 if result:
                     print("✓ Rename command queued successfully")
                     renamed_count += 1
@@ -235,26 +149,20 @@ def main():
             skipped_count += 1
 
     # Final summary
-    print("\n" + "=" * 80)
-    print("FINAL SUMMARY")
-    print("=" * 80)
-
     if args.dry_run:
+        print_section_header("FINAL SUMMARY")
         print(
             f"\n[DRY RUN] Found {len(series_with_renames)} series "
             "that need renaming"
         )
         print("No changes were made. Remove --dry-run to apply renames.")
     else:
-        print(f"\nSeries processed: {len(series_with_renames)}")
-        print(f"  - Renamed: {renamed_count}")
-        print(f"  - Skipped: {skipped_count}")
-
-        if renamed_count > 0:
-            print(
-                "\nNote: Rename operations are queued. "
-                "Check Sonarr's queue for progress."
-            )
+        print_final_summary(
+            len(series_with_renames),
+            renamed_count,
+            skipped_count,
+            "Renamed",
+        )
 
 
 if __name__ == "__main__":
Makefile
@@ -118,6 +118,38 @@ dns-update-gandi:
 dns-update-gandi-dry-run:
 	@bash tools/update-gandi-dns.sh --dry-run
 
+# Media Management (*arr Stack)
+# Default values (can be overridden via environment or make arguments)
+LIDARR_URL ?= https://lidarr.sbr.pm
+LIDARR_API_KEY ?= $(shell passage show home/services/lidarr)
+SONARR_URL ?= https://sonarr.sbr.pm
+SONARR_API_KEY ?= $(shell passage show home/services/sonarr)
+RADARR_URL ?= https://radarr.sbr.pm
+RADARR_API_KEY ?= $(shell passage show home/services/radarr)
+EXTRA_ARGS ?=
+
+.PHONY: lidarr-rename lidarr-retag lidarr-update-paths
+.PHONY: sonarr-rename
+.PHONY: radarr-rename
+
+# Lidarr
+lidarr-rename:
+	@tools/lidarr-rename-albums.py $(LIDARR_URL) $(LIDARR_API_KEY) $(EXTRA_ARGS)
+
+lidarr-retag:
+	@tools/lidarr-retag-albums.py $(LIDARR_URL) $(LIDARR_API_KEY) $(EXTRA_ARGS)
+
+lidarr-update-paths:
+	@tools/lidarr-update-paths.py $(LIDARR_URL) $(LIDARR_API_KEY) $(LIDARR_ROOT_PATH) $(EXTRA_ARGS)
+
+# Sonarr
+sonarr-rename:
+	@tools/sonarr-rename-series.py $(SONARR_URL) $(SONARR_API_KEY) $(EXTRA_ARGS)
+
+# Radarr
+radarr-rename:
+	@tools/radarr-rename-movies.py $(RADARR_URL) $(RADARR_API_KEY) $(EXTRA_ARGS)
+
 # Maintenance
 .PHONY: clean
 clean: clean-system clean-results
README.org
@@ -187,6 +187,43 @@
 
 See [[file:tools/README.org][tools/README.org]] for detailed DNS tool documentation.
 
+*** Media Management (*arr Stack)
+
+Default URLs and API keys (from passage) are configured for =*.sbr.pm= services.
+Override via environment variables or make arguments if needed.
+
+**** Lidarr
+- =make lidarr-rename= - Rename albums in Lidarr with confirmation
+- =make lidarr-rename EXTRA_ARGS="--dry-run"= - Preview album renames
+- =make lidarr-retag= - Retag albums in Lidarr with confirmation
+- =make lidarr-retag EXTRA_ARGS="--dry-run"= - Preview album retags
+- =make lidarr-update-paths= - Update artist paths (requires =LIDARR_ROOT_PATH=)
+
+**** Sonarr
+- =make sonarr-rename= - Rename TV series episodes with confirmation
+- =make sonarr-rename EXTRA_ARGS="--dry-run"= - Preview episode renames
+
+**** Radarr
+- =make radarr-rename= - Rename movies with confirmation
+- =make radarr-rename EXTRA_ARGS="--dry-run"= - Preview movie renames
+
+**** Examples
+#+begin_src bash
+# Use defaults (*.sbr.pm with passage API keys)
+make lidarr-rename
+
+# Dry-run mode
+make sonarr-rename EXTRA_ARGS="--dry-run"
+
+# Override URL and API key (e.g., for local testing)
+make radarr-rename RADARR_URL=http://localhost:7878 RADARR_API_KEY=mykey
+
+# Use --no-confirm flag
+make lidarr-retag EXTRA_ARGS="--no-confirm"
+#+end_src
+
+See [[file:tools/README.org][tools/README.org]] for detailed *arr tool documentation.
+
 *** Maintenance
 - =make clean= - Clean up old system generations and results
 - =make clean-system= - Remove system generations older than 15 days