From 3826f2901934970ab2f6c6db7126ca5d90e99032 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Tue, 3 Feb 2026 15:03:31 -0500 Subject: [PATCH] Add Jellyfin playlist discovery and linking feature - Added GET /api/admin/jellyfin/playlists to fetch all playlists from Jellyfin - Added POST /api/admin/jellyfin/playlists/{id}/link to link playlist to Spotify - Added DELETE /api/admin/jellyfin/playlists/{name}/unlink to remove link - Added new 'Jellyfin Playlists' tab in admin UI showing all playlists - Shows link status for each playlist (Linked/Not Linked) - Link modal accepts Spotify playlist ID or full URL - Renamed 'Playlists' tab to 'Configured Playlists' for clarity --- allstarr/Controllers/AdminController.cs | 156 ++++++++++++++++++++- allstarr/wwwroot/index.html | 178 +++++++++++++++++++++++- 2 files changed, 331 insertions(+), 3 deletions(-) diff --git a/allstarr/Controllers/AdminController.cs b/allstarr/Controllers/AdminController.cs index 9dc9fce..8cd8ba8 100644 --- a/allstarr/Controllers/AdminController.cs +++ b/allstarr/Controllers/AdminController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using allstarr.Models.Settings; using allstarr.Services.Spotify; +using allstarr.Services.Jellyfin; using allstarr.Services.Common; using allstarr.Filters; using System.Text.Json; @@ -30,6 +31,7 @@ public class AdminController : ControllerBase private readonly SpotifyApiClient _spotifyClient; private readonly SpotifyPlaylistFetcher _playlistFetcher; private readonly RedisCacheService _cache; + private readonly HttpClient _jellyfinHttpClient; private const string EnvFilePath = "/app/.env"; private const string CacheDirectory = "/app/cache/spotify"; @@ -44,7 +46,8 @@ public class AdminController : ControllerBase IOptions squidWtfSettings, SpotifyApiClient spotifyClient, SpotifyPlaylistFetcher playlistFetcher, - RedisCacheService cache) + RedisCacheService cache, + IHttpClientFactory httpClientFactory) { _logger = logger; _configuration = configuration; @@ -57,6 +60,7 @@ public class AdminController : ControllerBase _spotifyClient = spotifyClient; _playlistFetcher = playlistFetcher; _cache = cache; + _jellyfinHttpClient = httpClientFactory.CreateClient(); } /// @@ -528,6 +532,150 @@ public class AdminController : ControllerBase return await UpdateConfig(updateRequest); } + /// + /// Get all playlists from Jellyfin + /// + [HttpGet("jellyfin/playlists")] + public async Task GetJellyfinPlaylists() + { + if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) + { + return BadRequest(new { error = "Jellyfin URL or API key not configured" }); + } + + try + { + // Call Jellyfin API to get all playlists + var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount"; + + var request = new HttpRequestMessage(HttpMethod.Get, url); + request.Headers.Add("X-Emby-Authorization", GetJellyfinAuthHeader()); + + var response = await _jellyfinHttpClient.SendAsync(request); + + if (!response.IsSuccessStatusCode) + { + var errorBody = await response.Content.ReadAsStringAsync(); + _logger.LogError("Failed to fetch Jellyfin playlists: {StatusCode} - {Body}", response.StatusCode, errorBody); + return StatusCode((int)response.StatusCode, new { error = "Failed to fetch playlists from Jellyfin" }); + } + + var json = await response.Content.ReadAsStringAsync(); + using var doc = JsonDocument.Parse(json); + + var playlists = new List(); + + if (doc.RootElement.TryGetProperty("Items", out var items)) + { + foreach (var item in items.EnumerateArray()) + { + var id = item.GetProperty("Id").GetString(); + var name = item.GetProperty("Name").GetString(); + var childCount = item.TryGetProperty("ChildCount", out var cc) ? cc.GetInt32() : 0; + + // Check if this playlist has a linked Spotify ID in ProviderIds + string? linkedSpotifyId = null; + if (item.TryGetProperty("ProviderIds", out var providerIds)) + { + if (providerIds.TryGetProperty("Spotify", out var spotifyId)) + { + linkedSpotifyId = spotifyId.GetString(); + } + } + + // Check if this playlist is already configured in allstarr + var isConfigured = _spotifyImportSettings.Playlists.Any(p => + p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); + + playlists.Add(new + { + id, + name, + trackCount = childCount, + linkedSpotifyId, + isConfigured + }); + } + } + + return Ok(new { playlists }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error fetching Jellyfin playlists"); + return StatusCode(500, new { error = "Failed to fetch playlists", details = ex.Message }); + } + } + + /// + /// Link a Jellyfin playlist to a Spotify playlist + /// + [HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")] + public async Task LinkPlaylist(string jellyfinPlaylistId, [FromBody] LinkPlaylistRequest request) + { + if (string.IsNullOrEmpty(request.SpotifyPlaylistId)) + { + return BadRequest(new { error = "SpotifyPlaylistId is required" }); + } + + if (string.IsNullOrEmpty(request.Name)) + { + return BadRequest(new { error = "Name is required" }); + } + + _logger.LogInformation("Linking Jellyfin playlist {JellyfinId} to Spotify playlist {SpotifyId} with name {Name}", + jellyfinPlaylistId, request.SpotifyPlaylistId, request.Name); + + // Check if already configured + var existingPlaylist = _spotifyImportSettings.Playlists + .FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)); + + if (existingPlaylist != null) + { + return BadRequest(new { error = $"Playlist '{request.Name}' is already configured" }); + } + + // Add the playlist to configuration + var currentPlaylists = _spotifyImportSettings.Playlists.ToList(); + currentPlaylists.Add(new SpotifyPlaylistConfig + { + Name = request.Name, + Id = request.SpotifyPlaylistId, + LocalTracksPosition = LocalTracksPosition.First // Use Spotify order + }); + + // Convert to JSON format for env var + var playlistsJson = JsonSerializer.Serialize( + currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray() + ); + + // Update .env file + var updateRequest = new ConfigUpdateRequest + { + Updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + } + }; + + return await UpdateConfig(updateRequest); + } + + /// + /// Unlink a playlist (remove from configuration) + /// + [HttpDelete("jellyfin/playlists/{name}/unlink")] + public async Task UnlinkPlaylist(string name) + { + var decodedName = Uri.UnescapeDataString(name); + return await RemovePlaylist(decodedName); + } + + private string GetJellyfinAuthHeader() + { + return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\""; + } + private static string MaskValue(string? value, int showLast = 0) { if (string.IsNullOrEmpty(value)) return "(not set)"; @@ -558,3 +706,9 @@ public class AddPlaylistRequest public string SpotifyId { get; set; } = string.Empty; public string LocalTracksPosition { get; set; } = "first"; } + +public class LinkPlaylistRequest +{ + public string Name { get; set; } = string.Empty; + public string SpotifyPlaylistId { get; set; } = string.Empty; +} diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index 3528ea9..2238004 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -456,7 +456,8 @@
Dashboard
-
Playlists
+
Jellyfin Playlists
+
Configured Playlists
Configuration
@@ -516,6 +517,40 @@ + +
+
+

+ Jellyfin Playlists +
+ +
+

+

+ Link Jellyfin playlists to Spotify playlists to fill in missing tracks. + Playlists created by Spotify Import plugin will appear here. +

+ + + + + + + + + + + + + + + +
NameTracksLinked Spotify IDStatusActions
+ Loading Jellyfin playlists... +
+
+
+
@@ -724,6 +759,31 @@
+ + +