Commit 627507cb9b39

Vincent Demeester <vincent@sbr.pm>
2026-01-07 16:38:17
feat(jellyfin-sync): Add playlist support for more explicit sync control
- Enable playlist-based sync for more granular control than favorites - Maintain backward compatibility with favorites-based workflow - Configure rhea to use "Keep" playlist for targeted media sync Signed-off-by: Vincent Demeester <vincent@sbr.pm>
1 parent f714ad7
Changed files (5)
modules
jellyfin-favorites-sync
systems
tools
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