main
  1#!/usr/bin/env python3
  2"""
  3arr - Unified CLI for managing *arr services (Sonarr, Radarr, Lidarr).
  4
  5This tool provides a consistent interface for common operations across
  6the *arr media management stack.
  7"""
  8
  9import sys
 10from pathlib import Path
 11
 12import click
 13
 14# Add the arr package directory to Python path
 15ARR_DIR = Path(__file__).parent.resolve()
 16sys.path.insert(0, str(ARR_DIR))
 17
 18
 19@click.group()
 20def cli():
 21    """Unified CLI for managing *arr services (Sonarr, Radarr, Lidarr).
 22
 23    This tool provides a consistent interface for common operations across
 24    the *arr media management stack.
 25    """
 26    pass
 27
 28
 29@cli.group()
 30def sonarr():
 31    """Manage Sonarr TV series."""
 32    pass
 33
 34
 35@cli.group()
 36def radarr():
 37    """Manage Radarr movies."""
 38    pass
 39
 40
 41@cli.group()
 42def lidarr():
 43    """Manage Lidarr music."""
 44    pass
 45
 46
 47@cli.group()
 48def jellyfin():
 49    """Manage Jellyfin media server."""
 50    pass
 51
 52
 53@sonarr.command()
 54@click.argument("url")
 55@click.argument("api_key")
 56@click.option(
 57    "--dry-run",
 58    is_flag=True,
 59    help="Show what would be changed without making changes",
 60)
 61@click.option(
 62    "--no-confirm",
 63    "--yolo",
 64    is_flag=True,
 65    help="Skip interactive confirmation (use with caution)",
 66)
 67def sonarr_rename(url, api_key, dry_run, no_confirm):
 68    """Rename series episodes.
 69
 70    Examples:
 71        arr sonarr rename http://localhost:8989 your-api-key
 72        arr sonarr rename http://localhost:8989 your-api-key --dry-run
 73    """
 74    from commands import sonarr_rename
 75    sonarr_rename.run(url, api_key, dry_run, no_confirm)
 76
 77
 78@radarr.command()
 79@click.argument("url")
 80@click.argument("api_key")
 81@click.option(
 82    "--dry-run",
 83    is_flag=True,
 84    help="Show what would be changed without making changes",
 85)
 86@click.option(
 87    "--no-confirm",
 88    "--yolo",
 89    is_flag=True,
 90    help="Skip interactive confirmation (use with caution)",
 91)
 92def radarr_rename(url, api_key, dry_run, no_confirm):
 93    """Rename movies.
 94
 95    Examples:
 96        arr radarr rename http://localhost:7878 your-api-key
 97        arr radarr rename http://localhost:7878 your-api-key --dry-run
 98    """
 99    from commands import radarr_rename
100    radarr_rename.run(url, api_key, dry_run, no_confirm)
101
102
103@lidarr.command("rename-albums")
104@click.argument("url")
105@click.argument("api_key")
106@click.option(
107    "--dry-run",
108    is_flag=True,
109    help="Show what would be changed without making changes",
110)
111@click.option(
112    "--no-confirm",
113    "--yolo",
114    is_flag=True,
115    help="Skip interactive confirmation (use with caution)",
116)
117def lidarr_rename_albums(url, api_key, dry_run, no_confirm):
118    """Rename albums.
119
120    Examples:
121        arr lidarr rename-albums http://localhost:8686 your-api-key
122        arr lidarr rename-albums http://localhost:8686 your-api-key --dry-run
123    """
124    from commands import lidarr_rename_albums
125    lidarr_rename_albums.run(url, api_key, dry_run, no_confirm)
126
127
128@lidarr.command("retag-albums")
129@click.argument("url")
130@click.argument("api_key")
131@click.option(
132    "--dry-run",
133    is_flag=True,
134    help="Show what would be changed without making changes",
135)
136@click.option(
137    "--no-confirm",
138    "--yolo",
139    is_flag=True,
140    help="Skip interactive confirmation (use with caution)",
141)
142def lidarr_retag_albums(url, api_key, dry_run, no_confirm):
143    """Retag albums metadata.
144
145    Examples:
146        arr lidarr retag-albums http://localhost:8686 your-api-key
147        arr lidarr retag-albums http://localhost:8686 your-api-key --dry-run
148    """
149    from commands import lidarr_retag_albums
150    lidarr_retag_albums.run(url, api_key, dry_run, no_confirm)
151
152
153@lidarr.command("update-paths")
154@click.argument("url")
155@click.argument("api_key")
156@click.argument("music_folder")
157@click.option(
158    "--dry-run",
159    is_flag=True,
160    help="Show what would be updated without making changes",
161)
162def lidarr_update_paths(url, api_key, music_folder, dry_run):
163    """Update library paths.
164
165    Examples:
166        arr lidarr update-paths http://localhost:8686 your-api-key \\
167            /data/music
168        arr lidarr update-paths http://localhost:8686 your-api-key \\
169            /data/music --dry-run
170    """
171    from commands import lidarr_update_paths
172    lidarr_update_paths.run(url, api_key, music_folder, dry_run)
173
174
175@lidarr.command("fix-duplicate-paths")
176@click.argument("url")
177@click.option(
178    "--api-key",
179    "-k",
180    envvar="LIDARR_API_KEY",
181    required=True,
182    help="Lidarr API key",
183)
184@click.option(
185    "--dry-run",
186    is_flag=True,
187    help="Show what would be fixed without making changes",
188)
189@click.option(
190    "--no-confirm",
191    "--yolo",
192    is_flag=True,
193    help="Skip interactive confirmation (use with caution)",
194)
195def lidarr_fix_duplicate_paths(url, api_key, dry_run, no_confirm):
196    """Fix duplicate path segments in artist paths.
197
198    Detects and fixes paths like /music/library/library/Artist
199    to /music/library/Artist.
200
201    API key can be provided via --api-key flag or LIDARR_API_KEY environment variable.
202
203    Examples:
204        # With API key flag
205        arr lidarr fix-duplicate-paths http://localhost:8686 -k your-api-key
206
207        # With environment variable
208        export LIDARR_API_KEY=your-api-key
209        arr lidarr fix-duplicate-paths http://localhost:8686
210
211        # Dry run to preview changes
212        arr lidarr fix-duplicate-paths http://localhost:8686 -k your-api-key \\
213            --dry-run
214    """
215    from commands import lidarr_fix_duplicate_paths
216    lidarr_fix_duplicate_paths.run(url, api_key, dry_run, no_confirm)
217
218
219@lidarr.command("update-monitoring")
220@click.argument("url")
221@click.argument("api_key")
222@click.option(
223    "--monitor",
224    default="future",
225    type=click.Choice(["all", "future", "missing", "existing", "none"]),
226    help="Album monitoring mode (default: future)",
227)
228@click.option(
229    "--monitor-new-items",
230    type=click.Choice(["all", "new", "none"]),
231    help=(
232        "Monitor new items mode - controls automatic monitoring "
233        "of albums added to MusicBrainz after the artist is in Lidarr"
234    ),
235)
236@click.option(
237    "--artist",
238    "-a",
239    help="Filter by artist name pattern (case-insensitive)",
240)
241@click.option(
242    "--dry-run",
243    is_flag=True,
244    help="Show what would be changed without making changes",
245)
246@click.option(
247    "--no-confirm",
248    "--yolo",
249    is_flag=True,
250    help="Skip interactive confirmation (use with caution)",
251)
252def lidarr_update_monitoring(
253    url, api_key, monitor, monitor_new_items, artist, dry_run, no_confirm
254):
255    """Update artist monitoring settings.
256
257    Changes the monitoring mode for artists in Lidarr. By default updates
258    all artists to monitor 'future' albums only.
259
260    Monitoring modes (--monitor):
261      - all: Monitor all albums (past and future)
262      - future: Monitor only future albums (default)
263      - missing: Monitor only missing albums
264      - existing: Monitor only existing albums
265      - none: Don't monitor any albums
266
267    Monitor new items modes (--monitor-new-items):
268      Controls what happens with albums added to MusicBrainz AFTER the artist
269      is already in Lidarr (e.g., new releases or newly discovered old albums)
270      - all: Automatically monitor all new albums
271      - new: Monitor new albums
272      - none: Don't monitor new albums
273
274    Examples:
275        # Update all artists to monitor future albums only
276        arr lidarr update-monitoring http://localhost:8686 your-api-key
277
278        # Update specific artist pattern
279        arr lidarr update-monitoring http://localhost:8686 your-api-key \\
280            --artist "Taylor Swift"
281
282        # Set all artists to monitor all albums
283        arr lidarr update-monitoring http://localhost:8686 your-api-key \\
284            --monitor all
285
286        # Set all artists to monitor future albums and auto-monitor new items
287        arr lidarr update-monitoring http://localhost:8686 your-api-key \\
288            --monitor future --monitor-new-items all
289
290        # Dry run to see what would change
291        arr lidarr update-monitoring http://localhost:8686 your-api-key \\
292            --dry-run
293    """
294    from commands import lidarr_update_monitoring
295
296    lidarr_update_monitoring.run(
297        url, api_key, monitor, monitor_new_items, artist, dry_run, no_confirm
298    )
299
300
301@lidarr.command("update-metadata-profile")
302@click.argument("url")
303@click.argument("api_key")
304@click.option(
305    "--profile",
306    "-p",
307    help=(
308        "Metadata profile name to apply "
309        "(if not specified, lists available profiles)"
310    ),
311)
312@click.option(
313    "--artist",
314    "-a",
315    help="Filter by artist name pattern (case-insensitive)",
316)
317@click.option(
318    "--dry-run",
319    is_flag=True,
320    help="Show what would be changed without making changes",
321)
322@click.option(
323    "--no-confirm",
324    "--yolo",
325    is_flag=True,
326    help="Skip interactive confirmation (use with caution)",
327)
328def lidarr_update_metadata_profile(
329    url, api_key, profile, artist, dry_run, no_confirm
330):
331    """Update artist metadata profile settings.
332
333    Changes the metadata profile for artists in Lidarr. Metadata profiles
334    control which release types and album types are monitored (e.g., studio
335    albums, EPs, live albums, etc.).
336
337    If no profile is specified, the command will list available profiles and
338    use the first one as default.
339
340    Examples:
341        # List available metadata profiles and update all artists to first one
342        arr lidarr update-metadata-profile http://localhost:8686 your-api-key
343
344        # Update all artists to a specific metadata profile
345        arr lidarr update-metadata-profile http://localhost:8686 \\
346            your-api-key --profile "Standard"
347
348        # Update specific artist pattern
349        arr lidarr update-metadata-profile http://localhost:8686 \\
350            your-api-key --profile "Standard" --artist "Taylor Swift"
351
352        # Dry run to see what would change
353        arr lidarr update-metadata-profile http://localhost:8686 \\
354            your-api-key --profile "Standard" --dry-run
355    """
356    from commands import lidarr_update_metadata_profile
357
358    lidarr_update_metadata_profile.run(
359        url, api_key, profile, artist, dry_run, no_confirm
360    )
361
362
363@lidarr.command("manage-queue")
364@click.argument("url")
365@click.argument("api_key")
366@click.option(
367    "--filter",
368    "filter_type",
369    default="all",
370    type=click.Choice(["all", "manual", "warning", "error", "completed"]),
371    help="Filter queue items by type (default: all)",
372)
373@click.option(
374    "--tracked-state",
375    default=None,
376    help=(
377        "Filter by tracked download state "
378        "(e.g., importFailed, imported, importing)"
379    ),
380)
381@click.option(
382    "--remove-from-client/--keep-in-client",
383    default=True,
384    help="Remove from download client (default: yes)",
385)
386@click.option(
387    "--blocklist/--no-blocklist",
388    default=False,
389    help="Add to blocklist (default: no)",
390)
391@click.option(
392    "--skip-redownload/--allow-redownload",
393    default=False,
394    help="Skip automatic redownload (default: no)",
395)
396@click.option(
397    "--dry-run",
398    is_flag=True,
399    help="Show what would be removed without making changes",
400)
401@click.option(
402    "--no-confirm",
403    "--yolo",
404    is_flag=True,
405    help="Skip interactive confirmation (use with caution)",
406)
407def lidarr_manage_queue(
408    url,
409    api_key,
410    filter_type,
411    tracked_state,
412    remove_from_client,
413    blocklist,
414    skip_redownload,
415    dry_run,
416    no_confirm,
417):
418    """Manage Lidarr queue items with interactive selection.
419
420    Browse and remove items from the Lidarr queue, with filtering options
421    for items that need manual import, have warnings, errors, or are completed.
422
423    URL: Lidarr server URL (e.g., http://localhost:8686)
424    API_KEY: Lidarr API key
425
426    Filter types:
427      - all: Show all queue items (default)
428      - manual: Items that need manual import
429      - warning: Items with warnings
430      - error: Items with errors
431      - completed: Items that have completed downloading
432
433    Tracked states (use with --tracked-state):
434      - importFailed: Items where import failed
435      - imported: Successfully imported items
436      - importing: Currently importing items
437      - failedPending: Failed items pending retry
438      - (and other tracked download states from Lidarr)
439
440    Examples:
441        # View and manage all queue items
442        arr lidarr manage-queue http://localhost:8686 your-api-key
443
444        # Show only items that need manual import
445        arr lidarr manage-queue http://localhost:8686 your-api-key \\
446            --filter manual
447
448        # Show completed items where import failed
449        arr lidarr manage-queue http://localhost:8686 your-api-key \\
450            --filter completed --tracked-state importFailed
451
452        # Show only items with specific tracked state
453        arr lidarr manage-queue http://localhost:8686 your-api-key \\
454            --tracked-state importing
455
456        # Remove items with errors, add to blocklist
457        arr lidarr manage-queue http://localhost:8686 your-api-key \\
458            --filter error --blocklist
459
460        # Dry run to preview items
461        arr lidarr manage-queue http://localhost:8686 your-api-key \\
462            --filter manual --dry-run
463    """
464    from commands import lidarr_manage_queue
465    lidarr_manage_queue.run(
466        url,
467        api_key,
468        filter_type,
469        tracked_state,
470        remove_from_client,
471        blocklist,
472        skip_redownload,
473        dry_run,
474        no_confirm
475    )
476
477
478@lidarr.command("sync-spotify")
479@click.argument("url")
480@click.option(
481    "--api-key",
482    "-k",
483    envvar="LIDARR_API_KEY",
484    required=True,
485    help="Lidarr API key",
486)
487@click.option(
488    "--spotify-client-id",
489    envvar="SPOTIFY_CLIENT_ID",
490    required=True,
491    help="Spotify application client ID",
492)
493@click.option(
494    "--spotify-client-secret",
495    envvar="SPOTIFY_CLIENT_SECRET",
496    required=True,
497    help="Spotify application client secret",
498)
499@click.option(
500    "--spotify-username",
501    "-u",
502    envvar="SPOTIFY_USERNAME",
503    help="Spotify username for interactive mode (to list your playlists)",
504)
505@click.argument("playlist_ids", nargs=-1, required=False)
506@click.option(
507    "--root-folder",
508    default="/music",
509    help="Root folder for music in Lidarr",
510)
511@click.option(
512    "--monitor",
513    default="all",
514    type=click.Choice(["all", "future", "missing", "existing", "none"]),
515    help="Album monitoring mode",
516)
517@click.option(
518    "--request-delay",
519    default=1.5,
520    type=float,
521    help="Delay between Lidarr API requests (seconds)",
522)
523@click.option(
524    "--all-playlists",
525    is_flag=True,
526    help="Select all playlists (skip fzf selection)",
527)
528@click.option(
529    "--dry-run",
530    is_flag=True,
531    help="Show what would be added without making changes",
532)
533@click.option(
534    "--no-confirm",
535    "--yolo",
536    is_flag=True,
537    help="Skip interactive confirmation (use with caution)",
538)
539@click.option(
540    "--debug",
541    is_flag=True,
542    help="Enable debug output",
543)
544def lidarr_sync_spotify(
545    url,
546    api_key,
547    spotify_client_id,
548    spotify_client_secret,
549    spotify_username,
550    playlist_ids,
551    root_folder,
552    monitor,
553    request_delay,
554    all_playlists,
555    dry_run,
556    no_confirm,
557    debug,
558):
559    """Sync Spotify playlists to Lidarr.
560
561    Fetches tracks from Spotify playlists and adds missing artists to Lidarr.
562
563    URL: Lidarr server URL (e.g., http://localhost:8686)
564
565    Credentials can be provided via flags or environment variables:
566    - LIDARR_API_KEY
567    - SPOTIFY_CLIENT_ID (get from https://developer.spotify.com/dashboard)
568    - SPOTIFY_CLIENT_SECRET
569    - SPOTIFY_USERNAME (optional, for interactive mode)
570
571    Interactive mode: If no PLAYLIST_IDS are provided and --spotify-username
572    is set, you'll be prompted to select from your public playlists using fzf.
573
574    Examples:
575        # Interactive mode - select from your playlists
576        export SPOTIFY_USERNAME=your-spotify-username
577        arr lidarr sync-spotify http://localhost:8686 -u your-username
578
579        # Or with environment variables
580        export LIDARR_API_KEY=your-lidarr-key
581        export SPOTIFY_CLIENT_ID=your-client-id
582        export SPOTIFY_CLIENT_SECRET=your-client-secret
583        export SPOTIFY_USERNAME=your-username
584        arr lidarr sync-spotify http://localhost:8686
585
586        # Select all playlists automatically
587        arr lidarr sync-spotify http://localhost:8686 \\
588            -u username --all-playlists
589
590        # Sync specific playlists (no username needed)
591        arr lidarr sync-spotify http://localhost:8686 \\
592            37i9dQZF1DXcBWIGoYBM5M 37i9dQZF1DX0XUsuxWHRQd
593
594        # Dry run to see artist list for manual addition
595        arr lidarr sync-spotify http://localhost:8686 -u username --dry-run
596
597        # Monitor only future albums
598        arr lidarr sync-spotify http://localhost:8686 \\
599            -u username --monitor future
600    """
601    from commands import lidarr_sync_spotify
602    lidarr_sync_spotify.run(
603        url,
604        api_key,
605        spotify_client_id,
606        spotify_client_secret,
607        spotify_username,
608        list(playlist_ids),
609        root_folder,
610        monitor,
611        request_delay,
612        dry_run,
613        no_confirm,
614        all_playlists,
615        debug
616    )
617
618
619@jellyfin.command("sync-spotify")
620@click.option(
621    "--url",
622    envvar="JELLYFIN_URL",
623    default="http://localhost:8096",
624    help="Jellyfin server URL",
625)
626@click.option(
627    "--api-token",
628    "-t",
629    envvar="JELLYFIN_API_TOKEN",
630    required=True,
631    help="Jellyfin API token",
632)
633@click.option(
634    "--user-id",
635    "-i",
636    envvar="JELLYFIN_USER_ID",
637    required=True,
638    help="Jellyfin user ID",
639)
640@click.option(
641    "--spotify-client-id",
642    envvar="SPOTIFY_CLIENT_ID",
643    required=True,
644    help="Spotify application client ID",
645)
646@click.option(
647    "--spotify-client-secret",
648    envvar="SPOTIFY_CLIENT_SECRET",
649    required=True,
650    help="Spotify application client secret",
651)
652@click.option(
653    "--spotify-username",
654    "-u",
655    envvar="SPOTIFY_USERNAME",
656    help="Spotify username for interactive mode (to list your playlists)",
657)
658@click.option(
659    "--playlist-id",
660    "-p",
661    "playlist_ids",
662    multiple=True,
663    help="Spotify playlist ID (can be specified multiple times)",
664)
665@click.option(
666    "--all",
667    "--all-playlists",
668    "all_playlists",
669    is_flag=True,
670    help="Sync all user playlists (requires --spotify-username)",
671)
672@click.option(
673    "--match-threshold",
674    default=0.6,
675    type=float,
676    help="Minimum confidence score for track matching (0.0-1.0)",
677)
678@click.option(
679    "--public",
680    is_flag=True,
681    help="Make created playlists public",
682)
683@click.option(
684    "--skip-existing",
685    is_flag=True,
686    help="Skip playlists that already exist in Jellyfin",
687)
688@click.option(
689    "--dry-run",
690    is_flag=True,
691    help="Show what would be created without making changes",
692)
693@click.option(
694    "--no-confirm",
695    "--yolo",
696    is_flag=True,
697    help="Skip interactive confirmation (use with caution)",
698)
699@click.option(
700    "--debug",
701    is_flag=True,
702    help="Show debug output for troubleshooting",
703)
704def jellyfin_sync_spotify(
705    url,
706    api_token,
707    user_id,
708    spotify_client_id,
709    spotify_client_secret,
710    spotify_username,
711    playlist_ids,
712    all_playlists,
713    match_threshold,
714    public,
715    skip_existing,
716    dry_run,
717    no_confirm,
718    debug,
719):
720    """Sync Spotify playlists to Jellyfin.
721
722    Fetches tracks from Spotify playlists and creates matching playlists
723    in Jellyfin.
724
725    Three modes of operation:
726    1. Interactive selector (fzf): Use --spotify-username without
727       --playlist-id or --all
728    2. Sync all playlists: Use --spotify-username --all
729    3. Sync specific playlists: Use --playlist-id (one or more times)
730
731    All options can be provided via flags or environment variables:
732    - JELLYFIN_URL (default: http://localhost:8096)
733    - JELLYFIN_API_TOKEN (get from Jellyfin Dashboard > API Keys)
734    - JELLYFIN_USER_ID (get from user profile URL)
735    - SPOTIFY_CLIENT_ID (get from https://developer.spotify.com/dashboard)
736    - SPOTIFY_CLIENT_SECRET
737    - SPOTIFY_USERNAME (optional, for interactive/all modes)
738
739    Examples:
740        # Interactive mode - select playlists with fzf
741        export JELLYFIN_API_TOKEN=your-token
742        export JELLYFIN_USER_ID=your-user-id
743        export SPOTIFY_CLIENT_ID=your-client-id
744        export SPOTIFY_CLIENT_SECRET=your-client-secret
745        export SPOTIFY_USERNAME=your-username
746        arr jellyfin sync-spotify
747
748        # Sync ALL playlists from a user
749        arr jellyfin sync-spotify -u username --all
750
751        # Sync specific playlists by ID
752        arr jellyfin sync-spotify \\
753            -p 37i9dQZF1DXcBWIGoYBM5M -p 37i9dQZF1DX0XUsuxWHRQd
754
755        # Dry run with interactive selection
756        arr jellyfin sync-spotify -u username --dry-run
757
758        # Lower match threshold for more matches (may have false positives)
759        arr jellyfin sync-spotify -u username --match-threshold 0.4
760
761        # Make playlists public
762        arr jellyfin sync-spotify -u username --public
763
764        # Skip playlists that already exist (don't update them)
765        arr jellyfin sync-spotify -u username --skip-existing
766    """
767    from commands import jellyfin_sync_spotify
768    jellyfin_sync_spotify.run(
769        url,
770        api_token,
771        user_id,
772        spotify_client_id,
773        spotify_client_secret,
774        spotify_username,
775        list(playlist_ids),
776        all_playlists,
777        match_threshold,
778        public,
779        skip_existing,
780        dry_run,
781        no_confirm,
782        debug
783    )
784
785
786@cli.command()
787@click.argument(
788    "shell",
789    type=click.Choice(["bash", "zsh", "fish"], case_sensitive=False),
790)
791def completion(shell):
792    """Generate shell completion script.
793
794    Install completions by running:
795
796      Bash:
797        arr completion bash > ~/.local/share/bash-completion/completions/arr
798        # Or: arr completion bash | sudo tee /etc/bash_completion.d/arr
799
800      Zsh:
801        arr completion zsh > ~/.zsh/completions/_arr
802        # Then add to .zshrc: fpath=(~/.zsh/completions $fpath)
803
804      Fish:
805        arr completion fish > ~/.config/fish/completions/arr.fish
806
807    After installing, restart your shell or source the completion file.
808    """
809    try:
810        from click.shell_completion import get_completion_class
811    except ImportError:
812        # Fallback for older Click versions
813        click.echo(
814            "Error: Shell completion requires Click 8.0 or higher",
815            err=True
816        )
817        raise click.Abort()
818
819    shell_lower = shell.lower()
820
821    try:
822        # Get the appropriate completion class for the shell
823        completion_class = get_completion_class(shell_lower)
824        if completion_class is None:
825            click.echo(
826                f"Error: Unsupported shell '{shell}'",
827                err=True
828            )
829            raise click.Abort()
830
831        # Create completion instance
832        comp = completion_class(cli, {}, "arr", "_ARR_COMPLETE")
833
834        # Generate and output the completion script
835        click.echo(comp.source())
836    except Exception as e:
837        click.echo(f"Error generating completion: {e}", err=True)
838        raise click.Abort()
839
840
841if __name__ == "__main__":
842    # Fix program name for wrapper and enable shell completion
843    cli(prog_name="arr")