mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
New LyricsPrefetchService automatically fetches lyrics for all tracks in Spotify injected playlists. Lyrics are cached in Redis and persisted to disk for fast loading on startup. Features: - Prefetches lyrics for all playlist tracks on startup (after 3min delay) - Daily refresh to catch new tracks - File cache at /app/cache/lyrics for persistence - Cache warming on startup loads lyrics from disk into Redis - Rate limited to 2 requests/second to be respectful to LRCLIB API - Logs fetched/cached/missing counts per playlist Benefits: - Instant lyrics availability for playlist tracks - Survives container restarts - Reduces API calls during playback - Better user experience with pre-loaded lyrics
259 lines
9.2 KiB
C#
259 lines
9.2 KiB
C#
using System.Text.Json;
|
|
using allstarr.Models.Lyrics;
|
|
using allstarr.Models.Settings;
|
|
using allstarr.Services.Common;
|
|
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 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,
|
|
ILogger<LyricsPrefetchService> logger)
|
|
{
|
|
_spotifySettings = spotifySettings.Value;
|
|
_lrclibService = lrclibService;
|
|
_playlistFetcher = playlistFetcher;
|
|
_cache = cache;
|
|
_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);
|
|
}
|
|
|
|
private 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);
|
|
}
|
|
|
|
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
|
|
var cacheKey = $"lyrics:{track.PrimaryArtist}:{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;
|
|
}
|
|
|
|
// Fetch lyrics
|
|
var 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
|
|
await SaveLyricsToFileAsync(track.PrimaryArtist, 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();
|
|
}
|
|
}
|