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
This commit is contained in:
2026-02-03 15:03:31 -05:00
parent 4036c739a3
commit 3826f29019
2 changed files with 331 additions and 3 deletions

View File

@@ -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> 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();
}
/// <summary>
@@ -528,6 +532,150 @@ public class AdminController : ControllerBase
return await UpdateConfig(updateRequest);
}
/// <summary>
/// Get all playlists from Jellyfin
/// </summary>
[HttpGet("jellyfin/playlists")]
public async Task<IActionResult> 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<object>();
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 });
}
}
/// <summary>
/// Link a Jellyfin playlist to a Spotify playlist
/// </summary>
[HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")]
public async Task<IActionResult> 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<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
}
};
return await UpdateConfig(updateRequest);
}
/// <summary>
/// Unlink a playlist (remove from configuration)
/// </summary>
[HttpDelete("jellyfin/playlists/{name}/unlink")]
public async Task<IActionResult> 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;
}