Compare commits

..

17 Commits

Author SHA1 Message Date
0011538966 Use LRClib search API with fuzzy matching and prefer synced lyrics
Some checks failed
CI / build-and-test (push) Has been cancelled
- Search API is more forgiving than exact get endpoint
- Scores results by track/artist similarity and duration match
- +20 point bonus for results with synced lyrics
- Falls back to exact match if search fails
- Improves lyrics hit rate for metadata variations
2026-02-01 12:30:24 -05:00
5acdacf132 Fix unsynced lyrics displaying as one big line
When LRClib returns plain/unsynced lyrics, they contain newlines but
were being sent as a single text block. Jellyfin clients would display
them all on one line.

Now splits plain lyrics by newlines and sends each line separately,
so they display properly line-by-line in the client.

LRClib search URL format:
https://lrclib.net/api/get\?track_name\=\{track\}\&artist_name\=\{artist\}\&album_name\=\{album\}\&duration\=\{seconds\}
2026-02-01 12:23:08 -05:00
cef836da43 Fix Spotify playlist ChildCount in SearchItems endpoint
The playlist list was going through SearchItems (not ProxyRequest), so
UpdateSpotifyPlaylistCounts was never called. Now updates counts in both:
- SearchItems when browsing playlists (no search term)
- ProxyRequest for other playlist list requests

This fixes playlists showing 0 tracks when they should show the count
of missing tracks available.
2026-02-01 12:05:45 -05:00
26c9a72def Remove server API key fallback for client requests
SECURITY FIX: Stop using server API key when clients don't provide auth

Before: If client sent no auth → proxy used server API key → gave them access
After: If client sends no auth → proxy sends no auth → Jellyfin rejects (401)

This ensures:
- Unauthenticated users can't piggyback on server credentials
- All actions are properly attributed to the actual user
- Jellyfin's auth system works as intended
- Server API key only used for internal operations (images, library detection)

Updated test to reflect new behavior: GetJsonAsync without client headers
should NOT add any authentication.
2026-02-01 12:00:38 -05:00
f5124bdda2 Add debug logging for Spotify playlist ChildCount updates
- Log when checking items for Spotify playlists
- Log cache lookups and file cache fallbacks
- Log successful ChildCount updates
- Log when no Spotify playlists found
- Helps diagnose why playlist counts might not be updating correctly
2026-02-01 11:56:34 -05:00
f7f57e711c Add endpoint usage logging for analysis
- Log all proxied endpoints to /app/cache/endpoint-usage/endpoints.csv
- CSV format: timestamp, method, path, query string
- Add GET /debug/endpoint-usage?api_key=KEY to view statistics
  - Shows top N endpoints by usage count
  - Filter by date with since parameter
  - Returns total requests, unique endpoints, first/last seen
- Add DELETE /debug/endpoint-usage?api_key=KEY to clear logs
- Thread-safe file appending
- Helps identify which endpoints clients actually use
- Can inform future blocklist/allowlist decisions
2026-02-01 11:52:44 -05:00
76f633afce Add security blocklist for dangerous admin endpoints
- Block system restart/shutdown endpoints
- Block system configuration changes
- Block plugin management (install/uninstall/configure)
- Block scheduled task management
- Block server startup/setup endpoints
- Block user creation endpoint
- Block library management (refresh, virtual folders)
- Block server logs and activity log access
- Log blocked attempts with IP address for security monitoring
- Returns 403 Forbidden with descriptive error message

This maintains client compatibility via catch-all proxy while preventing
unauthorized access to administrative functions.
2026-02-01 11:48:45 -05:00
24df910ffa Update Spotify playlist ChildCount to show actual track count
- Intercept playlist list responses and update ChildCount for Spotify playlists
- Shows the number of missing tracks found (local + matched external)
- Fixes playlist showing 0 songs when Jellyfin has no local files
- Reads from cache (Redis or file) to get accurate count
2026-02-01 11:38:25 -05:00
eb46692b25 Extend backward search window to 24 hours for Spotify missing tracks
- Search forward 12 hours from sync time
- Search backward 24 hours from sync time (was 12 hours)
- Ensures yesterday's file is always found when running at 11 AM after 4 PM sync
- Sync runs daily at 4:15 PM, so 24h backward always catches previous day's file
2026-02-01 11:33:42 -05:00
c54a32ccfc Add extensive logging to debug startup cache loading
- List all files in cache directory on startup
- Show expected file paths and actual file existence
- Log each step of cache checking process
- Add phase indicators for forward/backward search
- Show when cache exists and fetch is skipped
- Help diagnose why yesterday's cache files aren't being loaded
2026-02-01 11:28:00 -05:00
c0c7668cc4 Bypass sync window check on startup to fetch missing tracks immediately
- On startup, if no cache exists, fetch immediately regardless of sync window
- Regular background checks still respect sync window timing
- Ensures playlists are populated even if app restarts before sync time
2026-02-01 11:22:12 -05:00
e860bbe0ee Fix nullable warnings in SpotifyMissingTracksFetcher 2026-02-01 11:18:58 -05:00
df3cc51e17 Fix DownloadSongAsync return type handling 2026-02-01 11:18:17 -05:00
027aeab969 Fix compilation errors in favorite-to-keep and file cache features 2026-02-01 11:14:18 -05:00
449bcc2561 Fix spotify/sync endpoint route priority 2026-02-01 11:11:11 -05:00
8da0bef481 Add favorite-to-keep feature for external tracks
- When favoriting an external track, automatically copy to /kept folder
- Organized as Artist/Album/Track structure
- Includes cover art if available
- Downloads track first if not already cached
- Add KEPT_PATH and CACHE_PATH volumes to docker-compose
- Update .env.example and README with new feature
2026-02-01 11:09:18 -05:00
ae8afa20f8 Add file-based persistence for Spotify missing tracks cache
- Save missing tracks to /app/cache/spotify/*.json files
- 24-hour TTL for file cache
- Fallback to file cache when Redis is empty/restarted
- Controller checks file cache before returning empty playlists
- Ensures playlist data persists across Redis restarts
2026-02-01 11:07:19 -05:00
8 changed files with 750 additions and 63 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)
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 =====
# Different quality options for SquidWTF. Only FLAC supported right now
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
- **Automatic Search**: Searches streaming providers when songs aren't local
- **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
- **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

View File

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

View File

@@ -121,6 +121,13 @@ public class JellyfinController : ControllerBase
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());
if (_logger.IsEnabled(LogLevel.Debug))
{
@@ -1055,13 +1062,26 @@ public class JellyfinController : ControllerBase
}
}
}
else if (!string.IsNullOrEmpty(lyricsText))
{
// Plain lyrics - split by newlines and return each line separately
var lines = lyricsText.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
foreach (var line in lines)
{
lyricLines.Add(new
{
Start = (long?)null,
Text = line.Trim()
});
}
}
else
{
// Plain lyrics - return as single block
// No lyrics at all
lyricLines.Add(new
{
Start = (long?)null,
Text = lyricsText
Text = ""
});
}
@@ -1118,12 +1138,24 @@ public class JellyfinController : ControllerBase
}
// Check if this is an external song/album
var (isExternal, _, _) = _localLibraryService.ParseSongId(itemId);
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (isExternal)
{
// External items don't exist in Jellyfin, so we can't favorite them there
// Just return success - the client will show it as favorited
_logger.LogDebug("Favoriting external item {ItemId} (not synced to Jellyfin)", itemId);
_logger.LogInformation("Favoriting external item {ItemId}, copying to kept folder", itemId);
// Copy the track to kept folder in background
_ = Task.Run(async () =>
{
try
{
await CopyExternalTrackToKeptAsync(itemId, provider!, externalId!);
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to copy external track {ItemId} to kept folder", itemId);
}
});
return Ok(new { IsFavorite = true });
}
@@ -1650,6 +1682,7 @@ public class JellyfinController : ControllerBase
/// <summary>
/// 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.
/// Blocks dangerous admin endpoints for security.
/// </summary>
[HttpGet("{**path}", Order = 100)]
[HttpPost("{**path}", Order = 100)]
@@ -1658,6 +1691,42 @@ public class JellyfinController : ControllerBase
// DEBUG: Log EVERY request to see what's happening
_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
if (_spotifySettings.Enabled &&
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
@@ -1812,6 +1881,13 @@ public class JellyfinController : ControllerBase
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()));
}
catch (Exception ex)
@@ -1825,6 +1901,141 @@ public class JellyfinController : ControllerBase
#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)
{
if (string.IsNullOrWhiteSpace(includeItemTypes))
@@ -1962,6 +2173,20 @@ public class JellyfinController : ControllerBase
var missingTracksKey = $"spotify:missing:{spotifyPlaylistName}";
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)
{
_logger.LogInformation("No missing tracks found for {Playlist}, returning {Count} existing tracks",
@@ -2069,11 +2294,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>
/// Manual trigger endpoint to force fetch Spotify missing tracks.
/// GET /spotify/sync?api_key=YOUR_KEY
/// </summary>
[HttpGet("spotify/sync")]
[HttpGet("spotify/sync", Order = 1)]
[ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> TriggerSpotifySync()
{
@@ -2228,5 +2558,114 @@ public class JellyfinController : ControllerBase
}
#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

View File

@@ -205,18 +205,11 @@ public class JellyfinProxyService
_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 (!string.IsNullOrEmpty(_settings.ApiKey))
{
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);
}
_logger.LogInformation("No client auth provided for {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
@@ -333,16 +326,12 @@ public class JellyfinProxyService
}
}
// For non-auth requests without headers, use API key
// For auth requests, client MUST provide their own client info
if (!authHeaderAdded && !endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase))
// DO NOT use server credentials as fallback
// Exception: For auth endpoints, client provides their own credentials in the body
// For all other endpoints, if client doesn't provide auth, let Jellyfin reject it
if (!authHeaderAdded)
{
var clientAuthHeader = $"MediaBrowser Client=\"{_settings.ClientName}\", " +
$"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");
_logger.LogInformation("No client auth provided for POST {Url} - forwarding without auth", url);
}
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

View File

@@ -42,25 +42,100 @@ public class LrclibService
try
{
var url = $"{BaseUrl}/get?" +
$"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(artistName)}&" +
$"album_name={Uri.EscapeDataString(albumName)}&" +
$"duration={durationSeconds}";
// First try search API for fuzzy matching (more forgiving)
var searchUrl = $"{BaseUrl}/search?" +
$"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(artistName)}";
_logger.LogDebug("Fetching lyrics from LRCLIB: {Url}", url);
_logger.LogDebug("Searching lyrics from LRCLIB: {Url}", searchUrl);
var response = await _httpClient.GetAsync(url);
var searchResponse = await _httpClient.GetAsync(searchUrl);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
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})",
artistName, trackName, bestMatch.Id, bestScore);
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.LogDebug("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)}&" +
$"artist_name={Uri.EscapeDataString(artistName)}&" +
$"album_name={Uri.EscapeDataString(albumName)}&" +
$"duration={durationSeconds}";
_logger.LogDebug("Trying exact match from LRCLIB: {Url}", exactUrl);
var exactResponse = await _httpClient.GetAsync(exactUrl);
if (exactResponse.StatusCode == System.Net.HttpStatusCode.NotFound)
{
_logger.LogDebug("Lyrics not found for {Artist} - {Track}", artistName, trackName);
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);
if (lyrics == null)
@@ -68,7 +143,7 @@ public class LrclibService
return null;
}
var result = new LyricsInfo
var exactResult = new LyricsInfo
{
Id = lyrics.Id,
TrackName = lyrics.TrackName ?? trackName,
@@ -80,11 +155,11 @@ public class LrclibService
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)
{
@@ -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)
{
try

View File

@@ -17,6 +17,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
private readonly IServiceProvider _serviceProvider;
private bool _hasRunOnce = false;
private Dictionary<string, string> _playlistIdToName = new();
private const string CacheDirectory = "/app/cache/spotify";
public SpotifyMissingTracksFetcher(
IOptions<SpotifyImportSettings> spotifySettings,
@@ -39,6 +40,9 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogInformation("========================================");
_logger.LogInformation("SpotifyMissingTracksFetcher: Starting up...");
// Ensure cache directory exists
Directory.CreateDirectory(CacheDirectory);
if (!_spotifySettings.Value.Enabled)
{
_logger.LogInformation("Spotify playlist injection is DISABLED");
@@ -74,10 +78,10 @@ public class SpotifyMissingTracksFetcher : BackgroundService
var shouldRunOnStartup = await ShouldRunOnStartupAsync();
if (shouldRunOnStartup)
{
_logger.LogInformation("Running initial fetch on startup");
_logger.LogInformation("Running initial fetch on startup (bypassing sync window check)");
try
{
await FetchMissingTracksAsync(stoppingToken);
await FetchMissingTracksAsync(stoppingToken, bypassSyncWindowCheck: true);
_hasRunOnce = true;
}
catch (Exception ex)
@@ -87,7 +91,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
}
else
{
_logger.LogInformation("Skipping startup fetch - already ran within last 24 hours");
_logger.LogInformation("Skipping startup fetch - already have recent cache");
_hasRunOnce = true;
}
}
@@ -125,19 +129,144 @@ public class SpotifyMissingTracksFetcher : BackgroundService
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)
{
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}";
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
_logger.LogInformation("=== NO RECENT CACHE FOUND - WILL FETCH ===");
return true;
}
private async Task FetchMissingTracksAsync(CancellationToken cancellationToken)
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 now = DateTime.UtcNow;
@@ -146,18 +275,31 @@ public class SpotifyMissingTracksFetcher : BackgroundService
.AddMinutes(settings.SyncStartMinute);
var syncEnd = syncStart.AddHours(settings.SyncWindowHours);
// Only run after the sync window has passed
if (now < syncEnd)
// Only run after the sync window has passed (unless bypassing for startup)
if (!bypassSyncWindowCheck && now < syncEnd)
{
_logger.LogInformation("Skipping fetch - sync window not passed yet (now: {Now}, window ends: {End})",
now, syncEnd);
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)
{
_logger.LogInformation("Fetching playlist: {Name}", kvp.Value);
await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken);
}
_logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ===");
}
private async Task FetchPlaylistMissingTracksAsync(
@@ -168,13 +310,22 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (await _cache.ExistsAsync(cacheKey))
{
_logger.LogDebug("Cache already exists for {Playlist}", playlistName);
_logger.LogInformation("Cache already exists for {Playlist}, skipping fetch", playlistName);
return;
}
_logger.LogInformation(" No cache found, will search for missing tracks file...");
var settings = _spotifySettings.Value;
var jellyfinUrl = _jellyfinSettings.Value.Url;
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();
// 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
var syncTime = now >= todaySync ? todaySync : todaySync.AddDays(-1);
_logger.LogInformation("Searching ±12 hours around {SyncTime} for {Playlist}",
syncTime, playlistName);
_logger.LogInformation(" Searching +12h forward, -24h backward from {SyncTime}", syncTime);
var found = false;
// 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
{
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)
{
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;
@@ -234,7 +386,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
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)
{
var cacheKey = $"spotify:missing:{playlistName}";
// Save to both Redis and file
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
await SaveToFileCache(playlistName, tracks);
_logger.LogInformation(
"✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
tracks.Count, playlistName, filename);

View File

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