feat: implement independent priority rankings for metadata search, downloads, playlist sync, and lyrics sourcing with drag-and-drop support

This commit is contained in:
joshpatra committed 2026-07-07 20:06:13 -04:00
1 parent f27bf2c328
commit 58daeda4da
6 files changed
+183 -69

No files matched your search

+3
View File
@@ -148,6 +148,9 @@ public class ConfigController : ControllerBase
providers = new providers = new
{ {
metadataOrder = GetEnvString(envVars, "MULTI_PROVIDER_METADATA_ORDER", "spotify,applemusic,deezer,qobuz,squidwtf"), metadataOrder = GetEnvString(envVars, "MULTI_PROVIDER_METADATA_ORDER", "spotify,applemusic,deezer,qobuz,squidwtf"),
downloadOrder = GetEnvString(envVars, "MULTI_PROVIDER_DOWNLOAD_ORDER", "applemusic,deezer,qobuz,squidwtf"),
playlistOrder = GetEnvString(envVars, "MULTI_PROVIDER_PLAYLIST_ORDER", "spotify,applemusic,deezer,qobuz,squidwtf"),
lyricsOrder = GetEnvString(envVars, "MULTI_PROVIDER_LYRICS_ORDER", "spotify,lyricsplus,lrclib"),
enabledSearch = GetEnvString(envVars, "MULTI_PROVIDER_ENABLED_SEARCH", "spotify,applemusic,deezer,qobuz,squidwtf"), enabledSearch = GetEnvString(envVars, "MULTI_PROVIDER_ENABLED_SEARCH", "spotify,applemusic,deezer,qobuz,squidwtf"),
enabledPlaylist = GetEnvString(envVars, "MULTI_PROVIDER_ENABLED_PLAYLIST", "spotify"), enabledPlaylist = GetEnvString(envVars, "MULTI_PROVIDER_ENABLED_PLAYLIST", "spotify"),
}, },
@@ -169,9 +169,7 @@ public class MultiProviderDownloadService : IDownloadService
private IReadOnlyList<string> GetPrioritizedDownloadProviders() private IReadOnlyList<string> GetPrioritizedDownloadProviders()
{ {
return _statusManager.GetEnabledSearchProviders() return _statusManager.GetEnabledDownloadProviders();
.Where(p => p != "spotify")
.ToList();
} }
private IDownloadService? GetDownloadServiceByName(string name) private IDownloadService? GetDownloadServiceByName(string name)
@@ -56,7 +56,7 @@ public class ProviderStatusManager
public IReadOnlyList<string> GetEnabledPlaylistProviders() public IReadOnlyList<string> GetEnabledPlaylistProviders()
{ {
var order = GetMetadataOrder(); var order = GetPlaylistOrder();
var enabled = GetEnabledPlaylistRaw(); var enabled = GetEnabledPlaylistRaw();
return order return order
@@ -64,6 +64,19 @@ public class ProviderStatusManager
.ToList(); .ToList();
} }
public IReadOnlyList<string> GetEnabledDownloadProviders()
{
var order = GetDownloadOrder();
return order
.Where(p => IsProviderHealthy(p))
.ToList();
}
public IReadOnlyList<string> GetEnabledLyricsProviders()
{
return GetLyricsOrder();
}
public bool IsProviderHealthy(string provider) public bool IsProviderHealthy(string provider)
{ {
var prov = provider.ToLowerInvariant(); var prov = provider.ToLowerInvariant();
@@ -127,6 +140,30 @@ public class ProviderStatusManager
.ToList(); .ToList();
} }
private List<string> GetDownloadOrder()
{
var val = _configuration["MULTI_PROVIDER_DOWNLOAD_ORDER"] ?? "applemusic,deezer,qobuz,squidwtf";
return val.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => s.ToLowerInvariant())
.ToList();
}
private List<string> GetPlaylistOrder()
{
var val = _configuration["MULTI_PROVIDER_PLAYLIST_ORDER"] ?? "spotify,applemusic,deezer,qobuz,squidwtf";
return val.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => s.ToLowerInvariant())
.ToList();
}
private List<string> GetLyricsOrder()
{
var val = _configuration["MULTI_PROVIDER_LYRICS_ORDER"] ?? "spotify,lyricsplus,lrclib";
return val.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Select(s => s.ToLowerInvariant())
.ToList();
}
private HashSet<string> GetEnabledSearchRaw() private HashSet<string> GetEnabledSearchRaw()
{ {
var val = _configuration["MULTI_PROVIDER_ENABLED_SEARCH"] ?? "spotify,applemusic,deezer,qobuz,squidwtf"; var val = _configuration["MULTI_PROVIDER_ENABLED_SEARCH"] ?? "spotify,applemusic,deezer,qobuz,squidwtf";
+57 -45
View File
@@ -1,12 +1,13 @@
using allstarr.Models.Lyrics; using allstarr.Models.Lyrics;
using allstarr.Models.Settings; using allstarr.Models.Settings;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using allstarr.Services.Common;
namespace allstarr.Services.Lyrics; namespace allstarr.Services.Lyrics;
/// <summary> /// <summary>
/// Orchestrates lyrics fetching from multiple sources with priority-based fallback. /// Orchestrates lyrics fetching from multiple sources with priority-based fallback.
/// Priority order: Spotify → LyricsPlus → LRCLib
/// Note: Jellyfin local lyrics are handled by the controller before calling this orchestrator. /// Note: Jellyfin local lyrics are handled by the controller before calling this orchestrator.
/// </summary> /// </summary>
public class LyricsOrchestrator public class LyricsOrchestrator
@@ -15,6 +16,7 @@ public class LyricsOrchestrator
private readonly LyricsPlusService _lyricsPlus; private readonly LyricsPlusService _lyricsPlus;
private readonly LrclibService _lrclib; private readonly LrclibService _lrclib;
private readonly SpotifyApiSettings _spotifySettings; private readonly SpotifyApiSettings _spotifySettings;
private readonly ProviderStatusManager _statusManager;
private readonly ILogger<LyricsOrchestrator> _logger; private readonly ILogger<LyricsOrchestrator> _logger;
public LyricsOrchestrator( public LyricsOrchestrator(
@@ -22,12 +24,14 @@ public class LyricsOrchestrator
LyricsPlusService lyricsPlus, LyricsPlusService lyricsPlus,
LrclibService lrclib, LrclibService lrclib,
IOptions<SpotifyApiSettings> spotifySettings, IOptions<SpotifyApiSettings> spotifySettings,
ProviderStatusManager statusManager,
ILogger<LyricsOrchestrator> logger) ILogger<LyricsOrchestrator> logger)
{ {
_spotifyLyrics = spotifyLyrics; _spotifyLyrics = spotifyLyrics;
_lyricsPlus = lyricsPlus; _lyricsPlus = lyricsPlus;
_lrclib = lrclib; _lrclib = lrclib;
_spotifySettings = spotifySettings.Value; _spotifySettings = spotifySettings.Value;
_statusManager = statusManager;
_logger = logger; _logger = logger;
} }
@@ -49,31 +53,37 @@ public class LyricsOrchestrator
string? spotifyTrackId = null) string? spotifyTrackId = null)
{ {
var artistName = string.Join(", ", artistNames); var artistName = string.Join(", ", artistNames);
_logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName); _logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName);
// 1. Try Spotify lyrics (if Spotify ID provided) var order = _statusManager.GetEnabledLyricsProviders();
if (!string.IsNullOrEmpty(spotifyTrackId))
foreach (var source in order)
{ {
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName); try
if (spotifyLyrics != null)
{ {
return spotifyLyrics; if (source.Equals("spotify", StringComparison.OrdinalIgnoreCase))
{
if (!string.IsNullOrEmpty(spotifyTrackId))
{
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
if (spotifyLyrics != null) return spotifyLyrics;
}
}
else if (source.Equals("lyricsplus", StringComparison.OrdinalIgnoreCase))
{
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lyricsPlusLyrics != null) return lyricsPlusLyrics;
}
else if (source.Equals("lrclib", StringComparison.OrdinalIgnoreCase))
{
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lrclibLyrics != null) return lrclibLyrics;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed fetching lyrics from source: {Source}", source);
} }
}
// 2. Try LyricsPlus
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lyricsPlusLyrics != null)
{
return lyricsPlusLyrics;
}
// 3. Try LRCLib
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lrclibLyrics != null)
{
return lrclibLyrics;
} }
_logger.LogInformation("❌ No lyrics found for: {Artist} - {Track}", artistName, trackName); _logger.LogInformation("❌ No lyrics found for: {Artist} - {Track}", artistName, trackName);
@@ -92,36 +102,38 @@ public class LyricsOrchestrator
string? spotifyTrackId = null) string? spotifyTrackId = null)
{ {
var artistName = string.Join(", ", artistNames); var artistName = string.Join(", ", artistNames);
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track} (Spotify ID: {SpotifyId})", _logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track} (Spotify ID: {SpotifyId})",
artistName, trackName, spotifyTrackId ?? "none"); artistName, trackName, spotifyTrackId ?? "none");
// 1. Try Spotify lyrics (if Spotify ID provided) var order = _statusManager.GetEnabledLyricsProviders();
if (!string.IsNullOrEmpty(spotifyTrackId))
foreach (var source in order)
{ {
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName); try
if (spotifyLyrics != null)
{ {
return true; if (source.Equals("spotify", StringComparison.OrdinalIgnoreCase))
{
if (!string.IsNullOrEmpty(spotifyTrackId))
{
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
if (spotifyLyrics != null) return true;
}
}
else if (source.Equals("lyricsplus", StringComparison.OrdinalIgnoreCase))
{
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lyricsPlusLyrics != null) return true;
}
else if (source.Equals("lrclib", StringComparison.OrdinalIgnoreCase))
{
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lrclibLyrics != null) return true;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed prefetching lyrics from source: {Source}", source);
} }
}
else
{
_logger.LogDebug("No Spotify ID available for prefetch, skipping Spotify lyrics");
}
// 2. Try LyricsPlus
var lyricsPlusLyrics = await TryLyricsPlusLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lyricsPlusLyrics != null)
{
return true;
}
// 3. Try LRCLib
var lrclibLyrics = await TryLrclibLyrics(trackName, artistNames, albumName, durationSeconds, artistName);
if (lrclibLyrics != null)
{
return true;
} }
_logger.LogDebug("No lyrics found for prefetch: {Artist} - {Track}", artistName, trackName); _logger.LogDebug("No lyrics found for prefetch: {Artist} - {Track}", artistName, trackName);
+43 -6
View File
@@ -949,6 +949,9 @@
<div class="card" id="multi-provider-card"> <div class="card" id="multi-provider-card">
<input type="hidden" id="config-multi-provider-metadata-order" data-env-key="MULTI_PROVIDER_METADATA_ORDER" class="config-input"> <input type="hidden" id="config-multi-provider-metadata-order" data-env-key="MULTI_PROVIDER_METADATA_ORDER" class="config-input">
<input type="hidden" id="config-multi-provider-download-order" data-env-key="MULTI_PROVIDER_DOWNLOAD_ORDER" class="config-input">
<input type="hidden" id="config-multi-provider-playlist-order" data-env-key="MULTI_PROVIDER_PLAYLIST_ORDER" class="config-input">
<input type="hidden" id="config-multi-provider-lyrics-order" data-env-key="MULTI_PROVIDER_LYRICS_ORDER" class="config-input">
<input type="hidden" id="config-multi-provider-enabled-search" data-env-key="MULTI_PROVIDER_ENABLED_SEARCH" class="config-input"> <input type="hidden" id="config-multi-provider-enabled-search" data-env-key="MULTI_PROVIDER_ENABLED_SEARCH" class="config-input">
<input type="hidden" id="config-multi-provider-enabled-playlist" data-env-key="MULTI_PROVIDER_ENABLED_PLAYLIST" class="config-input"> <input type="hidden" id="config-multi-provider-enabled-playlist" data-env-key="MULTI_PROVIDER_ENABLED_PLAYLIST" class="config-input">
<h2>Multi-Service Providers & Priority</h2> <h2>Multi-Service Providers & Priority</h2>
@@ -972,13 +975,47 @@
</table> </table>
</div> </div>
<h3>Metadata Search & Fallback Priority</h3> <div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; margin-top: 20px;">
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.9rem;"> <div>
Drag and drop to rank search priorities (highest on top). Fallback downloads will proceed in this order. <h4 style="margin: 0 0 8px 0; color: #fff;">1. Metadata Search Priority</h4>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.8rem; line-height: 1.3;">
Priority for search results.
</div>
<ul id="provider-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 0;">
<!-- Populated dynamically via JS -->
</ul>
</div>
<div>
<h4 style="margin: 0 0 8px 0; color: #fff;">2. Download & Playback Priority</h4>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.8rem; line-height: 1.3;">
Priority for downloads/streaming.
</div>
<ul id="download-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 0;">
<!-- Populated dynamically via JS -->
</ul>
</div>
<div>
<h4 style="margin: 0 0 8px 0; color: #fff;">3. Playlist Sync Priority</h4>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.8rem; line-height: 1.3;">
Priority for playlist matching.
</div>
<ul id="playlist-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 0;">
<!-- Populated dynamically via JS -->
</ul>
</div>
<div>
<h4 style="margin: 0 0 8px 0; color: #fff;">4. Lyrics Sourcing Priority</h4>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 12px; font-size: 0.8rem; line-height: 1.3;">
Priority for lyrics fetchers.
</div>
<ul id="lyrics-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 0;">
<!-- Populated dynamically via JS -->
</ul>
</div>
</div> </div>
<ul id="provider-priority-list" class="priority-list" style="list-style-type: none; padding: 0; margin: 10px 0;">
<!-- Populated dynamically via JS -->
</ul>
</div> </div>
<div class="card"> <div class="card">
+41 -14
View File
@@ -2050,15 +2050,25 @@ document.addEventListener("DOMContentLoaded", () => {
function renderMultiProviderSettings(data) { function renderMultiProviderSettings(data) {
const providersData = data.providers || { const providersData = data.providers || {
metadataOrder: "spotify,applemusic,deezer,qobuz,squidwtf", metadataOrder: "spotify,applemusic,deezer,qobuz,squidwtf",
downloadOrder: "applemusic,deezer,qobuz,squidwtf",
playlistOrder: "spotify,applemusic,deezer,qobuz,squidwtf",
lyricsOrder: "spotify,lyricsplus,lrclib",
enabledSearch: "spotify,applemusic,deezer,qobuz,squidwtf", enabledSearch: "spotify,applemusic,deezer,qobuz,squidwtf",
enabledPlaylist: "spotify" enabledPlaylist: "spotify"
}; };
setInputValue("config-multi-provider-metadata-order", providersData.metadataOrder); setInputValue("config-multi-provider-metadata-order", providersData.metadataOrder);
setInputValue("config-multi-provider-download-order", providersData.downloadOrder || "applemusic,deezer,qobuz,squidwtf");
setInputValue("config-multi-provider-playlist-order", providersData.playlistOrder || "spotify,applemusic,deezer,qobuz,squidwtf");
setInputValue("config-multi-provider-lyrics-order", providersData.lyricsOrder || "spotify,lyricsplus,lrclib");
setInputValue("config-multi-provider-enabled-search", providersData.enabledSearch); setInputValue("config-multi-provider-enabled-search", providersData.enabledSearch);
setInputValue("config-multi-provider-enabled-playlist", providersData.enabledPlaylist); setInputValue("config-multi-provider-enabled-playlist", providersData.enabledPlaylist);
const order = providersData.metadataOrder.split(',').map(s => s.trim().toLowerCase()); const order = providersData.metadataOrder.split(',').map(s => s.trim().toLowerCase());
const downloadOrder = (providersData.downloadOrder || "applemusic,deezer,qobuz,squidwtf").split(',').map(s => s.trim().toLowerCase());
const playlistOrder = (providersData.playlistOrder || "spotify,applemusic,deezer,qobuz,squidwtf").split(',').map(s => s.trim().toLowerCase());
const lyricsOrder = (providersData.lyricsOrder || "spotify,lyricsplus,lrclib").split(',').map(s => s.trim().toLowerCase());
const enabledSearch = providersData.enabledSearch.split(',').map(s => s.trim().toLowerCase()); const enabledSearch = providersData.enabledSearch.split(',').map(s => s.trim().toLowerCase());
const enabledPlaylist = providersData.enabledPlaylist.split(',').map(s => s.trim().toLowerCase()); const enabledPlaylist = providersData.enabledPlaylist.split(',').map(s => s.trim().toLowerCase());
@@ -2115,17 +2125,20 @@ function renderMultiProviderSettings(data) {
.catch(err => console.error("Error fetching provider status:", err)); .catch(err => console.error("Error fetching provider status:", err));
} }
const priorityList = document.getElementById("provider-priority-list"); const renderListItems = (listId, orderArray) => {
if (priorityList) { const list = document.getElementById(listId);
priorityList.innerHTML = ""; if (!list) return;
order.forEach(p => { list.innerHTML = "";
orderArray.forEach(p => {
const displayName = p === "spotify" ? "Spotify" : const displayName = p === "spotify" ? "Spotify" :
p === "applemusic" ? "Apple Music" : p === "applemusic" ? "Apple Music" :
p === "deezer" ? "Deezer" : p === "deezer" ? "Deezer" :
p === "qobuz" ? "Qobuz" : p === "qobuz" ? "Qobuz" :
p === "squidwtf" ? "SquidWTF (Tidal)" : p; p === "squidwtf" ? "SquidWTF (Tidal)" :
p === "lyricsplus" ? "LyricsPlus" :
p === "lrclib" ? "LRCLib" : p;
priorityList.innerHTML += ` list.innerHTML += `
<li class="priority-item" draggable="true" data-provider="${p}"> <li class="priority-item" draggable="true" data-provider="${p}">
<div class="priority-item-left"> <div class="priority-item-left">
<span class="priority-item-drag-handle">☰</span> <span class="priority-item-drag-handle">☰</span>
@@ -2134,15 +2147,29 @@ function renderMultiProviderSettings(data) {
</li> </li>
`; `;
}); });
};
initDragAndDrop(); renderListItems("provider-priority-list", order);
} renderListItems("download-priority-list", downloadOrder);
renderListItems("playlist-priority-list", playlistOrder);
renderListItems("lyrics-priority-list", lyricsOrder);
initDragAndDrop("provider-priority-list", updateProviderConfigInputs);
initDragAndDrop("download-priority-list", updateProviderConfigInputs);
initDragAndDrop("playlist-priority-list", updateProviderConfigInputs);
initDragAndDrop("lyrics-priority-list", updateProviderConfigInputs);
} }
function updateProviderConfigInputs() { function updateProviderConfigInputs() {
const listItems = document.querySelectorAll("#provider-priority-list li"); const getListOrder = (listId) => {
const order = Array.from(listItems).map(item => item.getAttribute("data-provider")); const listItems = document.querySelectorAll(`#${listId} li`);
setInputValue("config-multi-provider-metadata-order", order.join(",")); return Array.from(listItems).map(item => item.getAttribute("data-provider"));
};
setInputValue("config-multi-provider-metadata-order", getListOrder("provider-priority-list").join(","));
setInputValue("config-multi-provider-download-order", getListOrder("download-priority-list").join(","));
setInputValue("config-multi-provider-playlist-order", getListOrder("playlist-priority-list").join(","));
setInputValue("config-multi-provider-lyrics-order", getListOrder("lyrics-priority-list").join(","));
const searchCbs = document.querySelectorAll(".provider-search-checkbox"); const searchCbs = document.querySelectorAll(".provider-search-checkbox");
const enabledSearch = Array.from(searchCbs) const enabledSearch = Array.from(searchCbs)
@@ -2157,8 +2184,8 @@ function updateProviderConfigInputs() {
setInputValue("config-multi-provider-enabled-playlist", enabledPlaylist.join(",")); setInputValue("config-multi-provider-enabled-playlist", enabledPlaylist.join(","));
} }
function initDragAndDrop() { function initDragAndDrop(listId, onUpdate) {
const list = document.getElementById("provider-priority-list"); const list = document.getElementById(listId);
if (!list) return; if (!list) return;
let dragEl = null; let dragEl = null;
@@ -2176,7 +2203,7 @@ function initDragAndDrop() {
if (dragEl) { if (dragEl) {
dragEl.classList.remove("dragging"); dragEl.classList.remove("dragging");
dragEl = null; dragEl = null;
updateProviderConfigInputs(); onUpdate();
} }
}); });