mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
17 Commits
da1d28d292
...
0011538966
| Author | SHA1 | Date | |
|---|---|---|---|
|
0011538966
|
|||
|
5acdacf132
|
|||
|
cef836da43
|
|||
|
26c9a72def
|
|||
|
f5124bdda2
|
|||
|
f7f57e711c
|
|||
|
76f633afce
|
|||
|
24df910ffa
|
|||
|
eb46692b25
|
|||
|
c54a32ccfc
|
|||
|
c0c7668cc4
|
|||
|
e860bbe0ee
|
|||
|
df3cc51e17
|
|||
|
027aeab969
|
|||
|
449bcc2561
|
|||
|
8da0bef481
|
|||
|
ae8afa20f8
|
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 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)
|
||||
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);
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user