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;
}

View File

@@ -456,7 +456,8 @@
<div class="tabs">
<div class="tab active" data-tab="dashboard">Dashboard</div>
<div class="tab" data-tab="playlists">Playlists</div>
<div class="tab" data-tab="jellyfin-playlists">Jellyfin Playlists</div>
<div class="tab" data-tab="playlists">Configured Playlists</div>
<div class="tab" data-tab="config">Configuration</div>
</div>
@@ -516,6 +517,40 @@
</div>
</div>
<!-- Jellyfin Playlists Tab -->
<div class="tab-content" id="tab-jellyfin-playlists">
<div class="card">
<h2>
Jellyfin Playlists
<div class="actions">
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
</div>
</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Link Jellyfin playlists to Spotify playlists to fill in missing tracks.
Playlists created by Spotify Import plugin will appear here.
</p>
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Tracks</th>
<th>Linked Spotify ID</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="jellyfin-playlist-table-body">
<tr>
<td colspan="5" class="loading">
<span class="spinner"></span> Loading Jellyfin playlists...
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Playlists Tab -->
<div class="tab-content" id="tab-playlists">
<div class="card">
@@ -724,6 +759,31 @@
</div>
</div>
<!-- Link Playlist Modal -->
<div class="modal" id="link-playlist-modal">
<div class="modal-content">
<h3>Link to Spotify Playlist</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Enter the Spotify playlist ID to link with this Jellyfin playlist.
Allstarr will fill in missing tracks from Spotify.
</p>
<div class="form-group">
<label>Jellyfin Playlist</label>
<input type="text" id="link-jellyfin-name" readonly style="background: var(--bg-primary);">
<input type="hidden" id="link-jellyfin-id">
</div>
<div class="form-group">
<label>Spotify Playlist ID</label>
<input type="text" id="link-spotify-id" placeholder="e.g., 37i9dQZF1DX0XUsuxWHRQd">
<small style="color: var(--text-secondary);">Get this from Spotify URL or Spotify Import plugin</small>
</div>
<div class="modal-actions">
<button onclick="closeModal('link-playlist-modal')">Cancel</button>
<button class="primary" onclick="linkPlaylist()">Link Playlist</button>
</div>
</div>
</div>
<script>
// Current edit setting state
let currentEditKey = null;
@@ -881,7 +941,7 @@
const tbody = document.getElementById('playlist-table-body');
if (data.playlists.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured</td></tr>';
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
return;
}
@@ -948,6 +1008,119 @@
}
}
async function fetchJellyfinPlaylists() {
const tbody = document.getElementById('jellyfin-playlist-table-body');
tbody.innerHTML = '<tr><td colspan="5" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
try {
const res = await fetch('/api/admin/jellyfin/playlists');
if (!res.ok) {
const errorData = await res.json();
tbody.innerHTML = `<tr><td colspan="5" style="text-align:center;color:var(--error);padding:40px;">${errorData.error || 'Failed to fetch playlists'}</td></tr>`;
return;
}
const data = await res.json();
if (data.playlists.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
return;
}
tbody.innerHTML = data.playlists.map(p => {
const statusBadge = p.isConfigured
? '<span class="status-badge success"><span class="status-dot"></span>Linked</span>'
: '<span class="status-badge"><span class="status-dot"></span>Not Linked</span>';
const actionButton = p.isConfigured
? `<button class="danger" onclick="unlinkPlaylist('${escapeHtml(p.name)}')">Unlink</button>`
: `<button class="primary" onclick="openLinkPlaylist('${escapeHtml(p.id)}', '${escapeHtml(p.name)}')">Link to Spotify</button>`;
return `
<tr>
<td><strong>${escapeHtml(p.name)}</strong></td>
<td class="track-count">${p.trackCount || 0}</td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.linkedSpotifyId || '-'}</td>
<td>${statusBadge}</td>
<td>${actionButton}</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch Jellyfin playlists:', error);
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--error);padding:40px;">Failed to fetch playlists</td></tr>';
}
}
function openLinkPlaylist(jellyfinId, name) {
document.getElementById('link-jellyfin-id').value = jellyfinId;
document.getElementById('link-jellyfin-name').value = name;
document.getElementById('link-spotify-id').value = '';
openModal('link-playlist-modal');
}
async function linkPlaylist() {
const jellyfinId = document.getElementById('link-jellyfin-id').value;
const name = document.getElementById('link-jellyfin-name').value;
const spotifyId = document.getElementById('link-spotify-id').value.trim();
if (!spotifyId) {
showToast('Spotify Playlist ID is required', 'error');
return;
}
// Extract ID from URL if pasted
let cleanSpotifyId = spotifyId;
if (spotifyId.includes('spotify.com/playlist/')) {
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
if (match) cleanSpotifyId = match[1];
}
try {
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, spotifyPlaylistId: cleanSpotifyId })
});
const data = await res.json();
if (res.ok) {
showToast('Playlist linked! Restart container to apply.', 'success');
closeModal('link-playlist-modal');
fetchJellyfinPlaylists();
fetchPlaylists();
} else {
showToast(data.error || 'Failed to link playlist', 'error');
}
} catch (error) {
showToast('Failed to link playlist', 'error');
}
}
async function unlinkPlaylist(name) {
if (!confirm(`Unlink playlist "${name}"? This will stop filling in missing tracks.`)) return;
try {
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(name)}/unlink`, {
method: 'DELETE'
});
const data = await res.json();
if (res.ok) {
showToast('Playlist unlinked. Restart container to apply.', 'success');
fetchJellyfinPlaylists();
fetchPlaylists();
} else {
showToast(data.error || 'Failed to unlink playlist', 'error');
}
} catch (error) {
showToast('Failed to unlink playlist', 'error');
}
}
async function refreshPlaylists() {
try {
showToast('Refreshing playlists...', 'success');
@@ -1147,6 +1320,7 @@
// Initial load
fetchStatus();
fetchPlaylists();
fetchJellyfinPlaylists();
fetchConfig();
// Auto-refresh every 30 seconds