mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 10:42:37 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ebdd8d4e2a
|
|||
|
e4599a419e
|
+49
-8
@@ -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
|
||||
|
||||
+3
-15
@@ -83,21 +83,9 @@ cache/
|
||||
# Docker volumes
|
||||
redis-data/
|
||||
|
||||
# API keys and specs (ignore markdown docs, keep OpenAPI spec)
|
||||
apis/steering/
|
||||
apis/api-calls/*.json
|
||||
!apis/api-calls/jellyfin-openapi-stable.json
|
||||
apis/temp.json
|
||||
|
||||
# Temporary documentation files
|
||||
apis/*.md
|
||||
|
||||
# Log files for debugging
|
||||
apis/api-calls/*.log
|
||||
|
||||
# Endpoint usage tracking
|
||||
apis/api-calls/endpoint-usage.json
|
||||
/app/cache/endpoint-usage/
|
||||
# Ignore everything in apis folder except jellyfin-openapi-stable.json
|
||||
apis/*
|
||||
!apis/jellyfin-openapi-stable.json
|
||||
|
||||
# Original source code for reference
|
||||
originals/
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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))
|
||||
@@ -917,13 +951,14 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Trigger track matching for a specific playlist
|
||||
/// Re-match tracks when LOCAL library has changed (checks if Jellyfin playlist changed).
|
||||
/// This is a lightweight operation that reuses cached Spotify data.
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/match")]
|
||||
public async Task<IActionResult> MatchPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Name}", decodedName);
|
||||
_logger.LogInformation("Re-match tracks triggered for playlist: {Name} (checking for local changes)", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
@@ -932,12 +967,31 @@ public class AdminController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
// Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed
|
||||
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}";
|
||||
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
|
||||
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
|
||||
|
||||
// Clear the matched results cache to force re-matching
|
||||
var matchedTracksKey = $"spotify:matched:ordered:{decodedName}";
|
||||
await _cache.DeleteAsync(matchedTracksKey);
|
||||
_logger.LogDebug("Cleared matched tracks cache");
|
||||
|
||||
// Clear the playlist items cache
|
||||
var playlistItemsCacheKey = $"spotify:playlist:items:{decodedName}";
|
||||
await _cache.DeleteAsync(playlistItemsCacheKey);
|
||||
_logger.LogDebug("Cleared playlist items cache");
|
||||
|
||||
// Trigger matching (will use cached Spotify data if still valid)
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
InvalidatePlaylistSummaryCache();
|
||||
|
||||
return Ok(new { message = $"Track matching triggered for {decodedName}", timestamp = DateTime.UtcNow });
|
||||
return Ok(new {
|
||||
message = $"Re-matching tracks for {decodedName} (checking local changes)",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -947,13 +1001,14 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear cache and rebuild for a specific playlist
|
||||
/// Rebuild playlist from scratch when REMOTE (Spotify) playlist has changed.
|
||||
/// Clears all caches including Spotify data and forces fresh fetch.
|
||||
/// </summary>
|
||||
[HttpPost("playlists/{name}/clear-cache")]
|
||||
public async Task<IActionResult> ClearPlaylistCache(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
_logger.LogInformation("Clear cache & rebuild triggered for playlist: {Name}", decodedName);
|
||||
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (clearing Spotify cache)", decodedName);
|
||||
|
||||
if (_matchingService == null)
|
||||
{
|
||||
@@ -962,13 +1017,15 @@ public class AdminController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
// Clear all cache keys for this playlist
|
||||
// Clear ALL cache keys for this playlist (including Spotify data)
|
||||
var cacheKeys = new[]
|
||||
{
|
||||
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
|
||||
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
|
||||
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
||||
$"spotify:missing:{decodedName}" // Missing tracks
|
||||
$"spotify:playlist:items:{decodedName}", // Pre-built items cache
|
||||
$"spotify:matched:ordered:{decodedName}", // Ordered matched tracks
|
||||
$"spotify:matched:{decodedName}", // Legacy matched tracks
|
||||
$"spotify:missing:{decodedName}", // Missing tracks
|
||||
$"spotify:playlist:jellyfin-signature:{decodedName}", // Jellyfin signature
|
||||
$"spotify:playlist:{decodedName}" // Spotify playlist data
|
||||
};
|
||||
|
||||
foreach (var key in cacheKeys)
|
||||
@@ -994,9 +1051,9 @@ public class AdminController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Cleared all caches for playlist: {Name}", decodedName);
|
||||
_logger.LogInformation("✓ Cleared all caches for playlist: {Name} (including Spotify data)", decodedName);
|
||||
|
||||
// Trigger rebuild
|
||||
// Trigger rebuild (will fetch fresh Spotify data)
|
||||
await _matchingService.TriggerMatchingForPlaylistAsync(decodedName);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
@@ -1004,10 +1061,10 @@ public class AdminController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = $"Cache cleared and rebuild triggered for {decodedName}",
|
||||
message = $"Rebuilding {decodedName} from scratch (fetching fresh Spotify data)",
|
||||
timestamp = DateTime.UtcNow,
|
||||
clearedKeys = cacheKeys.Length,
|
||||
clearedFiles = filesToDelete.Count(System.IO.File.Exists)
|
||||
clearedFiles = filesToDelete.Count(f => System.IO.File.Exists(f))
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -1048,7 +1105,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 +1121,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 +1182,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 +1303,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 +1314,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 +1340,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 +1518,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 +1547,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 +1583,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 +1711,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 +1728,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 +1780,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 +1801,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 +2178,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 +2378,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 +2517,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 +2827,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 +2932,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 +3101,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 +3123,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 +3214,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 +3421,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 +3498,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 +3517,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 +3548,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 +3582,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 +3601,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 +3615,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 +3684,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,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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3497,26 +3497,26 @@ public class JellyfinController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// New mode: Gets playlist tracks with correct ordering using direct Spotify API data.
|
||||
/// Optimized to only re-match when Jellyfin playlist changes (cheap check).
|
||||
/// </summary>
|
||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName, string playlistId)
|
||||
{
|
||||
// Check Redis cache first for fast serving
|
||||
// Check if Jellyfin playlist has changed (cheap API call)
|
||||
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
|
||||
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
|
||||
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
||||
|
||||
var jellyfinPlaylistChanged = cachedJellyfinSignature != currentJellyfinSignature;
|
||||
|
||||
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
|
||||
var cacheKey = $"spotify:playlist:items:{spotifyPlaylistName}";
|
||||
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
||||
|
||||
if (cachedItems != null && cachedItems.Count > 0)
|
||||
if (cachedItems != null && cachedItems.Count > 0 && !jellyfinPlaylistChanged)
|
||||
{
|
||||
_logger.LogInformation("✅ Loaded {Count} playlist items from Redis cache for {Playlist}",
|
||||
_logger.LogDebug("✅ Loaded {Count} playlist items from Redis cache for {Playlist} (Jellyfin unchanged)",
|
||||
cachedItems.Count, spotifyPlaylistName);
|
||||
|
||||
// Log sample item to verify Spotify IDs are present
|
||||
if (cachedItems.Count > 0 && cachedItems[0].ContainsKey("ProviderIds"))
|
||||
{
|
||||
var providerIds = cachedItems[0]["ProviderIds"] as Dictionary<string, object>;
|
||||
var hasSpotifyId = providerIds?.ContainsKey("Spotify") ?? false;
|
||||
_logger.LogDebug("Sample cached item has Spotify ID: {HasSpotifyId}", hasSpotifyId);
|
||||
}
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = cachedItems,
|
||||
@@ -3525,15 +3525,20 @@ public class JellyfinController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
if (jellyfinPlaylistChanged)
|
||||
{
|
||||
_logger.LogInformation("🔄 Jellyfin playlist changed for {Playlist} - re-matching tracks", spotifyPlaylistName);
|
||||
}
|
||||
|
||||
// Check file cache as fallback
|
||||
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 +3554,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 +3648,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 +3732,17 @@ 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);
|
||||
|
||||
// Cache the Jellyfin playlist signature to detect future changes
|
||||
await _cache.SetAsync(jellyfinSignatureCacheKey, currentJellyfinSignature, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||
|
||||
// Return raw Jellyfin response format
|
||||
return new JsonResult(new
|
||||
@@ -3756,7 +3763,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 +3774,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 +3792,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 +3837,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 +3849,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 +3908,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 +3932,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 +4001,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 +4015,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 +4036,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 +4097,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 +4136,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 +4162,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 +4198,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 +4243,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 +4277,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 +4295,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 +4324,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 +4348,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 +4362,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 +4374,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;
|
||||
}
|
||||
}
|
||||
@@ -4397,6 +4404,54 @@ public class JellyfinController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a signature (hash) of the Jellyfin playlist to detect changes.
|
||||
/// This is a cheap operation compared to re-matching all tracks.
|
||||
/// Signature includes: track count + concatenated track IDs.
|
||||
/// </summary>
|
||||
private async Task<string> GetJellyfinPlaylistSignatureAsync(string playlistId)
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = _settings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
playlistItemsUrl += $"&UserId={userId}";
|
||||
}
|
||||
|
||||
var (response, _) = await _proxyService.GetJsonAsync(playlistItemsUrl, null, Request.Headers);
|
||||
|
||||
if (response != null && response.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
var trackIds = new List<string>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
if (item.TryGetProperty("Id", out var idEl))
|
||||
{
|
||||
trackIds.Add(idEl.GetString() ?? "");
|
||||
}
|
||||
}
|
||||
|
||||
// Create signature: count + sorted IDs (sorted for consistency)
|
||||
trackIds.Sort();
|
||||
var signature = $"{trackIds.Count}:{string.Join(",", trackIds)}";
|
||||
|
||||
// Hash it to keep it compact
|
||||
using var sha256 = System.Security.Cryptography.SHA256.Create();
|
||||
var hashBytes = sha256.ComputeHash(System.Text.Encoding.UTF8.GetBytes(signature));
|
||||
return Convert.ToHexString(hashBytes);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to get Jellyfin playlist signature for {PlaylistId}", playlistId);
|
||||
}
|
||||
|
||||
// Return empty string if failed (will trigger re-match)
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
@@ -4413,7 +4468,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 +4498,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 +4508,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 +4672,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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,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; }
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
{
|
||||
@@ -309,9 +267,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
_logger.LogInformation("========================================");
|
||||
|
||||
// Initial fetch of all playlists on startup
|
||||
await FetchAllPlaylistsAsync(stoppingToken);
|
||||
|
||||
// Cron-based refresh loop - only fetch when cron schedule triggers
|
||||
// This prevents excess Spotify API calls
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
@@ -374,7 +329,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("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
}
|
||||
}
|
||||
@@ -409,7 +364,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 +389,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("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
|
||||
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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
+110
-20
@@ -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;
|
||||
@@ -654,9 +655,9 @@
|
||||
<h2>
|
||||
Injected Spotify Playlists
|
||||
<div class="actions">
|
||||
<button onclick="matchAllPlaylists()" title="Match tracks for all playlists against your local library and external providers. This may take several minutes.">Match All Tracks</button>
|
||||
<button onclick="matchAllPlaylists()" title="Re-match tracks when local library changed (uses cached Spotify data)">Re-match All Local</button>
|
||||
<button onclick="refreshPlaylists()" title="Fetch the latest playlist data from Spotify without re-matching tracks.">Refresh All</button>
|
||||
<button onclick="refreshAndMatchAll()" title="Clear caches, fetch fresh data from Spotify, and match all tracks. This is a full rebuild and may take several minutes." style="background:var(--accent);border-color:var(--accent);">Refresh & Match All</button>
|
||||
<button onclick="refreshAndMatchAll()" title="Rebuild all playlists when Spotify playlists changed (fetches fresh data and re-matches)" style="background:var(--accent);border-color:var(--accent);">Rebuild All Remote</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
@@ -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,16 +1708,16 @@
|
||||
<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>
|
||||
</td>
|
||||
<td class="cache-age">${p.cacheAge || '-'}</td>
|
||||
<td>
|
||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')">Clear Cache & Rebuild</button>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')">Match Tracks</button>
|
||||
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')" title="Re-match when local library changed (uses cached Spotify data)">Re-match Local</button>
|
||||
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')" title="Rebuild when Spotify playlist changed (fetches fresh data)" style="background:var(--accent);border-color:var(--accent);">Rebuild Remote</button>
|
||||
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
|
||||
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
|
||||
</td>
|
||||
@@ -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);
|
||||
}
|
||||
@@ -2290,18 +2358,18 @@
|
||||
}
|
||||
|
||||
async function clearPlaylistCache(name) {
|
||||
if (!confirm(`Clear cache and rebuild for "${name}"?\n\nThis will:\n• Clear Redis cache\n• Delete file caches\n• Rebuild with latest Spotify IDs\n\nThis may take a minute.`)) return;
|
||||
if (!confirm(`Rebuild "${name}" from scratch?\n\nThis will:\n• Fetch fresh Spotify playlist data\n• Clear all caches\n• Re-match all tracks\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`)) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast(`Clearing cache for ${name}...`, 'info');
|
||||
showToast(`Rebuilding ${name} from scratch...`, 'info');
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
if (res.ok) {
|
||||
showToast(`✓ ${data.message} (Cleared ${data.clearedKeys} cache keys, ${data.clearedFiles} files)`, 'success', 5000);
|
||||
showToast(`✓ ${data.message}`, 'success', 5000);
|
||||
// Refresh the playlists table after a delay to show updated counts
|
||||
setTimeout(() => {
|
||||
fetchPlaylists();
|
||||
@@ -2309,7 +2377,7 @@
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 3000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to clear cache', 'error');
|
||||
showToast(data.error || 'Failed to rebuild playlist', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -2323,7 +2391,7 @@
|
||||
// Show warning banner
|
||||
document.getElementById('matching-warning-banner').style.display = 'block';
|
||||
|
||||
showToast(`Matching tracks for ${name}...`, 'success');
|
||||
showToast(`Re-matching local tracks for ${name}...`, 'info');
|
||||
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
|
||||
const data = await res.json();
|
||||
|
||||
@@ -2336,17 +2404,17 @@
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}, 2000);
|
||||
} else {
|
||||
showToast(data.error || 'Failed to match tracks', 'error');
|
||||
showToast(data.error || 'Failed to re-match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
} catch (error) {
|
||||
showToast('Failed to match tracks', 'error');
|
||||
showToast('Failed to re-match tracks', 'error');
|
||||
document.getElementById('matching-warning-banner').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function matchAllPlaylists() {
|
||||
if (!confirm('Match tracks for ALL playlists? This may take a few minutes.')) return;
|
||||
if (!confirm('Re-match local tracks for ALL playlists?\n\nUse this when your local library has changed.\n\nThis may take a few minutes.')) return;
|
||||
|
||||
try {
|
||||
// Show warning banner
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user