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")