Commit 4a8d94073a43

Vincent Demeester <vincent@sbr.pm>
2025-11-22 23:51:05
feat(tools): Add Lidarr retag automation script
- Enable interactive album retagging with preview of tag changes - Display old → new values for each modified metadata field - Support dry-run and --yolo modes for workflow flexibility Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 228bb33
tools/lidarr-retag-albums.py
@@ -0,0 +1,300 @@
+#!/usr/bin/env -S uv run --quiet --script
+# /// script
+# dependencies = [
+#   "requests",
+# ]
+# ///
+"""
+Retag albums in Lidarr with interactive confirmation.
+
+This script:
+1. Fetches all artists from Lidarr API
+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:
+    ./lidarr-retag-albums.py <lidarr_url> <api_key>
+
+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)
+
+
+def get_artist_albums(
+    base_url: str, api_key: str, 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 []
+
+
+def get_retag_preview(
+    base_url: str, api_key: str, 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 []
+
+
+def execute_retag(
+    base_url: str, api_key: str, 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'")
+
+
+def format_tag_changes(changes: List[Dict[str, Any]]) -> str:
+    """Format tag changes for display."""
+    lines = []
+    for change in changes:
+        field = change.get("field", "")
+        old_val = change.get("oldValue", "")
+        new_val = change.get("newValue", "")
+        if old_val != new_val:
+            lines.append(f"      {field}: '{old_val}' → '{new_val}'")
+    return "\n".join(lines) if lines else "      No changes"
+
+
+def main():
+    parser = argparse.ArgumentParser(
+        description="Retag Lidarr albums with confirmation"
+    )
+    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("/")
+
+    print(f"Fetching artists from {base_url}...")
+    all_artists = get_all_artists(base_url, args.api_key)
+    print(f"Found {len(all_artists)} artists\n")
+
+    artists_with_retags = []
+    artists_without_retags = []
+
+    # Check each artist for retag candidates
+    print("Checking which artists have albums needing retagging...")
+    for artist in all_artists:
+        artist_id = artist.get("id")
+        artist_name = artist.get("artistName", "Unknown")
+
+        retag_preview = get_retag_preview(
+            base_url, args.api_key, artist_id
+        )
+
+        if retag_preview:
+            # Get album count for this artist
+            albums = get_artist_albums(base_url, args.api_key, artist_id)
+            album_count = len(albums) if albums else 0
+
+            artists_with_retags.append(
+                (artist_id, artist_name, album_count, retag_preview)
+            )
+        else:
+            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")
+
+    if artists_with_retags:
+        count = len(artists_with_retags)
+        print(f"\n→ Artists with retags needed: {count}")
+
+    if not artists_with_retags:
+        print("\nNo artists need retagging!")
+        return
+
+    # Process each artist that needs retagging
+    print("\n" + "=" * 80)
+    print("RETAG PREVIEW")
+    print("=" * 80)
+
+    retagged_count = 0
+    skipped_count = 0
+
+    for artist_id, artist_name, album_count, retag_preview in (
+        artists_with_retags
+    ):
+        print(f"\n{'=' * 80}")
+        print(f"Artist: {artist_name}")
+        print(f"Albums: {album_count}")
+        print(f"Files to retag: {len(retag_preview)}")
+        print("=" * 80)
+
+        # Show preview of retags (limit to first 5)
+        display_limit = 5
+        for i, item in enumerate(retag_preview[:display_limit]):
+            path = item.get("path", "Unknown")
+            changes = item.get("changes", {})
+            print(f"\n  File {i + 1}: {path}")
+            print(format_tag_changes(changes))
+
+        if len(retag_preview) > display_limit:
+            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
+
+        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:
+            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
+                )
+                if result:
+                    print("✓ Retag command queued successfully")
+                    retagged_count += 1
+                else:
+                    print("✗ Failed to queue retag command")
+                    skipped_count += 1
+            else:
+                print("✗ No valid file IDs found")
+                skipped_count += 1
+        else:
+            if not args.dry_run:
+                print("Skipped")
+            skipped_count += 1
+
+    # Final summary
+    print("\n" + "=" * 80)
+    print("FINAL SUMMARY")
+    print("=" * 80)
+
+    if args.dry_run:
+        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."
+            )
+
+
+if __name__ == "__main__":
+    main()
tools/README.org
@@ -49,6 +49,29 @@
 - Interactive confirmation before applying changes
 - Dry-run and auto-confirm (--yolo/--no-confirm) modes
 
+*** lidarr-retag-albums.py
+
+Retag albums in Lidarr with interactive confirmation.
+
+*Usage:*
+#+begin_src shell
+# Dry run to preview retags
+./lidarr-retag-albums.py http://localhost:8686 API_KEY --dry-run
+
+# Interactive mode (asks y/n for each artist)
+./lidarr-retag-albums.py http://localhost:8686 API_KEY
+
+# Auto-confirm all retags (--yolo or --no-confirm)
+./lidarr-retag-albums.py http://localhost:8686 API_KEY --yolo
+#+end_src
+
+*Features:*
+- Checks all artists for albums with files needing retag
+- Shows preview of up to 5 file retags per artist with tag changes
+- Displays old → new values for each modified tag
+- Interactive confirmation before applying changes
+- Dry-run and auto-confirm (--yolo/--no-confirm) modes
+
 *** sonarr-rename-series.py
 
 Rename TV series episodes in Sonarr with interactive confirmation.