system-manager-wakasu
  1#!/usr/bin/env -S uv run --quiet --script
  2# /// script
  3# dependencies = [
  4#   "requests",
  5# ]
  6# ///
  7"""
  8Rename series episodes in Sonarr with interactive confirmation.
  9
 10This script:
 111. Fetches all series from Sonarr API
 122. Checks which series have episodes that need renaming
 133. Previews the rename changes for each series
 144. Asks for confirmation before applying renames
 15
 16Usage:
 17    ./sonarr-rename-series.py <sonarr_url> <api_key>
 18
 19Example:
 20    ./sonarr-rename-series.py http://localhost:8989 your-api-key
 21"""
 22
 23from typing import Any, Dict, List
 24
 25from arrlib import (
 26    ArrClient,
 27    create_arr_parser,
 28    get_confirmation_decision,
 29    print_final_summary,
 30    print_item_list,
 31    print_section_header,
 32)
 33
 34
 35def get_rename_preview(
 36    client: ArrClient, series_id: int
 37) -> List[Dict[str, Any]]:
 38    """Get preview of files that will be renamed for a series."""
 39    return client.get("/api/v3/rename", params={"seriesId": series_id})
 40
 41
 42def execute_rename(
 43    client: ArrClient, series_id: int, file_ids: List[int]
 44) -> Dict[str, Any]:
 45    """Execute rename operation for a series."""
 46    payload = {
 47        "name": "RenameFiles",
 48        "seriesId": series_id,
 49        "files": file_ids,
 50    }
 51    return client.post("/api/v3/command", payload)
 52
 53
 54def main():
 55    parser = create_arr_parser(
 56        "Sonarr", "Rename Sonarr series episodes with confirmation", 8989
 57    )
 58    args = parser.parse_args()
 59
 60    # Create client
 61    client = ArrClient(args.sonarr_url, args.api_key)
 62
 63    print(f"Fetching series from {client.base_url}...")
 64    all_series = client.get("/api/v3/series")
 65    print(f"Found {len(all_series)} series\n")
 66
 67    series_with_renames = []
 68    series_without_renames = []
 69
 70    # Check each series for rename candidates
 71    print("Checking which series need renaming...")
 72    for series in all_series:
 73        series_id = series.get("id")
 74        series_title = series.get("title", "Unknown")
 75
 76        rename_preview = get_rename_preview(client, series_id)
 77
 78        if rename_preview:
 79            series_with_renames.append(
 80                (series_id, series_title, rename_preview)
 81            )
 82        else:
 83            series_without_renames.append(series_title)
 84
 85    # Print summary
 86    print_section_header("SUMMARY")
 87    print_item_list(series_without_renames, "✓ No renames needed")
 88
 89    if series_with_renames:
 90        count = len(series_with_renames)
 91        print(f"\n→ Series with renames needed: {count}")
 92
 93    if not series_with_renames:
 94        print("\nNo series need renaming!")
 95        return
 96
 97    # Process each series that needs renaming
 98    print_section_header("RENAME PREVIEW")
 99
100    renamed_count = 0
101    skipped_count = 0
102
103    for series_id, series_title, rename_preview in series_with_renames:
104        print(f"\n{'=' * 80}")
105        print(f"Series: {series_title}")
106        print(f"Episodes to rename: {len(rename_preview)}")
107        print("=" * 80)
108
109        # Show preview of renames (limit to first 10)
110        display_limit = 10
111        for i, item in enumerate(rename_preview[:display_limit]):
112            existing_path = item.get("existingPath", "Unknown")
113            new_path = item.get("newPath", "Unknown")
114            print(f"\n  Episode {i + 1}:")
115            print(f"    FROM: {existing_path}")
116            print(f"    TO:   {new_path}")
117
118        if len(rename_preview) > display_limit:
119            remaining = len(rename_preview) - display_limit
120            print(f"\n  ... and {remaining} more episodes")
121
122        # Ask for confirmation
123        prompt = (
124            f"\nRename {len(rename_preview)} episodes "
125            f"for '{series_title}'?"
126        )
127        should_rename = get_confirmation_decision(args, prompt)
128
129        if should_rename:
130            print("Executing rename...")
131            # Extract episode file IDs from preview
132            file_ids = [item.get("episodeFileId") for item in rename_preview]
133            file_ids = [fid for fid in file_ids if fid is not None]
134
135            if file_ids:
136                result = execute_rename(client, series_id, file_ids)
137                if result:
138                    print("✓ Rename command queued successfully")
139                    renamed_count += 1
140                else:
141                    print("✗ Failed to queue rename command")
142                    skipped_count += 1
143            else:
144                print("✗ No valid file IDs found")
145                skipped_count += 1
146        else:
147            if not args.dry_run:
148                print("Skipped")
149            skipped_count += 1
150
151    # Final summary
152    if args.dry_run:
153        print_section_header("FINAL SUMMARY")
154        print(
155            f"\n[DRY RUN] Found {len(series_with_renames)} series "
156            "that need renaming"
157        )
158        print("No changes were made. Remove --dry-run to apply renames.")
159    else:
160        print_final_summary(
161            len(series_with_renames),
162            renamed_count,
163            skipped_count,
164            "Renamed",
165        )
166
167
168if __name__ == "__main__":
169    main()