mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 16:08:39 -05:00
Release v1.0.0 - Production Ready
Major Features: - Spotify playlist injection with missing tracks search - Transparent proxy authentication system - WebSocket session management for external tracks - Manual track mapping and favorites system - Lyrics support (Spotify + LRCLib) with prefetching - Admin dashboard with analytics and configuration - Performance optimizations with health checks and endpoint racing - Comprehensive caching and memory management Performance Improvements: - Quick health checks (3s timeout) before trying endpoints - Health check results cached for 30 seconds - 5 minute timeout for large artist responses - Background Odesli conversion after streaming starts - Parallel lyrics prefetching - Endpoint benchmarking and racing - 16 SquidWTF endpoints with load balancing Reliability: - Automatic endpoint fallback and failover - Token expiration handling - Concurrent request optimization - Memory leak fixes - Proper session cleanup User Experience: - Web UI for configuration and playlist management - Real-time progress tracking - API analytics dashboard - Manual track mapping interface - Playlist statistics and health monitoring
This commit is contained in:
535
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
535
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
@@ -0,0 +1,535 @@
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using allstarr.Services.Spotify;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
/// <summary>
|
||||
/// Background service that prefetches lyrics for all tracks in injected Spotify playlists.
|
||||
/// Lyrics are cached in Redis and persisted to disk for fast loading on startup.
|
||||
/// </summary>
|
||||
public class LyricsPrefetchService : BackgroundService
|
||||
{
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly LrclibService _lrclibService;
|
||||
private readonly SpotifyPlaylistFetcher _playlistFetcher;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly ILogger<LyricsPrefetchService> _logger;
|
||||
private readonly string _lyricsCacheDir = "/app/cache/lyrics";
|
||||
private const int DelayBetweenRequestsMs = 500; // 500ms = 2 requests/second to be respectful
|
||||
|
||||
public LyricsPrefetchService(
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
LrclibService lrclibService,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<LyricsPrefetchService> logger)
|
||||
{
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_lrclibService = lrclibService;
|
||||
_playlistFetcher = playlistFetcher;
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("LyricsPrefetchService: Starting up...");
|
||||
|
||||
if (!_spotifySettings.Enabled)
|
||||
{
|
||||
_logger.LogInformation("Spotify playlist injection is DISABLED, lyrics prefetch will not run");
|
||||
return;
|
||||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
Directory.CreateDirectory(_lyricsCacheDir);
|
||||
|
||||
// Wait for playlist fetcher to initialize
|
||||
await Task.Delay(TimeSpan.FromMinutes(3), stoppingToken);
|
||||
|
||||
// Run initial prefetch
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Running initial lyrics prefetch on startup");
|
||||
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error during startup lyrics prefetch");
|
||||
}
|
||||
|
||||
// Run periodic prefetch (daily)
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromHours(24), stoppingToken);
|
||||
|
||||
try
|
||||
{
|
||||
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error in lyrics prefetch service");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task PrefetchAllPlaylistLyricsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("🎵 Starting lyrics prefetch for {Count} playlists", _spotifySettings.Playlists.Count);
|
||||
|
||||
var totalFetched = 0;
|
||||
var totalCached = 0;
|
||||
var totalMissing = 0;
|
||||
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
var (fetched, cached, missing) = await PrefetchPlaylistLyricsAsync(playlist.Name, cancellationToken);
|
||||
totalFetched += fetched;
|
||||
totalCached += cached;
|
||||
totalMissing += missing;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error prefetching lyrics for playlist {Playlist}", playlist.Name);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Lyrics prefetch complete: {Fetched} fetched, {Cached} already cached, {Missing} not found",
|
||||
totalFetched, totalCached, totalMissing);
|
||||
}
|
||||
|
||||
public async Task<(int Fetched, int Cached, int Missing)> PrefetchPlaylistLyricsAsync(
|
||||
string playlistName,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
|
||||
|
||||
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
if (tracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No tracks found for playlist {Playlist}", playlistName);
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
// Get the pre-built playlist items cache which includes Jellyfin item IDs for local tracks
|
||||
var playlistItemsKey = $"spotify:playlist:items:{playlistName}";
|
||||
var playlistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
|
||||
|
||||
// Build a map of Spotify ID -> Jellyfin Item ID for quick lookup
|
||||
var spotifyToJellyfinId = new Dictionary<string, string>();
|
||||
if (playlistItems != null)
|
||||
{
|
||||
foreach (var item in playlistItems)
|
||||
{
|
||||
// Check if this is a local Jellyfin track (has Id field, no ProviderIds for external)
|
||||
if (item.TryGetValue("Id", out var idObj) && idObj != null)
|
||||
{
|
||||
var jellyfinId = idObj.ToString();
|
||||
|
||||
// Try to get Spotify provider ID
|
||||
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
|
||||
{
|
||||
var providerIdsJson = JsonSerializer.Serialize(providerIdsObj);
|
||||
using var doc = JsonDocument.Parse(providerIdsJson);
|
||||
if (doc.RootElement.TryGetProperty("Spotify", out var spotifyIdEl))
|
||||
{
|
||||
var spotifyId = spotifyIdEl.GetString();
|
||||
if (!string.IsNullOrEmpty(spotifyId) && !string.IsNullOrEmpty(jellyfinId))
|
||||
{
|
||||
spotifyToJellyfinId[spotifyId] = jellyfinId;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
|
||||
spotifyToJellyfinId.Count, playlistName);
|
||||
}
|
||||
|
||||
var fetched = 0;
|
||||
var cached = 0;
|
||||
var missing = 0;
|
||||
|
||||
foreach (var track in tracks)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
// Check if lyrics are already cached
|
||||
// Use same cache key format as LrclibService: join all artists with ", "
|
||||
var artistName = string.Join(", ", track.Artists);
|
||||
var cacheKey = $"lyrics:{artistName}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
|
||||
var existingLyrics = await _cache.GetStringAsync(cacheKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(existingLyrics))
|
||||
{
|
||||
cached++;
|
||||
_logger.LogDebug("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Priority 1: Check if this track has local Jellyfin lyrics (embedded in file)
|
||||
// Use the Jellyfin item ID from the playlist cache if available
|
||||
if (spotifyToJellyfinId.TryGetValue(track.SpotifyId, out var jellyfinItemId))
|
||||
{
|
||||
var hasLocalLyrics = await CheckForLocalJellyfinLyricsByIdAsync(jellyfinItemId, track.PrimaryArtist, track.Title);
|
||||
if (hasLocalLyrics)
|
||||
{
|
||||
cached++;
|
||||
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
|
||||
track.PrimaryArtist, track.Title);
|
||||
|
||||
// Remove any previously cached LRCLib lyrics for this track
|
||||
var artistNameForRemoval = string.Join(", ", track.Artists);
|
||||
await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 2: Try Spotify lyrics if we have a Spotify ID
|
||||
LyricsInfo? lyrics = null;
|
||||
if (!string.IsNullOrEmpty(track.SpotifyId))
|
||||
{
|
||||
lyrics = await TryGetSpotifyLyricsAsync(track.SpotifyId, track.Title, track.PrimaryArtist);
|
||||
}
|
||||
|
||||
// Priority 3: Fall back to LRCLib if no Spotify lyrics
|
||||
if (lyrics == null)
|
||||
{
|
||||
lyrics = await _lrclibService.GetLyricsAsync(
|
||||
track.Title,
|
||||
track.Artists.ToArray(),
|
||||
track.Album,
|
||||
track.DurationMs / 1000);
|
||||
}
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
fetched++;
|
||||
_logger.LogInformation("✓ Fetched lyrics for {Artist} - {Track} (synced: {HasSynced})",
|
||||
track.PrimaryArtist, track.Title, !string.IsNullOrEmpty(lyrics.SyncedLyrics));
|
||||
|
||||
// Save to file cache
|
||||
var artistNameForSave = string.Join(", ", track.Artists);
|
||||
await SaveLyricsToFileAsync(artistNameForSave, track.Title, track.Album, track.DurationMs / 1000, lyrics);
|
||||
}
|
||||
else
|
||||
{
|
||||
missing++;
|
||||
_logger.LogDebug("✗ No lyrics found for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
}
|
||||
|
||||
// Rate limiting
|
||||
await Task.Delay(DelayBetweenRequestsMs, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
|
||||
missing++;
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
|
||||
playlistName, fetched, cached, missing);
|
||||
|
||||
return (fetched, cached, missing);
|
||||
}
|
||||
|
||||
private async Task SaveLyricsToFileAsync(string artist, string title, string album, int duration, LyricsInfo lyrics)
|
||||
{
|
||||
try
|
||||
{
|
||||
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
|
||||
var filePath = Path.Combine(_lyricsCacheDir, fileName);
|
||||
|
||||
var json = JsonSerializer.Serialize(lyrics, new JsonSerializerOptions { WriteIndented = true });
|
||||
await File.WriteAllTextAsync(filePath, json);
|
||||
|
||||
_logger.LogDebug("💾 Saved lyrics to file: {FileName}", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads lyrics from file cache into Redis on startup
|
||||
/// </summary>
|
||||
public async Task WarmCacheFromFilesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(_lyricsCacheDir))
|
||||
{
|
||||
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
|
||||
return;
|
||||
}
|
||||
|
||||
var files = Directory.GetFiles(_lyricsCacheDir, "*.json");
|
||||
if (files.Length == 0)
|
||||
{
|
||||
_logger.LogInformation("No lyrics cache files found");
|
||||
return;
|
||||
}
|
||||
|
||||
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
|
||||
|
||||
var loaded = 0;
|
||||
foreach (var file in files)
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = await File.ReadAllTextAsync(file);
|
||||
var lyrics = JsonSerializer.Deserialize<LyricsInfo>(json);
|
||||
|
||||
if (lyrics != null)
|
||||
{
|
||||
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
|
||||
await _cache.SetStringAsync(cacheKey, json, TimeSpan.FromDays(30));
|
||||
loaded++;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✅ Warmed {Count} lyrics from file cache", loaded);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error warming lyrics cache from files");
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeFileName(string fileName)
|
||||
{
|
||||
var invalid = Path.GetInvalidFileNameChars();
|
||||
return string.Join("_", fileName.Split(invalid, StringSplitOptions.RemoveEmptyEntries))
|
||||
.Replace(" ", "_")
|
||||
.ToLowerInvariant();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes cached LRCLib lyrics from both Redis and file cache.
|
||||
/// Used when a track has local Jellyfin lyrics, making the LRCLib cache obsolete.
|
||||
/// </summary>
|
||||
private async Task RemoveCachedLyricsAsync(string artist, string title, string album, int duration)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Remove from Redis cache
|
||||
var cacheKey = $"lyrics:{artist}:{title}:{album}:{duration}";
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
// Remove from file cache
|
||||
var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json";
|
||||
var filePath = Path.Combine(_lyricsCacheDir, fileName);
|
||||
|
||||
if (File.Exists(filePath))
|
||||
{
|
||||
File.Delete(filePath);
|
||||
_logger.LogDebug("🗑️ Removed cached LRCLib lyrics file: {FileName}", fileName);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tries to get lyrics from Spotify using the track's Spotify ID.
|
||||
/// Returns null if Spotify API is not enabled or lyrics not found.
|
||||
/// </summary>
|
||||
private async Task<LyricsInfo?> TryGetSpotifyLyricsAsync(string spotifyTrackId, string trackTitle, string artistName)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var spotifyLyricsService = scope.ServiceProvider.GetService<SpotifyLyricsService>();
|
||||
|
||||
if (spotifyLyricsService == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var spotifyLyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId);
|
||||
|
||||
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
|
||||
artistName, trackTitle, spotifyLyrics.Lines.Count);
|
||||
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a track has embedded lyrics in Jellyfin using the Jellyfin item ID.
|
||||
/// This is the most efficient method as it directly queries the lyrics endpoint.
|
||||
/// </summary>
|
||||
private async Task<bool> CheckForLocalJellyfinLyricsByIdAsync(string jellyfinItemId, string artistName, string trackTitle)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
|
||||
if (proxyService == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Directly check if this track has lyrics using the item ID
|
||||
// Use internal method with server API key since this is a background operation
|
||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
||||
$"Audio/{jellyfinItemId}/Lyrics",
|
||||
null);
|
||||
|
||||
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||
{
|
||||
// Track has embedded lyrics in Jellyfin
|
||||
_logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (ID: {JellyfinId})",
|
||||
artistName, trackTitle, jellyfinItemId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a track has embedded lyrics in Jellyfin by querying the Jellyfin API.
|
||||
/// This prevents downloading lyrics from LRCLib when the local file already has them.
|
||||
/// </summary>
|
||||
private async Task<bool> CheckForLocalJellyfinLyricsAsync(string spotifyTrackId, string artistName, string trackTitle)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
|
||||
if (proxyService == null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Search for the track in Jellyfin by artist and title
|
||||
// Jellyfin doesn't support anyProviderIdEquals - that's an Emby API parameter
|
||||
var searchTerm = $"{artistName} {trackTitle}";
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
["searchTerm"] = searchTerm,
|
||||
["includeItemTypes"] = "Audio",
|
||||
["recursive"] = "true",
|
||||
["limit"] = "5" // Get a few results to find best match
|
||||
};
|
||||
|
||||
var (searchResult, statusCode) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||
|
||||
if (searchResult == null || statusCode != 200)
|
||||
{
|
||||
// Track not found in local library
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if we found any items
|
||||
if (!searchResult.RootElement.TryGetProperty("Items", out var items) ||
|
||||
items.GetArrayLength() == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Find the best matching track by comparing artist and title
|
||||
string? bestMatchId = null;
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
if (!item.TryGetProperty("Name", out var nameEl) ||
|
||||
!item.TryGetProperty("Id", out var idEl))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var itemTitle = nameEl.GetString() ?? "";
|
||||
var itemId = idEl.GetString();
|
||||
|
||||
// Check if title matches (case-insensitive)
|
||||
if (itemTitle.Equals(trackTitle, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
// Also check artist if available
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
var itemArtist = artistsEl[0].GetString() ?? "";
|
||||
if (itemArtist.Equals(artistName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
bestMatchId = itemId;
|
||||
break; // Exact match found
|
||||
}
|
||||
}
|
||||
|
||||
// If no exact artist match but title matches, use it as fallback
|
||||
if (bestMatchId == null)
|
||||
{
|
||||
bestMatchId = itemId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(bestMatchId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
// Check if this track has lyrics
|
||||
// Use internal method with server API key since this is a background operation
|
||||
var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal(
|
||||
$"Audio/{bestMatchId}/Lyrics",
|
||||
null);
|
||||
|
||||
if (lyricsResult != null && lyricsStatusCode == 200)
|
||||
{
|
||||
// Track has embedded lyrics in Jellyfin
|
||||
_logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (Jellyfin ID: {JellyfinId})",
|
||||
artistName, trackTitle, bestMatchId);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user