Commit 627507cb9b39
Changed files (5)
modules
jellyfin-favorites-sync
systems
rhea
tools
arr
jellyfin-favorites-sync
modules/jellyfin-favorites-sync/default.nix
@@ -60,6 +60,18 @@ in
description = "Jellyfin user ID or username (will be auto-resolved to GUID)";
};
+ playlistId = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Jellyfin playlist ID to sync (instead of favorites)";
+ };
+
+ playlistName = mkOption {
+ type = types.nullOr types.str;
+ default = null;
+ description = "Jellyfin playlist name to sync (instead of favorites)";
+ };
+
sourceRoot = mkOption {
type = types.str;
default = "/neo/videos";
@@ -148,6 +160,8 @@ in
--jellyfin-url "${cfg.jellyfinUrl}" \
--api-key "$API_KEY" \
--user-id "${cfg.userId}" \
+ ${optionalString (cfg.playlistId != null) "--playlist-id \"${cfg.playlistId}\""} \
+ ${optionalString (cfg.playlistName != null) "--playlist-name \"${cfg.playlistName}\""} \
--source-root "${cfg.sourceRoot}" \
--dest-host "${cfg.destination.host}" \
--dest-user "${cfg.destination.user}" \
systems/rhea/extra.nix
@@ -607,13 +607,16 @@ in
};
};
jellyfin-favorites-sync = {
- enable = false;
+ enable = true;
schedule = "daily"; # Run daily at midnight
jellyfinUrl = "http://localhost:8096";
apiKeyFile = config.age.secrets."jellyfin-favorites-sync-api-key".path;
userId = "400fef4e0ab2448cb8a2bc8ca2facc4f"; # vincent user ID
+ # Use "Keep" playlist instead of favorites
+ playlistName = "Keep";
+
sourceRoot = "/neo/videos";
destination = {
tools/arr/lib.py
@@ -801,6 +801,33 @@ class JellyfinClient:
items = result.get("Items", [])
return [item.get("Id") for item in items if item.get("Id")]
+ def get_playlist_items_full(
+ self,
+ playlist_id: str,
+ fields: Optional[List[str]] = None,
+ ) -> List[Dict[str, Any]]:
+ """
+ Get all items in a playlist with full metadata.
+
+ Args:
+ playlist_id: Jellyfin playlist ID
+ fields: Additional fields to include in response
+ Defaults to ["Path", "MediaSources"]
+
+ Returns:
+ List of items with full metadata
+ """
+ if fields is None:
+ fields = ["Path", "MediaSources"]
+
+ params = {
+ "ParentId": playlist_id,
+ "Fields": ",".join(fields),
+ }
+
+ result = self.get(f"/Users/{self.user_id}/Items", params=params)
+ return result.get("Items", [])
+
def clear_playlist(self, playlist_id: str) -> bool:
"""
Remove all items from a playlist.
tools/jellyfin-favorites-sync/jellyfin-favorites-sync
@@ -242,6 +242,14 @@ def execute_rsync(
required=True,
help="Jellyfin user ID or username",
)
+@click.option(
+ "--playlist-id",
+ help="Jellyfin playlist ID to sync (instead of favorites)",
+)
+@click.option(
+ "--playlist-name",
+ help="Jellyfin playlist name to sync (instead of favorites)",
+)
@click.option(
"--source-root",
type=click.Path(exists=True, file_okay=False),
@@ -284,6 +292,8 @@ def main(
api_key: str,
api_key_file: str,
user_id: str,
+ playlist_id: str,
+ playlist_name: str,
source_root: str,
dest_host: str,
dest_user: str,
@@ -322,7 +332,10 @@ def main(
# Print header
click.echo("=" * 80)
- click.echo("Jellyfin Favorites Sync")
+ if playlist_id or playlist_name:
+ click.echo("Jellyfin Playlist Sync")
+ else:
+ click.echo("Jellyfin Favorites Sync")
click.echo("=" * 80)
click.echo(f"Jellyfin URL: {jellyfin_url}")
click.echo(f"User ID: {user_id}")
@@ -340,26 +353,58 @@ def main(
click.echo(f"✗ Failed to connect to Jellyfin: {e}", err=True)
sys.exit(1)
- # Query favorites
- click.echo("Querying favorites...")
- try:
- favorites = client.get_favorites(
- include_types=["Movie", "Series"],
- fields=["Path", "MediaSources"],
- )
- click.echo(f"Found {len(favorites)} favorite items")
- except Exception as e:
- click.echo(f"✗ Failed to query favorites: {e}", err=True)
- sys.exit(1)
+ # Resolve playlist name to ID if needed
+ resolved_playlist_id = playlist_id
+ if playlist_name and not playlist_id:
+ click.echo(f"Looking up playlist: {playlist_name}")
+ try:
+ playlists = client.get_playlists()
+ matched = [p for p in playlists if p.get("Name", "").lower() == playlist_name.lower()]
+ if matched:
+ resolved_playlist_id = matched[0].get("Id")
+ click.echo(f"Found playlist: {matched[0].get('Name')} (ID: {resolved_playlist_id})")
+ else:
+ click.echo(f"✗ Playlist '{playlist_name}' not found", err=True)
+ sys.exit(1)
+ except Exception as e:
+ click.echo(f"✗ Failed to look up playlist: {e}", err=True)
+ sys.exit(1)
- if not favorites:
- click.echo("⚠ No favorites found. Nothing to sync.")
+ # Query items (from playlist or favorites)
+ if resolved_playlist_id:
+ click.echo(f"Querying playlist items...")
+ try:
+ items = client.get_playlist_items_full(
+ resolved_playlist_id,
+ fields=["Path", "MediaSources"],
+ )
+ click.echo(f"Found {len(items)} items in playlist")
+ except Exception as e:
+ click.echo(f"✗ Failed to query playlist: {e}", err=True)
+ sys.exit(1)
+ else:
+ click.echo("Querying favorites...")
+ try:
+ items = client.get_favorites(
+ include_types=["Movie", "Series"],
+ fields=["Path", "MediaSources"],
+ )
+ click.echo(f"Found {len(items)} favorite items")
+ except Exception as e:
+ click.echo(f"✗ Failed to query favorites: {e}", err=True)
+ sys.exit(1)
+
+ if not items:
+ if resolved_playlist_id:
+ click.echo("⚠ No items in playlist. Nothing to sync.")
+ else:
+ click.echo("⚠ No favorites found. Nothing to sync.")
return
# Expand series to episodes
click.echo("\nExpanding series to episodes...")
try:
- expanded = expand_favorites(client, favorites)
+ expanded = expand_favorites(client, items)
click.echo(f"Total items after expansion: {len(expanded)}")
except Exception as e:
click.echo(f"✗ Failed to expand series: {e}", err=True)
@@ -395,7 +440,10 @@ def main(
click.echo("✓ Dry-run completed successfully")
else:
click.echo("✓ Sync completed successfully")
- click.echo(f" Favorites: {len(favorites)}")
+ if resolved_playlist_id:
+ click.echo(f" Playlist items: {len(items)}")
+ else:
+ click.echo(f" Favorites: {len(items)}")
click.echo(f" Items (after series expansion): {len(expanded)}")
click.echo(f" Directories synced: {len(sync_paths)}")
else:
tools/jellyfin-favorites-sync/README.md
@@ -1,18 +1,18 @@
# Jellyfin Favorites Sync
-Sync Jellyfin favorite movies and series to a remote host via rsync.
+Sync Jellyfin playlist or favorite movies and series to a remote host via rsync.
## Overview
-This tool queries a Jellyfin server for favorited items (movies and TV series), expands series to individual episodes, discovers parent directories containing media files and metadata (subtitles, .nfo files, posters), and syncs them to a remote host using rsync in mirror mode.
+This tool queries a Jellyfin server for items in a playlist (or favorited items), expands series to individual episodes, discovers parent directories containing media files and metadata (subtitles, .nfo files, posters), and syncs them to a remote host using rsync in mirror mode.
## Features
-- **Favorites Query**: Automatically discovers movies and series marked as favorite
-- **Series Expansion**: Expands favorited TV series to include all episodes
+- **Playlist or Favorites**: Query items from a Jellyfin playlist or favorites
+- **Series Expansion**: Expands TV series to include all episodes
- **Complete Sync**: Syncs parent directories to include all auxiliary files (.srt, .nfo, poster.jpg, etc.)
-- **Mirror Mode**: Uses rsync `--delete` to remove files when items are unfavorited
-- **Efficient**: Single rsync invocation using `--files-from` for large favorite sets
+- **Mirror Mode**: Uses rsync `--delete` to remove files when items are removed from playlist/favorites
+- **Efficient**: Single rsync invocation using `--files-from` for large sets
- **Resumable**: Supports `--partial` and `--append-verify` for interrupted transfers
- **Configurable**: Target host, paths, and SSH arguments all configurable
@@ -20,6 +20,22 @@ This tool queries a Jellyfin server for favorited items (movies and TV series),
### CLI
+**Using a playlist:**
+```bash
+jellyfin-favorites-sync \
+ --jellyfin-url https://jellyfin.sbr.pm \
+ --api-key-file /run/agenix/jellyfin-api-key \
+ --user-id vincent \
+ --playlist-name "Sync to NAS" \
+ --source-root /neo/videos \
+ --dest-host aix.sbr.pm \
+ --dest-user vincent \
+ --dest-root /data/favorites \
+ --dry-run \
+ --verbose
+```
+
+**Using favorites:**
```bash
jellyfin-favorites-sync \
--jellyfin-url https://jellyfin.sbr.pm \
@@ -39,6 +55,8 @@ jellyfin-favorites-sync \
- `--api-key`: Jellyfin API key (use --api-key-file for secrets)
- `--api-key-file`: Path to file containing API key (recommended)
- `--user-id`: Jellyfin user ID or username (required)
+- `--playlist-id`: Jellyfin playlist ID to sync (optional, instead of favorites)
+- `--playlist-name`: Jellyfin playlist name to sync (optional, instead of favorites)
- `--source-root`: Root path of Jellyfin library (default: /neo/videos)
- `--dest-host`: Destination SSH host (required)
- `--dest-user`: SSH user (default: vincent)
@@ -51,6 +69,32 @@ jellyfin-favorites-sync \
Configure as a systemd service with scheduled execution:
+**Using a playlist:**
+```nix
+services.jellyfin-favorites-sync = {
+ enable = true;
+ schedule = "daily";
+
+ jellyfinUrl = "http://localhost:8096";
+ apiKeyFile = config.age.secrets."jellyfin-favorites-sync-api-key".path;
+ userId = "vincent";
+
+ # Use a playlist instead of favorites
+ playlistName = "Sync to NAS";
+
+ sourceRoot = "/neo/videos";
+
+ destination = {
+ host = "aix.sbr.pm";
+ user = "vincent";
+ root = "/data/favorites";
+ };
+
+ sshArgs = [ "-o StrictHostKeyChecking=accept-new" ];
+};
+```
+
+**Using favorites (default):**
```nix
services.jellyfin-favorites-sync = {
enable = true;
@@ -75,8 +119,8 @@ services.jellyfin-favorites-sync = {
## How It Works
1. **Connect to Jellyfin**: Authenticate using API key
-2. **Query Favorites**: Fetch all favorited movies and series
-3. **Expand Series**: For each favorited series, fetch all episodes
+2. **Query Items**: Fetch items from playlist (if specified) or favorites
+3. **Expand Series**: For each series, fetch all episodes
4. **Discover Paths**: Extract parent directories containing media + metadata
5. **Generate File List**: Create rsync `--files-from` input
6. **Execute Rsync**: Sync to remote host with `--delete` for mirror mode