mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat: implement independent priority rankings for metadata search, downloads, playlist sync, and lyrics sourcing with drag-and-drop support
This commit is contained in:
6 files changed
+183
-69
No files matched your search
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user