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
{
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"),
enabledPlaylist = GetEnvString(envVars, "MULTI_PROVIDER_ENABLED_PLAYLIST", "spotify"),
},
@@ -169,9 +169,7 @@ public class MultiProviderDownloadService : IDownloadService
private IReadOnlyList<string> GetPrioritizedDownloadProviders()
{
return _statusManager.GetEnabledSearchProviders()
.Where(p => p != "spotify")
.ToList();
return _statusManager.GetEnabledDownloadProviders();
}
private IDownloadService? GetDownloadServiceByName(string name)
@@ -56,7 +56,7 @@ public class ProviderStatusManager
public IReadOnlyList<string> GetEnabledPlaylistProviders()
{
var order = GetMetadataOrder();
var order = GetPlaylistOrder();
var enabled = GetEnabledPlaylistRaw();
return order
@@ -64,6 +64,19 @@ public class ProviderStatusManager
.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)
{
var prov = provider.ToLowerInvariant();
@@ -127,6 +140,30 @@ public class ProviderStatusManager
.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()
{
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.Settings;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Logging;
using allstarr.Services.Common;
namespace allstarr.Services.Lyrics;
/// <summary>
/// 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.
/// </summary>
public class LyricsOrchestrator
@@ -15,6 +16,7 @@ public class LyricsOrchestrator
private readonly LyricsPlusService _lyricsPlus;
private readonly LrclibService _lrclib;
private readonly SpotifyApiSettings _spotifySettings;
private readonly ProviderStatusManager _statusManager;
private readonly ILogger<LyricsOrchestrator> _logger;
public LyricsOrchestrator(
@@ -22,12 +24,14 @@ public class LyricsOrchestrator
LyricsPlusService lyricsPlus,
LrclibService lrclib,
IOptions<SpotifyApiSettings> spotifySettings,
ProviderStatusManager statusManager,
ILogger<LyricsOrchestrator> logger)
{
_spotifyLyrics = spotifyLyrics;
_lyricsPlus = lyricsPlus;
_lrclib = lrclib;
_spotifySettings = spotifySettings.Value;
_statusManager = statusManager;
_logger = logger;
}
@@ -49,31 +53,37 @@ public class LyricsOrchestrator
string? spotifyTrackId = null)
{
var artistName = string.Join(", ", artistNames);
_logger.LogInformation("🎵 Fetching lyrics for: {Artist} - {Track}", artistName, trackName);
// 1. Try Spotify lyrics (if Spotify ID provided)
if (!string.IsNullOrEmpty(spotifyTrackId))
var order = _statusManager.GetEnabledLyricsProviders();
foreach (var source in order)
{
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
if (spotifyLyrics != null)
try
{
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);
@@ -92,36 +102,38 @@ public class LyricsOrchestrator
string? spotifyTrackId = null)
{
var artistName = string.Join(", ", artistNames);
_logger.LogDebug("🎵 Prefetching lyrics for: {Artist} - {Track} (Spotify ID: {SpotifyId})",
artistName, trackName, spotifyTrackId ?? "none");
// 1. Try Spotify lyrics (if Spotify ID provided)
if (!string.IsNullOrEmpty(spotifyTrackId))
var order = _statusManager.GetEnabledLyricsProviders();
foreach (var source in order)
{
var spotifyLyrics = await TrySpotifyLyrics(spotifyTrackId, artistName, trackName);
if (spotifyLyrics != null)
try
{
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);
+43 -6
View File
@@ -949,6 +949,9 @@
<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-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-playlist" data-env-key="MULTI_PROVIDER_ENABLED_PLAYLIST" class="config-input">
<h2>Multi-Service Providers & Priority</h2>
@@ -972,13 +975,47 @@
</table>
</div>
<h3>Metadata Search & Fallback Priority</h3>
<div class="description-text" style="color: var(--text-secondary); margin-bottom: 16px; font-size: 0.9rem;">
Drag and drop to rank search priorities (highest on top). Fallback downloads will proceed in this order.
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; margin-top: 20px;">
<div>
<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>
<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 class="card">
+41 -14
View File
@@ -2050,15 +2050,25 @@ document.addEventListener("DOMContentLoaded", () => {
function renderMultiProviderSettings(data) {
const providersData = data.providers || {
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",
enabledPlaylist: "spotify"
};
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-playlist", providersData.enabledPlaylist);
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 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));
}
const priorityList = document.getElementById("provider-priority-list");
if (priorityList) {
priorityList.innerHTML = "";
order.forEach(p => {
const renderListItems = (listId, orderArray) => {
const list = document.getElementById(listId);
if (!list) return;
list.innerHTML = "";
orderArray.forEach(p => {
const displayName = p === "spotify" ? "Spotify" :
p === "applemusic" ? "Apple Music" :
p === "deezer" ? "Deezer" :
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}">
<div class="priority-item-left">
<span class="priority-item-drag-handle">☰</span>
@@ -2134,15 +2147,29 @@ function renderMultiProviderSettings(data) {
</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() {
const listItems = document.querySelectorAll("#provider-priority-list li");
const order = Array.from(listItems).map(item => item.getAttribute("data-provider"));
setInputValue("config-multi-provider-metadata-order", order.join(","));
const getListOrder = (listId) => {
const listItems = document.querySelectorAll(`#${listId} li`);
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 enabledSearch = Array.from(searchCbs)
@@ -2157,8 +2184,8 @@ function updateProviderConfigInputs() {
setInputValue("config-multi-provider-enabled-playlist", enabledPlaylist.join(","));
}
function initDragAndDrop() {
const list = document.getElementById("provider-priority-list");
function initDragAndDrop(listId, onUpdate) {
const list = document.getElementById(listId);
if (!list) return;
let dragEl = null;
@@ -2176,7 +2203,7 @@ function initDragAndDrop() {
if (dragEl) {
dragEl.classList.remove("dragging");
dragEl = null;
updateProviderConfigInputs();
onUpdate();
}
});