mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-10 07:58:39 -05:00
Add lyrics prefetching for injected playlists with file cache
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
This commit is contained in:
@@ -566,6 +566,10 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
|
||||
|
||||
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Lyrics.LyricsPrefetchService>());
|
||||
|
||||
// Register MusicBrainz service for metadata enrichment
|
||||
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
|
||||
{
|
||||
|
||||
@@ -11,15 +11,19 @@ public class CacheWarmingService : IHostedService
|
||||
{
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly ILogger<CacheWarmingService> _logger;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const string GenreCacheDirectory = "/app/cache/genres";
|
||||
private const string PlaylistCacheDirectory = "/app/cache/spotify";
|
||||
private const string MappingsCacheDirectory = "/app/cache/mappings";
|
||||
private const string LyricsCacheDirectory = "/app/cache/lyrics";
|
||||
|
||||
public CacheWarmingService(
|
||||
RedisCacheService cache,
|
||||
IServiceProvider serviceProvider,
|
||||
ILogger<CacheWarmingService> logger)
|
||||
{
|
||||
_cache = cache;
|
||||
_serviceProvider = serviceProvider;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
@@ -31,6 +35,7 @@ public class CacheWarmingService : IHostedService
|
||||
var genresWarmed = 0;
|
||||
var playlistsWarmed = 0;
|
||||
var mappingsWarmed = 0;
|
||||
var lyricsWarmed = 0;
|
||||
|
||||
try
|
||||
{
|
||||
@@ -42,11 +47,14 @@ public class CacheWarmingService : IHostedService
|
||||
|
||||
// Warm manual mappings cache
|
||||
mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken);
|
||||
|
||||
// Warm lyrics cache
|
||||
lyricsWarmed = await WarmLyricsCacheAsync(cancellationToken);
|
||||
|
||||
var duration = DateTime.UtcNow - startTime;
|
||||
_logger.LogInformation(
|
||||
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings",
|
||||
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed);
|
||||
"✅ Cache warming complete in {Duration:F1}s: {Genres} genres, {Playlists} playlists, {Mappings} manual mappings, {Lyrics} lyrics",
|
||||
duration.TotalSeconds, genresWarmed, playlistsWarmed, mappingsWarmed, lyricsWarmed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -275,6 +283,37 @@ public class CacheWarmingService : IHostedService
|
||||
|
||||
return warmedCount;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warms lyrics cache from file system using the LyricsPrefetchService.
|
||||
/// </summary>
|
||||
private async Task<int> WarmLyricsCacheAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Get the LyricsPrefetchService from DI
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var lyricsPrefetchService = scope.ServiceProvider.GetService<allstarr.Services.Lyrics.LyricsPrefetchService>();
|
||||
|
||||
if (lyricsPrefetchService != null)
|
||||
{
|
||||
await lyricsPrefetchService.WarmCacheFromFilesAsync();
|
||||
|
||||
// Count files to return warmed count
|
||||
if (Directory.Exists(LyricsCacheDirectory))
|
||||
{
|
||||
return Directory.GetFiles(LyricsCacheDirectory, "*.json").Length;
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to warm lyrics cache");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private class GenreCacheEntry
|
||||
{
|
||||
|
||||
258
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
258
allstarr/Services/Lyrics/LyricsPrefetchService.cs
Normal file
@@ -0,0 +1,258 @@
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user