Compare commits

...

1 Commits

47 changed files with 1285 additions and 845 deletions
+49 -8
View File
@@ -2,14 +2,50 @@
# Choose which media server backend to use: Subsonic or Jellyfin
BACKEND_TYPE=Subsonic
# ===== REDIS CACHE =====
# Enable Redis caching for metadata and images (default: true)
REDIS_ENABLED=true
# ===== REDIS CACHE (REQUIRED) =====
# Redis is the primary cache for all runtime data (search results, playlists, lyrics, etc.)
# File cache (/app/cache) acts as a persistence layer for cold starts
# Redis snapshots to disk every 60 seconds + AOF for durability
# Redis data persistence directory (default: ./redis-data)
# Redis will save snapshots and append-only logs here to persist cache across restarts
# Contains Redis RDB snapshots and AOF logs for crash recovery
REDIS_DATA_PATH=./redis-data
# ===== CACHE TTL SETTINGS =====
# Configure how long different types of data are cached
# Longer durations reduce API calls but may show stale data
# All values are configurable via Web UI (Configuration tab > Cache Settings)
# Changes require container restart to apply
# Search results cache duration in minutes (default: 120 = 2 hours)
CACHE_SEARCH_RESULTS_MINUTES=120
# Playlist cover images cache duration in hours (default: 168 = 1 week)
CACHE_PLAYLIST_IMAGES_HOURS=168
# Spotify playlist items cache duration in hours (default: 168 = 1 week)
CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS=168
# Spotify matched tracks cache duration in days (default: 30 days)
# This is the mapping of Spotify IDs to local/external tracks
CACHE_SPOTIFY_MATCHED_TRACKS_DAYS=30
# Lyrics cache duration in days (default: 14 = 2 weeks)
CACHE_LYRICS_DAYS=14
# Genre data cache duration in days (default: 30 days)
CACHE_GENRE_DAYS=30
# External metadata (SquidWTF/Deezer/Qobuz) cache duration in days (default: 7 days)
CACHE_METADATA_DAYS=7
# Odesli URL conversion cache duration in days (default: 60 days)
CACHE_ODESLI_LOOKUP_DAYS=60
# Jellyfin proxy images cache duration in days (default: 14 = 2 weeks)
CACHE_PROXY_IMAGES_DAYS=14
# ===== SUBSONIC/NAVIDROME CONFIGURATION =====
# Server URL (required if using Subsonic backend)
SUBSONIC_URL=http://localhost:4533
@@ -43,8 +79,13 @@ MUSIC_SERVICE=SquidWTF
Library__DownloadPath=./downloads
# ===== SQUIDWTF CONFIGURATION =====
# Different quality options for SquidWTF. Only FLAC supported right now
SQUIDWTF_QUALITY=FLAC
# Preferred audio quality (optional, default: LOSSLESS)
# - HI_RES or HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest quality)
# - FLAC or LOSSLESS: 16-bit/44.1kHz FLAC (CD quality, recommended)
# - HIGH: 320kbps AAC (high quality, smaller files)
# - LOW: 96kbps AAC (low quality, smallest files)
# If not specified, LOSSLESS (16-bit FLAC) will be used
SQUIDWTF_QUALITY=LOSSLESS
# ===== DEEZER CONFIGURATION =====
# Deezer ARL token (required if using Deezer)
@@ -95,12 +136,12 @@ EXPLICIT_FILTER=All
# The played track is downloaded first, remaining tracks are queued
DOWNLOAD_MODE=Track
# Storage mode (optional, default: Permanent)
# Storage mode (optional, default: Cache)
# - Permanent: Files are saved to the library permanently and registered in the media server
# - Cache: Files are stored in /tmp and automatically cleaned up after CACHE_DURATION_HOURS
# Not registered in media server, ideal for streaming without library bloat
# Note: On Linux/Docker, you can customize cache location by setting TMPDIR environment variable
STORAGE_MODE=Permanent
STORAGE_MODE=Cache
# Cache duration in hours (optional, default: 1)
# Files older than this duration will be automatically deleted when STORAGE_MODE=Cache
+8 -1
View File
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.DependencyInjection;
using Moq;
using Moq.Protected;
using allstarr.Models.Settings;
@@ -40,13 +41,19 @@ public class JellyfinProxyServiceTests
ClientName = "TestClient",
DeviceName = "TestDevice",
DeviceId = "test-device-id",
ClientVersion = "1.0.0"
ClientVersion = "1.0.1"
};
var httpContext = new DefaultHttpContext();
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
// Initialize cache settings for tests
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
var serviceProvider = serviceCollection.BuildServiceProvider();
CacheExtensions.InitializeCacheSettings(serviceProvider);
_service = new JellyfinProxyService(
_mockHttpClientFactory.Object,
Options.Create(_settings),
+1 -1
View File
@@ -151,7 +151,7 @@ public class QobuzDownloadServiceTests : IDisposable
var mockResponse = new HttpResponseMessage
{
StatusCode = HttpStatusCode.OK,
Content = new StringContent(@"<html><script src=""/resources/1.0.0-b001/bundle.js""></script></html>")
Content = new StringContent(@"<html><script src=""/resources/1.0.1-b001/bundle.js""></script></html>")
};
_httpMessageHandlerMock.Protected()
+104 -62
View File
@@ -150,7 +150,7 @@ public class AdminController : ControllerBase
return Ok(new
{
version = "1.0.0",
version = "1.0.1",
backendType = _configuration.GetValue<string>("Backend:Type") ?? "Jellyfin",
jellyfinUrl = _jellyfinSettings.Url,
spotify = new
@@ -207,6 +207,10 @@ public class AdminController : ControllerBase
return Ok(new { baseUrl });
}
/// <summary>
/// Get current configuration including cache settings
/// </summary>
/// <summary>
/// Get list of configured playlists with their current data
/// </summary>
@@ -232,17 +236,17 @@ public class AdminController : ControllerBase
}
else
{
_logger.LogDebug("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
_logger.LogWarning("🔄 Cache expired (age: {Age:F1}m), refreshing...", age.TotalMinutes);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cached playlist summary");
_logger.LogError(ex, "Failed to read cached playlist summary");
}
}
else if (refresh)
{
_logger.LogInformation("🔄 Force refresh requested for playlist summary");
_logger.LogDebug("🔄 Force refresh requested for playlist summary");
}
var playlists = new List<object>();
@@ -297,7 +301,7 @@ public class AdminController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read cache for playlist {Name}", config.Name);
_logger.LogError(ex, "Failed to read cache for playlist {Name}", config.Name);
}
}
@@ -364,7 +368,7 @@ public class AdminController : ControllerBase
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name);
}
_logger.LogInformation("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
_logger.LogDebug("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}",
config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
@@ -377,6 +381,7 @@ public class AdminController : ControllerBase
{
// Check if it's external by looking for external provider in ProviderIds
// External providers: SquidWTF, Deezer, Qobuz, Tidal
// Local tracks: Have Jellyfin ID OR no external provider keys
var isExternal = false;
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
@@ -399,7 +404,7 @@ public class AdminController : ControllerBase
if (providerIds != null)
{
// Check for external provider keys (not MusicBrainz, ISRC, Spotify, etc)
// Check for external provider keys (not MusicBrainz, ISRC, Spotify, Jellyfin, etc)
isExternal = providerIds.Keys.Any(k =>
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase) ||
k.Equals("Deezer", StringComparison.OrdinalIgnoreCase) ||
@@ -414,6 +419,7 @@ public class AdminController : ControllerBase
}
else
{
// Local track (has Jellyfin ID or no external provider)
localCount++;
}
}
@@ -428,7 +434,7 @@ public class AdminController : ControllerBase
playlistInfo["totalInJellyfin"] = cachedPlaylistItems.Count;
playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served
_logger.LogInformation("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
_logger.LogDebug("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
config.Name, spotifyTracks.Count, localCount, externalCount, externalMissingCount, localCount + externalCount);
}
else
@@ -545,7 +551,7 @@ public class AdminController : ControllerBase
playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount;
playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served
_logger.LogDebug("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
_logger.LogWarning("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable",
config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount);
}
}
@@ -556,19 +562,19 @@ public class AdminController : ControllerBase
}
else
{
_logger.LogWarning("Failed to get Jellyfin playlist {Name}: {StatusCode}",
_logger.LogError("Failed to get Jellyfin playlist {Name}: {StatusCode}",
config.Name, response.StatusCode);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
_logger.LogError(ex, "Failed to get Jellyfin playlist tracks for {Name}", config.Name);
}
}
else
{
_logger.LogWarning("Playlist {Name} has no JellyfinId configured", config.Name);
_logger.LogInformation("Playlist {Name} has no JellyfinId configured", config.Name);
}
playlists.Add(playlistInfo);
@@ -589,7 +595,7 @@ public class AdminController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save playlist summary cache");
_logger.LogError(ex, "Failed to save playlist summary cache");
}
return Ok(new { playlists });
@@ -622,7 +628,7 @@ public class AdminController : ControllerBase
_logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName);
}
_logger.LogInformation("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
_logger.LogDebug("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}",
decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0);
if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0)
@@ -630,8 +636,15 @@ public class AdminController : ControllerBase
// Build a map of Spotify ID -> cached item for quick lookup
var spotifyIdToItem = new Dictionary<string, Dictionary<string, object?>>();
foreach (var item in cachedPlaylistItems)
// Also track items by position for fallback matching
var itemsByPosition = new Dictionary<int, Dictionary<string, object?>>();
for (int i = 0; i < cachedPlaylistItems.Count; i++)
{
var item = cachedPlaylistItems[i];
// Try to get Spotify ID from ProviderIds (works for both local and external)
bool hasSpotifyId = false;
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
{
Dictionary<string, string>? providerIds = null;
@@ -652,8 +665,15 @@ public class AdminController : ControllerBase
if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId))
{
spotifyIdToItem[spotifyId] = item;
hasSpotifyId = true;
}
}
// If no Spotify ID found, use position-based matching as fallback
if (!hasSpotifyId)
{
itemsByPosition[i] = item;
}
}
// Match each Spotify track to its cached item
@@ -665,7 +685,20 @@ public class AdminController : ControllerBase
string? manualMappingType = null;
string? manualMappingId = null;
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out var cachedItem))
Dictionary<string, object?>? cachedItem = null;
// First try to match by Spotify ID
if (spotifyIdToItem.TryGetValue(track.SpotifyId, out cachedItem))
{
_logger.LogDebug("Matched track {Title} by Spotify ID", track.Title);
}
// Fallback: Try position-based matching for items without Spotify ID
else if (itemsByPosition.TryGetValue(track.Position, out cachedItem))
{
_logger.LogDebug("Matched track {Title} by position {Position}", track.Title, track.Position);
}
if (cachedItem != null)
{
// Track is in the cache - determine if it's local or external
if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null)
@@ -689,31 +722,29 @@ public class AdminController : ControllerBase
{
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
// Check for external provider keys (case-insensitive)
// External providers: squidwtf, deezer, qobuz, tidal (lowercase)
var providerKey = providerIds.Keys.FirstOrDefault(k =>
// Check for external provider keys (SquidWTF, Deezer, Qobuz, Tidal)
// If found, it's an external track
if (providerIds.Keys.Any(k =>
k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) ||
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase));
if (providerKey != null)
k.Equals("SquidWTF", StringComparison.OrdinalIgnoreCase)))
{
isLocal = false;
externalProvider = "SquidWTF";
_logger.LogDebug("✓ Track {Title} identified as SquidWTF", track.Title);
}
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase))) != null)
else if (providerIds.Keys.Any(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase)))
{
isLocal = false;
externalProvider = "Deezer";
_logger.LogDebug("✓ Track {Title} identified as Deezer", track.Title);
}
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase))) != null)
else if (providerIds.Keys.Any(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase)))
{
isLocal = false;
externalProvider = "Qobuz";
_logger.LogDebug("✓ Track {Title} identified as Qobuz", track.Title);
}
else if ((providerKey = providerIds.Keys.FirstOrDefault(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase))) != null)
else if (providerIds.Keys.Any(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase)))
{
isLocal = false;
externalProvider = "Tidal";
@@ -721,22 +752,25 @@ public class AdminController : ControllerBase
}
else
{
// No external provider key found - it's a local track
// Local tracks have MusicBrainz, ISRC, Spotify IDs but no external provider
// No external provider key found - it's a local Jellyfin track
// Local tracks may have: Jellyfin ID, MusicBrainz IDs, ISRC, etc.
isLocal = true;
_logger.LogDebug("✓ Track {Title} identified as LOCAL (has ProviderIds but no external provider)", track.Title);
}
}
else
{
_logger.LogWarning("Track {Title} has ProviderIds object but it's null after parsing", track.Title);
// ProviderIds exists but is null after parsing - treat as local
isLocal = true;
_logger.LogDebug("✓ Track {Title} identified as LOCAL (ProviderIds null)", track.Title);
}
}
else
{
_logger.LogWarning("Track {Title} in cache but has NO ProviderIds - treating as missing", track.Title);
isLocal = null;
externalProvider = null;
// Track is in cache but has NO ProviderIds property at all
// This is typical for local Jellyfin tracks - treat as local
isLocal = true;
_logger.LogDebug("✓ Track {Title} identified as LOCAL (in cache, no ProviderIds)", track.Title);
}
// Check if this is a manual mapping
@@ -862,7 +896,7 @@ public class AdminController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", track.Title);
_logger.LogError(ex, "Failed to process external manual mapping for {Title}", track.Title);
}
}
else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId))
@@ -1048,7 +1082,7 @@ public class AdminController : ControllerBase
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
_logger.LogError("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" });
}
@@ -1064,7 +1098,7 @@ public class AdminController : ControllerBase
var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : "";
if (type != "Audio")
{
_logger.LogDebug("Skipping non-audio item: {Type}", type);
_logger.LogWarning("Skipping non-audio item: {Type}", type);
continue;
}
@@ -1125,7 +1159,7 @@ public class AdminController : ControllerBase
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
_logger.LogError("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}",
id, response.StatusCode, errorBody);
return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" });
}
@@ -1246,7 +1280,7 @@ public class AdminController : ControllerBase
if (System.IO.File.Exists(matchedFile))
{
System.IO.File.Delete(matchedFile);
_logger.LogDebug("Deleted matched tracks file cache for {Playlist}", decodedName);
_logger.LogInformation("Deleted matched tracks file cache for {Playlist}", decodedName);
}
if (System.IO.File.Exists(itemsFile))
@@ -1257,7 +1291,7 @@ public class AdminController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete file caches for {Playlist}", decodedName);
_logger.LogError(ex, "Failed to delete file caches for {Playlist}", decodedName);
}
_logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName);
@@ -1283,13 +1317,13 @@ public class AdminController : ControllerBase
}
else
{
_logger.LogWarning("Failed to fetch external track metadata for {Provider} ID {Id}",
_logger.LogError("Failed to fetch external track metadata for {Provider} ID {Id}",
normalizedProvider, request.ExternalId);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch external track metadata, but mapping was saved");
_logger.LogError(ex, "Failed to fetch external track metadata, but mapping was saved");
}
}
@@ -1461,7 +1495,7 @@ public class AdminController : ControllerBase
return BadRequest(new { error = "No updates provided" });
}
_logger.LogInformation("Config update requested: {Count} changes", request.Updates.Count);
_logger.LogDebug("Config update requested: {Count} changes", request.Updates.Count);
try
{
@@ -1490,7 +1524,7 @@ public class AdminController : ControllerBase
envContent[key] = value;
}
}
_logger.LogInformation("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
_logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _envFilePath);
}
// Apply updates with validation
@@ -1526,7 +1560,7 @@ public class AdminController : ControllerBase
var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}"));
await System.IO.File.WriteAllTextAsync(_envFilePath, newContent + "\n");
_logger.LogInformation("Config file updated successfully at {Path}", _envFilePath);
_logger.LogDebug("Config file updated successfully at {Path}", _envFilePath);
// Invalidate playlist summary cache if playlists were updated
if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS"))
@@ -1654,7 +1688,7 @@ public class AdminController : ControllerBase
[HttpPost("cache/clear")]
public async Task<IActionResult> ClearCache()
{
_logger.LogInformation("Cache clear requested from admin UI");
_logger.LogDebug("Cache clear requested from admin UI");
var clearedFiles = 0;
var clearedRedisKeys = 0;
@@ -1671,7 +1705,7 @@ public class AdminController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete cache file {File}", file);
_logger.LogError(ex, "Failed to delete cache file {File}", file);
}
}
}
@@ -1723,7 +1757,7 @@ public class AdminController : ControllerBase
[HttpPost("restart")]
public async Task<IActionResult> RestartContainer()
{
_logger.LogInformation("Container restart requested from admin UI");
_logger.LogDebug("Container restart requested from admin UI");
try
{
@@ -1744,7 +1778,7 @@ public class AdminController : ControllerBase
var containerId = Environment.MachineName;
var containerName = "allstarr";
_logger.LogInformation("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
_logger.LogDebug("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName);
// Create Unix socket HTTP client
var handler = new SocketsHttpHandler
@@ -2121,7 +2155,7 @@ public class AdminController : ControllerBase
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
_logger.LogError("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode);
return (0, 0, 0);
}
@@ -2321,7 +2355,7 @@ public class AdminController : ControllerBase
private string GetJellyfinAuthHeader()
{
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.0\", Token=\"{_jellyfinSettings.ApiKey}\"";
return $"MediaBrowser Client=\"Allstarr\", Device=\"Server\", DeviceId=\"allstarr-admin\", Version=\"1.0.1\", Token=\"{_jellyfinSettings.ApiKey}\"";
}
/// <summary>
@@ -2460,7 +2494,7 @@ public class AdminController : ControllerBase
{
var backupPath = $"{_envFilePath}.backup.{DateTime.UtcNow:yyyyMMddHHmmss}";
System.IO.File.Copy(_envFilePath, backupPath, true);
_logger.LogInformation("Backed up existing .env to {BackupPath}", backupPath);
_logger.LogDebug("Backed up existing .env to {BackupPath}", backupPath);
}
// Write new .env file
@@ -2770,7 +2804,7 @@ public class AdminController : ControllerBase
}
}
_logger.LogInformation("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
_logger.LogDebug("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count);
return Ok(new {
message = "Spotify cache cleared successfully",
@@ -2875,7 +2909,7 @@ public class AdminController : ControllerBase
if (System.IO.File.Exists(logFile))
{
System.IO.File.Delete(logFile);
_logger.LogInformation("Cleared endpoint usage log via admin endpoint");
_logger.LogDebug("Cleared endpoint usage log via admin endpoint");
return Ok(new {
message = "Endpoint usage log cleared successfully",
@@ -3044,7 +3078,7 @@ public class AdminController : ControllerBase
// Cache the lyrics using the standard cache key
var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}";
await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics);
_logger.LogInformation("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
_logger.LogDebug("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title);
return Ok(new
{
@@ -3066,7 +3100,7 @@ public class AdminController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
_logger.LogError(ex, "Failed to fetch lyrics after mapping, but mapping was saved");
}
return Ok(new
@@ -3157,7 +3191,7 @@ public class AdminController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read mapping file {File}", file);
_logger.LogError(ex, "Failed to read mapping file {File}", file);
}
}
@@ -3364,7 +3398,7 @@ public class AdminController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to invalidate playlist summary cache");
_logger.LogError(ex, "Failed to invalidate playlist summary cache");
}
}
@@ -3441,7 +3475,7 @@ public class UpdateScheduleRequest
{
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
_logger.LogInformation("📂 Checking kept folder: {Path}", keptPath);
_logger.LogDebug("📂 Checking kept folder: {Path}", keptPath);
_logger.LogInformation("📂 Directory exists: {Exists}", Directory.Exists(keptPath));
if (!Directory.Exists(keptPath))
@@ -3460,7 +3494,7 @@ public class UpdateScheduleRequest
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
.ToList();
_logger.LogInformation("📂 Found {Count} audio files in kept folder", allFiles.Count);
_logger.LogDebug("📂 Found {Count} audio files in kept folder", allFiles.Count);
foreach (var filePath in allFiles)
{
@@ -3491,7 +3525,7 @@ public class UpdateScheduleRequest
totalSize += fileInfo.Length;
}
_logger.LogInformation("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
_logger.LogDebug("📂 Returning {Count} kept files, total size: {Size}", files.Count, FormatFileSize(totalSize));
return Ok(new
{
@@ -3525,7 +3559,7 @@ public class UpdateScheduleRequest
var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept");
var fullPath = Path.Combine(keptPath, path);
_logger.LogInformation("🗑️ Delete request for: {Path}", fullPath);
_logger.LogDebug("🗑️ Delete request for: {Path}", fullPath);
// Security: Ensure the path is within the kept directory
var normalizedFullPath = Path.GetFullPath(fullPath);
@@ -3544,7 +3578,7 @@ public class UpdateScheduleRequest
}
System.IO.File.Delete(fullPath);
_logger.LogInformation("🗑️ Deleted file: {Path}", fullPath);
_logger.LogDebug("🗑️ Deleted file: {Path}", fullPath);
// Clean up empty directories (Album folder, then Artist folder if empty)
var directory = Path.GetDirectoryName(fullPath);
@@ -3558,7 +3592,7 @@ public class UpdateScheduleRequest
}
else
{
_logger.LogDebug("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
_logger.LogInformation("🗑️ Directory not empty or doesn't exist, stopping cleanup: {Dir}", directory);
break;
}
}
@@ -3627,4 +3661,12 @@ public class UpdateScheduleRequest
}
return $"{len:0.##} {sizes[order]}";
}
}
}
/// <summary>
/// Request model for updating configuration
/// </summary>
public class ConfigUpdateRequest
{
public Dictionary<string, string> Updates { get; set; } = new();
}
+117 -118
View File
@@ -117,7 +117,7 @@ public class JellyfinController : ControllerBase
[FromQuery] bool recursive = true,
string? userId = null)
{
_logger.LogInformation("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, userId={UserId}",
searchTerm, includeItemTypes, parentId, artistIds, userId);
// Cache search results in Redis only (no file persistence, 15 min TTL)
@@ -216,14 +216,14 @@ public class JellyfinController : ControllerBase
return Unauthorized(new { error = "Authentication required" });
}
_logger.LogInformation("Jellyfin returned {StatusCode}, returning empty result", statusCode);
_logger.LogDebug("Jellyfin returned {StatusCode}, returning empty result", statusCode);
return new JsonResult(new { Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
}
// Update Spotify playlist counts if enabled and response contains playlists
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
{
_logger.LogInformation("Browse result has Items, checking for Spotify playlists to update counts");
_logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts");
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
}
@@ -254,7 +254,7 @@ public class JellyfinController : ControllerBase
}
var cleanQuery = searchTerm?.Trim().Trim('"') ?? "";
_logger.LogInformation("Performing integrated search for: {Query}", cleanQuery);
_logger.LogDebug("Performing integrated search for: {Query}", cleanQuery);
// Run local and external searches in parallel
var itemTypes = ParseItemTypes(includeItemTypes);
@@ -275,7 +275,7 @@ public class JellyfinController : ControllerBase
var externalResult = await externalTask;
var playlistResult = await playlistTask;
_logger.LogInformation("Search results: Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}",
_logger.LogDebug("Search results: Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}",
jellyfinResult != null ? "found" : "null",
externalResult.Songs.Count,
externalResult.Albums.Count,
@@ -346,7 +346,7 @@ public class JellyfinController : ControllerBase
mergedAlbums.AddRange(playlistItems);
}
_logger.LogInformation("Merged and sorted results by score: Songs={Songs}, Albums={Albums}, Artists={Artists}",
_logger.LogDebug("Merged and sorted results by score: Songs={Songs}, Albums={Albums}, Artists={Artists}",
mergedSongs.Count, mergedAlbums.Count, mergedArtists.Count);
// Pre-fetch lyrics for top 3 songs in background (don't await)
@@ -377,7 +377,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to pre-fetch lyrics for search results");
_logger.LogError(ex, "Failed to pre-fetch lyrics for search results");
}
});
}
@@ -385,28 +385,28 @@ public class JellyfinController : ControllerBase
// Filter by item types if specified
var items = new List<Dictionary<string, object?>>();
_logger.LogInformation("Filtering by item types: {ItemTypes}", itemTypes == null ? "null" : string.Join(",", itemTypes));
_logger.LogDebug("Filtering by item types: {ItemTypes}", itemTypes == null ? "null" : string.Join(",", itemTypes));
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist"))
{
_logger.LogInformation("Adding {Count} artists to results", mergedArtists.Count);
_logger.LogDebug("Adding {Count} artists to results", mergedArtists.Count);
items.AddRange(mergedArtists);
}
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || itemTypes.Contains("Playlist"))
{
_logger.LogInformation("Adding {Count} albums to results", mergedAlbums.Count);
_logger.LogDebug("Adding {Count} albums to results", mergedAlbums.Count);
items.AddRange(mergedAlbums);
}
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio"))
{
_logger.LogInformation("Adding {Count} songs to results", mergedSongs.Count);
_logger.LogDebug("Adding {Count} songs to results", mergedSongs.Count);
items.AddRange(mergedSongs);
}
// Apply pagination
var pagedItems = items.Skip(startIndex).Take(limit).ToList();
_logger.LogInformation("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count);
_logger.LogDebug("Returning {Count} items (total: {Total})", pagedItems.Count, items.Count);
try
{
@@ -422,11 +422,11 @@ public class JellyfinController : ControllerBase
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(artistIds))
{
var cacheKey = $"search:{searchTerm?.ToLowerInvariant()}:{includeItemTypes}:{limit}:{startIndex}";
await _cache.SetAsync(cacheKey, response, TimeSpan.FromMinutes(15));
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' (15 min TTL)", searchTerm);
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm, CacheExtensions.SearchResultsTTL.TotalMinutes);
}
_logger.LogInformation("About to serialize response...");
_logger.LogDebug("About to serialize response...");
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
{
@@ -615,7 +615,7 @@ public class JellyfinController : ControllerBase
{
var itemTypes = ParseItemTypes(includeItemTypes);
_logger.LogInformation("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
_logger.LogDebug("GetExternalChildItems: provider={Provider}, externalId={ExternalId}, itemTypes={ItemTypes}",
provider, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
// Check if asking for audio (album tracks)
@@ -636,7 +636,7 @@ public class JellyfinController : ControllerBase
var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId);
var artist = await _metadataService.GetArtistAsync(provider, externalId);
_logger.LogInformation("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
_logger.LogDebug("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown");
// Fill artist info
if (artist != null)
@@ -667,13 +667,13 @@ public class JellyfinController : ControllerBase
[FromQuery] int limit = 50,
[FromQuery] int startIndex = 0)
{
_logger.LogInformation("GetArtists called: searchTerm={SearchTerm}, limit={Limit}", searchTerm, limit);
_logger.LogDebug("GetArtists called: searchTerm={SearchTerm}, limit={Limit}", searchTerm, limit);
// If there's a search term, integrate external results
if (!string.IsNullOrWhiteSpace(searchTerm))
{
var cleanQuery = searchTerm.Trim().Trim('"');
_logger.LogInformation("Searching artists for: {Query}", cleanQuery);
_logger.LogDebug("Searching artists for: {Query}", cleanQuery);
// Run local and external searches in parallel
var jellyfinTask = _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers);
@@ -684,7 +684,7 @@ public class JellyfinController : ControllerBase
var (jellyfinResult, _) = await jellyfinTask;
var externalArtists = await externalTask;
_logger.LogInformation("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
_logger.LogDebug("Artist search results: Jellyfin={JellyfinCount}, External={ExternalCount}",
jellyfinResult != null ? "found" : "null", externalArtists.Count);
// Parse Jellyfin artists
@@ -701,7 +701,7 @@ public class JellyfinController : ControllerBase
// Show ALL matches (local + external) sorted by best match first
var mergedArtists = localArtists.Concat(externalArtists).ToList();
_logger.LogInformation("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
_logger.LogDebug("Returning {Count} total artists (local + external, no deduplication)", mergedArtists.Count);
// Convert to Jellyfin format
var artistItems = mergedArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
@@ -966,14 +966,14 @@ public class JellyfinController : ControllerBase
if (localPath != null && System.IO.File.Exists(localPath))
{
// Update last access time for cache cleanup
// Update last write time for cache cleanup (extends cache lifetime)
try
{
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath);
_logger.LogError(ex, "Failed to update last write time for {Path}", localPath);
}
var stream = System.IO.File.OpenRead(localPath);
@@ -1106,7 +1106,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch cover art from {Url}", coverUrl);
_logger.LogError(ex, "Failed to fetch cover art from {Url}", coverUrl);
// Return placeholder on exception
return await GetPlaceholderImageAsync();
}
@@ -1147,7 +1147,7 @@ public class JellyfinController : ControllerBase
[HttpGet("Items/{itemId}/Lyrics")]
public async Task<IActionResult> GetLyrics(string itemId)
{
_logger.LogInformation("🎵 GetLyrics called for itemId: {ItemId}", itemId);
_logger.LogDebug("🎵 GetLyrics called for itemId: {ItemId}", itemId);
if (string.IsNullOrWhiteSpace(itemId))
{
@@ -1156,18 +1156,18 @@ public class JellyfinController : ControllerBase
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
_logger.LogInformation("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
_logger.LogDebug("🎵 Lyrics request: itemId={ItemId}, isExternal={IsExternal}, provider={Provider}, externalId={ExternalId}",
itemId, isExternal, provider, externalId);
// For local tracks, check if Jellyfin already has embedded lyrics
if (!isExternal)
{
_logger.LogInformation("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
_logger.LogDebug("Checking Jellyfin for embedded lyrics for local track: {ItemId}", itemId);
// Try to get lyrics from Jellyfin first (it reads embedded lyrics from files)
var (jellyfinLyrics, statusCode) = await _proxyService.GetJsonAsync($"Audio/{itemId}/Lyrics", null, Request.Headers);
_logger.LogInformation("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
_logger.LogDebug("Jellyfin lyrics check result: statusCode={StatusCode}, hasLyrics={HasLyrics}",
statusCode, jellyfinLyrics != null);
if (jellyfinLyrics != null && statusCode == 200)
@@ -1176,7 +1176,7 @@ public class JellyfinController : ControllerBase
return new JsonResult(JsonSerializer.Deserialize<object>(jellyfinLyrics.RootElement.GetRawText()));
}
_logger.LogInformation("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
_logger.LogWarning("No embedded lyrics found in Jellyfin (status: {StatusCode}), trying Spotify/LRCLIB", statusCode);
}
// Get song metadata for lyrics search
@@ -1200,7 +1200,7 @@ public class JellyfinController : ControllerBase
spotifyTrackId = await FindSpotifyIdForExternalTrackAsync(song);
if (!string.IsNullOrEmpty(spotifyTrackId))
{
_logger.LogInformation("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
_logger.LogDebug("Found Spotify ID {SpotifyId} for external track {Provider}/{ExternalId} from cache",
spotifyTrackId, provider, externalId);
}
else
@@ -1228,7 +1228,7 @@ public class JellyfinController : ControllerBase
if (!string.IsNullOrEmpty(spotifyTrackId))
{
_logger.LogInformation("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
_logger.LogDebug("Converted {Provider}/{ExternalId} to Spotify ID {SpotifyId} via Odesli",
provider, externalId, spotifyTrackId);
}
}
@@ -1348,7 +1348,7 @@ public class JellyfinController : ControllerBase
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
{
_logger.LogInformation("Parsing synced lyrics (LRC format)");
_logger.LogDebug("Parsing synced lyrics (LRC format)");
// Parse LRC format: [mm:ss.xx] text
// Skip ID tags like [ar:Artist], [ti:Title], etc.
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
@@ -1376,7 +1376,7 @@ public class JellyfinController : ControllerBase
}
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
}
_logger.LogInformation("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
_logger.LogDebug("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
}
else if (!string.IsNullOrEmpty(lyricsText))
{
@@ -1392,7 +1392,7 @@ public class JellyfinController : ControllerBase
["Text"] = line.Trim()
});
}
_logger.LogInformation("Split into {Count} plain lyric lines", lyricLines.Count);
_logger.LogDebug("Split into {Count} plain lyric lines", lyricLines.Count);
}
else
{
@@ -1417,14 +1417,14 @@ public class JellyfinController : ControllerBase
Lyrics = lyricLines
};
_logger.LogInformation("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
_logger.LogDebug("Returning lyrics response: {LineCount} lines, synced={IsSynced}", lyricLines.Count, isSynced);
// Log a sample of the response for debugging
if (lyricLines.Count > 0)
{
var sampleLine = lyricLines[0];
var hasStart = sampleLine.ContainsKey("Start");
_logger.LogInformation("Sample line: Text='{Text}', HasStart={HasStart}",
_logger.LogDebug("Sample line: Text='{Text}', HasStart={HasStart}",
sampleLine.GetValueOrDefault("Text"), hasStart);
}
@@ -1574,7 +1574,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error prefetching lyrics for track {ItemId}", itemId);
_logger.LogError(ex, "Error prefetching lyrics for track {ItemId}", itemId);
}
}
@@ -1596,7 +1596,7 @@ public class JellyfinController : ControllerBase
userId = Request.Query["userId"].ToString();
}
_logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
_logger.LogDebug("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// Check if this is an external playlist - trigger download
@@ -1665,7 +1665,7 @@ public class JellyfinController : ControllerBase
endpoint = $"{endpoint}?userId={userId}";
}
_logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
_logger.LogDebug("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
@@ -1686,7 +1686,7 @@ public class JellyfinController : ControllerBase
userId = Request.Query["userId"].ToString();
}
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
_logger.LogDebug("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// External items - remove from kept folder if it exists
@@ -1723,7 +1723,7 @@ public class JellyfinController : ControllerBase
endpoint = $"{endpoint}?userId={userId}";
}
_logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
_logger.LogDebug("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
var (result, statusCode) = await _proxyService.DeleteAsync(endpoint, Request.Headers);
@@ -1785,7 +1785,7 @@ public class JellyfinController : ControllerBase
{
try
{
_logger.LogInformation("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
_logger.LogDebug("=== GetPlaylistTracks called === PlaylistId: {PlaylistId}", playlistId);
// Check if this is an external playlist (Deezer/Qobuz) first
if (PlaylistIdHelper.IsExternalPlaylist(playlistId))
@@ -1825,7 +1825,7 @@ public class JellyfinController : ControllerBase
endpoint = $"{endpoint}{Request.QueryString.Value}";
}
_logger.LogInformation("Proxying to Jellyfin: {Endpoint}", endpoint);
_logger.LogDebug("Proxying to Jellyfin: {Endpoint}", endpoint);
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
return HandleProxyResponse(result, statusCode);
@@ -1871,15 +1871,15 @@ public class JellyfinController : ControllerBase
var imageBytes = await response.Content.ReadAsByteArrayAsync();
var contentType = response.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
// Cache for 1 hour (playlists can change, so don't cache too long)
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
// Cache for configurable duration (playlists can change)
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
_logger.LogDebug("Cached playlist image for {PlaylistId}", playlistId);
return File(imageBytes, contentType);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get playlist image {PlaylistId}", playlistId);
_logger.LogError(ex, "Failed to get playlist image {PlaylistId}", playlistId);
return NotFound();
}
}
@@ -1907,7 +1907,7 @@ public class JellyfinController : ControllerBase
// Reset stream position
Request.Body.Position = 0;
_logger.LogInformation("Authentication request received");
_logger.LogDebug("Authentication request received");
// DO NOT log request body or detailed headers - contains password
// Forward to Jellyfin server with client headers - completely transparent proxy
@@ -1970,14 +1970,14 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to post session capabilities after auth");
_logger.LogError(ex, "Failed to post session capabilities after auth");
}
});
}
}
else
{
_logger.LogWarning("Authentication failed - status {StatusCode}", statusCode);
_logger.LogError("Authentication failed - status {StatusCode}", statusCode);
}
// Return Jellyfin's exact response
@@ -2060,7 +2060,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get similar items for external song {ItemId}", itemId);
_logger.LogError(ex, "Failed to get similar items for external song {ItemId}", itemId);
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
@@ -2169,7 +2169,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to create instant mix for external song {ItemId}", itemId);
_logger.LogError(ex, "Failed to create instant mix for external song {ItemId}", itemId);
return _responseBuilder.CreateJsonResponse(new
{
Items = Array.Empty<object>(),
@@ -2249,7 +2249,7 @@ public class JellyfinController : ControllerBase
}
else if (statusCode == 401)
{
_logger.LogDebug("⚠ Jellyfin returned 401 for capabilities (token expired)");
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
}
else
{
@@ -2331,7 +2331,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
_logger.LogError(ex, "Failed to prefetch lyrics for external track {ItemId}", itemId);
}
});
@@ -2381,7 +2381,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
_logger.LogError(ex, "Failed to prefetch lyrics for local track {ItemId}", itemId);
}
});
}
@@ -2425,7 +2425,7 @@ public class JellyfinController : ControllerBase
}
else
{
_logger.LogWarning("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
_logger.LogError("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
}
}
else
@@ -2451,7 +2451,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to send playback start, trying basic");
_logger.LogError(ex, "Failed to send playback start, trying basic");
// Fall back to basic playback start
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200)
@@ -2464,7 +2464,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to report playback start");
_logger.LogError(ex, "Failed to report playback start");
return NoContent(); // Return success anyway to not break playback
}
}
@@ -2577,7 +2577,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to report playback progress");
_logger.LogError(ex, "Failed to report playback progress");
return NoContent();
}
}
@@ -2598,7 +2598,7 @@ public class JellyfinController : ControllerBase
}
Request.Body.Position = 0;
_logger.LogDebug("⏹️ Playback STOPPED reported");
_logger.LogInformation("⏹️ Playback STOPPED reported");
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
@@ -2694,7 +2694,7 @@ public class JellyfinController : ControllerBase
}
else if (statusCode == 401)
{
_logger.LogDebug("Playback stop returned 401 (token expired)");
_logger.LogWarning("Playback stop returned 401 (token expired)");
}
else
{
@@ -2705,7 +2705,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to report playback stopped");
_logger.LogError(ex, "Failed to report playback stopped");
return NoContent();
}
}
@@ -2727,7 +2727,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to ping playback session");
_logger.LogError(ex, "Failed to ping playback session");
return NoContent();
}
}
@@ -2815,7 +2815,7 @@ public class JellyfinController : ControllerBase
{
LocalAddress = Request.Host.ToString(),
ServerName = serverName ?? "Allstarr",
Version = version ?? "1.0.0",
Version = version ?? "1.0.1",
ProductName = "Allstarr (Jellyfin Proxy)",
OperatingSystem = Environment.OSVersion.Platform.ToString(),
Id = _settings.DeviceId,
@@ -2899,7 +2899,7 @@ public class JellyfinController : ControllerBase
{
var playlistId = parts[1];
_logger.LogInformation("=== PLAYLIST REQUEST ===");
_logger.LogDebug("=== PLAYLIST REQUEST ===");
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
@@ -2975,7 +2975,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to proxy binary request for {Path}", path);
_logger.LogError(ex, "Failed to proxy binary request for {Path}", path);
return NotFound();
}
}
@@ -2985,12 +2985,12 @@ public class JellyfinController : ControllerBase
if (!string.IsNullOrWhiteSpace(searchTerm))
{
_logger.LogInformation("ProxyRequest intercepting search request: Path={Path}, SearchTerm={SearchTerm}", path, searchTerm);
_logger.LogDebug("ProxyRequest intercepting search request: Path={Path}, SearchTerm={SearchTerm}", path, searchTerm);
// Item search: /users/{userId}/items or /items
if (path.EndsWith("/items", StringComparison.OrdinalIgnoreCase) || path.Equals("items", StringComparison.OrdinalIgnoreCase))
{
_logger.LogInformation("Redirecting to SearchItems");
_logger.LogDebug("Redirecting to SearchItems");
return await SearchItems(
searchTerm: searchTerm,
includeItemTypes: Request.Query["IncludeItemTypes"],
@@ -3031,7 +3031,7 @@ public class JellyfinController : ControllerBase
Request.EnableBuffering();
// Log request details for debugging
_logger.LogInformation("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}",
_logger.LogDebug("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}",
fullPath, Request.Method, Request.ContentType, Request.ContentLength);
// Read body using StreamReader with proper encoding
@@ -3055,7 +3055,7 @@ public class JellyfinController : ControllerBase
}
else
{
_logger.LogInformation("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}",
_logger.LogDebug("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}",
fullPath, body.Length, Request.ContentType);
// Always log body content for playback endpoints to debug the issue
@@ -3112,7 +3112,7 @@ public class JellyfinController : ControllerBase
result.RootElement.ValueKind == JsonValueKind.Object &&
result.RootElement.TryGetProperty("Items", out var items))
{
_logger.LogInformation("Response has Items property, checking for Spotify playlists to update counts");
_logger.LogDebug("Response has Items property, checking for Spotify playlists to update counts");
result = await UpdateSpotifyPlaylistCounts(result);
}
@@ -3121,7 +3121,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Proxy request failed for {Path}", path);
_logger.LogError(ex, "Proxy request failed for {Path}", path);
return _responseBuilder.CreateError(502, $"Proxy error: {ex.Message}");
}
}
@@ -3183,7 +3183,7 @@ public class JellyfinController : ControllerBase
var modified = false;
var updatedItems = new List<Dictionary<string, object>>();
_logger.LogInformation("Checking {Count} items for Spotify playlists", itemsArray.Count);
_logger.LogDebug("Checking {Count} items for Spotify playlists", itemsArray.Count);
foreach (var item in itemsArray)
{
@@ -3216,7 +3216,7 @@ public class JellyfinController : ControllerBase
var matchedTracksKey = $"spotify:matched:ordered:{playlistName}";
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
_logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks",
matchedTracksKey, matchedTracks?.Count ?? 0);
// Fallback to legacy cache format
@@ -3241,7 +3241,7 @@ public class JellyfinController : ControllerBase
var fileItems = await LoadPlaylistItemsFromFile(playlistName);
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for count update", fileItems.Count);
// Use file cache count directly
itemDict["ChildCount"] = fileItems.Count;
modified = true;
@@ -3274,13 +3274,13 @@ public class JellyfinController : ControllerBase
localTracksResponse.RootElement.TryGetProperty("Items", out var localItems))
{
localTracksCount = localItems.GetArrayLength();
_logger.LogInformation("Found {Count} total items in Jellyfin playlist {Name}",
_logger.LogDebug("Found {Count} total items in Jellyfin playlist {Name}",
localTracksCount, playlistName);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get local tracks count for {Name}", playlistName);
_logger.LogError(ex, "Failed to get local tracks count for {Name}", playlistName);
}
// Count external matched tracks (not local)
@@ -3299,7 +3299,7 @@ public class JellyfinController : ControllerBase
// Update ChildCount to show actual available tracks
itemDict["ChildCount"] = totalAvailableCount;
modified = true;
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
_logger.LogDebug("✓ Updated ChildCount for Spotify playlist {Name} to {Total} ({Local} local + {External} external)",
playlistName, totalAvailableCount, localTracksCount, externalMatchedCount);
}
else
@@ -3325,7 +3325,7 @@ public class JellyfinController : ControllerBase
return response;
}
_logger.LogInformation("Modified {Count} Spotify playlists, rebuilding response",
_logger.LogDebug("Modified {Count} Spotify playlists, rebuilding response",
updatedItems.Count(i => i.ContainsKey("ChildCount")));
// Rebuild the response with updated items
@@ -3341,7 +3341,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update Spotify playlist counts");
_logger.LogError(ex, "Failed to update Spotify playlist counts");
return response;
}
}
@@ -3373,7 +3373,7 @@ public class JellyfinController : ControllerBase
catch (Exception ex)
{
// Don't let logging failures break the request
_logger.LogDebug(ex, "Failed to log endpoint usage");
_logger.LogError(ex, "Failed to log endpoint usage");
}
}
@@ -3506,7 +3506,7 @@ public class JellyfinController : ControllerBase
if (cachedItems != null && cachedItems.Count > 0)
{
_logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
cachedItems.Count, spotifyPlaylistName);
// Log sample item to verify Spotify IDs are present
@@ -3529,11 +3529,11 @@ public class JellyfinController : ControllerBase
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
if (fileItems != null && fileItems.Count > 0)
{
_logger.LogInformation("✅ Loaded {Count} playlist items from file cache for {Playlist}",
_logger.LogDebug("✅ Loaded {Count} playlist items from file cache for {Playlist}",
fileItems.Count, spotifyPlaylistName);
// Restore to Redis cache
await _cache.SetAsync(cacheKey, fileItems, TimeSpan.FromHours(24));
await _cache.SetAsync(cacheKey, fileItems, CacheExtensions.SpotifyPlaylistItemsTTL);
return new JsonResult(new
{
@@ -3549,12 +3549,12 @@ public class JellyfinController : ControllerBase
if (orderedTracks == null || orderedTracks.Count == 0)
{
_logger.LogDebug("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
spotifyPlaylistName);
return null; // Fall back to legacy mode
}
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
_logger.LogInformation("Using {Count} ordered matched tracks for {Playlist}",
orderedTracks.Count, spotifyPlaylistName);
// Get existing Jellyfin playlist items (RAW - don't convert!)
@@ -3643,7 +3643,7 @@ public class JellyfinController : ControllerBase
var localUsedCount = 0;
var externalUsedCount = 0;
_logger.LogInformation("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
_logger.LogDebug("🔍 Building playlist in Spotify order with {SpotifyCount} positions...", spotifyTracks.Count);
foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position))
{
@@ -3727,15 +3727,14 @@ public class JellyfinController : ControllerBase
}
}
_logger.LogInformation(
"🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
_logger.LogDebug("🎵 Final playlist '{Playlist}': {Total} tracks ({Local} LOCAL + {External} EXTERNAL)",
spotifyPlaylistName, finalItems.Count, localUsedCount, externalUsedCount);
// Save to file cache for persistence across restarts
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
await _cache.SetAsync(cacheKey, finalItems, TimeSpan.FromHours(24));
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
// Return raw Jellyfin response format
return new JsonResult(new
@@ -3756,7 +3755,7 @@ public class JellyfinController : ControllerBase
if (cachedTracks != null && cachedTracks.Count > 0)
{
_logger.LogDebug("Returning {Count} cached matched tracks from Redis for {Playlist}",
_logger.LogInformation("Returning {Count} cached matched tracks from Redis for {Playlist}",
cachedTracks.Count, spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(cachedTracks);
}
@@ -3767,8 +3766,8 @@ public class JellyfinController : ControllerBase
cachedTracks = await LoadMatchedTracksFromFile(spotifyPlaylistName);
if (cachedTracks != null && cachedTracks.Count > 0)
{
// Restore to Redis with 1 hour TTL
await _cache.SetAsync(cacheKey, cachedTracks, TimeSpan.FromHours(1));
// Restore to Redis with configurable TTL
await _cache.SetAsync(cacheKey, cachedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
_logger.LogInformation("Loaded {Count} matched tracks from file cache for {Playlist}",
cachedTracks.Count, spotifyPlaylistName);
return _responseBuilder.CreateItemsResponse(cachedTracks);
@@ -3785,7 +3784,7 @@ public class JellyfinController : ControllerBase
}
else
{
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks");
_logger.LogInformation("No UserId configured - may not be able to fetch existing playlist tracks");
}
var (existingTracksResponse, _) = await _proxyService.GetJsonAsync(
@@ -3830,7 +3829,7 @@ public class JellyfinController : ControllerBase
if (missingTracks != null && missingTracks.Count > 0)
{
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromDays(365));
_logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
_logger.LogDebug("Restored {Count} missing tracks from file cache for {Playlist} (no expiration)",
missingTracks.Count, spotifyPlaylistName);
}
}
@@ -3842,7 +3841,7 @@ public class JellyfinController : ControllerBase
return _responseBuilder.CreateItemsResponse(existingTracks);
}
_logger.LogInformation("Matching {Count} missing tracks for {Playlist}",
_logger.LogDebug("Matching {Count} missing tracks for {Playlist}",
missingTracks.Count, spotifyPlaylistName);
// Match missing tracks sequentially with rate limiting (excluding ones we already have locally)
@@ -3901,7 +3900,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
track.Title, track.PrimaryArtist);
}
}
@@ -3925,7 +3924,7 @@ public class JellyfinController : ControllerBase
finalTracks.AddRange(existingTracks);
}
await _cache.SetAsync(cacheKey, finalTracks, TimeSpan.FromHours(1));
await _cache.SetAsync(cacheKey, finalTracks, CacheExtensions.SpotifyMatchedTracksTTL);
// Also save to file cache for persistence across restarts
await SaveMatchedTracksToFile(spotifyPlaylistName, finalTracks);
@@ -3994,7 +3993,7 @@ public class JellyfinController : ControllerBase
if (cacheFiles.Length > 0)
{
sourceFilePath = cacheFiles[0];
_logger.LogInformation("Found track in cache folder: {Path}", sourceFilePath);
_logger.LogDebug("Found track in cache folder: {Path}", sourceFilePath);
}
}
@@ -4008,7 +4007,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
_logger.LogError(ex, "Failed to download track {ItemId}", itemId);
return;
}
}
@@ -4029,7 +4028,7 @@ public class JellyfinController : ControllerBase
}
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
_logger.LogInformation("✓ Copied track to kept folder: {Path}", keptFilePath);
_logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath);
// Also copy cover art if it exists
var sourceCoverPath = Path.Combine(Path.GetDirectoryName(sourceFilePath)!, "cover.jpg");
@@ -4090,7 +4089,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to check favorite status for {ItemId}", itemId);
_logger.LogError(ex, "Failed to check favorite status for {ItemId}", itemId);
return false;
}
}
@@ -4129,7 +4128,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to mark track as favorited: {ItemId}", itemId);
_logger.LogError(ex, "Failed to mark track as favorited: {ItemId}", itemId);
}
}
@@ -4155,7 +4154,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove track from favorites: {ItemId}", itemId);
_logger.LogError(ex, "Failed to remove track from favorites: {ItemId}", itemId);
}
}
@@ -4191,7 +4190,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to mark track for deletion: {ItemId}", itemId);
_logger.LogError(ex, "Failed to mark track for deletion: {ItemId}", itemId);
}
}
@@ -4236,7 +4235,7 @@ public class JellyfinController : ControllerBase
var updatedJson = JsonSerializer.Serialize(remaining, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(deletionFilePath, updatedJson);
_logger.LogInformation("Processed {Count} pending deletions", toDelete.Count);
_logger.LogDebug("Processed {Count} pending deletions", toDelete.Count);
}
}
catch (Exception ex)
@@ -4270,7 +4269,7 @@ public class JellyfinController : ControllerBase
foreach (var trackFile in trackFiles)
{
System.IO.File.Delete(trackFile);
_logger.LogInformation("✓ Deleted track from kept folder: {Path}", trackFile);
_logger.LogDebug("✓ Deleted track from kept folder: {Path}", trackFile);
}
// Clean up empty directories
@@ -4288,7 +4287,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete track {ItemId}", itemId);
_logger.LogError(ex, "Failed to delete track {ItemId}", itemId);
}
}
@@ -4317,14 +4316,14 @@ public class JellyfinController : ControllerBase
var json = await System.IO.File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<allstarr.Models.Spotify.MissingTrack>>(json);
_logger.LogInformation("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
_logger.LogDebug("Loaded {Count} missing tracks from file cache for {Playlist} (age: {Age:F1}h)",
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
return tracks;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
_logger.LogError(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
return null;
}
}
@@ -4341,7 +4340,7 @@ public class JellyfinController : ControllerBase
if (!System.IO.File.Exists(filePath))
{
_logger.LogDebug("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
_logger.LogInformation("No matched tracks file cache found for {Playlist} at {Path}", playlistName, filePath);
return null;
}
@@ -4355,7 +4354,7 @@ public class JellyfinController : ControllerBase
return null;
}
_logger.LogDebug("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
_logger.LogInformation("Matched tracks file cache for {Playlist} age: {Age:F1}h", playlistName, fileAge.TotalHours);
var json = await System.IO.File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<Song>>(json);
@@ -4367,7 +4366,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
_logger.LogError(ex, "Failed to load matched tracks from file for {Playlist}", playlistName);
return null;
}
}
@@ -4413,7 +4412,7 @@ public class JellyfinController : ControllerBase
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("💾 Saved {Count} playlist items to file cache for {Playlist}",
_logger.LogDebug("💾 Saved {Count} playlist items to file cache for {Playlist}",
items.Count, playlistName);
}
catch (Exception ex)
@@ -4443,7 +4442,7 @@ public class JellyfinController : ControllerBase
// Check if cache is too old (more than 24 hours)
if (fileAge.TotalHours > 24)
{
_logger.LogInformation("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
_logger.LogDebug("Playlist items file cache for {Playlist} is too old ({Age:F1}h), will rebuild",
playlistName, fileAge.TotalHours);
return null;
}
@@ -4453,14 +4452,14 @@ public class JellyfinController : ControllerBase
var json = await System.IO.File.ReadAllTextAsync(filePath);
var items = JsonSerializer.Deserialize<List<Dictionary<string, object?>>>(json);
_logger.LogInformation("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
_logger.LogDebug("💿 Loaded {Count} playlist items from file cache for {Playlist} (age: {Age:F1}h)",
items?.Count ?? 0, playlistName, fileAge.TotalHours);
return items;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
_logger.LogError(ex, "Failed to load playlist items from file for {Playlist}", playlistName);
return null;
}
}
@@ -4617,7 +4616,7 @@ public class JellyfinController : ControllerBase
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error finding Spotify ID for external track");
_logger.LogError(ex, "Error finding Spotify ID for external track");
return null;
}
}
+5 -5
View File
@@ -145,14 +145,14 @@ public class SubsonicController : ControllerBase
if (localPath != null && System.IO.File.Exists(localPath))
{
// Update last access time for cache cleanup
// Update last write time for cache cleanup (extends cache lifetime)
try
{
System.IO.File.SetLastAccessTimeUtc(localPath, DateTime.UtcNow);
System.IO.File.SetLastWriteTimeUtc(localPath, DateTime.UtcNow);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update last access time for {Path}", localPath);
_logger.LogError(ex, "Failed to update last write time for {Path}", localPath);
}
var stream = System.IO.File.OpenRead(localPath);
@@ -590,8 +590,8 @@ public class SubsonicController : ControllerBase
var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync();
var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg";
// Cache for 1 hour (playlists can change, so don't cache too long)
await _cache.SetAsync(cacheKey, imageBytes, TimeSpan.FromHours(1));
// Cache for configurable duration (playlists can change)
await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL);
_logger.LogDebug("Cached playlist cover art for {Id}", id);
return File(imageBytes, contentType);
+1 -1
View File
@@ -46,7 +46,7 @@ public class ApiKeyAuthFilter : IAsyncActionFilter
return;
}
_logger.LogDebug("API key authentication successful for {Path}", request.Path);
_logger.LogInformation("API key authentication successful for {Path}", request.Path);
await next();
}
}
@@ -27,7 +27,7 @@ public class WebSocketProxyMiddleware
_logger = logger;
_sessionManager = sessionManager;
_logger.LogDebug("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
_logger.LogInformation("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
}
public async Task InvokeAsync(HttpContext context)
@@ -139,10 +139,10 @@ public class WebSocketProxyMiddleware
}
// Set user agent
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.3.0");
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0.1");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
// Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
@@ -158,7 +158,7 @@ public class WebSocketProxyMiddleware
// 403 is expected when tokens expire or session ends - don't spam logs
if (wsEx.Message.Contains("403"))
{
_logger.LogDebug("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
_logger.LogWarning("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
}
else
{
@@ -180,7 +180,7 @@ public class WebSocketProxyMiddleware
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error closing client WebSocket");
_logger.LogError(ex, "Error closing client WebSocket");
}
}
@@ -192,7 +192,7 @@ public class WebSocketProxyMiddleware
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error closing server WebSocket");
_logger.LogError(ex, "Error closing server WebSocket");
}
}
@@ -202,7 +202,7 @@ public class WebSocketProxyMiddleware
// CRITICAL: Notify session manager that client disconnected
if (!string.IsNullOrEmpty(deviceId))
{
_logger.LogDebug("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
await _sessionManager.RemoveSessionAsync(deviceId);
}
@@ -247,7 +247,7 @@ public class WebSocketProxyMiddleware
if (direction == "Server→Client")
{
var messageText = System.Text.Encoding.UTF8.GetString(messageBytes);
_logger.LogTrace("📥 WEBSOCKET {Direction}: {Preview}",
_logger.LogDebug("📥 WEBSOCKET {Direction}: {Preview}",
direction,
messageText.Length > 500 ? messageText[..500] + "..." : messageText);
}
@@ -282,7 +282,7 @@ public class WebSocketProxyMiddleware
}
catch (Exception ex)
{
_logger.LogDebug(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
_logger.LogError(ex, "WEBSOCKET {Direction}: Error proxying messages (connection closed)", direction);
}
}
}
+74
View File
@@ -0,0 +1,74 @@
namespace allstarr.Models.Settings;
/// <summary>
/// Cache TTL (Time To Live) settings for various data types.
/// All values are configurable via Web UI and require restart to apply.
/// </summary>
public class CacheSettings
{
/// <summary>
/// Search results cache duration in minutes.
/// Default: 120 minutes (2 hours)
/// </summary>
public int SearchResultsMinutes { get; set; } = 120;
/// <summary>
/// Playlist cover images cache duration in hours.
/// Default: 168 hours (1 week)
/// </summary>
public int PlaylistImagesHours { get; set; } = 168;
/// <summary>
/// Spotify playlist items cache duration in hours.
/// Default: 168 hours (1 week, until next cron job)
/// </summary>
public int SpotifyPlaylistItemsHours { get; set; } = 168;
/// <summary>
/// Spotify matched tracks cache duration in days.
/// This is the mapping of Spotify IDs to local/external tracks.
/// Default: 30 days
/// </summary>
public int SpotifyMatchedTracksDays { get; set; } = 30;
/// <summary>
/// Lyrics cache duration in days.
/// Default: 14 days (2 weeks)
/// </summary>
public int LyricsDays { get; set; } = 14;
/// <summary>
/// Genre data cache duration in days.
/// Default: 30 days
/// </summary>
public int GenreDays { get; set; } = 30;
/// <summary>
/// External metadata (SquidWTF albums/artists) cache duration in days.
/// Default: 7 days
/// </summary>
public int MetadataDays { get; set; } = 7;
/// <summary>
/// Odesli Spotify ID lookup cache duration in days.
/// Default: 60 days
/// </summary>
public int OdesliLookupDays { get; set; } = 60;
/// <summary>
/// Jellyfin proxy images cache duration in days.
/// Default: 14 days (2 weeks)
/// </summary>
public int ProxyImagesDays { get; set; } = 14;
// Helper methods to get TimeSpan values
public TimeSpan SearchResultsTTL => TimeSpan.FromMinutes(SearchResultsMinutes);
public TimeSpan PlaylistImagesTTL => TimeSpan.FromHours(PlaylistImagesHours);
public TimeSpan SpotifyPlaylistItemsTTL => TimeSpan.FromHours(SpotifyPlaylistItemsHours);
public TimeSpan SpotifyMatchedTracksTTL => TimeSpan.FromDays(SpotifyMatchedTracksDays);
public TimeSpan LyricsTTL => TimeSpan.FromDays(LyricsDays);
public TimeSpan GenreTTL => TimeSpan.FromDays(GenreDays);
public TimeSpan MetadataTTL => TimeSpan.FromDays(MetadataDays);
public TimeSpan OdesliLookupTTL => TimeSpan.FromDays(OdesliLookupDays);
public TimeSpan ProxyImagesTTL => TimeSpan.FromDays(ProxyImagesDays);
}
+1 -1
View File
@@ -43,7 +43,7 @@ public class JellyfinSettings
/// <summary>
/// Client version reported to Jellyfin
/// </summary>
public string ClientVersion { get; set; } = "1.0.0";
public string ClientVersion { get; set; } = "1.0.1";
/// <summary>
/// Device ID reported to Jellyfin
+6 -6
View File
@@ -6,12 +6,12 @@ namespace allstarr.Models.Settings;
public class SquidWTFSettings
{
/// <summary>
/// No user auth should be needed for this site.
/// </summary>
/// <summary>
/// Preferred audio quality: FLAC, MP3_320, MP3_128
/// If not specified or unavailable, the highest available quality will be used.
/// Preferred audio quality:
/// - HI_RES or HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest quality)
/// - FLAC or LOSSLESS: 16-bit/44.1kHz FLAC (CD quality, default)
/// - HIGH: 320kbps AAC (high quality, smaller files)
/// - LOW: 96kbps AAC (low quality, smallest files)
/// If not specified or unavailable, LOSSLESS will be used.
/// </summary>
public string? Quality { get; set; }
}
+8
View File
@@ -150,6 +150,8 @@ builder.Services.Configure<SquidWTFSettings>(
builder.Configuration.GetSection("SquidWTF"));
builder.Services.Configure<RedisSettings>(
builder.Configuration.GetSection("Redis"));
builder.Services.Configure<CacheSettings>(
builder.Configuration.GetSection("Cache"));
// Configure Spotify Import settings with custom playlist parsing from env var
builder.Services.Configure<SpotifyImportSettings>(options =>
{
@@ -525,6 +527,9 @@ builder.Services.AddHostedService<CacheCleanupService>();
// Register cache warming service (loads file caches into Redis on startup)
builder.Services.AddHostedService<CacheWarmingService>();
// Register Redis persistence service (snapshots Redis to files periodically)
builder.Services.AddHostedService<RedisPersistenceService>();
// Register Spotify API client, lyrics service, and settings for direct API access
// Configure from environment variables with SPOTIFY_API_ prefix
builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options =>
@@ -639,6 +644,9 @@ builder.Services.AddCors(options =>
var app = builder.Build();
// Initialize cache settings for static access
CacheExtensions.InitializeCacheSettings(app.Services);
// Migrate old .env file format on startup
try
{
@@ -104,10 +104,10 @@ public abstract class BaseDownloadService : IDownloadService
var elapsed = (DateTime.UtcNow - startTime).TotalMilliseconds;
Logger.LogInformation("Streaming from local cache ({ElapsedMs}ms): {Path}", elapsed, localPath);
// Update access time for cache cleanup
// Update write time for cache cleanup (extends cache lifetime)
if (SubsonicSettings.StorageMode == StorageMode.Cache)
{
IOFile.SetLastAccessTime(localPath, DateTime.UtcNow);
IOFile.SetLastWriteTime(localPath, DateTime.UtcNow);
}
// Start background Odesli conversion for lyrics (if not already cached)
@@ -274,10 +274,10 @@ public abstract class BaseDownloadService : IDownloadService
{
Logger.LogInformation("Song already downloaded: {Path}", existingPath);
// For cache mode, update file access time for cache cleanup logic
// For cache mode, update file write time to extend cache lifetime
if (isCache)
{
IOFile.SetLastAccessTime(existingPath, DateTime.UtcNow);
IOFile.SetLastWriteTime(existingPath, DateTime.UtcNow);
}
return existingPath;
@@ -110,7 +110,7 @@ public class CacheCleanupService : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete cached file: {Path}", filePath);
_logger.LogError(ex, "Failed to delete cached file: {Path}", filePath);
}
}
@@ -125,7 +125,7 @@ public class CacheCleanupService : BackgroundService
}
else
{
_logger.LogDebug("Cache cleanup completed: no files to delete");
_logger.LogInformation("Cache cleanup completed: no files to delete");
}
}
catch (Exception ex)
@@ -156,13 +156,13 @@ public class CacheCleanupService : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to delete empty directory: {Path}", directory);
_logger.LogError(ex, "Failed to delete empty directory: {Path}", directory);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error cleaning up empty directories");
_logger.LogError(ex, "Error cleaning up empty directories");
}
await Task.CompletedTask;
@@ -0,0 +1,52 @@
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
namespace allstarr.Services.Common;
/// <summary>
/// Extension methods for cache TTL management.
/// Provides centralized access to configurable cache durations.
/// </summary>
public static class CacheExtensions
{
private static CacheSettings? _cacheSettings;
private static readonly object _lock = new();
/// <summary>
/// Initialize cache settings (called once at startup).
/// </summary>
public static void InitializeCacheSettings(IServiceProvider serviceProvider)
{
lock (_lock)
{
if (_cacheSettings == null)
{
var options = serviceProvider.GetService<IOptions<CacheSettings>>();
_cacheSettings = options?.Value ?? new CacheSettings();
}
}
}
/// <summary>
/// Get the current cache settings.
/// </summary>
public static CacheSettings GetCacheSettings()
{
if (_cacheSettings == null)
{
throw new InvalidOperationException("Cache settings not initialized. Call InitializeCacheSettings first.");
}
return _cacheSettings;
}
// Convenience methods for getting TTLs
public static TimeSpan SearchResultsTTL => GetCacheSettings().SearchResultsTTL;
public static TimeSpan PlaylistImagesTTL => GetCacheSettings().PlaylistImagesTTL;
public static TimeSpan SpotifyPlaylistItemsTTL => GetCacheSettings().SpotifyPlaylistItemsTTL;
public static TimeSpan SpotifyMatchedTracksTTL => GetCacheSettings().SpotifyMatchedTracksTTL;
public static TimeSpan LyricsTTL => GetCacheSettings().LyricsTTL;
public static TimeSpan GenreTTL => GetCacheSettings().GenreTTL;
public static TimeSpan MetadataTTL => GetCacheSettings().MetadataTTL;
public static TimeSpan OdesliLookupTTL => GetCacheSettings().OdesliLookupTTL;
public static TimeSpan ProxyImagesTTL => GetCacheSettings().ProxyImagesTTL;
}
+14 -14
View File
@@ -105,19 +105,19 @@ public class CacheWarmingService : IHostedService
if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey))
{
var redisKey = $"genre:{cacheEntry.CacheKey}";
await _cache.SetAsync(redisKey, cacheEntry.Genre, TimeSpan.FromDays(30));
await _cache.SetAsync(redisKey, cacheEntry.Genre, CacheExtensions.GenreTTL);
warmedCount++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm genre cache from file: {File}", file);
_logger.LogError(ex, "Failed to warm genre cache from file: {File}", file);
}
}
if (warmedCount > 0)
{
_logger.LogInformation("🔥 Warmed {Count} genre entries from file cache", warmedCount);
_logger.LogDebug("🔥 Warmed {Count} genre entries from file cache", warmedCount);
}
return warmedCount;
@@ -162,7 +162,7 @@ public class CacheWarmingService : IHostedService
var playlistName = fileName.Replace("_items", "");
var redisKey = $"spotify:playlist:items:{playlistName}";
await _cache.SetAsync(redisKey, items, TimeSpan.FromHours(24));
await _cache.SetAsync(redisKey, items, CacheExtensions.SpotifyPlaylistItemsTTL);
warmedCount++;
_logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)",
@@ -171,7 +171,7 @@ public class CacheWarmingService : IHostedService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm playlist items cache from file: {File}", file);
_logger.LogError(ex, "Failed to warm playlist items cache from file: {File}", file);
}
}
@@ -200,22 +200,22 @@ public class CacheWarmingService : IHostedService
var playlistName = fileName.Replace("_matched", "");
var redisKey = $"spotify:matched:ordered:{playlistName}";
await _cache.SetAsync(redisKey, matchedTracks, TimeSpan.FromHours(1));
await _cache.SetAsync(redisKey, matchedTracks, CacheExtensions.SpotifyMatchedTracksTTL);
warmedCount++;
_logger.LogDebug("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
_logger.LogInformation("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)",
playlistName, matchedTracks.Count);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm matched tracks cache from file: {File}", file);
_logger.LogError(ex, "Failed to warm matched tracks cache from file: {File}", file);
}
}
if (warmedCount > 0)
{
_logger.LogInformation("🔥 Warmed {Count} playlist caches from file system", warmedCount);
_logger.LogDebug("🔥 Warmed {Count} playlist caches from file system", warmedCount);
}
return warmedCount;
@@ -276,13 +276,13 @@ public class CacheWarmingService : IHostedService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm manual mappings from file: {File}", file);
_logger.LogError(ex, "Failed to warm manual mappings from file: {File}", file);
}
}
if (warmedCount > 0)
{
_logger.LogInformation("🔥 Warmed {Count} manual mappings from file system", warmedCount);
_logger.LogDebug("🔥 Warmed {Count} manual mappings from file system", warmedCount);
}
return warmedCount;
@@ -318,13 +318,13 @@ public class CacheWarmingService : IHostedService
await _cache.SetStringAsync(redisKey, mapping.LyricsId.ToString());
}
_logger.LogInformation("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
_logger.LogDebug("🔥 Warmed {Count} lyrics mappings from file system", mappings.Count);
return mappings.Count;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
_logger.LogError(ex, "Failed to warm lyrics mappings from file: {File}", mappingsFile);
}
return 0;
@@ -356,7 +356,7 @@ public class CacheWarmingService : IHostedService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to warm lyrics cache");
_logger.LogError(ex, "Failed to warm lyrics cache");
return 0;
}
}
@@ -30,7 +30,7 @@ public class EndpointBenchmarkService
int pingCount = 3,
CancellationToken cancellationToken = default)
{
_logger.LogInformation("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
_logger.LogDebug("🏁 Benchmarking {Count} endpoints with {Pings} pings each...", endpoints.Count, pingCount);
var tasks = endpoints.Select(async endpoint =>
{
@@ -54,7 +54,7 @@ public class EndpointBenchmarkService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Benchmark ping failed for {Endpoint}", endpoint);
_logger.LogError(ex, "Benchmark ping failed for {Endpoint}", endpoint);
}
// Small delay between pings
@@ -85,7 +85,7 @@ public class EndpointBenchmarkService
_lock.Release();
}
_logger.LogInformation(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
_logger.LogDebug(" {Endpoint}: {AvgMs}ms avg, {SuccessRate:P0} success rate",
endpoint, avgMs, metrics.SuccessRate);
return metrics;
@@ -18,7 +18,7 @@ public class EnvMigrationService
{
if (!File.Exists(_envFilePath))
{
_logger.LogDebug("No .env file found, skipping migration");
_logger.LogWarning("No .env file found, skipping migration");
return;
}
@@ -41,7 +41,28 @@ public class EnvMigrationService
var value = line.Substring("DOWNLOAD_PATH=".Length);
lines[i] = $"Library__DownloadPath={value}";
modified = true;
_logger.LogInformation("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
_logger.LogDebug("Migrated DOWNLOAD_PATH to Library__DownloadPath in .env file");
}
// Migrate old SquidWTF quality values to new format
if (line.StartsWith("SQUIDWTF_QUALITY="))
{
var value = line.Substring("SQUIDWTF_QUALITY=".Length).Trim();
var newValue = value.ToUpperInvariant() switch
{
"FLAC" => "LOSSLESS",
"HI_RES" => "HI_RES_LOSSLESS",
"MP3_320" => "HIGH",
"MP3_128" => "LOW",
_ => null // Keep as-is if already correct
};
if (newValue != null)
{
lines[i] = $"SQUIDWTF_QUALITY={newValue}";
modified = true;
_logger.LogInformation("Migrated SQUIDWTF_QUALITY from {Old} to {New} in .env file", value, newValue);
}
}
}
@@ -53,7 +74,7 @@ public class EnvMigrationService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
_logger.LogError(ex, "Failed to migrate .env file - please manually update DOWNLOAD_PATH to Library__DownloadPath");
}
}
}
@@ -93,7 +93,7 @@ public class GenreEnrichmentService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enrich genre for {Title} - {Artist}",
_logger.LogError(ex, "Failed to enrich genre for {Title} - {Artist}",
song.Title, song.Artist);
}
}
@@ -170,7 +170,7 @@ public class GenreEnrichmentService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read genre from file cache for {Key}", cacheKey);
_logger.LogError(ex, "Failed to read genre from file cache for {Key}", cacheKey);
return null;
}
}
@@ -201,7 +201,7 @@ public class GenreEnrichmentService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save genre to file cache for {Key}", cacheKey);
_logger.LogError(ex, "Failed to save genre to file cache for {Key}", cacheKey);
}
}
+8 -8
View File
@@ -63,10 +63,10 @@ public class OdesliService
if (match.Success)
{
var spotifyId = match.Groups[1].Value;
_logger.LogInformation("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId);
_logger.LogDebug("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId);
// Cache for 7 days
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
// Cache for configurable duration
await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL);
return spotifyId;
}
@@ -76,7 +76,7 @@ public class OdesliService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to convert Tidal track to Spotify ID via Odesli");
_logger.LogError(ex, "Failed to convert Tidal track to Spotify ID via Odesli");
}
return null;
@@ -122,10 +122,10 @@ public class OdesliService
if (match.Success)
{
var spotifyId = match.Groups[1].Value;
_logger.LogInformation("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId);
_logger.LogDebug("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId);
// Cache for 7 days
await _cache.SetAsync(cacheKey, spotifyId, TimeSpan.FromDays(7));
// Cache for configurable duration
await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL);
return spotifyId;
}
@@ -135,7 +135,7 @@ public class OdesliService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to convert URL to Spotify ID via Odesli");
_logger.LogError(ex, "Failed to convert URL to Spotify ID via Odesli");
}
return null;
@@ -51,7 +51,7 @@ public class ParallelMetadataService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "❌ {Provider} search failed", providerName);
_logger.LogError(ex, "❌ {Provider} search failed", providerName);
return (Success: false, Result: new SearchResult(), Provider: providerName, ElapsedMs: 0L);
}
}).ToList();
@@ -64,7 +64,7 @@ public class ParallelMetadataService
if (result.Success && (result.Result.Songs.Any() || result.Result.Albums.Any() || result.Result.Artists.Any()))
{
_logger.LogInformation("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
_logger.LogDebug("🏆 Using results from {Provider} ({Ms}ms) - fastest with results",
result.Provider, result.ElapsedMs);
return result.Result;
}
@@ -110,7 +110,7 @@ public class ParallelMetadataService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "❌ {Provider} song search failed", providerName);
_logger.LogError(ex, "❌ {Provider} song search failed", providerName);
return (Success: false, Song: (Song?)null, Provider: providerName, ElapsedMs: 0L);
}
}).ToList();
@@ -39,7 +39,7 @@ public class RedisCacheService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis connection failed. Caching disabled.");
_logger.LogError(ex, "Redis connection failed. Caching disabled.");
_redis = null;
_db = null;
}
@@ -70,7 +70,7 @@ public class RedisCacheService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis GET failed for key: {Key}", key);
_logger.LogError(ex, "Redis GET failed for key: {Key}", key);
return null;
}
}
@@ -89,7 +89,7 @@ public class RedisCacheService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize cached value for key: {Key}", key);
_logger.LogError(ex, "Failed to deserialize cached value for key: {Key}", key);
return null;
}
}
@@ -112,7 +112,7 @@ public class RedisCacheService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis SET failed for key: {Key}", key);
_logger.LogError(ex, "Redis SET failed for key: {Key}", key);
return false;
}
}
@@ -129,7 +129,7 @@ public class RedisCacheService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to serialize value for key: {Key}", key);
_logger.LogError(ex, "Failed to serialize value for key: {Key}", key);
return false;
}
}
@@ -147,7 +147,7 @@ public class RedisCacheService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis DELETE failed for key: {Key}", key);
_logger.LogError(ex, "Redis DELETE failed for key: {Key}", key);
return false;
}
}
@@ -165,7 +165,7 @@ public class RedisCacheService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis EXISTS failed for key: {Key}", key);
_logger.LogError(ex, "Redis EXISTS failed for key: {Key}", key);
return false;
}
}
@@ -190,12 +190,12 @@ public class RedisCacheService
}
var deleted = await _db!.KeyDeleteAsync(keys);
_logger.LogInformation("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
_logger.LogDebug("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
return (int)deleted;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
return 0;
}
}
@@ -0,0 +1,101 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
namespace allstarr.Services.Common;
/// <summary>
/// Periodically snapshots Redis cache to file system for cold start recovery.
/// Redis is the primary cache, files are the persistence layer.
/// </summary>
public class RedisPersistenceService : BackgroundService
{
private readonly RedisCacheService _cache;
private readonly ILogger<RedisPersistenceService> _logger;
private readonly TimeSpan _snapshotInterval = TimeSpan.FromMinutes(5);
private const string SnapshotDirectory = "/app/cache/redis-snapshots";
public RedisPersistenceService(
RedisCacheService cache,
ILogger<RedisPersistenceService> logger)
{
_cache = cache;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// Wait 2 minutes after startup before first snapshot (let cache warm up)
await Task.Delay(TimeSpan.FromMinutes(2), stoppingToken);
while (!stoppingToken.IsCancellationRequested)
{
try
{
await CreateSnapshotAsync(stoppingToken);
}
catch (Exception ex)
{
_logger.LogError(ex, "Error creating Redis snapshot");
}
await Task.Delay(_snapshotInterval, stoppingToken);
}
}
private async Task CreateSnapshotAsync(CancellationToken cancellationToken)
{
if (!_cache.IsEnabled)
{
_logger.LogWarning("Redis is disabled, skipping snapshot");
return;
}
try
{
Directory.CreateDirectory(SnapshotDirectory);
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd_HH-mm-ss");
var snapshotFile = Path.Combine(SnapshotDirectory, $"snapshot_{timestamp}.json");
// For now, we'll rely on Redis's built-in RDB + AOF persistence
// This service is a placeholder for future enhancements like:
// - Exporting specific key patterns to JSON
// - Creating human-readable backups
// - Syncing to external storage
_logger.LogDebug("Redis snapshot service running (using Redis native persistence)");
// Clean up old snapshots (keep last 10)
await CleanupOldSnapshotsAsync();
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to create Redis snapshot");
}
}
private async Task CleanupOldSnapshotsAsync()
{
try
{
if (!Directory.Exists(SnapshotDirectory))
return;
var files = Directory.GetFiles(SnapshotDirectory, "snapshot_*.json")
.OrderByDescending(f => f)
.Skip(10)
.ToArray();
foreach (var file in files)
{
File.Delete(file);
_logger.LogDebug("Deleted old snapshot: {File}", Path.GetFileName(file));
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to cleanup old snapshots");
}
}
}
@@ -78,7 +78,7 @@ public class RoundRobinFallbackHelper
}
catch (Exception ex)
{
_logger.LogDebug(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
_logger.LogError(ex, "{Service} endpoint {Endpoint} health check failed", _serviceName, baseUrl);
// Cache as unhealthy
lock (_healthCacheLock)
@@ -137,7 +137,7 @@ public class RoundRobinFallbackHelper
_apiUrls.AddRange(reordered);
_currentUrlIndex = 0;
_logger.LogInformation("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
_logger.LogDebug("📊 {Service} endpoints reordered by benchmark: {Endpoints}",
_serviceName, string.Join(", ", _apiUrls.Take(3)));
}
}
@@ -180,7 +180,7 @@ public class RoundRobinFallbackHelper
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
// Mark as unhealthy in cache
@@ -227,7 +227,7 @@ public class RoundRobinFallbackHelper
}
catch (Exception ex)
{
_logger.LogDebug(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
_logger.LogError(ex, "{Service} race failed for endpoint {Endpoint}", _serviceName, baseUrl);
return (default(T)!, baseUrl, false);
}
}, raceCts.Token);
@@ -243,7 +243,7 @@ public class RoundRobinFallbackHelper
if (success)
{
_logger.LogInformation("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
_logger.LogDebug("🏁 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
raceCts.Cancel(); // Cancel all other requests
return result;
}
@@ -291,7 +291,7 @@ public class RoundRobinFallbackHelper
}
catch (Exception ex)
{
_logger.LogWarning(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
_serviceName, baseUrl);
// Mark as unhealthy in cache
@@ -76,7 +76,7 @@ public class JellyfinModelMapper
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error parsing Jellyfin items response");
_logger.LogError(ex, "Error parsing Jellyfin items response");
}
return (songs, albums, artists);
@@ -126,7 +126,7 @@ public class JellyfinModelMapper
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error parsing Jellyfin search hints response");
_logger.LogError(ex, "Error parsing Jellyfin search hints response");
}
return (songs, albums, artists);
@@ -222,7 +222,7 @@ public class JellyfinProxyService
// Forward as X-Emby-Authorization (Jellyfin's expected header)
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
@@ -265,11 +265,11 @@ public class JellyfinProxyService
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
// 401 means token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
_logger.LogDebug("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
}
else if (!isBrowserStaticRequest && !isPublicEndpoint)
{
_logger.LogWarning("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
_logger.LogError("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
}
// Try to parse error response to pass through to client
@@ -374,7 +374,7 @@ public class JellyfinProxyService
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogTrace("Converted Authorization to X-Emby-Authorization");
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
@@ -418,11 +418,11 @@ public class JellyfinProxyService
// 401 is expected when tokens expire - don't spam logs
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
{
_logger.LogInformation("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
_logger.LogDebug("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
}
else
{
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
_logger.LogError("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
}
@@ -579,12 +579,12 @@ public class JellyfinProxyService
if (!authHeaderAdded)
{
_logger.LogInformation("No client auth provided for DELETE {Url} - forwarding without auth", url);
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_logger.LogInformation("DELETE to Jellyfin: {Url}", url);
_logger.LogDebug("DELETE to Jellyfin: {Url}", url);
var response = await _httpClient.SendAsync(request);
@@ -593,7 +593,7 @@ public class JellyfinProxyService
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
_logger.LogError("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent);
return (null, statusCode);
}
@@ -629,7 +629,7 @@ public class JellyfinProxyService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to get bytes from {Endpoint}", endpoint);
_logger.LogError(ex, "Failed to get bytes from {Endpoint}", endpoint);
return (null, null, false);
}
}
@@ -662,7 +662,7 @@ public class JellyfinProxyService
if (!string.IsNullOrEmpty(_settings.LibraryId))
{
queryParams["parentId"] = _settings.LibraryId;
_logger.LogDebug("Searching within configured LibraryId {LibraryId}", _settings.LibraryId);
_logger.LogInformation("Searching within configured LibraryId {LibraryId}", _settings.LibraryId);
}
if (includeItemTypes != null && includeItemTypes.Length > 0)
@@ -932,7 +932,7 @@ public class JellyfinProxyService
if (result.Success && result.Body != null)
{
var cacheValue = $"{Convert.ToBase64String(result.Body)}|{result.ContentType}";
await _cache.SetStringAsync(cacheKey, cacheValue, TimeSpan.FromDays(7));
await _cache.SetStringAsync(cacheKey, cacheValue, CacheExtensions.ProxyImagesTTL);
}
return (result.Body, result.ContentType);
@@ -33,7 +33,7 @@ public class JellyfinSessionManager : IDisposable
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
_logger.LogDebug("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
_logger.LogInformation("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
}
/// <summary>
@@ -44,7 +44,7 @@ public class JellyfinSessionManager : IDisposable
{
if (string.IsNullOrEmpty(deviceId))
{
_logger.LogWarning("Cannot create session - no device ID");
_logger.LogError("Cannot create session - no device ID");
return false;
}
@@ -52,7 +52,7 @@ public class JellyfinSessionManager : IDisposable
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
_logger.LogTrace("Session already exists for device {DeviceId}", deviceId);
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive
// If this returns false (401), the token expired and client needs to re-auth
@@ -60,7 +60,7 @@ public class JellyfinSessionManager : IDisposable
if (!success)
{
// Token expired - remove the stale session
_logger.LogInformation("Token expired for device {DeviceId} - removing session", deviceId);
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
@@ -78,11 +78,11 @@ public class JellyfinSessionManager : IDisposable
if (!success)
{
// Token expired or invalid - client needs to re-authenticate
_logger.LogInformation("Failed to create session for {DeviceId} - token may be expired", deviceId);
_logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId);
return false;
}
_logger.LogDebug("Session created for {DeviceId}", deviceId);
_logger.LogInformation("Session created for {DeviceId}", deviceId);
// Track this session
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
@@ -143,7 +143,7 @@ public class JellyfinSessionManager : IDisposable
else if (statusCode == 401)
{
// Token expired - this is expected, client needs to re-authenticate
_logger.LogDebug("Capabilities returned 401 (token expired) - client should re-authenticate");
_logger.LogWarning("Capabilities returned 401 (token expired) - client should re-authenticate");
return false;
}
else
@@ -165,7 +165,7 @@ public class JellyfinSessionManager : IDisposable
}
else
{
_logger.LogDebug("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
_logger.LogError("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
}
}
@@ -262,7 +262,7 @@ public class JellyfinSessionManager : IDisposable
}
catch (Exception ex)
{
_logger.LogDebug(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
_logger.LogError(ex, "WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
}
finally
{
@@ -282,7 +282,7 @@ public class JellyfinSessionManager : IDisposable
};
var stopJson = JsonSerializer.Serialize(stopPayload);
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
_logger.LogDebug("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
}
@@ -291,7 +291,7 @@ public class JellyfinSessionManager : IDisposable
}
catch (Exception ex)
{
_logger.LogWarning("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
_logger.LogError("⚠️ SESSION: Error removing session for {DeviceId}: {Message}", deviceId, ex.Message);
}
}
}
@@ -304,7 +304,7 @@ public class JellyfinSessionManager : IDisposable
{
if (!_sessions.TryGetValue(deviceId, out var session))
{
_logger.LogDebug("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
_logger.LogError("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
return;
}
@@ -366,7 +366,7 @@ public class JellyfinSessionManager : IDisposable
if (!string.IsNullOrEmpty(_settings.ApiKey))
{
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
_logger.LogDebug("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
_logger.LogWarning("WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
}
else
{
@@ -381,7 +381,7 @@ public class JellyfinSessionManager : IDisposable
// Connect to Jellyfin
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
_logger.LogDebug("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
// This tells Jellyfin to create/show the session in the dashboard
@@ -389,7 +389,7 @@ public class JellyfinSessionManager : IDisposable
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogDebug("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
_logger.LogInformation("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
// Also send SessionsStart to subscribe to session updates
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
@@ -522,20 +522,20 @@ public class JellyfinSessionManager : IDisposable
if (!success)
{
_logger.LogInformation("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
_logger.LogWarning("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId);
expiredSessions.Add(session.DeviceId);
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
_logger.LogError(ex, "Error keeping session alive for {DeviceId}", session.DeviceId);
}
}
// Remove sessions with expired tokens
foreach (var deviceId in expiredSessions)
{
_logger.LogInformation("Removing session with expired token: {DeviceId}", deviceId);
_logger.LogWarning("Removing session with expired token: {DeviceId}", deviceId);
await RemoveSessionAsync(deviceId);
}
+275 -275
View File
@@ -1,275 +1,275 @@
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services;
namespace allstarr.Services.Local;
/// <summary>
/// Local library service implementation
/// Uses a simple JSON file to store mappings (can be replaced with a database)
/// </summary>
public class LocalLibraryService : ILocalLibraryService
{
private readonly string _mappingFilePath;
private readonly string _downloadDirectory;
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _subsonicSettings;
private readonly ILogger<LocalLibraryService> _logger;
private Dictionary<string, LocalSongMapping>? _mappings;
private readonly SemaphoreSlim _lock = new(1, 1);
// Debounce to avoid triggering too many scans
private DateTime _lastScanTrigger = DateTime.MinValue;
private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30);
public LocalLibraryService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
IOptions<SubsonicSettings> subsonicSettings,
ILogger<LocalLibraryService> logger)
{
_downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
_mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json");
_httpClient = httpClientFactory.CreateClient();
_subsonicSettings = subsonicSettings.Value;
_logger = logger;
if (!Directory.Exists(_downloadDirectory))
{
Directory.CreateDirectory(_downloadDirectory);
}
}
public async Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId)
{
var mappings = await LoadMappingsAsync();
var key = $"{externalProvider}:{externalId}";
if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath))
{
return mapping.LocalPath;
}
return null;
}
public async Task RegisterDownloadedSongAsync(Song song, string localPath)
{
if (song.ExternalProvider == null || song.ExternalId == null) return;
// Load mappings first (this acquires the lock internally if needed)
var mappings = await LoadMappingsAsync();
await _lock.WaitAsync();
try
{
var key = $"{song.ExternalProvider}:{song.ExternalId}";
mappings[key] = new LocalSongMapping
{
ExternalProvider = song.ExternalProvider,
ExternalId = song.ExternalId,
LocalPath = localPath,
Title = song.Title,
Artist = song.Artist,
Album = song.Album,
DownloadedAt = DateTime.UtcNow
};
await SaveMappingsAsync(mappings);
}
finally
{
_lock.Release();
}
}
public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId)
{
// For now, return null as we don't yet have integration
// with the Subsonic server to retrieve local ID after scan
await Task.CompletedTask;
return null;
}
public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId)
{
var (isExternal, provider, _, externalId) = ParseExternalId(songId);
return (isExternal, provider, externalId);
}
public (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id)
{
if (!id.StartsWith("ext-"))
{
return (false, null, null, null);
}
var parts = id.Split('-');
// Known types for the new format
var knownTypes = new HashSet<string> { "song", "album", "artist" };
// New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259)
// Only use new format if parts[2] is a known type
if (parts.Length >= 4 && knownTypes.Contains(parts[2]))
{
var provider = parts[1];
var type = parts[2];
var externalId = string.Join("-", parts.Skip(3)); // Handle IDs with dashes
return (true, provider, type, externalId);
}
// Legacy format: ext-{provider}-{id} (assumes "song" type for backward compatibility)
// This handles both 3-part IDs and 4+ part IDs where parts[2] is NOT a known type
if (parts.Length >= 3)
{
var provider = parts[1];
var externalId = string.Join("-", parts.Skip(2)); // Everything after provider is the ID
return (true, provider, "song", externalId);
}
return (false, null, null, null);
}
private async Task<Dictionary<string, LocalSongMapping>> LoadMappingsAsync()
{
// Fast path: return cached mappings if available
if (_mappings != null) return _mappings;
// Slow path: acquire lock to load from file (prevents race condition)
await _lock.WaitAsync();
try
{
// Double-check after acquiring lock
if (_mappings != null) return _mappings;
if (File.Exists(_mappingFilePath))
{
var json = await File.ReadAllTextAsync(_mappingFilePath);
_mappings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, LocalSongMapping>>(json)
?? new Dictionary<string, LocalSongMapping>();
}
else
{
_mappings = new Dictionary<string, LocalSongMapping>();
}
return _mappings;
}
finally
{
_lock.Release();
}
}
private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> mappings)
{
_mappings = mappings;
var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(_mappingFilePath, json);
}
public string GetDownloadDirectory() => _downloadDirectory;
public async Task<bool> TriggerLibraryScanAsync()
{
// Debounce: avoid triggering too many successive scans
var now = DateTime.UtcNow;
if (now - _lastScanTrigger < _scanDebounceInterval)
{
_logger.LogDebug("Scan debounced - last scan was {Elapsed}s ago",
(now - _lastScanTrigger).TotalSeconds);
return true;
}
_lastScanTrigger = now;
try
{
// Call Subsonic API to trigger a scan
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
// when called from localhost. For remote servers requiring auth, this would need
// to be refactored to accept credentials from the controller layer.
var url = $"{_subsonicSettings.Url}/rest/startScan?f=json";
_logger.LogInformation("Triggering Subsonic library scan...");
var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
_logger.LogInformation("Subsonic scan triggered successfully: {Response}", content);
return true;
}
else
{
_logger.LogWarning("Failed to trigger Subsonic scan: {StatusCode} - Server may require authentication", response.StatusCode);
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering Subsonic library scan");
return false;
}
}
public async Task<ScanStatus?> GetScanStatusAsync()
{
try
{
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
// when called from localhost.
var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json";
var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) &&
subsonicResponse.TryGetProperty("scanStatus", out var scanStatus))
{
return new ScanStatus
{
Scanning = scanStatus.TryGetProperty("scanning", out var scanning) && scanning.GetBoolean(),
Count = scanStatus.TryGetProperty("count", out var count) ? count.GetInt32() : null
};
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Subsonic scan status");
}
return null;
}
}
/// <summary>
/// Represents the mapping between an external song and its local file
/// </summary>
public class LocalSongMapping
{
public string ExternalProvider { get; set; } = string.Empty;
public string ExternalId { get; set; } = string.Empty;
public string LocalPath { get; set; } = string.Empty;
public string? LocalSubsonicId { get; set; }
public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public string Album { get; set; } = string.Empty;
public DateTime DownloadedAt { get; set; }
}
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Domain;
using allstarr.Models.Settings;
using allstarr.Models.Download;
using allstarr.Models.Search;
using allstarr.Models.Subsonic;
using allstarr.Services;
namespace allstarr.Services.Local;
/// <summary>
/// Local library service implementation
/// Uses a simple JSON file to store mappings (can be replaced with a database)
/// </summary>
public class LocalLibraryService : ILocalLibraryService
{
private readonly string _mappingFilePath;
private readonly string _downloadDirectory;
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _subsonicSettings;
private readonly ILogger<LocalLibraryService> _logger;
private Dictionary<string, LocalSongMapping>? _mappings;
private readonly SemaphoreSlim _lock = new(1, 1);
// Debounce to avoid triggering too many scans
private DateTime _lastScanTrigger = DateTime.MinValue;
private readonly TimeSpan _scanDebounceInterval = TimeSpan.FromSeconds(30);
public LocalLibraryService(
IConfiguration configuration,
IHttpClientFactory httpClientFactory,
IOptions<SubsonicSettings> subsonicSettings,
ILogger<LocalLibraryService> logger)
{
_downloadDirectory = configuration["Library:DownloadPath"] ?? Path.Combine(Directory.GetCurrentDirectory(), "downloads");
_mappingFilePath = Path.Combine(_downloadDirectory, ".mappings.json");
_httpClient = httpClientFactory.CreateClient();
_subsonicSettings = subsonicSettings.Value;
_logger = logger;
if (!Directory.Exists(_downloadDirectory))
{
Directory.CreateDirectory(_downloadDirectory);
}
}
public async Task<string?> GetLocalPathForExternalSongAsync(string externalProvider, string externalId)
{
var mappings = await LoadMappingsAsync();
var key = $"{externalProvider}:{externalId}";
if (mappings.TryGetValue(key, out var mapping) && File.Exists(mapping.LocalPath))
{
return mapping.LocalPath;
}
return null;
}
public async Task RegisterDownloadedSongAsync(Song song, string localPath)
{
if (song.ExternalProvider == null || song.ExternalId == null) return;
// Load mappings first (this acquires the lock internally if needed)
var mappings = await LoadMappingsAsync();
await _lock.WaitAsync();
try
{
var key = $"{song.ExternalProvider}:{song.ExternalId}";
mappings[key] = new LocalSongMapping
{
ExternalProvider = song.ExternalProvider,
ExternalId = song.ExternalId,
LocalPath = localPath,
Title = song.Title,
Artist = song.Artist,
Album = song.Album,
DownloadedAt = DateTime.UtcNow
};
await SaveMappingsAsync(mappings);
}
finally
{
_lock.Release();
}
}
public async Task<string?> GetLocalIdForExternalSongAsync(string externalProvider, string externalId)
{
// For now, return null as we don't yet have integration
// with the Subsonic server to retrieve local ID after scan
await Task.CompletedTask;
return null;
}
public (bool isExternal, string? provider, string? externalId) ParseSongId(string songId)
{
var (isExternal, provider, _, externalId) = ParseExternalId(songId);
return (isExternal, provider, externalId);
}
public (bool isExternal, string? provider, string? type, string? externalId) ParseExternalId(string id)
{
if (!id.StartsWith("ext-"))
{
return (false, null, null, null);
}
var parts = id.Split('-');
// Known types for the new format
var knownTypes = new HashSet<string> { "song", "album", "artist" };
// New format: ext-{provider}-{type}-{id} (e.g., ext-deezer-artist-259)
// Only use new format if parts[2] is a known type
if (parts.Length >= 4 && knownTypes.Contains(parts[2]))
{
var provider = parts[1];
var type = parts[2];
var externalId = string.Join("-", parts.Skip(3)); // Handle IDs with dashes
return (true, provider, type, externalId);
}
// Legacy format: ext-{provider}-{id} (assumes "song" type for backward compatibility)
// This handles both 3-part IDs and 4+ part IDs where parts[2] is NOT a known type
if (parts.Length >= 3)
{
var provider = parts[1];
var externalId = string.Join("-", parts.Skip(2)); // Everything after provider is the ID
return (true, provider, "song", externalId);
}
return (false, null, null, null);
}
private async Task<Dictionary<string, LocalSongMapping>> LoadMappingsAsync()
{
// Fast path: return cached mappings if available
if (_mappings != null) return _mappings;
// Slow path: acquire lock to load from file (prevents race condition)
await _lock.WaitAsync();
try
{
// Double-check after acquiring lock
if (_mappings != null) return _mappings;
if (File.Exists(_mappingFilePath))
{
var json = await File.ReadAllTextAsync(_mappingFilePath);
_mappings = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, LocalSongMapping>>(json)
?? new Dictionary<string, LocalSongMapping>();
}
else
{
_mappings = new Dictionary<string, LocalSongMapping>();
}
return _mappings;
}
finally
{
_lock.Release();
}
}
private async Task SaveMappingsAsync(Dictionary<string, LocalSongMapping> mappings)
{
_mappings = mappings;
var json = System.Text.Json.JsonSerializer.Serialize(mappings, new System.Text.Json.JsonSerializerOptions
{
WriteIndented = true
});
await File.WriteAllTextAsync(_mappingFilePath, json);
}
public string GetDownloadDirectory() => _downloadDirectory;
public async Task<bool> TriggerLibraryScanAsync()
{
// Debounce: avoid triggering too many successive scans
var now = DateTime.UtcNow;
if (now - _lastScanTrigger < _scanDebounceInterval)
{
_logger.LogDebug("Scan debounced - last scan was {Elapsed}s ago",
(now - _lastScanTrigger).TotalSeconds);
return true;
}
_lastScanTrigger = now;
try
{
// Call Subsonic API to trigger a scan
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
// when called from localhost. For remote servers requiring auth, this would need
// to be refactored to accept credentials from the controller layer.
var url = $"{_subsonicSettings.Url}/rest/startScan?f=json";
_logger.LogInformation("Triggering Subsonic library scan...");
var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
_logger.LogInformation("Subsonic scan triggered successfully: {Response}", content);
return true;
}
else
{
_logger.LogError("Failed to trigger Subsonic scan: {StatusCode} - Server may require authentication", response.StatusCode);
return false;
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error triggering Subsonic library scan");
return false;
}
}
public async Task<ScanStatus?> GetScanStatusAsync()
{
try
{
// Note: This endpoint works without authentication on most Subsonic/Navidrome servers
// when called from localhost.
var url = $"{_subsonicSettings.Url}/rest/getScanStatus?f=json";
var response = await _httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var content = await response.Content.ReadAsStringAsync();
var doc = JsonDocument.Parse(content);
if (doc.RootElement.TryGetProperty("subsonic-response", out var subsonicResponse) &&
subsonicResponse.TryGetProperty("scanStatus", out var scanStatus))
{
return new ScanStatus
{
Scanning = scanStatus.TryGetProperty("scanning", out var scanning) && scanning.GetBoolean(),
Count = scanStatus.TryGetProperty("count", out var count) ? count.GetInt32() : null
};
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error getting Subsonic scan status");
}
return null;
}
}
/// <summary>
/// Represents the mapping between an external song and its local file
/// </summary>
public class LocalSongMapping
{
public string ExternalProvider { get; set; } = string.Empty;
public string ExternalId { get; set; } = string.Empty;
public string LocalPath { get; set; } = string.Empty;
public string? LocalSubsonicId { get; set; }
public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty;
public string Album { get; set; } = string.Empty;
public DateTime DownloadedAt { get; set; }
}
+10 -10
View File
@@ -18,7 +18,7 @@ public class LrclibService
ILogger<LrclibService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)");
_cache = cache;
_logger = logger;
}
@@ -75,7 +75,7 @@ public class LrclibService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize cached lyrics");
_logger.LogError(ex, "Failed to deserialize cached lyrics");
}
}
@@ -89,7 +89,7 @@ public class LrclibService
$"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(searchArtistName)}";
_logger.LogInformation("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
_logger.LogDebug("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
var searchResponse = await _httpClient.GetAsync(searchUrl);
@@ -157,12 +157,12 @@ public class LrclibService
SyncedLyrics = bestMatch.SyncedLyrics
};
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
return result;
}
else
{
_logger.LogInformation("Best match score too low ({Score:F1}), trying exact match", bestScore);
_logger.LogDebug("Best match score too low ({Score:F1}), trying exact match", bestScore);
}
}
}
@@ -206,7 +206,7 @@ public class LrclibService
SyncedLyrics = lyrics.SyncedLyrics
};
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), TimeSpan.FromDays(30));
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), CacheExtensions.LyricsTTL);
_logger.LogInformation("Retrieved lyrics via exact match for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id);
@@ -214,7 +214,7 @@ public class LrclibService
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch lyrics from LRCLIB for {Artist} - {Track}", artistName, trackName);
_logger.LogError(ex, "Failed to fetch lyrics from LRCLIB for {Artist} - {Track}", artistName, trackName);
return null;
}
catch (Exception ex)
@@ -350,7 +350,7 @@ public class LrclibService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to fetch cached lyrics for {Artist} - {Track}", artistName, trackName);
_logger.LogError(ex, "Failed to fetch cached lyrics for {Artist} - {Track}", artistName, trackName);
return null;
}
}
@@ -368,7 +368,7 @@ public class LrclibService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize cached lyrics");
_logger.LogError(ex, "Failed to deserialize cached lyrics");
}
}
@@ -404,7 +404,7 @@ public class LrclibService
SyncedLyrics = lyrics.SyncedLyrics
};
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
return result;
}
@@ -129,7 +129,7 @@ public class LyricsOrchestrator
{
if (!_spotifySettings.Enabled)
{
_logger.LogDebug("Spotify API not enabled, skipping Spotify lyrics");
_logger.LogWarning("Spotify API not enabled, skipping Spotify lyrics");
return null;
}
@@ -140,7 +140,7 @@ public class LyricsOrchestrator
if (cleanSpotifyId.Length != 22 || cleanSpotifyId.Contains(":") || cleanSpotifyId.Contains("local"))
{
_logger.LogDebug("Invalid Spotify ID format: {SpotifyId}, skipping", spotifyTrackId);
_logger.LogWarning("Invalid Spotify ID format: {SpotifyId}, skipping", spotifyTrackId);
return null;
}
@@ -150,7 +150,7 @@ public class LyricsOrchestrator
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines, type: {SyncType})",
_logger.LogDebug("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines, type: {SyncType})",
artistName, trackName, spotifyLyrics.Lines.Count, spotifyLyrics.SyncType);
return _spotifyLyrics.ToLyricsInfo(spotifyLyrics);
@@ -161,7 +161,7 @@ public class LyricsOrchestrator
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track ID {SpotifyId}", spotifyTrackId);
_logger.LogError(ex, "Error fetching Spotify lyrics for track ID {SpotifyId}", spotifyTrackId);
return null;
}
}
@@ -190,7 +190,7 @@ public class LyricsOrchestrator
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
_logger.LogError(ex, "Error fetching LyricsPlus lyrics for {Artist} - {Track}", artistName, trackName);
return null;
}
}
@@ -219,7 +219,7 @@ public class LyricsOrchestrator
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
_logger.LogError(ex, "Error fetching LRCLib lyrics for {Artist} - {Track}", artistName, trackName);
return null;
}
}
@@ -22,7 +22,7 @@ public class LyricsPlusService
ILogger<LyricsPlusService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)");
_cache = cache;
_logger = logger;
}
@@ -55,7 +55,7 @@ public class LyricsPlusService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to deserialize cached LyricsPlus lyrics");
_logger.LogError(ex, "Failed to deserialize cached LyricsPlus lyrics");
}
}
@@ -103,7 +103,7 @@ public class LyricsPlusService
if (result != null)
{
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL);
_logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})",
artistName, trackName, lyricsResponse.Type, lyricsResponse.Metadata?.Source);
}
@@ -112,7 +112,7 @@ public class LyricsPlusService
}
catch (HttpRequestException ex)
{
_logger.LogWarning(ex, "Failed to fetch lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
_logger.LogError(ex, "Failed to fetch lyrics from LyricsPlus for {Artist} - {Track}", artistName, trackName);
return null;
}
catch (Exception ex)
@@ -58,7 +58,7 @@ public class LyricsPrefetchService : BackgroundService
// Run initial prefetch
try
{
_logger.LogInformation("Running initial lyrics prefetch on startup");
_logger.LogDebug("Running initial lyrics prefetch on startup");
await PrefetchAllPlaylistLyricsAsync(stoppingToken);
}
catch (Exception ex)
@@ -115,7 +115,7 @@ public class LyricsPrefetchService : BackgroundService
string playlistName,
CancellationToken cancellationToken)
{
_logger.LogInformation("Prefetching lyrics for playlist: {Playlist}", playlistName);
_logger.LogDebug("Prefetching lyrics for playlist: {Playlist}", playlistName);
var tracks = await _playlistFetcher.GetPlaylistTracksAsync(playlistName);
if (tracks.Count == 0)
@@ -156,7 +156,7 @@ public class LyricsPrefetchService : BackgroundService
}
}
_logger.LogDebug("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
_logger.LogInformation("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}",
spotifyToJellyfinId.Count, playlistName);
}
@@ -179,7 +179,7 @@ public class LyricsPrefetchService : BackgroundService
if (!string.IsNullOrEmpty(existingLyrics))
{
cached++;
_logger.LogDebug("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
_logger.LogInformation("✓ Lyrics already cached for {Artist} - {Track}", track.PrimaryArtist, track.Title);
continue;
}
@@ -191,7 +191,7 @@ public class LyricsPrefetchService : BackgroundService
if (hasLocalLyrics)
{
cached++;
_logger.LogInformation("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
_logger.LogWarning("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch",
track.PrimaryArtist, track.Title);
// Remove any previously cached LRCLib lyrics for this track
@@ -239,12 +239,12 @@ public class LyricsPrefetchService : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
_logger.LogError(ex, "Failed to prefetch lyrics for {Artist} - {Track}", track.PrimaryArtist, track.Title);
missing++;
}
}
_logger.LogInformation("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
_logger.LogDebug("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing",
playlistName, fetched, cached, missing);
return (fetched, cached, missing);
@@ -264,7 +264,7 @@ public class LyricsPrefetchService : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
_logger.LogError(ex, "Failed to save lyrics to file for {Artist} - {Track}", artist, title);
}
}
@@ -277,7 +277,7 @@ public class LyricsPrefetchService : BackgroundService
{
if (!Directory.Exists(_lyricsCacheDir))
{
_logger.LogInformation("Lyrics cache directory does not exist, skipping cache warming");
_logger.LogWarning("Lyrics cache directory does not exist, skipping cache warming");
return;
}
@@ -288,7 +288,7 @@ public class LyricsPrefetchService : BackgroundService
return;
}
_logger.LogInformation("🔥 Warming lyrics cache from {Count} files...", files.Length);
_logger.LogDebug("🔥 Warming lyrics cache from {Count} files...", files.Length);
var loaded = 0;
foreach (var file in files)
@@ -301,17 +301,17 @@ public class LyricsPrefetchService : BackgroundService
if (lyrics != null)
{
var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}";
await _cache.SetStringAsync(cacheKey, json, TimeSpan.FromDays(30));
await _cache.SetStringAsync(cacheKey, json, CacheExtensions.LyricsTTL);
loaded++;
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
_logger.LogError(ex, "Failed to load lyrics from file {File}", Path.GetFileName(file));
}
}
_logger.LogInformation("✅ Warmed {Count} lyrics from file cache", loaded);
_logger.LogDebug("✅ Warmed {Count} lyrics from file cache", loaded);
}
catch (Exception ex)
{
@@ -351,7 +351,7 @@ public class LyricsPrefetchService : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
_logger.LogError(ex, "Failed to remove cached lyrics for {Artist} - {Track}", artist, title);
}
}
@@ -375,7 +375,7 @@ public class LyricsPrefetchService : BackgroundService
if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0)
{
_logger.LogInformation("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
_logger.LogDebug("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)",
artistName, trackTitle, spotifyLyrics.Lines.Count);
return spotifyLyricsService.ToLyricsInfo(spotifyLyrics);
}
@@ -384,7 +384,7 @@ public class LyricsPrefetchService : BackgroundService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
_logger.LogError(ex, "Error fetching Spotify lyrics for track {SpotifyId}", spotifyTrackId);
return null;
}
}
@@ -423,7 +423,7 @@ public class LyricsPrefetchService : BackgroundService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
_logger.LogError(ex, "Error checking Jellyfin lyrics for item {ItemId}", jellyfinItemId);
return false;
}
}
@@ -528,7 +528,7 @@ public class LyricsPrefetchService : BackgroundService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
_logger.LogError(ex, "Error checking for local Jellyfin lyrics for Spotify track {SpotifyId}", spotifyTrackId);
return false;
}
}
@@ -50,13 +50,13 @@ public class SpotifyLyricsService
{
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
{
_logger.LogDebug("Spotify API not enabled or no session cookie configured");
_logger.LogInformation("Spotify API not enabled or no session cookie configured");
return null;
}
if (string.IsNullOrEmpty(_settings.LyricsApiUrl))
{
_logger.LogWarning("Spotify lyrics API URL not configured");
_logger.LogInformation("Spotify lyrics API URL not configured");
return null;
}
@@ -84,7 +84,7 @@ public class SpotifyLyricsService
if (result != null)
{
_logger.LogInformation("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
_logger.LogDebug("Got Spotify lyrics from sidecar for track {TrackId} ({LineCount} lines)",
spotifyTrackId, result.Lines.Count);
}
@@ -92,7 +92,7 @@ public class SpotifyLyricsService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
_logger.LogError(ex, "Error fetching lyrics from sidecar API for track {TrackId}", spotifyTrackId);
return null;
}
}
@@ -110,14 +110,14 @@ public class SpotifyLyricsService
{
if (!_settings.Enabled || string.IsNullOrEmpty(_settings.SessionCookie))
{
_logger.LogDebug("Spotify lyrics search skipped: API not enabled or no session cookie");
_logger.LogInformation("Spotify lyrics search skipped: API not enabled or no session cookie");
return null;
}
// The sidecar API only supports track ID, not search
// So we skip Spotify lyrics for search-based requests
// LRCLib will be used as fallback
_logger.LogDebug("Spotify lyrics search by metadata not supported with sidecar API, skipping");
_logger.LogWarning("Spotify lyrics search by metadata not supported with sidecar API, skipping");
return null;
}
@@ -169,7 +169,7 @@ public class SpotifyLyricsService
// Check for error
if (root.TryGetProperty("error", out var error) && error.GetBoolean())
{
_logger.LogDebug("Sidecar API returned error for track {TrackId}", trackId);
_logger.LogError("Sidecar API returned error for track {TrackId}", trackId);
return null;
}
@@ -25,7 +25,7 @@ public class MusicBrainzService
ILogger<MusicBrainzService> logger)
{
_httpClient = httpClientFactory.CreateClient();
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.3.0 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.1 (https://github.com/SoPat712/allstarr)");
_httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
_settings = settings.Value;
@@ -130,7 +130,7 @@ public class MusicBrainzService
return new List<MusicBrainzRecording>();
}
_logger.LogInformation("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
_logger.LogDebug("Found {Count} MusicBrainz recordings for: {Title} - {Artist}",
result.Recordings.Count, title, artist);
return result.Recordings;
@@ -241,7 +241,7 @@ public class MusicBrainzService
.ToList());
}
_logger.LogInformation("Found {Count} genres for {Title} - {Artist}: {Genres}",
_logger.LogDebug("Found {Count} genres for {Title} - {Artist}: {Genres}",
genres.Count, title, artist, string.Join(", ", genres));
return genres;
@@ -92,18 +92,18 @@ public class QobuzBundleService
// Step 1: Get the bundle URL from login page
var bundleUrl = await GetBundleUrlAsync();
_logger.LogInformation("Found bundle URL: {BundleUrl}", bundleUrl);
_logger.LogDebug("Found bundle URL: {BundleUrl}", bundleUrl);
// Step 2: Download the bundle JavaScript
var bundleJs = await DownloadBundleAsync(bundleUrl);
// Step 3: Extract App ID
_cachedAppId = ExtractAppId(bundleJs);
_logger.LogInformation("Extracted App ID: {AppId}", _cachedAppId);
_logger.LogDebug("Extracted App ID: {AppId}", _cachedAppId);
// Step 4: Extract secrets (they are base64 encoded in the bundle)
_cachedSecrets = ExtractSecrets(bundleJs);
_logger.LogInformation("Extracted {Count} secrets", _cachedSecrets.Count);
_logger.LogDebug("Extracted {Count} secrets", _cachedSecrets.Count);
}
finally
{
@@ -253,7 +253,7 @@ public class QobuzBundleService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to decode secret for timezone {Timezone}", kvp.Key);
_logger.LogError(ex, "Failed to decode secret for timezone {Timezone}", kvp.Key);
}
}
@@ -195,7 +195,7 @@ public class QobuzMetadataService : IMusicMetadataService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
_logger.LogError(ex, "Failed to enrich genre for {Title}", song.Title);
}
});
}
@@ -98,7 +98,7 @@ public class SpotifyApiClient : IDisposable
{
if (string.IsNullOrEmpty(_settings.SessionCookie))
{
_logger.LogWarning("No Spotify session cookie configured");
_logger.LogInformation("No Spotify session cookie configured");
return null;
}
@@ -530,7 +530,7 @@ public class SpotifyApiClient : IDisposable
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to parse GraphQL track");
_logger.LogError(ex, "Failed to parse GraphQL track");
return null;
}
}
@@ -804,7 +804,7 @@ public class SpotifyApiClient : IDisposable
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
_logger.LogError("GraphQL user playlists request failed: {StatusCode}", response.StatusCode);
break;
}
@@ -956,7 +956,7 @@ public class SpotifyApiClient : IDisposable
await Task.Delay(delayMs, cancellationToken);
}
_logger.LogInformation("Found {Count} playlists{Filter} via GraphQL",
_logger.LogDebug("Found {Count} playlists{Filter} via GraphQL",
playlists.Count,
string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'");
return playlists;
@@ -60,7 +60,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie))
{
_logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping");
_logger.LogInformation("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
_logger.LogDebug("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists.");
_logger.LogInformation("========================================");
return;
}
@@ -77,7 +77,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
{
_logger.LogWarning("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
_logger.LogInformation("Jellyfin URL or API key not configured, Spotify playlist injection disabled");
_logger.LogInformation("========================================");
return;
}
@@ -115,7 +115,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
}
else
{
_logger.LogInformation("Skipping startup fetch - already have cached files");
_logger.LogWarning("Skipping startup fetch - already have cached files");
_hasRunOnce = true;
}
}
@@ -194,7 +194,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (File.Exists(filePath))
{
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
_logger.LogInformation(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
_logger.LogDebug(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours);
// Load into Redis if not already there
if (!await _cache.ExistsAsync(cacheKey))
@@ -207,7 +207,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
// Check Redis cache
if (await _cache.ExistsAsync(cacheKey))
{
_logger.LogInformation(" {Playlist}: Found in Redis cache", playlistName);
_logger.LogDebug(" {Playlist}: Found in Redis cache", playlistName);
continue;
}
@@ -218,7 +218,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (allPlaylistsHaveCache)
{
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
_logger.LogWarning("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
return false;
}
@@ -250,13 +250,13 @@ public class SpotifyMissingTracksFetcher : BackgroundService
// No expiration - cache persists until next Jellyfin job generates new file
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365));
_logger.LogInformation("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)",
_logger.LogDebug("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)",
tracks.Count, playlistName, fileAge.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load file cache for {Playlist}", playlistName);
_logger.LogError(ex, "Failed to load file cache for {Playlist}", playlistName);
}
}
@@ -267,7 +267,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
var filePath = GetCacheFilePath(playlistName);
var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true });
await File.WriteAllTextAsync(filePath, json);
_logger.LogInformation("Saved {Count} tracks to file cache for {Playlist}",
_logger.LogDebug("Saved {Count} tracks to file cache for {Playlist}",
tracks.Count, playlistName);
}
catch (Exception ex)
@@ -279,7 +279,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("=== FETCHING MISSING TRACKS ===");
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
_logger.LogDebug("Processing {Count} playlists", _playlistIdToName.Count);
// Track when we find files to optimize search for other playlists
DateTime? firstFoundTime = null;
@@ -324,11 +324,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (existingTracks != null && existingTracks.Count > 0)
{
_logger.LogInformation(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count);
_logger.LogDebug(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count);
}
else
{
_logger.LogInformation(" No existing cache, will search for missing tracks file");
_logger.LogDebug(" No existing cache, will search for missing tracks file");
}
var settings = _spotifySettings.Value;
@@ -428,7 +428,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
// Keep the existing cache - don't let it expire
if (existingTracks != null && existingTracks.Count > 0)
{
_logger.LogInformation(" ✓ Keeping existing cache with {Count} tracks (no expiration)", existingTracks.Count);
_logger.LogDebug(" ✓ Keeping existing cache with {Count} tracks (no expiration)", existingTracks.Count);
// Re-save with no expiration to ensure it persists
await _cache.SetAsync(cacheKey, existingTracks, TimeSpan.FromDays(365)); // Effectively no expiration
}
@@ -444,7 +444,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (tracks != null && tracks.Count > 0)
{
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); // No expiration
_logger.LogInformation(" ✓ Loaded {Count} tracks from file cache (no expiration)", tracks.Count);
_logger.LogDebug(" ✓ Loaded {Count} tracks from file cache (no expiration)", tracks.Count);
}
}
catch (Exception ex)
@@ -476,7 +476,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
try
{
// Log every request with the actual filename
_logger.LogInformation("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
_logger.LogDebug("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
var response = await httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode)
@@ -502,7 +502,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
_logger.LogError(ex, "Failed to fetch {Filename}", filename);
}
return (false, null);
@@ -27,7 +27,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
private readonly SpotifyApiClient _spotifyClient;
private readonly RedisCacheService _cache;
private const string CacheDirectory = "/app/cache/spotify";
private const string CacheKeyPrefix = "spotify:playlist:";
// Track Spotify playlist IDs after discovery
@@ -77,7 +76,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value)
{
shouldRefresh = true;
_logger.LogInformation("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
_logger.LogWarning("Cache expired for '{Name}' - next cron run was at {NextRun} UTC",
playlistName, nextRun.Value);
}
}
@@ -101,32 +100,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
}
// Try file cache
var filePath = GetCacheFilePath(playlistName);
if (File.Exists(filePath))
{
try
{
var json = await File.ReadAllTextAsync(filePath);
var filePlaylist = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
if (filePlaylist != null && filePlaylist.Tracks.Count > 0)
{
var age = DateTime.UtcNow - filePlaylist.FetchedAt;
if (age.TotalMinutes < _spotifyApiSettings.CacheDurationMinutes)
{
_logger.LogDebug("Using file-cached playlist '{Name}' ({Count} tracks)",
playlistName, filePlaylist.Tracks.Count);
return filePlaylist.Tracks;
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to read file cache for '{Name}'", playlistName);
}
}
// Need to fetch fresh - try to use cached or configured Spotify playlist ID
// Cache miss or expired - need to fetch fresh from Spotify
// Try to use cached or configured Spotify playlist ID
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
{
// Check if we have a configured Spotify ID for this playlist
@@ -141,7 +116,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
else
{
// No configured ID, try searching by name (works for public/followed playlists)
_logger.LogDebug("No configured Spotify ID for '{Name}', searching...", playlistName);
_logger.LogInformation("No configured Spotify ID for '{Name}', searching...", playlistName);
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
var exactMatch = playlists.FirstOrDefault(p =>
@@ -149,21 +124,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (exactMatch == null)
{
_logger.LogWarning("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
// Return file cache even if expired, as a fallback
if (File.Exists(filePath))
{
var json = await File.ReadAllTextAsync(filePath);
var fallback = JsonSerializer.Deserialize<SpotifyPlaylist>(json);
if (fallback != null)
{
_logger.LogWarning("Using expired file cache as fallback for '{Name}'", playlistName);
return fallback.Tracks;
}
}
return new List<SpotifyPlaylistTrack>();
_logger.LogInformation("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
spotifyId = exactMatch.SpotifyId;
@@ -176,7 +138,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
if (playlist == null || playlist.Tracks.Count == 0)
{
_logger.LogWarning("Failed to fetch playlist '{Name}' from Spotify", playlistName);
_logger.LogError("Failed to fetch playlist '{Name}' from Spotify", playlistName);
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
@@ -203,13 +165,12 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
_logger.LogError(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
}
}
// Update cache with cron-based expiration
// Update Redis cache with cron-based expiration
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
await SaveToFileCacheAsync(playlistName, playlist);
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
@@ -269,9 +230,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
_logger.LogInformation("========================================");
_logger.LogInformation("SpotifyPlaylistFetcher: Starting up...");
// Ensure cache directory exists
Directory.CreateDirectory(CacheDirectory);
if (!_spotifyApiSettings.Enabled)
{
_logger.LogInformation("Spotify API integration is DISABLED");
@@ -281,13 +239,13 @@ public class SpotifyPlaylistFetcher : BackgroundService
if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
{
_logger.LogWarning("Spotify session cookie not configured - cannot access editorial playlists");
_logger.LogError("Spotify session cookie not configured - cannot access editorial playlists");
_logger.LogInformation("========================================");
return;
}
// Verify we can get an access token (the most reliable auth check)
_logger.LogInformation("Attempting Spotify authentication...");
_logger.LogDebug("Attempting Spotify authentication...");
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
if (string.IsNullOrEmpty(token))
{
@@ -374,7 +332,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
// Rate limiting between playlists
if (playlistName != needsRefresh.Last())
{
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
_logger.LogWarning("Waiting 3 seconds before next playlist to avoid rate limits...");
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
}
}
@@ -409,7 +367,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
try
{
var tracks = await GetPlaylistTracksAsync(config.Name);
_logger.LogInformation(" {Name}: {Count} tracks", config.Name, tracks.Count);
_logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
// Log sample of track order for debugging
if (tracks.Count > 0)
@@ -434,36 +392,11 @@ public class SpotifyPlaylistFetcher : BackgroundService
// Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors
if (config != _spotifyImportSettings.Playlists.Last())
{
_logger.LogDebug("Waiting 3 seconds before next playlist to avoid rate limits...");
_logger.LogWarning("Waiting 3 seconds before next playlist to avoid rate limits...");
await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken);
}
}
_logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ===");
}
private string GetCacheFilePath(string playlistName)
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
return Path.Combine(CacheDirectory, $"{safeName}_spotify.json");
}
private async Task SaveToFileCacheAsync(string playlistName, SpotifyPlaylist playlist)
{
try
{
var filePath = GetCacheFilePath(playlistName);
var json = JsonSerializer.Serialize(playlist, new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
});
await File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("Saved playlist '{Name}' to file cache", playlistName);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to save file cache for '{Name}'", playlistName);
}
}
}
@@ -168,7 +168,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var timeSinceLastRun = now - lastRun;
if (timeSinceLastRun < _minimumRunInterval)
{
_logger.LogInformation("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
_logger.LogWarning("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
continue;
@@ -200,7 +200,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (playlist == null)
{
_logger.LogWarning("Playlist {Playlist} not found in configuration", playlistName);
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
return;
}
@@ -316,7 +316,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
if (spotifyTracks.Count == 0)
{
_logger.LogInformation("No tracks found for {Playlist}, skipping matching", playlistName);
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
return;
}
@@ -347,7 +347,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
else
{
_logger.LogWarning("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
_logger.LogInformation("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName);
}
var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal(
@@ -379,7 +379,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
_logger.LogError(ex, "Could not fetch existing Jellyfin tracks for {Playlist}, will match all tracks", playlistName);
}
}
}
@@ -391,12 +391,12 @@ public class SpotifyTrackMatchingService : BackgroundService
if (tracksToMatch.Count == 0)
{
_logger.LogInformation("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
_logger.LogWarning("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching",
spotifyTracks.Count, playlistName);
return;
}
_logger.LogInformation("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
_logger.LogWarning("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)",
tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching);
// Check cache - use snapshot/timestamp to detect changes
@@ -430,7 +430,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (!hasNewManualMappings)
{
_logger.LogInformation("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
_logger.LogWarning("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed",
playlistName, existingMatched.Count, tracksToMatch.Count);
return;
}
@@ -488,7 +488,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
spotifyTrack.Title, spotifyTrack.PrimaryArtist);
return (spotifyTrack, new List<(Song, double, string)>());
}
@@ -597,7 +597,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
_logger.LogError(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName);
}
}
@@ -810,7 +810,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
if (existingMatched != null && existingMatched.Count > 0)
{
_logger.LogInformation("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
_logger.LogWarning("Playlist {Playlist} already has {Count} matched tracks cached, skipping",
playlistName, existingMatched.Count);
return;
}
@@ -819,11 +819,11 @@ public class SpotifyTrackMatchingService : BackgroundService
var missingTracks = await _cache.GetAsync<List<MissingTrack>>(missingTracksKey);
if (missingTracks == null || missingTracks.Count == 0)
{
_logger.LogInformation("No missing tracks found for {Playlist}, skipping matching", playlistName);
_logger.LogWarning("No missing tracks found for {Playlist}, skipping matching", playlistName);
return;
}
_logger.LogInformation("Matching {Count} tracks for {Playlist} (with rate limiting)",
_logger.LogWarning("Matching {Count} tracks for {Playlist} (with rate limiting)",
missingTracks.Count, playlistName);
var matchedSongs = new List<Song>();
@@ -878,15 +878,15 @@ public class SpotifyTrackMatchingService : BackgroundService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to match track: {Title} - {Artist}",
_logger.LogError(ex, "Failed to match track: {Title} - {Artist}",
track.Title, track.PrimaryArtist);
}
}
if (matchedSongs.Count > 0)
{
// Cache matched tracks for 1 hour
await _cache.SetAsync(matchedTracksKey, matchedSongs, TimeSpan.FromHours(1));
// Cache matched tracks for configurable duration
await _cache.SetAsync(matchedTracksKey, matchedSongs, CacheExtensions.SpotifyMatchedTracksTTL);
_logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}",
matchedSongs.Count, missingTracks.Count, playlistName);
}
@@ -947,22 +947,23 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// Pre-builds the playlist items cache for instant serving.
/// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order.
/// PRIORITY: Local Jellyfin tracks FIRST, then external providers for unmatched tracks only.
/// </summary>
private async Task PreBuildPlaylistItemsCacheAsync(
string playlistName,
string? jellyfinPlaylistId,
List<SpotifyPlaylistTrack> spotifyTracks,
List<MatchedTrack> matchedTracks,
List<MatchedTrack> externalMatchedTracks,
TimeSpan cacheExpiration,
CancellationToken cancellationToken)
{
try
{
_logger.LogInformation("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
_logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
if (string.IsNullOrEmpty(jellyfinPlaylistId))
{
_logger.LogWarning("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
_logger.LogError("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName);
return;
}
@@ -981,7 +982,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var userId = jellyfinSettings.UserId;
if (string.IsNullOrEmpty(userId))
{
_logger.LogWarning("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
return;
}
@@ -998,7 +999,7 @@ public class SpotifyTrackMatchingService : BackgroundService
if (statusCode != 200 || existingTracksResponse == null)
{
_logger.LogWarning("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
_logger.LogError("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode);
return;
}
@@ -1029,8 +1030,10 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// Build the final track list in correct Spotify order
// PRIORITY: Local Jellyfin tracks FIRST, then external for unmatched only
var finalItems = new List<Dictionary<string, object?>>();
var usedJellyfinItems = new HashSet<string>();
var matchedSpotifyIds = new HashSet<string>(); // Track which Spotify tracks got local matches
var localUsedCount = 0;
var externalUsedCount = 0;
var manualExternalCount = 0;
@@ -1068,19 +1071,42 @@ public class SpotifyTrackMatchingService : BackgroundService
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
// Add Spotify ID to ProviderIds so lyrics can work for local tracks too
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
// Add Jellyfin ID to ProviderIds for easy identification
if (itemDict.TryGetValue("Id", out var jellyfinIdObj) && jellyfinIdObj != null)
{
if (!itemDict.ContainsKey("ProviderIds"))
var jellyfinId = jellyfinIdObj.ToString();
if (!string.IsNullOrEmpty(jellyfinId))
{
itemDict["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
_logger.LogDebug("Added Spotify ID {SpotifyId} to local track for lyrics support", spotifyTrack.SpotifyId);
if (!itemDict.ContainsKey("ProviderIds"))
{
itemDict["ProviderIds"] = new Dictionary<string, string>();
}
// Handle ProviderIds which might be a JsonElement or Dictionary
Dictionary<string, string>? providerIds = null;
if (itemDict["ProviderIds"] is Dictionary<string, string> dict)
{
providerIds = dict;
}
else if (itemDict["ProviderIds"] is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
{
// Convert JsonElement to Dictionary
providerIds = new Dictionary<string, string>();
foreach (var prop in jsonEl.EnumerateObject())
{
providerIds[prop.Name] = prop.Value.GetString() ?? "";
}
// Replace the JsonElement with the Dictionary
itemDict["ProviderIds"] = providerIds;
}
if (providerIds != null && !providerIds.ContainsKey("Jellyfin"))
{
providerIds["Jellyfin"] = jellyfinId;
_logger.LogDebug("Added Jellyfin ID {JellyfinId} to manual mapped local track {Title}",
jellyfinId, spotifyTrack.Title);
}
}
}
@@ -1089,6 +1115,7 @@ public class SpotifyTrackMatchingService : BackgroundService
{
usedJellyfinItems.Add(matchedKey);
}
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as locally matched
localUsedCount++;
}
continue; // Skip to next track
@@ -1137,7 +1164,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
else
{
_logger.LogWarning("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
_logger.LogError("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback",
provider, externalId);
}
}
@@ -1164,15 +1191,6 @@ public class SpotifyTrackMatchingService : BackgroundService
};
}
var matchedTrack = new MatchedTrack
{
Position = spotifyTrack.Position,
SpotifyId = spotifyTrack.SpotifyId,
MatchedSong = externalSong
};
matchedTracks.Add(matchedTrack);
// Convert external song to Jellyfin item format and add to finalItems
var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong);
@@ -1194,6 +1212,7 @@ public class SpotifyTrackMatchingService : BackgroundService
finalItems.Add(externalItem);
externalUsedCount++;
manualExternalCount++;
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external)
_logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}",
spotifyTrack.Title, provider, externalId);
@@ -1202,11 +1221,11 @@ public class SpotifyTrackMatchingService : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
_logger.LogError(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title);
}
}
// If no manual external mapping, try AGGRESSIVE fuzzy matching with local Jellyfin tracks
// THIRD: Try AGGRESSIVE fuzzy matching with local Jellyfin tracks (PRIORITY!)
double bestScore = 0;
foreach (var kvp in jellyfinItemsByName)
@@ -1246,19 +1265,42 @@ public class SpotifyTrackMatchingService : BackgroundService
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object?>>(matchedJellyfinItem.Value.GetRawText());
if (itemDict != null)
{
// Add Spotify ID to ProviderIds so lyrics can work for fuzzy-matched local tracks too
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
// Add Jellyfin ID to ProviderIds for easy identification
if (itemDict.TryGetValue("Id", out var jellyfinIdObj) && jellyfinIdObj != null)
{
if (!itemDict.ContainsKey("ProviderIds"))
var jellyfinId = jellyfinIdObj.ToString();
if (!string.IsNullOrEmpty(jellyfinId))
{
itemDict["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = itemDict["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
_logger.LogDebug("Added Spotify ID {SpotifyId} to fuzzy-matched local track for lyrics support", spotifyTrack.SpotifyId);
if (!itemDict.ContainsKey("ProviderIds"))
{
itemDict["ProviderIds"] = new Dictionary<string, string>();
}
// Handle ProviderIds which might be a JsonElement or Dictionary
Dictionary<string, string>? providerIds = null;
if (itemDict["ProviderIds"] is Dictionary<string, string> dict)
{
providerIds = dict;
}
else if (itemDict["ProviderIds"] is JsonElement jsonEl && jsonEl.ValueKind == JsonValueKind.Object)
{
// Convert JsonElement to Dictionary
providerIds = new Dictionary<string, string>();
foreach (var prop in jsonEl.EnumerateObject())
{
providerIds[prop.Name] = prop.Value.GetString() ?? "";
}
// Replace the JsonElement with the Dictionary
itemDict["ProviderIds"] = providerIds;
}
if (providerIds != null && !providerIds.ContainsKey("Jellyfin"))
{
providerIds["Jellyfin"] = jellyfinId;
_logger.LogDebug("Fuzzy matched local track {Title} with Jellyfin ID {Id} (score: {Score:F1})",
spotifyTrack.Title, jellyfinId, bestScore);
}
}
}
@@ -1267,36 +1309,27 @@ public class SpotifyTrackMatchingService : BackgroundService
{
usedJellyfinItems.Add(matchedKey);
}
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as locally matched
localUsedCount++;
}
}
else
{
// No local match - try to find external track
var matched = matchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
// FOURTH: No local match - try to find external track (ONLY for unmatched tracks)
var matched = externalMatchedTracks.FirstOrDefault(t => t.SpotifyId == spotifyTrack.SpotifyId);
if (matched != null && matched.MatchedSong != null)
{
// Convert external song to Jellyfin item format
var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong);
// Add Spotify ID to ProviderIds so lyrics can work
if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId))
{
if (!externalItem.ContainsKey("ProviderIds"))
{
externalItem["ProviderIds"] = new Dictionary<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
finalItems.Add(externalItem);
matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external)
externalUsedCount++;
_logger.LogDebug("Using external match for {Title}: {Provider}",
spotifyTrack.Title, matched.MatchedSong.ExternalProvider);
}
// else: Track remains unmatched (not added to finalItems)
}
}
@@ -1310,11 +1343,29 @@ public class SpotifyTrackMatchingService : BackgroundService
var genreEnrichment = _serviceProvider.GetService<GenreEnrichmentService>();
if (genreEnrichment != null)
{
_logger.LogInformation("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount);
_logger.LogDebug("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount);
// Extract external songs from matched tracks
var externalSongs = matchedTracks
.Where(t => t.MatchedSong != null && !t.MatchedSong.IsLocal)
// Extract external songs from externalMatchedTracks that were actually used
var usedExternalSpotifyIds = finalItems
.Where(item => item.TryGetValue("Id", out var idObj) &&
idObj is string id && id.StartsWith("ext-"))
.Select(item =>
{
// Try to get Spotify ID from ProviderIds
if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj is Dictionary<string, string> providerIds)
{
providerIds.TryGetValue("Spotify", out var spotifyId);
return spotifyId;
}
return null;
})
.Where(id => !string.IsNullOrEmpty(id))
.ToHashSet();
var externalSongs = externalMatchedTracks
.Where(t => t.MatchedSong != null &&
!t.MatchedSong.IsLocal &&
usedExternalSpotifyIds.Contains(t.SpotifyId))
.Select(t => t.MatchedSong!)
.ToList();
@@ -1353,7 +1404,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName);
_logger.LogError(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName);
}
}
@@ -1370,8 +1421,7 @@ public class SpotifyTrackMatchingService : BackgroundService
manualMappingInfo = $" [Manual external: {manualExternalCount}]";
}
_logger.LogInformation(
"✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
_logger.LogDebug("✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h",
playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours);
}
else
@@ -1425,7 +1475,7 @@ public class SpotifyTrackMatchingService : BackgroundService
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
await System.IO.File.WriteAllTextAsync(filePath, json);
_logger.LogDebug("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
_logger.LogInformation("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath);
}
catch (Exception ex)
{
@@ -174,7 +174,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
// Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogInformation("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url);
@@ -240,7 +240,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to parse playlist, skipping");
_logger.LogWarning(ex, "Failed to parse playlist, skipping");
// Skip this playlist and continue with others
}
}
@@ -301,7 +301,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to enrich genre for {Title}", song.Title);
_logger.LogError(ex, "Failed to enrich genre for {Title}", song.Title);
}
});
}
@@ -356,8 +356,8 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
}
// Cache for 24 hours
await _cache.SetAsync(cacheKey, album, TimeSpan.FromHours(24));
// Cache for configurable duration
await _cache.SetAsync(cacheKey, album, CacheExtensions.MetadataTTL);
return album;
}, (Album?)null);
@@ -367,14 +367,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
if (externalProvider != "squidwtf") return null;
_logger.LogInformation("GetArtistAsync called for SquidWTF artist {ExternalId}", externalId);
_logger.LogDebug("GetArtistAsync called for SquidWTF artist {ExternalId}", externalId);
// Try cache first
var cacheKey = $"squidwtf:artist:{externalId}";
var cached = await _cache.GetAsync<Artist>(cacheKey);
if (cached != null)
{
_logger.LogInformation("Returning cached artist {ArtistName}", cached.Name);
_logger.LogDebug("Returning cached artist {ArtistName}", cached.Name);
return cached;
}
@@ -382,12 +382,12 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist from {Url}", url);
_logger.LogDebug("Fetching artist from {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("SquidWTF artist request failed with status {StatusCode}", response.StatusCode);
_logger.LogError("SquidWTF artist request failed with status {StatusCode}", response.StatusCode);
return null;
}
@@ -408,7 +408,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
if (albumItems[0].TryGetProperty("artist", out var artistEl))
{
artistSource = artistEl;
_logger.LogInformation("Found artist from albums, albumCount={AlbumCount}", albumCount);
_logger.LogDebug("Found artist from albums, albumCount={AlbumCount}", albumCount);
}
}
@@ -443,10 +443,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString());
var artist = ParseTidalArtist(doc.RootElement);
_logger.LogInformation("Successfully parsed artist {ArtistName} with {AlbumCount} albums", artist.Name, albumCount);
_logger.LogDebug("Successfully parsed artist {ArtistName} with {AlbumCount} albums", artist.Name, albumCount);
// Cache for 24 hours
await _cache.SetAsync(cacheKey, artist, TimeSpan.FromHours(24));
// Cache for configurable duration
await _cache.SetAsync(cacheKey, artist, CacheExtensions.MetadataTTL);
return artist;
}, (Artist?)null);
@@ -458,16 +458,16 @@ public class SquidWTFMetadataService : IMusicMetadataService
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
_logger.LogInformation("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
_logger.LogDebug("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId);
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogInformation("Fetching artist albums from URL: {Url}", url);
_logger.LogDebug("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode);
_logger.LogError("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode);
return new List<Album>();
}
@@ -488,7 +488,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
parsedAlbum.Title, parsedAlbum.Artist, parsedAlbum.ArtistId);
albums.Add(parsedAlbum);
}
_logger.LogInformation("Found {AlbumCount} albums for artist {ExternalId}", albums.Count, externalId);
_logger.LogDebug("Found {AlbumCount} albums for artist {ExternalId}", albums.Count, externalId);
}
else
{
@@ -187,7 +187,7 @@ public class PlaylistSyncService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to download track '{Artist} - {Title}'", track.Artist, track.Title);
_logger.LogError(ex, "Failed to download track '{Artist} - {Title}'", track.Artist, track.Title);
}
}
@@ -239,7 +239,7 @@ public class PlaylistSyncService
}
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
_logger.LogInformation("Created M3U playlist: {Path}", playlistPath);
_logger.LogDebug("Created M3U playlist: {Path}", playlistPath);
}
catch (Exception ex)
{
@@ -259,7 +259,7 @@ public class PlaylistSyncService
// Skip real-time updates during full playlist download (M3U will be created once at the end)
if (isFullPlaylistDownload)
{
_logger.LogDebug("Skipping M3U update for track {TrackId} (full playlist download in progress)", track.Id);
_logger.LogWarning("Skipping M3U update for track {TrackId} (full playlist download in progress)", track.Id);
return;
}
@@ -349,7 +349,7 @@ public class PlaylistSyncService
// Write the M3U file (overwrites existing)
await IOFile.WriteAllTextAsync(playlistPath, m3uContent.ToString());
_logger.LogInformation("Updated M3U playlist '{PlaylistName}' with {Count} tracks (in correct order)",
_logger.LogDebug("Updated M3U playlist '{PlaylistName}' with {Count} tracks (in correct order)",
playlist.Name, addedCount);
}
catch (Exception ex)
@@ -382,7 +382,7 @@ public class PlaylistSyncService
if (expiredKeys.Count > 0)
{
_logger.LogDebug("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count);
_logger.LogWarning("Cleaned up {Count} expired playlist cache entries", expiredKeys.Count);
}
}
catch (OperationCanceledException)
@@ -392,7 +392,7 @@ public class PlaylistSyncService
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error during playlist cache cleanup");
_logger.LogError(ex, "Error during playlist cache cleanup");
}
}
@@ -91,7 +91,7 @@ public class SubsonicModelMapper
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Error parsing Subsonic search response");
_logger.LogError(ex, "Error parsing Subsonic search response");
}
return (songs, albums, artists);
+3 -3
View File
@@ -5,9 +5,9 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>allstarr</RootNamespace>
<Version>1.3.0</Version>
<AssemblyVersion>1.3.0.0</AssemblyVersion>
<FileVersion>1.3.0.0</FileVersion>
<Version>1.0.1</Version>
<AssemblyVersion>1.0.1.0</AssemblyVersion>
<FileVersion>1.0.1.0</FileVersion>
</PropertyGroup>
<ItemGroup>
+11
View File
@@ -51,6 +51,17 @@
"Enabled": true,
"ConnectionString": "localhost:6379"
},
"Cache": {
"SearchResultsMinutes": 120,
"PlaylistImagesHours": 168,
"SpotifyPlaylistItemsHours": 168,
"SpotifyMatchedTracksDays": 30,
"LyricsDays": 14,
"GenreDays": 30,
"MetadataDays": 7,
"OdesliLookupDays": 60,
"ProxyImagesDays": 14
},
"SpotifyImport": {
"Enabled": false,
"SyncStartHour": 16,
+98 -8
View File
@@ -75,6 +75,7 @@
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
.status-badge.info { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
.status-dot {
width: 8px;
@@ -913,7 +914,7 @@
<div class="config-item">
<span class="label">Quality</span>
<span class="value" id="config-squid-quality">-</span>
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', '', ['LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
<button onclick="openEditSetting('SQUIDWTF_QUALITY', 'SquidWTF Quality', 'select', 'HI_RES_LOSSLESS: 24-bit/192kHz FLAC (highest)\\nLOSSLESS: 16-bit/44.1kHz FLAC (default)\\nHIGH: 320kbps AAC\\nLOW: 96kbps AAC', ['HI_RES_LOSSLESS', 'LOSSLESS', 'HIGH', 'LOW'])">Edit</button>
</div>
</div>
</div>
@@ -1013,6 +1014,60 @@
</div>
</div>
<div class="card">
<h2>Cache Settings</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Configure how long different types of data are cached. Longer durations reduce API calls but may show stale data.
</p>
<div class="config-section">
<div class="config-item">
<span class="label">Search Results (minutes)</span>
<span class="value" id="config-cache-search">-</span>
<button onclick="openEditCacheSetting('SearchResultsMinutes', 'Search Results Cache (minutes)', 'How long to cache search results')">Edit</button>
</div>
<div class="config-item">
<span class="label">Playlist Images (hours)</span>
<span class="value" id="config-cache-playlist-images">-</span>
<button onclick="openEditCacheSetting('PlaylistImagesHours', 'Playlist Images Cache (hours)', 'How long to cache playlist cover images')">Edit</button>
</div>
<div class="config-item">
<span class="label">Spotify Playlist Items (hours)</span>
<span class="value" id="config-cache-spotify-items">-</span>
<button onclick="openEditCacheSetting('SpotifyPlaylistItemsHours', 'Spotify Playlist Items Cache (hours)', 'How long to cache Spotify playlist data')">Edit</button>
</div>
<div class="config-item">
<span class="label">Spotify Matched Tracks (days)</span>
<span class="value" id="config-cache-matched-tracks">-</span>
<button onclick="openEditCacheSetting('SpotifyMatchedTracksDays', 'Matched Tracks Cache (days)', 'How long to cache Spotify ID to track mappings')">Edit</button>
</div>
<div class="config-item">
<span class="label">Lyrics (days)</span>
<span class="value" id="config-cache-lyrics">-</span>
<button onclick="openEditCacheSetting('LyricsDays', 'Lyrics Cache (days)', 'How long to cache fetched lyrics')">Edit</button>
</div>
<div class="config-item">
<span class="label">Genre Data (days)</span>
<span class="value" id="config-cache-genres">-</span>
<button onclick="openEditCacheSetting('GenreDays', 'Genre Cache (days)', 'How long to cache genre information')">Edit</button>
</div>
<div class="config-item">
<span class="label">External Metadata (days)</span>
<span class="value" id="config-cache-metadata">-</span>
<button onclick="openEditCacheSetting('MetadataDays', 'Metadata Cache (days)', 'How long to cache SquidWTF/Deezer/Qobuz metadata')">Edit</button>
</div>
<div class="config-item">
<span class="label">Odesli Lookups (days)</span>
<span class="value" id="config-cache-odesli">-</span>
<button onclick="openEditCacheSetting('OdesliLookupDays', 'Odesli Lookup Cache (days)', 'How long to cache Odesli URL conversions')">Edit</button>
</div>
<div class="config-item">
<span class="label">Proxy Images (days)</span>
<span class="value" id="config-cache-proxy-images">-</span>
<button onclick="openEditCacheSetting('ProxyImagesDays', 'Proxy Images Cache (days)', 'How long to cache proxied Jellyfin images')">Edit</button>
</div>
</div>
</div>
<div class="card">
<h2>Configuration Backup</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
@@ -1615,13 +1670,13 @@
// Show breakdown with color coding
let breakdownParts = [];
if (localCount > 0) {
breakdownParts.push(`<span style="color:var(--success)">${localCount} local</span>`);
breakdownParts.push(`<span style="color:var(--success)">${localCount} Local</span>`);
}
if (externalMatched > 0) {
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} matched</span>`);
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} External</span>`);
}
if (externalMissing > 0) {
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} missing</span>`);
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} Missing</span>`);
}
const breakdown = breakdownParts.length > 0
@@ -1653,8 +1708,8 @@
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
<div style="width:${externalPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMatched} external matched tracks"></div>
<div style="width:${missingPct}%;height:100%;background:#6b7280;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
<div style="width:${externalPct}%;height:100%;background:#3b82f6;transition:width 0.3s;" title="${externalMatched} external tracks"></div>
<div style="width:${missingPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
</div>
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
</div>
@@ -1966,6 +2021,19 @@
// Sync settings
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
// Cache settings
if (data.cache) {
document.getElementById('config-cache-search').textContent = data.cache.searchResultsMinutes || '120';
document.getElementById('config-cache-playlist-images').textContent = data.cache.playlistImagesHours || '168';
document.getElementById('config-cache-spotify-items').textContent = data.cache.spotifyPlaylistItemsHours || '168';
document.getElementById('config-cache-matched-tracks').textContent = data.cache.spotifyMatchedTracksDays || '30';
document.getElementById('config-cache-lyrics').textContent = data.cache.lyricsDays || '14';
document.getElementById('config-cache-genres').textContent = data.cache.genreDays || '30';
document.getElementById('config-cache-metadata').textContent = data.cache.metadataDays || '7';
document.getElementById('config-cache-odesli').textContent = data.cache.odesliLookupDays || '60';
document.getElementById('config-cache-proxy-images').textContent = data.cache.proxyImagesDays || '14';
}
} catch (error) {
console.error('Failed to fetch config:', error);
}
@@ -2717,7 +2785,7 @@
}
} else if (t.isLocal === false) {
const provider = capitalizeProvider(t.externalProvider) || 'External';
statusBadge = `<span class="status-badge warning" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
statusBadge = `<span class="status-badge info" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
// Add manual mapping indicator for external tracks
if (t.isManualMapping && t.manualMappingType === 'external') {
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
@@ -2740,7 +2808,7 @@
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
} else {
// isLocal is null/undefined - track is missing (not found locally or externally)
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:var(--bg-tertiary);color:var(--text-secondary);"><span class="status-dot" style="background:var(--text-secondary);"></span>Missing</span>';
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:rgba(245, 158, 11, 0.2);color:#f59e0b;"><span class="status-dot" style="background:#f59e0b;"></span>Missing</span>';
// Add both mapping buttons for missing tracks
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
@@ -2887,6 +2955,28 @@
}
}
// Cache setting editor (uses appsettings.json instead of .env)
function openEditCacheSetting(settingKey, label, helpText) {
currentEditKey = settingKey;
currentEditType = 'number';
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
document.getElementById('edit-setting-label').textContent = label;
const helpEl = document.getElementById('edit-setting-help');
if (helpText) {
helpEl.textContent = helpText + ' (Requires restart to apply)';
helpEl.style.display = 'block';
} else {
helpEl.style.display = 'none';
}
const container = document.getElementById('edit-setting-input-container');
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value" min="1">`;
openModal('edit-setting-modal');
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
+11
View File
@@ -73,6 +73,17 @@ services:
- Redis__ConnectionString=redis:6379
- Redis__Enabled=${REDIS_ENABLED:-true}
# ===== CACHE TTL SETTINGS =====
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-120}
- Cache__PlaylistImagesHours=${CACHE_PLAYLIST_IMAGES_HOURS:-168}
- Cache__SpotifyPlaylistItemsHours=${CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS:-168}
- Cache__SpotifyMatchedTracksDays=${CACHE_SPOTIFY_MATCHED_TRACKS_DAYS:-30}
- Cache__LyricsDays=${CACHE_LYRICS_DAYS:-14}
- Cache__GenreDays=${CACHE_GENRE_DAYS:-30}
- Cache__MetadataDays=${CACHE_METADATA_DAYS:-7}
- Cache__OdesliLookupDays=${CACHE_ODESLI_LOOKUP_DAYS:-60}
- Cache__ProxyImagesDays=${CACHE_PROXY_IMAGES_DAYS:-14}
# ===== SUBSONIC BACKEND =====
- Subsonic__Url=${SUBSONIC_URL:-http://localhost:4533}
- Subsonic__ExplicitFilter=${EXPLICIT_FILTER:-ExplicitOnly}