Commit 0f5b1207db22
Changed files (7)
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