Lyrics and favorites improvements
Some checks failed
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled

- Fix unsynced lyrics displaying all at 0:00 by omitting Start field
- Improve LRC parsing with fuzzy search and synced lyrics preference
- Add DeleteAsync to proxy service for proper auth forwarding
- Support both /UserFavoriteItems and /Users/{userId}/FavoriteItems routes
- Fix favorite endpoints to properly forward authentication
- Add comprehensive logging for debugging
- Extend Spotify sync backward search window to 24 hours
This commit is contained in:
2026-02-01 18:32:52 -05:00
parent 425dc2e135
commit 6ea2331127
8 changed files with 946 additions and 130 deletions

View File

@@ -30,6 +30,12 @@ MUSIC_SERVICE=SquidWTF
# Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent) # Path where downloaded songs will be stored on the host (only applies if STORAGE_MODE=Permanent)
DOWNLOAD_PATH=./downloads DOWNLOAD_PATH=./downloads
# Path where favorited external tracks are permanently kept
KEPT_PATH=./kept
# Path for cache files (Spotify missing tracks, etc.)
CACHE_PATH=./cache
# ===== SQUIDWTF CONFIGURATION ===== # ===== SQUIDWTF CONFIGURATION =====
# Different quality options for SquidWTF. Only FLAC supported right now # Different quality options for SquidWTF. Only FLAC supported right now
SQUIDWTF_QUALITY=FLAC SQUIDWTF_QUALITY=FLAC

View File

@@ -83,6 +83,7 @@ This project brings together all the music streaming providers into one unified
- **Transparent Proxy**: Sits between your music clients and media server - **Transparent Proxy**: Sits between your music clients and media server
- **Automatic Search**: Searches streaming providers when songs aren't local - **Automatic Search**: Searches streaming providers when songs aren't local
- **On-the-Fly Downloads**: Songs download and cache for future use - **On-the-Fly Downloads**: Songs download and cache for future use
- **Favorite to Keep**: When you favorite an external track, it's automatically copied to a permanent `/kept` folder separate from the cache
- **External Playlist Support**: Search and download playlists from Deezer, Qobuz, and SquidWTF with M3U generation - **External Playlist Support**: Search and download playlists from Deezer, Qobuz, and SquidWTF with M3U generation
- **Hi-Res Audio**: SquidWTF supports up to 24-bit/192kHz FLAC - **Hi-Res Audio**: SquidWTF supports up to 24-bit/192kHz FLAC
- **Full Metadata**: Downloaded files include complete ID3 tags (title, artist, album, track number, year, genre, BPM, ISRC, etc.) and cover art - **Full Metadata**: Downloaded files include complete ID3 tags (title, artist, album, track number, year, genre, BPM, ISRC, etc.) and cover art

View File

@@ -85,7 +85,7 @@ public class JellyfinProxyServiceTests
} }
[Fact] [Fact]
public async Task GetJsonAsync_IncludesAuthHeader() public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
{ {
// Arrange // Arrange
HttpRequestMessage? captured = null; HttpRequestMessage? captured = null;
@@ -102,13 +102,10 @@ public class JellyfinProxyServiceTests
// Act // Act
await _service.GetJsonAsync("Items"); await _service.GetJsonAsync("Items");
// Assert // Assert - Should NOT include auth when no client headers provided
Assert.NotNull(captured); Assert.NotNull(captured);
Assert.True(captured!.Headers.Contains("Authorization")); Assert.False(captured!.Headers.Contains("Authorization"));
var authHeader = captured.Headers.GetValues("Authorization").First(); Assert.False(captured.Headers.Contains("X-Emby-Authorization"));
Assert.Contains("MediaBrowser", authHeader);
Assert.Contains(_settings.ApiKey!, authHeader);
Assert.Contains(_settings.ClientName!, authHeader);
} }
[Fact] [Fact]

View File

@@ -121,6 +121,13 @@ public class JellyfinController : ControllerBase
return Unauthorized(new { error = "Authentication required" }); return Unauthorized(new { error = "Authentication required" });
} }
// 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");
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
}
var result = JsonSerializer.Deserialize<object>(browseResult.RootElement.GetRawText()); var result = JsonSerializer.Deserialize<object>(browseResult.RootElement.GetRawText());
if (_logger.IsEnabled(LogLevel.Debug)) if (_logger.IsEnabled(LogLevel.Debug))
{ {
@@ -1026,15 +1033,21 @@ public class JellyfinController : ControllerBase
var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? ""; var lyricsText = lyrics.SyncedLyrics ?? lyrics.PlainLyrics ?? "";
var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics); var isSynced = !string.IsNullOrEmpty(lyrics.SyncedLyrics);
_logger.LogInformation("Lyrics for {Artist} - {Track}: synced={HasSynced}, plainLength={PlainLen}, syncedLength={SyncLen}",
song.Artist, song.Title, isSynced, lyrics.PlainLyrics?.Length ?? 0, lyrics.SyncedLyrics?.Length ?? 0);
// Parse LRC format into individual lines for Jellyfin // Parse LRC format into individual lines for Jellyfin
var lyricLines = new List<object>(); var lyricLines = new List<Dictionary<string, object>>();
if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics)) if (isSynced && !string.IsNullOrEmpty(lyrics.SyncedLyrics))
{ {
_logger.LogInformation("Parsing synced lyrics (LRC format)");
// Parse LRC format: [mm:ss.xx] text // Parse LRC format: [mm:ss.xx] text
// Skip ID tags like [ar:Artist], [ti:Title], etc.
var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries); var lines = lyrics.SyncedLyrics.Split('\n', StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines) foreach (var line in lines)
{ {
// Match timestamp format [mm:ss.xx] or [mm:ss.xxx]
var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$"); var match = System.Text.RegularExpressions.Regex.Match(line, @"^\[(\d+):(\d+)\.(\d+)\]\s*(.*)$");
if (match.Success) if (match.Success)
{ {
@@ -1047,21 +1060,40 @@ public class JellyfinController : ControllerBase
var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10; var totalMilliseconds = (minutes * 60 + seconds) * 1000 + centiseconds * 10;
var ticks = totalMilliseconds * 10000L; var ticks = totalMilliseconds * 10000L;
lyricLines.Add(new // For synced lyrics, include Start timestamp
lyricLines.Add(new Dictionary<string, object>
{ {
Start = ticks, ["Text"] = text,
Text = text ["Start"] = ticks
}); });
} }
// Skip ID tags like [ar:Artist], [ti:Title], [length:2:23], etc.
} }
_logger.LogInformation("Parsed {Count} synced lyric lines (skipped ID tags)", lyricLines.Count);
}
else if (!string.IsNullOrEmpty(lyricsText))
{
_logger.LogInformation("Splitting plain lyrics into lines (no timestamps)");
// Plain lyrics - split by newlines and return each line separately
// IMPORTANT: Do NOT include "Start" field at all for unsynced lyrics
// Including it (even as null) causes clients to treat it as synced with timestamp 0:00
var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
lyricLines.Add(new Dictionary<string, object>
{
["Text"] = line.Trim()
});
}
_logger.LogInformation("Split into {Count} plain lyric lines", lyricLines.Count);
} }
else else
{ {
// Plain lyrics - return as single block _logger.LogWarning("No lyrics text available");
lyricLines.Add(new // No lyrics at all
lyricLines.Add(new Dictionary<string, object>
{ {
Start = (long?)null, ["Text"] = ""
Text = lyricsText
}); });
} }
@@ -1078,6 +1110,17 @@ public class JellyfinController : ControllerBase
Lyrics = lyricLines Lyrics = lyricLines
}; };
_logger.LogInformation("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}",
sampleLine.GetValueOrDefault("Text"), hasStart);
}
return Ok(response); return Ok(response);
} }
@@ -1087,10 +1130,21 @@ public class JellyfinController : ControllerBase
/// <summary> /// <summary>
/// Marks an item as favorite. For playlists, triggers a full download. /// Marks an item as favorite. For playlists, triggers a full download.
/// Supports both /Users/{userId}/FavoriteItems/{itemId} and /UserFavoriteItems/{itemId}?userId=xxx
/// </summary> /// </summary>
[HttpPost("Users/{userId}/FavoriteItems/{itemId}")] [HttpPost("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> MarkFavorite(string userId, string itemId) [HttpPost("UserFavoriteItems/{itemId}")]
public async Task<IActionResult> MarkFavorite(string itemId, string? userId = null)
{ {
// Get userId from query string if not in path
if (string.IsNullOrEmpty(userId))
{
userId = Request.Query["userId"].ToString();
}
_logger.LogInformation("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// Check if this is an external playlist - trigger download // Check if this is an external playlist - trigger download
if (PlaylistIdHelper.IsExternalPlaylist(itemId)) if (PlaylistIdHelper.IsExternalPlaylist(itemId))
{ {
@@ -1114,97 +1168,115 @@ public class JellyfinController : ControllerBase
} }
}); });
return Ok(new { IsFavorite = true }); // Return a minimal UserItemDataDto response
return Ok(new
{
IsFavorite = true,
ItemId = itemId
});
} }
// Check if this is an external song/album // Check if this is an external song/album
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId); var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal) if (isExternal)
{ {
// External items don't exist in Jellyfin, so we can't favorite them there _logger.LogInformation("Favoriting external item {ItemId}, copying to kept folder", itemId);
// Just return success - the client will show it as favorited
_logger.LogDebug("Favoriting external item {ItemId} (not synced to Jellyfin)", itemId);
return Ok(new { IsFavorite = true });
}
// For local Jellyfin items, proxy the request through
var endpoint = $"Users/{userId}/FavoriteItems/{itemId}";
// Copy the track to kept folder in background
_ = Task.Run(async () =>
{
try try
{ {
using var request = new HttpRequestMessage(HttpMethod.Post, $"{_settings.Url?.TrimEnd('/')}/{endpoint}"); await CopyExternalTrackToKeptAsync(itemId, provider!, externalId!);
// Forward client authentication
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
}
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
}
var response = await _proxyService.HttpClient.SendAsync(request);
if (response.IsSuccessStatusCode)
{
return Ok(new { IsFavorite = true });
}
_logger.LogWarning("Failed to favorite item in Jellyfin: {StatusCode}", response.StatusCode);
return _responseBuilder.CreateError((int)response.StatusCode, "Failed to mark favorite");
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error favoriting item {ItemId}", itemId); _logger.LogError(ex, "Failed to copy external track {ItemId} to kept folder", itemId);
return _responseBuilder.CreateError(500, "Failed to mark favorite");
} }
});
// Return a minimal UserItemDataDto response
return Ok(new
{
IsFavorite = true,
ItemId = itemId
});
}
// For local Jellyfin items, proxy the request through
// Use the official Jellyfin endpoint format
var endpoint = $"UserFavoriteItems/{itemId}";
if (!string.IsNullOrEmpty(userId))
{
endpoint = $"{endpoint}?userId={userId}";
}
_logger.LogInformation("Proxying favorite request to Jellyfin: {Endpoint}", endpoint);
var result = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers);
if (result == null)
{
_logger.LogWarning("Failed to favorite item in Jellyfin - proxy returned null");
return StatusCode(500, new { error = "Failed to mark favorite" });
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
/// <summary> /// <summary>
/// Removes an item from favorites. /// Removes an item from favorites.
/// Supports both /Users/{userId}/FavoriteItems/{itemId} and /UserFavoriteItems/{itemId}?userId=xxx
/// </summary> /// </summary>
[HttpDelete("Users/{userId}/FavoriteItems/{itemId}")] [HttpDelete("Users/{userId}/FavoriteItems/{itemId}")]
public async Task<IActionResult> UnmarkFavorite(string userId, string itemId) [HttpDelete("UserFavoriteItems/{itemId}")]
public async Task<IActionResult> UnmarkFavorite(string itemId, string? userId = null)
{ {
// External items can't be unfavorited // Get userId from query string if not in path
if (string.IsNullOrEmpty(userId))
{
userId = Request.Query["userId"].ToString();
}
_logger.LogInformation("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}",
userId, itemId, Request.Path);
// External items can't be unfavorited (they're not really favorited in Jellyfin)
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId); var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId)) if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId))
{ {
return Ok(new { IsFavorite = false }); _logger.LogInformation("Unfavoriting external item {ItemId} - returning success", itemId);
return Ok(new
{
IsFavorite = false,
ItemId = itemId
});
} }
// Proxy to Jellyfin to unfavorite // Proxy to Jellyfin to unfavorite
var url = $"Users/{userId}/FavoriteItems/{itemId}"; // Use the official Jellyfin endpoint format
var endpoint = $"UserFavoriteItems/{itemId}";
try if (!string.IsNullOrEmpty(userId))
{ {
using var request = new HttpRequestMessage(HttpMethod.Delete, $"{_settings.Url?.TrimEnd('/')}/{url}"); endpoint = $"{endpoint}?userId={userId}";
// Forward client authentication
if (Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", embyAuth.ToString());
}
else if (Request.Headers.TryGetValue("Authorization", out var auth))
{
request.Headers.TryAddWithoutValidation("Authorization", auth.ToString());
} }
var response = await _proxyService.HttpClient.SendAsync(request); _logger.LogInformation("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint);
if (response.IsSuccessStatusCode) var result = await _proxyService.DeleteAsync(endpoint, Request.Headers);
if (result == null)
{ {
return Ok(new { IsFavorite = false }); // DELETE often returns 204 No Content, which is success
_logger.LogInformation("Unfavorite succeeded (no content returned)");
return Ok(new
{
IsFavorite = false,
ItemId = itemId
});
} }
return _responseBuilder.CreateError(500, "Failed to unfavorite item"); return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
}
catch (Exception ex)
{
_logger.LogError(ex, "Error unfavoriting item {ItemId}", itemId);
return _responseBuilder.CreateError(500, "Failed to unfavorite item");
}
} }
#endregion #endregion
@@ -1650,6 +1722,7 @@ public class JellyfinController : ControllerBase
/// <summary> /// <summary>
/// Catch-all endpoint that proxies unhandled requests to Jellyfin transparently. /// Catch-all endpoint that proxies unhandled requests to Jellyfin transparently.
/// This route has the lowest priority and should only match requests that don't have SearchTerm. /// This route has the lowest priority and should only match requests that don't have SearchTerm.
/// Blocks dangerous admin endpoints for security.
/// </summary> /// </summary>
[HttpGet("{**path}", Order = 100)] [HttpGet("{**path}", Order = 100)]
[HttpPost("{**path}", Order = 100)] [HttpPost("{**path}", Order = 100)]
@@ -1658,6 +1731,42 @@ public class JellyfinController : ControllerBase
// DEBUG: Log EVERY request to see what's happening // DEBUG: Log EVERY request to see what's happening
_logger.LogWarning("ProxyRequest called with path: {Path}", path); _logger.LogWarning("ProxyRequest called with path: {Path}", path);
// Log endpoint usage to file for analysis
await LogEndpointUsageAsync(path, Request.Method);
// Block dangerous admin endpoints
var blockedPrefixes = new[]
{
"system/restart", // Server restart
"system/shutdown", // Server shutdown
"system/configuration", // System configuration changes
"system/logs", // Server logs access
"system/activitylog", // Activity log access
"plugins/", // Plugin management (install/uninstall/configure)
"scheduledtasks/", // Scheduled task management
"startup/", // Initial server setup
"users/new", // User creation
"library/refresh", // Library scan (expensive operation)
"library/virtualfolders", // Library folder management
"branding/", // Branding configuration
"displaypreferences/", // Display preferences (if not user-specific)
"notifications/admin" // Admin notifications
};
// Check if path matches any blocked prefix
if (blockedPrefixes.Any(prefix =>
path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)))
{
_logger.LogWarning("BLOCKED: Access denied to admin endpoint: {Path} from {IP}",
path,
HttpContext.Connection.RemoteIpAddress);
return StatusCode(403, new
{
error = "Access to administrative endpoints is not allowed through this proxy",
path = path
});
}
// Intercept Spotify playlist requests by ID // Intercept Spotify playlist requests by ID
if (_spotifySettings.Enabled && if (_spotifySettings.Enabled &&
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) && path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
@@ -1812,6 +1921,13 @@ public class JellyfinController : ControllerBase
return NoContent(); return NoContent();
} }
// Modify response if it contains Spotify playlists to update ChildCount
if (_spotifySettings.Enabled && result.RootElement.TryGetProperty("Items", out var items))
{
_logger.LogInformation("Response has Items property, checking for Spotify playlists to update counts");
result = await UpdateSpotifyPlaylistCounts(result);
}
return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText())); return new JsonResult(JsonSerializer.Deserialize<object>(result.RootElement.GetRawText()));
} }
catch (Exception ex) catch (Exception ex)
@@ -1825,6 +1941,141 @@ public class JellyfinController : ControllerBase
#region Helpers #region Helpers
/// <summary>
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
/// </summary>
private async Task<JsonDocument> UpdateSpotifyPlaylistCounts(JsonDocument response)
{
try
{
if (!response.RootElement.TryGetProperty("Items", out var items))
{
return response;
}
var itemsArray = items.EnumerateArray().ToList();
var modified = false;
var updatedItems = new List<Dictionary<string, object>>();
_logger.LogInformation("Checking {Count} items for Spotify playlists", itemsArray.Count);
foreach (var item in itemsArray)
{
var itemDict = JsonSerializer.Deserialize<Dictionary<string, object>>(item.GetRawText());
if (itemDict == null)
{
continue;
}
// Check if this is a Spotify playlist
if (item.TryGetProperty("Id", out var idProp))
{
var playlistId = idProp.GetString();
_logger.LogDebug("Checking item with ID: {Id}", playlistId);
if (!string.IsNullOrEmpty(playlistId) &&
_spotifySettings.PlaylistIds.Any(id => id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)))
{
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
// This is a Spotify playlist - get the actual track count
var playlistIndex = _spotifySettings.PlaylistIds.FindIndex(id =>
id.Equals(playlistId, StringComparison.OrdinalIgnoreCase));
if (playlistIndex >= 0 && playlistIndex < _spotifySettings.PlaylistNames.Count)
{
var playlistName = _spotifySettings.PlaylistNames[playlistIndex];
var missingTracksKey = $"spotify:missing:{playlistName}";
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
_logger.LogInformation("Cache lookup for {Key}: {Count} tracks",
missingTracksKey, missingTracks?.Count ?? 0);
// Fallback to file cache
if (missingTracks == null || missingTracks.Count == 0)
{
_logger.LogInformation("Trying file cache for {Name}", playlistName);
missingTracks = await LoadMissingTracksFromFile(playlistName);
_logger.LogInformation("File cache result: {Count} tracks", missingTracks?.Count ?? 0);
}
if (missingTracks != null && missingTracks.Count > 0)
{
// Update ChildCount to show the number of tracks we'll provide
itemDict["ChildCount"] = missingTracks.Count;
modified = true;
_logger.LogInformation("✓ Updated ChildCount for Spotify playlist {Name} to {Count}",
playlistName, missingTracks.Count);
}
else
{
_logger.LogWarning("No missing tracks found for {Name}", playlistName);
}
}
}
}
updatedItems.Add(itemDict);
}
if (!modified)
{
_logger.LogInformation("No Spotify playlists found to update");
return response;
}
_logger.LogInformation("Modified {Count} Spotify playlists, rebuilding response",
updatedItems.Count(i => i.ContainsKey("ChildCount")));
// Rebuild the response with updated items
var responseDict = JsonSerializer.Deserialize<Dictionary<string, object>>(response.RootElement.GetRawText());
if (responseDict != null)
{
responseDict["Items"] = updatedItems;
var updatedJson = JsonSerializer.Serialize(responseDict);
return JsonDocument.Parse(updatedJson);
}
return response;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to update Spotify playlist counts");
return response;
}
}
/// <summary>
/// Logs endpoint usage to a file for analysis.
/// Creates a CSV file with timestamp, method, path, and query string.
/// </summary>
private async Task LogEndpointUsageAsync(string path, string method)
{
try
{
var logDir = "/app/cache/endpoint-usage";
Directory.CreateDirectory(logDir);
var logFile = Path.Combine(logDir, "endpoints.csv");
var timestamp = DateTime.UtcNow.ToString("yyyy-MM-dd HH:mm:ss");
var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : "";
// Sanitize path and query for CSV (remove commas, quotes, newlines)
var sanitizedPath = path.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
var sanitizedQuery = queryString.Replace(",", ";").Replace("\"", "'").Replace("\n", " ").Replace("\r", " ");
var logLine = $"{timestamp},{method},{sanitizedPath},{sanitizedQuery}\n";
// Append to file (thread-safe)
await System.IO.File.AppendAllTextAsync(logFile, logLine);
}
catch (Exception ex)
{
// Don't let logging failures break the request
_logger.LogDebug(ex, "Failed to log endpoint usage");
}
}
private static string[]? ParseItemTypes(string? includeItemTypes) private static string[]? ParseItemTypes(string? includeItemTypes)
{ {
if (string.IsNullOrWhiteSpace(includeItemTypes)) if (string.IsNullOrWhiteSpace(includeItemTypes))
@@ -1962,6 +2213,20 @@ public class JellyfinController : ControllerBase
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}"; var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey); var missingTracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(missingTracksKey);
// Fallback to file cache if Redis is empty
if (missingTracks == null || missingTracks.Count == 0)
{
missingTracks = await LoadMissingTracksFromFile(spotifyPlaylistName);
// If we loaded from file, restore to Redis
if (missingTracks != null && missingTracks.Count > 0)
{
await _cache.SetAsync(missingTracksKey, missingTracks, TimeSpan.FromHours(24));
_logger.LogInformation("Restored {Count} missing tracks from file cache for {Playlist}",
missingTracks.Count, spotifyPlaylistName);
}
}
if (missingTracks == null || missingTracks.Count == 0) if (missingTracks == null || missingTracks.Count == 0)
{ {
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks", _logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
@@ -2069,11 +2334,116 @@ public class JellyfinController : ControllerBase
} }
} }
/// <summary>
/// Copies an external track to the kept folder when favorited.
/// </summary>
private async Task CopyExternalTrackToKeptAsync(string itemId, string provider, string externalId)
{
try
{
// Get the song metadata
var song = await _metadataService.GetSongAsync(provider, externalId);
if (song == null)
{
_logger.LogWarning("Could not find song metadata for {ItemId}", itemId);
return;
}
// Trigger download first
_logger.LogInformation("Downloading track for kept folder: {ItemId}", itemId);
string downloadPath;
try
{
downloadPath = await _downloadService.DownloadSongAsync(provider, externalId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to download track {ItemId}", itemId);
return;
}
// Create kept folder structure: /app/kept/Artist/Album/
var keptBasePath = "/app/kept";
var keptArtistPath = Path.Combine(keptBasePath, PathHelper.SanitizeFileName(song.Artist));
var keptAlbumPath = Path.Combine(keptArtistPath, PathHelper.SanitizeFileName(song.Album));
Directory.CreateDirectory(keptAlbumPath);
// Copy file to kept folder
var fileName = Path.GetFileName(downloadPath);
var keptFilePath = Path.Combine(keptAlbumPath, fileName);
if (System.IO.File.Exists(keptFilePath))
{
_logger.LogInformation("Track already exists in kept folder: {Path}", keptFilePath);
return;
}
System.IO.File.Copy(downloadPath, keptFilePath, overwrite: false);
_logger.LogInformation("✓ Copied favorited track to kept folder: {Path}", keptFilePath);
// Also copy cover art if it exists
var coverPath = Path.Combine(Path.GetDirectoryName(downloadPath)!, "cover.jpg");
if (System.IO.File.Exists(coverPath))
{
var keptCoverPath = Path.Combine(keptAlbumPath, "cover.jpg");
if (!System.IO.File.Exists(keptCoverPath))
{
System.IO.File.Copy(coverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("Copied cover art to kept folder");
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error copying external track {ItemId} to kept folder", itemId);
}
}
/// <summary>
/// Loads missing tracks from file cache as fallback when Redis is empty.
/// </summary>
private async Task<List<allstarr.Models.Spotify.MissingTrack>?> LoadMissingTracksFromFile(string playlistName)
{
try
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_missing.json");
if (!System.IO.File.Exists(filePath))
{
_logger.LogDebug("No file cache found for {Playlist} at {Path}", playlistName, filePath);
return null;
}
var fileAge = DateTime.UtcNow - System.IO.File.GetLastWriteTimeUtc(filePath);
if (fileAge > TimeSpan.FromHours(24))
{
_logger.LogDebug("File cache for {Playlist} is too old ({Age:F1}h)", playlistName, fileAge.TotalHours);
return null;
}
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)",
tracks?.Count ?? 0, playlistName, fileAge.TotalHours);
return tracks;
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load missing tracks from file for {Playlist}", playlistName);
return null;
}
}
/// <summary> /// <summary>
/// Manual trigger endpoint to force fetch Spotify missing tracks. /// Manual trigger endpoint to force fetch Spotify missing tracks.
/// GET /spotify/sync?api_key=YOUR_KEY /// GET /spotify/sync?api_key=YOUR_KEY
/// </summary> /// </summary>
[HttpGet("spotify/sync")] [HttpGet("spotify/sync", Order = 1)]
[ServiceFilter(typeof(ApiKeyAuthFilter))] [ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> TriggerSpotifySync() public async Task<IActionResult> TriggerSpotifySync()
{ {
@@ -2228,5 +2598,114 @@ public class JellyfinController : ControllerBase
} }
#endregion #endregion
#region Debug & Monitoring
/// <summary>
/// Gets endpoint usage statistics from the log file.
/// GET /debug/endpoint-usage?api_key=YOUR_KEY
/// Optional query params: top=50 (default 100), since=2024-01-01
/// </summary>
[HttpGet("debug/endpoint-usage")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> GetEndpointUsage(
[FromQuery] int top = 100,
[FromQuery] string? since = null)
{
try
{
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
if (!System.IO.File.Exists(logFile))
{
return Ok(new
{
message = "No endpoint usage data collected yet",
endpoints = Array.Empty<object>()
});
}
var lines = await System.IO.File.ReadAllLinesAsync(logFile);
// Parse CSV and filter by date if provided
DateTime? sinceDate = null;
if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate))
{
sinceDate = parsedDate;
}
var entries = lines
.Select(line => line.Split(','))
.Where(parts => parts.Length >= 3)
.Where(parts => !sinceDate.HasValue ||
(DateTime.TryParse(parts[0], out var entryDate) && entryDate >= sinceDate.Value))
.Select(parts => new
{
Timestamp = parts[0],
Method = parts.Length > 1 ? parts[1] : "",
Path = parts.Length > 2 ? parts[2] : "",
Query = parts.Length > 3 ? parts[3] : ""
})
.ToList();
// Group by path and count
var pathCounts = entries
.GroupBy(e => new { e.Method, e.Path })
.Select(g => new
{
Method = g.Key.Method,
Path = g.Key.Path,
Count = g.Count(),
FirstSeen = g.Min(e => e.Timestamp),
LastSeen = g.Max(e => e.Timestamp)
})
.OrderByDescending(x => x.Count)
.Take(top)
.ToList();
return Ok(new
{
totalRequests = entries.Count,
uniqueEndpoints = pathCounts.Count,
topEndpoints = pathCounts,
logFile = logFile,
logSize = new FileInfo(logFile).Length
});
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to get endpoint usage");
return StatusCode(500, new { error = ex.Message });
}
}
/// <summary>
/// Clears the endpoint usage log file.
/// DELETE /debug/endpoint-usage?api_key=YOUR_KEY
/// </summary>
[HttpDelete("debug/endpoint-usage")]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public IActionResult ClearEndpointUsage()
{
try
{
var logFile = "/app/cache/endpoint-usage/endpoints.csv";
if (System.IO.File.Exists(logFile))
{
System.IO.File.Delete(logFile);
return Ok(new { status = "success", message = "Endpoint usage log cleared" });
}
return Ok(new { status = "success", message = "No log file to clear" });
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to clear endpoint usage log");
return StatusCode(500, new { error = ex.Message });
}
}
#endregion
} }
// force rebuild Sun Jan 25 13:22:47 EST 2026 // force rebuild Sun Jan 25 13:22:47 EST 2026

View File

@@ -205,18 +205,11 @@ public class JellyfinProxyService
_logger.LogWarning("✗ No client headers provided for {Url}", url); _logger.LogWarning("✗ No client headers provided for {Url}", url);
} }
// Use API key if no valid client auth was found // DO NOT use server API key as fallback - let Jellyfin handle unauthenticated requests
// If client doesn't provide auth, they get what they deserve (401 from Jellyfin)
if (!authHeaderAdded) if (!authHeaderAdded)
{ {
if (!string.IsNullOrEmpty(_settings.ApiKey)) _logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
{
request.Headers.Add("Authorization", GetAuthorizationHeader());
_logger.LogInformation("→ Using API key for {Url}", url);
}
else
{
_logger.LogWarning("✗ No authentication available for {Url} - request will fail", url);
}
} }
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -333,16 +326,12 @@ public class JellyfinProxyService
} }
} }
// For non-auth requests without headers, use API key // DO NOT use server credentials as fallback
// For auth requests, client MUST provide their own client info // Exception: For auth endpoints, client provides their own credentials in the body
if (!authHeaderAdded && !endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase)) // For all other endpoints, if client doesn't provide auth, let Jellyfin reject it
if (!authHeaderAdded)
{ {
var clientAuthHeader = $"MediaBrowser Client=\"{_settings.ClientName}\", " + _logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
$"Device=\"{_settings.DeviceName}\", " +
$"DeviceId=\"{_settings.DeviceId}\", " +
$"Version=\"{_settings.ClientVersion}\"";
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", clientAuthHeader);
_logger.LogDebug("Using server API key for non-auth request");
} }
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -409,6 +398,95 @@ public class JellyfinProxyService
return (body, contentType); return (body, contentType);
} }
/// <summary>
/// Sends a DELETE request to the Jellyfin server.
/// Forwards client headers for authentication passthrough.
/// </summary>
public async Task<JsonDocument?> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
{
var url = BuildUrl(endpoint, null);
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
bool authHeaderAdded = false;
// Forward authentication headers from client (case-insensitive)
foreach (var header in clientHeaders)
{
if (header.Key.Equals("X-Emby-Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
authHeaderAdded = true;
_logger.LogDebug("Forwarded X-Emby-Authorization from client");
break;
}
}
if (!authHeaderAdded)
{
foreach (var header in clientHeaders)
{
if (header.Key.Equals("Authorization", StringComparison.OrdinalIgnoreCase))
{
var headerValue = header.Value.ToString();
// Check if it's MediaBrowser/Jellyfin format
if (headerValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase) ||
headerValue.Contains("Client=", StringComparison.OrdinalIgnoreCase))
{
// Forward as X-Emby-Authorization
request.Headers.TryAddWithoutValidation("X-Emby-Authorization", headerValue);
_logger.LogDebug("Converted Authorization to X-Emby-Authorization");
}
else
{
// Standard Bearer token
request.Headers.TryAddWithoutValidation("Authorization", headerValue);
_logger.LogDebug("Forwarded Authorization header");
}
authHeaderAdded = true;
break;
}
}
}
if (!authHeaderAdded)
{
_logger.LogInformation("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);
var response = await _httpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
{
var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent);
return null;
}
// Handle 204 No Content responses
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{
return null;
}
var responseContent = await response.Content.ReadAsStringAsync();
// Handle empty responses
if (string.IsNullOrWhiteSpace(responseContent))
{
return null;
}
return JsonDocument.Parse(responseContent);
}
/// <summary> /// <summary>
/// Safely sends a GET request to the Jellyfin server, returning null on failure. /// Safely sends a GET request to the Jellyfin server, returning null on failure.
/// </summary> /// </summary>

View File

@@ -42,25 +42,100 @@ public class LrclibService
try try
{ {
var url = $"{BaseUrl}/get?" + // First try search API for fuzzy matching (more forgiving)
var searchUrl = $"{BaseUrl}/search?" +
$"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(artistName)}";
_logger.LogInformation("Searching LRCLIB: {Url}", searchUrl);
var searchResponse = await _httpClient.GetAsync(searchUrl);
if (searchResponse.IsSuccessStatusCode)
{
var searchJson = await searchResponse.Content.ReadAsStringAsync();
var searchResults = JsonSerializer.Deserialize<List<LrclibResponse>>(searchJson, JsonOptions);
if (searchResults != null && searchResults.Count > 0)
{
// Find best match by comparing track name, artist, and duration
LrclibResponse? bestMatch = null;
double bestScore = 0;
foreach (var result in searchResults)
{
// Calculate similarity scores
var trackScore = CalculateSimilarity(trackName, result.TrackName ?? "");
var artistScore = CalculateSimilarity(artistName, result.ArtistName ?? "");
// Duration match (within 5 seconds is good)
var durationDiff = Math.Abs(result.Duration - durationSeconds);
var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2));
// Bonus for having synced lyrics (prefer synced over plain)
var syncedBonus = !string.IsNullOrEmpty(result.SyncedLyrics) ? 20.0 : 0.0;
// Weighted score: track name most important, then artist, then duration, plus synced bonus
var totalScore = (trackScore * 0.5) + (artistScore * 0.3) + (durationScore * 0.2) + syncedBonus;
_logger.LogDebug("Candidate: {Track} by {Artist} - Score: {Score:F1} (track:{TrackScore:F1}, artist:{ArtistScore:F1}, duration:{DurationScore:F1}, synced:{Synced})",
result.TrackName, result.ArtistName, totalScore, trackScore, artistScore, durationScore, !string.IsNullOrEmpty(result.SyncedLyrics));
if (totalScore > bestScore)
{
bestScore = totalScore;
bestMatch = result;
}
}
// Only use result if score is good enough (>60%)
if (bestMatch != null && bestScore >= 60)
{
_logger.LogInformation("✓ Found lyrics via search for {Artist} - {Track} (ID: {Id}, score: {Score:F1}, synced: {HasSynced})",
artistName, trackName, bestMatch.Id, bestScore, !string.IsNullOrEmpty(bestMatch.SyncedLyrics));
var result = new LyricsInfo
{
Id = bestMatch.Id,
TrackName = bestMatch.TrackName ?? trackName,
ArtistName = bestMatch.ArtistName ?? artistName,
AlbumName = bestMatch.AlbumName ?? albumName,
Duration = (int)Math.Round(bestMatch.Duration),
Instrumental = bestMatch.Instrumental,
PlainLyrics = bestMatch.PlainLyrics,
SyncedLyrics = bestMatch.SyncedLyrics
};
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30));
return result;
}
else
{
_logger.LogInformation("Best match score too low ({Score:F1}), trying exact match", bestScore);
}
}
}
// Fall back to exact match API if search didn't find good results
var exactUrl = $"{BaseUrl}/get?" +
$"track_name={Uri.EscapeDataString(trackName)}&" + $"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(artistName)}&" + $"artist_name={Uri.EscapeDataString(artistName)}&" +
$"album_name={Uri.EscapeDataString(albumName)}&" + $"album_name={Uri.EscapeDataString(albumName)}&" +
$"duration={durationSeconds}"; $"duration={durationSeconds}";
_logger.LogDebug("Fetching lyrics from LRCLIB: {Url}", url); _logger.LogDebug("Trying exact match from LRCLIB: {Url}", exactUrl);
var response = await _httpClient.GetAsync(url); var exactResponse = await _httpClient.GetAsync(exactUrl);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound) if (exactResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
{ {
_logger.LogDebug("Lyrics not found for {Artist} - {Track}", artistName, trackName); _logger.LogDebug("Lyrics not found for {Artist} - {Track}", artistName, trackName);
return null; return null;
} }
response.EnsureSuccessStatusCode(); exactResponse.EnsureSuccessStatusCode();
var json = await response.Content.ReadAsStringAsync(); var json = await exactResponse.Content.ReadAsStringAsync();
var lyrics = JsonSerializer.Deserialize<LrclibResponse>(json, JsonOptions); var lyrics = JsonSerializer.Deserialize<LrclibResponse>(json, JsonOptions);
if (lyrics == null) if (lyrics == null)
@@ -68,7 +143,7 @@ public class LrclibService
return null; return null;
} }
var result = new LyricsInfo var exactResult = new LyricsInfo
{ {
Id = lyrics.Id, Id = lyrics.Id,
TrackName = lyrics.TrackName ?? trackName, TrackName = lyrics.TrackName ?? trackName,
@@ -80,11 +155,11 @@ public class LrclibService
SyncedLyrics = lyrics.SyncedLyrics SyncedLyrics = lyrics.SyncedLyrics
}; };
await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), TimeSpan.FromDays(30)); await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), TimeSpan.FromDays(30));
_logger.LogInformation("Retrieved lyrics for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id); _logger.LogInformation("Retrieved lyrics via exact match for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id);
return result; return exactResult;
} }
catch (HttpRequestException ex) catch (HttpRequestException ex)
{ {
@@ -98,6 +173,28 @@ public class LrclibService
} }
} }
private static double CalculateSimilarity(string str1, string str2)
{
if (string.IsNullOrEmpty(str1) || string.IsNullOrEmpty(str2))
return 0;
str1 = str1.ToLowerInvariant();
str2 = str2.ToLowerInvariant();
if (str1 == str2)
return 100;
// Simple token-based matching
var tokens1 = str1.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
var tokens2 = str2.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
if (tokens1.Length == 0 || tokens2.Length == 0)
return 0;
var matchedTokens = tokens1.Count(t1 => tokens2.Any(t2 => t2.Contains(t1) || t1.Contains(t2)));
return (matchedTokens * 100.0) / Math.Max(tokens1.Length, tokens2.Length);
}
public async Task<LyricsInfo?> GetLyricsCachedAsync(string trackName, string artistName, string albumName, int durationSeconds) public async Task<LyricsInfo?> GetLyricsCachedAsync(string trackName, string artistName, string albumName, int durationSeconds)
{ {
try try

View File

@@ -17,6 +17,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
private readonly IServiceProvider _serviceProvider; private readonly IServiceProvider _serviceProvider;
private bool _hasRunOnce = false; private bool _hasRunOnce = false;
private Dictionary<string, string> _playlistIdToName = new(); private Dictionary<string, string> _playlistIdToName = new();
private const string CacheDirectory = "/app/cache/spotify";
public SpotifyMissingTracksFetcher( public SpotifyMissingTracksFetcher(
IOptions<SpotifyImportSettings> spotifySettings, IOptions<SpotifyImportSettings> spotifySettings,
@@ -39,6 +40,9 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogInformation("========================================"); _logger.LogInformation("========================================");
_logger.LogInformation("SpotifyMissingTracksFetcher: Starting up..."); _logger.LogInformation("SpotifyMissingTracksFetcher: Starting up...");
// Ensure cache directory exists
Directory.CreateDirectory(CacheDirectory);
if (!_spotifySettings.Value.Enabled) if (!_spotifySettings.Value.Enabled)
{ {
_logger.LogInformation("Spotify playlist injection is DISABLED"); _logger.LogInformation("Spotify playlist injection is DISABLED");
@@ -74,10 +78,10 @@ public class SpotifyMissingTracksFetcher : BackgroundService
var shouldRunOnStartup = await ShouldRunOnStartupAsync(); var shouldRunOnStartup = await ShouldRunOnStartupAsync();
if (shouldRunOnStartup) if (shouldRunOnStartup)
{ {
_logger.LogInformation("Running initial fetch on startup"); _logger.LogInformation("Running initial fetch on startup (bypassing sync window check)");
try try
{ {
await FetchMissingTracksAsync(stoppingToken); await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true);
_hasRunOnce = true; _hasRunOnce = true;
} }
catch (Exception ex) catch (Exception ex)
@@ -87,7 +91,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
else else
{ {
_logger.LogInformation("Skipping startup fetch - already ran within last 24 hours"); _logger.LogInformation("Skipping startup fetch - already have recent cache");
_hasRunOnce = true; _hasRunOnce = true;
} }
} }
@@ -125,19 +129,144 @@ public class SpotifyMissingTracksFetcher : BackgroundService
private async Task<bool> ShouldRunOnStartupAsync() private async Task<bool> ShouldRunOnStartupAsync()
{ {
// Check if any playlist has cached data from the last 24 hours _logger.LogInformation("=== STARTUP CACHE CHECK ===");
_logger.LogInformation("Cache directory: {Dir}", CacheDirectory);
_logger.LogInformation("Checking {Count} playlists", _playlistIdToName.Count);
// List all files in cache directory for debugging
try
{
if (Directory.Exists(CacheDirectory))
{
var files = Directory.GetFiles(CacheDirectory, "*.json");
_logger.LogInformation("Found {Count} JSON files in cache directory:", files.Length);
foreach (var file in files)
{
var fileInfo = new FileInfo(file);
var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc;
_logger.LogInformation(" - {Name} (age: {Age:F1}h, size: {Size} bytes)",
Path.GetFileName(file), age.TotalHours, fileInfo.Length);
}
}
else
{
_logger.LogWarning("Cache directory does not exist: {Dir}", CacheDirectory);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Error listing cache directory");
}
// Check file cache first, then Redis
foreach (var playlistName in _playlistIdToName.Values) foreach (var playlistName in _playlistIdToName.Values)
{ {
var filePath = GetCacheFilePath(playlistName);
_logger.LogInformation("Checking playlist: {Playlist}", playlistName);
_logger.LogInformation(" Expected file path: {Path}", filePath);
if (File.Exists(filePath))
{
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
_logger.LogInformation(" File exists! Age: {Age:F1}h", fileAge.TotalHours);
if (fileAge < TimeSpan.FromHours(24))
{
_logger.LogInformation(" ✓ Found recent file cache (age: {Age:F1}h)", fileAge.TotalHours);
// Load from file into Redis if not already there
var key = $"spotify:missing:{playlistName}";
if (!await _cache.ExistsAsync(key))
{
_logger.LogInformation(" Loading into Redis...");
await LoadFromFileCache(playlistName);
}
else
{
_logger.LogInformation(" Already in Redis");
}
return false;
}
else
{
_logger.LogInformation(" File too old ({Age:F1}h > 24h), will fetch new", fileAge.TotalHours);
}
}
else
{
_logger.LogInformation(" File does not exist at expected path");
}
var cacheKey = $"spotify:missing:{playlistName}"; var cacheKey = $"spotify:missing:{playlistName}";
if (await _cache.ExistsAsync(cacheKey)) if (await _cache.ExistsAsync(cacheKey))
{ {
return false; // Already have recent data _logger.LogInformation(" ✓ Found in Redis cache");
return false;
} }
else
{
_logger.LogInformation(" Not in Redis cache");
} }
return true; // No recent data, should fetch
} }
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken) _logger.LogInformation("=== NO RECENT CACHE FOUND - WILL FETCH ===");
return true;
}
private string GetCacheFilePath(string playlistName)
{
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
return Path.Combine(CacheDirectory, $"{safeName}_missing.json");
}
private async Task LoadFromFileCache(string playlistName)
{
try
{
var filePath = GetCacheFilePath(playlistName);
if (!File.Exists(filePath))
return;
var json = await File.ReadAllTextAsync(filePath);
var tracks = JsonSerializer.Deserialize<List<MissingTrack>>(json);
if (tracks != null && tracks.Count > 0)
{
var cacheKey = $"spotify:missing:{playlistName}";
var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
var ttl = TimeSpan.FromHours(24) - fileAge;
if (ttl > TimeSpan.Zero)
{
await _cache.SetAsync(cacheKey, tracks, ttl);
_logger.LogInformation("Loaded {Count} tracks from file cache for {Playlist}",
tracks.Count, playlistName);
}
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to load file cache for {Playlist}", playlistName);
}
}
private async Task SaveToFileCache(string playlistName, List<MissingTrack> tracks)
{
try
{
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}",
tracks.Count, playlistName);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to save file cache for {Playlist}", playlistName);
}
}
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken, bool bypassSyncWindowCheck = false)
{ {
var settings = _spotifySettings.Value; var settings = _spotifySettings.Value;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
@@ -146,18 +275,31 @@ public class SpotifyMissingTracksFetcher : BackgroundService
.AddMinutes(settings.SyncStartMinute); .AddMinutes(settings.SyncStartMinute);
var syncEnd = syncStart.AddHours(settings.SyncWindowHours); var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
// Only run after the sync window has passed // Only run after the sync window has passed (unless bypassing for startup)
if (now < syncEnd) if (!bypassSyncWindowCheck && now < syncEnd)
{ {
_logger.LogInformation("Skipping fetch - sync window not passed yet (now: {Now}, window ends: {End})",
now, syncEnd);
return; return;
} }
_logger.LogInformation("Sync window passed, searching last 24 hours for missing tracks..."); if (bypassSyncWindowCheck)
{
_logger.LogInformation("=== FETCHING MISSING TRACKS (STARTUP MODE) ===");
}
else
{
_logger.LogInformation("=== FETCHING MISSING TRACKS (SYNC WINDOW PASSED) ===");
}
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
foreach (var kvp in _playlistIdToName) foreach (var kvp in _playlistIdToName)
{ {
_logger.LogInformation("Fetching playlist: {Name}", kvp.Value);
await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken); await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken);
} }
_logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ===");
} }
private async Task FetchPlaylistMissingTracksAsync( private async Task FetchPlaylistMissingTracksAsync(
@@ -168,13 +310,22 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (await _cache.ExistsAsync(cacheKey)) if (await _cache.ExistsAsync(cacheKey))
{ {
_logger.LogDebug("Cache already exists for {Playlist}", playlistName); _logger.LogInformation("Cache already exists for {Playlist}, skipping fetch", playlistName);
return; return;
} }
_logger.LogInformation(" No cache found, will search for missing tracks file...");
var settings = _spotifySettings.Value; var settings = _spotifySettings.Value;
var jellyfinUrl = _jellyfinSettings.Value.Url; var jellyfinUrl = _jellyfinSettings.Value.Url;
var apiKey = _jellyfinSettings.Value.ApiKey; var apiKey = _jellyfinSettings.Value.ApiKey;
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
{
_logger.LogWarning(" Jellyfin URL or API key not configured, skipping fetch");
return;
}
var httpClient = _httpClientFactory.CreateClient(); var httpClient = _httpClientFactory.CreateClient();
// Start from the configured sync time (most likely time) // Start from the configured sync time (most likely time)
@@ -186,12 +337,12 @@ public class SpotifyMissingTracksFetcher : BackgroundService
// If we haven't reached today's sync time yet, start from yesterday's sync time // If we haven't reached today's sync time yet, start from yesterday's sync time
var syncTime = now >= todaySync ? todaySync : todaySync.AddDays(-1); var syncTime = now >= todaySync ? todaySync : todaySync.AddDays(-1);
_logger.LogInformation("Searching ±12 hours around {SyncTime} for {Playlist}", _logger.LogInformation(" Searching +12h forward, -24h backward from {SyncTime}", syncTime);
syncTime, playlistName);
var found = false; var found = false;
// Search forward 12 hours from sync time // Search forward 12 hours from sync time
_logger.LogInformation(" Phase 1: Searching forward 12 hours from sync time...");
for (var minutesAhead = 0; minutesAhead <= 720; minutesAhead++) // 720 minutes = 12 hours for (var minutesAhead = 0; minutesAhead <= 720; minutesAhead++) // 720 minutes = 12 hours
{ {
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
@@ -210,10 +361,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
} }
// Then search backwards 12 hours from sync time // Then search backwards 24 hours from sync time to catch yesterday's file
if (!found) if (!found)
{ {
for (var minutesBehind = 1; minutesBehind <= 720; minutesBehind++) _logger.LogInformation(" Phase 2: Searching backward 24 hours from sync time...");
for (var minutesBehind = 1; minutesBehind <= 1440; minutesBehind++) // 1440 minutes = 24 hours
{ {
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
@@ -234,7 +386,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (!found) if (!found)
{ {
_logger.LogWarning("Could not find missing tracks file for {Playlist} in ±12 hour window", playlistName); _logger.LogWarning("Could not find missing tracks file (searched +12h/-24h window)");
} }
} }
@@ -262,7 +414,11 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (tracks.Count > 0) if (tracks.Count > 0)
{ {
var cacheKey = $"spotify:missing:{playlistName}"; var cacheKey = $"spotify:missing:{playlistName}";
// Save to both Redis and file
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24)); await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
await SaveToFileCache(playlistName, tracks);
_logger.LogInformation( _logger.LogInformation(
"✓ Cached {Count} missing tracks for {Playlist} from {Filename}", "✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
tracks.Count, playlistName, filename); tracks.Count, playlistName, filename);

View File

@@ -93,6 +93,8 @@ services:
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC} - Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
volumes: volumes:
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads - ${DOWNLOAD_PATH:-./downloads}:/app/downloads
- ${KEPT_PATH:-./kept}:/app/kept
- ${CACHE_PATH:-./cache}:/app/cache
networks: networks:
allstarr-network: allstarr-network: