Commit 3b066ad6c705

Vincent Demeester <vincent@sbr.pm>
2025-11-28 11:51:43
refactor: Unify arr tools and standardize package structure
- Consolidate scattered *arr scripts into single CLI for better UX - Move package definitions to tools/ for consistency with battery-monitor - Simplify tool discovery with unified arr command interface Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent b70045b
pkgs/default.nix
@@ -19,7 +19,8 @@ in
   govanityurl = pkgs.callPackage ./govanityurl { };
   batzconverter = pkgs.callPackage ./batzconverter { };
   manifest-tool = pkgs.callPackage ./manifest-tool { };
-  gh-restart-failed = pkgs.callPackage ./gh-restart-failed { };
+  gh-restart-failed = pkgs.callPackage ../tools/gh-restart-failed { };
+  arr = pkgs.callPackage ../tools/arr { };
 
   chmouzies-ai = pkgs.callPackage ./chmouzies/ai.nix { };
   chmouzies-git = pkgs.callPackage ./chmouzies/git.nix { };
tools/arr/commands/__init__.py
@@ -0,0 +1,1 @@
+"""Command modules for arr CLI."""
tools/lidarr-rename-albums.py → tools/arr/commands/lidarr_rename_albums.py
@@ -1,9 +1,3 @@
-#!/usr/bin/env -S uv run --quiet --script
-# /// script
-# dependencies = [
-#   "requests",
-# ]
-# ///
 """
 Rename albums in Lidarr with interactive confirmation.
 
@@ -14,15 +8,15 @@ This script:
 4. Asks for confirmation before applying renames
 
 Usage:
-    ./lidarr-rename-albums.py <lidarr_url> <api_key>
+    arr lidarr rename-albums <lidarr_url> <api_key>
 
 Example:
-    ./lidarr-rename-albums.py http://localhost:8686 your-api-key
+    arr lidarr rename-albums http://localhost:8686 your-api-key
 """
 
 from typing import Any, Dict, List
 
-from arrlib import (
+from lib import (
     ArrClient,
     create_arr_parser,
     get_confirmation_decision,
tools/lidarr-retag-albums.py → tools/arr/commands/lidarr_retag_albums.py
@@ -1,9 +1,3 @@
-#!/usr/bin/env -S uv run --quiet --script
-# /// script
-# dependencies = [
-#   "requests",
-# ]
-# ///
 """
 Retag albums in Lidarr with interactive confirmation.
 
@@ -14,15 +8,15 @@ This script:
 4. Asks for confirmation before applying retags
 
 Usage:
-    ./lidarr-retag-albums.py <lidarr_url> <api_key>
+    arr lidarr retag-albums <lidarr_url> <api_key>
 
 Example:
-    ./lidarr-retag-albums.py http://localhost:8686 your-api-key
+    arr lidarr retag-albums http://localhost:8686 your-api-key
 """
 
 from typing import Any, Dict, List
 
-from arrlib import (
+from lib import (
     ArrClient,
     create_arr_parser,
     get_confirmation_decision,
tools/lidarr-update-paths.py → tools/arr/commands/lidarr_update_paths.py
@@ -1,9 +1,3 @@
-#!/usr/bin/env -S uv run --quiet --script
-# /// script
-# dependencies = [
-#   "requests",
-# ]
-# ///
 """
 Update artist paths in Lidarr to use a 'library' subdirectory.
 
@@ -15,10 +9,10 @@ This script:
    <music_folder>/library/<artist>
 
 Usage:
-    ./lidarr-update-paths.py <lidarr_url> <api_key> <music_folder>
+    arr lidarr update-paths <lidarr_url> <api_key> <music_folder>
 
 Example:
-    ./lidarr-update-paths.py http://localhost:8686 your-api-key /data/music
+    arr lidarr update-paths http://localhost:8686 your-api-key /data/music
 """
 
 import argparse
tools/radarr-rename-movies.py → tools/arr/commands/radarr_rename.py
@@ -1,9 +1,3 @@
-#!/usr/bin/env -S uv run --quiet --script
-# /// script
-# dependencies = [
-#   "requests",
-# ]
-# ///
 """
 Rename movies in Radarr with interactive confirmation.
 
@@ -14,15 +8,15 @@ This script:
 4. Asks for confirmation before applying renames
 
 Usage:
-    ./radarr-rename-movies.py <radarr_url> <api_key>
+    arr radarr rename <radarr_url> <api_key>
 
 Example:
-    ./radarr-rename-movies.py http://localhost:7878 your-api-key
+    arr radarr rename http://localhost:7878 your-api-key
 """
 
 from typing import Any, Dict, List
 
-from arrlib import (
+from lib import (
     ArrClient,
     create_arr_parser,
     get_confirmation_decision,
tools/sonarr-rename-series.py → tools/arr/commands/sonarr_rename.py
@@ -1,9 +1,3 @@
-#!/usr/bin/env -S uv run --quiet --script
-# /// script
-# dependencies = [
-#   "requests",
-# ]
-# ///
 """
 Rename series episodes in Sonarr with interactive confirmation.
 
@@ -14,15 +8,15 @@ This script:
 4. Asks for confirmation before applying renames
 
 Usage:
-    ./sonarr-rename-series.py <sonarr_url> <api_key>
+    arr sonarr rename <sonarr_url> <api_key>
 
 Example:
-    ./sonarr-rename-series.py http://localhost:8989 your-api-key
+    arr sonarr rename http://localhost:8989 your-api-key
 """
 
 from typing import Any, Dict, List
 
-from arrlib import (
+from lib import (
     ArrClient,
     create_arr_parser,
     get_confirmation_decision,
tools/arr/arr
@@ -0,0 +1,117 @@
+#!/usr/bin/env python3
+"""
+arr - Unified CLI for managing *arr services (Sonarr, Radarr, Lidarr).
+
+This tool provides a consistent interface for common operations across
+the *arr media management stack.
+
+Usage:
+    arr <service> <command> [options]
+
+Services:
+    sonarr      Manage Sonarr TV series
+    radarr      Manage Radarr movies
+    lidarr      Manage Lidarr music
+
+Commands vary by service. Use 'arr <service> --help' for details.
+
+Examples:
+    arr sonarr rename http://localhost:8989 your-api-key
+    arr radarr rename http://localhost:7878 your-api-key --dry-run
+    arr lidarr rename-albums http://localhost:8686 your-api-key
+    arr lidarr retag-albums http://localhost:8686 your-api-key
+    arr lidarr update-paths http://localhost:8686 your-api-key /old /new
+"""
+
+import sys
+from pathlib import Path
+
+# Add the arr package directory to Python path
+ARR_DIR = Path(__file__).parent.resolve()
+sys.path.insert(0, str(ARR_DIR))
+
+
+def print_usage():
+    """Print usage information."""
+    print(__doc__)
+    sys.exit(1)
+
+
+def main():
+    """Main entry point for the arr CLI."""
+    if len(sys.argv) < 2:
+        print_usage()
+
+    service = sys.argv[1].lower()
+
+    # Map services to their command modules
+    if service == "sonarr":
+        if len(sys.argv) < 3:
+            print("Usage: arr sonarr <command> [options]")
+            print("\nCommands:")
+            print("  rename     Rename series episodes")
+            sys.exit(1)
+
+        command = sys.argv[2].lower()
+        if command == "rename":
+            # Remove 'sonarr rename' from argv and run the script
+            sys.argv = [sys.argv[0]] + sys.argv[3:]
+            from commands import sonarr_rename
+            sonarr_rename.main()
+        else:
+            print(f"Unknown sonarr command: {command}")
+            sys.exit(1)
+
+    elif service == "radarr":
+        if len(sys.argv) < 3:
+            print("Usage: arr radarr <command> [options]")
+            print("\nCommands:")
+            print("  rename     Rename movies")
+            sys.exit(1)
+
+        command = sys.argv[2].lower()
+        if command == "rename":
+            sys.argv = [sys.argv[0]] + sys.argv[3:]
+            from commands import radarr_rename
+            radarr_rename.main()
+        else:
+            print(f"Unknown radarr command: {command}")
+            sys.exit(1)
+
+    elif service == "lidarr":
+        if len(sys.argv) < 3:
+            print("Usage: arr lidarr <command> [options]")
+            print("\nCommands:")
+            print("  rename-albums    Rename albums")
+            print("  retag-albums     Retag albums metadata")
+            print("  update-paths     Update library paths")
+            sys.exit(1)
+
+        command = sys.argv[2].lower()
+        if command == "rename-albums":
+            sys.argv = [sys.argv[0]] + sys.argv[3:]
+            from commands import lidarr_rename_albums
+            lidarr_rename_albums.main()
+        elif command == "retag-albums":
+            sys.argv = [sys.argv[0]] + sys.argv[3:]
+            from commands import lidarr_retag_albums
+            lidarr_retag_albums.main()
+        elif command == "update-paths":
+            sys.argv = [sys.argv[0]] + sys.argv[3:]
+            from commands import lidarr_update_paths
+            lidarr_update_paths.main()
+        else:
+            print(f"Unknown lidarr command: {command}")
+            sys.exit(1)
+
+    elif service in ["-h", "--help", "help"]:
+        print_usage()
+
+    else:
+        print(f"Unknown service: {service}")
+        print("\nAvailable services: sonarr, radarr, lidarr")
+        sys.exit(1)
+
+
+if __name__ == "__main__":
+    main()
tools/arr/default.nix
@@ -0,0 +1,53 @@
+{
+  python3,
+  lib,
+  makeWrapper,
+}:
+
+python3.pkgs.buildPythonApplication {
+  pname = "arr";
+  version = "dev";
+  format = "other";
+
+  src = ./.;
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  propagatedBuildInputs = with python3.pkgs; [
+    requests
+  ];
+
+  # Don't try to create __pycache__ directories during build
+  dontUsePythonImportsCheck = true;
+
+  installPhase = ''
+    mkdir -p $out/bin $out/lib/arr
+
+    # Install the main CLI
+    cp arr $out/bin/arr
+    chmod +x $out/bin/arr
+
+    # Install the library
+    cp lib.py $out/lib/arr/
+
+    # Install commands
+    cp -r commands $out/lib/arr/
+
+    # Create __init__.py for the package
+    touch $out/lib/arr/__init__.py
+
+    # Wrap the main script to set PYTHONPATH
+    wrapProgram $out/bin/arr \
+      --prefix PYTHONPATH : "$out/lib/arr"
+  '';
+
+  meta = with lib; {
+    description = "Unified CLI for managing *arr services (Sonarr, Radarr, Lidarr)";
+    longDescription = ''
+      arr provides a consistent interface for common operations across
+      the *arr media management stack, including renaming, retagging,
+      and path updates.
+    '';
+    platforms = platforms.unix;
+  };
+}
tools/arrlib.py → tools/arr/lib.py
File renamed without changes
tools/gh-restart-failed/default.nix
@@ -0,0 +1,38 @@
+{
+  stdenv,
+  lib,
+  makeWrapper,
+  gh,
+  fzf,
+  jq,
+}:
+
+stdenv.mkDerivation {
+  name = "gh-restart-failed";
+  pname = "gh-restart-failed";
+  version = "0.1.0";
+
+  src = ./.;
+
+  nativeBuildInputs = [ makeWrapper ];
+
+  installPhase = ''
+    mkdir -p $out/bin
+    cp gh-restart-failed.sh $out/bin/gh-restart-failed
+    chmod +x $out/bin/gh-restart-failed
+
+    wrapProgram $out/bin/gh-restart-failed \
+      --prefix PATH : ${
+        lib.makeBinPath [
+          gh
+          fzf
+          jq
+        ]
+      }
+  '';
+
+  meta = {
+    description = "List and restart failed GitHub workflow checks on pull requests";
+    platforms = lib.platforms.unix;
+  };
+}
tools/gh-restart-failed/gh-restart-failed.sh
@@ -0,0 +1,236 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+# Colors for output
+RED='\033[0;31m'
+GREEN='\033[0;32m'
+YELLOW='\033[1;33m'
+BLUE='\033[0;34m'
+NC='\033[0m' # No Color
+
+# Help message
+usage() {
+    cat <<EOF
+Usage: gh-restart-failed [OPTIONS] [REPOSITORY]
+
+List pull requests with failed checks and restart selected workflows.
+
+Options:
+    -i, --ignore PATTERN    Ignore workflows matching PATTERN (can be used multiple times)
+    -l, --label LABEL       Filter PRs by label (can be used multiple times)
+    -h, --help             Show this help message
+
+Arguments:
+    REPOSITORY    Optional repository in OWNER/REPO format or path to local repo.
+                  If not provided, uses the current directory's repository.
+
+Dependencies:
+    - gh (GitHub CLI)
+    - fzf (fuzzy finder)
+    - jq (JSON processor)
+
+Note:
+    By default, "Label Checker" workflows are ignored. Use -i to add more patterns.
+
+Examples:
+    gh-restart-failed                                    # Use current repository
+    gh-restart-failed owner/repo                         # Use specific GitHub repository
+    gh-restart-failed -i "build" -i "test"              # Ignore build and test workflows
+    gh-restart-failed -l "bug" -l "enhancement"         # Only show PRs with bug OR enhancement labels
+    gh-restart-failed /path/to/repo                     # Use repository at path
+
+EOF
+    exit 0
+}
+
+# Check dependencies
+check_dependencies() {
+    local missing=()
+
+    for cmd in gh fzf jq; do
+        if ! command -v "$cmd" &> /dev/null; then
+            missing+=("$cmd")
+        fi
+    done
+
+    if [ ${#missing[@]} -gt 0 ]; then
+        echo -e "${RED}Error: Missing required dependencies: ${missing[*]}${NC}" >&2
+        echo "Please install them and try again." >&2
+        exit 1
+    fi
+}
+
+# Default ignore patterns
+IGNORE_PATTERNS=("Label Checker")
+LABEL_FILTERS=()
+
+# Parse arguments
+REPO_ARG=""
+while [[ $# -gt 0 ]]; do
+    case $1 in
+        -h|--help)
+            usage
+            ;;
+        -i|--ignore)
+            if [ -n "${2:-}" ]; then
+                IGNORE_PATTERNS+=("$2")
+                shift 2
+            else
+                echo -e "${RED}Error: --ignore requires a pattern argument${NC}" >&2
+                exit 1
+            fi
+            ;;
+        -l|--label)
+            if [ -n "${2:-}" ]; then
+                LABEL_FILTERS+=("$2")
+                shift 2
+            else
+                echo -e "${RED}Error: --label requires a label argument${NC}" >&2
+                exit 1
+            fi
+            ;;
+        -*)
+            echo -e "${RED}Error: Unknown option: $1${NC}" >&2
+            usage
+            ;;
+        *)
+            REPO_ARG="$1"
+            shift
+            ;;
+    esac
+done
+
+check_dependencies
+
+# Determine repository context
+REPO_FLAG=()
+if [ -n "$REPO_ARG" ]; then
+    if [ -d "$REPO_ARG" ]; then
+        # It's a directory path
+        REPO_FLAG=(-R "$(cd "$REPO_ARG" && gh repo view --json nameWithOwner -q .nameWithOwner)")
+    else
+        # Assume it's OWNER/REPO format
+        REPO_FLAG=(-R "$REPO_ARG")
+    fi
+fi
+
+# Show ignored patterns
+if [ ${#IGNORE_PATTERNS[@]} -gt 0 ]; then
+    echo -e "${YELLOW}Ignoring workflows matching: ${IGNORE_PATTERNS[*]}${NC}" >&2
+fi
+
+# Show label filters
+if [ ${#LABEL_FILTERS[@]} -gt 0 ]; then
+    echo -e "${YELLOW}Filtering PRs with labels: ${LABEL_FILTERS[*]}${NC}" >&2
+fi
+
+# Get all open PRs with their check status
+echo -e "${BLUE}Fetching pull requests...${NC}" >&2
+
+# Build label filter arguments for gh pr list
+LABEL_ARGS=()
+for label in "${LABEL_FILTERS[@]}"; do
+    LABEL_ARGS+=(--label "$label")
+done
+
+# Fetch PRs with detailed check information
+prs_json=$(gh pr list "${REPO_FLAG[@]}" \
+    "${LABEL_ARGS[@]}" \
+    --json number,title,headRefName,author,statusCheckRollup \
+    --limit 100)
+
+# Filter PRs with failed checks and format for display
+failed_prs=$(echo "$prs_json" | jq -r '
+    .[] |
+    select(.statusCheckRollup // [] | any(.conclusion == "FAILURE" or .conclusion == "TIMED_OUT" or .conclusion == "STARTUP_FAILURE" or .conclusion == "ACTION_REQUIRED")) |
+    {
+        number: .number,
+        title: .title,
+        branch: .headRefName,
+        author: .author.login,
+        failed_checks: [.statusCheckRollup[] | select(.conclusion == "FAILURE" or .conclusion == "TIMED_OUT" or .conclusion == "STARTUP_FAILURE" or .conclusion == "ACTION_REQUIRED")]
+    } |
+    "#\(.number) | \(.title) | @\(.author) | \(.branch) | \(.failed_checks | length) failed"
+')
+
+if [ -z "$failed_prs" ]; then
+    echo -e "${GREEN}No pull requests with failed checks found!${NC}"
+    exit 0
+fi
+
+echo -e "${YELLOW}Found pull requests with failed checks:${NC}" >&2
+echo ""
+
+# Use fzf to select PRs
+selected_prs=$(echo "$failed_prs" | fzf \
+    --multi \
+    --ansi \
+    --header="Select pull requests to restart failed workflows (TAB to select multiple, ENTER to confirm)" \
+    --preview="pr_number=\$(echo {} | cut -d'|' -f1 | tr -d '# '); gh pr checks ${REPO_FLAG[*]} \"\$pr_number\" 2>/dev/null | grep -E '(fail|FAILURE|×)' || echo 'Loading...'" \
+    --preview-window=right:60%:wrap \
+    --bind='ctrl-/:toggle-preview' \
+    --height=80%)
+
+if [ -z "$selected_prs" ]; then
+    echo -e "${YELLOW}No pull requests selected.${NC}"
+    exit 0
+fi
+
+echo ""
+echo -e "${BLUE}Processing selected pull requests...${NC}"
+echo ""
+
+# Process each selected PR
+while IFS= read -r pr_line; do
+    pr_number=$(echo "$pr_line" | cut -d'|' -f1 | tr -d '# ' | xargs)
+    pr_title=$(echo "$pr_line" | cut -d'|' -f2 | xargs)
+    pr_branch=$(echo "$pr_line" | cut -d'|' -f4 | xargs)
+
+    echo -e "${BLUE}PR #$pr_number: $pr_title${NC}"
+
+    # Build jq ignore filter
+    ignore_filter=""
+    for pattern in "${IGNORE_PATTERNS[@]}"; do
+        if [ -n "$ignore_filter" ]; then
+            ignore_filter="$ignore_filter and "
+        fi
+        ignore_filter="${ignore_filter}(.name | contains(\"$pattern\") | not)"
+    done
+
+    # Get failed workflow runs for this PR using the branch
+    failed_runs=$(gh run list "${REPO_FLAG[@]}" \
+        --branch "$pr_branch" \
+        --json databaseId,name,conclusion,status,event \
+        --limit 50 \
+        | jq -r "
+        .[] |
+        select(.event == \"pull_request\" and (.conclusion == \"failure\" or .conclusion == \"timed_out\" or .conclusion == \"startup_failure\" or .conclusion == \"action_required\") and ($ignore_filter)) |
+        \"\(.databaseId)|\(.name)|\(.conclusion)\"")
+
+    if [ -z "$failed_runs" ]; then
+        echo -e "${YELLOW}  No failed workflow runs found (may have been restarted already)${NC}"
+        continue
+    fi
+
+    # Restart all failed workflow runs
+    echo -e "${YELLOW}  Restarting failed workflows:${NC}"
+
+    echo "$failed_runs" | while IFS='|' read -r run_id workflow_name status; do
+        echo -e "  ${GREEN}→${NC} Restarting: $workflow_name ($status)"
+
+        rerun_output=$(gh run rerun "${REPO_FLAG[@]}" "$run_id" --failed 2>&1)
+
+        if echo "$rerun_output" | grep -q "created over a month ago"; then
+            echo -e "    ${YELLOW}⚠${NC} Cannot restart: workflow run is too old (>1 month)"
+        elif echo "$rerun_output" | grep -qi "error"; then
+            echo -e "    ${RED}✗${NC} Failed to restart: $rerun_output"
+        else
+            echo -e "    ${GREEN}✓${NC} Restarted successfully"
+        fi
+    done
+
+    echo ""
+done <<< "$selected_prs"
+
+echo -e "${GREEN}Done!${NC}"