mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
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:
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Services.Spotify;
|
using allstarr.Services.Spotify;
|
||||||
|
using allstarr.Services.Jellyfin;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
@@ -30,6 +31,7 @@ public class AdminController : ControllerBase
|
|||||||
private readonly SpotifyApiClient _spotifyClient;
|
private readonly SpotifyApiClient _spotifyClient;
|
||||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
|
private readonly HttpClient _jellyfinHttpClient;
|
||||||
private const string EnvFilePath = "/app/.env";
|
private const string EnvFilePath = "/app/.env";
|
||||||
private const string CacheDirectory = "/app/cache/spotify";
|
private const string CacheDirectory = "/app/cache/spotify";
|
||||||
|
|
||||||
@@ -44,7 +46,8 @@ public class AdminController : ControllerBase
|
|||||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||||
SpotifyApiClient spotifyClient,
|
SpotifyApiClient spotifyClient,
|
||||||
SpotifyPlaylistFetcher playlistFetcher,
|
SpotifyPlaylistFetcher playlistFetcher,
|
||||||
RedisCacheService cache)
|
RedisCacheService cache,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
@@ -57,6 +60,7 @@ public class AdminController : ControllerBase
|
|||||||
_spotifyClient = spotifyClient;
|
_spotifyClient = spotifyClient;
|
||||||
_playlistFetcher = playlistFetcher;
|
_playlistFetcher = playlistFetcher;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
|
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -528,6 +532,150 @@ public class AdminController : ControllerBase
|
|||||||
return await UpdateConfig(updateRequest);
|
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)
|
private static string MaskValue(string? value, int showLast = 0)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(value)) return "(not set)";
|
if (string.IsNullOrEmpty(value)) return "(not set)";
|
||||||
@@ -558,3 +706,9 @@ public class AddPlaylistRequest
|
|||||||
public string SpotifyId { get; set; } = string.Empty;
|
public string SpotifyId { get; set; } = string.Empty;
|
||||||
public string LocalTracksPosition { get; set; } = "first";
|
public string LocalTracksPosition { get; set; } = "first";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public class LinkPlaylistRequest
|
||||||
|
{
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public string SpotifyPlaylistId { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|||||||
@@ -456,7 +456,8 @@
|
|||||||
|
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
<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 class="tab" data-tab="config">Configuration</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -516,6 +517,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 -->
|
<!-- Playlists Tab -->
|
||||||
<div class="tab-content" id="tab-playlists">
|
<div class="tab-content" id="tab-playlists">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
@@ -724,6 +759,31 @@
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<script>
|
||||||
// Current edit setting state
|
// Current edit setting state
|
||||||
let currentEditKey = null;
|
let currentEditKey = null;
|
||||||
@@ -881,7 +941,7 @@
|
|||||||
const tbody = document.getElementById('playlist-table-body');
|
const tbody = document.getElementById('playlist-table-body');
|
||||||
|
|
||||||
if (data.playlists.length === 0) {
|
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;
|
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() {
|
async function refreshPlaylists() {
|
||||||
try {
|
try {
|
||||||
showToast('Refreshing playlists...', 'success');
|
showToast('Refreshing playlists...', 'success');
|
||||||
@@ -1147,6 +1320,7 @@
|
|||||||
// Initial load
|
// Initial load
|
||||||
fetchStatus();
|
fetchStatus();
|
||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
|
fetchJellyfinPlaylists();
|
||||||
fetchConfig();
|
fetchConfig();
|
||||||
|
|
||||||
// Auto-refresh every 30 seconds
|
// Auto-refresh every 30 seconds
|
||||||
|
|||||||
Reference in New Issue
Block a user