From 48b40f89c00e48d06ce43df628e3dbab23da3b30 Mon Sep 17 00:00:00 2001 From: Josh Patra Date: Fri, 6 Mar 2026 01:59:30 -0500 Subject: [PATCH] v1.3.0: Massive WebUI cleanup, Fixed/Stabilized scrobbling, Significant security hardening, added user login to WebUI, refactored searching/interleaving to work MUCH better, Tidal Powered recommendations for SquidWTF provider, Fixed double scrobbling, inferring stops much better, fixed playlist cron rebuilding, stale injected playlist artwork, and search cache TTL --- .env.example | 42 +- Directory.Build.props | 9 + Dockerfile | 1 + allstarr.Tests/AdminAuthControllerTests.cs | 213 +++ .../AdminAuthenticationMiddlewareTests.cs | 198 +++ .../AdminNetworkAllowlistMiddlewareTests.cs | 93 ++ .../AdminNetworkBindingPolicyTests.cs | 67 + .../AdminStaticFilesMiddlewareTests.cs | 147 ++ allstarr.Tests/ApiKeyAuthFilterTests.cs | 167 --- allstarr.Tests/AuthHeaderHelperTests.cs | 87 ++ allstarr.Tests/CacheKeyBuilderTests.cs | 71 + .../ConfigControllerAuthorizationTests.cs | 166 ++ .../DownloadsControllerPathSecurityTests.cs | 117 ++ allstarr.Tests/ExplicitContentFilterTests.cs | 43 + allstarr.Tests/JavaScriptSyntaxTests.cs | 141 +- allstarr.Tests/JellyfinProxyServiceTests.cs | 89 +- allstarr.Tests/JellyfinQueryRedactionTests.cs | 42 + .../JellyfinResponseBuilderTests.cs | 52 + .../JellyfinSearchTermRecoveryTests.cs | 47 + allstarr.Tests/OutboundRequestGuardTests.cs | 64 + allstarr.Tests/PlaybackSessionTests.cs | 64 + .../PlaylistTrackStatusResolverTests.cs | 107 ++ allstarr.Tests/ProviderIdsEnricherTests.cs | 58 + allstarr.Tests/RetryHelperTests.cs | 64 + .../ScrobblingAdminControllerTests.cs | 308 ++-- allstarr.Tests/ScrobblingOrchestratorTests.cs | 54 + allstarr.Tests/SpotifyApiClientTests.cs | 85 ++ .../SquidWTFMetadataServiceTests.cs | 236 ++- allstarr.Tests/TrackParserBaseTests.cs | 27 + allstarr.Tests/VersionUpgradePolicyTests.cs | 42 + allstarr/AppVersion.cs | 2 +- allstarr/Controllers/AdminAuthController.cs | 206 +++ allstarr/Controllers/ConfigController.cs | 515 +++++-- allstarr/Controllers/DiagnosticsController.cs | 158 +- allstarr/Controllers/DownloadsController.cs | 135 +- allstarr/Controllers/Helpers.cs | 266 +++- .../Controllers/JellyfinAdminController.cs | 463 ++++-- .../Controllers/JellyfinController.Audio.cs | 6 +- .../JellyfinController.Authentication.cs | 4 +- .../Controllers/JellyfinController.Lyrics.cs | 6 +- .../JellyfinController.PlaybackSessions.cs | 1099 ++++++++++++-- .../JellyfinController.PlaylistHandler.cs | 19 +- .../Controllers/JellyfinController.Search.cs | 919 ++++++++++-- .../Controllers/JellyfinController.Spotify.cs | 115 +- allstarr/Controllers/JellyfinController.cs | 662 +++++--- allstarr/Controllers/LyricsController.cs | 80 +- allstarr/Controllers/MappingController.cs | 9 +- allstarr/Controllers/PlaylistController.cs | 888 +++++++---- .../Controllers/ScrobblingAdminController.cs | 234 +-- .../Controllers/SpotifyAdminController.cs | 290 +++- allstarr/Controllers/SubSonicController.cs | 107 +- allstarr/Filters/ApiKeyAuthFilter.cs | 66 - .../AdminAuthenticationMiddleware.cs | 127 ++ .../AdminNetworkAllowlistMiddleware.cs | 52 + .../Middleware/AdminStaticFilesMiddleware.cs | 88 +- .../Middleware/RequestLoggingMiddleware.cs | 80 +- .../Middleware/WebSocketProxyMiddleware.cs | 62 +- allstarr/Models/Admin/AdminDtos.cs | 3 +- allstarr/Models/Scrobbling/PlaybackSession.cs | 13 +- allstarr/Models/Scrobbling/ScrobbleTrack.cs | 6 + allstarr/Models/Settings/CacheSettings.cs | 4 +- .../Models/Settings/MusicBrainzSettings.cs | 2 +- .../Models/Settings/ScrobblingSettings.cs | 43 +- .../Models/Settings/SpotifyImportSettings.cs | 38 +- .../Models/Spotify/SpotifyPlaylistTrack.cs | 80 +- allstarr/Program.cs | 415 +++-- .../Services/Admin/AdminAuthSessionService.cs | 108 ++ allstarr/Services/Admin/AdminHelperService.cs | 256 ++-- .../Admin/PlaylistTrackStatusResolver.cs | 104 ++ .../Common/AdminNetworkBindingPolicy.cs | 76 + allstarr/Services/Common/CacheKeyBuilder.cs | 159 +- .../Services/Common/CacheWarmingService.cs | 40 +- .../Services/Common/GenreEnrichmentService.cs | 27 +- allstarr/Services/Common/OdesliService.cs | 12 +- .../Services/Common/OutboundRequestGuard.cs | 156 ++ .../Common/ParallelMetadataService.cs | 8 +- .../Services/Common/ProviderIdsEnricher.cs | 113 ++ allstarr/Services/Common/RedisCacheService.cs | 94 +- .../Common/RoundRobinFallbackHelper.cs | 85 +- allstarr/Services/Common/TrackParserBase.cs | 47 + .../Services/Common/VersionUpgradePolicy.cs | 49 + .../Common/VersionUpgradeRebuildService.cs | 117 ++ .../Services/Deezer/DeezerMetadataService.cs | 412 +++-- allstarr/Services/IMusicMetadataService.cs | 28 +- .../Services/Jellyfin/JellyfinProxyService.cs | 183 ++- .../Jellyfin/JellyfinResponseBuilder.cs | 83 +- .../Jellyfin/JellyfinSessionManager.cs | 230 ++- allstarr/Services/Lyrics/LrclibService.cs | 76 +- allstarr/Services/Lyrics/LyricsPlusService.cs | 48 +- .../Services/Lyrics/LyricsPrefetchService.cs | 78 +- .../MusicBrainz/MusicBrainzService.cs | 58 +- .../Services/Qobuz/QobuzMetadataService.cs | 335 ++--- .../ListenBrainzScrobblingService.cs | 2 +- .../Services/Scrobbling/ScrobblingHelper.cs | 9 +- .../Scrobbling/ScrobblingOrchestrator.cs | 92 +- allstarr/Services/Spotify/SpotifyApiClient.cs | 666 ++++++--- .../Spotify/SpotifyApiClientFactory.cs | 39 + .../Services/Spotify/SpotifyMappingService.cs | 76 +- .../Spotify/SpotifyMissingTracksFetcher.cs | 134 +- .../Spotify/SpotifyPlaylistFetcher.cs | 307 ++-- .../Spotify/SpotifySessionCookieService.cs | 198 +++ .../Spotify/SpotifyTrackMatchingService.cs | 748 +++++----- .../SquidWTF/SquidWTFDownloadService.cs | 16 +- .../SquidWTF/SquidWTFMetadataService.cs | 1094 ++++++++------ .../SquidWTF/SquidWTFStartupValidator.cs | 182 ++- .../SquidWTF/SquidWtfEndpointCatalog.cs | 40 + .../SquidWTF/SquidWtfEndpointDiscovery.cs | 172 +++ .../Services/Subsonic/SubsonicProxyService.cs | 36 +- allstarr/allstarr.csproj | 12 +- allstarr/appsettings.json | 15 +- allstarr/wwwroot/index.html | 193 ++- allstarr/wwwroot/js/api.js | 603 +++++--- allstarr/wwwroot/js/auth-session.js | 262 ++++ allstarr/wwwroot/js/dashboard-data.js | 411 +++++ allstarr/wwwroot/js/helpers.js | 944 +++++++----- allstarr/wwwroot/js/main.js | 1329 ++--------------- allstarr/wwwroot/js/operations.js | 382 +++++ allstarr/wwwroot/js/playlist-admin.js | 304 ++++ allstarr/wwwroot/js/scrobbling-admin.js | 360 +++++ allstarr/wwwroot/js/settings-editor.js | 662 ++++++++ allstarr/wwwroot/js/ui.js | 1052 +++++++++---- allstarr/wwwroot/js/utils.js | 8 +- allstarr/wwwroot/spotify-mappings.html | 1049 ++++++++----- allstarr/wwwroot/spotify-mappings.js | 768 +++++++--- allstarr/wwwroot/styles.css | 517 ++++++- apis/steering/ADMIN-UI-MODULARITY.md | 72 + docker-compose.yml | 34 +- 127 files changed, 18679 insertions(+), 7254 deletions(-) create mode 100644 Directory.Build.props create mode 100644 allstarr.Tests/AdminAuthControllerTests.cs create mode 100644 allstarr.Tests/AdminAuthenticationMiddlewareTests.cs create mode 100644 allstarr.Tests/AdminNetworkAllowlistMiddlewareTests.cs create mode 100644 allstarr.Tests/AdminNetworkBindingPolicyTests.cs create mode 100644 allstarr.Tests/AdminStaticFilesMiddlewareTests.cs delete mode 100644 allstarr.Tests/ApiKeyAuthFilterTests.cs create mode 100644 allstarr.Tests/AuthHeaderHelperTests.cs create mode 100644 allstarr.Tests/CacheKeyBuilderTests.cs create mode 100644 allstarr.Tests/ConfigControllerAuthorizationTests.cs create mode 100644 allstarr.Tests/DownloadsControllerPathSecurityTests.cs create mode 100644 allstarr.Tests/ExplicitContentFilterTests.cs create mode 100644 allstarr.Tests/JellyfinQueryRedactionTests.cs create mode 100644 allstarr.Tests/JellyfinSearchTermRecoveryTests.cs create mode 100644 allstarr.Tests/OutboundRequestGuardTests.cs create mode 100644 allstarr.Tests/PlaybackSessionTests.cs create mode 100644 allstarr.Tests/PlaylistTrackStatusResolverTests.cs create mode 100644 allstarr.Tests/ProviderIdsEnricherTests.cs create mode 100644 allstarr.Tests/RetryHelperTests.cs create mode 100644 allstarr.Tests/ScrobblingOrchestratorTests.cs create mode 100644 allstarr.Tests/TrackParserBaseTests.cs create mode 100644 allstarr.Tests/VersionUpgradePolicyTests.cs create mode 100644 allstarr/Controllers/AdminAuthController.cs delete mode 100644 allstarr/Filters/ApiKeyAuthFilter.cs create mode 100644 allstarr/Middleware/AdminAuthenticationMiddleware.cs create mode 100644 allstarr/Middleware/AdminNetworkAllowlistMiddleware.cs create mode 100644 allstarr/Services/Admin/AdminAuthSessionService.cs create mode 100644 allstarr/Services/Admin/PlaylistTrackStatusResolver.cs create mode 100644 allstarr/Services/Common/AdminNetworkBindingPolicy.cs create mode 100644 allstarr/Services/Common/OutboundRequestGuard.cs create mode 100644 allstarr/Services/Common/ProviderIdsEnricher.cs create mode 100644 allstarr/Services/Common/TrackParserBase.cs create mode 100644 allstarr/Services/Common/VersionUpgradePolicy.cs create mode 100644 allstarr/Services/Common/VersionUpgradeRebuildService.cs create mode 100644 allstarr/Services/Spotify/SpotifyApiClientFactory.cs create mode 100644 allstarr/Services/Spotify/SpotifySessionCookieService.cs create mode 100644 allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs create mode 100644 allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs create mode 100644 allstarr/wwwroot/js/auth-session.js create mode 100644 allstarr/wwwroot/js/dashboard-data.js create mode 100644 allstarr/wwwroot/js/operations.js create mode 100644 allstarr/wwwroot/js/playlist-admin.js create mode 100644 allstarr/wwwroot/js/scrobbling-admin.js create mode 100644 allstarr/wwwroot/js/settings-editor.js create mode 100644 apis/steering/ADMIN-UI-MODULARITY.md diff --git a/.env.example b/.env.example index 30180b1..a7e7e75 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,29 @@ # Choose which media server backend to use: Subsonic or Jellyfin BACKEND_TYPE=Jellyfin +# ===== ADMIN NETWORK ACCESS (PORT 5275) ===== +# Keep false to bind admin UI to localhost only (recommended) +# Set true only if you need LAN access from another device +ADMIN_BIND_ANY_IP=false + +# Comma-separated trusted CIDR ranges allowed to access admin port when bind is enabled +# Examples: 192.168.1.0/24,10.0.0.0/8 +ADMIN_TRUSTED_SUBNETS= + +# ===== CORS POLICY ===== +# Cross-origin requests are disabled by default. +# Set explicit origins if you need browser access from another host. +# Example: https://my-jellyfin.example.com,http://localhost:3000 +CORS_ALLOWED_ORIGINS= + +# Explicit allowed methods and headers for CORS preflight. +# Keep these restrictive unless you have a concrete requirement. +CORS_ALLOWED_METHODS=GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD +CORS_ALLOWED_HEADERS=Accept,Authorization,Content-Type,Range,X-Requested-With,X-Emby-Authorization,X-MediaBrowser-Token + +# Set true only when your allowed origins require cookies/auth credentials. +CORS_ALLOW_CREDENTIALS=false + # ===== REDIS CACHE (REQUIRED) ===== # Redis is the primary cache for all runtime data (search results, playlists, lyrics, etc.) # File cache (/app/cache) acts as a persistence layer for cold starts @@ -113,6 +136,14 @@ QOBUZ_USER_ID= # If not specified, the highest available quality will be used QOBUZ_QUALITY= +# ===== MUSICBRAINZ CONFIGURATION ===== +# Enable MusicBrainz metadata lookups (optional, default: true) +MUSICBRAINZ_ENABLED=true + +# Optional MusicBrainz account credentials for authenticated requests +MUSICBRAINZ_USERNAME= +MUSICBRAINZ_PASSWORD= + # ===== GENERAL SETTINGS ===== # External playlists support (optional, default: true) # When enabled, allows searching and downloading playlists from Deezer/Qobuz @@ -232,6 +263,11 @@ SCROBBLING_ENABLED=false # This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz) SCROBBLING_LOCAL_TRACKS_ENABLED=false +# Emit synthetic local "played" events from progress when local scrobbling is disabled (default: false) +# Only enable this if you explicitly need UserPlayedItems-based plugin triggering. +# Keep false to avoid duplicate local scrobbles with Jellyfin plugins. +SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED=false + # ===== LAST.FM SCROBBLING ===== # Enable Last.fm scrobbling (default: false) SCROBBLING_LASTFM_ENABLED=false @@ -270,7 +306,11 @@ SCROBBLING_LISTENBRAINZ_USER_TOKEN= # Enable detailed request logging (default: false) # When enabled, logs every incoming HTTP request with full details: # - Method, path, query string -# - Headers (auth tokens are masked) +# - Headers # - Response status and timing # Useful for debugging client issues and seeing what API calls are being made DEBUG_LOG_ALL_REQUESTS=false + +# Redact auth/query sensitive values in request logs (default: false). +# Set true if you want DEBUG_LOG_ALL_REQUESTS while still masking tokens. +DEBUG_REDACT_SENSITIVE_REQUEST_VALUES=false diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..aba20cd --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,9 @@ + + + + false + + diff --git a/Dockerfile b/Dockerfile index 0328a37..5afefe9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,6 +4,7 @@ WORKDIR /src COPY allstarr.sln . COPY allstarr/allstarr.csproj allstarr/ +COPY allstarr/AppVersion.cs allstarr/ COPY allstarr.Tests/allstarr.Tests.csproj allstarr.Tests/ RUN dotnet restore diff --git a/allstarr.Tests/AdminAuthControllerTests.cs b/allstarr.Tests/AdminAuthControllerTests.cs new file mode 100644 index 0000000..233b339 --- /dev/null +++ b/allstarr.Tests/AdminAuthControllerTests.cs @@ -0,0 +1,213 @@ +using System.Net; +using System.Text.Json; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using allstarr.Controllers; +using allstarr.Models.Settings; +using allstarr.Services.Admin; + +namespace allstarr.Tests; + +public class AdminAuthControllerTests +{ + [Fact] + public async Task Login_WithValidNonAdminJellyfinUser_CreatesSessionAndCookie() + { + HttpRequestMessage? capturedRequest = null; + string? capturedBody = null; + + var handler = new DelegateHttpMessageHandler(async (request, _) => + { + capturedRequest = request; + capturedBody = request.Content is null ? null : await request.Content.ReadAsStringAsync(); + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(""" + { + "AccessToken":"token-123", + "ServerId":"server-1", + "User":{ + "Id":"user-1", + "Name":"josh", + "Policy":{"IsAdministrator":false} + } + } + """) + }; + }); + + var sessionService = new AdminAuthSessionService(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers["X-Forwarded-Proto"] = "https"; + + var controller = CreateController(handler, sessionService, httpContext); + var result = await controller.Login(new AdminAuthController.LoginRequest + { + Username = " josh ", + Password = "secret-pass" + }); + + var ok = Assert.IsType(result); + var payloadJson = JsonSerializer.Serialize(ok.Value); + using var payload = JsonDocument.Parse(payloadJson); + + Assert.True(payload.RootElement.GetProperty("authenticated").GetBoolean()); + Assert.Equal("user-1", payload.RootElement.GetProperty("user").GetProperty("id").GetString()); + Assert.Equal("josh", payload.RootElement.GetProperty("user").GetProperty("name").GetString()); + Assert.False(payload.RootElement.GetProperty("user").GetProperty("isAdministrator").GetBoolean()); + + Assert.NotNull(capturedRequest); + Assert.Equal(HttpMethod.Post, capturedRequest!.Method); + Assert.Equal("http://jellyfin.local/Users/AuthenticateByName", capturedRequest.RequestUri?.ToString()); + Assert.Contains("X-Emby-Authorization", capturedRequest.Headers.Select(h => h.Key)); + + Assert.NotNull(capturedBody); + Assert.Contains("\"Username\":\"josh\"", capturedBody!); + Assert.Contains("\"Pw\":\"secret-pass\"", capturedBody!); + + var setCookies = httpContext.Response.Headers.SetCookie; + Assert.Single(setCookies); + var setCookieHeader = setCookies[0] ?? string.Empty; + Assert.Contains($"{AdminAuthSessionService.SessionCookieName}=", setCookieHeader); + Assert.Contains("httponly", setCookieHeader.ToLowerInvariant()); + Assert.Contains("secure", setCookieHeader.ToLowerInvariant()); + Assert.Contains("samesite=strict", setCookieHeader.ToLowerInvariant()); + + var sessionId = ExtractCookieValue(setCookieHeader); + Assert.True(sessionService.TryGetValidSession(sessionId, out var session)); + Assert.Equal("user-1", session.UserId); + Assert.Equal("josh", session.UserName); + Assert.False(session.IsAdministrator); + } + + [Fact] + public async Task Login_WithInvalidCredentials_ReturnsUnauthorized() + { + var handler = new DelegateHttpMessageHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.Unauthorized))); + + var sessionService = new AdminAuthSessionService(); + var httpContext = new DefaultHttpContext(); + var controller = CreateController(handler, sessionService, httpContext); + + var result = await controller.Login(new AdminAuthController.LoginRequest + { + Username = "josh", + Password = "wrong" + }); + + var unauthorized = Assert.IsType(result); + Assert.Equal(StatusCodes.Status401Unauthorized, unauthorized.StatusCode); + Assert.False(httpContext.Response.Headers.ContainsKey("Set-Cookie")); + } + + [Fact] + public void GetCurrentSession_WithUnknownCookie_ReturnsUnauthenticated() + { + var handler = new DelegateHttpMessageHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + var sessionService = new AdminAuthSessionService(); + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Cookie = $"{AdminAuthSessionService.SessionCookieName}=missing-session"; + + var controller = CreateController(handler, sessionService, httpContext); + var result = controller.GetCurrentSession(); + + var ok = Assert.IsType(result); + var payloadJson = JsonSerializer.Serialize(ok.Value); + using var payload = JsonDocument.Parse(payloadJson); + + Assert.False(payload.RootElement.GetProperty("authenticated").GetBoolean()); + var setCookies = httpContext.Response.Headers.SetCookie; + Assert.Single(setCookies); + var setCookieHeader = setCookies[0] ?? string.Empty; + Assert.Contains($"{AdminAuthSessionService.SessionCookieName}=", setCookieHeader); + Assert.Contains("expires=", setCookieHeader.ToLowerInvariant()); + } + + [Fact] + public void GetCurrentSession_WithValidCookie_ReturnsSessionUser() + { + var handler = new DelegateHttpMessageHandler((_, _) => + Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK))); + + var sessionService = new AdminAuthSessionService(); + var session = sessionService.CreateSession( + userId: "user-42", + userName: "alice", + isAdministrator: true, + jellyfinAccessToken: "token", + jellyfinServerId: "server"); + + var httpContext = new DefaultHttpContext(); + httpContext.Request.Headers.Cookie = $"{AdminAuthSessionService.SessionCookieName}={session.SessionId}"; + + var controller = CreateController(handler, sessionService, httpContext); + var result = controller.GetCurrentSession(); + + var ok = Assert.IsType(result); + var payloadJson = JsonSerializer.Serialize(ok.Value); + using var payload = JsonDocument.Parse(payloadJson); + + Assert.True(payload.RootElement.GetProperty("authenticated").GetBoolean()); + Assert.Equal("user-42", payload.RootElement.GetProperty("user").GetProperty("id").GetString()); + Assert.Equal("alice", payload.RootElement.GetProperty("user").GetProperty("name").GetString()); + Assert.True(payload.RootElement.GetProperty("user").GetProperty("isAdministrator").GetBoolean()); + } + + private static AdminAuthController CreateController( + HttpMessageHandler handler, + AdminAuthSessionService sessionService, + HttpContext httpContext) + { + var jellyfinOptions = Options.Create(new JellyfinSettings + { + Url = "http://jellyfin.local" + }); + + var httpClientFactory = new Mock(); + httpClientFactory.Setup(x => x.CreateClient(It.IsAny())).Returns(new HttpClient(handler)); + + var logger = new Mock>(); + var controller = new AdminAuthController( + jellyfinOptions, + httpClientFactory.Object, + sessionService, + logger.Object) + { + ControllerContext = new ControllerContext + { + HttpContext = httpContext + } + }; + + return controller; + } + + private static string ExtractCookieValue(string setCookieHeader) + { + var cookiePart = setCookieHeader.Split(';', 2)[0]; + var parts = cookiePart.Split('=', 2); + return parts.Length == 2 ? parts[1] : string.Empty; + } + + private sealed class DelegateHttpMessageHandler : HttpMessageHandler + { + private readonly Func> _handler; + + public DelegateHttpMessageHandler(Func> handler) + { + _handler = handler; + } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + return _handler(request, cancellationToken); + } + } +} diff --git a/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs b/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs new file mode 100644 index 0000000..17d23fc --- /dev/null +++ b/allstarr.Tests/AdminAuthenticationMiddlewareTests.cs @@ -0,0 +1,198 @@ +using System.Text; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Logging.Abstractions; +using allstarr.Middleware; +using allstarr.Services.Admin; + +namespace allstarr.Tests; + +public class AdminAuthenticationMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_UnauthenticatedAdminRequest_Returns401() + { + var sessionService = new AdminAuthSessionService(); + var nextInvoked = false; + + var middleware = new AdminAuthenticationMiddleware( + _ => + { + nextInvoked = true; + return Task.CompletedTask; + }, + sessionService, + NullLogger.Instance); + + var context = CreateContext( + path: "/api/admin/config", + method: HttpMethods.Get, + localPort: 5275); + + await middleware.InvokeAsync(context); + + Assert.False(nextInvoked); + Assert.Equal(StatusCodes.Status401Unauthorized, context.Response.StatusCode); + + var body = await ReadResponseBodyAsync(context); + Assert.Contains("Authentication required", body); + } + + [Fact] + public async Task InvokeAsync_NonAdminUser_AllowedRoute_PassesThrough() + { + var sessionService = new AdminAuthSessionService(); + var session = sessionService.CreateSession( + userId: "user-1", + userName: "josh", + isAdministrator: false, + jellyfinAccessToken: "token", + jellyfinServerId: "server"); + + var nextInvoked = false; + var middleware = new AdminAuthenticationMiddleware( + context => + { + nextInvoked = true; + context.Response.StatusCode = StatusCodes.Status204NoContent; + return Task.CompletedTask; + }, + sessionService, + NullLogger.Instance); + + var context = CreateContext( + path: "/api/admin/jellyfin/playlists", + method: HttpMethods.Get, + localPort: 5275, + sessionIdCookie: session.SessionId); + + await middleware.InvokeAsync(context); + + Assert.True(nextInvoked); + Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + Assert.True(context.Items.ContainsKey(AdminAuthSessionService.HttpContextSessionItemKey)); + } + + [Fact] + public async Task InvokeAsync_NonAdminUser_DisallowedRoute_Returns403() + { + var sessionService = new AdminAuthSessionService(); + var session = sessionService.CreateSession( + userId: "user-1", + userName: "josh", + isAdministrator: false, + jellyfinAccessToken: "token", + jellyfinServerId: "server"); + + var nextInvoked = false; + var middleware = new AdminAuthenticationMiddleware( + _ => + { + nextInvoked = true; + return Task.CompletedTask; + }, + sessionService, + NullLogger.Instance); + + var context = CreateContext( + path: "/api/admin/config", + method: HttpMethods.Get, + localPort: 5275, + sessionIdCookie: session.SessionId); + + await middleware.InvokeAsync(context); + + Assert.False(nextInvoked); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + + var body = await ReadResponseBodyAsync(context); + Assert.Contains("Administrator permissions required", body); + } + + [Fact] + public async Task InvokeAsync_AdminUser_DisallowedForUserButAllowedForAdmin_PassesThrough() + { + var sessionService = new AdminAuthSessionService(); + var session = sessionService.CreateSession( + userId: "admin-1", + userName: "admin", + isAdministrator: true, + jellyfinAccessToken: "token", + jellyfinServerId: "server"); + + var nextInvoked = false; + var middleware = new AdminAuthenticationMiddleware( + context => + { + nextInvoked = true; + context.Response.StatusCode = StatusCodes.Status204NoContent; + return Task.CompletedTask; + }, + sessionService, + NullLogger.Instance); + + var context = CreateContext( + path: "/api/admin/config", + method: HttpMethods.Get, + localPort: 5275, + sessionIdCookie: session.SessionId); + + await middleware.InvokeAsync(context); + + Assert.True(nextInvoked); + Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_AdminApiOnMainPort_PassesThroughForDownstreamFilter() + { + var sessionService = new AdminAuthSessionService(); + var nextInvoked = false; + + var middleware = new AdminAuthenticationMiddleware( + context => + { + nextInvoked = true; + context.Response.StatusCode = StatusCodes.Status404NotFound; + return Task.CompletedTask; + }, + sessionService, + NullLogger.Instance); + + var context = CreateContext( + path: "/api/admin/config", + method: HttpMethods.Get, + localPort: 5274); + + await middleware.InvokeAsync(context); + + Assert.True(nextInvoked); + Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); + } + + private static DefaultHttpContext CreateContext( + string path, + string method, + int localPort, + string? sessionIdCookie = null) + { + var context = new DefaultHttpContext(); + context.Request.Path = path; + context.Request.Method = method; + context.Connection.LocalPort = localPort; + context.Response.Body = new MemoryStream(); + + if (!string.IsNullOrWhiteSpace(sessionIdCookie)) + { + context.Request.Headers.Cookie = $"{AdminAuthSessionService.SessionCookieName}={sessionIdCookie}"; + } + + return context; + } + + private static async Task ReadResponseBodyAsync(HttpContext context) + { + context.Response.Body.Seek(0, SeekOrigin.Begin); + using var reader = new StreamReader(context.Response.Body, Encoding.UTF8, leaveOpen: true); + return await reader.ReadToEndAsync(); + } +} diff --git a/allstarr.Tests/AdminNetworkAllowlistMiddlewareTests.cs b/allstarr.Tests/AdminNetworkAllowlistMiddlewareTests.cs new file mode 100644 index 0000000..b0088cd --- /dev/null +++ b/allstarr.Tests/AdminNetworkAllowlistMiddlewareTests.cs @@ -0,0 +1,93 @@ +using System.Net; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; +using allstarr.Middleware; + +namespace allstarr.Tests; + +public class AdminNetworkAllowlistMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_AdminPortLoopback_AllowsRequest() + { + var middleware = CreateMiddleware(new Dictionary(), out var nextInvoked); + var context = CreateContext(5275, "127.0.0.1"); + + await middleware.InvokeAsync(context); + + Assert.True(nextInvoked()); + Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_AdminPortUntrustedSubnet_BlocksRequest() + { + var middleware = CreateMiddleware(new Dictionary(), out var nextInvoked); + var context = CreateContext(5275, "192.168.1.25"); + + await middleware.InvokeAsync(context); + + Assert.False(nextInvoked()); + Assert.Equal(StatusCodes.Status403Forbidden, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_AdminPortTrustedSubnet_AllowsRequest() + { + var middleware = CreateMiddleware(new Dictionary + { + ["Admin:TrustedSubnets"] = "192.168.1.0/24" + }, out var nextInvoked); + var context = CreateContext(5275, "192.168.1.25"); + + await middleware.InvokeAsync(context); + + Assert.True(nextInvoked()); + Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + } + + [Fact] + public async Task InvokeAsync_NonAdminPort_BypassesAllowlist() + { + var middleware = CreateMiddleware(new Dictionary(), out var nextInvoked); + var context = CreateContext(8080, "8.8.8.8"); + + await middleware.InvokeAsync(context); + + Assert.True(nextInvoked()); + Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + } + + private static AdminNetworkAllowlistMiddleware CreateMiddleware( + IDictionary configValues, + out Func nextInvoked) + { + var invoked = false; + nextInvoked = () => invoked; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues) + .Build(); + + return new AdminNetworkAllowlistMiddleware( + context => + { + invoked = true; + context.Response.StatusCode = StatusCodes.Status204NoContent; + return Task.CompletedTask; + }, + configuration, + NullLogger.Instance); + } + + private static DefaultHttpContext CreateContext(int localPort, string remoteIp) + { + var context = new DefaultHttpContext(); + context.Connection.LocalPort = localPort; + context.Connection.RemoteIpAddress = IPAddress.Parse(remoteIp); + context.Request.Path = "/api/admin/status"; + context.Response.Body = new MemoryStream(); + return context; + } +} diff --git a/allstarr.Tests/AdminNetworkBindingPolicyTests.cs b/allstarr.Tests/AdminNetworkBindingPolicyTests.cs new file mode 100644 index 0000000..d00db6e --- /dev/null +++ b/allstarr.Tests/AdminNetworkBindingPolicyTests.cs @@ -0,0 +1,67 @@ +using allstarr.Services.Common; +using Microsoft.Extensions.Configuration; +using System.Net; + +namespace allstarr.Tests; + +public class AdminNetworkBindingPolicyTests +{ + [Fact] + public void ShouldBindAdminAnyIp_DefaultsToFalse_WhenUnset() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); + + var bindAnyIp = AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(configuration); + + Assert.False(bindAnyIp); + } + + [Fact] + public void ShouldBindAdminAnyIp_ReturnsTrue_WhenConfigured() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Admin:BindAnyIp"] = "true" + }) + .Build(); + + var bindAnyIp = AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(configuration); + + Assert.True(bindAnyIp); + } + + [Fact] + public void ParseTrustedSubnets_ReturnsOnlyValidNetworks() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Admin:TrustedSubnets"] = "192.168.1.0/24, invalid,10.0.0.0/8" + }) + .Build(); + + var subnets = AdminNetworkBindingPolicy.ParseTrustedSubnets(configuration); + + Assert.Equal(2, subnets.Count); + Assert.Contains(subnets, n => n.ToString() == "192.168.1.0/24"); + Assert.Contains(subnets, n => n.ToString() == "10.0.0.0/8"); + } + + [Theory] + [InlineData("127.0.0.1", true)] + [InlineData("192.168.1.55", true)] + [InlineData("10.25.1.3", false)] + public void IsRemoteIpAllowed_HonorsLoopbackAndTrustedSubnets(string ip, bool expected) + { + var trusted = new List(); + Assert.True(IPNetwork.TryParse("192.168.1.0/24", out var subnet)); + trusted.Add(subnet); + + var allowed = AdminNetworkBindingPolicy.IsRemoteIpAllowed(IPAddress.Parse(ip), trusted); + + Assert.Equal(expected, allowed); + } +} diff --git a/allstarr.Tests/AdminStaticFilesMiddlewareTests.cs b/allstarr.Tests/AdminStaticFilesMiddlewareTests.cs new file mode 100644 index 0000000..b63cc3b --- /dev/null +++ b/allstarr.Tests/AdminStaticFilesMiddlewareTests.cs @@ -0,0 +1,147 @@ +using allstarr.Middleware; +using Microsoft.AspNetCore.Http; +using Moq; + +namespace allstarr.Tests; + +public class AdminStaticFilesMiddlewareTests +{ + [Fact] + public async Task InvokeAsync_AdminRootPath_ServesIndexHtml() + { + var webRoot = CreateTempWebRoot(); + await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), "ok"); + + try + { + var middleware = CreateMiddleware(webRoot, out var nextInvoked); + var context = CreateContext(localPort: 5275, path: "/"); + + await middleware.InvokeAsync(context); + + Assert.False(nextInvoked()); + Assert.Equal("text/html", context.Response.ContentType); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + finally + { + DeleteTempWebRoot(webRoot); + } + } + + [Fact] + public async Task InvokeAsync_AdminPathTraversalAttempt_ReturnsNotFound() + { + var webRoot = CreateTempWebRoot(); + var parent = Directory.GetParent(webRoot)!.FullName; + await File.WriteAllTextAsync(Path.Combine(parent, "secret.txt"), "secret"); + + try + { + var middleware = CreateMiddleware(webRoot, out var nextInvoked); + var context = CreateContext(localPort: 5275, path: "/../secret.txt"); + + await middleware.InvokeAsync(context); + + Assert.False(nextInvoked()); + Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode); + } + finally + { + DeleteTempWebRoot(webRoot); + } + } + + [Fact] + public async Task InvokeAsync_AdminValidStaticFile_ServesFile() + { + var webRoot = CreateTempWebRoot(); + var jsDir = Path.Combine(webRoot, "js"); + Directory.CreateDirectory(jsDir); + await File.WriteAllTextAsync(Path.Combine(jsDir, "app.js"), "console.log('ok');"); + + try + { + var middleware = CreateMiddleware(webRoot, out var nextInvoked); + var context = CreateContext(localPort: 5275, path: "/js/app.js"); + + await middleware.InvokeAsync(context); + + Assert.False(nextInvoked()); + Assert.Equal("application/javascript", context.Response.ContentType); + Assert.Equal(StatusCodes.Status200OK, context.Response.StatusCode); + } + finally + { + DeleteTempWebRoot(webRoot); + } + } + + [Fact] + public async Task InvokeAsync_NonAdminPort_BypassesStaticMiddleware() + { + var webRoot = CreateTempWebRoot(); + await File.WriteAllTextAsync(Path.Combine(webRoot, "index.html"), "ok"); + + try + { + var middleware = CreateMiddleware(webRoot, out var nextInvoked); + var context = CreateContext(localPort: 8080, path: "/index.html"); + + await middleware.InvokeAsync(context); + + Assert.True(nextInvoked()); + Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode); + } + finally + { + DeleteTempWebRoot(webRoot); + } + } + + private static AdminStaticFilesMiddleware CreateMiddleware( + string webRootPath, + out Func nextInvoked) + { + var invoked = false; + nextInvoked = () => invoked; + + var environment = new Mock(); + environment.SetupGet(x => x.WebRootPath).Returns(webRootPath); + + return new AdminStaticFilesMiddleware( + context => + { + invoked = true; + context.Response.StatusCode = StatusCodes.Status204NoContent; + return Task.CompletedTask; + }, + environment.Object); + } + + private static DefaultHttpContext CreateContext(int localPort, string path) + { + var context = new DefaultHttpContext(); + context.Connection.LocalPort = localPort; + context.Request.Method = HttpMethods.Get; + context.Request.Path = path; + context.Response.Body = new MemoryStream(); + return context; + } + + private static string CreateTempWebRoot() + { + var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N"), "wwwroot"); + Directory.CreateDirectory(root); + return root; + } + + private static void DeleteTempWebRoot(string webRoot) + { + var testRoot = Directory.GetParent(webRoot)?.FullName; + if (!string.IsNullOrWhiteSpace(testRoot) && Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: true); + } + } +} diff --git a/allstarr.Tests/ApiKeyAuthFilterTests.cs b/allstarr.Tests/ApiKeyAuthFilterTests.cs deleted file mode 100644 index 3015301..0000000 --- a/allstarr.Tests/ApiKeyAuthFilterTests.cs +++ /dev/null @@ -1,167 +0,0 @@ -using System.Threading.Tasks; -using Microsoft.AspNetCore.Http; -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Abstractions; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.AspNetCore.Routing; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Moq; -using System.Collections.Generic; -using allstarr.Filters; -using allstarr.Models.Settings; - -namespace allstarr.Tests; - -public class ApiKeyAuthFilterTests -{ - private readonly Mock> _loggerMock; - private readonly IOptions _options; - - public ApiKeyAuthFilterTests() - { - _loggerMock = new Mock>(); - _options = Options.Create(new JellyfinSettings { ApiKey = "secret-key" }); - } - - private static (ActionExecutingContext ExecContext, ActionContext ActionContext) CreateContext(HttpContext httpContext) - { - var actionContext = new ActionContext(httpContext, new RouteData(), new ActionDescriptor()); - var execContext = new ActionExecutingContext(actionContext, new List(), new Dictionary(), controller: new object()); - return (execContext, actionContext); - } - - private static ActionExecutionDelegate CreateNext(ActionContext actionContext, Action onInvoke) - { - return () => - { - onInvoke(); - var executedContext = new ActionExecutedContext(actionContext, new List(), controller: new object()); - return Task.FromResult(executedContext); - }; - } - - [Fact] - public async Task OnActionExecutionAsync_WithValidHeader_AllowsRequest() - { - // Arrange - var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers["X-Api-Key"] = "secret-key"; - - var (ctx, actionCtx) = CreateContext(httpContext); - var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); - - var invoked = false; - var next = CreateNext(actionCtx, () => invoked = true); - - // Act - await filter.OnActionExecutionAsync(ctx, next); - - // Assert - Assert.True(invoked, "Next delegate should be invoked for valid API key header"); - Assert.Null(ctx.Result); - } - - [Fact] - public async Task OnActionExecutionAsync_WithValidQuery_AllowsRequest() - { - // Arrange - var httpContext = new DefaultHttpContext(); - httpContext.Request.QueryString = new QueryString("?api_key=secret-key"); - - var (ctx, actionCtx) = CreateContext(httpContext); - var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); - - var invoked = false; - var next = CreateNext(actionCtx, () => invoked = true); - - // Act - await filter.OnActionExecutionAsync(ctx, next); - - // Assert - Assert.True(invoked, "Next delegate should be invoked for valid API key query"); - Assert.Null(ctx.Result); - } - - [Fact] - public async Task OnActionExecutionAsync_WithXEmbyTokenHeader_AllowsRequest() - { - // Arrange - var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers["X-Emby-Token"] = "secret-key"; - - var (ctx, actionCtx) = CreateContext(httpContext); - var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); - - var invoked = false; - var next = CreateNext(actionCtx, () => invoked = true); - - // Act - await filter.OnActionExecutionAsync(ctx, next); - - // Assert - Assert.True(invoked, "Next delegate should be invoked for valid X-Emby-Token header"); - Assert.Null(ctx.Result); - } - - [Fact] - public async Task OnActionExecutionAsync_WithMissingKey_ReturnsUnauthorized() - { - // Arrange - var httpContext = new DefaultHttpContext(); - var (ctx, actionCtx) = CreateContext(httpContext); - var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); - - var invoked = false; - var next = CreateNext(actionCtx, () => invoked = true); - - // Act - await filter.OnActionExecutionAsync(ctx, next); - - // Assert - Assert.False(invoked, "Next delegate should not be invoked when API key is missing"); - Assert.IsType(ctx.Result); - } - - [Fact] - public async Task OnActionExecutionAsync_WithWrongKey_ReturnsUnauthorized() - { - // Arrange - var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers["X-Api-Key"] = "wrong-key"; - - var (ctx, actionCtx) = CreateContext(httpContext); - var filter = new ApiKeyAuthFilter(_options, _loggerMock.Object); - - var invoked = false; - var next = CreateNext(actionCtx, () => invoked = true); - - // Act - await filter.OnActionExecutionAsync(ctx, next); - - // Assert - Assert.False(invoked, "Next delegate should not be invoked for wrong API key"); - Assert.IsType(ctx.Result); - } - - [Fact] - public async Task OnActionExecutionAsync_ConstantTimeComparison_WorksForDifferentLengths() - { - // Arrange - var httpContext = new DefaultHttpContext(); - httpContext.Request.Headers["X-Api-Key"] = "short"; - - var (ctx, actionCtx) = CreateContext(httpContext); - var filter = new ApiKeyAuthFilter(Options.Create(new JellyfinSettings { ApiKey = "much-longer-secret-key" }), _loggerMock.Object); - - var invoked = false; - var next = CreateNext(actionCtx, () => invoked = true); - - // Act - await filter.OnActionExecutionAsync(ctx, next); - - // Assert - Assert.False(invoked, "Next should not be invoked for wrong key"); - Assert.IsType(ctx.Result); - } -} diff --git a/allstarr.Tests/AuthHeaderHelperTests.cs b/allstarr.Tests/AuthHeaderHelperTests.cs new file mode 100644 index 0000000..bf004e2 --- /dev/null +++ b/allstarr.Tests/AuthHeaderHelperTests.cs @@ -0,0 +1,87 @@ +using allstarr.Services.Common; +using Microsoft.AspNetCore.Http; +using Xunit; + +namespace allstarr.Tests; + +public class AuthHeaderHelperTests +{ + [Fact] + public void ForwardAuthHeaders_ShouldPreferXEmbyAuthorization() + { + var headers = new HeaderDictionary + { + ["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\"", + ["Authorization"] = "Bearer xyz" + }; + + using var request = new HttpRequestMessage(); + var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request); + + Assert.True(forwarded); + Assert.True(request.Headers.TryGetValues("X-Emby-Authorization", out var values)); + Assert.Contains("MediaBrowser Token=\"abc\"", values); + Assert.False(request.Headers.Contains("Authorization")); + } + + [Fact] + public void ForwardAuthHeaders_ShouldMapMediaBrowserAuthorizationToXEmby() + { + var headers = new HeaderDictionary + { + ["Authorization"] = "MediaBrowser Client=\"Feishin\", Token=\"abc\"" + }; + + using var request = new HttpRequestMessage(); + var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request); + + Assert.True(forwarded); + Assert.True(request.Headers.Contains("X-Emby-Authorization")); + } + + [Fact] + public void ForwardAuthHeaders_ShouldForwardStandardAuthorization() + { + var headers = new HeaderDictionary + { + ["Authorization"] = "Bearer xyz" + }; + + using var request = new HttpRequestMessage(); + var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request); + + Assert.True(forwarded); + Assert.True(request.Headers.Contains("Authorization")); + } + + [Fact] + public void ExtractDeviceIdAndClientName_ShouldParseMediaBrowserHeader() + { + var headers = new HeaderDictionary + { + ["X-Emby-Authorization"] = + "MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\"" + }; + + Assert.Equal("dev-123", AuthHeaderHelper.ExtractDeviceId(headers)); + Assert.Equal("Feishin", AuthHeaderHelper.ExtractClientName(headers)); + } + + [Fact] + public void CreateAuthHeader_ShouldBuildMediaBrowserString() + { + var header = AuthHeaderHelper.CreateAuthHeader( + token: "abc", + client: "Feishin", + device: "Desktop", + deviceId: "dev-123", + version: "1.0"); + + Assert.Contains("MediaBrowser", header); + Assert.Contains("Client=\"Feishin\"", header); + Assert.Contains("Device=\"Desktop\"", header); + Assert.Contains("DeviceId=\"dev-123\"", header); + Assert.Contains("Version=\"1.0\"", header); + Assert.Contains("Token=\"abc\"", header); + } +} diff --git a/allstarr.Tests/CacheKeyBuilderTests.cs b/allstarr.Tests/CacheKeyBuilderTests.cs new file mode 100644 index 0000000..a6293fb --- /dev/null +++ b/allstarr.Tests/CacheKeyBuilderTests.cs @@ -0,0 +1,71 @@ +using allstarr.Services.Common; +using Xunit; + +namespace allstarr.Tests; + +public class CacheKeyBuilderTests +{ + [Fact] + public void SearchKey_ShouldIncludeRouteContextDimensions() + { + var key = CacheKeyBuilder.BuildSearchKey( + " DATA ", + "MusicAlbum", + 500, + 0, + "efa26829c37196b030fa31d127e0715b", + "DateCreated,SortName", + "Descending", + true, + "1635cd7d23144ba08251ebe22a56119e"); + + Assert.Equal( + "search:data:musicalbum:500:0:efa26829c37196b030fa31d127e0715b:datecreated,sortname:descending:true:1635cd7d23144ba08251ebe22a56119e", + key); + } + + [Fact] + public void SearchKey_OldOverload_ShouldRemainCompatible() + { + Assert.Equal("search:data:Audio:500:0", CacheKeyBuilder.BuildSearchKey("DATA", "Audio", 500, 0)); + } + + [Fact] + public void SpotifyKeys_ShouldMatchExpectedFormats() + { + Assert.Equal("spotify:playlist:Road Trip", CacheKeyBuilder.BuildSpotifyPlaylistKey("Road Trip")); + Assert.Equal("spotify:playlist:items:Road Trip", CacheKeyBuilder.BuildSpotifyPlaylistItemsKey("Road Trip")); + Assert.Equal("spotify:playlist:ordered:Road Trip", CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey("Road Trip")); + Assert.Equal("spotify:matched:ordered:Road Trip", CacheKeyBuilder.BuildSpotifyMatchedTracksKey("Road Trip")); + Assert.Equal("spotify:matched:Road Trip", CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey("Road Trip")); + Assert.Equal("spotify:playlist:stats:Road Trip", CacheKeyBuilder.BuildSpotifyPlaylistStatsKey("Road Trip")); + Assert.Equal("spotify:manual-map:Road Trip:abc123", CacheKeyBuilder.BuildSpotifyManualMappingKey("Road Trip", "abc123")); + Assert.Equal("spotify:external-map:Road Trip:abc123", CacheKeyBuilder.BuildSpotifyExternalMappingKey("Road Trip", "abc123")); + Assert.Equal("spotify:global-map:abc123", CacheKeyBuilder.BuildSpotifyGlobalMappingKey("abc123")); + Assert.Equal("spotify:global-map:all-ids", CacheKeyBuilder.BuildSpotifyGlobalMappingsIndexKey()); + } + + [Fact] + public void LyricsAndGenreKeys_ShouldMatchExpectedFormats() + { + Assert.Equal("lyrics:Artist:Title:Album:240", CacheKeyBuilder.BuildLyricsKey("Artist", "Title", "Album", 240)); + Assert.Equal("lyricsplus:Artist:Title:Album:240", CacheKeyBuilder.BuildLyricsPlusKey("Artist", "Title", "Album", 240)); + Assert.Equal("lyrics:manual-map:Artist:Title", CacheKeyBuilder.BuildLyricsManualMappingKey("Artist", "Title")); + Assert.Equal("lyrics:id:42", CacheKeyBuilder.BuildLyricsByIdKey(42)); + + Assert.Equal("genre:Track:Artist", CacheKeyBuilder.BuildGenreEnrichmentKey("Track", "Artist")); + Assert.Equal("genre:Track:Artist", CacheKeyBuilder.BuildGenreEnrichmentKey("Track:Artist")); + Assert.Equal("genre:rock", CacheKeyBuilder.BuildGenreKey("Rock")); + } + + [Fact] + public void MusicBrainzAndOdesliKeys_ShouldMatchExpectedFormats() + { + Assert.Equal("musicbrainz:isrc:USABC123", CacheKeyBuilder.BuildMusicBrainzIsrcKey("USABC123")); + Assert.Equal("musicbrainz:search:title:artist:5", CacheKeyBuilder.BuildMusicBrainzSearchKey("Title", "Artist", 5)); + Assert.Equal("musicbrainz:mbid:abc-def", CacheKeyBuilder.BuildMusicBrainzMbidKey("abc-def")); + + Assert.Equal("odesli:tidal-to-spotify:123", CacheKeyBuilder.BuildOdesliTidalToSpotifyKey("123")); + Assert.Equal("odesli:url-to-spotify:https://example.com/track", CacheKeyBuilder.BuildOdesliUrlToSpotifyKey("https://example.com/track")); + } +} diff --git a/allstarr.Tests/ConfigControllerAuthorizationTests.cs b/allstarr.Tests/ConfigControllerAuthorizationTests.cs new file mode 100644 index 0000000..c2cee63 --- /dev/null +++ b/allstarr.Tests/ConfigControllerAuthorizationTests.cs @@ -0,0 +1,166 @@ +using System.Text.Json; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using allstarr.Controllers; +using allstarr.Models.Admin; +using allstarr.Models.Settings; +using allstarr.Services.Admin; +using allstarr.Services.Common; +using allstarr.Services.Spotify; + +namespace allstarr.Tests; + +public class ConfigControllerAuthorizationTests +{ + [Fact] + public async Task UpdateConfig_WithoutAdminSession_ReturnsForbidden() + { + var controller = CreateController(CreateHttpContextWithSession(isAdmin: false)); + var result = await controller.UpdateConfig(new ConfigUpdateRequest + { + Updates = new Dictionary { ["TEST_KEY"] = "value" } + }); + + AssertForbidden(result); + } + + [Fact] + public async Task RestartContainer_WithoutAdminSession_ReturnsForbidden() + { + var controller = CreateController(CreateHttpContextWithSession(isAdmin: false)); + var result = await controller.RestartContainer(); + + AssertForbidden(result); + } + + [Fact] + public void ExportEnv_WithoutAdminSession_ReturnsForbidden() + { + var controller = CreateController(CreateHttpContextWithSession(isAdmin: false)); + var result = controller.ExportEnv(); + + AssertForbidden(result); + } + + [Fact] + public async Task ImportEnv_WithoutAdminSession_ReturnsForbidden() + { + var controller = CreateController(CreateHttpContextWithSession(isAdmin: false)); + var file = new FormFile(Stream.Null, 0, 0, "file", "config.env"); + var result = await controller.ImportEnv(file); + + AssertForbidden(result); + } + + [Fact] + public async Task UpdateConfig_WithAdminSession_ContinuesToValidation() + { + var controller = CreateController(CreateHttpContextWithSession(isAdmin: true)); + var result = await controller.UpdateConfig(new ConfigUpdateRequest()); + + var badRequest = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); + } + + [Fact] + public void ExportEnv_WithAdminSession_WhenFeatureDisabled_ReturnsNotFound() + { + var controller = CreateController(CreateHttpContextWithSession(isAdmin: true)); + var result = controller.ExportEnv(); + + var notFound = Assert.IsType(result); + Assert.Equal(StatusCodes.Status404NotFound, notFound.StatusCode); + } + + private static HttpContext CreateHttpContextWithSession(bool isAdmin) + { + var context = new DefaultHttpContext(); + context.Connection.LocalPort = 5275; + context.Items[AdminAuthSessionService.HttpContextSessionItemKey] = new AdminAuthSession + { + SessionId = "session-id", + UserId = "user-id", + UserName = "user", + IsAdministrator = isAdmin, + JellyfinAccessToken = "token", + JellyfinServerId = "server-id", + ExpiresAtUtc = DateTime.UtcNow.AddHours(1), + LastSeenUtc = DateTime.UtcNow + }; + + return context; + } + + private static ConfigController CreateController( + HttpContext httpContext, + Dictionary? configValues = null) + { + var logger = new Mock>(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(configValues ?? new Dictionary()) + .Build(); + + var webHostEnvironment = new Mock(); + webHostEnvironment.SetupGet(e => e.EnvironmentName).Returns(Environments.Development); + webHostEnvironment.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory()); + var helperLogger = new Mock>(); + var helperService = new AdminHelperService( + helperLogger.Object, + Options.Create(new JellyfinSettings()), + webHostEnvironment.Object); + + var redisLogger = new Mock>(); + var redisCache = new RedisCacheService( + Options.Create(new RedisSettings + { + Enabled = false, + ConnectionString = "localhost:6379" + }), + redisLogger.Object); + var spotifyCookieLogger = new Mock>(); + var spotifySessionCookieService = new SpotifySessionCookieService( + Options.Create(new SpotifyApiSettings()), + helperService, + spotifyCookieLogger.Object); + + var controller = new ConfigController( + logger.Object, + configuration, + Options.Create(new SpotifyApiSettings()), + Options.Create(new JellyfinSettings()), + Options.Create(new SubsonicSettings()), + Options.Create(new DeezerSettings()), + Options.Create(new QobuzSettings()), + Options.Create(new SquidWTFSettings()), + Options.Create(new MusicBrainzSettings()), + Options.Create(new SpotifyImportSettings()), + Options.Create(new ScrobblingSettings()), + helperService, + spotifySessionCookieService, + redisCache) + { + ControllerContext = new ControllerContext + { + HttpContext = httpContext + } + }; + + return controller; + } + + private static void AssertForbidden(IActionResult result) + { + var forbidden = Assert.IsType(result); + Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode); + + var payload = JsonSerializer.Serialize(forbidden.Value); + using var document = JsonDocument.Parse(payload); + Assert.Equal("Administrator permissions required", document.RootElement.GetProperty("error").GetString()); + } +} diff --git a/allstarr.Tests/DownloadsControllerPathSecurityTests.cs b/allstarr.Tests/DownloadsControllerPathSecurityTests.cs new file mode 100644 index 0000000..8712fe4 --- /dev/null +++ b/allstarr.Tests/DownloadsControllerPathSecurityTests.cs @@ -0,0 +1,117 @@ +using allstarr.Controllers; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging.Abstractions; + +namespace allstarr.Tests; + +public class DownloadsControllerPathSecurityTests +{ + [Fact] + public void DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected() + { + var testRoot = CreateTestRoot(); + var downloadsRoot = Path.Combine(testRoot, "downloads"); + var keptRoot = Path.Combine(downloadsRoot, "kept"); + var siblingRoot = Path.Combine(downloadsRoot, "kept-malicious"); + + Directory.CreateDirectory(keptRoot); + Directory.CreateDirectory(siblingRoot); + File.WriteAllText(Path.Combine(siblingRoot, "attack.mp3"), "not-allowed"); + + try + { + var controller = CreateController(downloadsRoot); + var result = controller.DownloadFile("../kept-malicious/attack.mp3"); + + var badRequest = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); + } + finally + { + DeleteTestRoot(testRoot); + } + } + + [Fact] + public void DeleteDownload_PathTraversalIntoPrefixedSibling_IsRejected() + { + var testRoot = CreateTestRoot(); + var downloadsRoot = Path.Combine(testRoot, "downloads"); + var keptRoot = Path.Combine(downloadsRoot, "kept"); + var siblingRoot = Path.Combine(downloadsRoot, "kept-malicious"); + var siblingFile = Path.Combine(siblingRoot, "attack.mp3"); + + Directory.CreateDirectory(keptRoot); + Directory.CreateDirectory(siblingRoot); + File.WriteAllText(siblingFile, "not-allowed"); + + try + { + var controller = CreateController(downloadsRoot); + var result = controller.DeleteDownload("../kept-malicious/attack.mp3"); + + var badRequest = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); + Assert.True(File.Exists(siblingFile)); + } + finally + { + DeleteTestRoot(testRoot); + } + } + + [Fact] + public void DownloadFile_ValidPathInsideKeptFolder_AllowsDownload() + { + var testRoot = CreateTestRoot(); + var downloadsRoot = Path.Combine(testRoot, "downloads"); + var artistDir = Path.Combine(downloadsRoot, "kept", "Artist"); + var validFile = Path.Combine(artistDir, "track.mp3"); + + Directory.CreateDirectory(artistDir); + File.WriteAllText(validFile, "ok"); + + try + { + var controller = CreateController(downloadsRoot); + var result = controller.DownloadFile("Artist/track.mp3"); + + Assert.IsType(result); + } + finally + { + DeleteTestRoot(testRoot); + } + } + + private static DownloadsController CreateController(string downloadsRoot) + { + var config = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary + { + ["Library:DownloadPath"] = downloadsRoot + }) + .Build(); + + return new DownloadsController( + NullLogger.Instance, + config); + } + + private static string CreateTestRoot() + { + var root = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(root); + return root; + } + + private static void DeleteTestRoot(string root) + { + if (Directory.Exists(root)) + { + Directory.Delete(root, recursive: true); + } + } +} diff --git a/allstarr.Tests/ExplicitContentFilterTests.cs b/allstarr.Tests/ExplicitContentFilterTests.cs new file mode 100644 index 0000000..e6b5516 --- /dev/null +++ b/allstarr.Tests/ExplicitContentFilterTests.cs @@ -0,0 +1,43 @@ +using allstarr.Models.Domain; +using allstarr.Models.Settings; +using allstarr.Services.Common; +using Xunit; + +namespace allstarr.Tests; + +public class ExplicitContentFilterTests +{ + [Fact] + public void ShouldIncludeSong_ShouldIncludeUnknownExplicitState() + { + var song = new Song { ExplicitContentLyrics = null }; + + Assert.True(ExplicitContentFilter.ShouldIncludeSong(song, ExplicitFilter.All)); + Assert.True(ExplicitContentFilter.ShouldIncludeSong(song, ExplicitFilter.ExplicitOnly)); + Assert.True(ExplicitContentFilter.ShouldIncludeSong(song, ExplicitFilter.CleanOnly)); + } + + [Fact] + public void ShouldIncludeSong_ExplicitOnly_ShouldExcludeOnlyCleanEditedValue3() + { + Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 0 }, ExplicitFilter.ExplicitOnly)); + Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 1 }, ExplicitFilter.ExplicitOnly)); + Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 2 }, ExplicitFilter.ExplicitOnly)); + Assert.False(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 3 }, ExplicitFilter.ExplicitOnly)); + } + + [Fact] + public void ShouldIncludeSong_CleanOnly_ShouldExcludeExplicitValue1() + { + Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 0 }, ExplicitFilter.CleanOnly)); + Assert.False(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 1 }, ExplicitFilter.CleanOnly)); + Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 3 }, ExplicitFilter.CleanOnly)); + } + + [Fact] + public void ShouldIncludeSong_All_ShouldAlwaysInclude() + { + Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 1 }, ExplicitFilter.All)); + Assert.True(ExplicitContentFilter.ShouldIncludeSong(new Song { ExplicitContentLyrics = 3 }, ExplicitFilter.All)); + } +} diff --git a/allstarr.Tests/JavaScriptSyntaxTests.cs b/allstarr.Tests/JavaScriptSyntaxTests.cs index e27916a..6f74e39 100644 --- a/allstarr.Tests/JavaScriptSyntaxTests.cs +++ b/allstarr.Tests/JavaScriptSyntaxTests.cs @@ -1,4 +1,5 @@ using System.Diagnostics; +using System.Text.RegularExpressions; using Xunit; namespace allstarr.Tests; @@ -23,7 +24,7 @@ public class JavaScriptSyntaxTests { var filePath = Path.Combine(_wwwrootPath, "app.js"); Assert.True(File.Exists(filePath), $"app.js not found at {filePath}"); - + var isValid = ValidateJavaScriptSyntax(filePath, out var error); Assert.True(isValid, $"app.js has syntax errors:\n{error}"); } @@ -33,7 +34,7 @@ public class JavaScriptSyntaxTests { var filePath = Path.Combine(_wwwrootPath, "spotify-mappings.js"); Assert.True(File.Exists(filePath), $"spotify-mappings.js not found at {filePath}"); - + var isValid = ValidateJavaScriptSyntax(filePath, out var error); Assert.True(isValid, $"spotify-mappings.js has syntax errors:\n{error}"); } @@ -43,7 +44,7 @@ public class JavaScriptSyntaxTests { var filePath = Path.Combine(_wwwrootPath, "js", "utils.js"); Assert.True(File.Exists(filePath), $"js/utils.js not found at {filePath}"); - + var isValid = ValidateJavaScriptSyntax(filePath, out var error); Assert.True(isValid, $"js/utils.js has syntax errors:\n{error}"); } @@ -53,7 +54,7 @@ public class JavaScriptSyntaxTests { var filePath = Path.Combine(_wwwrootPath, "js", "api.js"); Assert.True(File.Exists(filePath), $"js/api.js not found at {filePath}"); - + var isValid = ValidateJavaScriptSyntax(filePath, out var error); Assert.True(isValid, $"js/api.js has syntax errors:\n{error}"); } @@ -63,17 +64,40 @@ public class JavaScriptSyntaxTests { var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); Assert.True(File.Exists(filePath), $"js/main.js not found at {filePath}"); - + var isValid = ValidateJavaScriptSyntax(filePath, out var error); Assert.True(isValid, $"js/main.js has syntax errors:\n{error}"); } + [Fact] + public void ModularJs_ExtractedModulesShouldHaveValidSyntax() + { + var moduleFiles = new[] + { + "settings-editor.js", + "auth-session.js", + "dashboard-data.js", + "operations.js", + "playlist-admin.js", + "scrobbling-admin.js" + }; + + foreach (var moduleFile in moduleFiles) + { + var filePath = Path.Combine(_wwwrootPath, "js", moduleFile); + Assert.True(File.Exists(filePath), $"js/{moduleFile} not found at {filePath}"); + + var isValid = ValidateJavaScriptSyntax(filePath, out var error); + Assert.True(isValid, $"js/{moduleFile} has syntax errors:\n{error}"); + } + } + [Fact] public void AppJs_ShouldBeDeprecated() { var filePath = Path.Combine(_wwwrootPath, "app.js"); var content = File.ReadAllText(filePath); - + // Check that the file is now just a deprecation notice Assert.Contains("DEPRECATED", content); Assert.Contains("main.js", content); @@ -82,18 +106,45 @@ public class JavaScriptSyntaxTests [Fact] public void MainJs_ShouldBeComplete() { - var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); - var content = File.ReadAllText(filePath); - - // Check that critical window functions exist - Assert.Contains("window.fetchStatus", content); - Assert.Contains("window.fetchPlaylists", content); - Assert.Contains("window.fetchConfig", content); - Assert.Contains("window.fetchEndpointUsage", content); - - // Check that the file has proper initialization - Assert.Contains("DOMContentLoaded", content); - Assert.Contains("window.fetchStatus();", content); + var mainPath = Path.Combine(_wwwrootPath, "js", "main.js"); + var dashboardPath = Path.Combine(_wwwrootPath, "js", "dashboard-data.js"); + var settingsPath = Path.Combine(_wwwrootPath, "js", "settings-editor.js"); + var authPath = Path.Combine(_wwwrootPath, "js", "auth-session.js"); + var operationsPath = Path.Combine(_wwwrootPath, "js", "operations.js"); + var playlistPath = Path.Combine(_wwwrootPath, "js", "playlist-admin.js"); + var scrobblingPath = Path.Combine(_wwwrootPath, "js", "scrobbling-admin.js"); + + Assert.True(File.Exists(mainPath), $"js/main.js not found at {mainPath}"); + Assert.True(File.Exists(dashboardPath), $"js/dashboard-data.js not found at {dashboardPath}"); + Assert.True(File.Exists(settingsPath), $"js/settings-editor.js not found at {settingsPath}"); + Assert.True(File.Exists(authPath), $"js/auth-session.js not found at {authPath}"); + Assert.True(File.Exists(operationsPath), $"js/operations.js not found at {operationsPath}"); + Assert.True(File.Exists(playlistPath), $"js/playlist-admin.js not found at {playlistPath}"); + Assert.True(File.Exists(scrobblingPath), $"js/scrobbling-admin.js not found at {scrobblingPath}"); + + var mainContent = File.ReadAllText(mainPath); + var dashboardContent = File.ReadAllText(dashboardPath); + var settingsContent = File.ReadAllText(settingsPath); + var authContent = File.ReadAllText(authPath); + var operationsContent = File.ReadAllText(operationsPath); + var playlistContent = File.ReadAllText(playlistPath); + var scrobblingContent = File.ReadAllText(scrobblingPath); + + Assert.Contains("DOMContentLoaded", mainContent); + Assert.Contains("authSession.bootstrapAuth()", mainContent); + Assert.Contains("initDashboardData", mainContent); + + Assert.Contains("window.fetchStatus", dashboardContent); + Assert.Contains("window.fetchPlaylists", dashboardContent); + Assert.Contains("window.fetchConfig", dashboardContent); + Assert.Contains("window.fetchEndpointUsage", dashboardContent); + + Assert.Contains("window.openEditSetting", settingsContent); + Assert.Contains("window.saveEditSetting", settingsContent); + Assert.Contains("window.logoutAdminSession", authContent); + Assert.Contains("window.restartContainer", operationsContent); + Assert.Contains("window.linkPlaylist", playlistContent); + Assert.Contains("window.loadScrobblingConfig", scrobblingContent); } [Fact] @@ -103,10 +154,10 @@ public class JavaScriptSyntaxTests // Skip this test or check main.js instead var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); var content = File.ReadAllText(filePath); - + var openBraces = content.Count(c => c == '{'); var closeBraces = content.Count(c => c == '}'); - + Assert.Equal(openBraces, closeBraces); } @@ -116,15 +167,42 @@ public class JavaScriptSyntaxTests // app.js is now deprecated and just contains comments // Skip this test or check main.js instead var filePath = Path.Combine(_wwwrootPath, "js", "main.js"); - + // Use Node.js to validate syntax instead of counting parentheses // This is more reliable than regex-based string/comment removal string error; var isValid = ValidateJavaScriptSyntax(filePath, out error); - + Assert.True(isValid, $"JavaScript syntax validation failed: {error}"); } + [Fact] + public void ApiJs_ShouldCentralizeFetchHandling() + { + var filePath = Path.Combine(_wwwrootPath, "js", "api.js"); + var content = File.ReadAllText(filePath); + + Assert.Contains("async function requestJson", content); + Assert.Contains("async function requestBlob", content); + Assert.Contains("async function requestOptionalJson", content); + + var fetchCallCount = Regex.Matches(content, @"\bfetch\(").Count; + Assert.Equal(3, fetchCallCount); + } + + [Fact] + public void ScrobblingAdmin_ShouldUseApiWrappersInsteadOfDirectFetch() + { + var filePath = Path.Combine(_wwwrootPath, "js", "scrobbling-admin.js"); + var content = File.ReadAllText(filePath); + + Assert.DoesNotContain("fetch(", content); + Assert.Contains("API.fetchScrobblingStatus()", content); + Assert.Contains("API.updateLocalTracksScrobbling", content); + Assert.Contains("API.authenticateLastFm()", content); + Assert.Contains("API.validateListenBrainzToken", content); + } + private bool ValidateJavaScriptSyntax(string filePath, out string error) { error = string.Empty; @@ -165,23 +243,4 @@ public class JavaScriptSyntaxTests } } - private string RemoveStringsAndComments(string content) - { - // Simple removal of strings and comments for brace counting - // This is not perfect but good enough for basic validation - var result = content; - - // Remove single-line comments - result = System.Text.RegularExpressions.Regex.Replace(result, @"//.*$", "", System.Text.RegularExpressions.RegexOptions.Multiline); - - // Remove multi-line comments - result = System.Text.RegularExpressions.Regex.Replace(result, @"/\*.*?\*/", "", System.Text.RegularExpressions.RegexOptions.Singleline); - - // Remove strings (simple approach) - result = System.Text.RegularExpressions.Regex.Replace(result, @"""(?:[^""\\]|\\.)*""", ""); - result = System.Text.RegularExpressions.Regex.Replace(result, @"'(?:[^'\\]|\\.)*'", ""); - result = System.Text.RegularExpressions.Regex.Replace(result, @"`(?:[^`\\]|\\.)*`", ""); - - return result; - } } diff --git a/allstarr.Tests/JellyfinProxyServiceTests.cs b/allstarr.Tests/JellyfinProxyServiceTests.cs index 15b0620..a967cbc 100644 --- a/allstarr.Tests/JellyfinProxyServiceTests.cs +++ b/allstarr.Tests/JellyfinProxyServiceTests.cs @@ -182,7 +182,7 @@ public class JellyfinProxyServiceTests // Assert Assert.NotNull(captured); var url = captured!.RequestUri!.ToString(); - + // Verify the query parameters are properly URL encoded Assert.Contains("searchTerm=", url); Assert.Contains("test", url); @@ -192,7 +192,7 @@ public class JellyfinProxyServiceTests Assert.Contains("MusicAlbum", url); Assert.Contains("limit=25", url); Assert.Contains("recursive=true", url); - + // Verify spaces are encoded (either as %20 or +) var uri = captured.RequestUri; var searchTermValue = System.Web.HttpUtility.ParseQueryString(uri!.Query).Get("searchTerm"); @@ -225,6 +225,67 @@ public class JellyfinProxyServiceTests Assert.Equal(200, statusCode); } + [Fact] + public async Task GetJsonAsync_WithEndpointQuery_PreservesCallerParameters() + { + // Arrange + HttpRequestMessage? captured = null; + _mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => captured = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"Id\":\"abc-123\"}") + }); + + // Act + await _service.GetJsonAsync( + "Users/user-abc/Items/abc-123?api_key=query-token&Fields=DateCreated,PremiereDate,ProductionYear"); + + // Assert + Assert.NotNull(captured); + var requestUri = captured!.RequestUri!; + Assert.Contains("/Users/user-abc/Items/abc-123", requestUri.ToString()); + + var query = System.Web.HttpUtility.ParseQueryString(requestUri.Query); + Assert.Equal("query-token", query.Get("api_key")); + Assert.Equal("DateCreated,PremiereDate,ProductionYear", query.Get("Fields")); + } + + [Fact] + public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence() + { + // Arrange + HttpRequestMessage? captured = null; + _mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => captured = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("{\"Items\":[]}") + }); + + // Act + await _service.GetJsonAsync( + "Items/abc-123?api_key=endpoint-token&Fields=DateCreated", + new Dictionary + { + ["api_key"] = "explicit-token", + ["UserId"] = "route-user" + }); + + // Assert + Assert.NotNull(captured); + var query = System.Web.HttpUtility.ParseQueryString(captured!.RequestUri!.Query); + Assert.Equal("explicit-token", query.Get("api_key")); + Assert.Equal("DateCreated", query.Get("Fields")); + Assert.Equal("route-user", query.Get("UserId")); + } + [Fact] public async Task GetArtistsAsync_WithSearchTerm_IncludesInQuery() { @@ -277,6 +338,30 @@ public class JellyfinProxyServiceTests Assert.Contains("maxHeight=300", url); } + [Fact] + public async Task GetImageAsync_WithTag_IncludesTagInQuery() + { + // Arrange + HttpRequestMessage? captured = null; + _mockHandler.Protected() + .Setup>("SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((req, ct) => captured = req) + .ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(new byte[] { 1, 2, 3 }) + }); + + // Act + await _service.GetImageAsync("item-123", "Primary", imageTag: "playlist-art-v2"); + + // Assert + Assert.NotNull(captured); + var query = System.Web.HttpUtility.ParseQueryString(captured!.RequestUri!.Query); + Assert.Equal("playlist-art-v2", query.Get("tag")); + } + [Fact] diff --git a/allstarr.Tests/JellyfinQueryRedactionTests.cs b/allstarr.Tests/JellyfinQueryRedactionTests.cs new file mode 100644 index 0000000..36d926e --- /dev/null +++ b/allstarr.Tests/JellyfinQueryRedactionTests.cs @@ -0,0 +1,42 @@ +using System.Reflection; +using allstarr.Controllers; + +namespace allstarr.Tests; + +public class JellyfinQueryRedactionTests +{ + [Fact] + public void MaskSensitiveQueryString_RedactsSensitiveValues() + { + var masked = InvokeMaskSensitiveQueryString( + "?api_key=secret1&query=hello&x-emby-token=secret2&AuthToken=secret3"); + + Assert.Contains("api_key=", masked); + Assert.Contains("query=hello", masked); + Assert.Contains("x-emby-token=", masked); + Assert.Contains("AuthToken=", masked); + Assert.DoesNotContain("secret1", masked); + Assert.DoesNotContain("secret2", masked); + Assert.DoesNotContain("secret3", masked); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + public void MaskSensitiveQueryString_EmptyOrNull_ReturnsEmpty(string? input) + { + var masked = InvokeMaskSensitiveQueryString(input); + Assert.Equal(string.Empty, masked); + } + + private static string InvokeMaskSensitiveQueryString(string? queryString) + { + var method = typeof(JellyfinController).GetMethod( + "MaskSensitiveQueryString", + BindingFlags.Static | BindingFlags.NonPublic); + + Assert.NotNull(method); + var result = method!.Invoke(null, new object?[] { queryString }); + return Assert.IsType(result); + } +} diff --git a/allstarr.Tests/JellyfinResponseBuilderTests.cs b/allstarr.Tests/JellyfinResponseBuilderTests.cs index 39b1d8b..47e9246 100644 --- a/allstarr.Tests/JellyfinResponseBuilderTests.cs +++ b/allstarr.Tests/JellyfinResponseBuilderTests.cs @@ -75,6 +75,58 @@ public class JellyfinResponseBuilderTests Assert.Equal("USRC12345678", providerIds["ISRC"]); } + [Theory] + [InlineData("deezer")] + [InlineData("qobuz")] + [InlineData("squidwtf")] + [InlineData("Deezer")] + [InlineData("Qobuz")] + [InlineData("SquidWTF")] + public void ConvertSongToJellyfinItem_ExternalStreamingProviders_DisableTranscoding(string provider) + { + // Arrange + var song = new Song + { + Id = $"ext-{provider}-song-123", + Title = "External Track", + Artist = "External Artist", + IsLocal = false, + ExternalProvider = provider, + ExternalId = "123" + }; + + // Act + var result = _builder.ConvertSongToJellyfinItem(song); + + // Assert + var mediaSources = Assert.IsAssignableFrom(result["MediaSources"]); + var mediaSource = Assert.IsType>(mediaSources[0]); + Assert.False(Assert.IsType(mediaSource["SupportsTranscoding"])); + } + + [Fact] + public void ConvertSongToJellyfinItem_OtherExternalProviders_KeepTranscodingEnabled() + { + // Arrange + var song = new Song + { + Id = "ext-spotify-song-123", + Title = "External Track", + Artist = "External Artist", + IsLocal = false, + ExternalProvider = "spotify", + ExternalId = "123" + }; + + // Act + var result = _builder.ConvertSongToJellyfinItem(song); + + // Assert + var mediaSources = Assert.IsAssignableFrom(result["MediaSources"]); + var mediaSource = Assert.IsType>(mediaSources[0]); + Assert.True(Assert.IsType(mediaSource["SupportsTranscoding"])); + } + [Fact] public void ConvertAlbumToJellyfinItem_SetsCorrectFields() { diff --git a/allstarr.Tests/JellyfinSearchTermRecoveryTests.cs b/allstarr.Tests/JellyfinSearchTermRecoveryTests.cs new file mode 100644 index 0000000..3772592 --- /dev/null +++ b/allstarr.Tests/JellyfinSearchTermRecoveryTests.cs @@ -0,0 +1,47 @@ +using System.Reflection; +using allstarr.Controllers; + +namespace allstarr.Tests; + +public class JellyfinSearchTermRecoveryTests +{ + [Fact] + public void RecoverSearchTermFromRawQuery_PreservesUnencodedAmpersand() + { + var raw = "?SearchTerm=Love%20&%20Hyperbole&Recursive=true&IncludeItemTypes=MusicAlbum"; + var recovered = InvokePrivateStatic("RecoverSearchTermFromRawQuery", raw); + + Assert.Equal("Love & Hyperbole", recovered); + } + + [Fact] + public void GetEffectiveSearchTerm_PrefersRecoveredWhenBoundIsTruncated() + { + var bound = "Love "; + var raw = "?SearchTerm=Love%20&%20Hyperbole&Recursive=true"; + var effective = InvokePrivateStatic("GetEffectiveSearchTerm", bound, raw); + + Assert.Equal("Love & Hyperbole", effective); + } + + [Fact] + public void GetEffectiveSearchTerm_UsesBoundWhenRecoveredIsMissing() + { + var bound = "Love & Hyperbole"; + var raw = "?Recursive=true&IncludeItemTypes=MusicAlbum"; + var effective = InvokePrivateStatic("GetEffectiveSearchTerm", bound, raw); + + Assert.Equal("Love & Hyperbole", effective); + } + + private static T InvokePrivateStatic(string methodName, params object?[] args) + { + var method = typeof(JellyfinController).GetMethod( + methodName, + BindingFlags.NonPublic | BindingFlags.Static); + + Assert.NotNull(method); + var result = method!.Invoke(null, args); + return (T)result!; + } +} diff --git a/allstarr.Tests/OutboundRequestGuardTests.cs b/allstarr.Tests/OutboundRequestGuardTests.cs new file mode 100644 index 0000000..756b357 --- /dev/null +++ b/allstarr.Tests/OutboundRequestGuardTests.cs @@ -0,0 +1,64 @@ +using allstarr.Services.Common; + +namespace allstarr.Tests; + +public class OutboundRequestGuardTests +{ + [Fact] + public void TryCreateSafeHttpUri_WithPublicHttpsUrl_AllowsRequest() + { + var allowed = OutboundRequestGuard.TryCreateSafeHttpUri( + "https://example.com/cover.jpg", + out var uri, + out var reason); + + Assert.True(allowed); + Assert.NotNull(uri); + Assert.Equal("https://example.com/cover.jpg", uri!.ToString()); + Assert.Equal(string.Empty, reason); + } + + [Theory] + [InlineData("http://localhost/test")] + [InlineData("http://127.0.0.1/test")] + [InlineData("http://10.0.0.5/album.png")] + [InlineData("http://192.168.1.10/album.png")] + [InlineData("http://100.64.0.25/path")] + [InlineData("http://[::1]/image")] + [InlineData("http://[fd00::1]/image")] + public void TryCreateSafeHttpUri_WithLocalOrPrivateHost_BlocksRequest(string rawUrl) + { + var allowed = OutboundRequestGuard.TryCreateSafeHttpUri(rawUrl, out var uri, out var reason); + + Assert.False(allowed); + Assert.Null(uri); + Assert.NotEmpty(reason); + } + + [Theory] + [InlineData("ftp://example.com/file")] + [InlineData("file:///etc/passwd")] + [InlineData("javascript:alert(1)")] + [InlineData("/relative/path")] + public void TryCreateSafeHttpUri_WithInvalidSchemeOrRelativeUrl_BlocksRequest(string rawUrl) + { + var allowed = OutboundRequestGuard.TryCreateSafeHttpUri(rawUrl, out var uri, out var reason); + + Assert.False(allowed); + Assert.Null(uri); + Assert.NotEmpty(reason); + } + + [Fact] + public void TryCreateSafeHttpUri_WithUserInfo_BlocksRequest() + { + var allowed = OutboundRequestGuard.TryCreateSafeHttpUri( + "https://user:pass@example.com/image.jpg", + out var uri, + out var reason); + + Assert.False(allowed); + Assert.Null(uri); + Assert.Contains("Userinfo", reason, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/allstarr.Tests/PlaybackSessionTests.cs b/allstarr.Tests/PlaybackSessionTests.cs new file mode 100644 index 0000000..7daf493 --- /dev/null +++ b/allstarr.Tests/PlaybackSessionTests.cs @@ -0,0 +1,64 @@ +using allstarr.Models.Scrobbling; +using Xunit; + +namespace allstarr.Tests; + +public class PlaybackSessionTests +{ + [Fact] + public void ShouldScrobble_ExternalTrackStartedFromBeginning_ScrobblesWhenThresholdMet() + { + var session = CreateSession(isExternal: true, startPositionSeconds: 0, durationSeconds: 300, playedSeconds: 240); + + Assert.True(session.ShouldScrobble()); + } + + [Fact] + public void ShouldScrobble_ExternalTrackResumedMidTrack_DoesNotScrobble() + { + var session = CreateSession(isExternal: true, startPositionSeconds: 90, durationSeconds: 300, playedSeconds: 240); + + Assert.False(session.ShouldScrobble()); + } + + [Fact] + public void ShouldScrobble_ExternalTrackAtToleranceBoundary_Scrobbles() + { + var session = CreateSession(isExternal: true, startPositionSeconds: 5, durationSeconds: 240, playedSeconds: 120); + + Assert.True(session.ShouldScrobble()); + } + + [Fact] + public void ShouldScrobble_LocalTrackIgnoresStartPosition_ScrobblesWhenThresholdMet() + { + var session = CreateSession(isExternal: false, startPositionSeconds: 120, durationSeconds: 300, playedSeconds: 150); + + Assert.True(session.ShouldScrobble()); + } + + private static PlaybackSession CreateSession( + bool isExternal, + int startPositionSeconds, + int durationSeconds, + int playedSeconds) + { + return new PlaybackSession + { + SessionId = "session-1", + DeviceId = "device-1", + StartTime = DateTime.UtcNow, + LastActivity = DateTime.UtcNow, + LastPositionSeconds = playedSeconds, + Track = new ScrobbleTrack + { + Title = "Track", + Artist = "Artist", + DurationSeconds = durationSeconds, + Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), + IsExternal = isExternal, + StartPositionSeconds = startPositionSeconds + } + }; + } +} diff --git a/allstarr.Tests/PlaylistTrackStatusResolverTests.cs b/allstarr.Tests/PlaylistTrackStatusResolverTests.cs new file mode 100644 index 0000000..3208d6a --- /dev/null +++ b/allstarr.Tests/PlaylistTrackStatusResolverTests.cs @@ -0,0 +1,107 @@ +using allstarr.Models.Domain; +using allstarr.Models.Spotify; +using allstarr.Services.Admin; + +namespace allstarr.Tests; + +public class PlaylistTrackStatusResolverTests +{ + [Fact] + public void TryResolveFromMatchedTrack_LocalMatch_ReturnsLocal() + { + var matchedBySpotifyId = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["1UNWD6R5EOFklUHKZZvww2"] = new MatchedTrack + { + SpotifyId = "1UNWD6R5EOFklUHKZZvww2", + MatchedSong = new Song + { + IsLocal = true + } + } + }; + + var resolved = PlaylistTrackStatusResolver.TryResolveFromMatchedTrack( + matchedBySpotifyId, + "1UNWD6R5EOFklUHKZZvww2", + out var isLocal, + out var externalProvider); + + Assert.True(resolved); + Assert.True(isLocal); + Assert.Null(externalProvider); + } + + [Fact] + public void TryResolveFromMatchedTrack_ExternalMatch_ReturnsProvider() + { + var matchedBySpotifyId = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["6zSpb8dQRaw0M1dK8PBwQz"] = new MatchedTrack + { + SpotifyId = "6zSpb8dQRaw0M1dK8PBwQz", + MatchedSong = new Song + { + IsLocal = false, + ExternalProvider = "squidwtf" + } + } + }; + + var resolved = PlaylistTrackStatusResolver.TryResolveFromMatchedTrack( + matchedBySpotifyId, + "6zspb8dqraw0m1dk8pbwqz", + out var isLocal, + out var externalProvider); + + Assert.True(resolved); + Assert.False(isLocal); + Assert.Equal("squidwtf", externalProvider); + } + + [Fact] + public void TryResolveFromMatchedTrack_NoMatch_ReturnsFalse() + { + var matchedBySpotifyId = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["abc"] = new MatchedTrack + { + SpotifyId = "abc", + MatchedSong = new Song { IsLocal = true } + } + }; + + var resolved = PlaylistTrackStatusResolver.TryResolveFromMatchedTrack( + matchedBySpotifyId, + "def", + out var isLocal, + out var externalProvider); + + Assert.False(resolved); + Assert.Null(isLocal); + Assert.Null(externalProvider); + } + + [Fact] + public void TryResolveFromMatchedTrack_NullMatchedSong_ReturnsFalse() + { + var matchedBySpotifyId = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["abc"] = new MatchedTrack + { + SpotifyId = "abc", + MatchedSong = null! + } + }; + + var resolved = PlaylistTrackStatusResolver.TryResolveFromMatchedTrack( + matchedBySpotifyId, + "abc", + out var isLocal, + out var externalProvider); + + Assert.False(resolved); + Assert.Null(isLocal); + Assert.Null(externalProvider); + } +} diff --git a/allstarr.Tests/ProviderIdsEnricherTests.cs b/allstarr.Tests/ProviderIdsEnricherTests.cs new file mode 100644 index 0000000..b4c497d --- /dev/null +++ b/allstarr.Tests/ProviderIdsEnricherTests.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using allstarr.Services.Common; + +namespace allstarr.Tests; + +public class ProviderIdsEnricherTests +{ + [Fact] + public void EnsureSpotifyProviderIds_WhenProviderIdsMissing_AddsSpotifyKeys() + { + var item = new Dictionary + { + ["Name"] = "As the World Caves In", + ["ProviderIds"] = null + }; + + ProviderIdsEnricher.EnsureSpotifyProviderIds(item, "2xXNLutYAOELYVObYb1C1S", "album-123"); + + var providerIds = Assert.IsType>(item["ProviderIds"]); + Assert.Equal("2xXNLutYAOELYVObYb1C1S", providerIds["Spotify"]); + Assert.Equal("album-123", providerIds["SpotifyAlbum"]); + } + + [Fact] + public void EnsureSpotifyProviderIds_WhenProviderIdsJsonElement_PreservesAndAdds() + { + using var doc = JsonDocument.Parse("""{"Jellyfin":"cde0216ad42ece9b66e2626a744e8283"}"""); + var item = new Dictionary + { + ["ProviderIds"] = doc.RootElement.Clone() + }; + + ProviderIdsEnricher.EnsureSpotifyProviderIds(item, "2xXNLutYAOELYVObYb1C1S", null); + + var providerIds = Assert.IsType>(item["ProviderIds"]); + Assert.Equal("cde0216ad42ece9b66e2626a744e8283", providerIds["Jellyfin"]); + Assert.Equal("2xXNLutYAOELYVObYb1C1S", providerIds["Spotify"]); + } + + [Fact] + public void EnsureSpotifyProviderIds_WhenSpotifyAlreadyExists_DoesNotOverwrite() + { + var providerIds = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["Spotify"] = "existing-spid" + }; + var item = new Dictionary + { + ["ProviderIds"] = providerIds + }; + + ProviderIdsEnricher.EnsureSpotifyProviderIds(item, "new-spid", "album-1"); + + var normalized = Assert.IsType>(item["ProviderIds"]); + Assert.Equal("existing-spid", normalized["Spotify"]); + Assert.Equal("album-1", normalized["SpotifyAlbum"]); + } +} diff --git a/allstarr.Tests/RetryHelperTests.cs b/allstarr.Tests/RetryHelperTests.cs new file mode 100644 index 0000000..2d47746 --- /dev/null +++ b/allstarr.Tests/RetryHelperTests.cs @@ -0,0 +1,64 @@ +using System.Net; +using allstarr.Services.Common; +using Microsoft.Extensions.Logging.Abstractions; +using Xunit; + +namespace allstarr.Tests; + +public class RetryHelperTests +{ + [Fact] + public async Task RetryWithBackoffAsync_ShouldRetryOn503AndSucceed() + { + var attempts = 0; + + var result = await RetryHelper.RetryWithBackoffAsync(async () => + { + attempts++; + await Task.Yield(); + + if (attempts < 3) + { + throw new HttpRequestException("temporary", null, HttpStatusCode.ServiceUnavailable); + } + + return "ok"; + }, NullLogger.Instance, maxRetries: 4, initialDelayMs: 1); + + Assert.Equal("ok", result); + Assert.Equal(3, attempts); + } + + [Fact] + public async Task RetryWithBackoffAsync_ShouldRetryOn429ThenThrowAfterMaxRetries() + { + var attempts = 0; + + var ex = await Assert.ThrowsAsync(async () => + await RetryHelper.RetryWithBackoffAsync(async () => + { + attempts++; + await Task.Yield(); + throw new HttpRequestException("rate limited", null, HttpStatusCode.TooManyRequests); + }, NullLogger.Instance, maxRetries: 3, initialDelayMs: 1)); + + Assert.Equal(HttpStatusCode.TooManyRequests, ex.StatusCode); + Assert.Equal(3, attempts); + } + + [Fact] + public async Task RetryWithBackoffAsync_ShouldNotRetryOnNonHttpRequestException() + { + var attempts = 0; + + await Assert.ThrowsAsync(async () => + await RetryHelper.RetryWithBackoffAsync(async () => + { + attempts++; + await Task.Yield(); + throw new InvalidOperationException("fatal"); + }, NullLogger.Instance, maxRetries: 3, initialDelayMs: 1)); + + Assert.Equal(1, attempts); + } +} diff --git a/allstarr.Tests/ScrobblingAdminControllerTests.cs b/allstarr.Tests/ScrobblingAdminControllerTests.cs index 51592d5..53c4f5b 100644 --- a/allstarr.Tests/ScrobblingAdminControllerTests.cs +++ b/allstarr.Tests/ScrobblingAdminControllerTests.cs @@ -1,69 +1,32 @@ -using Xunit; -using Moq; -using Microsoft.AspNetCore.Mvc; -using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Options; -using Microsoft.Extensions.Configuration; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.Json; using allstarr.Controllers; using allstarr.Models.Settings; using allstarr.Services.Admin; -using System.Net; -using System.Net.Http; +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Xunit; namespace allstarr.Tests; public class ScrobblingAdminControllerTests { - private readonly Mock> _mockSettings; - private readonly Mock _mockConfiguration; - private readonly Mock> _mockLogger; - private readonly Mock _mockHttpClientFactory; - private readonly ScrobblingAdminController _controller; - - public ScrobblingAdminControllerTests() - { - _mockSettings = new Mock>(); - _mockConfiguration = new Mock(); - _mockLogger = new Mock>(); - _mockHttpClientFactory = new Mock(); - - var settings = new ScrobblingSettings - { - Enabled = true, - LastFm = new LastFmSettings - { - Enabled = true, - ApiKey = "cb3bdcd415fcb40cd572b137b2b255f5", - SharedSecret = "3a08f9fad6ddc4c35b0dce0062cecb5e", - SessionKey = "", - Username = null, - Password = null - } - }; - - _mockSettings.Setup(s => s.Value).Returns(settings); - - _controller = new ScrobblingAdminController( - _mockSettings.Object, - _mockConfiguration.Object, - _mockHttpClientFactory.Object, - _mockLogger.Object, - null! // AdminHelperService not needed for these tests - ); - } - [Fact] - public void GetStatus_ReturnsCorrectConfiguration() + public void GetStatus_ReturnsOk() { - // Act - var result = _controller.GetStatus() as OkObjectResult; + var controller = CreateController( + CreateSettings(username: null, password: null), + new HttpResponseMessage(HttpStatusCode.OK)); - // Assert - Assert.NotNull(result); - Assert.Equal(200, result.StatusCode); - - dynamic? status = result.Value; - Assert.NotNull(status); + var result = controller.GetStatus(); + Assert.IsType(result); } [Theory] @@ -73,119 +36,176 @@ public class ScrobblingAdminControllerTests [InlineData("username", null)] public async Task AuthenticateLastFm_MissingCredentials_ReturnsBadRequest(string? username, string? password) { - // Arrange - set credentials in settings - var settings = new ScrobblingSettings + var controller = CreateController( + CreateSettings(username, password), + new HttpResponseMessage(HttpStatusCode.OK)); + + var result = await controller.AuthenticateLastFm(); + var badRequest = Assert.IsType(result); + Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode); + } + + [Fact] + public async Task AuthenticateLastFm_WhenSessionSaveFails_DoesNotExposeSessionKey() + { + var sessionKey = "super-secret-session-key"; + var successXml = $"testuser{sessionKey}"; + + var controller = CreateController( + CreateSettings("testuser", "password123"), + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(successXml, Encoding.UTF8, "application/xml") + }, + adminHelper: null); + + var result = await controller.AuthenticateLastFm(); + var serverError = Assert.IsType(result); + Assert.Equal(StatusCodes.Status500InternalServerError, serverError.StatusCode); + + var payload = JsonSerializer.Serialize(serverError.Value); + Assert.DoesNotContain("sessionKey", payload, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(sessionKey, payload, StringComparison.Ordinal); + } + + [Fact] + public async Task AuthenticateLastFm_SuccessResponse_DoesNotIncludeSessionKey() + { + var tempRoot = Path.Combine(Path.GetTempPath(), "allstarr-tests", Guid.NewGuid().ToString("N"), "app"); + Directory.CreateDirectory(tempRoot); + + try + { + var successXml = "testusersecret-session-key"; + + var adminHelper = CreateAdminHelperService(tempRoot); + var controller = CreateController( + CreateSettings("testuser", "password123"), + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(successXml, Encoding.UTF8, "application/xml") + }, + adminHelper); + + var result = await controller.AuthenticateLastFm(); + var ok = Assert.IsType(result); + Assert.Equal(StatusCodes.Status200OK, ok.StatusCode); + + var payload = JsonSerializer.Serialize(ok.Value); + using var document = JsonDocument.Parse(payload); + Assert.False(document.RootElement.TryGetProperty("SessionKey", out _)); + Assert.True(document.RootElement.GetProperty("Success").GetBoolean()); + } + finally + { + var testRoot = Path.GetDirectoryName(tempRoot); + if (!string.IsNullOrEmpty(testRoot) && Directory.Exists(testRoot)) + { + Directory.Delete(testRoot, recursive: true); + } + } + } + + [Fact] + public async Task ValidateListenBrainzToken_WhenSaveFails_DoesNotExposeUserToken() + { + var userToken = "listenbrainz-secret-token"; + var validResponse = "{\"valid\":true,\"user_name\":\"listener\"}"; + + var controller = CreateController( + CreateSettings("testuser", "password123"), + new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(validResponse, Encoding.UTF8, "application/json") + }, + adminHelper: null); + + var result = await controller.ValidateListenBrainzToken( + new ScrobblingAdminController.ValidateTokenRequest { UserToken = userToken }); + var serverError = Assert.IsType(result); + Assert.Equal(StatusCodes.Status500InternalServerError, serverError.StatusCode); + + var payload = JsonSerializer.Serialize(serverError.Value); + Assert.DoesNotContain("userToken", payload, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(userToken, payload, StringComparison.Ordinal); + } + + private static ScrobblingSettings CreateSettings(string? username, string? password) + { + return new ScrobblingSettings { Enabled = true, + LocalTracksEnabled = false, LastFm = new LastFmSettings { Enabled = true, ApiKey = "cb3bdcd415fcb40cd572b137b2b255f5", SharedSecret = "3a08f9fad6ddc4c35b0dce0062cecb5e", - SessionKey = "", + SessionKey = string.Empty, Username = username, Password = password + }, + ListenBrainz = new ListenBrainzSettings + { + Enabled = true, + UserToken = string.Empty } }; - _mockSettings.Setup(s => s.Value).Returns(settings); - - var controller = new ScrobblingAdminController( - _mockSettings.Object, - _mockConfiguration.Object, - _mockHttpClientFactory.Object, - _mockLogger.Object, - null! // AdminHelperService not needed for this test - ); - - // Act - var result = await controller.AuthenticateLastFm() as BadRequestObjectResult; - - // Assert - Assert.NotNull(result); - Assert.Equal(400, result.StatusCode); } - [Fact] - public void DebugAuth_ValidCredentials_ReturnsDebugInfo() + private static AdminHelperService CreateAdminHelperService(string contentRootPath) { - // Arrange - var request = new ScrobblingAdminController.AuthenticateRequest - { - Username = "testuser", - Password = "testpass123" - }; + var helperLogger = new Mock>(); + var webHostEnvironment = new Mock(); + webHostEnvironment.SetupGet(e => e.EnvironmentName).Returns(Environments.Development); + webHostEnvironment.SetupGet(e => e.ContentRootPath).Returns(contentRootPath); - // Act - var result = _controller.DebugAuth(request) as OkObjectResult; - - // Assert - Assert.NotNull(result); - Assert.Equal(200, result.StatusCode); - - dynamic? debugInfo = result.Value; - Assert.NotNull(debugInfo); + return new AdminHelperService( + helperLogger.Object, + Options.Create(new JellyfinSettings()), + webHostEnvironment.Object); } - [Theory] - [InlineData("user!@#$%", "pass!@#$%")] - [InlineData("user with spaces", "pass with spaces")] - [InlineData("user\ttab", "pass\ttab")] - [InlineData("user'quote", "pass\"doublequote")] - [InlineData("user&ersand", "pass&ersand")] - [InlineData("user*asterisk", "pass*asterisk")] - [InlineData("user$dollar", "pass$dollar")] - [InlineData("user(paren)", "pass)paren")] - [InlineData("user[bracket]", "pass{bracket}")] - public void DebugAuth_SpecialCharacters_HandlesCorrectly(string username, string password) + private static ScrobblingAdminController CreateController( + ScrobblingSettings settings, + HttpResponseMessage httpResponse, + AdminHelperService? adminHelper = null) { - // Arrange - var request = new ScrobblingAdminController.AuthenticateRequest - { - Username = username, - Password = password - }; + var mockSettings = new Mock>(); + mockSettings.Setup(s => s.Value).Returns(settings); - // Act - var result = _controller.DebugAuth(request) as OkObjectResult; + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(new Dictionary()) + .Build(); - // Assert - Assert.NotNull(result); - Assert.Equal(200, result.StatusCode); - Assert.NotNull(result.Value); - - // Use reflection to access anonymous type properties - var passwordLengthProp = result.Value.GetType().GetProperty("PasswordLength"); - Assert.NotNull(passwordLengthProp); - var passwordLength = (int?)passwordLengthProp.GetValue(result.Value); - Assert.Equal(password.Length, passwordLength); + var logger = new Mock>(); + var httpClientFactory = new Mock(); + + var httpClient = new HttpClient(new StubHttpMessageHandler(httpResponse)); + httpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); + + return new ScrobblingAdminController( + mockSettings.Object, + configuration, + httpClientFactory.Object, + logger.Object, + adminHelper!); } - [Theory] - [InlineData("test!pass456")] - [InlineData("p@ssw0rd!")] - [InlineData("test&test")] - [InlineData("my*password")] - [InlineData("pass$word")] - public void DebugAuth_PasswordsWithShellSpecialChars_CalculatesCorrectLength(string password) + private sealed class StubHttpMessageHandler : HttpMessageHandler { - // Arrange - var request = new ScrobblingAdminController.AuthenticateRequest + private readonly HttpResponseMessage _response; + + public StubHttpMessageHandler(HttpResponseMessage response) { - Username = "testuser", - Password = password - }; + _response = response; + } - // Act - var result = _controller.DebugAuth(request) as OkObjectResult; - - // Assert - Assert.NotNull(result); - Assert.NotNull(result.Value); - - // Use reflection to access anonymous type properties - var passwordLengthProp = result.Value.GetType().GetProperty("PasswordLength"); - Assert.NotNull(passwordLengthProp); - var passwordLength = (int?)passwordLengthProp.GetValue(result.Value); - Assert.Equal(password.Length, passwordLength); + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + return Task.FromResult(_response); + } } } diff --git a/allstarr.Tests/ScrobblingOrchestratorTests.cs b/allstarr.Tests/ScrobblingOrchestratorTests.cs new file mode 100644 index 0000000..fa35f51 --- /dev/null +++ b/allstarr.Tests/ScrobblingOrchestratorTests.cs @@ -0,0 +1,54 @@ +using allstarr.Models.Scrobbling; +using allstarr.Models.Settings; +using allstarr.Services.Scrobbling; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace allstarr.Tests; + +public class ScrobblingOrchestratorTests +{ + [Fact] + public async Task OnPlaybackStartAsync_DuplicateStartForSameTrack_SendsNowPlayingOnce() + { + var service = new Mock(); + service.SetupGet(s => s.IsEnabled).Returns(true); + service.SetupGet(s => s.ServiceName).Returns("MockService"); + service.Setup(s => s.UpdateNowPlayingAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(ScrobbleResult.CreateSuccess()); + + var orchestrator = CreateOrchestrator(service.Object); + var track = CreateTrack(); + + await orchestrator.OnPlaybackStartAsync("device-1", track); + await orchestrator.OnPlaybackStartAsync("device-1", track); + + service.Verify( + s => s.UpdateNowPlayingAsync(It.IsAny(), It.IsAny()), + Times.Once); + } + + private static ScrobblingOrchestrator CreateOrchestrator(IScrobblingService service) + { + var settings = Options.Create(new ScrobblingSettings + { + Enabled = true + }); + + var logger = Mock.Of>(); + return new ScrobblingOrchestrator(new[] { service }, settings, logger); + } + + private static ScrobbleTrack CreateTrack() + { + return new ScrobbleTrack + { + Title = "Sad Girl Summer", + Artist = "Maisie Peters", + DurationSeconds = 180, + IsExternal = true, + StartPositionSeconds = 0 + }; + } +} diff --git a/allstarr.Tests/SpotifyApiClientTests.cs b/allstarr.Tests/SpotifyApiClientTests.cs index d9d5d3f..3454b93 100644 --- a/allstarr.Tests/SpotifyApiClientTests.cs +++ b/allstarr.Tests/SpotifyApiClientTests.cs @@ -4,6 +4,9 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using allstarr.Services.Spotify; using allstarr.Models.Settings; +using allstarr.Models.Spotify; +using System.Reflection; +using System.Text.Json; namespace allstarr.Tests; @@ -79,4 +82,86 @@ public class SpotifyApiClientTests // Assert Assert.NotNull(client); } + + [Fact] + public void ParseGraphQLPlaylist_ParsesCreatedAtFromAttributes() + { + // Arrange + var client = new SpotifyApiClient(_mockLogger.Object, _settings); + using var doc = JsonDocument.Parse(""" + { + "name": "Discover Weekly", + "description": "Weekly picks", + "revisionId": "rev123", + "attributes": [ + { "key": "core:created_at", "value": "1771218000000" } + ] + } + """); + + // Act + var playlist = InvokePrivateMethod(client, "ParseGraphQLPlaylist", doc.RootElement, "37i9dQZEVXcJyaHDR0yDFT"); + + // Assert + Assert.NotNull(playlist); + Assert.Equal("Discover Weekly", playlist!.Name); + Assert.Equal(DateTimeOffset.FromUnixTimeMilliseconds(1771218000000).UtcDateTime, playlist.CreatedAt); + } + + [Fact] + public void ParseGraphQLTrack_ParsesAddedAtIsoStringAsUtc() + { + // Arrange + var client = new SpotifyApiClient(_mockLogger.Object, _settings); + using var doc = JsonDocument.Parse(""" + { + "addedAt": { "isoString": "2026-02-16T05:00:00Z" }, + "itemV2": { + "data": { + "uri": "spotify:track:3a8mo25v74BMUOJ1IDUEBL", + "name": "Sample Track", + "artists": { + "items": [ + { + "profile": { "name": "Sample Artist" }, + "uri": "spotify:artist:123" + } + ] + }, + "albumOfTrack": { + "name": "Sample Album", + "uri": "spotify:album:456", + "coverArt": { + "sources": [ + { "url": "https://example.com/small.jpg", "width": 64 }, + { "url": "https://example.com/large.jpg", "width": 640 } + ] + } + }, + "trackDuration": { "totalMilliseconds": 201526 }, + "contentRating": { "label": "NONE" }, + "trackNumber": 1, + "discNumber": 1, + "playcount": "1200" + } + } + } + """); + + // Act + var track = InvokePrivateMethod(client, "ParseGraphQLTrack", doc.RootElement, 0); + + // Assert + Assert.NotNull(track); + Assert.Equal("3a8mo25v74BMUOJ1IDUEBL", track!.SpotifyId); + Assert.Equal(new DateTime(2026, 2, 16, 5, 0, 0, DateTimeKind.Utc), track.AddedAt); + } + + private static T InvokePrivateMethod(object instance, string methodName, params object?[] args) + { + var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + var result = method!.Invoke(instance, args); + return (T)result!; + } } diff --git a/allstarr.Tests/SquidWTFMetadataServiceTests.cs b/allstarr.Tests/SquidWTFMetadataServiceTests.cs index 38003c1..5ac4271 100644 --- a/allstarr.Tests/SquidWTFMetadataServiceTests.cs +++ b/allstarr.Tests/SquidWTFMetadataServiceTests.cs @@ -4,8 +4,11 @@ using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using allstarr.Services.SquidWTF; using allstarr.Services.Common; +using allstarr.Models.Domain; using allstarr.Models.Settings; using System.Collections.Generic; +using System.Reflection; +using System.Text.Json; namespace allstarr.Tests; @@ -22,29 +25,29 @@ public class SquidWTFMetadataServiceTests { _mockLogger = new Mock>(); _mockHttpClientFactory = new Mock(); - + _subsonicSettings = Options.Create(new SubsonicSettings { ExplicitFilter = ExplicitFilter.All }); - + _squidwtfSettings = Options.Create(new SquidWTFSettings { Quality = "FLAC" }); - + // Create mock Redis cache var mockRedisLogger = new Mock>(); var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false }); _mockCache = new Mock(mockRedisSettings, mockRedisLogger.Object); - - _apiUrls = new List - { + + _apiUrls = new List + { "https://test1.example.com", "https://test2.example.com", "https://test3.example.com" }; - + var httpClient = new System.Net.Http.HttpClient(); _mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny())).Returns(httpClient); } @@ -339,4 +342,223 @@ public class SquidWTFMetadataServiceTests // Assert Assert.NotNull(service); } + + [Fact] + public void BuildSearchQueryVariants_WithAmpersand_AddsAndVariant() + { + var variants = InvokePrivateStaticMethod>( + typeof(SquidWTFMetadataService), + "BuildSearchQueryVariants", + "love & hyperbole"); + + Assert.Equal(2, variants.Count); + Assert.Contains("love & hyperbole", variants); + Assert.Contains("love and hyperbole", variants); + } + + [Fact] + public void BuildSearchQueryVariants_WithoutAmpersand_KeepsOriginalOnly() + { + var variants = InvokePrivateStaticMethod>( + typeof(SquidWTFMetadataService), + "BuildSearchQueryVariants", + "love and hyperbole"); + + Assert.Single(variants); + Assert.Equal("love and hyperbole", variants[0]); + } + + [Fact] + public void ParseTidalTrack_MapsFieldsUsedByJellyfinAndTagWriter() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + using var doc = JsonDocument.Parse(""" + { + "id": 452455962, + "title": "Stuck Up", + "version": "Live", + "duration": 151, + "trackNumber": 1, + "volumeNumber": 1, + "explicit": true, + "bpm": 130, + "isrc": "USUG12504959", + "streamStartDate": "2025-08-08T00:00:00.000+0000", + "copyright": "℗ 2025 Golden Angel LLC, under exclusive license to Interscope Records.", + "artists": [ + { "id": 9321197, "name": "Amaarae" }, + { "id": 30396, "name": "Black Star" } + ], + "album": { + "id": 452455961, + "title": "BLACK STAR", + "cover": "87f0be2b-dd7e-42d4-b438-f8f161d29674", + "numberOfTracks": 13, + "releaseDate": "2025-08-08", + "artist": { "id": 9321197, "name": "Amaarae" } + } + } + """); + + // Act + var song = InvokePrivateMethod(service, "ParseTidalTrack", doc.RootElement, null); + + // Assert + Assert.Equal("ext-squidwtf-song-452455962", song.Id); + Assert.Equal("Stuck Up (Live)", song.Title); + Assert.Equal("Amaarae", song.Artist); + Assert.Equal("Amaarae", song.AlbumArtist); + Assert.Equal("USUG12504959", song.Isrc); + Assert.Equal(130, song.Bpm); + Assert.Equal("2025-08-08", song.ReleaseDate); + Assert.Equal(2025, song.Year); + Assert.Equal(13, song.TotalTracks); + Assert.Equal("℗ 2025 Golden Angel LLC, under exclusive license to Interscope Records.", song.Copyright); + Assert.Equal("Black Star", Assert.Single(song.Contributors)); + Assert.Contains("/87f0be2b/dd7e/42d4/b438/f8f161d29674/320x320.jpg", song.CoverArtUrl); + Assert.Contains("/87f0be2b/dd7e/42d4/b438/f8f161d29674/1280x1280.jpg", song.CoverArtUrlLarge); + } + + [Fact] + public void ParseTidalTrackFull_MapsCopyrightToCopyrightField() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + using var doc = JsonDocument.Parse(""" + { + "id": 987654, + "title": "Night Walk", + "duration": 200, + "trackNumber": 7, + "volumeNumber": 1, + "streamStartDate": "2024-02-01T00:00:00.000+0000", + "copyright": "℗ 2024 Example Label", + "artist": { "id": 111, "name": "Main Artist" }, + "album": { + "id": 222, + "title": "Moonlight", + "cover": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" + } + } + """); + + // Act + var song = InvokePrivateMethod(service, "ParseTidalTrackFull", doc.RootElement); + + // Assert + Assert.Equal("℗ 2024 Example Label", song.Copyright); + Assert.Null(song.Label); + Assert.Equal(2024, song.Year); + } + + [Fact] + public void ParseTidalPlaylist_UsesPromotedArtistsAndFallbackMetadata() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + using var doc = JsonDocument.Parse(""" + { + "uuid": "b55ffed4-ab60-4da5-9faf-e54a45de4f9c", + "title": "Guest Verses: BIG30", + "description": "Remixes and guest verses", + "creator": { "id": 0 }, + "promotedArtists": [ + { "id": 19872911, "name": "BigWalkDog" } + ], + "lastUpdated": "2022-09-23T17:52:48.974+0000", + "image": "75ed74c0-58d8-4af7-a4c0-cbae0315dc34", + "numberOfTracks": 32, + "duration": 5466 + } + """); + + // Act + var playlist = InvokePrivateMethod(service, "ParseTidalPlaylist", doc.RootElement); + + // Assert + Assert.Equal("BigWalkDog", playlist.CuratorName); + Assert.Equal(32, playlist.TrackCount); + Assert.Equal(5466, playlist.Duration); + Assert.True(playlist.CreatedDate.HasValue); + Assert.Equal(2022, playlist.CreatedDate!.Value.Year); + Assert.Contains("/75ed74c0/58d8/4af7/a4c0/cbae0315dc34/1080x1080.jpg", playlist.CoverUrl); + } + + [Fact] + public void ParseTidalAlbum_AppendsVersionAndParsesYearFallback() + { + // Arrange + var service = new SquidWTFMetadataService( + _mockHttpClientFactory.Object, + _subsonicSettings, + _squidwtfSettings, + _mockLogger.Object, + _mockCache.Object, + _apiUrls); + + using var doc = JsonDocument.Parse(""" + { + "id": 579814, + "title": "Black Star", + "version": "Remastered", + "streamStartDate": "2002-06-04T00:00:00.000+0000", + "numberOfTracks": 13, + "cover": "49fcdc8b-2f43-43a9-b156-f2f83908f95f", + "artists": [ + { "id": 30396, "name": "Black Star" } + ] + } + """); + + // Act + var album = InvokePrivateMethod(service, "ParseTidalAlbum", doc.RootElement); + + // Assert + Assert.Equal("Black Star (Remastered)", album.Title); + Assert.Equal(2002, album.Year); + Assert.Equal(13, album.SongCount); + Assert.Contains("/49fcdc8b/2f43/43a9/b156/f2f83908f95f/320x320.jpg", album.CoverArtUrl); + } + + private static T InvokePrivateMethod(object target, string methodName, params object?[] parameters) + { + var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic); + Assert.NotNull(method); + + var result = method!.Invoke(target, parameters); + Assert.NotNull(result); + return (T)result!; + } + + private static T InvokePrivateStaticMethod(Type targetType, string methodName, params object?[] parameters) + { + var method = targetType.GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic); + Assert.NotNull(method); + + var result = method!.Invoke(null, parameters); + Assert.NotNull(result); + return (T)result!; + } } diff --git a/allstarr.Tests/TrackParserBaseTests.cs b/allstarr.Tests/TrackParserBaseTests.cs new file mode 100644 index 0000000..76359d8 --- /dev/null +++ b/allstarr.Tests/TrackParserBaseTests.cs @@ -0,0 +1,27 @@ +using allstarr.Services.Common; +using Xunit; + +namespace allstarr.Tests; + +public class TrackParserBaseTests +{ + [Fact] + public void TrackParserBaseHelpers_ShouldBuildConsistentIdsAndYears() + { + Assert.Equal("ext-deezer-song-123", TrackParserProbe.SongId("deezer", "123")); + Assert.Equal("ext-qobuz-album-555", TrackParserProbe.AlbumId("qobuz", "555")); + Assert.Equal("ext-squidwtf-artist-77", TrackParserProbe.ArtistId("squidwtf", "77")); + + Assert.Equal(2024, TrackParserProbe.Year("2024-11-03")); + Assert.Null(TrackParserProbe.Year("")); + Assert.Null(TrackParserProbe.Year("abc")); + } + + private sealed class TrackParserProbe : TrackParserBase + { + public static string SongId(string provider, string externalId) => BuildExternalSongId(provider, externalId); + public static string AlbumId(string provider, string externalId) => BuildExternalAlbumId(provider, externalId); + public static string ArtistId(string provider, string externalId) => BuildExternalArtistId(provider, externalId); + public static int? Year(string? dateString) => ParseYearFromDateString(dateString); + } +} diff --git a/allstarr.Tests/VersionUpgradePolicyTests.cs b/allstarr.Tests/VersionUpgradePolicyTests.cs new file mode 100644 index 0000000..4a67304 --- /dev/null +++ b/allstarr.Tests/VersionUpgradePolicyTests.cs @@ -0,0 +1,42 @@ +using allstarr.Services.Common; + +namespace allstarr.Tests; + +public class VersionUpgradePolicyTests +{ + [Fact] + public void ShouldTriggerRebuild_ReturnsTrue_ForMinorUpgrade() + { + var shouldRebuild = VersionUpgradePolicy.ShouldTriggerRebuild("1.1.0", "1.2.0", out var reason); + + Assert.True(shouldRebuild); + Assert.Equal("minor version upgrade", reason); + } + + [Fact] + public void ShouldTriggerRebuild_ReturnsTrue_ForMajorUpgrade() + { + var shouldRebuild = VersionUpgradePolicy.ShouldTriggerRebuild("1.9.3", "2.0.0", out var reason); + + Assert.True(shouldRebuild); + Assert.Equal("major version upgrade", reason); + } + + [Fact] + public void ShouldTriggerRebuild_ReturnsFalse_ForPatchUpgrade() + { + var shouldRebuild = VersionUpgradePolicy.ShouldTriggerRebuild("1.2.0", "1.2.1", out var reason); + + Assert.False(shouldRebuild); + Assert.Equal("patch-only upgrade", reason); + } + + [Fact] + public void ShouldTriggerRebuild_ReturnsFalse_ForDowngrade() + { + var shouldRebuild = VersionUpgradePolicy.ShouldTriggerRebuild("2.0.0", "1.9.9", out var reason); + + Assert.False(shouldRebuild); + Assert.Equal("version is not an upgrade", reason); + } +} diff --git a/allstarr/AppVersion.cs b/allstarr/AppVersion.cs index 5dc08d1..e0d7c68 100644 --- a/allstarr/AppVersion.cs +++ b/allstarr/AppVersion.cs @@ -9,5 +9,5 @@ public static class AppVersion /// /// Current application version. /// - public const string Version = "1.1.3"; + public const string Version = "1.3.0"; } diff --git a/allstarr/Controllers/AdminAuthController.cs b/allstarr/Controllers/AdminAuthController.cs new file mode 100644 index 0000000..95b56d4 --- /dev/null +++ b/allstarr/Controllers/AdminAuthController.cs @@ -0,0 +1,206 @@ +using System.Text.Json; +using System.Text; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; +using allstarr.Filters; +using allstarr.Models.Settings; +using allstarr.Services.Admin; + +namespace allstarr.Controllers; + +[ApiController] +[Route("api/admin/auth")] +[ServiceFilter(typeof(AdminPortFilter))] +public class AdminAuthController : ControllerBase +{ + private readonly JellyfinSettings _jellyfinSettings; + private readonly HttpClient _httpClient; + private readonly AdminAuthSessionService _sessionService; + private readonly ILogger _logger; + + public AdminAuthController( + IOptions jellyfinSettings, + IHttpClientFactory httpClientFactory, + AdminAuthSessionService sessionService, + ILogger logger) + { + _jellyfinSettings = jellyfinSettings.Value; + _httpClient = httpClientFactory.CreateClient(); + _sessionService = sessionService; + _logger = logger; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginRequest request) + { + if (string.IsNullOrWhiteSpace(_jellyfinSettings.Url)) + { + return StatusCode(500, new { error = "Jellyfin URL is not configured" }); + } + + var username = request.Username?.Trim(); + if (string.IsNullOrWhiteSpace(username) || string.IsNullOrWhiteSpace(request.Password)) + { + return BadRequest(new { error = "Username and password are required" }); + } + + var jellyfinAuthUrl = $"{_jellyfinSettings.Url.TrimEnd('/')}/Users/AuthenticateByName"; + var deviceId = Guid.NewGuid().ToString("N"); + var authHeader = + $"MediaBrowser Client=\"AllstarrAdmin\", Device=\"WebUI\", DeviceId=\"{deviceId}\", Version=\"1.0.0\""; + + try + { + var loginJson = JsonSerializer.Serialize(new JellyfinAuthenticateRequest + { + Username = username, + Pw = request.Password + }, new JsonSerializerOptions + { + PropertyNamingPolicy = null + }); + + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, jellyfinAuthUrl) + { + Content = new StringContent(loginJson, Encoding.UTF8, "application/json") + }; + httpRequest.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader); + + using var response = await _httpClient.SendAsync(httpRequest); + + if (!response.IsSuccessStatusCode) + { + if (response.StatusCode is System.Net.HttpStatusCode.Unauthorized or + System.Net.HttpStatusCode.Forbidden) + { + return Unauthorized(new { error = "Invalid Jellyfin credentials" }); + } + + if (response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable) + { + return StatusCode(503, new { error = "Jellyfin is temporarily unavailable" }); + } + + return StatusCode((int)response.StatusCode, new + { + error = "Failed to authenticate with Jellyfin" + }); + } + + using var authDoc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + var root = authDoc.RootElement; + + var accessToken = root.TryGetProperty("AccessToken", out var tokenProp) ? tokenProp.GetString() : null; + var serverId = root.TryGetProperty("ServerId", out var serverIdProp) ? serverIdProp.GetString() : null; + if (string.IsNullOrWhiteSpace(accessToken) || + !root.TryGetProperty("User", out var userProp)) + { + return StatusCode(502, new { error = "Jellyfin returned an invalid authentication response" }); + } + + var userId = userProp.TryGetProperty("Id", out var userIdProp) ? userIdProp.GetString() : null; + var userName = userProp.TryGetProperty("Name", out var userNameProp) ? userNameProp.GetString() : username; + var isAdministrator = userProp.TryGetProperty("Policy", out var policyProp) && + policyProp.TryGetProperty("IsAdministrator", out var adminProp) && + adminProp.ValueKind == JsonValueKind.True; + + if (string.IsNullOrWhiteSpace(userId) || string.IsNullOrWhiteSpace(userName)) + { + return StatusCode(502, new { error = "Jellyfin user details are missing in auth response" }); + } + + var session = _sessionService.CreateSession( + userId: userId, + userName: userName, + isAdministrator: isAdministrator, + jellyfinAccessToken: accessToken, + jellyfinServerId: serverId); + + SetSessionCookie(session.SessionId, session.ExpiresAtUtc); + + _logger.LogInformation("Admin WebUI login successful for Jellyfin user {UserName} ({UserId})", + session.UserName, session.UserId); + + return Ok(new + { + authenticated = true, + user = new + { + id = session.UserId, + name = session.UserName, + isAdministrator = session.IsAdministrator + }, + expiresAtUtc = session.ExpiresAtUtc + }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Admin WebUI Jellyfin login failed"); + return StatusCode(500, new { error = "Failed to authenticate with Jellyfin" }); + } + } + + [HttpGet("me")] + public IActionResult GetCurrentSession() + { + if (!Request.Cookies.TryGetValue(AdminAuthSessionService.SessionCookieName, out var sessionId) || + !_sessionService.TryGetValidSession(sessionId, out var session)) + { + Response.Cookies.Delete(AdminAuthSessionService.SessionCookieName); + return Ok(new { authenticated = false }); + } + + return Ok(new + { + authenticated = true, + user = new + { + id = session.UserId, + name = session.UserName, + isAdministrator = session.IsAdministrator + }, + expiresAtUtc = session.ExpiresAtUtc + }); + } + + [HttpPost("logout")] + public IActionResult Logout() + { + if (Request.Cookies.TryGetValue(AdminAuthSessionService.SessionCookieName, out var sessionId)) + { + _sessionService.RemoveSession(sessionId); + } + + Response.Cookies.Delete(AdminAuthSessionService.SessionCookieName); + return Ok(new { success = true }); + } + + private void SetSessionCookie(string sessionId, DateTime expiresAtUtc) + { + var secure = Request.IsHttps || + string.Equals(Request.Headers["X-Forwarded-Proto"], "https", + StringComparison.OrdinalIgnoreCase); + + Response.Cookies.Append(AdminAuthSessionService.SessionCookieName, sessionId, new CookieOptions + { + HttpOnly = true, + Secure = secure, + SameSite = SameSiteMode.Strict, + Path = "/", + IsEssential = true, + Expires = expiresAtUtc + }); + } + + public class LoginRequest + { + public string? Username { get; set; } + public string? Password { get; set; } + } + + private sealed class JellyfinAuthenticateRequest + { + public string? Username { get; init; } + public string? Pw { get; init; } + } +} diff --git a/allstarr/Controllers/ConfigController.cs b/allstarr/Controllers/ConfigController.cs index be39fe7..a81d511 100644 --- a/allstarr/Controllers/ConfigController.cs +++ b/allstarr/Controllers/ConfigController.cs @@ -5,6 +5,7 @@ using allstarr.Models.Admin; using allstarr.Filters; using allstarr.Services.Admin; using allstarr.Services.Common; +using allstarr.Services.Spotify; using System.Text.Json; using System.Net.Sockets; @@ -27,6 +28,7 @@ public class ConfigController : ControllerBase private readonly SpotifyImportSettings _spotifyImportSettings; private readonly ScrobblingSettings _scrobblingSettings; private readonly AdminHelperService _helperService; + private readonly SpotifySessionCookieService _spotifySessionCookieService; private readonly RedisCacheService _cache; private const string CacheDirectory = "/app/cache/spotify"; @@ -43,6 +45,7 @@ public class ConfigController : ControllerBase IOptions spotifyImportSettings, IOptions scrobblingSettings, AdminHelperService helperService, + SpotifySessionCookieService spotifySessionCookieService, RedisCacheService cache) { _logger = logger; @@ -57,38 +60,117 @@ public class ConfigController : ControllerBase _spotifyImportSettings = spotifyImportSettings.Value; _scrobblingSettings = scrobblingSettings.Value; _helperService = helperService; + _spotifySessionCookieService = spotifySessionCookieService; _cache = cache; } [HttpGet("config")] public async Task GetConfig() { + var envVars = await ReadEnvSettingsAsync(); + + var backendType = GetEnvString( + envVars, + "BACKEND_TYPE", + _configuration.GetValue("Backend:Type") ?? "Jellyfin"); + var useJellyfinSettings = backendType.Equals("Jellyfin", StringComparison.OrdinalIgnoreCase); + + var fallbackMusicService = useJellyfinSettings + ? _jellyfinSettings.MusicService.ToString() + : _subsonicSettings.MusicService.ToString(); + var fallbackExplicitFilter = useJellyfinSettings + ? _jellyfinSettings.ExplicitFilter.ToString() + : _subsonicSettings.ExplicitFilter.ToString(); + var fallbackEnableExternalPlaylists = useJellyfinSettings + ? _jellyfinSettings.EnableExternalPlaylists + : _subsonicSettings.EnableExternalPlaylists; + var fallbackPlaylistsDirectory = useJellyfinSettings + ? _jellyfinSettings.PlaylistsDirectory + : _subsonicSettings.PlaylistsDirectory; + var fallbackStorageMode = useJellyfinSettings + ? _jellyfinSettings.StorageMode.ToString() + : _subsonicSettings.StorageMode.ToString(); + var fallbackCacheDurationHours = useJellyfinSettings + ? _jellyfinSettings.CacheDurationHours + : _subsonicSettings.CacheDurationHours; + var fallbackDownloadMode = useJellyfinSettings + ? _jellyfinSettings.DownloadMode.ToString() + : _subsonicSettings.DownloadMode.ToString(); + + var storageModeValue = GetEnvString(envVars, "STORAGE_MODE", fallbackStorageMode); + var isCacheStorageMode = storageModeValue.Equals(nameof(StorageMode.Cache), StringComparison.OrdinalIgnoreCase); + + var libraryDownloadRoot = GetEnvString( + envVars, + "LIBRARY_DOWNLOAD_PATH", + GetEnvString( + envVars, + "Library__DownloadPath", + _configuration["Library:DownloadPath"] ?? "./downloads", + treatEmptyAsMissing: true), + treatEmptyAsMissing: true); + var libraryKeptPath = GetEnvString( + envVars, + "LIBRARY_KEPT_PATH", + Path.Combine(libraryDownloadRoot, "kept"), + treatEmptyAsMissing: true); + + var envPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); + var hasEnvPlaylistKey = envVars.ContainsKey("SPOTIFY_IMPORT_PLAYLISTS"); + var effectivePlaylists = hasEnvPlaylistKey ? envPlaylists : _spotifyImportSettings.Playlists; + var sessionUserId = GetAuthenticatedUserId(); + var cookieStatus = await _spotifySessionCookieService.GetCookieStatusAsync(sessionUserId); + var effectiveSessionCookie = await _spotifySessionCookieService.ResolveSessionCookieAsync(sessionUserId); + var userCookieSetDate = !string.IsNullOrWhiteSpace(sessionUserId) + ? await _spotifySessionCookieService.GetCookieSetDateAsync(sessionUserId) + : null; + var effectiveCookieSetDate = userCookieSetDate?.ToString("o"); + + if (string.IsNullOrWhiteSpace(effectiveCookieSetDate) && cookieStatus.UsingGlobalFallback) + { + effectiveCookieSetDate = GetEnvString( + envVars, + "SPOTIFY_API_SESSION_COOKIE_SET_DATE", + _spotifyApiSettings.SessionCookieSetDate ?? string.Empty); + } + return Ok(new { - backendType = _configuration.GetValue("Backend:Type") ?? "Jellyfin", - musicService = _configuration.GetValue("MusicService") ?? "SquidWTF", - explicitFilter = _configuration.GetValue("ExplicitFilter") ?? "All", - enableExternalPlaylists = _configuration.GetValue("EnableExternalPlaylists", false), - playlistsDirectory = _configuration.GetValue("PlaylistsDirectory") ?? "(not set)", - redisEnabled = _configuration.GetValue("Redis:Enabled", false), + backendType, + musicService = GetEnvString(envVars, "MUSIC_SERVICE", fallbackMusicService), + explicitFilter = GetEnvString(envVars, "EXPLICIT_FILTER", fallbackExplicitFilter), + enableExternalPlaylists = GetEnvBool(envVars, "ENABLE_EXTERNAL_PLAYLISTS", fallbackEnableExternalPlaylists), + playlistsDirectory = GetEnvString(envVars, "PLAYLISTS_DIRECTORY", fallbackPlaylistsDirectory), + redisEnabled = GetEnvBool(envVars, "REDIS_ENABLED", _configuration.GetValue("Redis:Enabled", false)), debug = new { - logAllRequests = _configuration.GetValue("Debug:LogAllRequests", false) + logAllRequests = GetEnvBool(envVars, "DEBUG_LOG_ALL_REQUESTS", _configuration.GetValue("Debug:LogAllRequests", false)), + redactSensitiveRequestValues = GetEnvBool( + envVars, + "DEBUG_REDACT_SENSITIVE_REQUEST_VALUES", + _configuration.GetValue("Debug:RedactSensitiveRequestValues", false)) + }, + admin = new + { + bindAnyIp = GetEnvBool(envVars, "ADMIN_BIND_ANY_IP", AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(_configuration)), + trustedSubnets = GetEnvString(envVars, "ADMIN_TRUSTED_SUBNETS", _configuration.GetValue("Admin:TrustedSubnets") ?? string.Empty), + allowEnvExport = IsEnvExportEnabled() }, spotifyApi = new { - enabled = _spotifyApiSettings.Enabled, - sessionCookie = AdminHelperService.MaskValue(_spotifyApiSettings.SessionCookie, showLast: 8), - sessionCookieSetDate = _spotifyApiSettings.SessionCookieSetDate, - cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes, + enabled = GetEnvBool(envVars, "SPOTIFY_API_ENABLED", _spotifyApiSettings.Enabled), + sessionCookie = AdminHelperService.MaskValue(effectiveSessionCookie, showLast: 8), + sessionCookieSetDate = effectiveCookieSetDate ?? string.Empty, + usingGlobalFallback = cookieStatus.UsingGlobalFallback, + cacheDurationMinutes = GetEnvInt(envVars, "SPOTIFY_API_CACHE_DURATION_MINUTES", _spotifyApiSettings.CacheDurationMinutes), rateLimitDelayMs = _spotifyApiSettings.RateLimitDelayMs, - preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching + preferIsrcMatching = GetEnvBool(envVars, "SPOTIFY_API_PREFER_ISRC_MATCHING", _spotifyApiSettings.PreferIsrcMatching) }, spotifyImport = new { - enabled = _spotifyImportSettings.Enabled, - matchingIntervalHours = _spotifyImportSettings.MatchingIntervalHours, - playlists = _spotifyImportSettings.Playlists.Select(p => new + enabled = GetEnvBool(envVars, "SPOTIFY_IMPORT_ENABLED", _spotifyImportSettings.Enabled), + matchingIntervalHours = GetEnvInt(envVars, "SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS", _spotifyImportSettings.MatchingIntervalHours), + playlists = effectivePlaylists.Select(p => new { name = p.Name, id = p.Id, @@ -97,49 +179,152 @@ public class ConfigController : ControllerBase }, jellyfin = new { - url = _jellyfinSettings.Url, - apiKey = AdminHelperService.MaskValue(_jellyfinSettings.ApiKey), - userId = _jellyfinSettings.UserId ?? "(not set)", - libraryId = _jellyfinSettings.LibraryId + url = GetEnvString(envVars, "JELLYFIN_URL", _jellyfinSettings.Url ?? string.Empty), + apiKey = AdminHelperService.MaskValue(GetEnvString(envVars, "JELLYFIN_API_KEY", _jellyfinSettings.ApiKey ?? string.Empty)), + userId = GetEnvString(envVars, "JELLYFIN_USER_ID", _jellyfinSettings.UserId ?? string.Empty), + libraryId = GetEnvString(envVars, "JELLYFIN_LIBRARY_ID", _jellyfinSettings.LibraryId ?? string.Empty) }, library = new { - downloadPath = _subsonicSettings.StorageMode == StorageMode.Cache - ? Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "cache") - : Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "permanent"), - keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"), - storageMode = _subsonicSettings.StorageMode.ToString(), - cacheDurationHours = _subsonicSettings.CacheDurationHours, - downloadMode = _subsonicSettings.DownloadMode.ToString() + downloadPath = isCacheStorageMode + ? Path.Combine(libraryDownloadRoot, "cache") + : Path.Combine(libraryDownloadRoot, "permanent"), + keptPath = libraryKeptPath, + storageMode = storageModeValue, + cacheDurationHours = GetEnvInt(envVars, "CACHE_DURATION_HOURS", fallbackCacheDurationHours), + downloadMode = GetEnvString(envVars, "DOWNLOAD_MODE", fallbackDownloadMode) }, deezer = new { - arl = AdminHelperService.MaskValue(_deezerSettings.Arl, showLast: 8), - arlFallback = AdminHelperService.MaskValue(_deezerSettings.ArlFallback, showLast: 8), - quality = _deezerSettings.Quality ?? "FLAC" + arl = AdminHelperService.MaskValue(GetEnvString(envVars, "DEEZER_ARL", _deezerSettings.Arl ?? string.Empty), showLast: 8), + arlFallback = AdminHelperService.MaskValue(GetEnvString(envVars, "DEEZER_ARL_FALLBACK", _deezerSettings.ArlFallback ?? string.Empty), showLast: 8), + quality = GetEnvString(envVars, "DEEZER_QUALITY", _deezerSettings.Quality ?? "FLAC") }, qobuz = new { - userAuthToken = AdminHelperService.MaskValue(_qobuzSettings.UserAuthToken, showLast: 8), - userId = _qobuzSettings.UserId, - quality = _qobuzSettings.Quality ?? "FLAC" + userAuthToken = AdminHelperService.MaskValue(GetEnvString(envVars, "QOBUZ_USER_AUTH_TOKEN", _qobuzSettings.UserAuthToken ?? string.Empty), showLast: 8), + userId = GetEnvString(envVars, "QOBUZ_USER_ID", _qobuzSettings.UserId ?? string.Empty), + quality = GetEnvString(envVars, "QOBUZ_QUALITY", _qobuzSettings.Quality ?? "FLAC") }, squidWtf = new { - quality = _squidWtfSettings.Quality ?? "LOSSLESS" + quality = GetEnvString(envVars, "SQUIDWTF_QUALITY", _squidWtfSettings.Quality ?? "LOSSLESS") }, musicBrainz = new { - enabled = _musicBrainzSettings.Enabled, - username = _musicBrainzSettings.Username ?? "(not set)", - password = AdminHelperService.MaskValue(_musicBrainzSettings.Password), + enabled = GetEnvBool(envVars, "MUSICBRAINZ_ENABLED", _musicBrainzSettings.Enabled), + username = GetEnvString(envVars, "MUSICBRAINZ_USERNAME", _musicBrainzSettings.Username ?? string.Empty), + password = AdminHelperService.MaskValue(GetEnvString(envVars, "MUSICBRAINZ_PASSWORD", _musicBrainzSettings.Password ?? string.Empty)), baseUrl = _musicBrainzSettings.BaseUrl, rateLimitMs = _musicBrainzSettings.RateLimitMs }, + cache = new + { + searchResultsMinutes = GetEnvInt(envVars, "CACHE_SEARCH_RESULTS_MINUTES", _configuration.GetValue("Cache:SearchResultsMinutes", 1)), + playlistImagesHours = GetEnvInt(envVars, "CACHE_PLAYLIST_IMAGES_HOURS", _configuration.GetValue("Cache:PlaylistImagesHours", 168)), + spotifyPlaylistItemsHours = GetEnvInt(envVars, "CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS", _configuration.GetValue("Cache:SpotifyPlaylistItemsHours", 168)), + spotifyMatchedTracksDays = GetEnvInt(envVars, "CACHE_SPOTIFY_MATCHED_TRACKS_DAYS", _configuration.GetValue("Cache:SpotifyMatchedTracksDays", 30)), + lyricsDays = GetEnvInt(envVars, "CACHE_LYRICS_DAYS", _configuration.GetValue("Cache:LyricsDays", 14)), + genreDays = GetEnvInt(envVars, "CACHE_GENRE_DAYS", _configuration.GetValue("Cache:GenreDays", 30)), + metadataDays = GetEnvInt(envVars, "CACHE_METADATA_DAYS", _configuration.GetValue("Cache:MetadataDays", 7)), + odesliLookupDays = GetEnvInt(envVars, "CACHE_ODESLI_LOOKUP_DAYS", _configuration.GetValue("Cache:OdesliLookupDays", 60)), + proxyImagesDays = GetEnvInt(envVars, "CACHE_PROXY_IMAGES_DAYS", _configuration.GetValue("Cache:ProxyImagesDays", 14)) + }, scrobbling = await GetScrobblingSettingsFromEnvAsync() }); } - + + private async Task> ReadEnvSettingsAsync() + { + var envVars = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var envPath = _helperService.GetEnvFilePath(); + if (!System.IO.File.Exists(envPath)) + { + return envVars; + } + + var lines = await System.IO.File.ReadAllLinesAsync(envPath); + foreach (var line in lines) + { + if (AdminHelperService.ShouldSkipEnvLine(line)) + continue; + + var (key, value) = AdminHelperService.ParseEnvLine(line); + if (!string.IsNullOrWhiteSpace(key)) + { + envVars[key] = value; + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to parse env settings for config view"); + } + + return envVars; + } + + private static string GetEnvString( + IReadOnlyDictionary envVars, + string key, + string fallback, + bool treatEmptyAsMissing = false) + { + if (!envVars.TryGetValue(key, out var value)) + { + return fallback; + } + + if (treatEmptyAsMissing && string.IsNullOrWhiteSpace(value)) + { + return fallback; + } + + return value; + } + + private static bool GetEnvBool(IReadOnlyDictionary envVars, string key, bool fallback) + { + if (!envVars.TryGetValue(key, out var rawValue)) + { + return fallback; + } + + if (bool.TryParse(rawValue, out var parsed)) + { + return parsed; + } + + if (rawValue.Equals("1", StringComparison.OrdinalIgnoreCase) || + rawValue.Equals("yes", StringComparison.OrdinalIgnoreCase) || + rawValue.Equals("on", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (rawValue.Equals("0", StringComparison.OrdinalIgnoreCase) || + rawValue.Equals("no", StringComparison.OrdinalIgnoreCase) || + rawValue.Equals("off", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return fallback; + } + + private static int GetEnvInt(IReadOnlyDictionary envVars, string key, int fallback) + { + if (!envVars.TryGetValue(key, out var rawValue)) + { + return fallback; + } + + return int.TryParse(rawValue, out var parsed) ? parsed : fallback; + } + /// /// Read scrobbling settings directly from .env file for real-time updates /// @@ -154,6 +339,8 @@ public class ConfigController : ControllerBase return new { enabled = _scrobblingSettings.Enabled, + localTracksEnabled = _scrobblingSettings.LocalTracksEnabled, + syntheticLocalPlayedSignalEnabled = _scrobblingSettings.SyntheticLocalPlayedSignalEnabled, lastFm = new { enabled = _scrobblingSettings.LastFm.Enabled, @@ -170,27 +357,33 @@ public class ConfigController : ControllerBase } }; } - + var lines = await System.IO.File.ReadAllLinesAsync(envPath); var envVars = new Dictionary(); - + foreach (var line in lines) { if (AdminHelperService.ShouldSkipEnvLine(line)) continue; - + var (key, value) = AdminHelperService.ParseEnvLine(line); if (!string.IsNullOrEmpty(key)) { envVars[key] = value; } } - + return new { - enabled = envVars.TryGetValue("SCROBBLING_ENABLED", out var scrobblingEnabled) - ? scrobblingEnabled.Equals("true", StringComparison.OrdinalIgnoreCase) + enabled = envVars.TryGetValue("SCROBBLING_ENABLED", out var scrobblingEnabled) + ? scrobblingEnabled.Equals("true", StringComparison.OrdinalIgnoreCase) : _scrobblingSettings.Enabled, + localTracksEnabled = envVars.TryGetValue("SCROBBLING_LOCAL_TRACKS_ENABLED", out var localTracksEnabled) + ? localTracksEnabled.Equals("true", StringComparison.OrdinalIgnoreCase) + : _scrobblingSettings.LocalTracksEnabled, + syntheticLocalPlayedSignalEnabled = envVars.TryGetValue("SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED", out var syntheticPlayedSignalEnabled) + ? syntheticPlayedSignalEnabled.Equals("true", StringComparison.OrdinalIgnoreCase) + : _scrobblingSettings.SyntheticLocalPlayedSignalEnabled, lastFm = new { enabled = envVars.TryGetValue("SCROBBLING_LASTFM_ENABLED", out var lastFmEnabled) @@ -230,6 +423,8 @@ public class ConfigController : ControllerBase return new { enabled = _scrobblingSettings.Enabled, + localTracksEnabled = _scrobblingSettings.LocalTracksEnabled, + syntheticLocalPlayedSignalEnabled = _scrobblingSettings.SyntheticLocalPlayedSignalEnabled, lastFm = new { enabled = _scrobblingSettings.LastFm.Enabled, @@ -247,20 +442,26 @@ public class ConfigController : ControllerBase }; } } - + /// /// Update configuration by modifying .env file /// [HttpPost("config")] public async Task UpdateConfig([FromBody] ConfigUpdateRequest request) { + var adminCheck = RequireAdministratorForSensitiveOperation("config update"); + if (adminCheck != null) + { + return adminCheck; + } + if (request == null || request.Updates == null || request.Updates.Count == 0) { return BadRequest(new { error = "No updates provided" }); } - + _logger.LogDebug("Config update requested: {Count} changes", request.Updates.Count); - + try { // Check if .env file exists @@ -268,10 +469,10 @@ public class ConfigController : ControllerBase { _logger.LogWarning(".env file not found at {Path}, creating new file", _helperService.GetEnvFilePath()); } - + // Read current .env file or create new one var envContent = new Dictionary(); - + if (System.IO.File.Exists(_helperService.GetEnvFilePath())) { var lines = await System.IO.File.ReadAllLinesAsync(_helperService.GetEnvFilePath()); @@ -279,25 +480,25 @@ public class ConfigController : ControllerBase { if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) continue; - + var eqIndex = line.IndexOf('='); if (eqIndex > 0) { var key = line[..eqIndex].Trim(); var value = line[(eqIndex + 1)..].Trim(); - + // Remove surrounding quotes if present (for proper re-quoting) if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2) { value = value[1..^1]; } - + envContent[key] = value; } } _logger.LogDebug("Loaded {Count} existing env vars from {Path}", envContent.Count, _helperService.GetEnvFilePath()); } - + // Apply updates with validation var appliedUpdates = new List(); foreach (var (key, value) in request.Updates) @@ -308,17 +509,17 @@ public class ConfigController : ControllerBase _logger.LogWarning("Invalid env key rejected: {Key}", key); return BadRequest(new { error = $"Invalid environment variable key: {key}" }); } - + // IMPORTANT: Docker Compose does NOT need quotes in .env files // It handles special characters correctly without them // When quotes are used, they become part of the value itself envContent[key] = value; appliedUpdates.Add(key); - _logger.LogInformation(" Setting {Key} = {Value}", key, + _logger.LogInformation(" Setting {Key} = {Value}", key, key.Contains("COOKIE") || key.Contains("TOKEN") || key.Contains("KEY") || key.Contains("ARL") || key.Contains("PASSWORD") - ? "***" + (value.Length > 8 ? value[^8..] : "") + ? "***" + (value.Length > 8 ? value[^8..] : "") : value); - + // Auto-set cookie date when Spotify session cookie is updated if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value)) { @@ -329,19 +530,19 @@ public class ConfigController : ControllerBase _logger.LogInformation(" Auto-setting {Key} to {Value}", dateKey, dateValue); } } - + // Write back to .env file (no quoting needed - Docker Compose handles special chars) var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}")); await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), newContent + "\n"); - + _logger.LogDebug("Config file updated successfully at {Path}", _helperService.GetEnvFilePath()); - + // Invalidate playlist summary cache if playlists were updated if (appliedUpdates.Contains("SPOTIFY_IMPORT_PLAYLISTS")) { _helperService.InvalidatePlaylistSummaryCache(); } - + return Ok(new { message = "Configuration updated. Restart container to apply changes.", @@ -353,23 +554,20 @@ public class ConfigController : ControllerBase catch (UnauthorizedAccessException ex) { _logger.LogError(ex, "Permission denied writing to .env file at {Path}", _helperService.GetEnvFilePath()); - return StatusCode(500, new { - error = "Permission denied", - details = "Cannot write to .env file. Check file permissions and volume mount.", - path = _helperService.GetEnvFilePath() + return StatusCode(500, new { + error = "Permission denied", + message = "Cannot write to .env file. Check file permissions and volume mount." }); } catch (Exception ex) { _logger.LogError(ex, "Failed to update configuration at {Path}", _helperService.GetEnvFilePath()); - return StatusCode(500, new { - error = "Failed to update configuration", - details = ex.Message, - path = _helperService.GetEnvFilePath() + return StatusCode(500, new { + error = "Failed to update configuration" }); } } - + /// /// Add a new playlist to the configuration /// @@ -377,10 +575,10 @@ public class ConfigController : ControllerBase public async Task ClearCache() { _logger.LogDebug("Cache clear requested from admin UI"); - + var clearedFiles = 0; var clearedRedisKeys = 0; - + // Clear file cache if (Directory.Exists(CacheDirectory)) { @@ -397,7 +595,7 @@ public class ConfigController : ControllerBase } } } - + // Clear ALL Redis cache keys for Spotify playlists // This includes matched tracks, ordered tracks, missing tracks, playlist items, etc. foreach (var playlist in _spotifyImportSettings.Playlists) @@ -410,7 +608,7 @@ public class ConfigController : ControllerBase CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name), CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name) }; - + foreach (var key in keysToDelete) { if (await _cache.DeleteAsync(key)) @@ -420,54 +618,60 @@ public class ConfigController : ControllerBase } } } - + // Clear all search cache keys (pattern-based deletion) var searchKeysDeleted = await _cache.DeleteByPatternAsync("search:*"); clearedRedisKeys += searchKeysDeleted; - + // Clear all image cache keys (pattern-based deletion) var imageKeysDeleted = await _cache.DeleteByPatternAsync("image:*"); clearedRedisKeys += imageKeysDeleted; - - _logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)", + + _logger.LogInformation("Cache cleared: {Files} files, {RedisKeys} Redis keys (including {SearchKeys} search keys, {ImageKeys} image keys)", clearedFiles, clearedRedisKeys, searchKeysDeleted, imageKeysDeleted); - - return Ok(new { - message = "Cache cleared successfully", + + return Ok(new { + message = "Cache cleared successfully", filesDeleted = clearedFiles, redisKeysDeleted = clearedRedisKeys }); } - + /// /// Restart the allstarr container to apply configuration changes /// [HttpPost("restart")] public async Task RestartContainer() { + var adminCheck = RequireAdministratorForSensitiveOperation("container restart"); + if (adminCheck != null) + { + return adminCheck; + } + _logger.LogDebug("Container restart requested from admin UI"); - + try { // Use Docker socket to restart the container var socketPath = "/var/run/docker.sock"; - + if (!System.IO.File.Exists(socketPath)) { _logger.LogWarning("Docker socket not available at {Path}", socketPath); - return StatusCode(503, new { - error = "Docker socket not available", - message = "Please restart manually: docker-compose restart allstarr" + return StatusCode(503, new { + error = "Docker socket not available", + message = "Please restart manually: docker-compose restart allstarr" }); } - + // Get container ID from hostname (Docker sets hostname to container ID by default) // Or use the well-known container name var containerId = Environment.MachineName; var containerName = "allstarr"; - + _logger.LogDebug("Attempting to restart container {ContainerId} / {ContainerName}", containerId, containerName); - + // Create Unix socket HTTP client var handler = new SocketsHttpHandler { @@ -477,28 +681,28 @@ public class ConfigController : ControllerBase System.Net.Sockets.AddressFamily.Unix, System.Net.Sockets.SocketType.Stream, System.Net.Sockets.ProtocolType.Unspecified); - + var endpoint = new System.Net.Sockets.UnixDomainSocketEndPoint(socketPath); await socket.ConnectAsync(endpoint, cancellationToken); - + return new System.Net.Sockets.NetworkStream(socket, ownsSocket: true); } }; - + using var dockerClient = new HttpClient(handler) { BaseAddress = new Uri("http://localhost") }; - + // Try to restart by container name first, then by ID var response = await dockerClient.PostAsync($"/containers/{containerName}/restart?t=5", null); - + if (!response.IsSuccessStatusCode) { // Try by container ID response = await dockerClient.PostAsync($"/containers/{containerId}/restart?t=5", null); } - + if (response.IsSuccessStatusCode) { _logger.LogInformation("Container restart initiated successfully"); @@ -508,42 +712,47 @@ public class ConfigController : ControllerBase { var errorBody = await response.Content.ReadAsStringAsync(); _logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody); - return StatusCode((int)response.StatusCode, new { - error = "Failed to restart container", - message = "Please restart manually: docker-compose restart allstarr" + return StatusCode((int)response.StatusCode, new { + error = "Failed to restart container", + message = "Please restart manually: docker-compose restart allstarr" }); } } catch (Exception ex) { _logger.LogError(ex, "Error restarting container"); - return StatusCode(500, new { - error = "Failed to restart container", - details = ex.Message, - message = "Please restart manually: docker-compose restart allstarr" + return StatusCode(500, new { + error = "Failed to restart container", + message = "Please restart manually: docker-compose restart allstarr" }); } } - + /// /// Initialize cookie date to current date if cookie exists but date is not set /// [HttpPost("config/init-cookie-date")] public async Task InitCookieDate() { + var adminCheck = RequireAdministratorForSensitiveOperation("init cookie date"); + if (adminCheck != null) + { + return adminCheck; + } + // Only init if cookie exists but date is not set if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie)) { return BadRequest(new { error = "No cookie set" }); } - + if (!string.IsNullOrEmpty(_spotifyApiSettings.SessionCookieSetDate)) { return Ok(new { message = "Cookie date already set", date = _spotifyApiSettings.SessionCookieSetDate }); } - + _logger.LogInformation("Initializing cookie date to current date (cookie existed without date tracking)"); - + var updateRequest = new ConfigUpdateRequest { Updates = new Dictionary @@ -551,16 +760,32 @@ public class ConfigController : ControllerBase ["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = DateTime.UtcNow.ToString("o") } }; - + return await UpdateConfig(updateRequest); } - + /// /// Get all Jellyfin users /// [HttpGet("export-env")] public IActionResult ExportEnv() { + var adminCheck = RequireAdministratorForSensitiveOperation("export env"); + if (adminCheck != null) + { + return adminCheck; + } + + if (!IsEnvExportEnabled()) + { + _logger.LogWarning("Blocked export-env request because ADMIN__ENABLE_ENV_EXPORT is disabled"); + return NotFound(new + { + error = "Export endpoint is disabled by default", + message = "Set ADMIN__ENABLE_ENV_EXPORT=true to temporarily enable .env export." + }); + } + try { if (!System.IO.File.Exists(_helperService.GetEnvFilePath())) @@ -570,22 +795,28 @@ public class ConfigController : ControllerBase var envContent = System.IO.File.ReadAllText(_helperService.GetEnvFilePath()); var bytes = System.Text.Encoding.UTF8.GetBytes(envContent); - + return File(bytes, "text/plain", ".env"); } catch (Exception ex) { _logger.LogError(ex, "Failed to export .env file"); - return StatusCode(500, new { error = "Failed to export .env file", details = ex.Message }); + return StatusCode(500, new { error = "Failed to export .env file" }); } } - + /// /// Import .env file from upload /// [HttpPost("import-env")] public async Task ImportEnv([FromForm] IFormFile file) { + var adminCheck = RequireAdministratorForSensitiveOperation("import env"); + if (adminCheck != null) + { + return adminCheck; + } + if (file == null || file.Length == 0) { return BadRequest(new { error = "No file provided" }); @@ -601,7 +832,7 @@ public class ConfigController : ControllerBase // Read uploaded file using var reader = new StreamReader(file.OpenReadStream()); var content = await reader.ReadToEndAsync(); - + // Validate it's a valid .env file (basic check) if (string.IsNullOrWhiteSpace(content)) { @@ -618,22 +849,66 @@ public class ConfigController : ControllerBase // Write new .env file await System.IO.File.WriteAllTextAsync(_helperService.GetEnvFilePath(), content); - + _logger.LogInformation(".env file imported successfully"); - - return Ok(new - { - success = true, - message = ".env file imported successfully. Restart the application for changes to take effect." + + return Ok(new + { + success = true, + message = ".env file imported successfully. Restart the application for changes to take effect." }); } catch (Exception ex) { _logger.LogError(ex, "Failed to import .env file"); - return StatusCode(500, new { error = "Failed to import .env file", details = ex.Message }); + return StatusCode(500, new { error = "Failed to import .env file" }); } } - + + private string? GetAuthenticatedUserId() + { + if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) && + sessionObj is AdminAuthSession session && + !string.IsNullOrWhiteSpace(session.UserId)) + { + return session.UserId; + } + + return null; + } + + private IActionResult? RequireAdministratorForSensitiveOperation(string operationName) + { + if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) && + sessionObj is AdminAuthSession session && + session.IsAdministrator) + { + return null; + } + + _logger.LogWarning("Blocked sensitive admin operation '{Operation}' due to missing administrator session", operationName); + return StatusCode(StatusCodes.Status403Forbidden, new + { + error = "Administrator permissions required", + message = "This operation is restricted to Jellyfin administrators." + }); + } + + private bool IsEnvExportEnabled() + { + if (_configuration.GetValue("Admin:EnableEnvExport")) + { + return true; + } + + if (_configuration.GetValue("ADMIN__ENABLE_ENV_EXPORT")) + { + return true; + } + + return _configuration.GetValue("ADMIN_ENABLE_ENV_EXPORT"); + } + /// /// Gets detailed memory usage statistics for debugging. /// diff --git a/allstarr/Controllers/DiagnosticsController.cs b/allstarr/Controllers/DiagnosticsController.cs index 7872a4c..90eaf0c 100644 --- a/allstarr/Controllers/DiagnosticsController.cs +++ b/allstarr/Controllers/DiagnosticsController.cs @@ -2,8 +2,13 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Options; using allstarr.Models.Settings; using allstarr.Filters; +using allstarr.Models.Admin; using allstarr.Services.Jellyfin; using allstarr.Services.Common; +using allstarr.Services.Admin; +using allstarr.Services.Spotify; +using allstarr.Services.Scrobbling; +using allstarr.Services.SquidWTF; using System.Runtime; namespace allstarr.Controllers; @@ -22,6 +27,7 @@ public class DiagnosticsController : ControllerBase private readonly QobuzSettings _qobuzSettings; private readonly SquidWTFSettings _squidWtfSettings; private readonly RedisCacheService _cache; + private readonly SpotifySessionCookieService _spotifySessionCookieService; private readonly List _squidWtfApiUrls; private static int _urlIndex = 0; private static readonly object _urlIndexLock = new(); @@ -35,6 +41,8 @@ public class DiagnosticsController : ControllerBase IOptions deezerSettings, IOptions qobuzSettings, IOptions squidWtfSettings, + SpotifySessionCookieService spotifySessionCookieService, + SquidWtfEndpointCatalog squidWtfEndpointCatalog, RedisCacheService cache) { _logger = logger; @@ -45,52 +53,42 @@ public class DiagnosticsController : ControllerBase _deezerSettings = deezerSettings.Value; _qobuzSettings = qobuzSettings.Value; _squidWtfSettings = squidWtfSettings.Value; + _spotifySessionCookieService = spotifySessionCookieService; _cache = cache; - _squidWtfApiUrls = DecodeSquidWtfUrls(); - } - - private static List DecodeSquidWtfUrls() - { - var encodedUrls = new[] - { - "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", - "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", - "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", - "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", - "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", - "aHR0cDovL2h1bmQucXFkbC5zaXRl", - "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", - "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", - "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", - "aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", - "aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", - "aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", - "aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", - "aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" - }; - return encodedUrls.Select(encoded => System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(encoded))).ToList(); + _squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls; } [HttpGet("status")] - public IActionResult GetStatus() + public async Task GetStatus() { // Determine Spotify auth status based on configuration only // DO NOT call Spotify API here - this endpoint is polled frequently var spotifyAuthStatus = "not_configured"; string? spotifyUser = null; - - if (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie)) + var sessionUserId = GetAuthenticatedUserId(); + var cookieStatus = await _spotifySessionCookieService.GetCookieStatusAsync(sessionUserId); + var userCookieSetDate = !string.IsNullOrWhiteSpace(sessionUserId) + ? await _spotifySessionCookieService.GetCookieSetDateAsync(sessionUserId) + : null; + var effectiveCookieSetDate = userCookieSetDate?.ToString("o"); + + if (string.IsNullOrWhiteSpace(effectiveCookieSetDate) && cookieStatus.UsingGlobalFallback) + { + effectiveCookieSetDate = _spotifyApiSettings.SessionCookieSetDate; + } + + if (_spotifyApiSettings.Enabled && cookieStatus.HasCookie) { // If cookie is set, assume it's working until proven otherwise // Actual validation happens when playlists are fetched spotifyAuthStatus = "configured"; - spotifyUser = "(cookie set)"; + spotifyUser = cookieStatus.UsingGlobalFallback ? "(global fallback cookie set)" : "(user cookie set)"; } else if (_spotifyApiSettings.Enabled) { spotifyAuthStatus = "missing_cookie"; } - + return Ok(new { version = AppVersion.Version, @@ -101,8 +99,9 @@ public class DiagnosticsController : ControllerBase apiEnabled = _spotifyApiSettings.Enabled, authStatus = spotifyAuthStatus, user = spotifyUser, - hasCookie = !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie), - cookieSetDate = _spotifyApiSettings.SessionCookieSetDate, + hasCookie = cookieStatus.HasCookie, + usingGlobalFallback = cookieStatus.UsingGlobalFallback, + cookieSetDate = effectiveCookieSetDate, cacheDurationMinutes = _spotifyApiSettings.CacheDurationMinutes, preferIsrcMatching = _spotifyApiSettings.PreferIsrcMatching }, @@ -128,7 +127,19 @@ public class DiagnosticsController : ControllerBase } }); } - + + private string? GetAuthenticatedUserId() + { + if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) && + sessionObj is AdminAuthSession session && + !string.IsNullOrWhiteSpace(session.UserId)) + { + return session.UserId; + } + + return null; + } + /// /// Get a random SquidWTF base URL for searching (round-robin) /// @@ -139,21 +150,21 @@ public class DiagnosticsController : ControllerBase { return NotFound(new { error = "No SquidWTF base URLs configured" }); } - + string baseUrl; lock (_urlIndexLock) { baseUrl = _squidWtfApiUrls[_urlIndex]; _urlIndex = (_urlIndex + 1) % _squidWtfApiUrls.Count; } - + return Ok(new { baseUrl }); } - + /// /// Get current configuration including cache settings /// - + /// /// Get list of configured playlists with their current data /// @@ -167,7 +178,7 @@ public class DiagnosticsController : ControllerBase var gen0Before = GC.CollectionCount(0); var gen1Before = GC.CollectionCount(1); var gen2Before = GC.CollectionCount(2); - + // Force garbage collection to get accurate numbers GC.Collect(); GC.WaitForPendingFinalizers(); @@ -177,10 +188,10 @@ public class DiagnosticsController : ControllerBase var gen0After = GC.CollectionCount(0); var gen1After = GC.CollectionCount(1); var gen2After = GC.CollectionCount(2); - + // Get process memory info var process = System.Diagnostics.Process.GetCurrentProcess(); - + return Ok(new { Timestamp = DateTime.UtcNow, BeforeGC = new { @@ -215,7 +226,8 @@ public class DiagnosticsController : ControllerBase } catch (Exception ex) { - return BadRequest(new { error = ex.Message }); + _logger.LogError(ex, "Failed to collect memory statistics"); + return BadRequest(new { error = "Failed to collect memory statistics" }); } } @@ -229,15 +241,15 @@ public class DiagnosticsController : ControllerBase { var memoryBefore = GC.GetTotalMemory(false); var processBefore = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64; - + // Force full garbage collection GC.Collect(2, GCCollectionMode.Forced); GC.WaitForPendingFinalizers(); GC.Collect(2, GCCollectionMode.Forced); - + var memoryAfter = GC.GetTotalMemory(false); var processAfter = System.Diagnostics.Process.GetCurrentProcess().WorkingSet64; - + return Ok(new { Timestamp = DateTime.UtcNow, MemoryFreedMB = Math.Round((memoryBefore - memoryAfter) / (1024.0 * 1024.0), 2), @@ -250,7 +262,8 @@ public class DiagnosticsController : ControllerBase } catch (Exception ex) { - return BadRequest(new { error = ex.Message }); + _logger.LogError(ex, "Failed to force garbage collection"); + return BadRequest(new { error = "Failed to force garbage collection" }); } } @@ -273,7 +286,32 @@ public class DiagnosticsController : ControllerBase } catch (Exception ex) { - return BadRequest(new { error = ex.Message }); + _logger.LogError(ex, "Failed to get active sessions"); + return BadRequest(new { error = "Failed to get active sessions" }); + } + } + + /// + /// Gets current active scrobbling sessions for debugging. + /// + [HttpGet("scrobbling-sessions")] + public IActionResult GetScrobblingSessions() + { + try + { + var scrobblingOrchestrator = HttpContext.RequestServices.GetService(); + if (scrobblingOrchestrator == null) + { + return BadRequest(new { error = "Scrobbling orchestrator not available" }); + } + + var sessionInfo = scrobblingOrchestrator.GetSessionsInfo(); + return Ok(sessionInfo); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to get scrobbling sessions"); + return BadRequest(new { error = "Failed to get scrobbling sessions" }); } } @@ -288,24 +326,24 @@ public class DiagnosticsController : ControllerBase try { var logFile = "/app/cache/endpoint-usage/endpoints.csv"; - + if (!System.IO.File.Exists(logFile)) { - return Ok(new { + return Ok(new { message = "No endpoint usage data available", endpoints = new object[0] }); } - + var lines = await System.IO.File.ReadAllLinesAsync(logFile); var usage = new Dictionary(); DateTime? sinceDate = null; - + if (!string.IsNullOrEmpty(since) && DateTime.TryParse(since, out var parsedDate)) { sinceDate = parsedDate; } - + foreach (var line in lines.Skip(1)) // Skip header { var parts = line.Split(','); @@ -314,27 +352,27 @@ public class DiagnosticsController : ControllerBase var timestamp = parts[0]; var method = parts[1]; var endpoint = parts[2]; - + // Combine method and endpoint for better clarity var fullEndpoint = $"{method} {endpoint}"; - + // Filter by date if specified if (sinceDate.HasValue && DateTime.TryParse(timestamp, out var logDate)) { if (logDate < sinceDate.Value) continue; } - + usage[fullEndpoint] = usage.GetValueOrDefault(fullEndpoint, 0) + 1; } } - + var topEndpoints = usage .OrderByDescending(kv => kv.Value) .Take(top) .Select(kv => new { endpoint = kv.Key, count = kv.Value }) .ToArray(); - + return Ok(new { totalEndpoints = usage.Count, totalRequests = usage.Values.Sum(), @@ -359,20 +397,20 @@ public class DiagnosticsController : ControllerBase try { var logFile = "/app/cache/endpoint-usage/endpoints.csv"; - + if (System.IO.File.Exists(logFile)) { System.IO.File.Delete(logFile); _logger.LogDebug("Cleared endpoint usage log via admin endpoint"); - - return Ok(new { + + return Ok(new { message = "Endpoint usage log cleared successfully", timestamp = DateTime.UtcNow }); } else { - return Ok(new { + return Ok(new { message = "No endpoint usage log file found", timestamp = DateTime.UtcNow }); @@ -385,8 +423,8 @@ public class DiagnosticsController : ControllerBase } } - - + + /// /// Saves a manual mapping to file for persistence across restarts. /// Manual mappings NEVER expire - they are permanent user decisions. diff --git a/allstarr/Controllers/DownloadsController.cs b/allstarr/Controllers/DownloadsController.cs index 62e872a..412dc7c 100644 --- a/allstarr/Controllers/DownloadsController.cs +++ b/allstarr/Controllers/DownloadsController.cs @@ -26,34 +26,34 @@ public class DownloadsController : ControllerBase try { var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); - + if (!Directory.Exists(keptPath)) { return Ok(new { files = new List(), totalSize = 0, count = 0 }); } - + var files = new List(); long totalSize = 0; - + // Recursively get all audio files from kept folder var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" }; - + var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) .Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) .ToList(); - + foreach (var filePath in allFiles) { - + var fileInfo = new FileInfo(filePath); var relativePath = Path.GetRelativePath(keptPath, filePath); - + // Parse artist/album/track from path structure var parts = relativePath.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar); var artist = parts.Length > 0 ? parts[0] : ""; var album = parts.Length > 1 ? parts[1] : ""; var fileName = parts.Length > 2 ? parts[^1] : Path.GetFileName(filePath); - + files.Add(new { path = relativePath, @@ -66,10 +66,10 @@ public class DownloadsController : ControllerBase lastModified = fileInfo.LastWriteTimeUtc, extension = fileInfo.Extension }); - + totalSize += fileInfo.Length; } - + return Ok(new { files = files.OrderBy(f => ((dynamic)f).artist).ThenBy(f => ((dynamic)f).album).ThenBy(f => ((dynamic)f).fileName), @@ -84,7 +84,7 @@ public class DownloadsController : ControllerBase return StatusCode(500, new { error = "Failed to list kept downloads" }); } } - + /// /// DELETE /api/admin/downloads /// Deletes a specific kept file and cleans up empty folders @@ -98,29 +98,26 @@ public class DownloadsController : ControllerBase { return BadRequest(new { error = "Path is required" }); } - - var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); - var fullPath = Path.Combine(keptPath, path); - - // Security: Ensure the path is within the kept directory - var normalizedFullPath = Path.GetFullPath(fullPath); - var normalizedKeptPath = Path.GetFullPath(keptPath); - - if (!normalizedFullPath.StartsWith(normalizedKeptPath)) + + var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept")); + + if (!TryResolvePathUnderRoot(keptPath, path, out var fullPath)) { return BadRequest(new { error = "Invalid path" }); } - + if (!System.IO.File.Exists(fullPath)) { return NotFound(new { error = "File not found" }); } - + System.IO.File.Delete(fullPath); - + // Clean up empty directories (Album folder, then Artist folder if empty) var directory = Path.GetDirectoryName(fullPath); - while (directory != null && directory != keptPath && directory.StartsWith(keptPath)) + while (directory != null && + !string.Equals(directory, keptPath, GetPathComparison()) && + IsPathUnderRoot(directory, keptPath)) { if (Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any()) { @@ -132,7 +129,7 @@ public class DownloadsController : ControllerBase break; } } - + return Ok(new { success = true, message = "File deleted successfully" }); } catch (Exception ex) @@ -141,7 +138,7 @@ public class DownloadsController : ControllerBase return StatusCode(500, new { error = "Failed to delete file" }); } } - + /// /// GET /api/admin/downloads/file /// Downloads a specific file from the kept folder @@ -155,27 +152,22 @@ public class DownloadsController : ControllerBase { return BadRequest(new { error = "Path is required" }); } - - var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); - var fullPath = Path.Combine(keptPath, path); - - // Security: Ensure the path is within the kept directory - var normalizedFullPath = Path.GetFullPath(fullPath); - var normalizedKeptPath = Path.GetFullPath(keptPath); - - if (!normalizedFullPath.StartsWith(normalizedKeptPath)) + + var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept")); + + if (!TryResolvePathUnderRoot(keptPath, path, out var fullPath)) { return BadRequest(new { error = "Invalid path" }); } - + if (!System.IO.File.Exists(fullPath)) { return NotFound(new { error = "File not found" }); } - + var fileName = Path.GetFileName(fullPath); var fileStream = System.IO.File.OpenRead(fullPath); - + return File(fileStream, "application/octet-stream", fileName); } catch (Exception ex) @@ -184,7 +176,7 @@ public class DownloadsController : ControllerBase return StatusCode(500, new { error = "Failed to download file" }); } } - + /// /// GET /api/admin/downloads/all /// Downloads all kept files as a zip archive @@ -195,24 +187,24 @@ public class DownloadsController : ControllerBase try { var keptPath = Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"); - + if (!Directory.Exists(keptPath)) { return NotFound(new { error = "No kept files found" }); } - + var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" }; var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories) .Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant())) .ToList(); - + if (allFiles.Count == 0) { return NotFound(new { error = "No audio files found in kept folder" }); } - + _logger.LogInformation("📦 Creating zip archive with {Count} files", allFiles.Count); - + // Create zip in memory var memoryStream = new MemoryStream(); using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true)) @@ -221,13 +213,13 @@ public class DownloadsController : ControllerBase { var relativePath = Path.GetRelativePath(keptPath, filePath); var entry = archive.CreateEntry(relativePath, System.IO.Compression.CompressionLevel.NoCompression); - + using var entryStream = entry.Open(); using var fileStream = System.IO.File.OpenRead(filePath); fileStream.CopyTo(entryStream); } } - + memoryStream.Position = 0; var timestamp = DateTime.Now.ToString("yyyyMMdd_HHmmss"); return File(memoryStream, "application/zip", $"allstarr_kept_{timestamp}.zip"); @@ -238,7 +230,56 @@ public class DownloadsController : ControllerBase return StatusCode(500, new { error = "Failed to create zip archive" }); } } - + + private static bool TryResolvePathUnderRoot(string rootPath, string requestedPath, out string resolvedPath) + { + resolvedPath = string.Empty; + + if (string.IsNullOrWhiteSpace(requestedPath)) + { + return false; + } + + try + { + var normalizedRoot = Path.GetFullPath(rootPath); + var normalizedRootWithSeparator = normalizedRoot.EndsWith(Path.DirectorySeparatorChar) + ? normalizedRoot + : normalizedRoot + Path.DirectorySeparatorChar; + + var candidatePath = Path.GetFullPath(Path.Combine(normalizedRoot, requestedPath)); + if (!candidatePath.StartsWith(normalizedRootWithSeparator, GetPathComparison())) + { + return false; + } + + resolvedPath = candidatePath; + return true; + } + catch (Exception) + { + return false; + } + } + + private static bool IsPathUnderRoot(string candidatePath, string rootPath) + { + var normalizedRoot = Path.GetFullPath(rootPath); + var normalizedRootWithSeparator = normalizedRoot.EndsWith(Path.DirectorySeparatorChar) + ? normalizedRoot + : normalizedRoot + Path.DirectorySeparatorChar; + var normalizedCandidate = Path.GetFullPath(candidatePath); + + return normalizedCandidate.StartsWith(normalizedRootWithSeparator, GetPathComparison()); + } + + private static StringComparison GetPathComparison() + { + return OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + } + /// /// Gets all Spotify track mappings (paginated) /// diff --git a/allstarr/Controllers/Helpers.cs b/allstarr/Controllers/Helpers.cs index c5869cc..7b1a6b9 100644 --- a/allstarr/Controllers/Helpers.cs +++ b/allstarr/Controllers/Helpers.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text; using allstarr.Models.Domain; using allstarr.Models.Spotify; using allstarr.Services.Common; @@ -62,6 +63,7 @@ public partial class JellyfinController var itemsArray = items.EnumerateArray().ToList(); var modified = false; var updatedItems = new List>(); + var spotifyPlaylistCreatedDates = new Dictionary(StringComparer.OrdinalIgnoreCase); _logger.LogDebug("Checking {Count} items for Spotify playlists", itemsArray.Count); @@ -81,23 +83,34 @@ public partial class JellyfinController if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId)) { - _logger.LogInformation("Found Spotify playlist: {Id}", playlistId); + _logger.LogDebug("Found Spotify playlist: {Id}", playlistId); // This is a Spotify playlist - get the actual track count var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId); if (playlistConfig != null) { - _logger.LogInformation( + _logger.LogDebug( "Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})", playlistId, playlistConfig.Name, playlistConfig.Id); var playlistName = playlistConfig.Name; + if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate)) + { + playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistName); + spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate; + } + + if (ApplySpotifyPlaylistCreatedDate(itemDict, playlistCreatedDate)) + { + modified = true; + } + // Get matched external tracks (tracks that were successfully downloaded/matched) var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName); var matchedTracks = await _cache.GetAsync>(matchedTracksKey); - _logger.LogInformation("Cache lookup for {Key}: {Count} matched tracks", + _logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks", matchedTracksKey, matchedTracks?.Count ?? 0); // Fallback to legacy cache format @@ -210,7 +223,7 @@ public partial class JellyfinController if (!modified) { - _logger.LogInformation("No Spotify playlists found to update"); + _logger.LogDebug("No Spotify playlists found to update"); return response; } @@ -224,7 +237,11 @@ public partial class JellyfinController { responseDict["Items"] = updatedItems; var updatedJson = JsonSerializer.Serialize(responseDict); - return JsonDocument.Parse(updatedJson); + + // Parse new document and dispose the old one to prevent memory leak + var newDocument = JsonDocument.Parse(updatedJson); + response.Dispose(); + return newDocument; } return response; @@ -236,9 +253,78 @@ public partial class JellyfinController } } + private async Task ResolveSpotifyPlaylistCreatedDateAsync(string playlistName) + { + try + { + var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName); + var cachedPlaylist = await _cache.GetAsync(cacheKey); + var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist); + if (createdAt.HasValue) + { + return createdAt.Value; + } + + if (_spotifyPlaylistFetcher == null) + { + return null; + } + + var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(playlistName); + var earliestTrackAddedAt = tracks + .Where(t => t.AddedAt.HasValue) + .Select(t => t.AddedAt!.Value.ToUniversalTime()) + .OrderBy(t => t) + .FirstOrDefault(); + return earliestTrackAddedAt == default ? null : earliestTrackAddedAt; + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve created date for Spotify playlist {PlaylistName}", playlistName); + return null; + } + } + + private static DateTime? GetCreatedDateFromSpotifyPlaylist(SpotifyPlaylist? playlist) + { + if (playlist == null) + { + return null; + } + + if (playlist.CreatedAt.HasValue) + { + return playlist.CreatedAt.Value.ToUniversalTime(); + } + + var earliestTrackAddedAt = playlist.Tracks + .Where(t => t.AddedAt.HasValue) + .Select(t => t.AddedAt!.Value.ToUniversalTime()) + .OrderBy(t => t) + .FirstOrDefault(); + return earliestTrackAddedAt == default ? null : earliestTrackAddedAt; + } + + private static bool ApplySpotifyPlaylistCreatedDate(Dictionary itemDict, DateTime? playlistCreatedDate) + { + if (!playlistCreatedDate.HasValue) + { + return false; + } + + var createdUtc = playlistCreatedDate.Value.ToUniversalTime(); + var createdAtIso = createdUtc.ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + + itemDict["DateCreated"] = createdAtIso; + itemDict["PremiereDate"] = createdAtIso; + itemDict["ProductionYear"] = createdUtc.Year; + return true; + } + /// /// Logs endpoint usage to a file for analysis. - /// Creates a CSV file with timestamp, method, path, and query string. + /// Creates a CSV file with timestamp, method, and path only. + /// Query strings are intentionally excluded to avoid persisting sensitive data. /// private async Task LogEndpointUsageAsync(string path, string method) { @@ -249,13 +335,11 @@ public partial class JellyfinController 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) + // Sanitize path 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"; + var logLine = $"{timestamp},{method},{sanitizedPath}\n"; // Append to file (thread-safe) await System.IO.File.AppendAllTextAsync(logFile, logLine); @@ -267,6 +351,41 @@ public partial class JellyfinController } } + // Redacts security-sensitive query params before any logging or analytics persistence. + private static string MaskSensitiveQueryString(string? queryString) + { + if (string.IsNullOrEmpty(queryString)) + { + return string.Empty; + } + + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString); + var parts = new List(); + + foreach (var kv in query) + { + var key = kv.Key; + var value = kv.Value.ToString(); + if (string.Equals(key, "api_key", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "token", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "auth", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "authorization", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "x-emby-token", StringComparison.OrdinalIgnoreCase) || + string.Equals(key, "x-emby-authorization", StringComparison.OrdinalIgnoreCase) || + key.Contains("token", StringComparison.OrdinalIgnoreCase) || + key.Contains("auth", StringComparison.OrdinalIgnoreCase)) + { + parts.Add($"{key}="); + } + else + { + parts.Add($"{key}={value}"); + } + } + + return parts.Count > 0 ? "?" + string.Join("&", parts) : string.Empty; + } + private static string[]? ParseItemTypes(string? includeItemTypes) { if (string.IsNullOrWhiteSpace(includeItemTypes)) @@ -277,6 +396,131 @@ public partial class JellyfinController return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); } + /// + /// Determines whether Spotify playlist count enrichment should run for a response. + /// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists + /// (for example, album browse responses requested by clients like Finer). + /// + private bool ShouldProcessSpotifyPlaylistCounts(JsonDocument response, string? includeItemTypes) + { + if (!_spotifySettings.Enabled) + { + return false; + } + + if (response.RootElement.ValueKind != JsonValueKind.Object || + !response.RootElement.TryGetProperty("Items", out var items) || + items.ValueKind != JsonValueKind.Array) + { + return false; + } + + var requestedTypes = ParseItemTypes(includeItemTypes); + if (requestedTypes != null && requestedTypes.Length > 0) + { + return requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase); + } + + // If the request did not explicitly constrain types, inspect payload types. + foreach (var item in items.EnumerateArray()) + { + if (!item.TryGetProperty("Type", out var typeProp)) + { + continue; + } + + if (string.Equals(typeProp.GetString(), "Playlist", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + } + + return false; + } + + /// + /// Recovers SearchTerm directly from raw query string. + /// Handles malformed clients that do not URL-encode '&' inside SearchTerm. + /// + private static string? RecoverSearchTermFromRawQuery(string? rawQueryString) + { + if (string.IsNullOrWhiteSpace(rawQueryString)) + { + return null; + } + + var query = rawQueryString[0] == '?' ? rawQueryString[1..] : rawQueryString; + const string key = "SearchTerm="; + var start = query.IndexOf(key, StringComparison.OrdinalIgnoreCase); + if (start < 0) + { + return null; + } + + var valueStart = start + key.Length; + if (valueStart >= query.Length) + { + return string.Empty; + } + + var sb = new StringBuilder(); + var i = valueStart; + while (i < query.Length) + { + var ch = query[i]; + if (ch == '&') + { + var next = i + 1; + var equalsIndex = query.IndexOf('=', next); + var nextAmp = query.IndexOf('&', next); + + var isParameterDelimiter = equalsIndex > next && + (nextAmp < 0 || equalsIndex < nextAmp); + + if (isParameterDelimiter) + { + break; + } + } + + sb.Append(ch); + i++; + } + + var encoded = sb.ToString(); + if (string.IsNullOrWhiteSpace(encoded)) + { + return string.Empty; + } + + var plusAsSpace = encoded.Replace("+", " "); + return Uri.UnescapeDataString(plusAsSpace); + } + + /// + /// Uses model-bound SearchTerm when valid; falls back to raw query recovery when needed. + /// + private static string? GetEffectiveSearchTerm(string? boundSearchTerm, string? rawQueryString) + { + var recovered = RecoverSearchTermFromRawQuery(rawQueryString); + if (string.IsNullOrWhiteSpace(recovered)) + { + return boundSearchTerm; + } + + if (string.IsNullOrWhiteSpace(boundSearchTerm)) + { + return recovered; + } + + // Prefer recovered when it is meaningfully longer (common malformed '&' case). + var boundTrimmed = boundSearchTerm.Trim(); + var recoveredTrimmed = recovered.Trim(); + return recoveredTrimmed.Length > boundTrimmed.Length + ? recoveredTrimmed + : boundSearchTerm; + } + private static string GetContentType(string filePath) { var extension = Path.GetExtension(filePath).ToLowerInvariant(); @@ -353,4 +597,4 @@ public partial class JellyfinController } #endregion -} \ No newline at end of file +} diff --git a/allstarr/Controllers/JellyfinAdminController.cs b/allstarr/Controllers/JellyfinAdminController.cs index bf49a46..eed8edd 100644 --- a/allstarr/Controllers/JellyfinAdminController.cs +++ b/allstarr/Controllers/JellyfinAdminController.cs @@ -40,6 +40,113 @@ public class JellyfinAdminController : ControllerBase _spotifyImportSettings = spotifyImportSettings.Value; } + private bool TryGetCurrentSession(out AdminAuthSession session) + { + session = null!; + if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) && + sessionObj is AdminAuthSession typedSession) + { + session = typedSession; + return true; + } + + return false; + } + + private static bool UserIdsEqual(string? left, string? right) + { + return !string.IsNullOrWhiteSpace(left) && + !string.IsNullOrWhiteSpace(right) && + left.Equals(right, StringComparison.OrdinalIgnoreCase); + } + + private static SpotifyPlaylistConfig? ResolveScopedLinkedPlaylist( + IReadOnlyCollection allLinkedForPlaylist, + bool isAdministrator, + string? requestedUserId, + string? sessionUserId) + { + if (isAdministrator && string.IsNullOrWhiteSpace(requestedUserId)) + { + return allLinkedForPlaylist.FirstOrDefault(); + } + + var ownerUserId = requestedUserId ?? sessionUserId; + + // Prefer user-scoped entries, but treat legacy/global entries (without UserId) + // as linked for all scopes so old configurations render correctly. + return allLinkedForPlaylist.FirstOrDefault(p => UserIdsEqual(p.UserId, ownerUserId)) + ?? allLinkedForPlaylist.FirstOrDefault(p => string.IsNullOrWhiteSpace(p.UserId)); + } + + private static bool IsValidCronExpression(string cron) + { + var cronParts = cron.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); + return cronParts.Length == 5; + } + + private HttpRequestMessage CreateJellyfinRequestForSession(HttpMethod method, string url, AdminAuthSession session) + { + if (session.IsAdministrator) + { + return _helperService.CreateJellyfinRequest(method, url); + } + + var request = new HttpRequestMessage(method, url); + var authHeader = + $"MediaBrowser Client=\"AllstarrAdmin\", Device=\"WebUI\", DeviceId=\"allstarr-admin-webui\", Version=\"{AppVersion.Version}\", Token=\"{session.JellyfinAccessToken}\""; + request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader); + request.Headers.TryAddWithoutValidation("X-Emby-Token", session.JellyfinAccessToken); + return request; + } + + private async Task<(string? Name, IActionResult? Error)> TryGetJellyfinPlaylistNameAsync( + string jellyfinPlaylistId, + string userId, + AdminAuthSession session) + { + var playlistUrl = $"{_jellyfinSettings.Url}/Items/{jellyfinPlaylistId}?UserId={Uri.EscapeDataString(userId)}"; + var playlistRequest = CreateJellyfinRequestForSession(HttpMethod.Get, playlistUrl, session); + var playlistResponse = await _jellyfinHttpClient.SendAsync(playlistRequest); + + if (playlistResponse.StatusCode == System.Net.HttpStatusCode.NotFound) + { + return (null, NotFound(new { error = "Jellyfin playlist not found for this user" })); + } + + if (playlistResponse.StatusCode == System.Net.HttpStatusCode.Forbidden) + { + return (null, StatusCode(StatusCodes.Status403Forbidden, + new { error = "User does not have access to this Jellyfin playlist" })); + } + + if (!playlistResponse.IsSuccessStatusCode) + { + var errorBody = await playlistResponse.Content.ReadAsStringAsync(); + _logger.LogError( + "Failed to resolve Jellyfin playlist {PlaylistId} for user {UserId}: {StatusCode} - {Body}", + jellyfinPlaylistId, userId, playlistResponse.StatusCode, errorBody); + return (null, StatusCode((int)playlistResponse.StatusCode, + new { error = "Failed to fetch Jellyfin playlist details" })); + } + + using var playlistDoc = await JsonDocument.ParseAsync(await playlistResponse.Content.ReadAsStreamAsync()); + var root = playlistDoc.RootElement; + var itemType = root.TryGetProperty("Type", out var typeProp) ? typeProp.GetString() : null; + if (!string.Equals(itemType, "Playlist", StringComparison.OrdinalIgnoreCase)) + { + return (null, BadRequest(new { error = "Selected Jellyfin item is not a playlist" })); + } + + var playlistName = root.TryGetProperty("Name", out var nameProp) ? nameProp.GetString() : null; + if (string.IsNullOrWhiteSpace(playlistName)) + { + return (null, BadRequest(new { error = "Jellyfin playlist name is missing" })); + } + + return (playlistName.Trim(), null); + } + [HttpGet("jellyfin/users")] public async Task GetJellyfinUsers() { @@ -47,44 +154,44 @@ public class JellyfinAdminController : ControllerBase { return BadRequest(new { error = "Jellyfin URL or API key not configured" }); } - + try { var url = $"{_jellyfinSettings.Url}/Users"; - + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); - + var response = await _jellyfinHttpClient.SendAsync(request); - + if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(); _logger.LogError("Failed to fetch Jellyfin users: {StatusCode} - {Body}", response.StatusCode, errorBody); return StatusCode((int)response.StatusCode, new { error = "Failed to fetch users from Jellyfin" }); } - + var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); - + var users = new List(); - + foreach (var user in doc.RootElement.EnumerateArray()) { var id = user.GetProperty("Id").GetString(); var name = user.GetProperty("Name").GetString(); - + users.Add(new { id, name }); } - + return Ok(new { users }); } catch (Exception ex) { _logger.LogError(ex, "Error fetching Jellyfin users"); - return StatusCode(500, new { error = "Failed to fetch users", details = ex.Message }); + return StatusCode(500, new { error = "Failed to fetch users" }); } } - + /// /// Get all Jellyfin libraries (virtual folders) /// @@ -95,45 +202,45 @@ public class JellyfinAdminController : ControllerBase { return BadRequest(new { error = "Jellyfin URL or API key not configured" }); } - + try { var url = $"{_jellyfinSettings.Url}/Library/VirtualFolders"; - + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); - + var response = await _jellyfinHttpClient.SendAsync(request); - + if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(); _logger.LogError("Failed to fetch Jellyfin libraries: {StatusCode} - {Body}", response.StatusCode, errorBody); return StatusCode((int)response.StatusCode, new { error = "Failed to fetch libraries from Jellyfin" }); } - + var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); - + var libraries = new List(); - + foreach (var lib in doc.RootElement.EnumerateArray()) { var name = lib.GetProperty("Name").GetString(); var itemId = lib.TryGetProperty("ItemId", out var id) ? id.GetString() : null; var collectionType = lib.TryGetProperty("CollectionType", out var ct) ? ct.GetString() : null; - + libraries.Add(new { id = itemId, name, collectionType }); } - + return Ok(new { libraries }); } catch (Exception ex) { _logger.LogError(ex, "Error fetching Jellyfin libraries"); - return StatusCode(500, new { error = "Failed to fetch libraries", details = ex.Message }); + return StatusCode(500, new { error = "Failed to fetch libraries" }); } } - + /// /// Get all playlists from the user's Spotify account /// @@ -144,71 +251,94 @@ public class JellyfinAdminController : ControllerBase { return BadRequest(new { error = "Jellyfin URL or API key not configured" }); } - + + if (!TryGetCurrentSession(out var session)) + { + return Unauthorized(new { error = "Authentication required" }); + } + + var requestedUserId = string.IsNullOrWhiteSpace(userId) ? null : userId.Trim(); + if (!session.IsAdministrator) + { + if (!string.IsNullOrWhiteSpace(requestedUserId) && !UserIdsEqual(requestedUserId, session.UserId)) + { + return StatusCode(StatusCodes.Status403Forbidden, + new { error = "You can only view your own Jellyfin playlists" }); + } + + requestedUserId = session.UserId; + } + try { - // Build URL with optional userId filter var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount"; - - if (!string.IsNullOrEmpty(userId)) + if (!string.IsNullOrWhiteSpace(requestedUserId)) { - url += $"&UserId={userId}"; + url += $"&UserId={Uri.EscapeDataString(requestedUserId)}"; } - - var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); - + + var request = CreateJellyfinRequestForSession(HttpMethod.Get, url, session); var response = await _jellyfinHttpClient.SendAsync(request); - + if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(); _logger.LogError("Failed to fetch Jellyfin playlists: {StatusCode} - {Body}", response.StatusCode, errorBody); return StatusCode((int)response.StatusCode, new { error = "Failed to fetch playlists from Jellyfin" }); } - + var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); - + var playlists = new List(); - - // Read current playlists from .env file for accurate linked status var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); - + if (doc.RootElement.TryGetProperty("Items", out var items)) { foreach (var item in items.EnumerateArray()) { var id = item.GetProperty("Id").GetString(); var name = item.GetProperty("Name").GetString(); - - // Try multiple fields for track count - Jellyfin may use different fields + var childCount = 0; if (item.TryGetProperty("ChildCount", out var cc) && cc.ValueKind == JsonValueKind.Number) + { childCount = cc.GetInt32(); + } else if (item.TryGetProperty("SongCount", out var sc) && sc.ValueKind == JsonValueKind.Number) + { childCount = sc.GetInt32(); + } else if (item.TryGetProperty("RecursiveItemCount", out var ric) && ric.ValueKind == JsonValueKind.Number) + { childCount = ric.GetInt32(); - - // Check if this playlist is configured in allstarr by Jellyfin ID - var configuredPlaylist = configuredPlaylists - .FirstOrDefault(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase)); - var isConfigured = configuredPlaylist != null; - var linkedSpotifyId = configuredPlaylist?.Id; - - // Only fetch detailed track stats for configured Spotify playlists - // This avoids expensive queries for large non-Spotify playlists + } + + var allLinkedForPlaylist = configuredPlaylists + .Where(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase)) + .ToList(); + + var scopedLinkedPlaylist = ResolveScopedLinkedPlaylist( + allLinkedForPlaylist, + session.IsAdministrator, + requestedUserId, + session.UserId); + + var isConfigured = scopedLinkedPlaylist != null; + var isLinkedByAnotherUser = !isConfigured && allLinkedForPlaylist.Count > 0; + var linkedSpotifyId = scopedLinkedPlaylist?.Id; + + var statsUserId = requestedUserId; var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0); if (isConfigured) { - trackStats = await GetPlaylistTrackStats(id!); + trackStats = await GetPlaylistTrackStats(id!, session, statsUserId); } - - // Use actual track stats for configured playlists, otherwise use Jellyfin's count - var actualTrackCount = isConfigured - ? trackStats.LocalTracks + trackStats.ExternalTracks + + var actualTrackCount = isConfigured + ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount; - + playlists.Add(new { id, @@ -216,39 +346,47 @@ public class JellyfinAdminController : ControllerBase trackCount = actualTrackCount, linkedSpotifyId, isConfigured, + isLinkedByAnotherUser, + linkedOwnerUserId = scopedLinkedPlaylist?.UserId ?? + allLinkedForPlaylist.FirstOrDefault()?.UserId, localTracks = trackStats.LocalTracks, externalTracks = trackStats.ExternalTracks, externalAvailable = trackStats.ExternalAvailable }); } } - + return Ok(new { playlists }); } catch (Exception ex) { _logger.LogError(ex, "Error fetching Jellyfin playlists"); - return StatusCode(500, new { error = "Failed to fetch playlists", details = ex.Message }); + return StatusCode(500, new { error = "Failed to fetch playlists" }); } } - + /// /// Get track statistics for a playlist (local vs external) /// - private async Task<(int LocalTracks, int ExternalTracks, int ExternalAvailable)> GetPlaylistTrackStats(string playlistId) + private async Task<(int LocalTracks, int ExternalTracks, int ExternalAvailable)> GetPlaylistTrackStats( + string playlistId, + AdminAuthSession session, + string? requestedUserId = null) { try { // Jellyfin requires a UserId to fetch playlist items - // We'll use the first available user if not specified - var userId = _jellyfinSettings.UserId; - - // If no user configured, try to get the first user - if (string.IsNullOrEmpty(userId)) + // Non-admin users are always scoped to their own Jellyfin user. + var userId = string.IsNullOrWhiteSpace(requestedUserId) + ? (session.IsAdministrator ? _jellyfinSettings.UserId : session.UserId) + : requestedUserId.Trim(); + + // Admin fallback: if no configured user, try to get the first Jellyfin user. + if (session.IsAdministrator && string.IsNullOrEmpty(userId)) { - var usersRequest = _helperService.CreateJellyfinRequest(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users"); + var usersRequest = CreateJellyfinRequestForSession(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users", session); var usersResponse = await _jellyfinHttpClient.SendAsync(usersRequest); - + if (usersResponse.IsSuccessStatusCode) { var usersJson = await usersResponse.Content.ReadAsStringAsync(); @@ -259,40 +397,40 @@ public class JellyfinAdminController : ControllerBase } } } - + if (string.IsNullOrEmpty(userId)) { _logger.LogWarning("No user ID available to fetch playlist items for {PlaylistId}", playlistId); return (0, 0, 0); } - + var url = $"{_jellyfinSettings.Url}/Playlists/{playlistId}/Items?UserId={userId}&Fields=Path"; - var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); - + var request = CreateJellyfinRequestForSession(HttpMethod.Get, url, session); + var response = await _jellyfinHttpClient.SendAsync(request); if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to fetch playlist items for {PlaylistId}: {StatusCode}", playlistId, response.StatusCode); return (0, 0, 0); } - + var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); - + var localTracks = 0; var externalTracks = 0; var externalAvailable = 0; - + if (doc.RootElement.TryGetProperty("Items", out var items)) { foreach (var item in items.EnumerateArray()) { // Simpler detection: Check if Path exists and is not empty // External tracks from allstarr won't have a Path property - var hasPath = item.TryGetProperty("Path", out var pathProp) && + var hasPath = item.TryGetProperty("Path", out var pathProp) && pathProp.ValueKind == JsonValueKind.String && !string.IsNullOrEmpty(pathProp.GetString()); - + if (hasPath) { var pathStr = pathProp.GetString()!; @@ -315,15 +453,15 @@ public class JellyfinAdminController : ControllerBase externalAvailable++; } } - - _logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external", + + _logger.LogDebug("Playlist {PlaylistId} stats: {Local} local, {External} external", playlistId, localTracks, externalTracks); } else { _logger.LogWarning("No Items property in playlist response for {PlaylistId}", playlistId); } - + return (localTracks, externalTracks, externalAvailable); } catch (Exception ex) @@ -332,69 +470,90 @@ public class JellyfinAdminController : ControllerBase return (0, 0, 0); } } - + /// /// Link a Jellyfin playlist to a Spotify playlist /// [HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")] public async Task LinkPlaylist(string jellyfinPlaylistId, [FromBody] LinkPlaylistRequest request) { - if (string.IsNullOrEmpty(request.SpotifyPlaylistId)) + if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey)) + { + return BadRequest(new { error = "Jellyfin URL or API key not configured" }); + } + + if (!TryGetCurrentSession(out var session)) + { + return Unauthorized(new { error = "Authentication required" }); + } + + if (string.IsNullOrWhiteSpace(request.SpotifyPlaylistId)) { return BadRequest(new { error = "SpotifyPlaylistId is required" }); } - - if (string.IsNullOrEmpty(request.Name)) + + var syncSchedule = string.IsNullOrWhiteSpace(request.SyncSchedule) + ? "0 8 * * *" + : request.SyncSchedule.Trim(); + if (!IsValidCronExpression(syncSchedule)) { - return BadRequest(new { error = "Name is required" }); + return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" }); } - - _logger.LogInformation("Linking Jellyfin playlist {JellyfinId} to Spotify playlist {SpotifyId} with name {Name}", - jellyfinPlaylistId, request.SpotifyPlaylistId, request.Name); - - // Read current playlists from .env file (not in-memory config which is stale) + + var ownerUserId = string.IsNullOrWhiteSpace(request.UserId) ? session.UserId : request.UserId.Trim(); + if (!session.IsAdministrator && !UserIdsEqual(ownerUserId, session.UserId)) + { + return StatusCode(StatusCodes.Status403Forbidden, + new { error = "You can only link playlists for your own Jellyfin user" }); + } + + if (string.IsNullOrWhiteSpace(ownerUserId)) + { + return BadRequest(new { error = "Unable to determine Jellyfin owner user" }); + } + + var (playlistName, playlistError) = await TryGetJellyfinPlaylistNameAsync(jellyfinPlaylistId, ownerUserId, session); + if (playlistError != null) + { + return playlistError; + } + + _logger.LogInformation( + "Linking Jellyfin playlist {JellyfinId} ({PlaylistName}) to Spotify playlist {SpotifyId} for user {OwnerUserId}", + jellyfinPlaylistId, playlistName, request.SpotifyPlaylistId, ownerUserId); + var currentPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); - - // Check if already configured by Jellyfin ID + var existingByJellyfinId = currentPlaylists .FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase)); - if (existingByJellyfinId != null) { - return BadRequest(new { error = $"This Jellyfin playlist is already linked to '{existingByJellyfinId.Name}'" }); + if (UserIdsEqual(existingByJellyfinId.UserId, ownerUserId)) + { + return BadRequest(new { error = "This Jellyfin playlist is already linked for this user" }); + } + + return BadRequest(new { error = "This Jellyfin playlist is already linked by another user" }); } - - // Check if already configured by name + var existingByName = currentPlaylists - .FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase)); - + .FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); if (existingByName != null) { - return BadRequest(new { error = $"Playlist name '{request.Name}' is already configured" }); + return BadRequest(new { error = $"Playlist name '{playlistName}' is already configured" }); } - - // Add the playlist to configuration + currentPlaylists.Add(new SpotifyPlaylistConfig { - Name = request.Name, - Id = request.SpotifyPlaylistId, + Name = playlistName!, + Id = request.SpotifyPlaylistId.Trim(), JellyfinId = jellyfinPlaylistId, - LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order - SyncSchedule = request.SyncSchedule ?? "0 8 * * *" // Default to daily 8 AM + LocalTracksPosition = LocalTracksPosition.First, + SyncSchedule = syncSchedule, + UserId = ownerUserId }); - - // Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...] - var playlistsJson = JsonSerializer.Serialize( - currentPlaylists.Select(p => new[] { - p.Name, - p.Id, - p.JellyfinId, - p.LocalTracksPosition.ToString().ToLower(), - p.SyncSchedule ?? "0 8 * * *" - }).ToArray() - ); - - // Update .env file + + var playlistsJson = AdminHelperService.SerializePlaylistsForEnv(currentPlaylists); var updateRequest = new ConfigUpdateRequest { Updates = new Dictionary @@ -402,20 +561,54 @@ public class JellyfinAdminController : ControllerBase ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson } }; - + return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates); } - + /// /// Unlink a playlist (remove from configuration) /// - [HttpDelete("jellyfin/playlists/{name}/unlink")] - public async Task UnlinkPlaylist(string name) + [HttpDelete("jellyfin/playlists/{jellyfinPlaylistId}/unlink")] + public async Task UnlinkPlaylist(string jellyfinPlaylistId) { - var decodedName = Uri.UnescapeDataString(name); - return await _helperService.RemovePlaylistFromConfigAsync(decodedName); + if (!TryGetCurrentSession(out var session)) + { + return Unauthorized(new { error = "Authentication required" }); + } + + var decodedIdentifier = Uri.UnescapeDataString(jellyfinPlaylistId); + var currentPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); + var playlist = currentPlaylists.FirstOrDefault(p => + p.JellyfinId.Equals(decodedIdentifier, StringComparison.OrdinalIgnoreCase)); + + // Backward compatibility: older UI versions unlink by playlist name. + if (playlist == null) + { + playlist = currentPlaylists.FirstOrDefault(p => + p.Name.Equals(decodedIdentifier, StringComparison.OrdinalIgnoreCase)); + } + + if (playlist == null) + { + return NotFound(new { error = "Playlist link not found" }); + } + + if (!session.IsAdministrator && !UserIdsEqual(playlist.UserId, session.UserId)) + { + return StatusCode(StatusCodes.Status403Forbidden, + new { error = "You can only unlink playlists you own" }); + } + + currentPlaylists.Remove(playlist); + var playlistsJson = AdminHelperService.SerializePlaylistsForEnv(currentPlaylists); + var updates = new Dictionary + { + ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson + }; + + return await _helperService.UpdateEnvConfigAsync(updates); } - + /// /// Update playlist sync schedule /// @@ -423,42 +616,34 @@ public class JellyfinAdminController : ControllerBase public async Task UpdatePlaylistSchedule(string name, [FromBody] UpdateScheduleRequest request) { var decodedName = Uri.UnescapeDataString(name); - + if (string.IsNullOrWhiteSpace(request.SyncSchedule)) { return BadRequest(new { error = "SyncSchedule is required" }); } - + // Basic cron validation var cronParts = request.SyncSchedule.Trim().Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries); if (cronParts.Length != 5) { return BadRequest(new { error = "Invalid cron format. Expected: minute hour day month dayofweek" }); } - + // Read current playlists var currentPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(decodedName, StringComparison.OrdinalIgnoreCase)); - + if (playlist == null) { return NotFound(new { error = $"Playlist '{decodedName}' not found" }); } - + // Update the schedule playlist.SyncSchedule = request.SyncSchedule.Trim(); - + // Save back to .env - var playlistsJson = JsonSerializer.Serialize( - currentPlaylists.Select(p => new[] { - p.Name, - p.Id, - p.JellyfinId, - p.LocalTracksPosition.ToString().ToLower(), - p.SyncSchedule ?? "0 8 * * *" - }).ToArray() - ); - + var playlistsJson = AdminHelperService.SerializePlaylistsForEnv(currentPlaylists); + var updateRequest = new ConfigUpdateRequest { Updates = new Dictionary @@ -466,8 +651,8 @@ public class JellyfinAdminController : ControllerBase ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson } }; - + return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates); } - + } diff --git a/allstarr/Controllers/JellyfinController.Audio.cs b/allstarr/Controllers/JellyfinController.Audio.cs index 1ae57b5..c3a205c 100644 --- a/allstarr/Controllers/JellyfinController.Audio.cs +++ b/allstarr/Controllers/JellyfinController.Audio.cs @@ -144,7 +144,7 @@ public partial class JellyfinController catch (Exception ex) { _logger.LogError(ex, "Failed to proxy stream from Jellyfin for {ItemId}", itemId); - return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" }); + return StatusCode(500, new { error = "Streaming failed" }); } } @@ -185,7 +185,7 @@ public partial class JellyfinController catch (Exception ex) { _logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId); - return StatusCode(500, new { error = $"Streaming failed: {ex.Message}" }); + return StatusCode(500, new { error = "Streaming failed" }); } } @@ -221,4 +221,4 @@ public partial class JellyfinController } #endregion -} \ No newline at end of file +} diff --git a/allstarr/Controllers/JellyfinController.Authentication.cs b/allstarr/Controllers/JellyfinController.Authentication.cs index 36e2392..6cce5af 100644 --- a/allstarr/Controllers/JellyfinController.Authentication.cs +++ b/allstarr/Controllers/JellyfinController.Authentication.cs @@ -115,9 +115,9 @@ public partial class JellyfinController catch (Exception ex) { _logger.LogError(ex, "Error during authentication"); - return StatusCode(500, new { error = $"Authentication error: {ex.Message}" }); + return StatusCode(500, new { error = "Authentication error" }); } } #endregion -} \ No newline at end of file +} diff --git a/allstarr/Controllers/JellyfinController.Lyrics.cs b/allstarr/Controllers/JellyfinController.Lyrics.cs index 6c7f587..f4be672 100644 --- a/allstarr/Controllers/JellyfinController.Lyrics.cs +++ b/allstarr/Controllers/JellyfinController.Lyrics.cs @@ -318,7 +318,7 @@ public partial class JellyfinController /// Proactively fetches and caches lyrics for a track in the background. /// Called when playback starts to ensure lyrics are ready when requested. /// - private async Task PrefetchLyricsForTrackAsync(string itemId, bool isExternal, string? provider, string? externalId) + private async Task PrefetchLyricsForTrackAsync(string itemId, bool isExternal, string? provider, string? externalId, CancellationToken cancellationToken = default) { try { @@ -339,7 +339,7 @@ public partial class JellyfinController if (string.IsNullOrEmpty(spotifyTrackId) && provider == "squidwtf") { spotifyTrackId = - await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, HttpContext.RequestAborted); + await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, cancellationToken); } } } @@ -463,7 +463,7 @@ public partial class JellyfinController } catch (Exception ex) { - _logger.LogError(ex, "Error prefetching lyrics for track {ItemId}", itemId); + _logger.LogWarning("Failed to prefetch lyrics for track {ItemId}: {Message}", itemId, ex.Message); } } diff --git a/allstarr/Controllers/JellyfinController.PlaybackSessions.cs b/allstarr/Controllers/JellyfinController.PlaybackSessions.cs index 5bf3e67..853bb48 100644 --- a/allstarr/Controllers/JellyfinController.PlaybackSessions.cs +++ b/allstarr/Controllers/JellyfinController.PlaybackSessions.cs @@ -1,4 +1,6 @@ +using System.Collections.Concurrent; using System.Text.Json; +using System.Globalization; using allstarr.Models.Scrobbling; using Microsoft.AspNetCore.Mvc; @@ -6,6 +8,11 @@ namespace allstarr.Controllers; public partial class JellyfinController { + private static readonly TimeSpan InferredStopDedupeWindow = TimeSpan.FromSeconds(15); + private static readonly TimeSpan PlaybackSignalDedupeWindow = TimeSpan.FromSeconds(8); + private static readonly TimeSpan PlaybackSignalRetentionWindow = TimeSpan.FromMinutes(5); + private static readonly ConcurrentDictionary RecentPlaybackSignals = new(); + #region Playback Session Reporting #region Session Management @@ -24,15 +31,15 @@ public partial class JellyfinController { var method = Request.Method; var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; + var maskedQueryString = MaskSensitiveQueryString(queryString); - _logger.LogDebug("📡 Session capabilities reported - Method: {Method}, Query: {Query}", method, - queryString); - _logger.LogInformation("Headers: {Headers}", - string.Join(", ", Request.Headers.Where(h => - h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase) || - h.Key.Contains("Device", StringComparison.OrdinalIgnoreCase) || - h.Key.Contains("Client", StringComparison.OrdinalIgnoreCase)) - .Select(h => $"{h.Key}={h.Value}"))); + _logger.LogDebug("📡 Session capabilities reported - Method: {Method}, QueryLength: {QueryLength}", + method, maskedQueryString.Length); + _logger.LogDebug("Capabilities header keys: {HeaderKeys}", + string.Join(", ", Request.Headers.Keys.Where(k => + k.Contains("Auth", StringComparison.OrdinalIgnoreCase) || + k.Contains("Device", StringComparison.OrdinalIgnoreCase) || + k.Contains("Client", StringComparison.OrdinalIgnoreCase)))); // Forward to Jellyfin with query string and headers var endpoint = $"Sessions/Capabilities{queryString}"; @@ -49,25 +56,31 @@ public partial class JellyfinController } Request.Body.Position = 0; - _logger.LogInformation("Capabilities body: {Body}", body); + _logger.LogDebug("Capabilities body length: {BodyLength} bytes", body.Length); } - var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers); + var (_, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers); if (statusCode == 204 || statusCode == 200) { _logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode); - } - else if (statusCode == 401) - { - _logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)"); - } - else - { - _logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode); + return NoContent(); } - return NoContent(); + if (statusCode == 401) + { + _logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)"); + return Unauthorized(); + } + + if (statusCode == 403) + { + _logger.LogWarning("⚠ Jellyfin returned 403 for capabilities"); + return Forbid(); + } + + _logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode); + return StatusCode(statusCode); } catch (Exception ex) { @@ -103,24 +116,21 @@ public partial class JellyfinController string? itemId = null; string? itemName = null; long? positionTicks = null; + string? playSessionId = null; - if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp)) - { - itemId = itemIdProp.GetString(); - } + itemId = ParsePlaybackItemId(doc.RootElement); if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp)) { itemName = itemNameProp.GetString(); } - if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp)) - { - positionTicks = posProp.GetInt64(); - } + positionTicks = ParsePlaybackPositionTicks(doc.RootElement); + playSessionId = ParsePlaybackSessionId(doc.RootElement); // Track the playing item for scrobbling on session cleanup (local tracks only) var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers); + deviceId = ResolveDeviceId(deviceId, doc.RootElement); // Only update session for local tracks - external tracks don't need session tracking if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId)) @@ -138,11 +148,63 @@ public partial class JellyfinController if (isExternal) { + var sessionReady = false; + if (!string.IsNullOrEmpty(deviceId)) + { + sessionReady = _sessionManager.HasSession(deviceId); + if (!sessionReady) + { + var ensured = await _sessionManager.EnsureSessionAsync( + deviceId, + client ?? "Unknown", + device ?? "Unknown", + version ?? "1.0", + Request.Headers); + + if (!ensured) + { + _logger.LogWarning( + "⚠️ SESSION: Could not ensure session from external playback start for device {DeviceId}", + deviceId); + } + + sessionReady = ensured || _sessionManager.HasSession(deviceId); + } + + if (sessionReady) + { + var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId); + var inferredStop = !string.IsNullOrWhiteSpace(previousItemId) && + !string.Equals(previousItemId, itemId, StringComparison.Ordinal); + if (inferredStop && !string.IsNullOrWhiteSpace(previousItemId)) + { + await HandleInferredStopOnProgressTransitionAsync(deviceId, previousItemId, previousPositionTicks); + } + } + } + + if (ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId)) + { + _logger.LogDebug( + "Skipping duplicate external playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})", + itemId, + deviceId ?? "unknown", + playSessionId ?? "none"); + + if (sessionReady) + { + _sessionManager.UpdateActivity(deviceId!); + _sessionManager.UpdatePlayingItem(deviceId!, itemId, positionTicks); + } + + return NoContent(); + } + // Fetch metadata early so we can log the correct track name var song = await _metadataService.GetSongAsync(provider!, externalId!); var trackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown"; - _logger.LogInformation("🎵 External track playback started: {TrackName} ({Provider}/{ExternalId})", + _logger.LogInformation("▶️ External track playback started: {TrackName} ({Provider}/{ExternalId})", trackName, provider, externalId); // Proactively fetch lyrics in background for external tracks @@ -150,7 +212,7 @@ public partial class JellyfinController { try { - await PrefetchLyricsForTrackAsync(itemId, isExternal: true, provider, externalId); + await PrefetchLyricsForTrackAsync(itemId, isExternal: true, provider, externalId, CancellationToken.None); } catch (Exception ex) { @@ -194,14 +256,14 @@ public partial class JellyfinController } // Scrobble external track playback start - _logger.LogInformation( - "🎵 Checking scrobbling: orchestrator={HasOrchestrator}, helper={HasHelper}, deviceId={DeviceId}", + _logger.LogDebug( + "Checking scrobbling: orchestrator={HasOrchestrator}, helper={HasHelper}, deviceId={DeviceId}", _scrobblingOrchestrator != null, _scrobblingHelper != null, deviceId ?? "null"); if (_scrobblingOrchestrator != null && _scrobblingHelper != null && !string.IsNullOrEmpty(deviceId) && song != null) { - _logger.LogInformation("🎵 Starting scrobble task for external track"); + _logger.LogDebug("Starting scrobble task for external track"); _ = Task.Run(async () => { try @@ -211,7 +273,8 @@ public partial class JellyfinController artist: song.Artist, album: song.Album, albumArtist: song.AlbumArtist, - durationSeconds: song.Duration + durationSeconds: song.Duration, + startPositionSeconds: ToPlaybackPositionSeconds(positionTicks) ); if (track != null) @@ -230,6 +293,12 @@ public partial class JellyfinController }); } + if (sessionReady) + { + _sessionManager.UpdateActivity(deviceId!); + _sessionManager.UpdatePlayingItem(deviceId!, itemId, positionTicks); + } + return NoContent(); } @@ -238,7 +307,7 @@ public partial class JellyfinController { try { - await PrefetchLyricsForTrackAsync(itemId, isExternal: false, null, null); + await PrefetchLyricsForTrackAsync(itemId, isExternal: false, null, null, CancellationToken.None); } catch (Exception ex) { @@ -247,6 +316,24 @@ public partial class JellyfinController }); } + if (!string.IsNullOrEmpty(itemId) && + ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId)) + { + _logger.LogDebug( + "Skipping duplicate local playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})", + itemId, + deviceId ?? "unknown", + playSessionId ?? "none"); + + if (!string.IsNullOrEmpty(deviceId)) + { + _sessionManager.UpdateActivity(deviceId); + _sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks); + } + + return NoContent(); + } + // For local tracks, forward playback start to Jellyfin FIRST _logger.LogDebug("Forwarding playback start to Jellyfin..."); @@ -278,7 +365,7 @@ public partial class JellyfinController }; var playbackJson = JsonSerializer.Serialize(playbackStart); - _logger.LogInformation("📤 Sending playback start: {Json}", playbackJson); + _logger.LogDebug("📤 Sending playback start: {Json}", playbackJson); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers); @@ -309,27 +396,6 @@ public partial class JellyfinController } }); } - - // NOW ensure session exists with capabilities (after playback is reported) - if (!string.IsNullOrEmpty(deviceId)) - { - var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", - device ?? "Unknown", version ?? "1.0", Request.Headers); - if (sessionCreated) - { - _logger.LogDebug( - "✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId); - } - else - { - _logger.LogError("⚠️ SESSION: Failed to ensure session for device {DeviceId}", - deviceId); - } - } - else - { - _logger.LogWarning("⚠️ SESSION: No device ID found in headers for playback start"); - } } else { @@ -346,6 +412,8 @@ public partial class JellyfinController if (statusCode == 204 || statusCode == 200) { _logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode); + _logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})", + itemName ?? "Unknown", itemId ?? "unknown"); } } } @@ -356,10 +424,37 @@ public partial class JellyfinController var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers); if (statusCode == 204 || statusCode == 200) { - _logger.LogInformation("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode); + _logger.LogDebug("✓ Basic playback start forwarded to Jellyfin ({StatusCode})", statusCode); + _logger.LogInformation("🎵 Local track playback started: {Name} (ID: {ItemId})", + itemName ?? "Unknown", itemId ?? "unknown"); } } + // Ensure session exists for local playback regardless of start payload path taken. + if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId)) + { + var (isExt, _, _) = _localLibraryService.ParseSongId(itemId); + if (!isExt) + { + var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", + device ?? "Unknown", version ?? "1.0", Request.Headers); + if (sessionCreated) + { + _sessionManager.UpdateActivity(deviceId); + _sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks); + _logger.LogDebug("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId); + } + else + { + _logger.LogError("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId); + } + } + } + else if (string.IsNullOrEmpty(deviceId)) + { + _logger.LogWarning("⚠️ SESSION: No device ID found in headers for playback start"); + } + return NoContent(); } catch (Exception ex) @@ -388,34 +483,30 @@ public partial class JellyfinController Request.Body.Position = 0; // Update session activity (local tracks only) - var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers); + var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers); // Parse the body to check if it's an external track var doc = JsonDocument.Parse(body); string? itemId = null; long? positionTicks = null; + string? playSessionId = null; - if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp)) + itemId = ParsePlaybackItemId(doc.RootElement); + positionTicks = ParsePlaybackPositionTicks(doc.RootElement); + playSessionId = ParsePlaybackSessionId(doc.RootElement); + + deviceId = ResolveDeviceId(deviceId, doc.RootElement); + + if (string.IsNullOrWhiteSpace(itemId)) { - itemId = itemIdProp.GetString(); + _logger.LogWarning( + "⚠️ Playback progress missing item id after parsing. Payload keys: {Keys}", + string.Join(", ", doc.RootElement.EnumerateObject().Select(p => p.Name))); } - if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp)) - { - positionTicks = posProp.GetInt64(); - } - - // Only update session for local tracks + // Scrobble progress check (both local and external) if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId)) { - var (isExt, _, _) = _localLibraryService.ParseSongId(itemId); - if (!isExt) - { - _sessionManager.UpdateActivity(deviceId); - _sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks); - } - - // Scrobble progress check (both local and external) if (_scrobblingOrchestrator != null && _scrobblingHelper != null && positionTicks.HasValue) { _ = Task.Run(async () => @@ -443,9 +534,15 @@ public partial class JellyfinController artist: song.Artist, album: song.Album, albumArtist: song.AlbumArtist, - durationSeconds: song.Duration + durationSeconds: song.Duration, + startPositionSeconds: ToPlaybackPositionSeconds(positionTicks) ); } + else + { + _logger.LogDebug("Could not fetch metadata for external track progress: {Provider}/{ExternalId}", + provider, externalId); + } } else { @@ -457,8 +554,7 @@ public partial class JellyfinController if (track != null) { var positionSeconds = (int)(positionTicks.Value / TimeSpan.TicksPerSecond); - await _scrobblingOrchestrator.OnPlaybackProgressAsync(deviceId, track.Artist, - track.Title, positionSeconds); + await _scrobblingOrchestrator.OnPlaybackProgressAsync(deviceId, track, positionSeconds); } } catch (Exception ex) @@ -475,6 +571,123 @@ public partial class JellyfinController if (isExternal) { + if (!string.IsNullOrEmpty(deviceId)) + { + var sessionReady = _sessionManager.HasSession(deviceId); + if (!sessionReady) + { + var ensured = await _sessionManager.EnsureSessionAsync( + deviceId, + client ?? "Unknown", + device ?? "Unknown", + version ?? "1.0", + Request.Headers); + + if (!ensured) + { + _logger.LogWarning( + "⚠️ SESSION: Could not ensure session from external progress for device {DeviceId}", + deviceId); + } + + sessionReady = ensured || _sessionManager.HasSession(deviceId); + } + + var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId); + var inferredStop = sessionReady && + !string.IsNullOrWhiteSpace(previousItemId) && + !string.Equals(previousItemId, itemId, StringComparison.Ordinal); + var inferredStart = sessionReady && + !string.IsNullOrWhiteSpace(itemId) && + !string.Equals(previousItemId, itemId, StringComparison.Ordinal); + + if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId)) + { + await HandleInferredStopOnProgressTransitionAsync(deviceId, previousItemId, previousPositionTicks); + } + + if (sessionReady) + { + _sessionManager.UpdateActivity(deviceId); + _sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks); + } + + if (inferredStart && + !ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId)) + { + var song = await _metadataService.GetSongAsync(provider!, externalId!); + var externalTrackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown"; + _logger.LogInformation( + "▶️ External track playback started (inferred from progress): {TrackName} ({Provider}/{ExternalId})", + externalTrackName, + provider, + externalId); + + var inferredStartGhostUuid = GenerateUuidFromString(itemId); + var inferredExternalStartPayload = JsonSerializer.Serialize(new + { + ItemId = inferredStartGhostUuid, + PositionTicks = positionTicks ?? 0, + CanSeek = true, + IsPaused = false, + IsMuted = false, + PlayMethod = "DirectPlay" + }); + + var (_, inferredStartStatusCode) = await _proxyService.PostJsonAsync( + "Sessions/Playing", + inferredExternalStartPayload, + Request.Headers); + + if (inferredStartStatusCode == 200 || inferredStartStatusCode == 204) + { + _logger.LogDebug("✓ Inferred external playback start forwarded to Jellyfin ({StatusCode})", + inferredStartStatusCode); + } + + if (_scrobblingOrchestrator != null && _scrobblingHelper != null && + !string.IsNullOrEmpty(deviceId) && song != null) + { + _ = Task.Run(async () => + { + try + { + var track = _scrobblingHelper.CreateScrobbleTrackFromExternal( + title: song.Title, + artist: song.Artist, + album: song.Album, + albumArtist: song.AlbumArtist, + durationSeconds: song.Duration, + startPositionSeconds: ToPlaybackPositionSeconds(positionTicks)); + + if (track != null) + { + await _scrobblingOrchestrator.OnPlaybackStartAsync(deviceId, track); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to scrobble inferred external track playback start"); + } + }); + } + } + else if (inferredStart) + { + _logger.LogDebug( + "Skipping duplicate inferred external playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})", + itemId, + deviceId, + playSessionId ?? "none"); + } + else if (!sessionReady) + { + _logger.LogDebug( + "Skipping inferred external playback start/stop from progress for {DeviceId} because session is unavailable", + deviceId); + } + } + // For external tracks, report progress with ghost UUID to Jellyfin var ghostUuid = GenerateUuidFromString(itemId); @@ -510,6 +723,116 @@ public partial class JellyfinController return NoContent(); } + // Some clients (e.g. mobile) may skip /Sessions/Playing and only send Progress. + // Infer playback start from first progress event or track-change progress event. + if (!string.IsNullOrEmpty(deviceId)) + { + var sessionReady = _sessionManager.HasSession(deviceId); + if (!sessionReady) + { + var ensured = await _sessionManager.EnsureSessionAsync( + deviceId, + client ?? "Unknown", + device ?? "Unknown", + version ?? "1.0", + Request.Headers); + + if (!ensured) + { + _logger.LogWarning( + "⚠️ SESSION: Could not ensure session from progress for device {DeviceId}", + deviceId); + } + + sessionReady = ensured || _sessionManager.HasSession(deviceId); + } + + var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId); + var inferredStop = sessionReady && + !string.IsNullOrWhiteSpace(previousItemId) && + !string.Equals(previousItemId, itemId, StringComparison.Ordinal); + var inferredStart = sessionReady && + !string.IsNullOrWhiteSpace(itemId) && + !string.Equals(previousItemId, itemId, StringComparison.Ordinal); + + if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId)) + { + await HandleInferredStopOnProgressTransitionAsync(deviceId, previousItemId, previousPositionTicks); + } + + if (sessionReady) + { + _sessionManager.UpdateActivity(deviceId); + _sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks); + } + + if (inferredStart && + !ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId)) + { + var trackName = await TryGetLocalTrackNameAsync(itemId); + _logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})", + trackName ?? "Unknown", itemId); + + var inferredStartPayload = JsonSerializer.Serialize(new + { + ItemId = itemId, + PositionTicks = positionTicks ?? 0 + }); + + var (_, inferredStartStatusCode) = + await _proxyService.PostJsonAsync("Sessions/Playing", inferredStartPayload, Request.Headers); + + if (inferredStartStatusCode == 200 || inferredStartStatusCode == 204) + { + _logger.LogDebug("✓ Inferred playback start forwarded to Jellyfin ({StatusCode})", inferredStartStatusCode); + } + else + { + _logger.LogDebug("Inferred playback start returned {StatusCode}", inferredStartStatusCode); + } + + // Scrobble local track playback start (only if enabled) + if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null && + _scrobblingHelper != null) + { + _ = Task.Run(async () => + { + try + { + var track = await _scrobblingHelper.GetScrobbleTrackFromItemIdAsync(itemId, Request.Headers); + if (track != null) + { + await _scrobblingOrchestrator.OnPlaybackStartAsync(deviceId, track); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to scrobble inferred local track playback start"); + } + }); + } + } + else if (inferredStart) + { + _logger.LogDebug( + "Skipping duplicate inferred local playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})", + itemId, + deviceId, + playSessionId ?? "none"); + } + else if (!sessionReady) + { + _logger.LogDebug( + "Skipping inferred local playback start/stop from progress for {DeviceId} because session is unavailable", + deviceId); + } + + // When local scrobbling is disabled, still trigger Jellyfin's user-data path + // shortly after the normal scrobble threshold so downstream plugins that listen + // to user-data events can process local listens even without a stop event. + await MaybeTriggerLocalPlayedSignalFromProgressAsync(doc.RootElement, deviceId, itemId, positionTicks); + } + // Log progress for local tracks (only every ~10 seconds to avoid spam) if (positionTicks.HasValue) { @@ -523,7 +846,7 @@ public partial class JellyfinController } // For local tracks, forward to Jellyfin - _logger.LogDebug("📤 Sending playback progress body: {Body}", body); + _logger.LogDebug("📤 Sending playback progress body ({BodyLength} bytes)", body.Length); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Progress", body, Request.Headers); @@ -542,6 +865,312 @@ public partial class JellyfinController } } + private async Task TryGetLocalTrackNameAsync(string itemId) + { + try + { + var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers); + if (itemResult != null && itemStatus == 200 && + itemResult.RootElement.TryGetProperty("Name", out var nameElement)) + { + return nameElement.GetString(); + } + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Could not fetch local track name for {ItemId}", itemId); + } + + return null; + } + + private async Task HandleInferredStopOnProgressTransitionAsync( + string deviceId, + string previousItemId, + long? previousPositionTicks) + { + if (_sessionManager.WasRecentlyExplicitlyStopped(deviceId, previousItemId, InferredStopDedupeWindow)) + { + _logger.LogDebug( + "Skipping inferred stop for {ItemId} on {DeviceId} (explicit stop already recorded within {Window}s)", + previousItemId, + deviceId, + InferredStopDedupeWindow.TotalSeconds); + return; + } + + if (ShouldSuppressPlaybackSignal("stop", deviceId, previousItemId, playSessionId: null)) + { + _logger.LogDebug( + "Skipping duplicate inferred playback stop signal for {ItemId} on {DeviceId}", + previousItemId, + deviceId); + return; + } + + var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(previousItemId); + + if (isExternal) + { + var song = await _metadataService.GetSongAsync(provider!, externalId!); + var externalTrackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown"; + _logger.LogInformation( + "🎵 External track playback stopped (inferred from progress): {TrackName} ({Provider}/{ExternalId})", + externalTrackName, + provider, + externalId); + + if (_scrobblingOrchestrator != null && _scrobblingHelper != null && + !string.IsNullOrEmpty(deviceId) && previousPositionTicks.HasValue && song != null) + { + _ = Task.Run(async () => + { + try + { + var track = _scrobblingHelper.CreateScrobbleTrackFromExternal( + title: song.Title, + artist: song.Artist, + album: song.Album, + albumArtist: song.AlbumArtist, + durationSeconds: song.Duration); + + if (track != null) + { + var positionSeconds = (int)(previousPositionTicks.Value / TimeSpan.TicksPerSecond); + await _scrobblingOrchestrator.OnPlaybackStopAsync(deviceId, track.Artist, track.Title, positionSeconds); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to scrobble inferred external track playback stop"); + } + }); + } + + var ghostUuid = GenerateUuidFromString(previousItemId); + var inferredExternalStopPayload = JsonSerializer.Serialize(new + { + ItemId = ghostUuid, + PositionTicks = previousPositionTicks ?? 0, + IsPaused = false + }); + + var (_, inferredExternalStopStatusCode) = await _proxyService.PostJsonAsync( + "Sessions/Playing/Stopped", + inferredExternalStopPayload, + Request.Headers); + + if (inferredExternalStopStatusCode == 200 || inferredExternalStopStatusCode == 204) + { + _logger.LogDebug("✓ Inferred external playback stop forwarded to Jellyfin ({StatusCode})", + inferredExternalStopStatusCode); + } + + return; + } + + var previousTrackName = await TryGetLocalTrackNameAsync(previousItemId); + _logger.LogInformation( + "🎵 Local track playback stopped (inferred from progress): {Name} (ID: {ItemId})", + previousTrackName ?? "Unknown", + previousItemId); + + // Scrobble local track playback stop (only if enabled) + if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null && + _scrobblingHelper != null && previousPositionTicks.HasValue) + { + _ = Task.Run(async () => + { + try + { + var track = await _scrobblingHelper.GetScrobbleTrackFromItemIdAsync(previousItemId, Request.Headers); + if (track != null) + { + var positionSeconds = (int)(previousPositionTicks.Value / TimeSpan.TicksPerSecond); + await _scrobblingOrchestrator.OnPlaybackStopAsync(deviceId, track.Artist, track.Title, positionSeconds); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to scrobble inferred local track playback stop"); + } + }); + } + + var inferredStopPayload = JsonSerializer.Serialize(new + { + ItemId = previousItemId, + PositionTicks = previousPositionTicks ?? 0, + IsPaused = false + }); + + var (_, inferredStopStatusCode) = await _proxyService.PostJsonAsync( + "Sessions/Playing/Stopped", + inferredStopPayload, + Request.Headers); + + if (inferredStopStatusCode == 200 || inferredStopStatusCode == 204) + { + _logger.LogDebug("✓ Inferred playback stop forwarded to Jellyfin ({StatusCode})", inferredStopStatusCode); + } + else + { + _logger.LogDebug("Inferred playback stop returned {StatusCode}", inferredStopStatusCode); + } + } + + private async Task MaybeTriggerLocalPlayedSignalFromProgressAsync( + JsonElement progressPayload, + string? deviceId, + string itemId, + long? positionTicks) + { + if (!_scrobblingSettings.Enabled || + _scrobblingSettings.LocalTracksEnabled || + !_scrobblingSettings.SyntheticLocalPlayedSignalEnabled || + _scrobblingHelper == null) + { + return; + } + + if (string.IsNullOrWhiteSpace(deviceId) || !positionTicks.HasValue) + { + return; + } + + if (_sessionManager.HasSentLocalPlayedSignal(deviceId, itemId)) + { + return; + } + + var playedSeconds = (int)(positionTicks.Value / TimeSpan.TicksPerSecond); + if (playedSeconds < 25) + { + return; + } + + var track = await _scrobblingHelper.GetScrobbleTrackFromItemIdAsync(itemId, Request.Headers); + if (track?.DurationSeconds is not int durationSeconds || durationSeconds < 30) + { + return; + } + + var baseThresholdSeconds = Math.Min(durationSeconds / 2.0, 240.0); + var triggerAtSeconds = (int)Math.Ceiling(baseThresholdSeconds + 10.0); + if (playedSeconds < triggerAtSeconds) + { + return; + } + + var userId = ResolvePlaybackUserId(progressPayload); + if (string.IsNullOrWhiteSpace(userId)) + { + _logger.LogDebug("Skipping local played signal for {ItemId} - no user id available", itemId); + return; + } + + var endpoint = $"UserPlayedItems/{Uri.EscapeDataString(itemId)}?userId={Uri.EscapeDataString(userId)}"; + var (_, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers); + + if (statusCode == 404) + { + var legacyEndpoint = $"Users/{Uri.EscapeDataString(userId)}/PlayedItems/{Uri.EscapeDataString(itemId)}"; + (_, statusCode) = await _proxyService.PostJsonAsync(legacyEndpoint, "{}", Request.Headers); + } + + if (statusCode == 200 || statusCode == 204) + { + _sessionManager.MarkLocalPlayedSignalSent(deviceId, itemId); + _logger.LogInformation( + "🎧 Local played signal sent via PlayedItems for {ItemId} at {Position}s (trigger={Trigger}s)", + itemId, + playedSeconds, + triggerAtSeconds); + } + else + { + _logger.LogDebug( + "Local played signal returned {StatusCode} for {ItemId} (position={Position}s, trigger={Trigger}s)", + statusCode, + itemId, + playedSeconds, + triggerAtSeconds); + } + } + + private string? ResolvePlaybackUserId(JsonElement progressPayload) + { + if (progressPayload.TryGetProperty("UserId", out var userIdElement) && + userIdElement.ValueKind == JsonValueKind.String) + { + var payloadUserId = userIdElement.GetString(); + if (!string.IsNullOrWhiteSpace(payloadUserId)) + { + return payloadUserId; + } + } + + var queryUserId = Request.Query["userId"].ToString(); + if (!string.IsNullOrWhiteSpace(queryUserId)) + { + return queryUserId; + } + + return _settings.UserId; + } + + private static int? ToPlaybackPositionSeconds(long? positionTicks) + { + if (!positionTicks.HasValue) + { + return null; + } + + var seconds = positionTicks.Value / TimeSpan.TicksPerSecond; + if (seconds <= 0) + { + return 0; + } + + return seconds > int.MaxValue ? int.MaxValue : (int)seconds; + } + + private string? ResolveDeviceId(string? parsedDeviceId, JsonElement? payload = null) + { + if (!string.IsNullOrWhiteSpace(parsedDeviceId)) + { + return parsedDeviceId; + } + + if (payload.HasValue && + payload.Value.TryGetProperty("DeviceId", out var payloadDeviceIdElement) && + payloadDeviceIdElement.ValueKind == JsonValueKind.String) + { + var payloadDeviceId = payloadDeviceIdElement.GetString(); + if (!string.IsNullOrWhiteSpace(payloadDeviceId)) + { + return payloadDeviceId; + } + } + + if (Request.Headers.TryGetValue("X-Emby-Device-Id", out var headerDeviceId)) + { + var deviceIdFromHeader = headerDeviceId.FirstOrDefault(); + if (!string.IsNullOrWhiteSpace(deviceIdFromHeader)) + { + return deviceIdFromHeader; + } + } + + var queryDeviceId = Request.Query["DeviceId"].ToString(); + if (string.IsNullOrWhiteSpace(queryDeviceId)) + { + queryDeviceId = Request.Query["deviceId"].ToString(); + } + + return string.IsNullOrWhiteSpace(queryDeviceId) ? parsedDeviceId : queryDeviceId; + } + /// /// Reports playback stopped. Handles both local and external tracks. /// @@ -560,35 +1189,50 @@ public partial class JellyfinController Request.Body.Position = 0; - _logger.LogInformation("⏹️ Playback STOPPED reported"); - _logger.LogDebug("📤 Sending playback stop body: {Body}", body); + _logger.LogInformation("⏹️ Playback STOPPED reported"); + _logger.LogDebug("📤 Sending playback stop body ({BodyLength} bytes)", body.Length); // Parse the body to check if it's an external track var doc = JsonDocument.Parse(body); string? itemId = null; string? itemName = null; long? positionTicks = null; - string? deviceId = null; + string? playSessionId = null; + var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers); - if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp)) - { - itemId = itemIdProp.GetString(); - } + itemId = ParsePlaybackItemId(doc.RootElement); if (doc.RootElement.TryGetProperty("ItemName", out var itemNameProp)) { itemName = itemNameProp.GetString(); } - if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp)) + if (string.IsNullOrWhiteSpace(itemName)) { - positionTicks = posProp.GetInt64(); + itemName = ParsePlaybackItemName(doc.RootElement); } - // Try to get device ID from headers for session management - if (Request.Headers.TryGetValue("X-Emby-Device-Id", out var deviceIdHeader)) + positionTicks = ParsePlaybackPositionTicks(doc.RootElement); + playSessionId = ParsePlaybackSessionId(doc.RootElement); + + deviceId = ResolveDeviceId(deviceId, doc.RootElement); + + // Some clients send stop without ItemId. Recover from tracked session state when possible. + if (string.IsNullOrWhiteSpace(itemId) && !string.IsNullOrWhiteSpace(deviceId)) { - deviceId = deviceIdHeader.FirstOrDefault(); + var (trackedItemId, trackedPositionTicks) = _sessionManager.GetLastPlayingState(deviceId); + if (!string.IsNullOrWhiteSpace(trackedItemId)) + { + itemId = trackedItemId; + if (!positionTicks.HasValue) + { + positionTicks = trackedPositionTicks; + } + + _logger.LogInformation( + "⏹️ Playback stop missing ItemId - recovered from session state: {ItemId}", + itemId); + } } if (!string.IsNullOrEmpty(itemId)) @@ -597,6 +1241,24 @@ public partial class JellyfinController if (isExternal) { + if (ShouldSuppressPlaybackSignal("stop", deviceId, itemId, playSessionId)) + { + _logger.LogDebug( + "Skipping duplicate external playback stop signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})", + itemId, + deviceId ?? "unknown", + playSessionId ?? "none"); + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + _sessionManager.MarkExplicitStop(deviceId, itemId); + _sessionManager.UpdatePlayingItem(deviceId, null, null); + _sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30)); + } + + return NoContent(); + } + var position = positionTicks.HasValue ? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss") : "unknown"; @@ -691,6 +1353,13 @@ public partial class JellyfinController }); } + if ((stopStatusCode == 200 || stopStatusCode == 204) && !string.IsNullOrWhiteSpace(deviceId)) + { + _sessionManager.MarkExplicitStop(deviceId, itemId); + _sessionManager.UpdatePlayingItem(deviceId, null, null); + _sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30)); + } + return NoContent(); } @@ -720,6 +1389,24 @@ public partial class JellyfinController _logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})", trackName ?? "Unknown", itemId); + if (ShouldSuppressPlaybackSignal("stop", deviceId, itemId, playSessionId)) + { + _logger.LogDebug( + "Skipping duplicate local playback stop signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})", + itemId, + deviceId ?? "unknown", + playSessionId ?? "none"); + + if (!string.IsNullOrWhiteSpace(deviceId)) + { + _sessionManager.MarkExplicitStop(deviceId, itemId); + _sessionManager.UpdatePlayingItem(deviceId, null, null); + _sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30)); + } + + return NoContent(); + } + // Scrobble local track playback stop (only if enabled) if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null && _scrobblingHelper != null && !string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId) && @@ -750,7 +1437,7 @@ public partial class JellyfinController _logger.LogDebug("Forwarding playback stop to Jellyfin..."); // Log the body being sent for debugging - _logger.LogDebug("📤 Original playback stop body: {Body}", body); + _logger.LogDebug("📤 Original playback stop body length: {BodyLength} bytes", body.Length); // Parse and fix the body - ensure IsPaused is false for a proper stop var stopDoc = JsonDocument.Parse(body); @@ -763,21 +1450,11 @@ public partial class JellyfinController // Force IsPaused to false for a proper stop stopInfo[prop.Name] = false; } - else if (prop.Value.ValueKind == JsonValueKind.String) - { - stopInfo[prop.Name] = prop.Value.GetString(); - } - else if (prop.Value.ValueKind == JsonValueKind.Number) - { - stopInfo[prop.Name] = prop.Value.GetInt64(); - } - else if (prop.Value.ValueKind == JsonValueKind.True || prop.Value.ValueKind == JsonValueKind.False) - { - stopInfo[prop.Name] = prop.Value.GetBoolean(); - } else { - stopInfo[prop.Name] = prop.Value.GetRawText(); + // Preserve client payload types as-is (number/string/object/array) to avoid + // format exceptions on non-int64 numbers and keep Jellyfin-compatible shapes. + stopInfo[prop.Name] = prop.Value.Clone(); } } @@ -793,7 +1470,7 @@ public partial class JellyfinController } body = JsonSerializer.Serialize(stopInfo); - _logger.LogInformation("📤 Sending playback stop body (IsPaused=false): {Body}", body); + _logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", body, Request.Headers); @@ -801,6 +1478,15 @@ public partial class JellyfinController if (statusCode == 204 || statusCode == 200) { _logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode); + if (!string.IsNullOrWhiteSpace(deviceId)) + { + if (!string.IsNullOrWhiteSpace(itemId)) + { + _sessionManager.MarkExplicitStop(deviceId, itemId); + } + _sessionManager.UpdatePlayingItem(deviceId, null, null); + _sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30)); + } } else if (statusCode == 401) { @@ -862,11 +1548,15 @@ public partial class JellyfinController var method = Request.Method; var queryString = Request.QueryString.HasValue ? Request.QueryString.Value : ""; var endpoint = string.IsNullOrEmpty(path) ? $"Sessions{queryString}" : $"Sessions/{path}{queryString}"; + var maskedQueryString = MaskSensitiveQueryString(queryString); + var logEndpoint = string.IsNullOrEmpty(path) + ? $"Sessions{maskedQueryString}" + : $"Sessions/{path}{maskedQueryString}"; - _logger.LogDebug("🔄 Proxying session request: {Method} {Endpoint}", method, endpoint); - _logger.LogDebug("Session proxy headers: {Headers}", - string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase)) - .Select(h => $"{h.Key}={h.Value}"))); + _logger.LogDebug("🔄 Proxying session request: {Method} {Endpoint}", method, logEndpoint); + _logger.LogDebug("Session proxy auth header keys: {HeaderKeys}", + string.Join(", ", Request.Headers.Keys.Where(h => + h.Contains("Auth", StringComparison.OrdinalIgnoreCase)))); // Read body if present string body = "{}"; @@ -880,7 +1570,7 @@ public partial class JellyfinController } Request.Body.Position = 0; - _logger.LogDebug("Session proxy body: {Body}", body); + _logger.LogDebug("Session proxy body length: {BodyLength} bytes", body.Length); } // Forward to Jellyfin @@ -909,7 +1599,194 @@ public partial class JellyfinController } } + private static long? ParseOptionalInt64(JsonElement value) + { + if (value.ValueKind == JsonValueKind.Number) + { + if (value.TryGetInt64(out var int64Value)) + { + return int64Value; + } + + if (value.TryGetDouble(out var doubleValue)) + { + return (long)doubleValue; + } + + return null; + } + + if (value.ValueKind == JsonValueKind.String) + { + var text = value.GetString(); + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + if (long.TryParse(text, NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsedInt)) + { + return parsedInt; + } + + if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var parsedDouble)) + { + return (long)parsedDouble; + } + } + + return null; + } + + private static string? ParseOptionalString(JsonElement value) + { + if (value.ValueKind == JsonValueKind.String) + { + var stringValue = value.GetString(); + return string.IsNullOrWhiteSpace(stringValue) ? null : stringValue; + } + + return null; + } + + private static string? TryReadStringProperty(JsonElement obj, string propertyName) + { + if (obj.ValueKind != JsonValueKind.Object) + { + return null; + } + + if (!obj.TryGetProperty(propertyName, out var value)) + { + return null; + } + + return ParseOptionalString(value); + } + + private static string? ParsePlaybackSessionId(JsonElement payload) + { + var direct = TryReadStringProperty(payload, "PlaySessionId"); + if (!string.IsNullOrWhiteSpace(direct)) + { + return direct; + } + + if (payload.TryGetProperty("PlaySession", out var playSession)) + { + var nested = TryReadStringProperty(playSession, "Id"); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + + return null; + } + + private static bool ShouldSuppressPlaybackSignal( + string signalType, + string? deviceId, + string itemId, + string? playSessionId) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return false; + } + + var normalizedDevice = string.IsNullOrWhiteSpace(deviceId) ? "unknown-device" : deviceId; + var baseKey = $"{signalType}:{normalizedDevice}:{itemId}"; + var sessionKey = string.IsNullOrWhiteSpace(playSessionId) + ? null + : $"{baseKey}:{playSessionId}"; + + var now = DateTime.UtcNow; + if (RecentPlaybackSignals.TryGetValue(baseKey, out var lastSeenAtUtc) && + (now - lastSeenAtUtc) <= PlaybackSignalDedupeWindow) + { + return true; + } + + if (!string.IsNullOrWhiteSpace(sessionKey) && + RecentPlaybackSignals.TryGetValue(sessionKey, out var lastSeenForSessionAtUtc) && + (now - lastSeenForSessionAtUtc) <= PlaybackSignalDedupeWindow) + { + return true; + } + + RecentPlaybackSignals[baseKey] = now; + if (!string.IsNullOrWhiteSpace(sessionKey)) + { + RecentPlaybackSignals[sessionKey] = now; + } + + if (RecentPlaybackSignals.Count > 4096) + { + var cutoff = now - PlaybackSignalRetentionWindow; + foreach (var pair in RecentPlaybackSignals) + { + if (pair.Value < cutoff) + { + RecentPlaybackSignals.TryRemove(pair.Key, out _); + } + } + } + + return false; + } + + private static string? ParsePlaybackItemId(JsonElement payload) + { + var direct = TryReadStringProperty(payload, "ItemId"); + if (!string.IsNullOrWhiteSpace(direct)) + { + return direct; + } + + if (payload.TryGetProperty("Item", out var item)) + { + var nested = TryReadStringProperty(item, "Id"); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + + return null; + } + + private static string? ParsePlaybackItemName(JsonElement payload) + { + var direct = TryReadStringProperty(payload, "ItemName") ?? TryReadStringProperty(payload, "Name"); + if (!string.IsNullOrWhiteSpace(direct)) + { + return direct; + } + + if (payload.TryGetProperty("Item", out var item)) + { + var nested = TryReadStringProperty(item, "Name"); + if (!string.IsNullOrWhiteSpace(nested)) + { + return nested; + } + } + + return null; + } + + private static long? ParsePlaybackPositionTicks(JsonElement payload) + { + if (payload.TryGetProperty("PositionTicks", out var positionTicks)) + { + return ParseOptionalInt64(positionTicks); + } + + return null; + } + #endregion // Session Management #endregion // Playback Session Reporting -} \ No newline at end of file +} diff --git a/allstarr/Controllers/JellyfinController.PlaylistHandler.cs b/allstarr/Controllers/JellyfinController.PlaylistHandler.cs index 5a32429..3252781 100644 --- a/allstarr/Controllers/JellyfinController.PlaylistHandler.cs +++ b/allstarr/Controllers/JellyfinController.PlaylistHandler.cs @@ -87,20 +87,20 @@ public partial class JellyfinController } // Check if this is a Spotify playlist (by ID) - _logger.LogInformation("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}", + _logger.LogDebug("Spotify Import Enabled: {Enabled}, Configured Playlists: {Count}", _spotifySettings.Enabled, _spotifySettings.Playlists.Count); if (_spotifySettings.Enabled && _spotifySettings.IsSpotifyPlaylist(playlistId)) { // Get playlist info from Jellyfin to get the name for matching missing tracks - _logger.LogInformation("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId); + _logger.LogDebug("Fetching playlist info from Jellyfin for ID: {PlaylistId}", playlistId); var (playlistInfo, _) = await _proxyService.GetJsonAsync($"Items/{playlistId}", null, Request.Headers); if (playlistInfo != null && playlistInfo.RootElement.TryGetProperty("Name", out var nameElement)) { var playlistName = nameElement.GetString() ?? ""; _logger.LogInformation( - "✓ MATCHED! Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})", + "Intercepting Spotify playlist: {PlaylistName} (ID: {PlaylistId})", playlistName, playlistId); return await GetSpotifyPlaylistTracksAsync(playlistName, playlistId); } @@ -154,7 +154,16 @@ public partial class JellyfinController return NotFound(); } - var response = await _proxyService.HttpClient.GetAsync(playlist.CoverUrl); + if (!OutboundRequestGuard.TryCreateSafeHttpUri(playlist.CoverUrl, out var validatedCoverUri, + out var validationReason) || validatedCoverUri == null) + { + _logger.LogWarning("Blocked playlist image URL fetch for {PlaylistId}: {Reason}", + playlistId, validationReason); + return NotFound(); + } + + var coverUri = validatedCoverUri!; + var response = await _proxyService.HttpClient.GetAsync(coverUri); if (!response.IsSuccessStatusCode) { return NotFound(); @@ -177,4 +186,4 @@ public partial class JellyfinController } #endregion -} \ No newline at end of file +} diff --git a/allstarr/Controllers/JellyfinController.Search.cs b/allstarr/Controllers/JellyfinController.Search.cs index 1eb90c4..cf9b488 100644 --- a/allstarr/Controllers/JellyfinController.Search.cs +++ b/allstarr/Controllers/JellyfinController.Search.cs @@ -1,4 +1,5 @@ using System.Text.Json; +using System.Text; using allstarr.Models.Subsonic; using allstarr.Services.Common; using Microsoft.AspNetCore.Mvc; @@ -28,12 +29,20 @@ public partial class JellyfinController [FromQuery] bool recursive = true, string? userId = null) { + var boundSearchTerm = searchTerm; + searchTerm = GetEffectiveSearchTerm(searchTerm, Request.QueryString.Value); + // AlbumArtistIds takes precedence over ArtistIds if both are provided var effectiveArtistIds = albumArtistIds ?? artistIds; - _logger.LogDebug( - "=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}", + _logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}", searchTerm, includeItemTypes, parentId, artistIds, albumArtistIds, albumIds, userId); + _logger.LogInformation( + "SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'", + Request.QueryString.Value ?? string.Empty, + boundSearchTerm ?? string.Empty, + searchTerm ?? string.Empty, + includeItemTypes ?? string.Empty); // ============================================================================ // REQUEST ROUTING LOGIC (Priority Order) @@ -57,13 +66,13 @@ public partial class JellyfinController // Check if this is a curator ID (format: ext-{provider}-curator-{name}) if (artistId.Contains("-curator-", StringComparison.OrdinalIgnoreCase)) { - _logger.LogInformation("Fetching playlists for curator: {ArtistId}", artistId); - return await GetCuratorPlaylists(provider!, externalId!, includeItemTypes); + _logger.LogDebug("Fetching playlists for curator: {ArtistId}", artistId); + return await GetCuratorPlaylists(provider!, externalId!, includeItemTypes, HttpContext.RequestAborted); } - _logger.LogInformation("Fetching content for external artist: {Provider}/{ExternalId}, type={Type}, parentId={ParentId}", + _logger.LogDebug("Fetching content for external artist: {Provider}/{ExternalId}, type={Type}, parentId={ParentId}", provider, externalId, type, parentId); - return await GetExternalChildItems(provider!, type!, externalId!, includeItemTypes); + return await GetExternalChildItems(provider!, type!, externalId!, includeItemTypes, HttpContext.RequestAborted); } // If library artist, fall through to handle with ParentId or proxy } @@ -76,10 +85,10 @@ public partial class JellyfinController if (isExternal) { - _logger.LogInformation("Fetching songs for external album: {Provider}/{ExternalId}", provider, + _logger.LogDebug("Fetching songs for external album: {Provider}/{ExternalId}", provider, externalId); - var album = await _metadataService.GetAlbumAsync(provider!, externalId!); + var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted); if (album == null) { return new JsonResult(new @@ -98,23 +107,39 @@ public partial class JellyfinController // If library album, fall through to handle with ParentId or proxy } - // PRIORITY 3: ParentId present - handles both external and library items + // PRIORITY 3: ParentId present - check if external first if (!string.IsNullOrWhiteSpace(parentId)) { - // Check if this is the music library root with a search term - if so, do integrated search + // Check if this is an external playlist + if (PlaylistIdHelper.IsExternalPlaylist(parentId)) + { + return await GetPlaylistTracks(parentId); + } + + var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(parentId); + + if (isExternal) + { + // External parent - get external content + _logger.LogDebug("Fetching children for external parent: {Provider}/{Type}/{ExternalId}", + provider, type, externalId); + return await GetExternalChildItems(provider!, type!, externalId!, includeItemTypes, HttpContext.RequestAborted); + } + + // Library ParentId - check if it's the music library root with a search term var isMusicLibrary = parentId == _settings.LibraryId; if (isMusicLibrary && !string.IsNullOrWhiteSpace(searchTerm)) { - _logger.LogInformation("Searching within music library {ParentId}, including external sources", + _logger.LogDebug("Searching within music library {ParentId}, including external sources", parentId); // Fall through to integrated search below } else { - // Browse parent item (external playlist/album/artist OR library item) - _logger.LogDebug("Browsing parent: {ParentId}", parentId); - return await GetChildItems(parentId, includeItemTypes, limit, startIndex, sortBy); + // Library parent - proxy the entire request to Jellyfin as-is + _logger.LogDebug("Library ParentId detected, proxying entire request to Jellyfin"); + // Fall through to proxy logic at the end } } @@ -136,12 +161,21 @@ public partial class JellyfinController // Check cache for search results (only cache pure searches, not filtered searches) if (string.IsNullOrWhiteSpace(effectiveArtistIds) && string.IsNullOrWhiteSpace(albumIds)) { - var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex); + var cacheKey = CacheKeyBuilder.BuildSearchKey( + searchTerm, + includeItemTypes, + limit, + startIndex, + parentId, + sortBy, + Request.Query["SortOrder"].ToString(), + recursive, + userId); var cachedResult = await _cache.GetAsync(cacheKey); if (cachedResult != null) { - _logger.LogDebug("✅ Returning cached search results for '{SearchTerm}'", searchTerm); + _logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", cacheKey); return new JsonResult(cachedResult); } } @@ -155,10 +189,14 @@ public partial class JellyfinController var endpoint = userId != null ? $"Users/{userId}/Items" : "Items"; - // Ensure MediaSources is included in Fields parameter for bitrate info + // Include MediaSources only for audio-oriented browse requests (bitrate needs). + // Album/artist browse requests should stay as close to raw Jellyfin responses as possible. var queryString = Request.QueryString.Value ?? ""; + var requestedTypes = ParseItemTypes(includeItemTypes); + var shouldIncludeMediaSources = requestedTypes != null && + requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase); - if (!string.IsNullOrEmpty(queryString)) + if (shouldIncludeMediaSources && !string.IsNullOrEmpty(queryString)) { // Parse query string to modify Fields parameter var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString); @@ -197,31 +235,28 @@ public partial class JellyfinController queryString = $"{queryString}&Fields=MediaSources"; } } - else + else if (shouldIncludeMediaSources) { // No query string at all queryString = "?Fields=MediaSources"; } - endpoint = $"{endpoint}{queryString}"; + if (!string.IsNullOrEmpty(queryString)) + { + endpoint = $"{endpoint}{queryString}"; + } var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + // If Jellyfin returned an error, pass it through unchanged if (browseResult == null) { - if (statusCode == 401) - { - _logger.LogInformation("Jellyfin returned 401 Unauthorized, returning 401 to client"); - return Unauthorized(new { error = "Authentication required" }); - } - - _logger.LogDebug("Jellyfin returned {StatusCode}, returning empty result", statusCode); - return new JsonResult(new - { Items = Array.Empty(), TotalRecordCount = 0, StartIndex = startIndex }); + _logger.LogDebug("Jellyfin returned {StatusCode}, passing through to client", statusCode); + return HandleProxyResponse(browseResult, statusCode); } // Update Spotify playlist counts if enabled and response contains playlists - if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _)) + if (ShouldProcessSpotifyPlaylistCounts(browseResult, includeItemTypes)) { _logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts"); browseResult = await UpdateSpotifyPlaylistCounts(browseResult); @@ -247,15 +282,21 @@ public partial class JellyfinController // Run local and external searches in parallel var itemTypes = ParseItemTypes(includeItemTypes); - var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, recursive, Request.Headers); + var jellyfinTask = GetLocalSearchResultForCurrentRequest( + cleanQuery, + includeItemTypes, + limit, + startIndex, + recursive, + userId); // Use parallel metadata service if available (races providers), otherwise use primary var externalTask = _parallelMetadataService != null - ? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit) - : _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit); + ? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted) + : _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted); var playlistTask = _settings.EnableExternalPlaylists - ? _metadataService.SearchPlaylistsAsync(cleanQuery, limit) + ? _metadataService.SearchPlaylistsAsync(cleanQuery, limit, HttpContext.RequestAborted) : Task.FromResult(new List()); _logger.LogDebug("Playlist search enabled: {Enabled}, searching for: '{Query}'", @@ -267,7 +308,7 @@ public partial class JellyfinController var externalResult = await externalTask; var playlistResult = await playlistTask; - _logger.LogInformation( + _logger.LogDebug( "Search results for '{Query}': Jellyfin={JellyfinCount}, External Songs={ExtSongs}, Albums={ExtAlbums}, Artists={ExtArtists}, Playlists={Playlists}", cleanQuery, jellyfinResult != null ? "found" : "null", @@ -276,31 +317,55 @@ public partial class JellyfinController externalResult.Artists.Count, playlistResult.Count); - // Parse Jellyfin results into domain models - var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); + // Keep raw Jellyfin items for local tracks (preserves ALL metadata!) + var jellyfinSongItems = new List>(); + var jellyfinAlbumItems = new List>(); + var jellyfinArtistItems = new List>(); - // Sort all results by match score (local tracks get +10 boost) - // This ensures best matches appear first regardless of source - var allSongs = localSongs.Concat(externalResult.Songs) - .Select(s => new - { Song = s, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, s.Title) + (s.IsLocal ? 10.0 : 0.0) }) - .OrderByDescending(x => x.Score) - .Select(x => x.Song) - .ToList(); + if (jellyfinResult != null && jellyfinResult.RootElement.TryGetProperty("Items", out var jellyfinItems)) + { + foreach (var item in jellyfinItems.EnumerateArray()) + { + if (!item.TryGetProperty("Type", out var typeEl)) continue; + var type = typeEl.GetString(); + var itemDict = JsonElementToDictionary(item); - var allAlbums = localAlbums.Concat(externalResult.Albums) - .Select(a => new - { Album = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Title) + (a.IsLocal ? 10.0 : 0.0) }) - .OrderByDescending(x => x.Score) - .Select(x => x.Album) - .ToList(); + if (type == "Audio") + { + jellyfinSongItems.Add(itemDict); + } + else if (type == "MusicAlbum") + { + jellyfinAlbumItems.Add(itemDict); + } + else if (type == "MusicArtist") + { + jellyfinArtistItems.Add(itemDict); + } + } + } - var allArtists = localArtists.Concat(externalResult.Artists) - .Select(a => new - { Artist = a, Score = FuzzyMatcher.CalculateSimilarity(cleanQuery, a.Name) + (a.IsLocal ? 10.0 : 0.0) }) - .OrderByDescending(x => x.Score) - .Select(x => x.Artist) - .ToList(); + var localAlbumNamesPreview = string.Join(" | ", jellyfinAlbumItems + .Take(10) + .Select(GetItemName)); + _logger.LogInformation( + "SEARCH TRACE: Jellyfin local counts for query '{Query}' => songs={SongCount}, albums={AlbumCount}, artists={ArtistCount}; localAlbumPreview=[{AlbumPreview}]", + cleanQuery, + jellyfinSongItems.Count, + jellyfinAlbumItems.Count, + jellyfinArtistItems.Count, + localAlbumNamesPreview); + + // Convert external results to Jellyfin format + var externalSongItems = externalResult.Songs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList(); + var externalAlbumItems = externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList(); + var externalArtistItems = externalResult.Artists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList(); + + // Score-sort each source, then interleave by highest remaining score. + // Keep only a small source preference for already-relevant primary results. + var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 72); + var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 78); + var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 75); // Log top results for debugging if (_logger.IsEnabled(LogLevel.Debug)) @@ -308,97 +373,85 @@ public partial class JellyfinController if (allSongs.Any()) { var topSong = allSongs.First(); - var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topSong.Title) + - (topSong.IsLocal ? 10.0 : 0.0); - _logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal}, score={Score:F2})", - topSong.Title, topSong.IsLocal, topScore); + var topName = topSong.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl ? nameEl.GetString() ?? "" : topSong["Name"]?.ToString() ?? ""; + _logger.LogDebug("🎵 Top song: '{Title}' (local={IsLocal})", + topName, IsLocalItem(topSong)); } if (allAlbums.Any()) { var topAlbum = allAlbums.First(); - var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topAlbum.Title) + - (topAlbum.IsLocal ? 10.0 : 0.0); - _logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal}, score={Score:F2})", - topAlbum.Title, topAlbum.IsLocal, topScore); + var topName = topAlbum.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl ? nameEl.GetString() ?? "" : topAlbum["Name"]?.ToString() ?? ""; + _logger.LogDebug("💿 Top album: '{Title}' (local={IsLocal})", + topName, IsLocalItem(topAlbum)); } if (allArtists.Any()) { var topArtist = allArtists.First(); - var topScore = FuzzyMatcher.CalculateSimilarity(cleanQuery, topArtist.Name) + - (topArtist.IsLocal ? 10.0 : 0.0); - _logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal}, score={Score:F2})", - topArtist.Name, topArtist.IsLocal, topScore); + var topName = topArtist.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl ? nameEl.GetString() ?? "" : topArtist["Name"]?.ToString() ?? ""; + _logger.LogDebug("🎤 Top artist: '{Name}' (local={IsLocal})", + topName, IsLocalItem(topArtist)); } } - // Convert to Jellyfin format - var mergedSongs = allSongs.Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)).ToList(); - var mergedAlbums = allAlbums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList(); - var mergedArtists = allArtists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList(); - - // Add playlists with scoring (albums get +10 boost over playlists) - // Playlists are mixed with albums due to Jellyfin API limitations (no dedicated playlist search) - var mergedPlaylistsWithScore = new List<(Dictionary Item, double Score)>(); + // Add playlists (mixed with albums due to Jellyfin API limitations) + // Playlists are converted to album format for compatibility + var mergedPlaylistItems = new List>(); if (playlistResult.Count > 0) { - _logger.LogInformation("Processing {Count} playlists for merging with albums", playlistResult.Count); + _logger.LogDebug("Processing {Count} playlists for merging with albums", playlistResult.Count); foreach (var playlist in playlistResult) { var playlistItem = _responseBuilder.ConvertPlaylistToAlbumItem(playlist); - var score = FuzzyMatcher.CalculateSimilarity(cleanQuery, playlist.Name); - mergedPlaylistsWithScore.Add((playlistItem, score)); - _logger.LogDebug("Playlist '{Name}' score: {Score:F2}", playlist.Name, score); + mergedPlaylistItems.Add(playlistItem); } - _logger.LogInformation("Found {Count} playlists, merging with albums (albums get +10 score boost)", - playlistResult.Count); + _logger.LogDebug("Found {Count} playlists, merging with albums", playlistResult.Count); } else { _logger.LogDebug("No playlists found to merge with albums"); } - // Merge albums and playlists, sorted by score (albums get +10 boost) - var albumsWithScore = mergedAlbums.Select(a => - { - var title = a.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl - ? nameEl.GetString() ?? "" - : ""; - var score = FuzzyMatcher.CalculateSimilarity(cleanQuery, title) + 10.0; // Albums get +10 boost - return (Item: a, Score: score); - }); - - var mergedAlbumsAndPlaylists = albumsWithScore - .Concat(mergedPlaylistsWithScore) - .OrderByDescending(x => x.Score) - .Select(x => x.Item) - .ToList(); + // Merge albums and playlists using score-based interleaving (albums keep a light priority over playlists). + var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 2.0, boostMinScore: 70); + mergedAlbumsAndPlaylists = ApplyRequestedAlbumOrderingIfApplicable( + mergedAlbumsAndPlaylists, + itemTypes, + Request.Query["SortBy"].ToString(), + Request.Query["SortOrder"].ToString()); _logger.LogDebug( - "Merged and sorted results by score: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}", - mergedSongs.Count, mergedAlbumsAndPlaylists.Count, mergedArtists.Count); + "Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}", + allSongs.Count, mergedAlbumsAndPlaylists.Count, allArtists.Count); - // Pre-fetch lyrics for top 3 songs in background (don't await) - if (_lrclibService != null && mergedSongs.Count > 0) + // Pre-fetch lyrics for top 3 LOCAL songs in background (don't await) + // Skip external tracks to avoid spamming LRCLIB with malformed titles + if (_lrclibService != null && allSongs.Count > 0) { _ = Task.Run(async () => { try { - var top3 = mergedSongs.Take(3).ToList(); - _logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count); - - foreach (var songItem in top3) + var top3Local = allSongs.Where(IsLocalItem).Take(3).ToList(); + if (top3Local.Count > 0) { - if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl && - songItem.TryGetValue("Artists", out var artistsObj) && - artistsObj is JsonElement artistsEl && - artistsEl.GetArrayLength() > 0) + _logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} LOCAL search results", top3Local.Count); + + foreach (var songItem in top3Local) { - var title = nameEl.GetString() ?? ""; - var artist = artistsEl[0].GetString() ?? ""; + var title = songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl ? nameEl.GetString() ?? "" : songItem["Name"]?.ToString() ?? ""; + var artist = ""; + + if (songItem.TryGetValue("Artists", out var artistsObj) && artistsObj is JsonElement artistsEl && artistsEl.GetArrayLength() > 0) + { + artist = artistsEl[0].GetString() ?? ""; + } + else if (songItem.TryGetValue("Artists", out var artistsListObj) && artistsListObj is object[] artistsList && artistsList.Length > 0) + { + artist = artistsList[0]?.ToString() ?? ""; + } if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist)) { @@ -422,8 +475,8 @@ public partial class JellyfinController if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist")) { - _logger.LogDebug("Adding {Count} artists to results", mergedArtists.Count); - items.AddRange(mergedArtists); + _logger.LogDebug("Adding {Count} artists to results", allArtists.Count); + items.AddRange(allArtists); } if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || @@ -435,10 +488,19 @@ public partial class JellyfinController if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio")) { - _logger.LogDebug("Adding {Count} songs to results", mergedSongs.Count); - items.AddRange(mergedSongs); + _logger.LogDebug("Adding {Count} songs to results", allSongs.Count); + items.AddRange(allSongs); } + var includesSongs = itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio"); + var includesAlbums = itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || itemTypes.Contains("Playlist"); + var includesArtists = itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist"); + + var externalHasRequestedTypeResults = + (includesSongs && externalSongItems.Count > 0) || + (includesAlbums && (externalAlbumItems.Count > 0 || mergedPlaylistItems.Count > 0)) || + (includesArtists && externalArtistItems.Count > 0); + // Apply pagination var pagedItems = items.Skip(startIndex).Take(limit).ToList(); @@ -457,10 +519,29 @@ public partial class JellyfinController // Cache search results in Redis (15 min TTL, no file persistence) if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds)) { - var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex); - await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL); - _logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm, - CacheExtensions.SearchResultsTTL.TotalMinutes); + if (externalHasRequestedTypeResults) + { + var cacheKey = CacheKeyBuilder.BuildSearchKey( + searchTerm, + includeItemTypes, + limit, + startIndex, + parentId, + sortBy, + Request.Query["SortOrder"].ToString(), + recursive, + userId); + await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL); + _logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm, + CacheExtensions.SearchResultsTTL.TotalMinutes); + } + else + { + _logger.LogInformation( + "SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})", + cleanQuery, + includeItemTypes ?? string.Empty); + } } _logger.LogDebug("About to serialize response..."); @@ -515,15 +596,44 @@ public partial class JellyfinController // Build endpoint - handle both /Items and /Users/{userId}/Items routes var userIdFromRoute = Request.RouteValues["userId"]?.ToString(); - var endpoint = string.IsNullOrEmpty(userIdFromRoute) + var endpoint = string.IsNullOrEmpty(userIdFromRoute) ? $"Items{Request.QueryString}" : $"Users/{userIdFromRoute}/Items{Request.QueryString}"; - + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); return HandleProxyResponse(result, statusCode); } + private async Task<(JsonDocument? Body, int StatusCode)> GetLocalSearchResultForCurrentRequest( + string cleanQuery, + string? includeItemTypes, + int limit, + int startIndex, + bool recursive, + string? userId) + { + var endpoint = !string.IsNullOrWhiteSpace(userId) + ? $"Users/{userId}/Items" + : "Items"; + + var queryParams = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in Request.Query) + { + queryParams[kvp.Key] = kvp.Value.ToString(); + } + + // Preserve literal request semantics, only normalize recovered SearchTerm. + queryParams["SearchTerm"] = cleanQuery; + + _logger.LogInformation( + "SEARCH TRACE: local proxy request endpoint='{Endpoint}' query='{SafeQuery}'", + endpoint, + ToSafeQueryStringForLogs(queryParams)); + + return await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers); + } + /// /// Quick search endpoint. Works with /Search/Hints and /Users/{userId}/Search/Hints. /// @@ -535,6 +645,8 @@ public partial class JellyfinController [FromQuery] string? includeItemTypes = null, string? userId = null) { + searchTerm = GetEffectiveSearchTerm(searchTerm, Request.QueryString.Value) ?? searchTerm; + if (string.IsNullOrWhiteSpace(searchTerm)) { return _responseBuilder.CreateJsonResponse(new @@ -545,18 +657,21 @@ public partial class JellyfinController } var cleanQuery = searchTerm.Trim().Trim('"'); - var itemTypes = ParseItemTypes(includeItemTypes); - // Run searches in parallel - var jellyfinTask = _proxyService.SearchAsync(cleanQuery, itemTypes, limit, true, Request.Headers); - var externalTask = _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit); + // Use parallel metadata service if available (races providers), otherwise use primary + var externalTask = _parallelMetadataService != null + ? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted) + : _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted); + + // Run searches in parallel (local Jellyfin hints + external providers) + var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId); await Task.WhenAll(jellyfinTask, externalTask); var (jellyfinResult, _) = await jellyfinTask; var externalResult = await externalTask; - var (localSongs, localAlbums, localArtists) = _modelMapper.ParseItemsResponse(jellyfinResult); + var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult); // NO deduplication - merge all results and take top matches var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList(); @@ -569,5 +684,553 @@ public partial class JellyfinController allArtists.Take(limit).ToList()); } + private async Task<(JsonDocument? Body, int StatusCode)> GetLocalSearchHintsResultForCurrentRequest( + string cleanQuery, + string? userId) + { + var endpoint = !string.IsNullOrWhiteSpace(userId) + ? $"Users/{userId}/Search/Hints" + : "Search/Hints"; + + var queryParams = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in Request.Query) + { + queryParams[kvp.Key] = kvp.Value.ToString(); + } + + // Preserve literal request semantics, only normalize recovered SearchTerm. + queryParams["SearchTerm"] = cleanQuery; + + _logger.LogInformation( + "SEARCH TRACE: local hints proxy request endpoint='{Endpoint}' query='{SafeQuery}'", + endpoint, + ToSafeQueryStringForLogs(queryParams)); + + return await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers); + } + + private static string ToSafeQueryStringForLogs(IReadOnlyDictionary queryParams) + { + if (queryParams.Count == 0) + { + return string.Empty; + } + + var query = "?" + string.Join("&", queryParams.Select(kvp => + $"{Uri.EscapeDataString(kvp.Key)}={Uri.EscapeDataString(kvp.Value ?? string.Empty)}")); + + return MaskSensitiveQueryString(query); + } + + private List> ApplyRequestedAlbumOrderingIfApplicable( + List> items, + string[]? requestedTypes, + string? sortBy, + string? sortOrder) + { + if (items.Count <= 1 || string.IsNullOrWhiteSpace(sortBy)) + { + return items; + } + + if (requestedTypes == null || requestedTypes.Length == 0) + { + return items; + } + + var isAlbumOnlyRequest = requestedTypes.All(type => + string.Equals(type, "MusicAlbum", StringComparison.OrdinalIgnoreCase) || + string.Equals(type, "Playlist", StringComparison.OrdinalIgnoreCase)); + + if (!isAlbumOnlyRequest) + { + return items; + } + + var sortFields = sortBy + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(field => !string.IsNullOrWhiteSpace(field)) + .ToList(); + + if (sortFields.Count == 0) + { + return items; + } + + var descending = string.Equals(sortOrder, "Descending", StringComparison.OrdinalIgnoreCase); + var sorted = items.ToList(); + sorted.Sort((left, right) => CompareAlbumItemsByRequestedSort(left, right, sortFields, descending)); + return sorted; + } + + private int CompareAlbumItemsByRequestedSort( + Dictionary left, + Dictionary right, + IReadOnlyList sortFields, + bool descending) + { + foreach (var field in sortFields) + { + var comparison = CompareAlbumItemsByField(left, right, field); + if (comparison == 0) + { + continue; + } + + return descending ? -comparison : comparison; + } + + return string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase); + } + + private int CompareAlbumItemsByField(Dictionary left, Dictionary right, string field) + { + return field.ToLowerInvariant() switch + { + "sortname" => string.Compare(GetItemStringValue(left, "SortName"), GetItemStringValue(right, "SortName"), StringComparison.OrdinalIgnoreCase), + "name" => string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase), + "datecreated" => DateTime.Compare(GetItemDateValue(left, "DateCreated"), GetItemDateValue(right, "DateCreated")), + "premieredate" => DateTime.Compare(GetItemDateValue(left, "PremiereDate"), GetItemDateValue(right, "PremiereDate")), + "productionyear" => CompareIntValues(GetItemIntValue(left, "ProductionYear"), GetItemIntValue(right, "ProductionYear")), + _ => 0 + }; + } + + private static int CompareIntValues(int? left, int? right) + { + if (left.HasValue && right.HasValue) + { + return left.Value.CompareTo(right.Value); + } + + if (left.HasValue) + { + return 1; + } + + if (right.HasValue) + { + return -1; + } + + return 0; + } + + private static DateTime GetItemDateValue(Dictionary item, string key) + { + if (!item.TryGetValue(key, out var value) || value == null) + { + return DateTime.MinValue; + } + + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.String && + DateTime.TryParse(jsonElement.GetString(), out var parsedDate)) + { + return parsedDate; + } + + return DateTime.MinValue; + } + + if (DateTime.TryParse(value.ToString(), out var parsed)) + { + return parsed; + } + + return DateTime.MinValue; + } + + private static int? GetItemIntValue(Dictionary item, string key) + { + if (!item.TryGetValue(key, out var value) || value == null) + { + return null; + } + + if (value is JsonElement jsonElement) + { + if (jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue)) + { + return intValue; + } + + if (jsonElement.ValueKind == JsonValueKind.String && + int.TryParse(jsonElement.GetString(), out var parsedInt)) + { + return parsedInt; + } + + return null; + } + + return int.TryParse(value.ToString(), out var parsed) ? parsed : null; + } + + /// + /// Score-sorts each source and then interleaves by highest remaining score. + /// This avoids weak head results in one source blocking stronger results later in that same source. + /// + private List> InterleaveByScore( + List> primaryItems, + List> secondaryItems, + string query, + double primaryBoost, + double boostMinScore = 70) + { + var primaryScored = primaryItems.Select((item, index) => + { + var baseScore = CalculateItemRelevanceScore(query, item); + var finalScore = baseScore >= boostMinScore + ? Math.Min(100.0, baseScore + primaryBoost) + : baseScore; + return new + { + Item = item, + BaseScore = baseScore, + Score = finalScore, + SourceIndex = index + }; + }) + .OrderByDescending(x => x.Score) + .ThenByDescending(x => x.BaseScore) + .ThenBy(x => x.SourceIndex) + .ToList(); + + var secondaryScored = secondaryItems.Select((item, index) => + { + var baseScore = CalculateItemRelevanceScore(query, item); + return new + { + Item = item, + BaseScore = baseScore, + Score = baseScore, + SourceIndex = index + }; + }) + .OrderByDescending(x => x.Score) + .ThenByDescending(x => x.BaseScore) + .ThenBy(x => x.SourceIndex) + .ToList(); + + var result = new List>(primaryScored.Count + secondaryScored.Count); + int primaryIdx = 0, secondaryIdx = 0; + + while (primaryIdx < primaryScored.Count || secondaryIdx < secondaryScored.Count) + { + if (primaryIdx >= primaryScored.Count) + { + result.Add(secondaryScored[secondaryIdx++].Item); + continue; + } + + if (secondaryIdx >= secondaryScored.Count) + { + result.Add(primaryScored[primaryIdx++].Item); + continue; + } + + var primaryCandidate = primaryScored[primaryIdx]; + var secondaryCandidate = secondaryScored[secondaryIdx]; + + if (primaryCandidate.Score > secondaryCandidate.Score) + { + result.Add(primaryScored[primaryIdx++].Item); + } + else if (secondaryCandidate.Score > primaryCandidate.Score) + { + result.Add(secondaryScored[secondaryIdx++].Item); + } + else if (primaryCandidate.BaseScore >= secondaryCandidate.BaseScore) + { + result.Add(primaryScored[primaryIdx++].Item); + } + else + { + result.Add(secondaryScored[secondaryIdx++].Item); + } + } + + return result; + } + + /// + /// Calculates query relevance for a search item. + /// Title is primary; metadata context is secondary and down-weighted. + /// + private double CalculateItemRelevanceScore(string query, Dictionary item) + { + var title = GetItemName(item); + if (string.IsNullOrWhiteSpace(title)) + { + return 0; + } + + var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(query, title); + var searchText = BuildItemSearchText(item, title); + + if (string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase)) + { + return titleScore; + } + + var metadataScore = FuzzyMatcher.CalculateSimilarityAggressive(query, searchText); + var weightedMetadataScore = metadataScore * 0.85; + + var baseScore = Math.Max(titleScore, weightedMetadataScore); + return ApplyQueryCoverageAdjustment(query, title, searchText, baseScore); + } + + private static double ApplyQueryCoverageAdjustment(string query, string title, string searchText, double baseScore) + { + var queryTokens = TokenizeForCoverage(query); + if (queryTokens.Count < 2) + { + return baseScore; + } + + var titleCoverage = CalculateTokenCoverage(queryTokens, title); + var searchCoverage = string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase) + ? titleCoverage + : CalculateTokenCoverage(queryTokens, searchText); + + var coverage = Math.Max(titleCoverage, searchCoverage); + + if (coverage >= 0.999) + { + return Math.Min(100.0, baseScore + 3.0); + } + + if (coverage >= 0.8) + { + return baseScore * 0.9; + } + + if (coverage >= 0.6) + { + return baseScore * 0.72; + } + + return baseScore * 0.5; + } + + private static double CalculateTokenCoverage(IReadOnlyList queryTokens, string target) + { + var targetTokens = TokenizeForCoverage(target); + if (queryTokens.Count == 0 || targetTokens.Count == 0) + { + return 0; + } + + var matched = 0; + foreach (var queryToken in queryTokens) + { + if (targetTokens.Any(targetToken => IsTokenMatch(queryToken, targetToken))) + { + matched++; + } + } + + return (double)matched / queryTokens.Count; + } + + private static bool IsTokenMatch(string queryToken, string targetToken) + { + return queryToken.Equals(targetToken, StringComparison.Ordinal) || + queryToken.StartsWith(targetToken, StringComparison.Ordinal) || + targetToken.StartsWith(queryToken, StringComparison.Ordinal); + } + + private static IReadOnlyList TokenizeForCoverage(string text) + { + if (string.IsNullOrWhiteSpace(text)) + { + return Array.Empty(); + } + + var normalized = NormalizeForCoverage(text); + var allTokens = normalized + .Split(' ', StringSplitOptions.RemoveEmptyEntries) + .Distinct(StringComparer.Ordinal) + .ToList(); + + if (allTokens.Count == 0) + { + return Array.Empty(); + } + + var significant = allTokens + .Where(token => token.Length >= 2 && !SearchStopWords.Contains(token)) + .ToList(); + + return significant.Count > 0 + ? significant + : allTokens.Where(token => token.Length >= 2).ToList(); + } + + private static string NormalizeForCoverage(string text) + { + var normalized = RemoveDiacritics(text).ToLowerInvariant(); + normalized = normalized.Replace('&', ' '); + normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", " "); + normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim(); + return normalized; + } + + private static string RemoveDiacritics(string text) + { + var normalized = text.Normalize(NormalizationForm.FormD); + var chars = new List(normalized.Length); + + foreach (var c in normalized) + { + if (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c) != System.Globalization.UnicodeCategory.NonSpacingMark) + { + chars.Add(c); + } + } + + return new string(chars.ToArray()).Normalize(NormalizationForm.FormC); + } + + /// + /// Extracts the name/title from a Jellyfin item dictionary. + /// + private string GetItemName(Dictionary item) + { + return GetItemStringValue(item, "Name"); + } + + private string BuildItemSearchText(Dictionary item, string title) + { + var parts = new List(); + + AddDistinct(parts, title); + AddDistinct(parts, GetItemStringValue(item, "SortName")); + AddDistinct(parts, GetItemStringValue(item, "AlbumArtist")); + AddDistinct(parts, GetItemStringValue(item, "Artist")); + AddDistinct(parts, GetItemStringValue(item, "Album")); + + foreach (var artist in GetItemStringList(item, "Artists").Take(3)) + { + AddDistinct(parts, artist); + } + + return string.Join(" ", parts); + } + + private static readonly HashSet SearchStopWords = new(StringComparer.Ordinal) + { + "a", + "an", + "and", + "at", + "for", + "in", + "of", + "on", + "the", + "to", + "with", + "feat", + "ft" + }; + + private static void AddDistinct(List values, string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return; + } + + if (!values.Contains(value, StringComparer.OrdinalIgnoreCase)) + { + values.Add(value); + } + } + + private string GetItemStringValue(Dictionary item, string key) + { + if (!item.TryGetValue(key, out var value) || value == null) + { + return string.Empty; + } + + if (value is JsonElement el) + { + return el.ValueKind switch + { + JsonValueKind.String => el.GetString() ?? string.Empty, + JsonValueKind.Number => el.ToString(), + JsonValueKind.True => bool.TrueString, + JsonValueKind.False => bool.FalseString, + _ => string.Empty + }; + } + + return value.ToString() ?? string.Empty; + } + + private IEnumerable GetItemStringList(Dictionary item, string key) + { + if (!item.TryGetValue(key, out var value) || value == null) + { + yield break; + } + + if (value is JsonElement el && el.ValueKind == JsonValueKind.Array) + { + foreach (var arrayItem in el.EnumerateArray()) + { + if (arrayItem.ValueKind == JsonValueKind.String) + { + var text = arrayItem.GetString(); + if (!string.IsNullOrWhiteSpace(text)) + { + yield return text; + } + } + else if (arrayItem.ValueKind == JsonValueKind.Object && + arrayItem.TryGetProperty("Name", out var nameEl) && + nameEl.ValueKind == JsonValueKind.String) + { + var text = nameEl.GetString(); + if (!string.IsNullOrWhiteSpace(text)) + { + yield return text; + } + } + } + + yield break; + } + + if (value is IEnumerable stringValues) + { + foreach (var text in stringValues) + { + if (!string.IsNullOrWhiteSpace(text)) + { + yield return text; + } + } + + yield break; + } + + if (value is IEnumerable objectValues) + { + foreach (var objectValue in objectValues) + { + var text = objectValue?.ToString(); + if (!string.IsNullOrWhiteSpace(text)) + { + yield return text; + } + } + } + } + #endregion -} \ No newline at end of file +} diff --git a/allstarr/Controllers/JellyfinController.Spotify.cs b/allstarr/Controllers/JellyfinController.Spotify.cs index 429bb45..d531b07 100644 --- a/allstarr/Controllers/JellyfinController.Spotify.cs +++ b/allstarr/Controllers/JellyfinController.Spotify.cs @@ -14,7 +14,7 @@ public partial class JellyfinController /// /// Gets tracks for a Spotify playlist by matching missing tracks against external providers /// and merging with existing local tracks from Jellyfin. - /// + /// /// Supports two modes: /// 1. Direct Spotify API (new): Uses SpotifyPlaylistFetcher for ordered tracks with ISRC matching /// 2. Jellyfin Plugin (legacy): Uses MissingTrack data from Jellyfin Spotify Import plugin @@ -31,8 +31,7 @@ public partial class JellyfinController } // Spotify API not enabled or no ordered tracks - proxy through without modification - _logger.LogInformation( - "Spotify API not enabled or no tracks found, proxying playlist {PlaylistName} without modification", + _logger.LogDebug("Spotify API not enabled or no tracks found, proxying playlist {PlaylistName} without modification", spotifyPlaylistName); var endpoint = $"Playlists/{playlistId}/Items"; @@ -117,7 +116,7 @@ public partial class JellyfinController return null; // Fall back to legacy mode } - _logger.LogInformation("Using {Count} ordered matched tracks for {Playlist}", + _logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}", orderedTracks.Count, spotifyPlaylistName); // Get existing Jellyfin playlist items (RAW - don't convert!) @@ -142,7 +141,7 @@ public partial class JellyfinController playlistItemsUrl = $"{playlistItemsUrl}&{queryString}"; } - _logger.LogInformation("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}", + _logger.LogDebug("🔍 Fetching existing tracks from Jellyfin playlist {PlaylistId} with UserId {UserId}", playlistId, userId); var (existingTracksResponse, statusCode) = await _proxyService.GetJsonAsync( @@ -188,7 +187,7 @@ public partial class JellyfinController } } - _logger.LogInformation("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count); + _logger.LogDebug("✅ Found {Count} existing LOCAL tracks in Jellyfin playlist", jellyfinItems.Count); } else { @@ -247,6 +246,8 @@ public partial class JellyfinController { // Use the raw Jellyfin item (preserves ALL metadata including MediaSources!) var itemDict = JsonElementToDictionary(matchedJellyfinItem.Value); + ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId, spotifyTrack.AlbumId); + ApplySpotifyAddedAtDateCreated(itemDict, spotifyTrack.AddedAt); finalItems.Add(itemDict); usedJellyfinItems.Add(matchedKey); localUsedCount++; @@ -271,6 +272,9 @@ public partial class JellyfinController { // Found the full Jellyfin item - use it! var itemDict = JsonElementToDictionary(jellyfinItem); + ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId, + spotifyTrack.AlbumId); + ApplySpotifyAddedAtDateCreated(itemDict, spotifyTrack.AddedAt); finalItems.Add(itemDict); localUsedCount++; _logger.LogDebug("✅ Position #{Pos}: '{Title}' → LOCAL from cache (ID: {Id})", @@ -288,20 +292,11 @@ public partial class JellyfinController // External track or local track not found - convert Song to Jellyfin item format var externalItem = _responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong); - // Add Spotify ID to ProviderIds so lyrics can work - if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId)) - { - if (!externalItem.ContainsKey("ProviderIds")) - { - externalItem["ProviderIds"] = new Dictionary(); - } + // Enhance with additional Spotify metadata + ProviderIdsEnricher.EnsureSpotifyProviderIds(externalItem, spotifyTrack.SpotifyId, + spotifyTrack.AlbumId); - var providerIds = externalItem["ProviderIds"] as Dictionary; - if (providerIds != null && !providerIds.ContainsKey("Spotify")) - { - providerIds["Spotify"] = spotifyTrack.SpotifyId; - } - } + ApplySpotifyAddedAtDateCreated(externalItem, spotifyTrack.AddedAt); finalItems.Add(externalItem); externalUsedCount++; @@ -340,6 +335,18 @@ public partial class JellyfinController }); } + private static void ApplySpotifyAddedAtDateCreated( + Dictionary item, + DateTime? addedAt) + { + if (!addedAt.HasValue) + { + return; + } + + item["DateCreated"] = addedAt.Value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ"); + } + /// /// /// Copies an external track to the kept folder when favorited. @@ -424,14 +431,6 @@ public partial class JellyfinController var fileName = Path.GetFileName(sourceFilePath); var keptFilePath = Path.Combine(keptAlbumPath, fileName); - // Double-check in case of race condition (multiple favorite clicks) - if (System.IO.File.Exists(keptFilePath)) - { - _logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath); - await MarkTrackAsFavoritedAsync(itemId, song); - return; - } - // Create hard link instead of copying to save space // Both locations will point to the same file data on disk try @@ -451,22 +450,47 @@ public partial class JellyfinController if (process != null) { await process.WaitForExitAsync(); - _logger.LogDebug("✓ Created hard link to kept folder: {Path}", keptFilePath); + + // Check if link was created successfully + if (process.ExitCode != 0) + { + throw new IOException($"ln command failed with exit code {process.ExitCode}"); + } + + _logger.LogInformation("🔗 Created hard link: {Source} → {Destination}", sourceFilePath, keptFilePath); } } else { // Fall back to copy on Windows System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false); - _logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath); + _logger.LogInformation("📋 Copied track: {Source} → {Destination}", sourceFilePath, keptFilePath); } } + catch (IOException ex) when (ex.Message.Contains("already exists") || System.IO.File.Exists(keptFilePath)) + { + // Race condition - file was created by another request + _logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath); + await MarkTrackAsFavoritedAsync(itemId, song); + return; + } catch (Exception ex) { // Fall back to copy if hard link fails (e.g., different filesystems) _logger.LogWarning(ex, "Failed to create hard link, falling back to copy"); - System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false); - _logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath); + + try + { + System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false); + _logger.LogInformation("📋 Copied track (fallback): {Source} → {Destination}", sourceFilePath, keptFilePath); + } + catch (IOException copyEx) when (copyEx.Message.Contains("already exists") || System.IO.File.Exists(keptFilePath)) + { + // Race condition on copy fallback + _logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath); + await MarkTrackAsFavoritedAsync(itemId, song); + return; + } } // Also create hard link for cover art if it exists @@ -492,20 +516,35 @@ public partial class JellyfinController if (process != null) { await process.WaitForExitAsync(); - _logger.LogDebug("Created hard link for cover art"); + _logger.LogDebug("🔗 Created hard link for cover: {Source} → {Destination}", sourceCoverPath, keptCoverPath); } } else { System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false); - _logger.LogDebug("Copied cover art to kept folder"); + _logger.LogDebug("📋 Copied cover: {Source} → {Destination}", sourceCoverPath, keptCoverPath); } } - catch + catch (IOException ex) when (ex.Message.Contains("already exists") || System.IO.File.Exists(keptCoverPath)) + { + // Race condition - cover already exists + _logger.LogDebug("Cover art already exists (race condition)"); + } + catch (Exception ex) { // Fall back to copy if hard link fails - System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false); - _logger.LogDebug("Copied cover art to kept folder"); + _logger.LogDebug(ex, "Failed to create hard link for cover, falling back to copy"); + + try + { + System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false); + _logger.LogDebug("📋 Copied cover (fallback): {Source} → {Destination}", sourceCoverPath, keptCoverPath); + } + catch (IOException copyEx) when (copyEx.Message.Contains("already exists") || System.IO.File.Exists(keptCoverPath)) + { + // Race condition on copy fallback + _logger.LogDebug("Cover art already exists (race condition on copy)"); + } } } } @@ -558,7 +597,7 @@ public partial class JellyfinController await Task.WhenAll(downloadTasks); - _logger.LogInformation("✓ Finished downloading album: {Artist} - {Album}", album.Artist, album.Title); + _logger.LogInformation("Finished downloading album: {Artist} - {Album}", album.Artist, album.Title); } catch (Exception ex) { @@ -943,4 +982,4 @@ public partial class JellyfinController } #endregion -} \ No newline at end of file +} diff --git a/allstarr/Controllers/JellyfinController.cs b/allstarr/Controllers/JellyfinController.cs index 988d326..a479b92 100644 --- a/allstarr/Controllers/JellyfinController.cs +++ b/allstarr/Controllers/JellyfinController.cs @@ -16,6 +16,7 @@ using allstarr.Services.Lyrics; using allstarr.Services.Spotify; using allstarr.Services.Scrobbling; using allstarr.Services.Admin; +using allstarr.Services.SquidWTF; using allstarr.Filters; namespace allstarr.Controllers; @@ -134,52 +135,61 @@ public partial class JellyfinController : ControllerBase if (isExternal) { - return await GetExternalItem(provider!, type, externalId!); + return await GetExternalItem(provider!, type, externalId!, HttpContext.RequestAborted); } - // Proxy to Jellyfin - var (result, statusCode) = await _proxyService.GetItemAsync(itemId, Request.Headers); - + // Proxy to Jellyfin using the same route shape and query string the client sent. + var endpoint = !string.IsNullOrWhiteSpace(userId) + ? $"Users/{userId}/Items/{itemId}" + : $"Items/{itemId}"; + + if (Request.QueryString.HasValue) + { + endpoint = $"{endpoint}{Request.QueryString.Value}"; + } + + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + return HandleProxyResponse(result, statusCode); } /// /// Gets an external item (song, album, or artist). /// - private async Task GetExternalItem(string provider, string? type, string externalId) + private async Task GetExternalItem(string provider, string? type, string externalId, CancellationToken cancellationToken = default) { switch (type) { case "song": - var song = await _metadataService.GetSongAsync(provider, externalId); + var song = await _metadataService.GetSongAsync(provider, externalId, cancellationToken); if (song == null) return _responseBuilder.CreateError(404, "Song not found"); return _responseBuilder.CreateSongResponse(song); case "album": - var album = await _metadataService.GetAlbumAsync(provider, externalId); + var album = await _metadataService.GetAlbumAsync(provider, externalId, cancellationToken); if (album == null) return _responseBuilder.CreateError(404, "Album not found"); return _responseBuilder.CreateAlbumResponse(album); case "artist": - var artist = await _metadataService.GetArtistAsync(provider, externalId); + var artist = await _metadataService.GetArtistAsync(provider, externalId, cancellationToken); if (artist == null) return _responseBuilder.CreateError(404, "Artist not found"); - var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId); - + var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId, cancellationToken); + // Fill in artist info for albums foreach (var a in albums) { if (string.IsNullOrEmpty(a.Artist)) a.Artist = artist.Name; if (string.IsNullOrEmpty(a.ArtistId)) a.ArtistId = artist.Id; } - + return _responseBuilder.CreateArtistResponse(artist, albums); default: // Try song first, then album - var s = await _metadataService.GetSongAsync(provider, externalId); + var s = await _metadataService.GetSongAsync(provider, externalId, cancellationToken); if (s != null) return _responseBuilder.CreateSongResponse(s); - var alb = await _metadataService.GetAlbumAsync(provider, externalId); + var alb = await _metadataService.GetAlbumAsync(provider, externalId, cancellationToken); if (alb != null) return _responseBuilder.CreateAlbumResponse(alb); return _responseBuilder.CreateError(404, "Item not found"); @@ -189,45 +199,64 @@ public partial class JellyfinController : ControllerBase /// /// Gets child items for an external parent (album tracks or artist albums). /// - private async Task GetExternalChildItems(string provider, string type, string externalId, string? includeItemTypes) + private async Task GetExternalChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default) { var itemTypes = ParseItemTypes(includeItemTypes); + var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0; - _logger.LogDebug("GetExternalChildItems: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}", + _logger.LogDebug("GetExternalChildItems: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}", provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty())); - // Check if asking for audio (album tracks or artist songs) - if (itemTypes?.Contains("Audio") == true) + // Albums are track containers in Jellyfin clients; when ParentId points to an album, + // return tracks even if IncludeItemTypes is omitted. + if (type == "album" && (itemTypesUnspecified || itemTypes!.Contains("Audio", StringComparer.OrdinalIgnoreCase))) { - if (type == "album") + _logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId); + var album = await _metadataService.GetAlbumAsync(provider, externalId, cancellationToken); + if (album == null) { - _logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId); - var album = await _metadataService.GetAlbumAsync(provider, externalId); - if (album == null) - { - return _responseBuilder.CreateError(404, "Album not found"); - } - - return _responseBuilder.CreateItemsResponse(album.Songs); + return _responseBuilder.CreateError(404, "Album not found"); } - else if (type == "artist") + + var sortedAndPagedSongs = ApplySongSortAndPagingForCurrentRequest(album.Songs, out var totalRecordCount, out var startIndex); + var items = sortedAndPagedSongs.Select(_responseBuilder.ConvertSongToJellyfinItem).ToList(); + + return _responseBuilder.CreateJsonResponse(new + { + Items = items, + TotalRecordCount = totalRecordCount, + StartIndex = startIndex + }); + } + + // Check if asking for audio (artist songs) + if (itemTypes?.Contains("Audio", StringComparer.OrdinalIgnoreCase) == true) + { + if (type == "artist") { // For artist + Audio, fetch top tracks from the artist endpoint _logger.LogDebug("Fetching artist tracks for {Provider}/{ExternalId}", provider, externalId); - var tracks = await _metadataService.GetArtistTracksAsync(provider, externalId); + var tracks = await _metadataService.GetArtistTracksAsync(provider, externalId, cancellationToken); + + if (tracks == null) + { + _logger.LogWarning("No tracks found for artist {Provider}/{ExternalId}", provider, externalId); + return _responseBuilder.CreateItemsResponse(new List()); + } + _logger.LogDebug("Found {Count} tracks for artist", tracks.Count); return _responseBuilder.CreateItemsResponse(tracks); } } // Check if asking for albums (artist albums) - if (itemTypes?.Contains("MusicAlbum") == true || itemTypes == null) + if (itemTypes?.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) == true || itemTypesUnspecified) { if (type == "artist") { _logger.LogDebug("Fetching artist albums for {Provider}/{ExternalId}", provider, externalId); - var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId); - var artist = await _metadataService.GetArtistAsync(provider, externalId); + var albums = await _metadataService.GetArtistAlbumsAsync(provider, externalId, cancellationToken); + var artist = await _metadataService.GetArtistAsync(provider, externalId, cancellationToken); _logger.LogDebug("Found {Count} albums for artist {ArtistName}", albums.Count, artist?.Name ?? "unknown"); @@ -246,11 +275,83 @@ public partial class JellyfinController : ControllerBase } // Fallback: return empty result - _logger.LogWarning("Unhandled GetExternalChildItems request: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}", + _logger.LogWarning("Unhandled GetExternalChildItems request: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}", provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty())); return _responseBuilder.CreateItemsResponse(new List()); } - private async Task GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes) + + private List ApplySongSortAndPagingForCurrentRequest(IReadOnlyCollection songs, out int totalRecordCount, out int startIndex) + { + var sortBy = Request.Query["SortBy"].ToString(); + var sortOrder = Request.Query["SortOrder"].ToString(); + var descending = sortOrder.Equals("Descending", StringComparison.OrdinalIgnoreCase); + var sortFields = ParseSortFields(sortBy); + + var sortedSongs = songs.ToList(); + sortedSongs.Sort((left, right) => CompareSongs(left, right, sortFields, descending)); + + totalRecordCount = sortedSongs.Count; + startIndex = 0; + if (int.TryParse(Request.Query["StartIndex"], out var parsedStartIndex) && parsedStartIndex > 0) + { + startIndex = parsedStartIndex; + } + + if (int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit > 0) + { + return sortedSongs.Skip(startIndex).Take(parsedLimit).ToList(); + } + + return sortedSongs.Skip(startIndex).ToList(); + } + + private static int CompareSongs(Song left, Song right, IReadOnlyList sortFields, bool descending) + { + var effectiveSortFields = sortFields.Count > 0 + ? sortFields + : new[] { "ParentIndexNumber", "IndexNumber", "SortName" }; + + foreach (var field in effectiveSortFields) + { + var comparison = CompareSongsByField(left, right, field); + if (comparison == 0) + { + continue; + } + + return descending ? -comparison : comparison; + } + + return string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase); + } + + private static int CompareSongsByField(Song left, Song right, string field) + { + return field.ToLowerInvariant() switch + { + "parentindexnumber" => Nullable.Compare(left.DiscNumber, right.DiscNumber), + "indexnumber" => Nullable.Compare(left.Track, right.Track), + "sortname" => string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase), + "name" => string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase), + "datecreated" => Nullable.Compare(left.Year, right.Year), + "productionyear" => Nullable.Compare(left.Year, right.Year), + _ => 0 + }; + } + + private static List ParseSortFields(string sortBy) + { + if (string.IsNullOrWhiteSpace(sortBy)) + { + return new List(); + } + + return sortBy + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(field => !string.IsNullOrWhiteSpace(field)) + .ToList(); + } + private async Task GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default) { var itemTypes = ParseItemTypes(includeItemTypes); @@ -263,7 +364,7 @@ public partial class JellyfinController : ControllerBase // Search for playlists by this curator // Since we don't have a direct "get playlists by curator" method, we'll search for the curator name // and filter the results - var playlists = await _metadataService.SearchPlaylistsAsync(curatorName, 50); + var playlists = await _metadataService.SearchPlaylistsAsync(curatorName, 50, cancellationToken); // Filter to only playlists from this curator (case-insensitive match) var curatorPlaylists = playlists @@ -271,7 +372,7 @@ public partial class JellyfinController : ControllerBase p.CuratorName.Equals(curatorName, StringComparison.OrdinalIgnoreCase)) .ToList(); - _logger.LogInformation("Found {Count} playlists for curator '{CuratorName}'", curatorPlaylists.Count, curatorName); + _logger.LogDebug("Found {Count} playlists for curator '{CuratorName}'", curatorPlaylists.Count, curatorName); // Convert playlists to album items var albumItems = curatorPlaylists @@ -315,8 +416,19 @@ public partial class JellyfinController : ControllerBase _logger.LogDebug("Searching artists for: {Query}", cleanQuery); // Run local and external searches in parallel - var jellyfinTask = _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers); - var externalTask = _metadataService.SearchArtistsAsync(cleanQuery, limit); + var jellyfinTask = GetLocalArtistsResultForCurrentRequest(cleanQuery); + + // Use parallel metadata service if available (races providers), otherwise use primary + Task> externalTask; + if (_parallelMetadataService != null) + { + externalTask = _parallelMetadataService.SearchAllAsync(cleanQuery, 0, 0, limit, HttpContext.RequestAborted) + .ContinueWith(t => t.Result.Artists, HttpContext.RequestAborted); + } + else + { + externalTask = _metadataService.SearchArtistsAsync(cleanQuery, limit, HttpContext.RequestAborted); + } await Task.WhenAll(jellyfinTask, externalTask); @@ -353,15 +465,37 @@ public partial class JellyfinController : ControllerBase }); } - // No search term - just proxy to Jellyfin - var (result, statusCode) = await _proxyService.GetArtistsAsync(searchTerm, limit, startIndex, Request.Headers); - - return HandleProxyResponse(result, statusCode, new + // No search term - proxy the literal request route and query string to Jellyfin + var endpoint = Request.Path.Value?.TrimStart('/') ?? "Artists"; + if (Request.QueryString.HasValue) { - Items = Array.Empty(), - TotalRecordCount = 0, - StartIndex = startIndex - }); + endpoint = $"{endpoint}{Request.QueryString.Value}"; + } + + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + + return HandleProxyResponse(result, statusCode); + } + + private async Task<(JsonDocument? Body, int StatusCode)> GetLocalArtistsResultForCurrentRequest(string cleanQuery) + { + var endpoint = Request.Path.Value?.TrimStart('/') ?? "Artists"; + + var queryParams = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var kvp in Request.Query) + { + queryParams[kvp.Key] = kvp.Value.ToString(); + } + + // Preserve literal request semantics, only normalize recovered SearchTerm. + queryParams["SearchTerm"] = cleanQuery; + + _logger.LogInformation( + "SEARCH TRACE: local artists proxy request endpoint='{Endpoint}' query='{SafeQuery}'", + endpoint, + ToSafeQueryStringForLogs(queryParams)); + + return await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers); } /// @@ -413,12 +547,12 @@ public partial class JellyfinController : ControllerBase // Filter to just this artist's albums var artistAlbums = localAlbums - .Where(a => a.ArtistId == localArtistId || + .Where(a => a.ArtistId == localArtistId || (a.Artist?.Equals(artistName, StringComparison.OrdinalIgnoreCase) ?? false)) .ToList(); // Search for external albums by this artist - var externalArtists = await _metadataService.SearchArtistsAsync(artistName, 1); + var externalArtists = await _metadataService.SearchArtistsAsync(artistName, 1, HttpContext.RequestAborted); var externalAlbums = new List(); if (externalArtists.Count > 0) @@ -426,7 +560,7 @@ public partial class JellyfinController : ControllerBase var extArtist = externalArtists[0]; if (extArtist.Name.Equals(artistName, StringComparison.OrdinalIgnoreCase)) { - externalAlbums = await _metadataService.GetArtistAlbumsAsync("deezer", extArtist.ExternalId!); + externalAlbums = await _metadataService.GetArtistAlbumsAsync("deezer", extArtist.ExternalId!, HttpContext.RequestAborted); // Set artist info to local artist so albums link back correctly foreach (var a in externalAlbums) @@ -459,7 +593,8 @@ public partial class JellyfinController : ControllerBase string imageType, int imageIndex = 0, [FromQuery] int? maxWidth = null, - [FromQuery] int? maxHeight = null) + [FromQuery] int? maxHeight = null, + [FromQuery(Name = "tag")] string? tag = null) { if (string.IsNullOrWhiteSpace(itemId)) { @@ -481,18 +616,19 @@ public partial class JellyfinController : ControllerBase itemId, imageType, maxWidth, - maxHeight); - + maxHeight, + tag); + if (imageBytes == null || contentType == null) { // Try to get the item details to find fallback image (album/parent) var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers); - + if (itemResult != null && itemStatus == 200) { var item = itemResult.RootElement; string? fallbackItemId = null; - + // Check for album image fallback (for songs) if (item.TryGetProperty("AlbumId", out var albumIdProp)) { @@ -503,30 +639,30 @@ public partial class JellyfinController : ControllerBase { fallbackItemId = parentIdProp.GetString(); } - + // Try to fetch the fallback image if (!string.IsNullOrEmpty(fallbackItemId)) { - _logger.LogDebug("Item {ItemId} has no {ImageType} image, trying fallback from {FallbackId}", + _logger.LogDebug("Item {ItemId} has no {ImageType} image, trying fallback from {FallbackId}", itemId, imageType, fallbackItemId); - + var (fallbackBytes, fallbackContentType) = await _proxyService.GetImageAsync( fallbackItemId, imageType, maxWidth, maxHeight); - + if (fallbackBytes != null && fallbackContentType != null) { return File(fallbackBytes, fallbackContentType); } } } - + // Return placeholder if no fallback found return await GetPlaceholderImageAsync(); } - + return File(imageBytes, contentType); } @@ -539,7 +675,8 @@ public partial class JellyfinController : ControllerBase _ => null }; - _logger.LogDebug("External {Type} {Provider}/{ExternalId} coverUrl: {CoverUrl}", type, provider, externalId, coverUrl ?? "NULL"); + _logger.LogDebug("External {Type} {Provider}/{ExternalId} has cover URL: {HasCoverUrl}", + type, provider, externalId, !string.IsNullOrEmpty(coverUrl)); if (string.IsNullOrEmpty(coverUrl)) { @@ -548,41 +685,57 @@ public partial class JellyfinController : ControllerBase return await GetPlaceholderImageAsync(); } + if (!OutboundRequestGuard.TryCreateSafeHttpUri(coverUrl, out var validatedCoverUri, out var validationReason) || + validatedCoverUri == null) + { + _logger.LogWarning( + "Blocked external image URL for {Type} {Provider}/{ExternalId}: {Reason}", + type, + provider, + externalId, + validationReason); + return await GetPlaceholderImageAsync(); + } + + var safeCoverUri = validatedCoverUri!; + // Fetch and return the image using the proxy service's HttpClient try { - _logger.LogDebug("Fetching external image from {Url}", coverUrl); - + _logger.LogDebug("Fetching external image from host {Host}", safeCoverUri.Host); + var imageBytes = await RetryHelper.RetryWithBackoffAsync(async () => { - var response = await _proxyService.HttpClient.GetAsync(coverUrl); - + var response = await _proxyService.HttpClient.GetAsync(safeCoverUri); + if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests || response.StatusCode == System.Net.HttpStatusCode.ServiceUnavailable) { throw new HttpRequestException($"Transient error: {response.StatusCode}", null, response.StatusCode); } - + if (!response.IsSuccessStatusCode) { - _logger.LogWarning("Failed to fetch external image from {Url}: {StatusCode}", coverUrl, response.StatusCode); + _logger.LogWarning("Failed to fetch external image from host {Host}: {StatusCode}", + safeCoverUri.Host, response.StatusCode); return null; } - + return await response.Content.ReadAsByteArrayAsync(); }, _logger, maxRetries: 3, initialDelayMs: 500); - + if (imageBytes == null) { return await GetPlaceholderImageAsync(); } - - _logger.LogDebug("Successfully fetched external image from {Url}, size: {Size} bytes", coverUrl, imageBytes.Length); + + _logger.LogDebug("Successfully fetched external image from host {Host}, size: {Size} bytes", + safeCoverUri.Host, imageBytes.Length); return File(imageBytes, "image/jpeg"); } catch (Exception ex) { - _logger.LogError(ex, "Failed to fetch cover art from {Url}", coverUrl); + _logger.LogError(ex, "Failed to fetch cover art from host {Host}", safeCoverUri.Host); // Return placeholder on exception return await GetPlaceholderImageAsync(); } @@ -602,12 +755,12 @@ public partial class JellyfinController : ControllerBase var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath); return File(imageBytes, "image/png"); } - + // Fallback: Return a 1x1 transparent PNG as minimal placeholder var transparentPng = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" ); - + return File(transparentPng, "image/png"); } @@ -628,10 +781,10 @@ public partial class JellyfinController : ControllerBase { userId = Request.Query["userId"].ToString(); } - - _logger.LogDebug("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}", + + _logger.LogDebug("MarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}", userId, itemId, Request.Path); - + // Check if this is an external playlist - trigger download if (PlaylistIdHelper.IsExternalPlaylist(itemId)) { @@ -656,8 +809,8 @@ public partial class JellyfinController : ControllerBase }); // Return a minimal UserItemDataDto response - return Ok(new - { + return Ok(new + { IsFavorite = true, ItemId = itemId }); @@ -669,11 +822,11 @@ public partial class JellyfinController : ControllerBase { // Check if it's an album by parsing the full ID with type var (_, _, type, _) = _localLibraryService.ParseExternalId(itemId); - + if (type == "album") { _logger.LogInformation("Favoriting external album {ItemId}, downloading all tracks to kept folder", itemId); - + // Download entire album to kept folder in background _ = Task.Run(async () => { @@ -690,7 +843,7 @@ public partial class JellyfinController : ControllerBase else { _logger.LogInformation("Favoriting external track {ItemId}, copying to kept folder", itemId); - + // Copy the track to kept folder in background _ = Task.Run(async () => { @@ -704,10 +857,10 @@ public partial class JellyfinController : ControllerBase } }); } - + // Return a minimal UserItemDataDto response - return Ok(new - { + return Ok(new + { IsFavorite = true, ItemId = itemId }); @@ -720,11 +873,11 @@ public partial class JellyfinController : ControllerBase { endpoint = $"{endpoint}?userId={userId}"; } - + _logger.LogDebug("Proxying favorite request to Jellyfin: {Endpoint}", endpoint); - + var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, "{}", Request.Headers); - + return HandleProxyResponse(result, statusCode); } @@ -741,16 +894,16 @@ public partial class JellyfinController : ControllerBase { userId = Request.Query["userId"].ToString(); } - - _logger.LogDebug("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}", + + _logger.LogDebug("UnmarkFavorite called: userId={UserId}, itemId={ItemId}, route={Route}", userId, itemId, Request.Path); - + // External items - remove from kept folder if it exists var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); if (isExternal || PlaylistIdHelper.IsExternalPlaylist(itemId)) { _logger.LogInformation("Unfavoriting external item {ItemId} - removing from kept folder", itemId); - + // Remove from kept folder in background _ = Task.Run(async () => { @@ -763,9 +916,9 @@ public partial class JellyfinController : ControllerBase _logger.LogError(ex, "Failed to remove external track {ItemId} from kept folder", itemId); } }); - - return Ok(new - { + + return Ok(new + { IsFavorite = false, ItemId = itemId }); @@ -778,16 +931,12 @@ public partial class JellyfinController : ControllerBase { endpoint = $"{endpoint}?userId={userId}"; } - + _logger.LogDebug("Proxying unfavorite request to Jellyfin: {Endpoint}", endpoint); - + var (result, statusCode) = await _proxyService.DeleteAsync(endpoint, Request.Headers); - - return HandleProxyResponse(result, statusCode, new - { - IsFavorite = false, - ItemId = itemId - }); + + return HandleProxyResponse(result, statusCode); } #endregion @@ -808,8 +957,12 @@ public partial class JellyfinController : ControllerBase [FromQuery] string? userId = null) { var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); - - if (isExternal) + var isRawSquidTrackId = !isExternal && long.TryParse(itemId, out _); + var squidTrackId = provider?.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) == true + ? externalId + : (isRawSquidTrackId ? itemId : null); + + if (isExternal || !string.IsNullOrWhiteSpace(squidTrackId)) { // Check if this is an artist if (itemId.Contains("-artist-", StringComparison.OrdinalIgnoreCase)) @@ -822,9 +975,42 @@ public partial class JellyfinController : ControllerBase TotalRecordCount = 0 }); } - + try { + if (!string.IsNullOrWhiteSpace(squidTrackId) && + _metadataService is SquidWTFMetadataService squidWtfMetadataService) + { + var recommendations = await squidWtfMetadataService + .GetTrackRecommendationsAsync(squidTrackId, limit, HttpContext.RequestAborted); + + var recommendedItems = recommendations + .Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)) + .ToList(); + + _logger.LogInformation( + "SQUIDWTF similar lookup: itemId={ItemId}, trackId={TrackId}, recommendations={Count}", + itemId, + squidTrackId, + recommendedItems.Count); + + return _responseBuilder.CreateJsonResponse(new + { + Items = recommendedItems, + TotalRecordCount = recommendedItems.Count + }); + } + + if (!isExternal) + { + _logger.LogDebug("Similar lookup skipped for non-external item {ItemId}", itemId); + return _responseBuilder.CreateJsonResponse(new + { + Items = Array.Empty(), + TotalRecordCount = 0 + }); + } + // Get the original song to find similar content var song = await _metadataService.GetSongAsync(provider!, externalId!); if (song == null) @@ -839,10 +1025,11 @@ public partial class JellyfinController : ControllerBase // Search for similar songs using artist and genre var searchQuery = $"{song.Artist}"; var searchResult = await _metadataService.SearchSongsAsync(searchQuery, limit); - + // Filter out the original song and convert to Jellyfin format var similarSongs = searchResult - .Where(s => s.Id != itemId) + .Where(s => !string.Equals(s.ExternalId, externalId, StringComparison.OrdinalIgnoreCase) + && !string.Equals(s.Id, itemId, StringComparison.OrdinalIgnoreCase)) .Take(limit) .Select(s => _responseBuilder.ConvertSongToJellyfinItem(s)) .ToList(); @@ -863,30 +1050,21 @@ public partial class JellyfinController : ControllerBase }); } } - + // For local items, determine the correct endpoint based on the request path var endpoint = Request.Path.Value?.Contains("/Artists/", StringComparison.OrdinalIgnoreCase) == true ? $"Artists/{itemId}/Similar" : $"Items/{itemId}/Similar"; - - var queryParams = new Dictionary + + // Preserve full client query string to keep Jellyfin behavior consistent for all supported params + if (Request.QueryString.HasValue) { - ["limit"] = limit.ToString() - }; - - if (!string.IsNullOrEmpty(fields)) - { - queryParams["fields"] = fields; - } - - if (!string.IsNullOrEmpty(userId)) - { - queryParams["userId"] = userId; + endpoint = $"{endpoint}{Request.QueryString.Value}"; } - var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers); - - return HandleProxyResponse(result, statusCode, new { Items = Array.Empty(), TotalRecordCount = 0 }); + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + + return HandleProxyResponse(result, statusCode); } /// @@ -902,7 +1080,7 @@ public partial class JellyfinController : ControllerBase [FromQuery] string? userId = null) { var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId); - + if (isExternal) { try @@ -920,13 +1098,13 @@ public partial class JellyfinController : ControllerBase // Get artist's albums to build a mix var mixSongs = new List(); - + // Try to get artist albums if (!string.IsNullOrEmpty(song.ExternalProvider) && !string.IsNullOrEmpty(song.ArtistId)) { var artistExternalId = song.ArtistId.Replace($"ext-{song.ExternalProvider}-artist-", ""); var albums = await _metadataService.GetArtistAlbumsAsync(song.ExternalProvider, artistExternalId); - + // Get songs from a few albums foreach (var album in albums.Take(3)) { @@ -935,11 +1113,11 @@ public partial class JellyfinController : ControllerBase { mixSongs.AddRange(fullAlbum.Songs); } - + if (mixSongs.Count >= limit) break; } } - + // If we don't have enough songs, search for more by the artist if (mixSongs.Count < limit) { @@ -972,26 +1150,20 @@ public partial class JellyfinController : ControllerBase }); } } - - // For local items, proxy to Jellyfin - var queryParams = new Dictionary + + // For local items, proxy using the same route shape and full query string from the client + var endpoint = Request.Path.Value?.Contains("/Items/", StringComparison.OrdinalIgnoreCase) == true + ? $"Items/{itemId}/InstantMix" + : $"Songs/{itemId}/InstantMix"; + + if (Request.QueryString.HasValue) { - ["limit"] = limit.ToString() - }; - - if (!string.IsNullOrEmpty(fields)) - { - queryParams["fields"] = fields; - } - - if (!string.IsNullOrEmpty(userId)) - { - queryParams["userId"] = userId; + endpoint = $"{endpoint}{Request.QueryString.Value}"; } - var (result, statusCode) = await _proxyService.GetJsonAsync($"Songs/{itemId}/InstantMix", queryParams, Request.Headers); - - return HandleProxyResponse(result, statusCode, new { Items = Array.Empty(), TotalRecordCount = 0 }); + var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers); + + return HandleProxyResponse(result, statusCode); } #endregion @@ -1042,21 +1214,22 @@ public partial class JellyfinController : ControllerBase _logger.LogWarning("Admin route {Path} reached ProxyRequest - this should be handled by admin controllers", path); return NotFound(new { error = "Admin endpoint not found" }); } - + // Log session-related requests prominently to debug missing capabilities call - if (path.Contains("session", StringComparison.OrdinalIgnoreCase) || + if (path.Contains("session", StringComparison.OrdinalIgnoreCase) || path.Contains("capabilit", StringComparison.OrdinalIgnoreCase)) { - _logger.LogDebug("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, Request.QueryString); + _logger.LogDebug("🔍 SESSION/CAPABILITY REQUEST: {Method} /{Path}{Query}", Request.Method, path, + MaskSensitiveQueryString(Request.QueryString.Value)); } else { _logger.LogDebug("ProxyRequest: {Method} /{Path}", Request.Method, path); } - + // Log endpoint usage to file for analysis await LogEndpointUsageAsync(path, Request.Method); - + // Block dangerous admin endpoints var blockedPrefixes = new[] { @@ -1075,24 +1248,24 @@ public partial class JellyfinController : ControllerBase "displaypreferences/", // Display preferences (if not user-specific) "notifications/admin" // Admin notifications }; - + // Check if path matches any blocked prefix - if (blockedPrefixes.Any(prefix => + if (blockedPrefixes.Any(prefix => path.StartsWith(prefix, StringComparison.OrdinalIgnoreCase))) { - _logger.LogWarning("BLOCKED: Access denied to admin endpoint: {Path} from {IP}", - path, + _logger.LogWarning("BLOCKED: Access denied to admin endpoint: {Path} from {IP}", + path, HttpContext.Connection.RemoteIpAddress); - return StatusCode(403, new - { + 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) && + if (_spotifySettings.Enabled && + path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) && path.Contains("/items", StringComparison.OrdinalIgnoreCase)) { // Extract playlist ID from path: playlists/{id}/items @@ -1100,13 +1273,13 @@ public partial class JellyfinController : ControllerBase if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase)) { var playlistId = parts[1]; - + _logger.LogDebug("=== PLAYLIST REQUEST ==="); _logger.LogInformation("Playlist ID: {PlaylistId}", playlistId); _logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled); _logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}"))); _logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId)); - + // Check if this playlist ID is configured for Spotify injection if (_spotifySettings.IsSpotifyPlaylist(playlistId)) { @@ -1118,10 +1291,10 @@ public partial class JellyfinController : ControllerBase } } } - + // Handle non-JSON responses (images, robots.txt, etc.) if (path.Contains("/Images/", StringComparison.OrdinalIgnoreCase) || - path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) || + path.EndsWith(".txt", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".jpg", StringComparison.OrdinalIgnoreCase) || path.EndsWith(".jpeg", StringComparison.OrdinalIgnoreCase) || @@ -1137,24 +1310,24 @@ public partial class JellyfinController : ControllerBase { fullPath = $"{path}{Request.QueryString.Value}"; } - + var url = $"{_settings.Url?.TrimEnd('/')}/{fullPath}"; - + try { // Forward authentication headers for image requests using var request = new HttpRequestMessage(HttpMethod.Get, url); - + // Forward auth headers from client AuthHeaderHelper.ForwardAuthHeaders(Request.Headers, request); - + var response = await _proxyService.HttpClient.SendAsync(request); - + if (!response.IsSuccessStatusCode) { return StatusCode((int)response.StatusCode); } - + var contentBytes = await response.Content.ReadAsByteArrayAsync(); var contentType = response.Content.Headers.ContentType?.ToString() ?? "application/octet-stream"; return File(contentBytes, contentType); @@ -1168,11 +1341,11 @@ public partial class JellyfinController : ControllerBase // Check if this is a search request that should be handled by specific endpoints var searchTerm = Request.Query["SearchTerm"].ToString(); - + if (!string.IsNullOrWhiteSpace(searchTerm)) { _logger.LogDebug("ProxyRequest intercepting search request: Path={Path}, SearchTerm={SearchTerm}", path, searchTerm); - + // Item search: /users/{userId}/items or /items if (path.EndsWith("/items", StringComparison.OrdinalIgnoreCase) || path.Equals("items", StringComparison.OrdinalIgnoreCase)) { @@ -1187,7 +1360,7 @@ public partial class JellyfinController : ControllerBase recursive: Request.Query["Recursive"].ToString().Equals("true", StringComparison.OrdinalIgnoreCase), userId: path.Contains("/users/", StringComparison.OrdinalIgnoreCase) && path.Split('/').Length > 2 ? path.Split('/')[2] : null); } - + // Artist search: /artists/albumartists or /artists if (path.Contains("/artists", StringComparison.OrdinalIgnoreCase)) { @@ -1198,59 +1371,52 @@ public partial class JellyfinController : ControllerBase startIndex: int.TryParse(Request.Query["StartIndex"], out var start) ? start : 0); } } - + try { // Include query string in the path var fullPath = path; + var safePathForLogs = path; if (Request.QueryString.HasValue) { fullPath = $"{path}{Request.QueryString.Value}"; + safePathForLogs = $"{path}{MaskSensitiveQueryString(Request.QueryString.Value)}"; } - + JsonDocument? result; int statusCode; - + if (HttpContext.Request.Method == HttpMethod.Post.Method) { // Enable buffering BEFORE any reads Request.EnableBuffering(); - + // Log request details for debugging - _logger.LogDebug("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}", - fullPath, Request.Method, Request.ContentType, Request.ContentLength); - + _logger.LogDebug("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}", + safePathForLogs, Request.Method, Request.ContentType, Request.ContentLength); + // Read body using StreamReader with proper encoding string body; using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true)) { body = await reader.ReadToEndAsync(); } - + // Reset stream position after reading so it can be read again if needed Request.Body.Position = 0; - + if (string.IsNullOrWhiteSpace(body)) { - _logger.LogWarning("Empty POST body received from client for {Path}, ContentLength={ContentLength}, ContentType={ContentType}", - fullPath, Request.ContentLength, Request.ContentType); - - // Log all headers to debug - _logger.LogWarning("Request headers: {Headers}", - string.Join(", ", Request.Headers.Select(h => $"{h.Key}={h.Value}"))); + _logger.LogWarning("Empty POST body received from client for {Path}, ContentLength={ContentLength}, ContentType={ContentType}", + safePathForLogs, Request.ContentLength, Request.ContentType); + _logger.LogWarning("Empty POST body metadata: HeaderCount={HeaderCount}", Request.Headers.Count); } else { - _logger.LogDebug("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}", - fullPath, body.Length, Request.ContentType); - - // Always log body content for playback endpoints to debug the issue - if (fullPath.Contains("Playing", StringComparison.OrdinalIgnoreCase)) - { - _logger.LogInformation("POST body content from client: {Body}", body); - } + _logger.LogDebug("POST body received from client for {Path}: {BodyLength} bytes, ContentType={ContentType}", + safePathForLogs, body.Length, Request.ContentType); } - + (result, statusCode) = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers); } else @@ -1258,7 +1424,7 @@ public partial class JellyfinController : ControllerBase // Forward GET requests transparently with authentication headers and query string (result, statusCode) = await _proxyService.GetJsonAsync(fullPath, null, Request.Headers); } - + // Handle different status codes if (result == null) { @@ -1287,16 +1453,14 @@ public partial class JellyfinController : ControllerBase { return StatusCode(statusCode); } - + // Default to 204 for 2xx responses with no body return NoContent(); } // Modify response if it contains Spotify playlists to update ChildCount // Only check for Items if the response is an object (not a string or array) - if (_spotifySettings.Enabled && - result.RootElement.ValueKind == JsonValueKind.Object && - result.RootElement.TryGetProperty("Items", out var items)) + if (ShouldProcessSpotifyPlaylistCounts(result, Request.Query["IncludeItemTypes"].ToString())) { _logger.LogDebug("Response has Items property, checking for Spotify playlists to update counts"); result = await UpdateSpotifyPlaylistCounts(result); @@ -1305,15 +1469,57 @@ public partial class JellyfinController : ControllerBase // Return the raw JSON element directly to avoid deserialization issues with simple types return new JsonResult(result.RootElement.Clone()); } + catch (HttpRequestException httpEx) + { + // HTTP-specific errors - preserve the status code if available + var statusCode = httpEx.StatusCode.HasValue ? (int)httpEx.StatusCode.Value : 502; + + _logger.LogError(httpEx, "HTTP error proxying request to Jellyfin for {Path}: {StatusCode}", path, statusCode); + + // Return appropriate status code based on the error + if (statusCode == 404) + { + return NotFound(); + } + else if (statusCode >= 400 && statusCode < 500) + { + return StatusCode(statusCode, new { error = $"Jellyfin returned {statusCode}" }); + } + else + { + return StatusCode(502, new { error = "Failed to connect to Jellyfin server" }); + } + } + catch (TaskCanceledException) + { + // Request was cancelled (timeout or client disconnect) + _logger.LogWarning("Proxy request cancelled or timed out for {Path}", path); + return StatusCode(504, new { error = "Request to Jellyfin timed out" }); + } catch (Exception ex) { + // Generic error - return 502 Bad Gateway _logger.LogError(ex, "Proxy request failed for {Path}", path); - return _responseBuilder.CreateError(502, $"Proxy error: {ex.Message}"); + return _responseBuilder.CreateError(502, "Proxy error"); } } #endregion + /// + /// Checks if an item dictionary represents a local Jellyfin item (not external). + /// + private bool IsLocalItem(Dictionary item) + { + if (!item.TryGetValue("Id", out var idObj)) return false; + + var id = idObj is JsonElement idEl ? idEl.GetString() : idObj?.ToString(); + if (string.IsNullOrEmpty(id)) return false; + + // External items have IDs starting with "ext-" + return !id.StartsWith("ext-", StringComparison.OrdinalIgnoreCase); + } + /// /// Converts a JsonElement to a Dictionary while properly preserving nested objects and arrays. /// This prevents metadata from being stripped when deserializing Jellyfin responses. @@ -1321,12 +1527,12 @@ public partial class JellyfinController : ControllerBase private Dictionary JsonElementToDictionary(JsonElement element) { var dict = new Dictionary(); - + foreach (var property in element.EnumerateObject()) { dict[property.Name] = ConvertJsonElement(property.Value); } - + return dict; } @@ -1344,7 +1550,7 @@ public partial class JellyfinController : ControllerBase dict[property.Name] = ConvertJsonElement(property.Value); } return dict; - + case JsonValueKind.Array: var list = new List(); foreach (var item in element.EnumerateArray()) @@ -1352,10 +1558,10 @@ public partial class JellyfinController : ControllerBase list.Add(ConvertJsonElement(item)); } return list; - + case JsonValueKind.String: return element.GetString(); - + case JsonValueKind.Number: if (element.TryGetInt32(out var intValue)) return intValue; @@ -1364,16 +1570,16 @@ public partial class JellyfinController : ControllerBase if (element.TryGetDouble(out var doubleValue)) return doubleValue; return element.GetDecimal(); - + case JsonValueKind.True: return true; - + case JsonValueKind.False: return false; - + case JsonValueKind.Null: return null; - + default: return null; } @@ -1388,7 +1594,7 @@ public partial class JellyfinController : ControllerBase string? client = null; string? device = null; string? version = null; - + // Check X-Emby-Authorization FIRST (most Jellyfin clients use this) // Then fall back to Authorization header string? authStr = null; @@ -1400,7 +1606,7 @@ public partial class JellyfinController : ControllerBase { authStr = authHeader.ToString(); } - + if (!string.IsNullOrEmpty(authStr)) { // Parse: MediaBrowser Client="...", Device="...", DeviceId="...", Version="..." @@ -1419,10 +1625,10 @@ public partial class JellyfinController : ControllerBase } } } - + return (deviceId, client, device, version); } - + /// /// Generates a deterministic UUID (v5) from a string. /// This allows us to create consistent UUIDs for external track IDs. @@ -1432,15 +1638,15 @@ public partial class JellyfinController : ControllerBase // Use MD5 hash to generate a deterministic UUID using var md5 = System.Security.Cryptography.MD5.Create(); var hash = md5.ComputeHash(System.Text.Encoding.UTF8.GetBytes(input)); - + // Convert to UUID format (version 5, namespace-based) hash[6] = (byte)((hash[6] & 0x0F) | 0x50); // Version 5 hash[8] = (byte)((hash[8] & 0x3F) | 0x80); // Variant - + var guid = new Guid(hash); return guid.ToString(); } - + /// /// Finds the Spotify ID for an external track by searching through all playlist matched tracks caches. /// This allows us to get Spotify lyrics for external tracks that were matched from Spotify playlists. @@ -1451,31 +1657,31 @@ public partial class JellyfinController : ControllerBase { // Get all configured playlists var playlists = _spotifySettings.Playlists; - + // Search through each playlist's matched tracks cache foreach (var playlist in playlists) { var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name); var matchedTracks = await _cache.GetAsync>(cacheKey); - + if (matchedTracks == null || matchedTracks.Count == 0) continue; - + // Look for a match by external ID - var match = matchedTracks.FirstOrDefault(t => - t.MatchedSong != null && + var match = matchedTracks.FirstOrDefault(t => + t.MatchedSong != null && t.MatchedSong.ExternalProvider == externalSong.ExternalProvider && t.MatchedSong.ExternalId == externalSong.ExternalId); - + if (match != null && !string.IsNullOrEmpty(match.SpotifyId)) { - _logger.LogDebug("Found Spotify ID {SpotifyId} for {Provider}/{ExternalId} in playlist {Playlist}", + _logger.LogDebug("Found Spotify ID {SpotifyId} for {Provider}/{ExternalId} in playlist {Playlist}", match.SpotifyId, externalSong.ExternalProvider, externalSong.ExternalId, playlist.Name); return match.SpotifyId; } } - - _logger.LogDebug("No Spotify ID found for external track {Provider}/{ExternalId}", + + _logger.LogDebug("No Spotify ID found for external track {Provider}/{ExternalId}", externalSong.ExternalProvider, externalSong.ExternalId); return null; } diff --git a/allstarr/Controllers/LyricsController.cs b/allstarr/Controllers/LyricsController.cs index f8bfb03..0c8f3af 100644 --- a/allstarr/Controllers/LyricsController.cs +++ b/allstarr/Controllers/LyricsController.cs @@ -33,7 +33,7 @@ public class LyricsController : ControllerBase _serviceProvider = serviceProvider; } - + /// /// Save manual lyrics ID mapping for a track /// @@ -44,24 +44,24 @@ public class LyricsController : ControllerBase { return BadRequest(new { error = "Artist and Title are required" }); } - + if (request.LyricsId <= 0) { return BadRequest(new { error = "Valid LyricsId is required" }); } - + try { // Store lyrics mapping in cache (NO EXPIRATION - manual mappings are permanent) var mappingKey = $"lyrics:manual-map:{request.Artist}:{request.Title}"; await _cache.SetStringAsync(mappingKey, request.LyricsId.ToString()); - + // Also save to file for persistence across restarts await _adminHelper.SaveLyricsMappingToFileAsync(request.Artist, request.Title, request.Album ?? "", request.DurationSeconds, request.LyricsId); - - _logger.LogInformation("Manual lyrics mapping saved: {Artist} - {Title} → Lyrics ID {LyricsId}", + + _logger.LogInformation("Manual lyrics mapping saved: {Artist} - {Title} → Lyrics ID {LyricsId}", request.Artist, request.Title, request.LyricsId); - + // Optionally fetch and cache the lyrics immediately try { @@ -75,9 +75,9 @@ public class LyricsController : ControllerBase var lyricsCacheKey = $"lyrics:{request.Artist}:{request.Title}:{request.Album ?? ""}:{request.DurationSeconds}"; await _cache.SetAsync(lyricsCacheKey, lyricsInfo.PlainLyrics); _logger.LogDebug("✓ Fetched and cached lyrics for {Artist} - {Title}", request.Artist, request.Title); - - return Ok(new - { + + return Ok(new + { message = "Lyrics mapping saved and lyrics cached successfully", lyricsId = request.LyricsId, cached = true, @@ -98,9 +98,9 @@ public class LyricsController : ControllerBase { _logger.LogError(ex, "Failed to fetch lyrics after mapping, but mapping was saved"); } - - return Ok(new - { + + return Ok(new + { message = "Lyrics mapping saved successfully", lyricsId = request.LyricsId, cached = false @@ -112,7 +112,7 @@ public class LyricsController : ControllerBase return StatusCode(500, new { error = "Failed to save lyrics mapping" }); } } - + /// /// Get manual lyrics mappings /// @@ -122,15 +122,15 @@ public class LyricsController : ControllerBase try { var mappingsFile = "/app/cache/lyrics_mappings.json"; - + if (!System.IO.File.Exists(mappingsFile)) { return Ok(new { mappings = new List() }); } - + var json = await System.IO.File.ReadAllTextAsync(mappingsFile); var mappings = JsonSerializer.Deserialize>(json) ?? new List(); - + return Ok(new { mappings }); } catch (Exception ex) @@ -139,9 +139,9 @@ public class LyricsController : ControllerBase return StatusCode(500, new { error = "Failed to get lyrics mappings" }); } } - - - + + + /// /// Test Spotify lyrics API by fetching lyrics for a specific Spotify track ID /// Example: GET /api/admin/lyrics/spotify/test?trackId=3yII7UwgLF6K5zW3xad3MP @@ -153,30 +153,30 @@ public class LyricsController : ControllerBase { return BadRequest(new { error = "trackId parameter is required" }); } - + try { var spotifyLyricsService = _serviceProvider.GetService(); - + if (spotifyLyricsService == null) { return StatusCode(500, new { error = "Spotify lyrics service not available" }); } - + _logger.LogInformation("Testing Spotify lyrics for track ID: {TrackId}", trackId); - + var result = await spotifyLyricsService.GetLyricsByTrackIdAsync(trackId); - + if (result == null) { - return NotFound(new - { + return NotFound(new + { error = "No lyrics found", trackId, message = "Lyrics may not be available for this track, or the Spotify API is not configured correctly" }); } - + return Ok(new { success = true, @@ -206,10 +206,10 @@ public class LyricsController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Failed to test Spotify lyrics for track {TrackId}", trackId); - return StatusCode(500, new { error = $"Failed to fetch lyrics: {ex.Message}" }); + return StatusCode(500, new { error = "Failed to fetch lyrics" }); } } - + /// /// Prefetch lyrics for a specific playlist /// @@ -217,22 +217,22 @@ public class LyricsController : ControllerBase public async Task PrefetchPlaylistLyrics(string name) { var decodedName = Uri.UnescapeDataString(name); - + try { var lyricsPrefetchService = _serviceProvider.GetService(); - + if (lyricsPrefetchService == null) { return StatusCode(500, new { error = "Lyrics prefetch service not available" }); } - + _logger.LogInformation("Starting lyrics prefetch for playlist: {Playlist}", decodedName); - + var (fetched, cached, missing) = await lyricsPrefetchService.PrefetchPlaylistLyricsAsync( - decodedName, + decodedName, HttpContext.RequestAborted); - + return Ok(new { message = "Lyrics prefetch complete", @@ -246,12 +246,12 @@ public class LyricsController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Failed to prefetch lyrics for playlist {Playlist}", decodedName); - return StatusCode(500, new { error = $"Failed to prefetch lyrics: {ex.Message}" }); + return StatusCode(500, new { error = "Failed to prefetch lyrics" }); } } - - - + + + /// /// Invalidates the cached playlist summary so it will be regenerated on next request /// diff --git a/allstarr/Controllers/MappingController.cs b/allstarr/Controllers/MappingController.cs index b03feea..dd23563 100644 --- a/allstarr/Controllers/MappingController.cs +++ b/allstarr/Controllers/MappingController.cs @@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc; using allstarr.Models.Admin; using allstarr.Services.Common; using allstarr.Services.Admin; +using allstarr.Services.Spotify; using allstarr.Filters; using System.Text.Json; @@ -15,15 +16,18 @@ public class MappingController : ControllerBase private readonly ILogger _logger; private readonly RedisCacheService _cache; private readonly AdminHelperService _adminHelper; + private readonly SpotifyMappingService _mappingService; public MappingController( ILogger logger, RedisCacheService cache, - AdminHelperService adminHelper) + AdminHelperService adminHelper, + SpotifyMappingService mappingService) { _logger = logger; _cache = cache; _adminHelper = adminHelper; + _mappingService = mappingService; } @@ -144,6 +148,9 @@ public class MappingController : ControllerBase // Also remove from Redis cache var cacheKey = $"manual:mapping:{playlist}:{spotifyId}"; await _cache.DeleteAsync(cacheKey); + + // Keep global Spotify mapping index in sync as well. + await _mappingService.DeleteMappingAsync(spotifyId); return Ok(new { success = true, message = "Mapping deleted successfully" }); } diff --git a/allstarr/Controllers/PlaylistController.cs b/allstarr/Controllers/PlaylistController.cs index 5828757..4a92d36 100644 --- a/allstarr/Controllers/PlaylistController.cs +++ b/allstarr/Controllers/PlaylistController.cs @@ -18,7 +18,6 @@ namespace allstarr.Controllers; public class PlaylistController : ControllerBase { private readonly ILogger _logger; - private readonly IConfiguration _configuration; private readonly JellyfinSettings _jellyfinSettings; private readonly SpotifyImportSettings _spotifyImportSettings; private readonly SpotifyPlaylistFetcher _playlistFetcher; @@ -32,7 +31,6 @@ public class PlaylistController : ControllerBase public PlaylistController( ILogger logger, - IConfiguration configuration, IOptions jellyfinSettings, IOptions spotifyImportSettings, SpotifyPlaylistFetcher playlistFetcher, @@ -44,7 +42,6 @@ public class PlaylistController : ControllerBase SpotifyTrackMatchingService? matchingService = null) { _logger = logger; - _configuration = configuration; _jellyfinSettings = jellyfinSettings.Value; _spotifyImportSettings = spotifyImportSettings.Value; _playlistFetcher = playlistFetcher; @@ -60,7 +57,7 @@ public class PlaylistController : ControllerBase public async Task GetPlaylists([FromQuery] bool refresh = false) { var playlistCacheFile = "/app/cache/admin_playlists_summary.json"; - + // Check file cache first (5 minute TTL) unless refresh is requested if (!refresh && System.IO.File.Exists(playlistCacheFile)) { @@ -68,7 +65,7 @@ public class PlaylistController : ControllerBase { var fileInfo = new FileInfo(playlistCacheFile); var age = DateTime.UtcNow - fileInfo.LastWriteTimeUtc; - + if (age.TotalMinutes < 5) { var cachedJson = await System.IO.File.ReadAllTextAsync(playlistCacheFile); @@ -90,13 +87,13 @@ public class PlaylistController : ControllerBase { _logger.LogDebug("🔄 Force refresh requested for playlist summary"); } - + var playlists = new List(); - + // Read playlists directly from .env file to get the latest configuration // (IOptions is cached and doesn't reload after .env changes) var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); - + foreach (var config in configuredPlaylists) { var playlistInfo = new Dictionary @@ -112,11 +109,11 @@ public class PlaylistController : ControllerBase ["lastFetched"] = null as DateTime?, ["cacheAge"] = null as string }; - + // Get Spotify playlist track count from cache OR fetch it fresh var cacheFilePath = Path.Combine(CacheDirectory, $"{AdminHelperService.SanitizeFileName(config.Name)}_spotify.json"); int spotifyTrackCount = 0; - + if (System.IO.File.Exists(cacheFilePath)) { try @@ -124,20 +121,20 @@ public class PlaylistController : ControllerBase var json = await System.IO.File.ReadAllTextAsync(cacheFilePath); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - + if (root.TryGetProperty("tracks", out var tracks)) { spotifyTrackCount = tracks.GetArrayLength(); playlistInfo["trackCount"] = spotifyTrackCount; } - + if (root.TryGetProperty("fetchedAt", out var fetchedAt)) { var fetchedTime = fetchedAt.GetDateTime(); playlistInfo["lastFetched"] = fetchedTime; var age = DateTime.UtcNow - fetchedTime; - playlistInfo["cacheAge"] = age.TotalHours < 1 - ? $"{age.TotalMinutes:F0}m" + playlistInfo["cacheAge"] = age.TotalHours < 1 + ? $"{age.TotalMinutes:F0}m" : $"{age.TotalHours:F1}h"; } } @@ -146,7 +143,7 @@ public class PlaylistController : ControllerBase _logger.LogError(ex, "Failed to read cache for playlist {Name}", config.Name); } } - + // If cache doesn't exist or failed to read, fetch track count from Spotify API if (spotifyTrackCount == 0) { @@ -162,7 +159,7 @@ public class PlaylistController : ControllerBase _logger.LogWarning(ex, "Failed to fetch Spotify track count for playlist {Name}", config.Name); } } - + // Calculate stats from playlist items cache (source of truth) // This is fast and always accurate if (spotifyTrackCount > 0) @@ -171,7 +168,7 @@ public class PlaylistController : ControllerBase { // Try to use the pre-built playlist cache var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name); - + List>? cachedPlaylistItems = null; try { @@ -181,19 +178,19 @@ public class PlaylistController : ControllerBase { _logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name); } - + if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0) { // Calculate stats from the actual playlist cache var localCount = 0; var externalCount = 0; - + foreach (var item in cachedPlaylistItems) { if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) { Dictionary? providerIds = null; - + if (providerIdsObj is Dictionary dict) { providerIds = dict; @@ -206,15 +203,15 @@ public class PlaylistController : ControllerBase providerIds[prop.Name] = prop.Value.GetString() ?? ""; } } - + if (providerIds != null) { // Check if it's external (has squidwtf, deezer, qobuz, or tidal key) - var isExternal = providerIds.ContainsKey("squidwtf") || - providerIds.ContainsKey("deezer") || - providerIds.ContainsKey("qobuz") || + var isExternal = providerIds.ContainsKey("squidwtf") || + providerIds.ContainsKey("deezer") || + providerIds.ContainsKey("qobuz") || providerIds.ContainsKey("tidal"); - + if (isExternal) { externalCount++; @@ -226,17 +223,17 @@ public class PlaylistController : ControllerBase } } } - + var missingCount = spotifyTrackCount - (localCount + externalCount); - + playlistInfo["localTracks"] = localCount; playlistInfo["externalMatched"] = externalCount; playlistInfo["externalMissing"] = missingCount; playlistInfo["externalTotal"] = externalCount + missingCount; playlistInfo["totalInJellyfin"] = localCount + externalCount; playlistInfo["totalPlayable"] = localCount + externalCount; - - _logger.LogDebug("📊 Calculated stats from playlist cache for {Name}: {Local} local, {External} external, {Missing} missing", + + _logger.LogDebug("📊 Calculated stats from playlist cache for {Name}: {Local} local, {External} external, {Missing} missing", config.Name, localCount, externalCount, missingCount); } else @@ -246,11 +243,11 @@ public class PlaylistController : ControllerBase var localCount = 0; var externalCount = 0; var missingCount = 0; - + foreach (var track in spotifyTracks) { var mapping = await _mappingService.GetMappingAsync(track.SpotifyId); - + if (mapping != null) { if (mapping.TargetType == "local") @@ -267,15 +264,15 @@ public class PlaylistController : ControllerBase missingCount++; } } - + playlistInfo["localTracks"] = localCount; playlistInfo["externalMatched"] = externalCount; playlistInfo["externalMissing"] = missingCount; playlistInfo["externalTotal"] = externalCount + missingCount; playlistInfo["totalInJellyfin"] = localCount + externalCount; playlistInfo["totalPlayable"] = localCount + externalCount; - - _logger.LogDebug("📊 Calculated stats from global mappings for {Name}: {Local} local, {External} external, {Missing} missing", + + _logger.LogDebug("📊 Calculated stats from global mappings for {Name}: {Local} local, {External} external, {Missing} missing", config.Name, localCount, externalCount, missingCount); } } @@ -284,24 +281,24 @@ public class PlaylistController : ControllerBase _logger.LogError(ex, "Failed to calculate playlist stats for {Name}", config.Name); } } - + // LEGACY FALLBACK: Only used if global mappings fail // This is the old slow path - kept for backwards compatibility - if (!string.IsNullOrEmpty(config.JellyfinId) && - (int)(playlistInfo["totalPlayable"] ?? 0) == 0 && + if (!string.IsNullOrEmpty(config.JellyfinId) && + (int)(playlistInfo["totalPlayable"] ?? 0) == 0 && spotifyTrackCount > 0) { try { // Jellyfin requires UserId parameter to fetch playlist items var userId = _jellyfinSettings.UserId; - + // If no user configured, try to get the first user if (string.IsNullOrEmpty(userId)) { var usersRequest = _helperService.CreateJellyfinRequest(HttpMethod.Get, $"{_jellyfinSettings.Url}/Users"); var usersResponse = await _jellyfinHttpClient.SendAsync(usersRequest); - + if (usersResponse.IsSuccessStatusCode) { var usersJson = await usersResponse.Content.ReadAsStringAsync(); @@ -312,7 +309,7 @@ public class PlaylistController : ControllerBase } } } - + if (string.IsNullOrEmpty(userId)) { _logger.LogWarning("No user ID available to fetch playlist items for {Name}", config.Name); @@ -321,23 +318,23 @@ public class PlaylistController : ControllerBase { var url = $"{_jellyfinSettings.Url}/Playlists/{config.JellyfinId}/Items?UserId={userId}&Fields=Path"; var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); - + _logger.LogDebug("Fetching Jellyfin playlist items for {Name} from {Url}", config.Name, url); - + var response = await _jellyfinHttpClient.SendAsync(request); if (response.IsSuccessStatusCode) { var jellyfinJson = await response.Content.ReadAsStringAsync(); using var jellyfinDoc = JsonDocument.Parse(jellyfinJson); - + if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items)) { // Get Spotify tracks to match against var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name); - + // Try to use the pre-built playlist cache first (includes manual mappings!) var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name); - + List>? cachedPlaylistItems = null; try { @@ -347,10 +344,10 @@ public class PlaylistController : ControllerBase { _logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", config.Name); } - - _logger.LogDebug("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}", + + _logger.LogDebug("Checking cache for {Playlist}: {CacheKey}, Found: {Found}, Count: {Count}", config.Name, playlistItemsCacheKey, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0); - + if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0) { // Use the pre-built cache which respects manual mappings @@ -358,14 +355,14 @@ public class PlaylistController : ControllerBase var localCount = 0; var externalCount = 0; var missingCount = 0; - + // Count tracks by checking provider keys foreach (var item in cachedPlaylistItems) { if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) { Dictionary? providerIds = null; - + if (providerIdsObj is Dictionary dict) { providerIds = dict; @@ -378,7 +375,7 @@ public class PlaylistController : ControllerBase providerIds[prop.Name] = prop.Value.GetString() ?? ""; } } - + if (providerIds != null) { // Check if it's external (has squidwtf, deezer, qobuz, or tidal key) @@ -387,7 +384,7 @@ public class PlaylistController : ControllerBase var hasQobuz = providerIds.ContainsKey("qobuz"); var hasTidal = providerIds.ContainsKey("tidal"); var isExternal = hasSquidWTF || hasDeezer || hasQobuz || hasTidal; - + if (isExternal) { externalCount++; @@ -400,20 +397,20 @@ public class PlaylistController : ControllerBase } } } - + // Calculate missing tracks: total Spotify tracks minus matched tracks // The playlist cache only contains successfully matched tracks (local + external) // So missing = total - (local + external) missingCount = spotifyTracks.Count - (localCount + externalCount); - + playlistInfo["localTracks"] = localCount; playlistInfo["externalMatched"] = externalCount; playlistInfo["externalMissing"] = missingCount; playlistInfo["externalTotal"] = externalCount + missingCount; playlistInfo["totalInJellyfin"] = localCount + externalCount; // Tracks actually in the Jellyfin playlist playlistInfo["totalPlayable"] = localCount + externalCount; // Total tracks that will be served - - _logger.LogDebug("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable", + + _logger.LogDebug("Playlist {Name} (from cache): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable", config.Name, spotifyTracks.Count, localCount, externalCount, missingCount, localCount + externalCount); } else @@ -424,7 +421,7 @@ public class PlaylistController : ControllerBase { var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; var artist = ""; - + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) { artist = artistsEl[0].GetString() ?? ""; @@ -433,34 +430,34 @@ public class PlaylistController : ControllerBase { artist = albumArtistEl.GetString() ?? ""; } - + if (!string.IsNullOrEmpty(title)) { localTracks.Add((title, artist)); } } - + // Get matched external tracks cache once var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(config.Name); var matchedTracks = await _cache.GetAsync>(matchedTracksKey); var matchedSpotifyIds = new HashSet( matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() ); - + var localCount = 0; var externalMatchedCount = 0; var externalMissingCount = 0; - + // Match each Spotify track to determine if it's local, external, or missing foreach (var track in spotifyTracks) { var isLocal = false; var hasExternalMapping = false; - + // FIRST: Check for manual Jellyfin mapping var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}"; var manualJellyfinId = await _cache.GetAsync(manualMappingKey); - + if (!string.IsNullOrEmpty(manualJellyfinId)) { // Manual Jellyfin mapping exists - this track is definitely local @@ -471,7 +468,7 @@ public class PlaylistController : ControllerBase // Check for external manual mapping var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}"; var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); - + if (!string.IsNullOrEmpty(externalMappingJson)) { // External manual mapping exists @@ -496,7 +493,7 @@ public class PlaylistController : ControllerBase }) .OrderByDescending(x => x.TotalScore) .FirstOrDefault(); - + // Use 70% threshold (same as playback matching) if (bestMatch != null && bestMatch.TotalScore >= 70) { @@ -504,7 +501,7 @@ public class PlaylistController : ControllerBase } } } - + if (isLocal) { localCount++; @@ -522,15 +519,15 @@ public class PlaylistController : ControllerBase } } } - + playlistInfo["localTracks"] = localCount; playlistInfo["externalMatched"] = externalMatchedCount; playlistInfo["externalMissing"] = externalMissingCount; playlistInfo["externalTotal"] = externalMatchedCount + externalMissingCount; playlistInfo["totalInJellyfin"] = localCount + externalMatchedCount; playlistInfo["totalPlayable"] = localCount + externalMatchedCount; // Total tracks that will be served - - _logger.LogWarning("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable", + + _logger.LogWarning("Playlist {Name} (fallback): {Total} Spotify tracks, {Local} local, {ExtMatched} external matched, {ExtMissing} external missing, {Playable} total playable", config.Name, spotifyTracks.Count, localCount, externalMatchedCount, externalMissingCount, localCount + externalMatchedCount); } } @@ -541,7 +538,7 @@ public class PlaylistController : ControllerBase } else { - _logger.LogError("Failed to get Jellyfin playlist {Name}: {StatusCode}", + _logger.LogError("Failed to get Jellyfin playlist {Name}: {StatusCode}", config.Name, response.StatusCode); } } @@ -563,31 +560,31 @@ public class PlaylistController : ControllerBase _logger.LogInformation("Playlist {Name} has no JellyfinId configured", config.Name); } } - + playlists.Add(playlistInfo); } - + // Save to file cache try { var cacheDir = "/app/cache"; Directory.CreateDirectory(cacheDir); var cacheFile = Path.Combine(cacheDir, "admin_playlists_summary.json"); - + var response = new { playlists }; var json = JsonSerializer.Serialize(response, new JsonSerializerOptions { WriteIndented = false }); await System.IO.File.WriteAllTextAsync(cacheFile, json); - + _logger.LogDebug("💾 Saved playlist summary to cache"); } catch (Exception ex) { _logger.LogError(ex, "Failed to save playlist summary cache"); } - + return Ok(new { playlists }); } - + /// /// Get tracks for a specific playlist with local/external status /// @@ -595,16 +592,43 @@ public class PlaylistController : ControllerBase public async Task GetPlaylistTracks(string name) { var decodedName = Uri.UnescapeDataString(name); - + // Get Spotify tracks var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName); - + var tracksWithStatus = new List(); - + var matchedTracksBySpotifyId = new Dictionary(StringComparer.OrdinalIgnoreCase); + + try + { + var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName); + var matchedTracks = await _cache.GetAsync>(matchedTracksKey); + + if (matchedTracks != null) + { + foreach (var matched in matchedTracks) + { + if (string.IsNullOrWhiteSpace(matched.SpotifyId) || matched.MatchedSong == null) + { + continue; + } + + if (!matchedTracksBySpotifyId.ContainsKey(matched.SpotifyId)) + { + matchedTracksBySpotifyId[matched.SpotifyId] = matched; + } + } + } + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to load matched tracks cache for {Playlist}", decodedName); + } + // Use the pre-built playlist cache (same as GetPlaylists endpoint) // This cache includes all matched tracks with proper provider IDs var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName); - + List>? cachedPlaylistItems = null; try { @@ -614,22 +638,22 @@ public class PlaylistController : ControllerBase { _logger.LogWarning(cacheEx, "Failed to deserialize playlist cache for {Playlist}", decodedName); } - - _logger.LogDebug("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}", + + _logger.LogDebug("GetPlaylistTracks for {Playlist}: Cache found: {Found}, Count: {Count}", decodedName, cachedPlaylistItems != null, cachedPlaylistItems?.Count ?? 0); - + if (cachedPlaylistItems != null && cachedPlaylistItems.Count > 0) { // Build a map of Spotify ID -> cached item for quick lookup var spotifyIdToItem = new Dictionary>(); - + foreach (var item in cachedPlaylistItems) { // Try to get Spotify ID from ProviderIds (works for both local and external) if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) { Dictionary? providerIds = null; - + if (providerIdsObj is Dictionary dict) { providerIds = dict; @@ -642,14 +666,14 @@ public class PlaylistController : ControllerBase providerIds[prop.Name] = prop.Value.GetString() ?? ""; } } - + if (providerIds != null && providerIds.TryGetValue("Spotify", out var spotifyId) && !string.IsNullOrEmpty(spotifyId)) { spotifyIdToItem[spotifyId] = item; } } } - + // Match each Spotify track to its cached item foreach (var track in spotifyTracks) { @@ -658,15 +682,15 @@ public class PlaylistController : ControllerBase bool isManualMapping = false; string? manualMappingType = null; string? manualMappingId = null; - + Dictionary? cachedItem = null; - + // Try to match by Spotify ID only (no position-based fallback!) if (spotifyIdToItem.TryGetValue(track.SpotifyId, out cachedItem)) { _logger.LogDebug("Matched track {Title} by Spotify ID", track.Title); } - + // Check if track is in the playlist cache first if (cachedItem != null) { @@ -682,17 +706,17 @@ public class PlaylistController : ControllerBase { serverId = jsonEl.GetString(); } - + if (serverId == "allstarr") { // This is an external track stub isLocal = false; - + // Try to determine the provider from ProviderIds if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObjExt) && providerIdsObjExt != null) { Dictionary? providerIdsExt = null; - + if (providerIdsObjExt is Dictionary dictExt) { providerIdsExt = dictExt; @@ -705,43 +729,68 @@ public class PlaylistController : ControllerBase providerIdsExt[prop.Name] = prop.Value.GetString() ?? ""; } } - + if (providerIdsExt != null) { - // Check for external provider keys - if (providerIdsExt.ContainsKey("squidwtf")) - externalProvider = "squidwtf"; - else if (providerIdsExt.ContainsKey("deezer")) - externalProvider = "deezer"; - else if (providerIdsExt.ContainsKey("qobuz")) - externalProvider = "qobuz"; - else if (providerIdsExt.ContainsKey("tidal")) - externalProvider = "tidal"; + externalProvider = ResolveExternalProviderFromProviderIds(providerIdsExt); } } - - _logger.LogDebug("✓ Track {Title} identified as EXTERNAL from ServerId=allstarr (provider: {Provider})", - track.Title, externalProvider ?? "unknown"); - - // Check if this is a manual mapping + + // Fallback 1: derive provider from matched-track cache + if (string.IsNullOrWhiteSpace(externalProvider) && + PlaylistTrackStatusResolver.TryResolveFromMatchedTrack( + matchedTracksBySpotifyId, + track.SpotifyId, + out var resolvedIsLocal, + out var resolvedExternalProvider) && + resolvedIsLocal == false) + { + externalProvider = NormalizeExternalProviderForDisplay(resolvedExternalProvider); + } + + // Fallback 2: derive provider from global mapping var globalMappingExt = await _mappingService.GetMappingAsync(track.SpotifyId); + if (string.IsNullOrWhiteSpace(externalProvider) && + globalMappingExt?.TargetType == "external") + { + externalProvider = NormalizeExternalProviderForDisplay(globalMappingExt.ExternalProvider); + } + + // Fallback 3: derive provider from external item ID prefix (ext-{provider}-...) + if (string.IsNullOrWhiteSpace(externalProvider) && + cachedItem.TryGetValue("Id", out var cachedItemIdObj)) + { + var externalItemId = cachedItemIdObj switch + { + string s => s, + JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(), + _ => null + }; + + externalProvider = ExtractExternalProviderFromItemId(externalItemId); + } + + _logger.LogDebug("✓ Track {Title} identified as EXTERNAL from ServerId=allstarr (provider: {Provider})", + track.Title, externalProvider ?? "unknown"); + + // Check if this is a manual mapping if (globalMappingExt != null && globalMappingExt.Source == "manual") { isManualMapping = true; manualMappingType = "external"; manualMappingId = globalMappingExt.ExternalId; } - + // Skip the rest of the ProviderIds logic goto AddTrack; } } - + // Track is in the playlist cache with real Jellyfin ServerId - determine type from ProviderIds if (cachedItem.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) { Dictionary? providerIds = null; - + if (providerIdsObj is Dictionary dict) { providerIds = dict; @@ -754,41 +803,17 @@ public class PlaylistController : ControllerBase providerIds[prop.Name] = prop.Value.GetString() ?? ""; } } - + if (providerIds != null) { _logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys)); - - // Check for external provider keys (case-insensitive) - // External providers: squidwtf, deezer, qobuz, tidal - var hasSquidWTF = providerIds.Keys.Any(k => k.Equals("squidwtf", StringComparison.OrdinalIgnoreCase)); - var hasDeezer = providerIds.Keys.Any(k => k.Equals("deezer", StringComparison.OrdinalIgnoreCase)); - var hasQobuz = providerIds.Keys.Any(k => k.Equals("qobuz", StringComparison.OrdinalIgnoreCase)); - var hasTidal = providerIds.Keys.Any(k => k.Equals("tidal", StringComparison.OrdinalIgnoreCase)); - - if (hasSquidWTF) + + externalProvider = ResolveExternalProviderFromProviderIds(providerIds); + + if (!string.IsNullOrWhiteSpace(externalProvider)) { isLocal = false; - externalProvider = "squidwtf"; - _logger.LogDebug("✓ Track {Title} identified as SquidWTF from cache", track.Title); - } - else if (hasDeezer) - { - isLocal = false; - externalProvider = "deezer"; - _logger.LogDebug("✓ Track {Title} identified as Deezer from cache", track.Title); - } - else if (hasQobuz) - { - isLocal = false; - externalProvider = "qobuz"; - _logger.LogDebug("✓ Track {Title} identified as Qobuz from cache", track.Title); - } - else if (hasTidal) - { - isLocal = false; - externalProvider = "tidal"; - _logger.LogDebug("✓ Track {Title} identified as Tidal from cache", track.Title); + _logger.LogDebug("✓ Track {Title} identified as {Provider} from cache", track.Title, externalProvider); } else { @@ -809,7 +834,7 @@ public class PlaylistController : ControllerBase isLocal = true; _logger.LogDebug("✓ Track {Title} identified as LOCAL (in cache, no ProviderIds)", track.Title); } - + // Check if this is a manual mapping (for display purposes) var globalMapping = await _mappingService.GetMappingAsync(track.SpotifyId); if (globalMapping != null && globalMapping.Source == "manual") @@ -823,12 +848,12 @@ public class PlaylistController : ControllerBase { // Track NOT in playlist cache - check if there's a MANUAL global mapping var globalMapping = await _mappingService.GetMappingAsync(track.SpotifyId); - + if (globalMapping != null && globalMapping.Source == "manual") { // Manual mapping exists - trust it even if not in cache yet _logger.LogDebug("✓ Track {Title} has MANUAL global mapping: {Type}", track.Title, globalMapping.TargetType); - + if (globalMapping.TargetType == "local") { isLocal = true; @@ -839,7 +864,7 @@ public class PlaylistController : ControllerBase else if (globalMapping.TargetType == "external") { isLocal = false; - externalProvider = globalMapping.ExternalProvider; + externalProvider = NormalizeExternalProviderForDisplay(globalMapping.ExternalProvider); isManualMapping = true; manualMappingType = "external"; manualMappingId = globalMapping.ExternalId; @@ -848,19 +873,44 @@ public class PlaylistController : ControllerBase else { // No manual mapping and not in cache - it's missing - // (Auto mappings don't count if track isn't in the playlist cache) - isLocal = null; - externalProvider = null; - _logger.LogDebug("✗ Track {Title} ({SpotifyId}) is MISSING (not in cache, no manual mapping)", track.Title, track.SpotifyId); + // Fall back to ordered matched-tracks cache so auto local/external matches + // are shown correctly even when playlist item cache lacks Spotify ProviderIds. + if (PlaylistTrackStatusResolver.TryResolveFromMatchedTrack( + matchedTracksBySpotifyId, + track.SpotifyId, + out var resolvedIsLocal, + out var resolvedExternalProvider)) + { + isLocal = resolvedIsLocal; + externalProvider = resolvedExternalProvider; + _logger.LogDebug( + "✓ Track {Title} ({SpotifyId}) resolved from matched cache as {Type}", + track.Title, + track.SpotifyId, + isLocal == true ? "local" : "external"); + } + else + { + isLocal = null; + externalProvider = null; + _logger.LogDebug( + "✗ Track {Title} ({SpotifyId}) is MISSING (not in cache, no manual mapping, no matched cache)", + track.Title, track.SpotifyId); + } } } - + AddTrack: + if (isLocal == false) + { + externalProvider = NormalizeExternalProviderForDisplay(externalProvider); + } + // Check lyrics status var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}"; var existingLyrics = await _cache.GetStringAsync(cacheKey); var hasLyrics = !string.IsNullOrEmpty(existingLyrics); - + tracksWithStatus.Add(new { position = track.Position, @@ -880,7 +930,7 @@ public class PlaylistController : ControllerBase hasLyrics = hasLyrics }); } - + return Ok(new { name = decodedName, @@ -888,25 +938,19 @@ public class PlaylistController : ControllerBase tracks = tracksWithStatus }); } - + // Fallback: Cache not available, use matched tracks cache _logger.LogWarning("Playlist cache not available for {Playlist}, using fallback", decodedName); - - var fallbackMatchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName); - var fallbackMatchedTracks = await _cache.GetAsync>(fallbackMatchedTracksKey); - var fallbackMatchedSpotifyIds = new HashSet( - fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty() - ); - + foreach (var track in spotifyTracks) { bool? isLocal = null; string? externalProvider = null; - + // Check for manual Jellyfin mapping var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}"; var manualJellyfinId = await _cache.GetAsync(manualMappingKey); - + if (!string.IsNullOrEmpty(manualJellyfinId)) { isLocal = true; @@ -916,25 +960,25 @@ public class PlaylistController : ControllerBase // Check for external manual mapping var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}"; var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); - + if (!string.IsNullOrEmpty(externalMappingJson)) { try { using var extDoc = JsonDocument.Parse(externalMappingJson); var extRoot = extDoc.RootElement; - + string? provider = null; - + if (extRoot.TryGetProperty("provider", out var providerEl)) { provider = providerEl.GetString(); } - + if (!string.IsNullOrEmpty(provider)) { isLocal = false; - externalProvider = provider; + externalProvider = NormalizeExternalProviderForDisplay(provider); } } catch (Exception ex) @@ -942,10 +986,14 @@ public class PlaylistController : ControllerBase _logger.LogError(ex, "Failed to process external manual mapping for {Title}", track.Title); } } - else if (fallbackMatchedSpotifyIds.Contains(track.SpotifyId)) + else if (PlaylistTrackStatusResolver.TryResolveFromMatchedTrack( + matchedTracksBySpotifyId, + track.SpotifyId, + out var resolvedIsLocal, + out var resolvedExternalProvider)) { - isLocal = false; - externalProvider = "SquidWTF"; + isLocal = resolvedIsLocal; + externalProvider = resolvedExternalProvider; } else { @@ -953,7 +1001,12 @@ public class PlaylistController : ControllerBase externalProvider = null; } } - + + if (isLocal == false) + { + externalProvider = NormalizeExternalProviderForDisplay(externalProvider); + } + tracksWithStatus.Add(new { position = track.Position, @@ -969,7 +1022,7 @@ public class PlaylistController : ControllerBase searchQuery = isLocal != true ? $"{track.Title} {track.PrimaryArtist}" : null }); } - + return Ok(new { name = decodedName, @@ -977,7 +1030,7 @@ public class PlaylistController : ControllerBase tracks = tracksWithStatus }); } - + /// /// Trigger a manual refresh of all playlists /// @@ -986,10 +1039,10 @@ public class PlaylistController : ControllerBase { _logger.LogInformation("Manual playlist refresh triggered from admin UI"); await _playlistFetcher.TriggerFetchAsync(); - + // Invalidate playlist summary cache _helperService.InvalidatePlaylistSummaryCache(); - + // Clear ALL playlist stats caches var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); foreach (var playlist in configuredPlaylists) @@ -998,10 +1051,10 @@ public class PlaylistController : ControllerBase await _cache.DeleteAsync(statsCacheKey); } _logger.LogInformation("Cleared stats cache for all {Count} playlists", configuredPlaylists.Count); - + return Ok(new { message = "Playlist refresh triggered", timestamp = DateTime.UtcNow }); } - + /// /// Refresh a single playlist from Spotify (fetch latest data without re-matching). /// @@ -1010,35 +1063,35 @@ public class PlaylistController : ControllerBase { var decodedName = Uri.UnescapeDataString(name); _logger.LogInformation("Manual refresh triggered for playlist: {Name}", decodedName); - + if (_playlistFetcher == null) { return BadRequest(new { error = "Playlist fetcher is not available" }); } - + try { await _playlistFetcher.RefreshPlaylistAsync(decodedName); - + // Clear playlist stats cache first (so it gets recalculated with fresh data) var statsCacheKey = $"spotify:playlist:stats:{decodedName}"; await _cache.DeleteAsync(statsCacheKey); - + // Then invalidate playlist summary cache (will rebuild with fresh stats) _helperService.InvalidatePlaylistSummaryCache(); - - return Ok(new { - message = $"Refreshed {decodedName} from Spotify (no re-matching)", - timestamp = DateTime.UtcNow + + return Ok(new { + message = $"Refreshed {decodedName} from Spotify (no re-matching)", + timestamp = DateTime.UtcNow }); } catch (Exception ex) { _logger.LogError(ex, "Failed to refresh playlist {Name}", decodedName); - return StatusCode(500, new { error = "Failed to refresh playlist", details = ex.Message }); + return StatusCode(500, new { error = "Failed to refresh playlist" }); } } - + /// /// Re-match tracks when LOCAL library has changed (checks if Jellyfin playlist changed). /// This is a lightweight operation that reuses cached Spotify data. @@ -1048,52 +1101,52 @@ public class PlaylistController : ControllerBase { var decodedName = Uri.UnescapeDataString(name); _logger.LogInformation("Re-match tracks triggered for playlist: {Name} (checking for local changes)", decodedName); - + if (_matchingService == null) { return BadRequest(new { error = "Track matching service is not available" }); } - + try { // Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}"; await _cache.DeleteAsync(jellyfinSignatureCacheKey); _logger.LogDebug("Cleared Jellyfin signature cache to force change detection"); - + // Clear the matched results cache to force re-matching var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName); await _cache.DeleteAsync(matchedTracksKey); _logger.LogDebug("Cleared matched tracks cache"); - + // Clear the playlist items cache var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName); await _cache.DeleteAsync(playlistItemsCacheKey); _logger.LogDebug("Cleared playlist items cache"); - + // Trigger matching (will use cached Spotify data if still valid) await _matchingService.TriggerMatchingForPlaylistAsync(decodedName); - + // Invalidate playlist summary cache _helperService.InvalidatePlaylistSummaryCache(); - + // Clear playlist stats cache to force recalculation from new mappings var statsCacheKey = $"spotify:playlist:stats:{decodedName}"; await _cache.DeleteAsync(statsCacheKey); _logger.LogDebug("Cleared stats cache for {Name}", decodedName); - - return Ok(new { - message = $"Re-matching tracks for {decodedName} (checking local changes)", - timestamp = DateTime.UtcNow + + return Ok(new { + message = $"Re-matching tracks for {decodedName} (checking local changes)", + timestamp = DateTime.UtcNow }); } catch (Exception ex) { _logger.LogError(ex, "Failed to trigger track matching for {Name}", decodedName); - return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message }); + return StatusCode(500, new { error = "Failed to trigger track matching" }); } } - + /// /// Rebuild playlist from scratch when REMOTE (Spotify) playlist has changed. /// Clears all caches including Spotify data and forces fresh fetch. @@ -1102,34 +1155,34 @@ public class PlaylistController : ControllerBase public async Task ClearPlaylistCache(string name) { var decodedName = Uri.UnescapeDataString(name); - _logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (same as cron job)", decodedName); - + _logger.LogInformation("Rebuild from scratch triggered for playlist: {Name}", decodedName); + if (_matchingService == null) { return BadRequest(new { error = "Track matching service is not available" }); } - + try { - // Use the unified rebuild method (same as cron job and "Rebuild All Remote") + // Use the unified per-playlist rebuild method (same workflow as per-playlist cron rebuilds) await _matchingService.TriggerRebuildForPlaylistAsync(decodedName); - + // Invalidate playlist summary cache _helperService.InvalidatePlaylistSummaryCache(); - - return Ok(new - { - message = $"Rebuilding {decodedName} from scratch (same as cron job)", + + return Ok(new + { + message = $"Rebuilding {decodedName} from scratch", timestamp = DateTime.UtcNow }); } catch (Exception ex) { _logger.LogError(ex, "Failed to rebuild playlist {Name}", decodedName); - return StatusCode(500, new { error = "Failed to rebuild playlist", details = ex.Message }); + return StatusCode(500, new { error = "Failed to rebuild playlist" }); } } - + /// /// Search Jellyfin library for tracks (for manual mapping) /// @@ -1140,22 +1193,22 @@ public class PlaylistController : ControllerBase { return BadRequest(new { error = "Query is required" }); } - + try { var userId = _jellyfinSettings.UserId; - + // Build URL with UserId if available var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20"; if (!string.IsNullOrEmpty(userId)) { url += $"&UserId={userId}"; } - + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); - + _logger.LogDebug("Searching Jellyfin: {Url}", url); - + var response = await _jellyfinHttpClient.SendAsync(request); if (!response.IsSuccessStatusCode) { @@ -1163,10 +1216,10 @@ public class PlaylistController : ControllerBase _logger.LogError("Jellyfin search failed: {StatusCode} - {Error}", response.StatusCode, errorBody); return StatusCode((int)response.StatusCode, new { error = "Failed to search Jellyfin" }); } - + var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); - + var tracks = new List(); if (doc.RootElement.TryGetProperty("Items", out var items)) { @@ -1179,12 +1232,12 @@ public class PlaylistController : ControllerBase _logger.LogWarning("Skipping non-audio item: {Type}", type); continue; } - + var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : ""; var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : ""; var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : ""; var artist = ""; - + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) { artist = artistsEl[0].GetString() ?? ""; @@ -1193,12 +1246,12 @@ public class PlaylistController : ControllerBase { artist = albumArtistEl.GetString() ?? ""; } - - tracks.Add(new { id, title, artist, album }); + + tracks.Add(new { id, name = title, title, artist, album }); } } - - return Ok(new { tracks }); + + return Ok(new { tracks, results = tracks }); } catch (Exception ex) { @@ -1206,7 +1259,62 @@ public class PlaylistController : ControllerBase return StatusCode(500, new { error = "Search failed" }); } } - + + /// + /// Search external provider tracks for manual mapping. + /// + [HttpGet("external/search")] + public async Task SearchExternalTracks( + [FromQuery] string query, + [FromQuery] string provider = "squidwtf", + [FromQuery] int limit = 20) + { + if (string.IsNullOrWhiteSpace(query)) + { + return BadRequest(new { error = "Query is required" }); + } + + var normalizedProvider = (provider ?? string.Empty).Trim().ToLowerInvariant(); + if (normalizedProvider != "squidwtf" && normalizedProvider != "deezer" && normalizedProvider != "qobuz") + { + return BadRequest(new { error = "Unsupported provider" }); + } + + try + { + var metadataService = HttpContext.RequestServices.GetRequiredService(); + var songs = await metadataService.SearchSongsAsync( + query.Trim(), + Math.Clamp(limit, 1, 50), + HttpContext.RequestAborted); + + var results = songs + .Where(s => !string.IsNullOrWhiteSpace(s.ExternalId)) + .Where(s => string.IsNullOrWhiteSpace(s.ExternalProvider) || + string.Equals(s.ExternalProvider, normalizedProvider, StringComparison.OrdinalIgnoreCase)) + .GroupBy(s => s.ExternalId!, StringComparer.OrdinalIgnoreCase) + .Select(g => g.First()) + .Select(song => new + { + id = song.ExternalId, + externalId = song.ExternalId, + title = song.Title, + artist = song.Artist, + album = song.Album, + externalProvider = song.ExternalProvider ?? normalizedProvider, + url = BuildExternalTrackUrl(song.ExternalProvider ?? normalizedProvider, song.ExternalId!) + }) + .ToList(); + + return Ok(new { results }); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to search external tracks for provider {Provider}", provider); + return StatusCode(500, new { error = "Failed to search external tracks" }); + } + } + /// /// Get track details by Jellyfin ID (for URL-based mapping) /// @@ -1217,35 +1325,35 @@ public class PlaylistController : ControllerBase { return BadRequest(new { error = "Track ID is required" }); } - + try { var userId = _jellyfinSettings.UserId; - + var url = $"{_jellyfinSettings.Url}/Items/{id}"; if (!string.IsNullOrEmpty(userId)) { url += $"?UserId={userId}"; } - + var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url); - + _logger.LogDebug("Fetching Jellyfin track {Id} from {Url}", id, url); - + var response = await _jellyfinHttpClient.SendAsync(request); if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(); - _logger.LogError("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}", + _logger.LogError("Failed to fetch Jellyfin track {Id}: {StatusCode} - {Error}", id, response.StatusCode, errorBody); return StatusCode((int)response.StatusCode, new { error = "Track not found in Jellyfin" }); } - + var json = await response.Content.ReadAsStringAsync(); using var doc = JsonDocument.Parse(json); - + var item = doc.RootElement; - + // Verify it's an Audio item var type = item.TryGetProperty("Type", out var typeEl) ? typeEl.GetString() : ""; if (type != "Audio") @@ -1253,12 +1361,12 @@ public class PlaylistController : ControllerBase _logger.LogWarning("Item {Id} is not an Audio track, it's a {Type}", id, type); return BadRequest(new { error = $"Item is not an audio track (it's a {type})" }); } - + var trackId = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() : ""; var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() : ""; var album = item.TryGetProperty("Album", out var albumEl) ? albumEl.GetString() : ""; var artist = ""; - + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) { artist = artistsEl[0].GetString() ?? ""; @@ -1267,10 +1375,18 @@ public class PlaylistController : ControllerBase { artist = albumArtistEl.GetString() ?? ""; } - + _logger.LogInformation("Found Jellyfin track: {Title} by {Artist}", title, artist); - - return Ok(new { id = trackId, title, artist, album }); + + return Ok(new + { + id = trackId, + name = title, + title, + artist, + album, + track = new { id = trackId, name = title, title, artist, album } + }); } catch (Exception ex) { @@ -1278,7 +1394,7 @@ public class PlaylistController : ControllerBase return StatusCode(500, new { error = "Failed to get track details" }); } } - + /// /// Save manual track mapping (local Jellyfin or external provider) /// @@ -1286,40 +1402,41 @@ public class PlaylistController : ControllerBase public async Task SaveManualMapping(string name, [FromBody] ManualMappingRequest request) { var decodedName = Uri.UnescapeDataString(name); - + if (string.IsNullOrWhiteSpace(request.SpotifyId)) { return BadRequest(new { error = "SpotifyId is required" }); } - + // Validate that either Jellyfin mapping or external mapping is provided var hasJellyfinMapping = !string.IsNullOrWhiteSpace(request.JellyfinId); var hasExternalMapping = !string.IsNullOrWhiteSpace(request.ExternalProvider) && !string.IsNullOrWhiteSpace(request.ExternalId); - + if (!hasJellyfinMapping && !hasExternalMapping) { return BadRequest(new { error = "Either JellyfinId or (ExternalProvider + ExternalId) is required" }); } - + if (hasJellyfinMapping && hasExternalMapping) { return BadRequest(new { error = "Cannot specify both Jellyfin and external mapping for the same track" }); } - + try { string? normalizedProvider = null; - + string? normalizedExternalId = null; + if (hasJellyfinMapping) { // Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent) var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}"; await _cache.SetAsync(mappingKey, request.JellyfinId!); - + // Also save to file for persistence across restarts await _helperService.SaveManualMappingToFileAsync(decodedName, request.SpotifyId, request.JellyfinId!, null, null); - - _logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}", + + _logger.LogInformation("Manual Jellyfin mapping saved: {Playlist} - Spotify {SpotifyId} → Jellyfin {JellyfinId}", decodedName, request.SpotifyId, request.JellyfinId); } else @@ -1327,27 +1444,54 @@ public class PlaylistController : ControllerBase // Store external mapping in cache (NO EXPIRATION - manual mappings are permanent) var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}"; normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase - var externalMapping = new { provider = normalizedProvider, id = request.ExternalId }; + normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!); + var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId }; await _cache.SetAsync(externalMappingKey, externalMapping); - + // Also save to file for persistence across restarts - await _helperService.SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, request.ExternalId!); - - _logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}", - decodedName, request.SpotifyId, normalizedProvider, request.ExternalId); + await _helperService.SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, normalizedExternalId); + + _logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}", + decodedName, request.SpotifyId, normalizedProvider, normalizedExternalId); } - + + // Keep global Spotify mappings in sync so the dedicated mappings page reflects manual map actions. + var existingGlobalMapping = await _mappingService.GetMappingAsync(request.SpotifyId); + var globalMetadata = existingGlobalMapping?.Metadata; + + var globalMappingSaved = hasJellyfinMapping + ? await _mappingService.SaveManualMappingAsync( + request.SpotifyId, + "local", + localId: request.JellyfinId!, + metadata: globalMetadata) + : await _mappingService.SaveManualMappingAsync( + request.SpotifyId, + "external", + externalProvider: normalizedProvider!, + externalId: normalizedExternalId!, + metadata: globalMetadata); + + if (globalMappingSaved) + { + _logger.LogInformation("Global mapping synchronized for Spotify {SpotifyId}", request.SpotifyId); + } + else + { + _logger.LogWarning("Global mapping synchronization skipped for Spotify {SpotifyId}", request.SpotifyId); + } + // Clear all related caches to force rebuild var matchedCacheKey = $"spotify:matched:{decodedName}"; var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName); var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName); var statsCacheKey = $"spotify:playlist:stats:{decodedName}"; - + await _cache.DeleteAsync(matchedCacheKey); await _cache.DeleteAsync(orderedCacheKey); await _cache.DeleteAsync(playlistItemsKey); await _cache.DeleteAsync(statsCacheKey); - + // Also delete file caches to force rebuild try { @@ -1356,19 +1500,19 @@ public class PlaylistController : ControllerBase var matchedFile = Path.Combine(cacheDir, $"{safeName}_matched.json"); var itemsFile = Path.Combine(cacheDir, $"{safeName}_items.json"); var statsFile = Path.Combine(cacheDir, $"{safeName}_stats.json"); - + if (System.IO.File.Exists(matchedFile)) { System.IO.File.Delete(matchedFile); _logger.LogInformation("Deleted matched tracks file cache for {Playlist}", decodedName); } - + if (System.IO.File.Exists(itemsFile)) { System.IO.File.Delete(itemsFile); _logger.LogDebug("Deleted playlist items file cache for {Playlist}", decodedName); } - + if (System.IO.File.Exists(statsFile)) { System.IO.File.Delete(statsFile); @@ -1379,21 +1523,21 @@ public class PlaylistController : ControllerBase { _logger.LogError(ex, "Failed to delete file caches for {Playlist}", decodedName); } - + _logger.LogInformation("Cleared playlist caches for {Playlist} to force rebuild", decodedName); - + // Fetch external provider track details to return to the UI (only for external mappings) string? trackTitle = null; string? trackArtist = null; string? trackAlbum = null; - + if (hasExternalMapping && normalizedProvider != null) { try { var metadataService = HttpContext.RequestServices.GetRequiredService(); - var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!); - + var externalSong = await metadataService.GetSongAsync(normalizedProvider, normalizedExternalId!); + if (externalSong != null) { trackTitle = externalSong.Title; @@ -1403,8 +1547,8 @@ public class PlaylistController : ControllerBase } else { - _logger.LogError("Failed to fetch external track metadata for {Provider} ID {Id}", - normalizedProvider, request.ExternalId); + _logger.LogError("Failed to fetch external track metadata for {Provider} ID {Id}", + normalizedProvider, normalizedExternalId); } } catch (Exception ex) @@ -1412,12 +1556,12 @@ public class PlaylistController : ControllerBase _logger.LogError(ex, "Failed to fetch external track metadata, but mapping was saved"); } } - + // Trigger immediate playlist rebuild with the new mapping if (_matchingService != null) { _logger.LogInformation("Triggering immediate playlist rebuild for {Playlist} with new manual mapping", decodedName); - + // Run rebuild in background with timeout to avoid blocking the response _ = Task.Run(async () => { @@ -1441,21 +1585,35 @@ public class PlaylistController : ControllerBase { _logger.LogWarning("Matching service not available - playlist will rebuild on next scheduled run"); } - + + if (hasJellyfinMapping) + { + return Ok(new + { + message = "Mapping saved and playlist rebuild triggered", + track = new + { + id = request.JellyfinId, + isLocal = true + }, + rebuildTriggered = _matchingService != null + }); + } + // Return success with track details if available var mappedTrack = new { - id = request.ExternalId, + id = normalizedExternalId ?? request.ExternalId, title = trackTitle ?? "Unknown", artist = trackArtist ?? "Unknown", album = trackAlbum ?? "Unknown", isLocal = false, - externalProvider = request.ExternalProvider!.ToLowerInvariant() + externalProvider = normalizedProvider ?? request.ExternalProvider?.ToLowerInvariant() ?? "unknown" }; - - return Ok(new - { - message = "Mapping saved and playlist rebuild triggered", + + return Ok(new + { + message = "Mapping saved and playlist rebuild triggered", track = mappedTrack, rebuildTriggered = _matchingService != null }); @@ -1466,7 +1624,7 @@ public class PlaylistController : ControllerBase return StatusCode(500, new { error = "Failed to save mapping" }); } } - + /// /// Trigger track matching for all playlists /// @@ -1474,12 +1632,12 @@ public class PlaylistController : ControllerBase public async Task MatchAllPlaylistTracks() { _logger.LogInformation("Manual track matching triggered for all playlists"); - + if (_matchingService == null) { return BadRequest(new { error = "Track matching service is not available" }); } - + try { await _matchingService.TriggerMatchingAsync(); @@ -1488,36 +1646,152 @@ public class PlaylistController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Failed to trigger track matching for all playlists"); - return StatusCode(500, new { error = "Failed to trigger track matching", details = ex.Message }); + return StatusCode(500, new { error = "Failed to trigger track matching" }); } } - + + private static string? NormalizeKnownExternalProvider(string? provider) + { + if (string.IsNullOrWhiteSpace(provider)) + { + return null; + } + + return provider.Trim().ToLowerInvariant() switch + { + "squidwtf" or "squid-wtf" or "squid_wtf" or "tidal" => "squidwtf", + "deezer" => "deezer", + "qobuz" => "qobuz", + _ => null + }; + } + + private static string? NormalizeExternalProviderForDisplay(string? provider) + { + if (string.IsNullOrWhiteSpace(provider)) + { + return null; + } + + return NormalizeKnownExternalProvider(provider) ?? provider.Trim().ToLowerInvariant(); + } + + private static string? ResolveExternalProviderFromProviderIds(Dictionary providerIds) + { + foreach (var providerKey in providerIds.Keys) + { + var normalized = NormalizeKnownExternalProvider(providerKey); + if (!string.IsNullOrWhiteSpace(normalized)) + { + return normalized; + } + } + + return null; + } + + private static string? ExtractExternalProviderFromItemId(string? itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return null; + } + + var trimmed = itemId.Trim(); + if (!trimmed.StartsWith("ext-", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var parts = trimmed.Split('-', 4, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + return null; + } + + return NormalizeExternalProviderForDisplay(parts[1]); + } + + private static string BuildExternalTrackUrl(string provider, string externalId) + { + if (string.IsNullOrWhiteSpace(externalId)) + { + return string.Empty; + } + + return provider.ToLowerInvariant() switch + { + "squidwtf" => $"https://www.tidal.com/track/{externalId}", + "deezer" => $"https://www.deezer.com/track/{externalId}", + "qobuz" => $"https://open.qobuz.com/track/{externalId}", + _ => externalId + }; + } + + private static string NormalizeExternalTrackId(string provider, string externalId) + { + var normalizedProvider = (provider ?? string.Empty).ToLowerInvariant(); + var trimmed = (externalId ?? string.Empty).Trim(); + + if (normalizedProvider != "squidwtf" || string.IsNullOrWhiteSpace(trimmed)) + { + return trimmed; + } + + if (trimmed.All(char.IsDigit)) + { + return trimmed; + } + + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + return trimmed; + } + + var queryId = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(uri.Query) + .TryGetValue("id", out var values) + ? values.FirstOrDefault() + : null; + if (!string.IsNullOrWhiteSpace(queryId) && queryId.All(char.IsDigit)) + { + return queryId; + } + + var lastSegment = uri.Segments.LastOrDefault()?.Trim('/'); + if (!string.IsNullOrWhiteSpace(lastSegment) && lastSegment.All(char.IsDigit)) + { + return lastSegment; + } + + return trimmed; + } + /// /// Rebuild all playlists from scratch (clear cache, fetch fresh data, re-match). - /// This is the same process as the scheduled cron job - used by "Rebuild All Remote" button. + /// This is a manual bulk action across all playlists - used by "Rebuild All Remote" button. /// [HttpPost("playlists/rebuild-all")] public async Task RebuildAllPlaylists() { - _logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)"); - + _logger.LogInformation("Manual full rebuild triggered for all playlists"); + if (_matchingService == null) { return BadRequest(new { error = "Track matching service is not available" }); } - + try { await _matchingService.TriggerRebuildAllAsync(); - return Ok(new { message = "Full rebuild triggered for all playlists (same as cron job)", timestamp = DateTime.UtcNow }); + return Ok(new { message = "Full rebuild triggered for all playlists", timestamp = DateTime.UtcNow }); } catch (Exception ex) { _logger.LogError(ex, "Failed to trigger full rebuild for all playlists"); - return StatusCode(500, new { error = "Failed to trigger full rebuild", details = ex.Message }); + return StatusCode(500, new { error = "Failed to trigger full rebuild" }); } } - + /// /// Get current configuration (safe values only) /// @@ -1528,33 +1802,33 @@ public class PlaylistController : ControllerBase { return BadRequest(new { error = "Name and SpotifyId are required" }); } - + _logger.LogInformation("Adding playlist: {Name} ({SpotifyId})", request.Name, request.SpotifyId); - + // Get current playlists var currentPlaylists = _spotifyImportSettings.Playlists.ToList(); - + // Check for duplicates if (currentPlaylists.Any(p => p.Id == request.SpotifyId || p.Name == request.Name)) { return BadRequest(new { error = "Playlist with this name or ID already exists" }); } - + // Add new playlist currentPlaylists.Add(new SpotifyPlaylistConfig { Name = request.Name, Id = request.SpotifyId, - LocalTracksPosition = request.LocalTracksPosition == "last" - ? LocalTracksPosition.Last + LocalTracksPosition = request.LocalTracksPosition == "last" + ? LocalTracksPosition.Last : LocalTracksPosition.First }); - + // Convert to JSON format for env var var playlistsJson = JsonSerializer.Serialize( currentPlaylists.Select(p => new[] { p.Name, p.Id, p.LocalTracksPosition.ToString().ToLower() }).ToArray() ); - + // Update .env file var updateRequest = new ConfigUpdateRequest { @@ -1563,10 +1837,10 @@ public class PlaylistController : ControllerBase ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson } }; - + return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates); } - + /// /// Remove a playlist from the configuration /// @@ -1575,23 +1849,23 @@ public class PlaylistController : ControllerBase { var decodedName = Uri.UnescapeDataString(name); _logger.LogInformation("Removing playlist: {Name}", decodedName); - + // Read current playlists from .env file (not stale in-memory config) var currentPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); var playlist = currentPlaylists.FirstOrDefault(p => p.Name == decodedName); - + if (playlist == null) { return NotFound(new { error = "Playlist not found" }); } - + currentPlaylists.Remove(playlist); - + // Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last"],...] var playlistsJson = JsonSerializer.Serialize( currentPlaylists.Select(p => new[] { p.Name, p.Id, p.JellyfinId, p.LocalTracksPosition.ToString().ToLower() }).ToArray() ); - + // Update .env file var updateRequest = new ConfigUpdateRequest { @@ -1600,11 +1874,11 @@ public class PlaylistController : ControllerBase ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson } }; - + return await _helperService.UpdateEnvConfigAsync(updateRequest.Updates); } - - + + /// /// Save lyrics mapping to file for persistence across restarts. /// Lyrics mappings NEVER expire - they are permanent user decisions. diff --git a/allstarr/Controllers/ScrobblingAdminController.cs b/allstarr/Controllers/ScrobblingAdminController.cs index 3f7f791..2392f61 100644 --- a/allstarr/Controllers/ScrobblingAdminController.cs +++ b/allstarr/Controllers/ScrobblingAdminController.cs @@ -23,7 +23,7 @@ public class ScrobblingAdminController : ControllerBase private readonly ILogger _logger; private readonly HttpClient _httpClient; private readonly AdminHelperService _adminHelper; - + public ScrobblingAdminController( IOptions settings, IConfiguration configuration, @@ -37,20 +37,21 @@ public class ScrobblingAdminController : ControllerBase _httpClient = httpClientFactory.CreateClient("LastFm"); _adminHelper = adminHelper; } - + /// /// Gets current scrobbling configuration status. /// [HttpGet("status")] public IActionResult GetStatus() { - var hasApiCredentials = !string.IsNullOrEmpty(_settings.LastFm.ApiKey) && + var hasApiCredentials = !string.IsNullOrEmpty(_settings.LastFm.ApiKey) && !string.IsNullOrEmpty(_settings.LastFm.SharedSecret); - + return Ok(new { Enabled = _settings.Enabled, LocalTracksEnabled = _settings.LocalTracksEnabled, + SyntheticLocalPlayedSignalEnabled = _settings.SyntheticLocalPlayedSignalEnabled, LastFm = new { Enabled = _settings.LastFm.Enabled, @@ -58,8 +59,8 @@ public class ScrobblingAdminController : ControllerBase HasApiKey = hasApiCredentials, HasSessionKey = !string.IsNullOrEmpty(_settings.LastFm.SessionKey), Username = _settings.LastFm.Username, - UsingHardcodedCredentials = hasApiCredentials && - _settings.LastFm.ApiKey == "cb3bdcd415fcb40cd572b137b2b255f5" + UsingHardcodedCredentials = hasApiCredentials && + _settings.LastFm.ApiKey == LastFmSettings.DefaultApiKey }, ListenBrainz = new { @@ -69,7 +70,7 @@ public class ScrobblingAdminController : ControllerBase } }); } - + /// /// Authenticate with Last.fm using credentials from .env file. /// Uses hardcoded API credentials from Jellyfin Last.fm plugin for convenience. @@ -80,23 +81,18 @@ public class ScrobblingAdminController : ControllerBase // Get username and password from settings (loaded from .env) var username = _settings.LastFm.Username; var password = _settings.LastFm.Password; - + if (string.IsNullOrEmpty(username) || string.IsNullOrEmpty(password)) { return BadRequest(new { error = "Username and password must be set in .env file (SCROBBLING_LASTFM_USERNAME and SCROBBLING_LASTFM_PASSWORD)" }); } - + // Check if API credentials are available if (string.IsNullOrEmpty(_settings.LastFm.ApiKey) || string.IsNullOrEmpty(_settings.LastFm.SharedSecret)) { return BadRequest(new { error = "Last.fm API credentials not configured. This should not happen - please report this bug." }); } - - _logger.LogInformation("🔍 DEBUG: Password from settings: '{Password}' (length: {Length})", - password, password.Length); - _logger.LogInformation("🔍 DEBUG: Password bytes: {Bytes}", - string.Join(" ", System.Text.Encoding.UTF8.GetBytes(password).Select(b => b.ToString("X2")))); - + try { // Build parameters for auth.getMobileSession @@ -107,51 +103,48 @@ public class ScrobblingAdminController : ControllerBase ["username"] = username, ["password"] = password }; - + // Generate signature var signature = GenerateSignature(parameters, _settings.LastFm.SharedSecret); parameters["api_sig"] = signature; - - _logger.LogInformation("🔍 DEBUG: Signature: {Signature}", signature); - + // Send POST request over HTTPS var content = new FormUrlEncodedContent(parameters); var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content); var responseBody = await response.Content.ReadAsStringAsync(); - - _logger.LogInformation("🔍 DEBUG: Last.fm response: {Status} - {Body}", - response.StatusCode, responseBody); - + + _logger.LogInformation("Last.fm authentication response status: {StatusCode}", response.StatusCode); + // Parse response var doc = XDocument.Parse(responseBody); var root = doc.Root; - + if (root?.Attribute("status")?.Value == "failed") { var errorElement = root.Element("error"); var errorCode = errorElement?.Attribute("code")?.Value; var errorMessage = errorElement?.Value ?? "Unknown error"; - + if (errorCode == "4") { return BadRequest(new { error = "Invalid username or password" }); } - + return BadRequest(new { error = $"Last.fm error: {errorMessage}" }); } - + // Extract session info var sessionElement = root?.Element("session"); var sessionKey = sessionElement?.Element("key")?.Value; var authenticatedUsername = sessionElement?.Element("name")?.Value; - + if (string.IsNullOrEmpty(sessionKey)) { return BadRequest(new { error = "Failed to get session key from Last.fm response" }); } - + _logger.LogInformation("Successfully authenticated Last.fm user: {Username}", authenticatedUsername); - + // Save session key to .env file try { @@ -159,24 +152,22 @@ public class ScrobblingAdminController : ControllerBase { ["SCROBBLING_LASTFM_SESSION_KEY"] = sessionKey }; - + await _adminHelper.UpdateEnvConfigAsync(updates); _logger.LogInformation("Session key saved to .env file"); } catch (Exception saveEx) { _logger.LogError(saveEx, "Failed to save session key to .env file"); - return StatusCode(500, new { - error = "Authentication successful but failed to save session key", - sessionKey = sessionKey, - details = saveEx.Message + return StatusCode(500, new { + error = "Authentication succeeded but failed to save session key", + message = "The session key could not be persisted. Check server logs and retry." }); } - + return Ok(new { Success = true, - SessionKey = sessionKey, Username = authenticatedUsername, Message = "Authentication successful! Session key saved. Please restart the container for changes to take effect." }); @@ -184,10 +175,10 @@ public class ScrobblingAdminController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error authenticating with Last.fm"); - return StatusCode(500, new { error = $"Error: {ex.Message}" }); + return StatusCode(500, new { error = "Failed to authenticate with Last.fm" }); } } - + /// /// DEPRECATED: OAuth method - use /authenticate instead for simpler username/password auth. /// Step 1: Get Last.fm authentication URL for user to authorize the app. @@ -195,12 +186,12 @@ public class ScrobblingAdminController : ControllerBase [HttpGet("lastfm/auth-url")] public IActionResult GetLastFmAuthUrl() { - return BadRequest(new { + return BadRequest(new { error = "OAuth authentication is deprecated. Use POST /lastfm/authenticate with username and password instead.", hint = "This is simpler and doesn't require a callback URL." }); } - + /// /// DEPRECATED: OAuth method - use /authenticate instead. /// Step 2: Exchange Last.fm auth token for session key. @@ -208,12 +199,12 @@ public class ScrobblingAdminController : ControllerBase [HttpPost("lastfm/get-session")] public IActionResult GetLastFmSession([FromBody] GetSessionRequest request) { - return BadRequest(new { + return BadRequest(new { error = "OAuth authentication is deprecated. Use POST /lastfm/authenticate with username and password instead.", hint = "This is simpler and doesn't require a callback URL." }); } - + /// /// Test Last.fm connection with current configuration. /// @@ -224,14 +215,14 @@ public class ScrobblingAdminController : ControllerBase { return BadRequest(new { error = "Last.fm scrobbling is not enabled" }); } - - if (string.IsNullOrEmpty(_settings.LastFm.ApiKey) || - string.IsNullOrEmpty(_settings.LastFm.SharedSecret) || + + if (string.IsNullOrEmpty(_settings.LastFm.ApiKey) || + string.IsNullOrEmpty(_settings.LastFm.SharedSecret) || string.IsNullOrEmpty(_settings.LastFm.SessionKey)) { return BadRequest(new { error = "Last.fm is not fully configured (missing API key, shared secret, or session key)" }); } - + try { // Try to get user info to test the session key @@ -241,35 +232,35 @@ public class ScrobblingAdminController : ControllerBase ["method"] = "user.getInfo", ["sk"] = _settings.LastFm.SessionKey }; - + var signature = GenerateSignature(parameters, _settings.LastFm.SharedSecret); parameters["api_sig"] = signature; - + var content = new FormUrlEncodedContent(parameters); var response = await _httpClient.PostAsync("https://ws.audioscrobbler.com/2.0/", content); var responseBody = await response.Content.ReadAsStringAsync(); - + var doc = XDocument.Parse(responseBody); var root = doc.Root; - + if (root?.Attribute("status")?.Value == "failed") { var errorElement = root.Element("error"); var errorCode = errorElement?.Attribute("code")?.Value; var errorMessage = errorElement?.Value ?? "Unknown error"; - + if (errorCode == "9") { return BadRequest(new { error = "Session key is invalid. Please re-authenticate." }); } - + return BadRequest(new { error = $"Last.fm error: {errorMessage}" }); } - + var userElement = root?.Element("user"); var username = userElement?.Element("name")?.Value; var playcount = userElement?.Element("playcount")?.Value; - + return Ok(new { Success = true, @@ -281,10 +272,10 @@ public class ScrobblingAdminController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error testing Last.fm connection"); - return StatusCode(500, new { error = $"Error: {ex.Message}" }); + return StatusCode(500, new { error = "Failed to test Last.fm connection" }); } } - + /// /// Update local tracks scrobbling setting. /// @@ -297,10 +288,10 @@ public class ScrobblingAdminController : ControllerBase { ["SCROBBLING_LOCAL_TRACKS_ENABLED"] = request.Enabled.ToString().ToLower() }; - + await _adminHelper.UpdateEnvConfigAsync(updates); _logger.LogInformation("Local tracks scrobbling setting updated to: {Enabled}", request.Enabled); - + return Ok(new { Success = true, @@ -311,10 +302,10 @@ public class ScrobblingAdminController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Failed to update local tracks scrobbling setting"); - return StatusCode(500, new { error = $"Error: {ex.Message}" }); + return StatusCode(500, new { error = "Failed to update local tracks scrobbling setting" }); } } - + /// /// Validate ListenBrainz user token. /// @@ -325,30 +316,30 @@ public class ScrobblingAdminController : ControllerBase { return BadRequest(new { error = "User token is required" }); } - + try { var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token"); httpRequest.Headers.Add("Authorization", $"Token {request.UserToken}"); - + var response = await _httpClient.SendAsync(httpRequest); var responseBody = await response.Content.ReadAsStringAsync(); - + if (!response.IsSuccessStatusCode) { return BadRequest(new { error = "Invalid user token" }); } - + var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody); var valid = jsonDoc.RootElement.GetProperty("valid").GetBoolean(); - + if (!valid) { return BadRequest(new { error = "Invalid user token" }); } - + var username = jsonDoc.RootElement.GetProperty("user_name").GetString(); - + // Save token to .env file try { @@ -356,21 +347,20 @@ public class ScrobblingAdminController : ControllerBase { ["SCROBBLING_LISTENBRAINZ_USER_TOKEN"] = request.UserToken }; - + await _adminHelper.UpdateEnvConfigAsync(updates); _logger.LogInformation("ListenBrainz token saved to .env file"); } catch (Exception saveEx) { _logger.LogError(saveEx, "Failed to save token to .env file"); - return StatusCode(500, new { - error = "Token validation successful but failed to save", - userToken = request.UserToken, + return StatusCode(500, new { + error = "Token validation succeeded but failed to save", username = username, - details = saveEx.Message + message = "The token could not be persisted. Check server logs and retry." }); } - + return Ok(new { Success = true, @@ -382,10 +372,10 @@ public class ScrobblingAdminController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error validating ListenBrainz token"); - return StatusCode(500, new { error = $"Error: {ex.Message}" }); + return StatusCode(500, new { error = "Failed to validate ListenBrainz token" }); } } - + /// /// Test ListenBrainz connection with current configuration. /// @@ -396,35 +386,35 @@ public class ScrobblingAdminController : ControllerBase { return BadRequest(new { error = "ListenBrainz scrobbling is not enabled" }); } - + if (string.IsNullOrEmpty(_settings.ListenBrainz.UserToken)) { return BadRequest(new { error = "ListenBrainz user token is not configured" }); } - + try { var httpRequest = new HttpRequestMessage(HttpMethod.Get, "https://api.listenbrainz.org/1/validate-token"); httpRequest.Headers.Add("Authorization", $"Token {_settings.ListenBrainz.UserToken}"); - + var response = await _httpClient.SendAsync(httpRequest); var responseBody = await response.Content.ReadAsStringAsync(); - + if (!response.IsSuccessStatusCode) { return BadRequest(new { error = "Invalid user token" }); } - + var jsonDoc = System.Text.Json.JsonDocument.Parse(responseBody); var valid = jsonDoc.RootElement.GetProperty("valid").GetBoolean(); - + if (!valid) { return BadRequest(new { error = "Invalid user token" }); } - + var username = jsonDoc.RootElement.GetProperty("user_name").GetString(); - + return Ok(new { Success = true, @@ -435,78 +425,26 @@ public class ScrobblingAdminController : ControllerBase catch (Exception ex) { _logger.LogError(ex, "Error testing ListenBrainz connection"); - return StatusCode(500, new { error = $"Error: {ex.Message}" }); + return StatusCode(500, new { error = "Failed to test ListenBrainz connection" }); } } - - /// - /// Debug endpoint to test authentication parameters without actually calling Last.fm. - /// Shows what would be sent to Last.fm for debugging. - /// - [HttpPost("lastfm/debug-auth")] - public IActionResult DebugAuth([FromBody] AuthenticateRequest request) - { - if (string.IsNullOrEmpty(request.Username) || string.IsNullOrEmpty(request.Password)) - { - return BadRequest(new { error = "Username and password are required" }); - } - - // Build parameters for auth.getMobileSession - var parameters = new Dictionary - { - ["api_key"] = _settings.LastFm.ApiKey, - ["method"] = "auth.getMobileSession", - ["username"] = request.Username, - ["password"] = request.Password - }; - - // Generate signature - var signature = GenerateSignature(parameters, _settings.LastFm.SharedSecret); - - // Build signature string for debugging - var sorted = parameters.OrderBy(kvp => kvp.Key); - var signatureString = new StringBuilder(); - foreach (var kvp in sorted) - { - signatureString.Append(kvp.Key); - signatureString.Append(kvp.Value); - } - signatureString.Append(_settings.LastFm.SharedSecret); - - return Ok(new - { - ApiKey = _settings.LastFm.ApiKey, - SharedSecret = _settings.LastFm.SharedSecret.Substring(0, 8) + "...", - Username = request.Username, - PasswordLength = request.Password.Length, - SignatureString = signatureString.ToString(), - Signature = signature, - CurlCommand = $"curl -X POST \"https://ws.audioscrobbler.com/2.0/\" " + - $"-d \"method=auth.getMobileSession\" " + - $"-d \"username={request.Username}\" " + - $"-d \"password={request.Password}\" " + - $"-d \"api_key={_settings.LastFm.ApiKey}\" " + - $"-d \"api_sig={signature}\" " + - $"-d \"format=json\"" - }); - } - + private string GenerateSignature(Dictionary parameters, string sharedSecret) { var sorted = parameters.OrderBy(kvp => kvp.Key); var signatureString = new StringBuilder(); - + foreach (var kvp in sorted) { signatureString.Append(kvp.Key); signatureString.Append(kvp.Value); } - + signatureString.Append(sharedSecret); - + var bytes = Encoding.UTF8.GetBytes(signatureString.ToString()); var hash = MD5.HashData(bytes); - + // Convert to UPPERCASE hex string (Last.fm requires uppercase) var sb = new StringBuilder(); foreach (byte b in hash) @@ -515,23 +453,17 @@ public class ScrobblingAdminController : ControllerBase } return sb.ToString(); } - - public class AuthenticateRequest - { - public required string Username { get; set; } - public required string Password { get; set; } - } - + public class GetSessionRequest { public required string Token { get; set; } } - + public class ValidateTokenRequest { public required string UserToken { get; set; } } - + public class UpdateLocalTracksRequest { public required bool Enabled { get; set; } diff --git a/allstarr/Controllers/SpotifyAdminController.cs b/allstarr/Controllers/SpotifyAdminController.cs index ec04c9b..3b892a2 100644 --- a/allstarr/Controllers/SpotifyAdminController.cs +++ b/allstarr/Controllers/SpotifyAdminController.cs @@ -19,6 +19,8 @@ public class SpotifyAdminController : ControllerBase { private readonly ILogger _logger; private readonly SpotifyApiClient _spotifyClient; + private readonly SpotifyApiClientFactory _spotifyClientFactory; + private readonly SpotifySessionCookieService _spotifySessionCookieService; private readonly SpotifyMappingService _mappingService; private readonly RedisCacheService _cache; private readonly IServiceProvider _serviceProvider; @@ -29,6 +31,8 @@ public class SpotifyAdminController : ControllerBase public SpotifyAdminController( ILogger logger, SpotifyApiClient spotifyClient, + SpotifyApiClientFactory spotifyClientFactory, + SpotifySessionCookieService spotifySessionCookieService, SpotifyMappingService mappingService, RedisCacheService cache, IServiceProvider serviceProvider, @@ -38,6 +42,8 @@ public class SpotifyAdminController : ControllerBase { _logger = logger; _spotifyClient = spotifyClient; + _spotifyClientFactory = spotifyClientFactory; + _spotifySessionCookieService = spotifySessionCookieService; _mappingService = mappingService; _cache = cache; _serviceProvider = serviceProvider; @@ -47,30 +53,78 @@ public class SpotifyAdminController : ControllerBase } [HttpGet("spotify/user-playlists")] - public async Task GetSpotifyUserPlaylists() + public async Task GetSpotifyUserPlaylists([FromQuery] string? userId = null) { - if (!_spotifyApiSettings.Enabled || string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie)) + if (!_spotifyApiSettings.Enabled) { - return BadRequest(new { error = "Spotify API not configured. Please set sp_dc session cookie." }); + return BadRequest(new { error = "Spotify API is not enabled." }); } - + + if (!HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) || + sessionObj is not AdminAuthSession session) + { + return Unauthorized(new { error = "Authentication required" }); + } + + var requestedUserId = string.IsNullOrWhiteSpace(userId) ? null : userId.Trim(); + if (!session.IsAdministrator) + { + if (!string.IsNullOrWhiteSpace(requestedUserId) && + !requestedUserId.Equals(session.UserId, StringComparison.OrdinalIgnoreCase)) + { + return StatusCode(StatusCodes.Status403Forbidden, + new { error = "You can only access your own playlist links" }); + } + + requestedUserId = session.UserId; + } + + var cookieScopeUserId = requestedUserId ?? session.UserId; + var sessionCookie = await _spotifySessionCookieService.ResolveSessionCookieAsync(cookieScopeUserId); + if (string.IsNullOrWhiteSpace(sessionCookie)) + { + return BadRequest(new + { + error = "No Spotify session cookie configured for this user.", + message = "Set a user-scoped sp_dc cookie via POST /api/admin/spotify/session-cookie." + }); + } + + SpotifyApiClient spotifyClient = _spotifyClient; + SpotifyApiClient? scopedSpotifyClient = null; + + if (!string.Equals(sessionCookie, _spotifyApiSettings.SessionCookie, StringComparison.Ordinal)) + { + scopedSpotifyClient = _spotifyClientFactory.Create(sessionCookie); + spotifyClient = scopedSpotifyClient; + } + try { - // Get list of already-configured Spotify playlist IDs + // Get list of already-configured Spotify playlist IDs in the selected ownership scope. var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync(); + + var scopedConfiguredPlaylists = configuredPlaylists.AsEnumerable(); + if (!string.IsNullOrWhiteSpace(requestedUserId)) + { + scopedConfiguredPlaylists = scopedConfiguredPlaylists.Where(p => + string.IsNullOrWhiteSpace(p.UserId) || + p.UserId.Equals(requestedUserId, StringComparison.OrdinalIgnoreCase)); + } + var linkedSpotifyIds = new HashSet( - configuredPlaylists.Select(p => p.Id), + scopedConfiguredPlaylists.Select(p => p.Id), StringComparer.OrdinalIgnoreCase ); - + // Use SpotifyApiClient's GraphQL method - much less rate-limited than REST API - var spotifyPlaylists = await _spotifyClient.GetUserPlaylistsAsync(searchName: null); - + var spotifyPlaylists = await spotifyClient.GetUserPlaylistsAsync(searchName: null); + if (spotifyPlaylists == null || spotifyPlaylists.Count == 0) { return Ok(new { playlists = new List() }); } - + var playlists = spotifyPlaylists.Select(p => new { id = p.SpotifyId, @@ -80,16 +134,90 @@ public class SpotifyAdminController : ControllerBase isPublic = p.Public, isLinked = linkedSpotifyIds.Contains(p.SpotifyId) }).ToList(); - + return Ok(new { playlists }); } catch (Exception ex) { _logger.LogError(ex, "Error fetching Spotify user playlists"); - return StatusCode(500, new { error = "Failed to fetch Spotify playlists", details = ex.Message }); + return StatusCode(500, new { error = "Failed to fetch Spotify playlists" }); + } + finally + { + scopedSpotifyClient?.Dispose(); } } - + + [HttpGet("spotify/session-cookie/status")] + public async Task GetSpotifySessionCookieStatus([FromQuery] string? userId = null) + { + if (!HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) || + sessionObj is not AdminAuthSession session) + { + return Unauthorized(new { error = "Authentication required" }); + } + + var requestedUserId = string.IsNullOrWhiteSpace(userId) ? null : userId.Trim(); + if (!session.IsAdministrator) + { + requestedUserId = session.UserId; + } + + var status = await _spotifySessionCookieService.GetCookieStatusAsync(requestedUserId); + var cookieSetDate = string.IsNullOrWhiteSpace(requestedUserId) + ? null + : await _spotifySessionCookieService.GetCookieSetDateAsync(requestedUserId); + + return Ok(new + { + userId = requestedUserId ?? session.UserId, + hasCookie = status.HasCookie, + usingGlobalFallback = status.UsingGlobalFallback, + cookieSetDate = cookieSetDate?.ToString("o") + }); + } + + [HttpPost("spotify/session-cookie")] + public async Task SetSpotifySessionCookie([FromBody] SetSpotifySessionCookieRequest request) + { + if (!HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) || + sessionObj is not AdminAuthSession session) + { + return Unauthorized(new { error = "Authentication required" }); + } + + var targetUserId = string.IsNullOrWhiteSpace(request.UserId) + ? session.UserId + : request.UserId.Trim(); + + if (!session.IsAdministrator && + !targetUserId.Equals(session.UserId, StringComparison.OrdinalIgnoreCase)) + { + return StatusCode(StatusCodes.Status403Forbidden, new + { + error = "You can only update your own Spotify session cookie" + }); + } + + if (string.IsNullOrWhiteSpace(targetUserId)) + { + return BadRequest(new { error = "User ID is required" }); + } + + var saveResult = await _spotifySessionCookieService.SetUserSessionCookieAsync(targetUserId, request.SessionCookie); + if (saveResult is ObjectResult { StatusCode: >= 400 } failure) + { + return failure; + } + + return Ok(new + { + success = true, + message = "Spotify session cookie saved for user scope.", + userId = targetUserId + }); + } + /// /// Get all playlists from Jellyfin /// @@ -104,7 +232,7 @@ public class SpotifyAdminController : ControllerBase } _logger.LogInformation("Manual Spotify sync triggered via admin endpoint"); - + // Find the SpotifyMissingTracksFetcher service var fetcherService = hostedServices .OfType() @@ -121,9 +249,9 @@ public class SpotifyAdminController : ControllerBase try { // Use reflection to call the private ExecuteOnceAsync method - var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync", + var method = fetcherService.GetType().GetMethod("ExecuteOnceAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - + if (method != null) { await (Task)method.Invoke(fetcherService, new object[] { CancellationToken.None })!; @@ -140,7 +268,7 @@ public class SpotifyAdminController : ControllerBase } }); - return Ok(new { + return Ok(new { message = "Spotify sync started in background", timestamp = DateTime.UtcNow }); @@ -166,7 +294,7 @@ public class SpotifyAdminController : ControllerBase } _logger.LogInformation("Manual Spotify track matching triggered via admin endpoint"); - + // Find the SpotifyTrackMatchingService var matchingService = hostedServices .OfType() @@ -183,9 +311,9 @@ public class SpotifyAdminController : ControllerBase try { // Use reflection to call the private ExecuteOnceAsync method - var method = matchingService.GetType().GetMethod("ExecuteOnceAsync", + var method = matchingService.GetType().GetMethod("ExecuteOnceAsync", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - + if (method != null) { await (Task)method.Invoke(matchingService, new object[] { CancellationToken.None })!; @@ -202,7 +330,7 @@ public class SpotifyAdminController : ControllerBase } }); - return Ok(new { + return Ok(new { message = "Spotify track matching started in background", timestamp = DateTime.UtcNow }); @@ -223,7 +351,7 @@ public class SpotifyAdminController : ControllerBase try { var clearedKeys = new List(); - + // Clear Redis cache for all configured playlists foreach (var playlist in _spotifyImportSettings.Playlists) { @@ -233,17 +361,17 @@ public class SpotifyAdminController : ControllerBase CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name), CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name) }; - + foreach (var key in keys) { await _cache.DeleteAsync(key); clearedKeys.Add(key); } } - + _logger.LogDebug("Cleared Spotify cache for {Count} keys via admin endpoint", clearedKeys.Count); - - return Ok(new { + + return Ok(new { message = "Spotify cache cleared successfully", clearedKeys = clearedKeys, timestamp = DateTime.UtcNow @@ -263,8 +391,8 @@ public class SpotifyAdminController : ControllerBase /// [HttpGet("spotify/mappings")] public async Task GetSpotifyMappings( - [FromQuery] int page = 1, - [FromQuery] int pageSize = 50, + [FromQuery] int page = 1, + [FromQuery] int pageSize = 50, [FromQuery] bool enrichMetadata = true, [FromQuery] string? targetType = null, [FromQuery] string? source = null, @@ -277,28 +405,28 @@ public class SpotifyAdminController : ControllerBase // Get all mappings (we'll filter and sort in memory for now) var allMappings = await _mappingService.GetAllMappingsAsync(0, int.MaxValue); var stats = await _mappingService.GetStatsAsync(); - + // Enrich metadata for external tracks that are missing it if (enrichMetadata) { await EnrichExternalMappingsMetadataAsync(allMappings); } - + // Apply filters var filteredMappings = allMappings.AsEnumerable(); - + if (!string.IsNullOrEmpty(targetType) && targetType != "all") { - filteredMappings = filteredMappings.Where(m => + filteredMappings = filteredMappings.Where(m => m.TargetType.Equals(targetType, StringComparison.OrdinalIgnoreCase)); } - + if (!string.IsNullOrEmpty(source) && source != "all") { - filteredMappings = filteredMappings.Where(m => + filteredMappings = filteredMappings.Where(m => m.Source.Equals(source, StringComparison.OrdinalIgnoreCase)); } - + if (!string.IsNullOrEmpty(search)) { var searchLower = search.ToLower(); @@ -307,43 +435,43 @@ public class SpotifyAdminController : ControllerBase (m.Metadata?.Title?.ToLower().Contains(searchLower) ?? false) || (m.Metadata?.Artist?.ToLower().Contains(searchLower) ?? false)); } - + // Apply sorting if (!string.IsNullOrEmpty(sortBy)) { var isDescending = sortOrder?.ToLower() == "desc"; - + filteredMappings = sortBy.ToLower() switch { - "title" => isDescending - ? filteredMappings.OrderByDescending(m => m.Metadata?.Title ?? "") + "title" => isDescending + ? filteredMappings.OrderByDescending(m => m.Metadata?.Title ?? "") : filteredMappings.OrderBy(m => m.Metadata?.Title ?? ""), - "artist" => isDescending - ? filteredMappings.OrderByDescending(m => m.Metadata?.Artist ?? "") + "artist" => isDescending + ? filteredMappings.OrderByDescending(m => m.Metadata?.Artist ?? "") : filteredMappings.OrderBy(m => m.Metadata?.Artist ?? ""), - "spotifyid" => isDescending - ? filteredMappings.OrderByDescending(m => m.SpotifyId) + "spotifyid" => isDescending + ? filteredMappings.OrderByDescending(m => m.SpotifyId) : filteredMappings.OrderBy(m => m.SpotifyId), - "type" => isDescending - ? filteredMappings.OrderByDescending(m => m.TargetType) + "type" => isDescending + ? filteredMappings.OrderByDescending(m => m.TargetType) : filteredMappings.OrderBy(m => m.TargetType), - "source" => isDescending - ? filteredMappings.OrderByDescending(m => m.Source) + "source" => isDescending + ? filteredMappings.OrderByDescending(m => m.Source) : filteredMappings.OrderBy(m => m.Source), - "created" => isDescending - ? filteredMappings.OrderByDescending(m => m.CreatedAt) + "created" => isDescending + ? filteredMappings.OrderByDescending(m => m.CreatedAt) : filteredMappings.OrderBy(m => m.CreatedAt), _ => filteredMappings }; } - + var filteredList = filteredMappings.ToList(); var totalCount = filteredList.Count; - + // Apply pagination var skip = (page - 1) * pageSize; var pagedMappings = filteredList.Skip(skip).Take(pageSize).ToList(); - + return Ok(new { mappings = pagedMappings, @@ -363,7 +491,7 @@ public class SpotifyAdminController : ControllerBase return StatusCode(500, new { error = "Failed to get mappings" }); } } - + /// /// Gets a specific Spotify track mapping /// @@ -377,7 +505,7 @@ public class SpotifyAdminController : ControllerBase { return NotFound(new { error = "Mapping not found" }); } - + return Ok(mapping); } catch (Exception ex) @@ -386,7 +514,7 @@ public class SpotifyAdminController : ControllerBase return StatusCode(500, new { error = "Failed to get mapping" }); } } - + /// /// Creates or updates a Spotify track mapping (manual override) /// @@ -403,7 +531,7 @@ public class SpotifyAdminController : ControllerBase ArtworkUrl = request.Metadata.ArtworkUrl, DurationMs = request.Metadata.DurationMs } : null; - + var success = await _mappingService.SaveManualMappingAsync( request.SpotifyId, request.TargetType, @@ -411,23 +539,23 @@ public class SpotifyAdminController : ControllerBase request.ExternalProvider, request.ExternalId, metadata); - + if (success) { - _logger.LogInformation("Saved manual mapping: {SpotifyId} → {TargetType}", + _logger.LogInformation("Saved manual mapping: {SpotifyId} → {TargetType}", request.SpotifyId, request.TargetType); return Ok(new { success = true }); } - + return StatusCode(500, new { error = "Failed to save mapping" }); } catch (Exception ex) { _logger.LogError(ex, "Failed to save Spotify mapping"); - return StatusCode(500, new { error = ex.Message }); + return StatusCode(500, new { error = "Failed to save mapping" }); } } - + /// /// Deletes a Spotify track mapping /// @@ -442,7 +570,7 @@ public class SpotifyAdminController : ControllerBase _logger.LogInformation("Deleted mapping for {SpotifyId}", spotifyId); return Ok(new { success = true }); } - + return NotFound(new { error = "Mapping not found" }); } catch (Exception ex) @@ -451,7 +579,7 @@ public class SpotifyAdminController : ControllerBase return StatusCode(500, new { error = "Failed to delete mapping" }); } } - + /// /// Gets statistics about Spotify track mappings /// @@ -469,7 +597,7 @@ public class SpotifyAdminController : ControllerBase return StatusCode(500, new { error = "Failed to get stats" }); } } - + /// /// Enriches metadata for external mappings that are missing title/artist/artwork /// @@ -481,30 +609,30 @@ public class SpotifyAdminController : ControllerBase _logger.LogWarning("No metadata service available for enrichment"); return; } - + foreach (var mapping in mappings) { // Skip if not external or already has metadata - if (mapping.TargetType != "external" || - string.IsNullOrEmpty(mapping.ExternalProvider) || + if (mapping.TargetType != "external" || + string.IsNullOrEmpty(mapping.ExternalProvider) || string.IsNullOrEmpty(mapping.ExternalId)) { continue; } - + // Skip if already has complete metadata - if (mapping.Metadata != null && - !string.IsNullOrEmpty(mapping.Metadata.Title) && + if (mapping.Metadata != null && + !string.IsNullOrEmpty(mapping.Metadata.Title) && !string.IsNullOrEmpty(mapping.Metadata.Artist)) { continue; } - + try { // Fetch track details from external provider var song = await metadataService.GetSongAsync(mapping.ExternalProvider.ToLowerInvariant(), mapping.ExternalId); - + if (song != null) { // Update metadata @@ -512,26 +640,32 @@ public class SpotifyAdminController : ControllerBase { mapping.Metadata = new TrackMetadata(); } - + mapping.Metadata.Title = song.Title; mapping.Metadata.Artist = song.Artist; mapping.Metadata.Album = song.Album; mapping.Metadata.ArtworkUrl = song.CoverArtUrl; mapping.Metadata.DurationMs = song.Duration.HasValue ? song.Duration.Value * 1000 : null; - + // Save enriched metadata back to cache await _mappingService.SaveMappingAsync(mapping); - - _logger.LogDebug("Enriched metadata for {SpotifyId} from {Provider}: {Title} by {Artist}", + + _logger.LogDebug("Enriched metadata for {SpotifyId} from {Provider}: {Title} by {Artist}", mapping.SpotifyId, mapping.ExternalProvider, song.Title, song.Artist); } } catch (Exception ex) { - _logger.LogWarning(ex, "Failed to enrich metadata for {SpotifyId} from {Provider}:{ExternalId}", + _logger.LogWarning(ex, "Failed to enrich metadata for {SpotifyId} from {Provider}:{ExternalId}", mapping.SpotifyId, mapping.ExternalProvider, mapping.ExternalId); } } } - + + public class SetSpotifySessionCookieRequest + { + public required string SessionCookie { get; set; } + public string? UserId { get; set; } + } + } diff --git a/allstarr/Controllers/SubSonicController.cs b/allstarr/Controllers/SubSonicController.cs index 32dba32..1f6369a 100644 --- a/allstarr/Controllers/SubSonicController.cs +++ b/allstarr/Controllers/SubSonicController.cs @@ -30,7 +30,7 @@ public class SubsonicController : ControllerBase private readonly PlaylistSyncService? _playlistSyncService; private readonly RedisCacheService _cache; private readonly ILogger _logger; - + public SubsonicController( IOptions subsonicSettings, IMusicMetadataService metadataService, @@ -79,9 +79,9 @@ public class SubsonicController : ControllerBase var parameters = await ExtractAllParameters(); var query = parameters.GetValueOrDefault("query", ""); var format = parameters.GetValueOrDefault("f", "xml"); - + var cleanQuery = query.Trim().Trim('"'); - + if (string.IsNullOrWhiteSpace(cleanQuery)) { try @@ -103,7 +103,7 @@ public class SubsonicController : ControllerBase int.TryParse(parameters.GetValueOrDefault("albumCount", "20"), out var ac) ? ac : 20, int.TryParse(parameters.GetValueOrDefault("artistCount", "20"), out var arc) ? arc : 20 ); - + // Search playlists if enabled Task> playlistTask = _subsonicSettings.EnableExternalPlaylists ? _metadataService.SearchPlaylistsAsync(cleanQuery, ac) // Use same limit as albums @@ -154,7 +154,7 @@ public class SubsonicController : ControllerBase { _logger.LogError(ex, "Failed to update last write time for {Path}", localPath); } - + var stream = System.IO.File.OpenRead(localPath); return File(stream, GetContentType(localPath), enableRangeProcessing: true); } @@ -166,7 +166,8 @@ public class SubsonicController : ControllerBase } catch (Exception ex) { - return StatusCode(500, new { error = $"Failed to stream: {ex.Message}" }); + _logger.LogError(ex, "Failed to stream external Subsonic item {Id}", id); + return StatusCode(500, new { error = "Failed to stream" }); } } @@ -234,7 +235,7 @@ public class SubsonicController : ControllerBase } var albums = await _metadataService.GetArtistAlbumsAsync(provider!, externalId!); - + // Fill artist info for each album (Deezer API doesn't include it in artist/albums endpoint) foreach (var album in albums) { @@ -247,12 +248,12 @@ public class SubsonicController : ControllerBase album.ArtistId = artist.Id; } } - + return _responseBuilder.CreateArtistResponse(format, artist, albums); } var navidromeResult = await _proxyService.RelaySafeAsync("rest/getArtist", parameters); - + if (!navidromeResult.Success || navidromeResult.Body == null) { return _responseBuilder.CreateError(format, 70, "Artist not found"); @@ -272,7 +273,7 @@ public class SubsonicController : ControllerBase { artistName = artistElement.TryGetProperty("name", out var name) ? name.GetString() ?? "" : ""; artistData = _responseBuilder.ConvertSubsonicJsonElement(artistElement, true); - + if (artistElement.TryGetProperty("album", out var albums)) { foreach (var album in albums.EnumerateArray()) @@ -290,14 +291,14 @@ public class SubsonicController : ControllerBase var deezerArtists = await _metadataService.SearchArtistsAsync(artistName, 1); var deezerAlbums = new List(); - + if (deezerArtists.Count > 0) { var deezerArtist = deezerArtists[0]; if (deezerArtist.Name.Equals(artistName, StringComparison.OrdinalIgnoreCase)) { deezerAlbums = await _metadataService.GetArtistAlbumsAsync("deezer", deezerArtist.ExternalId!); - + // Fill artist info for each album (Deezer API doesn't include it in artist/albums endpoint) // Use local artist ID and name so albums link back to the local artist foreach (var album in deezerAlbums) @@ -362,24 +363,24 @@ public class SubsonicController : ControllerBase { return _responseBuilder.CreateError(format, 10, "Missing id parameter"); } - + // Check if this is an external playlist if (PlaylistIdHelper.IsExternalPlaylist(id)) { try { var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); - + // Get playlist metadata var playlist = await _metadataService.GetPlaylistAsync(provider, externalId); if (playlist == null) { return _responseBuilder.CreateError(format, 70, "Playlist not found"); } - + // Get playlist tracks var tracks = await _metadataService.GetPlaylistTracksAsync(provider, externalId); - + // Add all tracks to playlist cache so when they're played, we know they belong to this playlist if (_playlistSyncService != null) { @@ -391,10 +392,10 @@ public class SubsonicController : ControllerBase _playlistSyncService.AddTrackToPlaylistCache(trackId, id); } } - + _logger.LogDebug("Added {TrackCount} tracks to playlist cache for {PlaylistId}", tracks.Count, id); } - + // Convert to album response (playlist as album) return _responseBuilder.CreatePlaylistAsAlbumResponse(format, playlist, tracks); } @@ -420,7 +421,7 @@ public class SubsonicController : ControllerBase } var navidromeResult = await _proxyService.RelaySafeAsync("rest/getAlbum", parameters); - + if (!navidromeResult.Success || navidromeResult.Body == null) { return _responseBuilder.CreateError(format, 70, "Album not found"); @@ -441,7 +442,7 @@ public class SubsonicController : ControllerBase albumName = albumElement.TryGetProperty("name", out var name) ? name.GetString() ?? "" : ""; artistName = albumElement.TryGetProperty("artist", out var artist) ? artist.GetString() ?? "" : ""; albumData = _responseBuilder.ConvertSubsonicJsonElement(albumElement, true); - + if (albumElement.TryGetProperty("song", out var songs)) { foreach (var song in songs.EnumerateArray()) @@ -460,11 +461,11 @@ public class SubsonicController : ControllerBase var searchQuery = $"{artistName} {albumName}"; var deezerAlbums = await _metadataService.SearchAlbumsAsync(searchQuery, 5); Album? deezerAlbum = null; - + // Find matching album on Deezer (exact match first) foreach (var candidate in deezerAlbums) { - if (candidate.Artist != null && + if (candidate.Artist != null && candidate.Artist.Equals(artistName, StringComparison.OrdinalIgnoreCase) && candidate.Title.Equals(albumName, StringComparison.OrdinalIgnoreCase)) { @@ -478,7 +479,7 @@ public class SubsonicController : ControllerBase { foreach (var candidate in deezerAlbums) { - if (candidate.Artist != null && + if (candidate.Artist != null && candidate.Artist.Contains(artistName, StringComparison.OrdinalIgnoreCase) && (candidate.Title.Contains(albumName, StringComparison.OrdinalIgnoreCase) || albumName.Contains(candidate.Title, StringComparison.OrdinalIgnoreCase))) @@ -510,8 +511,8 @@ public class SubsonicController : ControllerBase } mergedSongs = mergedSongs - .OrderBy(s => s is Dictionary dict && dict.TryGetValue("track", out var track) - ? Convert.ToInt32(track) + .OrderBy(s => s is Dictionary dict && dict.TryGetValue("track", out var track) + ? Convert.ToInt32(track) : 0) .ToList(); @@ -519,7 +520,7 @@ public class SubsonicController : ControllerBase { albumDict["song"] = mergedSongs; albumDict["songCount"] = mergedSongs.Count; - + var totalDuration = 0; foreach (var song in mergedSongs) { @@ -556,7 +557,7 @@ public class SubsonicController : ControllerBase { return NotFound(); } - + // Check if this is a playlist cover art request if (PlaylistIdHelper.IsExternalPlaylist(id)) { @@ -565,35 +566,35 @@ public class SubsonicController : ControllerBase // Check cache first (1 hour TTL for playlist images since they can change) var cacheKey = $"playlist:image:{id}"; var cachedImage = await _cache.GetAsync(cacheKey); - + if (cachedImage != null) { _logger.LogDebug("Serving cached playlist cover art for {Id}", id); return File(cachedImage, "image/jpeg"); } - + var (provider, externalId) = PlaylistIdHelper.ParsePlaylistId(id); var playlist = await _metadataService.GetPlaylistAsync(provider, externalId); - + if (playlist == null || string.IsNullOrEmpty(playlist.CoverUrl)) { return NotFound(); } - + // Download and return the cover image var imageResponse = await new HttpClient().GetAsync(playlist.CoverUrl); if (!imageResponse.IsSuccessStatusCode) { return NotFound(); } - + var imageBytes = await imageResponse.Content.ReadAsByteArrayAsync(); var contentType = imageResponse.Content.Headers.ContentType?.ToString() ?? "image/jpeg"; - + // Cache for configurable duration (playlists can change) await _cache.SetAsync(cacheKey, imageBytes, CacheExtensions.PlaylistImagesTTL); _logger.LogDebug("Cached playlist cover art for {Id}", id); - + return File(imageBytes, contentType); } catch (Exception ex) @@ -620,7 +621,7 @@ public class SubsonicController : ControllerBase } string? coverUrl = null; - + // Use type to determine which API to call first switch (type) { @@ -631,7 +632,7 @@ public class SubsonicController : ControllerBase coverUrl = artist.ImageUrl; } break; - + case "album": var album = await _metadataService.GetAlbumAsync(coverProvider!, coverExternalId!); if (album?.CoverArtUrl != null) @@ -639,7 +640,7 @@ public class SubsonicController : ControllerBase coverUrl = album.CoverArtUrl; } break; - + case "song": default: // For songs, try to get from song first, then album @@ -659,7 +660,7 @@ public class SubsonicController : ControllerBase } break; } - + if (coverUrl != null) { using var httpClient = new HttpClient(); @@ -689,9 +690,9 @@ public class SubsonicController : ControllerBase var isJson = format == "json" || subsonicResult.ContentType?.Contains("json") == true; var (mergedSongs, mergedAlbums, mergedArtists) = _modelMapper.MergeSearchResults( - localSongs, - localAlbums, - localArtists, + localSongs, + localAlbums, + localArtists, externalResult, playlistResult, isJson); @@ -714,7 +715,7 @@ public class SubsonicController : ControllerBase { var ns = XNamespace.Get("http://subsonic.org/restapi"); var searchResult3 = new XElement(ns + "searchResult3"); - + foreach (var artist in mergedArtists.Cast()) { searchResult3.Add(artist); @@ -767,19 +768,19 @@ public class SubsonicController : ControllerBase { var parameters = await ExtractAllParameters(); var format = parameters.GetValueOrDefault("f", "xml"); - + // Check if this is a playlist var playlistId = parameters.GetValueOrDefault("id", ""); - + if (!string.IsNullOrEmpty(playlistId) && PlaylistIdHelper.IsExternalPlaylist(playlistId)) { if (_playlistSyncService == null) { return _responseBuilder.CreateError(format, 0, "Playlist functionality is not enabled"); } - + _logger.LogInformation("Starring external playlist {PlaylistId}, triggering download", playlistId); - + // Trigger playlist download in background _ = Task.Run(async () => { @@ -792,11 +793,11 @@ public class SubsonicController : ControllerBase _logger.LogError(ex, "Failed to download playlist {PlaylistId}", playlistId); } }); - + // Return success response immediately return _responseBuilder.CreateResponse(format, "starred", new { }); } - + // For non-playlist items, relay to real Subsonic server try { @@ -806,7 +807,8 @@ public class SubsonicController : ControllerBase } catch (HttpRequestException ex) { - return _responseBuilder.CreateError(format, 0, $"Error connecting to Subsonic server: {ex.Message}"); + _logger.LogError(ex, "Error connecting to Subsonic server for star operation"); + return _responseBuilder.CreateError(format, 0, "Error connecting to Subsonic server"); } } @@ -817,7 +819,7 @@ public class SubsonicController : ControllerBase { var parameters = await ExtractAllParameters(); var format = parameters.GetValueOrDefault("f", "xml"); - + try { var result = await _proxyService.RelayAsync(endpoint, parameters); @@ -827,7 +829,8 @@ public class SubsonicController : ControllerBase catch (HttpRequestException ex) { // Return Subsonic-compatible error response - return _responseBuilder.CreateError(format, 0, $"Error connecting to Subsonic server: {ex.Message}"); + _logger.LogError(ex, "Error connecting to Subsonic server for endpoint {Endpoint}", endpoint); + return _responseBuilder.CreateError(format, 0, "Error connecting to Subsonic server"); } } -} \ No newline at end of file +} diff --git a/allstarr/Filters/ApiKeyAuthFilter.cs b/allstarr/Filters/ApiKeyAuthFilter.cs deleted file mode 100644 index 6e5f804..0000000 --- a/allstarr/Filters/ApiKeyAuthFilter.cs +++ /dev/null @@ -1,66 +0,0 @@ -using Microsoft.AspNetCore.Mvc; -using Microsoft.AspNetCore.Mvc.Filters; -using Microsoft.Extensions.Options; -using allstarr.Models.Settings; - -namespace allstarr.Filters; - -/// -/// Simple API key authentication filter for admin endpoints. -/// Validates against Jellyfin API key via query parameter or header. -/// -public class ApiKeyAuthFilter : IAsyncActionFilter -{ - private readonly JellyfinSettings _settings; - private readonly ILogger _logger; - - public ApiKeyAuthFilter( - IOptions settings, - ILogger logger) - { - _settings = settings.Value; - _logger = logger; - } - - public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) - { - var request = context.HttpContext.Request; - - // Extract API key from query parameter or header - var apiKey = request.Query["api_key"].FirstOrDefault() - ?? request.Headers["X-Api-Key"].FirstOrDefault() - ?? request.Headers["X-Emby-Token"].FirstOrDefault(); - - // Validate API key - if (string.IsNullOrEmpty(apiKey) || string.IsNullOrEmpty(_settings.ApiKey) || !FixedTimeEquals(apiKey, _settings.ApiKey)) - { - _logger.LogWarning("Unauthorized access attempt to {Path} from {IP}", - request.Path, - context.HttpContext.Connection.RemoteIpAddress); - - context.Result = new UnauthorizedObjectResult(new - { - error = "Unauthorized", - message = "Valid API key required. Provide via ?api_key=YOUR_KEY or X-Api-Key header." - }); - return; - } - - _logger.LogInformation("API key authentication successful for {Path}", request.Path); - await next(); - } - - // Use a robust constant-time comparison by comparing fixed-length hashes of the inputs. - // This avoids leaking lengths and uses the platform's fixed-time compare helper. - private static bool FixedTimeEquals(string a, string b) - { - if (a == null || b == null) return false; - - // Compute SHA-256 hashes and compare them in constant time - using var sha = System.Security.Cryptography.SHA256.Create(); - var aHash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(a)); - var bHash = sha.ComputeHash(System.Text.Encoding.UTF8.GetBytes(b)); - - return System.Security.Cryptography.CryptographicOperations.FixedTimeEquals(aHash, bHash); - } -} diff --git a/allstarr/Middleware/AdminAuthenticationMiddleware.cs b/allstarr/Middleware/AdminAuthenticationMiddleware.cs new file mode 100644 index 0000000..f0f500e --- /dev/null +++ b/allstarr/Middleware/AdminAuthenticationMiddleware.cs @@ -0,0 +1,127 @@ +using System.Text.Json; +using System.Text.RegularExpressions; +using allstarr.Services.Admin; + +namespace allstarr.Middleware; + +/// +/// Enforces Jellyfin-authenticated local sessions for admin API endpoints on port 5275. +/// +public class AdminAuthenticationMiddleware +{ + private const int AdminPort = 5275; + private static readonly Regex PlaylistLinkRoute = new( + @"^/api/admin/jellyfin/playlists/[^/]+/(link|unlink)$", + RegexOptions.IgnoreCase | RegexOptions.Compiled); + + private readonly RequestDelegate _next; + private readonly AdminAuthSessionService _sessionService; + private readonly ILogger _logger; + + public AdminAuthenticationMiddleware( + RequestDelegate next, + AdminAuthSessionService sessionService, + ILogger logger) + { + _next = next; + _sessionService = sessionService; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + var path = context.Request.Path.Value ?? string.Empty; + if (!path.StartsWith("/api/admin", StringComparison.OrdinalIgnoreCase)) + { + await _next(context); + return; + } + + // Keep 404 behavior from AdminPortFilter for non-admin-port requests. + if (context.Connection.LocalPort != AdminPort) + { + await _next(context); + return; + } + + if (path.StartsWith("/api/admin/auth", StringComparison.OrdinalIgnoreCase)) + { + await _next(context); + return; + } + + if (!context.Request.Cookies.TryGetValue(AdminAuthSessionService.SessionCookieName, out var sessionId) || + !_sessionService.TryGetValidSession(sessionId, out var session)) + { + context.Response.Cookies.Delete(AdminAuthSessionService.SessionCookieName); + await WriteUnauthorizedResponse(context); + return; + } + + context.Items[AdminAuthSessionService.HttpContextSessionItemKey] = session; + + if (!session.IsAdministrator && !IsAllowedForNonAdministrator(context.Request)) + { + await WriteForbiddenResponse(context); + return; + } + + await _next(context); + } + + private static bool IsAllowedForNonAdministrator(HttpRequest request) + { + var path = request.Path.Value ?? string.Empty; + var method = request.Method; + + if (HttpMethods.IsGet(method) && + path.Equals("/api/admin/jellyfin/playlists", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + if (HttpMethods.IsPost(method) || HttpMethods.IsDelete(method)) + { + if (PlaylistLinkRoute.IsMatch(path)) + { + return true; + } + } + + if (HttpMethods.IsGet(method) && + path.Equals("/api/admin/spotify/user-playlists", StringComparison.OrdinalIgnoreCase)) + { + return true; + } + + return false; + } + + private async Task WriteUnauthorizedResponse(HttpContext context) + { + _logger.LogDebug("AdminAuthenticationMiddleware rejected unauthenticated request to {Path}", + context.Request.Path); + + context.Response.StatusCode = StatusCodes.Status401Unauthorized; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(new + { + error = "Authentication required", + message = "Please sign in with your Jellyfin account." + })); + } + + private async Task WriteForbiddenResponse(HttpContext context) + { + _logger.LogDebug("AdminAuthenticationMiddleware rejected unauthorized request to {Path}", + context.Request.Path); + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsync(JsonSerializer.Serialize(new + { + error = "Administrator permissions required", + message = "This action is restricted to Jellyfin administrators." + })); + } +} diff --git a/allstarr/Middleware/AdminNetworkAllowlistMiddleware.cs b/allstarr/Middleware/AdminNetworkAllowlistMiddleware.cs new file mode 100644 index 0000000..066a7a8 --- /dev/null +++ b/allstarr/Middleware/AdminNetworkAllowlistMiddleware.cs @@ -0,0 +1,52 @@ +using System.Net; +using allstarr.Services.Common; + +namespace allstarr.Middleware; + +/// +/// Restricts admin port (5275) access to loopback and configured trusted subnets. +/// +public class AdminNetworkAllowlistMiddleware +{ + private const int AdminPort = 5275; + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly List _trustedSubnets; + + public AdminNetworkAllowlistMiddleware( + RequestDelegate next, + IConfiguration configuration, + ILogger logger) + { + _next = next; + _logger = logger; + _trustedSubnets = AdminNetworkBindingPolicy.ParseTrustedSubnets(configuration); + } + + public async Task InvokeAsync(HttpContext context) + { + if (context.Connection.LocalPort != AdminPort) + { + await _next(context); + return; + } + + var remoteIp = context.Connection.RemoteIpAddress; + if (AdminNetworkBindingPolicy.IsRemoteIpAllowed(remoteIp, _trustedSubnets)) + { + await _next(context); + return; + } + + _logger.LogWarning("Blocked admin-port request from untrusted IP {RemoteIp} to {Path}", + remoteIp?.ToString() ?? "(null)", context.Request.Path); + + context.Response.StatusCode = StatusCodes.Status403Forbidden; + context.Response.ContentType = "application/json"; + await context.Response.WriteAsJsonAsync(new + { + error = "Access denied", + message = "Admin UI is restricted to localhost and configured trusted subnets." + }); + } +} diff --git a/allstarr/Middleware/AdminStaticFilesMiddleware.cs b/allstarr/Middleware/AdminStaticFilesMiddleware.cs index a95a804..cdd089d 100644 --- a/allstarr/Middleware/AdminStaticFilesMiddleware.cs +++ b/allstarr/Middleware/AdminStaticFilesMiddleware.cs @@ -1,6 +1,3 @@ -using Microsoft.AspNetCore.StaticFiles; -using Microsoft.Extensions.FileProviders; - namespace allstarr.Middleware; /// @@ -12,27 +9,42 @@ public class AdminStaticFilesMiddleware private readonly RequestDelegate _next; private readonly IWebHostEnvironment _env; private const int AdminPort = 5275; - + private readonly string _webRootPath; + private readonly string _webRootPathWithSeparator; + public AdminStaticFilesMiddleware( RequestDelegate next, IWebHostEnvironment env) { _next = next; _env = env; + var webRoot = string.IsNullOrWhiteSpace(_env.WebRootPath) + ? Path.Combine(_env.ContentRootPath, "wwwroot") + : _env.WebRootPath; + _webRootPath = Path.GetFullPath(webRoot); + _webRootPathWithSeparator = _webRootPath.EndsWith(Path.DirectorySeparatorChar) + ? _webRootPath + : _webRootPath + Path.DirectorySeparatorChar; } - + public async Task InvokeAsync(HttpContext context) { var port = context.Connection.LocalPort; - + if (port == AdminPort) { var path = context.Request.Path.Value ?? "/"; - + + if (!HttpMethods.IsGet(context.Request.Method) && !HttpMethods.IsHead(context.Request.Method)) + { + await _next(context); + return; + } + // Serve index.html for root path if (path == "/" || path == "/index.html") { - var indexPath = Path.Combine(_env.WebRootPath, "index.html"); + var indexPath = Path.Combine(_webRootPath, "index.html"); if (File.Exists(indexPath)) { context.Response.ContentType = "text/html"; @@ -40,22 +52,66 @@ public class AdminStaticFilesMiddleware return; } } - - // Try to serve static file from wwwroot - var filePath = Path.Combine(_env.WebRootPath, path.TrimStart('/')); - if (File.Exists(filePath)) + + // Canonicalize and enforce root boundary to block traversal attempts. + var candidatePath = ResolveStaticFilePath(path); + if (candidatePath == null) { - var contentType = GetContentType(filePath); + context.Response.StatusCode = StatusCodes.Status404NotFound; + return; + } + + if (File.Exists(candidatePath)) + { + var contentType = GetContentType(candidatePath); context.Response.ContentType = contentType; - await context.Response.SendFileAsync(filePath); + await context.Response.SendFileAsync(candidatePath); return; } } - + // Not admin port or file not found - continue pipeline await _next(context); } - + + private string? ResolveStaticFilePath(string requestPath) + { + var relativePath = requestPath.TrimStart('/'); + if (string.IsNullOrWhiteSpace(relativePath)) + { + return null; + } + + try + { + var normalizedRelativePath = relativePath.Replace('/', Path.DirectorySeparatorChar); + var candidatePath = Path.GetFullPath(Path.Combine(_webRootPath, normalizedRelativePath)); + + if (string.Equals(candidatePath, _webRootPath, GetPathComparison())) + { + return null; + } + + if (!candidatePath.StartsWith(_webRootPathWithSeparator, GetPathComparison())) + { + return null; + } + + return candidatePath; + } + catch (Exception) + { + return null; + } + } + + private static StringComparison GetPathComparison() + { + return OperatingSystem.IsWindows() + ? StringComparison.OrdinalIgnoreCase + : StringComparison.Ordinal; + } + private static string GetContentType(string filePath) { var ext = Path.GetExtension(filePath).ToLowerInvariant(); diff --git a/allstarr/Middleware/RequestLoggingMiddleware.cs b/allstarr/Middleware/RequestLoggingMiddleware.cs index 5c28fd1..547b4b7 100644 --- a/allstarr/Middleware/RequestLoggingMiddleware.cs +++ b/allstarr/Middleware/RequestLoggingMiddleware.cs @@ -12,51 +12,58 @@ public class RequestLoggingMiddleware private readonly RequestDelegate _next; private readonly ILogger _logger; private readonly IConfiguration _configuration; - + public RequestLoggingMiddleware( - RequestDelegate next, + RequestDelegate next, ILogger logger, IConfiguration configuration) { _next = next; _logger = logger; _configuration = configuration; - + // Log initialization status var initialValue = _configuration.GetValue("Debug:LogAllRequests"); + var initialRedactionValue = _configuration.GetValue("Debug:RedactSensitiveRequestValues", false); _logger.LogWarning("🔍 RequestLoggingMiddleware initialized - LogAllRequests={LogAllRequests}", initialValue); - + if (initialValue) { - _logger.LogWarning("🔍 Request logging ENABLED - all HTTP requests will be logged"); + _logger.LogWarning( + "🔍 Request logging ENABLED - all HTTP requests will be logged (RedactSensitiveRequestValues={Redact})", + initialRedactionValue); } else { _logger.LogInformation("Request logging disabled (set DEBUG_LOG_ALL_REQUESTS=true to enable)"); } } - + public async Task InvokeAsync(HttpContext context) { // Check configuration on every request to allow dynamic toggling var logAllRequests = _configuration.GetValue("Debug:LogAllRequests"); - + var redactSensitiveValues = _configuration.GetValue("Debug:RedactSensitiveRequestValues", false); + if (!logAllRequests) { await _next(context); return; } - + var stopwatch = Stopwatch.StartNew(); var request = context.Request; - + var queryStringForLog = redactSensitiveValues + ? BuildMaskedQueryString(request.QueryString.Value) + : request.QueryString.Value ?? string.Empty; + // Log request details var requestLog = new StringBuilder(); - requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{request.QueryString}"); + requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{queryStringForLog}"); requestLog.AppendLine($" Host: {request.Host}"); requestLog.AppendLine($" Content-Type: {request.ContentType ?? "(none)"}"); requestLog.AppendLine($" Content-Length: {request.ContentLength?.ToString() ?? "(none)"}"); - + // Log important headers if (request.Headers.ContainsKey("User-Agent")) { @@ -64,15 +71,18 @@ public class RequestLoggingMiddleware } if (request.Headers.ContainsKey("X-Emby-Authorization")) { - requestLog.AppendLine($" X-Emby-Authorization: {MaskAuthHeader(request.Headers["X-Emby-Authorization"]!)}"); + var value = request.Headers["X-Emby-Authorization"].ToString(); + requestLog.AppendLine($" X-Emby-Authorization: {(redactSensitiveValues ? MaskAuthHeader(value) : value)}"); } if (request.Headers.ContainsKey("Authorization")) { - requestLog.AppendLine($" Authorization: {MaskAuthHeader(request.Headers["Authorization"]!)}"); + var value = request.Headers["Authorization"].ToString(); + requestLog.AppendLine($" Authorization: {(redactSensitiveValues ? MaskAuthHeader(value) : value)}"); } if (request.Headers.ContainsKey("X-Emby-Token")) { - requestLog.AppendLine($" X-Emby-Token: ***"); + var value = request.Headers["X-Emby-Token"].ToString(); + requestLog.AppendLine($" X-Emby-Token: {(redactSensitiveValues ? "***" : value)}"); } if (request.Headers.ContainsKey("X-Emby-Device-Id")) { @@ -82,18 +92,18 @@ public class RequestLoggingMiddleware { requestLog.AppendLine($" X-Emby-Client: {request.Headers["X-Emby-Client"]}"); } - + _logger.LogInformation(requestLog.ToString().TrimEnd()); - + // Capture response status var originalBodyStream = context.Response.Body; - + try { await _next(context); - + stopwatch.Stop(); - + // Log response _logger.LogInformation( "📤 HTTP {Method} {Path} → {StatusCode} ({ElapsedMs}ms)", @@ -105,7 +115,7 @@ public class RequestLoggingMiddleware catch (Exception ex) { stopwatch.Stop(); - _logger.LogError(ex, + _logger.LogError(ex, "❌ HTTP {Method} {Path} → EXCEPTION ({ElapsedMs}ms)", request.Method, request.Path, @@ -113,13 +123,13 @@ public class RequestLoggingMiddleware throw; } } - + private static string MaskAuthHeader(string authHeader) { // Mask tokens in auth headers for security if (string.IsNullOrEmpty(authHeader)) return "(empty)"; - + // For MediaBrowser format: MediaBrowser Client="...", Token="..." if (authHeader.Contains("Token=", StringComparison.OrdinalIgnoreCase)) { @@ -138,19 +148,39 @@ public class RequestLoggingMiddleware } return string.Join(", ", masked); } - + // For Bearer tokens if (authHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) { return "Bearer ***"; } - + // For other formats, just mask everything after first 10 chars if (authHeader.Length > 10) { return authHeader.Substring(0, 10) + "***"; } - + return "***"; } + + private static string BuildMaskedQueryString(string? queryString) + { + if (string.IsNullOrWhiteSpace(queryString)) + { + return string.Empty; + } + + var query = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString); + if (query.Count == 0) + { + return string.Empty; + } + + var redactedParts = query.Keys + .Select(key => $"{key}=") + .ToArray(); + + return "?" + string.Join("&", redactedParts); + } } diff --git a/allstarr/Middleware/WebSocketProxyMiddleware.cs b/allstarr/Middleware/WebSocketProxyMiddleware.cs index 47b2693..b1dc2ac 100644 --- a/allstarr/Middleware/WebSocketProxyMiddleware.cs +++ b/allstarr/Middleware/WebSocketProxyMiddleware.cs @@ -26,7 +26,7 @@ public class WebSocketProxyMiddleware _settings = settings.Value; _logger = logger; _sessionManager = sessionManager; - + _logger.LogInformation("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url); } @@ -35,9 +35,9 @@ public class WebSocketProxyMiddleware // Log ALL requests for debugging var path = context.Request.Path.Value ?? ""; var isWebSocket = context.WebSockets.IsWebSocketRequest; - + // Log any request that might be WebSocket-related - if (path.Contains("socket", StringComparison.OrdinalIgnoreCase) || + if (path.Contains("socket", StringComparison.OrdinalIgnoreCase) || path.Contains("ws", StringComparison.OrdinalIgnoreCase) || isWebSocket || context.Request.Headers.ContainsKey("Upgrade")) @@ -54,7 +54,7 @@ public class WebSocketProxyMiddleware if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) && context.WebSockets.IsWebSocketRequest) { - _logger.LogDebug("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}", + _logger.LogDebug("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}", context.Connection.RemoteIpAddress); await HandleWebSocketProxyAsync(context); @@ -94,10 +94,6 @@ public class WebSocketProxyMiddleware _logger.LogDebug("🔍 WEBSOCKET: Client WebSocket for device {DeviceId}", deviceId); } - // Accept the WebSocket connection from the client - clientWebSocket = await context.WebSockets.AcceptWebSocketAsync(); - _logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted"); - // Build Jellyfin WebSocket URL var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? ""; var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://"; @@ -146,6 +142,11 @@ public class WebSocketProxyMiddleware await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted); _logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket"); + // Only accept the client socket after upstream auth/handshake succeeds. + // This ensures auth failures surface as HTTP status (401/403) instead of misleading 101 upgrades. + clientWebSocket = await context.WebSockets.AcceptWebSocketAsync(); + _logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted"); + // Start bidirectional proxying var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted); var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted); @@ -157,10 +158,25 @@ public class WebSocketProxyMiddleware } catch (WebSocketException wsEx) { - // 403 is expected when tokens expire or session ends - don't spam logs - if (wsEx.Message.Contains("403")) + var isAuthFailure = + wsEx.Message.Contains("403", StringComparison.OrdinalIgnoreCase) || + wsEx.Message.Contains("401", StringComparison.OrdinalIgnoreCase) || + wsEx.Message.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) || + wsEx.Message.Contains("Forbidden", StringComparison.OrdinalIgnoreCase); + + if (isAuthFailure) { - _logger.LogWarning("WEBSOCKET: Connection rejected with 403 (token expired or session ended)"); + _logger.LogWarning("WEBSOCKET: Connection rejected by Jellyfin auth (token expired or session ended)"); + if (!context.Response.HasStarted) + { + context.Response.StatusCode = StatusCodes.Status403Forbidden; + await context.Response.WriteAsJsonAsync(new + { + type = "https://tools.ietf.org/html/rfc9110#section-15.5.4", + title = "Forbidden", + status = StatusCodes.Status403Forbidden + }); + } } else { @@ -201,8 +217,8 @@ public class WebSocketProxyMiddleware clientWebSocket?.Dispose(); serverWebSocket?.Dispose(); - // CRITICAL: Notify session manager that client disconnected - if (!string.IsNullOrEmpty(deviceId)) + // CRITICAL: Notify session manager only when a client socket was accepted. + if (clientWebSocket != null && !string.IsNullOrEmpty(deviceId)) { _logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId); await _sessionManager.RemoveSessionAsync(deviceId); @@ -270,23 +286,11 @@ public class WebSocketProxyMiddleware if (result.EndOfMessage) { var messageBytes = messageBuffer.ToArray(); - - // Log message for Server→Client direction to see remote control commands - if (direction == "Server→Client") + + if (_logger.IsEnabled(LogLevel.Debug)) { - var messageText = System.Text.Encoding.UTF8.GetString(messageBytes); - _logger.LogDebug("📥 WEBSOCKET {Direction}: {Preview}", - direction, - messageText.Length > 500 ? messageText[..500] + "..." : messageText); - } - else if (_logger.IsEnabled(LogLevel.Debug)) - { - var messageText = System.Text.Encoding.UTF8.GetString(messageBytes); - _logger.LogDebug("{Direction}: {MessageType} message ({Size} bytes): {Preview}", - direction, - result.MessageType, - messageBytes.Length, - messageText.Length > 200 ? messageText[..200] + "..." : messageText); + _logger.LogDebug("WEBSOCKET {Direction}: {MessageType} message ({Size} bytes)", + direction, result.MessageType, messageBytes.Length); } // Forward the complete message diff --git a/allstarr/Models/Admin/AdminDtos.cs b/allstarr/Models/Admin/AdminDtos.cs index ac61644..38d08d7 100644 --- a/allstarr/Models/Admin/AdminDtos.cs +++ b/allstarr/Models/Admin/AdminDtos.cs @@ -50,9 +50,10 @@ public class AddPlaylistRequest public class LinkPlaylistRequest { - public string Name { get; set; } = string.Empty; + public string? Name { get; set; } public string SpotifyPlaylistId { get; set; } = string.Empty; public string SyncSchedule { get; set; } = "0 8 * * *"; + public string? UserId { get; set; } } public class UpdateScheduleRequest diff --git a/allstarr/Models/Scrobbling/PlaybackSession.cs b/allstarr/Models/Scrobbling/PlaybackSession.cs index d285155..b7ae93b 100644 --- a/allstarr/Models/Scrobbling/PlaybackSession.cs +++ b/allstarr/Models/Scrobbling/PlaybackSession.cs @@ -5,6 +5,8 @@ namespace allstarr.Models.Scrobbling; /// public class PlaybackSession { + private const int ExternalStartToleranceSeconds = 5; + /// /// Unique identifier for this playback session. /// @@ -54,13 +56,18 @@ public class PlaybackSession { if (Scrobbled) return false; // Already scrobbled - + if (Track.DurationSeconds == null || Track.DurationSeconds <= 30) return false; // Track too short or duration unknown - + + // External scrobbles should only count if playback started near the beginning. + // This avoids duplicate/resume scrobbles when users jump into a track mid-way. + if (Track.IsExternal && (Track.StartPositionSeconds ?? 0) > ExternalStartToleranceSeconds) + return false; + var halfDuration = Track.DurationSeconds.Value / 2; var scrobbleThreshold = Math.Min(halfDuration, 240); // 4 minutes = 240 seconds - + return LastPositionSeconds >= scrobbleThreshold; } } diff --git a/allstarr/Models/Scrobbling/ScrobbleTrack.cs b/allstarr/Models/Scrobbling/ScrobbleTrack.cs index 06ae9ca..b937102 100644 --- a/allstarr/Models/Scrobbling/ScrobbleTrack.cs +++ b/allstarr/Models/Scrobbling/ScrobbleTrack.cs @@ -52,4 +52,10 @@ public record ScrobbleTrack /// ListenBrainz only scrobbles external tracks. /// public bool IsExternal { get; init; } = false; + + /// + /// Playback position in seconds when this listen started. + /// Used to prevent scrobbling resumed external tracks that did not start near the beginning. + /// + public int? StartPositionSeconds { get; init; } } diff --git a/allstarr/Models/Settings/CacheSettings.cs b/allstarr/Models/Settings/CacheSettings.cs index a4eca44..8fa4180 100644 --- a/allstarr/Models/Settings/CacheSettings.cs +++ b/allstarr/Models/Settings/CacheSettings.cs @@ -8,9 +8,9 @@ public class CacheSettings { /// /// Search results cache duration in minutes. - /// Default: 120 minutes (2 hours) + /// Default: 1 minute (60 seconds) /// - public int SearchResultsMinutes { get; set; } = 120; + public int SearchResultsMinutes { get; set; } = 1; /// /// Playlist cover images cache duration in hours. diff --git a/allstarr/Models/Settings/MusicBrainzSettings.cs b/allstarr/Models/Settings/MusicBrainzSettings.cs index 56397c2..4f21e83 100644 --- a/allstarr/Models/Settings/MusicBrainzSettings.cs +++ b/allstarr/Models/Settings/MusicBrainzSettings.cs @@ -5,7 +5,7 @@ namespace allstarr.Models.Settings; /// public class MusicBrainzSettings { - public bool Enabled { get; set; } = true; + public bool Enabled { get; set; } = false; public string? Username { get; set; } public string? Password { get; set; } diff --git a/allstarr/Models/Settings/ScrobblingSettings.cs b/allstarr/Models/Settings/ScrobblingSettings.cs index c9826c3..5929264 100644 --- a/allstarr/Models/Settings/ScrobblingSettings.cs +++ b/allstarr/Models/Settings/ScrobblingSettings.cs @@ -1,3 +1,5 @@ +using System.Text; + namespace allstarr.Models.Settings; /// @@ -9,18 +11,24 @@ public class ScrobblingSettings /// Whether scrobbling is enabled globally. /// public bool Enabled { get; set; } - + /// /// Whether to scrobble local library tracks. /// Recommended: Keep disabled and use native Jellyfin plugins instead. /// public bool LocalTracksEnabled { get; set; } - + + /// + /// Emits a synthetic local "played" signal from progress events when local scrobbling is disabled. + /// Default is false to avoid duplicate local scrobbles with Jellyfin plugins. + /// + public bool SyntheticLocalPlayedSignalEnabled { get; set; } + /// /// Last.fm settings. /// public LastFmSettings LastFm { get; set; } = new(); - + /// /// ListenBrainz settings (future). /// @@ -32,41 +40,54 @@ public class ScrobblingSettings /// public class LastFmSettings { + // These defaults match the Jellyfin Last.fm plugin credentials. + // Stored base64-encoded to avoid plain-text source exposure. + private const string DefaultApiKeyBase64 = "Y2IzYmRjZDQxNWZjYjQwY2Q1NzJiMTM3YjJiMjU1ZjU="; + private const string DefaultSharedSecretBase64 = "M2EwOGY5ZmFkNmRkYzRjMzViMGRjZTAwNjJjZWNiNWU="; + + public static string DefaultApiKey => DecodeBase64(DefaultApiKeyBase64); + public static string DefaultSharedSecret => DecodeBase64(DefaultSharedSecretBase64); + /// /// Whether Last.fm scrobbling is enabled. /// public bool Enabled { get; set; } - + /// /// Last.fm API key (32-character hex string). /// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience. /// Users can override by setting SCROBBLING_LASTFM_API_KEY in .env /// - public string ApiKey { get; set; } = "cb3bdcd415fcb40cd572b137b2b255f5"; - + public string ApiKey { get; set; } = DefaultApiKey; + /// /// Last.fm shared secret (32-character hex string). /// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience. /// Users can override by setting SCROBBLING_LASTFM_SHARED_SECRET in .env /// - public string SharedSecret { get; set; } = "3a08f9fad6ddc4c35b0dce0062cecb5e"; - + public string SharedSecret { get; set; } = DefaultSharedSecret; + /// /// Last.fm session key (obtained via Mobile Authentication). /// This is user-specific and has infinite lifetime (unless revoked by user). /// public string SessionKey { get; set; } = string.Empty; - + /// /// Last.fm username. /// public string? Username { get; set; } - + /// /// Last.fm password (stored for automatic re-authentication if needed). /// Only used for authentication, not stored in plaintext in production. /// public string? Password { get; set; } + + private static string DecodeBase64(string encoded) + { + return Encoding.UTF8.GetString(Convert.FromBase64String(encoded)); + } } /// @@ -78,7 +99,7 @@ public class ListenBrainzSettings /// Whether ListenBrainz scrobbling is enabled. /// public bool Enabled { get; set; } - + /// /// ListenBrainz user token. /// Get from: https://listenbrainz.org/profile/ diff --git a/allstarr/Models/Settings/SpotifyImportSettings.cs b/allstarr/Models/Settings/SpotifyImportSettings.cs index 7382444..b2f15e5 100644 --- a/allstarr/Models/Settings/SpotifyImportSettings.cs +++ b/allstarr/Models/Settings/SpotifyImportSettings.cs @@ -9,7 +9,7 @@ public enum LocalTracksPosition /// Local tracks appear first, external tracks appended at the end (default) /// First, - + /// /// External tracks appear first, local tracks appended at the end /// @@ -26,26 +26,26 @@ public class SpotifyPlaylistConfig /// Example: "Discover Weekly", "Release Radar" /// public string Name { get; set; } = string.Empty; - + /// /// Spotify playlist ID (get from Spotify playlist URL) /// Example: "37i9dQZF1DXcBWIGoYBM5M" (from open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M) /// Required for personalized playlists like Discover Weekly, Release Radar, etc. /// public string Id { get; set; } = string.Empty; - + /// /// Jellyfin playlist ID (internal Jellyfin GUID) /// Example: "4383a46d8bcac3be2ef9385053ea18df" /// This is the ID Jellyfin uses when requesting playlist tracks /// public string JellyfinId { get; set; } = string.Empty; - + /// /// Where to position local tracks: "first" or "last" /// public LocalTracksPosition LocalTracksPosition { get; set; } = LocalTracksPosition.First; - + /// /// Cron schedule for syncing this playlist with Spotify /// Format: minute hour day month dayofweek @@ -53,6 +53,12 @@ public class SpotifyPlaylistConfig /// Default: "0 8 * * *" (daily at 8 AM) /// public string SyncSchedule { get; set; } = "0 8 * * *"; + + /// + /// Optional Jellyfin user owner for this playlist link. + /// Null/empty means legacy/global playlist configuration. + /// + public string? UserId { get; set; } } /// @@ -66,7 +72,7 @@ public class SpotifyImportSettings /// Enable Spotify playlist injection feature /// public bool Enabled { get; set; } - + /// /// How often to run track matching in hours. /// Spotify playlists like Discover Weekly update once per week, Release Radar updates weekly. @@ -75,28 +81,28 @@ public class SpotifyImportSettings /// Default: 24 hours /// public int MatchingIntervalHours { get; set; } = 24; - + /// /// Combined playlist configuration as JSON array. - /// Format: [["Name","Id","first|last"],...] - /// Example: [["Discover Weekly","abc123","first"],["Release Radar","def456","last"]] + /// Format: [["Name","Id","JellyfinId","first|last","cron","UserId?"],...] + /// UserId is optional for legacy/global entries. /// public List Playlists { get; set; } = new(); - + /// /// Legacy: Comma-separated list of Jellyfin playlist IDs to inject /// Deprecated: Use Playlists instead /// [Obsolete("Use Playlists instead")] public List PlaylistIds { get; set; } = new(); - + /// /// Legacy: Comma-separated list of playlist names /// Deprecated: Use Playlists instead /// [Obsolete("Use Playlists instead")] public List PlaylistNames { get; set; } = new(); - + /// /// Legacy: Comma-separated list of local track positions ("first" or "last") /// Deprecated: Use Playlists instead @@ -104,25 +110,25 @@ public class SpotifyImportSettings /// [Obsolete("Use Playlists instead")] public List PlaylistLocalTracksPositions { get; set; } = new(); - + /// /// Gets the playlist configuration by Jellyfin playlist ID. /// public SpotifyPlaylistConfig? GetPlaylistById(string playlistId) => Playlists.FirstOrDefault(p => p.Id.Equals(playlistId, StringComparison.OrdinalIgnoreCase)); - + /// /// Gets the playlist configuration by Jellyfin playlist ID. /// public SpotifyPlaylistConfig? GetPlaylistByJellyfinId(string jellyfinPlaylistId) => Playlists.FirstOrDefault(p => p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase)); - + /// /// Gets the playlist configuration by name. /// public SpotifyPlaylistConfig? GetPlaylistByName(string name) => Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); - + /// /// Checks if a Jellyfin playlist ID is configured for Spotify import. /// diff --git a/allstarr/Models/Spotify/SpotifyPlaylistTrack.cs b/allstarr/Models/Spotify/SpotifyPlaylistTrack.cs index ad82c33..41e1464 100644 --- a/allstarr/Models/Spotify/SpotifyPlaylistTrack.cs +++ b/allstarr/Models/Spotify/SpotifyPlaylistTrack.cs @@ -12,104 +12,104 @@ public class SpotifyPlaylistTrack /// Spotify track ID (e.g., "3a8mo25v74BMUOJ1IDUEBL") /// public string SpotifyId { get; set; } = string.Empty; - + /// /// Track's position in the playlist (0-based index). /// This is critical for maintaining correct playlist order. /// public int Position { get; set; } - + /// /// Track title /// public string Title { get; set; } = string.Empty; - + /// /// Album name /// public string Album { get; set; } = string.Empty; - + /// /// Album Spotify ID /// public string AlbumId { get; set; } = string.Empty; - + /// /// List of artist names /// public List Artists { get; set; } = new(); - + /// /// List of artist Spotify IDs /// public List ArtistIds { get; set; } = new(); - + /// /// ISRC (International Standard Recording Code) for exact track identification. /// This enables precise matching across different streaming services. /// public string? Isrc { get; set; } - + /// /// Track duration in milliseconds /// public int DurationMs { get; set; } - + /// /// Whether the track contains explicit content /// public bool Explicit { get; set; } - + /// /// Track's popularity score (0-100) /// public int Popularity { get; set; } - + /// /// Preview URL for 30-second audio clip (may be null) /// public string? PreviewUrl { get; set; } - + /// /// Album artwork URL (largest available) /// public string? AlbumArtUrl { get; set; } - + /// /// Release date of the album (format varies: YYYY, YYYY-MM, or YYYY-MM-DD) /// public string? ReleaseDate { get; set; } - + /// /// When this track was added to the playlist /// public DateTime? AddedAt { get; set; } - + /// /// Disc number within the album /// public int DiscNumber { get; set; } = 1; - + /// /// Track number within the disc /// public int TrackNumber { get; set; } = 1; - + /// /// Primary (first) artist name /// public string PrimaryArtist => Artists.FirstOrDefault() ?? string.Empty; - + /// /// All artists as a comma-separated string /// public string AllArtists => string.Join(", ", Artists); - + /// /// Track duration as TimeSpan /// public TimeSpan Duration => TimeSpan.FromMilliseconds(DurationMs); - + /// /// Converts to the legacy MissingTrack format for compatibility with existing matching logic. /// @@ -131,61 +131,67 @@ public class SpotifyPlaylist /// Spotify playlist ID /// public string SpotifyId { get; set; } = string.Empty; - + /// /// Playlist name /// public string Name { get; set; } = string.Empty; - + /// /// Playlist description /// public string? Description { get; set; } - + /// /// Playlist owner's display name /// public string? OwnerName { get; set; } - + /// /// Playlist owner's Spotify ID /// public string? OwnerId { get; set; } - + /// /// Total number of tracks in the playlist /// public int TotalTracks { get; set; } - + /// /// Playlist cover image URL /// public string? ImageUrl { get; set; } - + /// /// Whether this is a collaborative playlist /// public bool Collaborative { get; set; } - + /// /// Whether this playlist is public /// public bool Public { get; set; } - + /// /// Tracks in the playlist, ordered by position /// public List Tracks { get; set; } = new(); - + /// /// When this data was fetched from Spotify /// public DateTime FetchedAt { get; set; } = DateTime.UtcNow; - + /// /// Snapshot ID for change detection (Spotify's playlist version identifier) /// public string? SnapshotId { get; set; } + + /// + /// Playlist creation date when provided by Spotify. + /// If unavailable, this may be inferred from track AddedAt timestamps. + /// + public DateTime? CreatedAt { get; set; } } /// @@ -198,32 +204,32 @@ public class MatchedTrack /// Position in the original Spotify playlist (0-based) /// public int Position { get; set; } - + /// /// Original Spotify track ID /// public string SpotifyId { get; set; } = string.Empty; - + /// /// Original Spotify track title (for debugging/logging) /// public string SpotifyTitle { get; set; } = string.Empty; - + /// /// Original Spotify artist (for debugging/logging) /// public string SpotifyArtist { get; set; } = string.Empty; - + /// /// ISRC used for matching (if available) /// public string? Isrc { get; set; } - + /// /// How the match was made: "isrc" or "fuzzy" /// public string MatchType { get; set; } = string.Empty; - + /// /// The matched song from the external provider /// diff --git a/allstarr/Program.cs b/allstarr/Program.cs index fd6ac28..de432ef 100644 --- a/allstarr/Program.cs +++ b/allstarr/Program.cs @@ -13,72 +13,121 @@ using allstarr.Services.Scrobbling; using allstarr.Middleware; using allstarr.Filters; using Microsoft.Extensions.Http; -using System.Text; using System.Net; var builder = WebApplication.CreateBuilder(args); +// Discover SquidWTF API and streaming endpoints from uptime feeds. +var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync(); +var squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls; +var squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls; + // Configure forwarded headers for reverse proxy support (nginx, etc.) -// This allows ASP.NET Core to read X-Forwarded-For, X-Real-IP, etc. +// Trust should be explicit: set ForwardedHeaders__KnownProxies and/or +// ForwardedHeaders__KnownNetworks (comma-separated) in deployment config. builder.Services.Configure(options => { - options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor + options.ForwardedHeaders = Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedFor | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedProto | Microsoft.AspNetCore.HttpOverrides.ForwardedHeaders.XForwardedHost; - - // Clear known networks and proxies to accept headers from any proxy - // This is safe when running behind a trusted reverse proxy (nginx) - options.KnownIPNetworks.Clear(); - options.KnownProxies.Clear(); - - // Trust X-Forwarded-* headers from any source - // Only do this if your reverse proxy is properly configured and trusted - options.ForwardLimit = null; + + // Keep a bounded chain by default; configurable for multi-hop proxy setups. + options.ForwardLimit = builder.Configuration.GetValue("ForwardedHeaders:ForwardLimit") ?? 2; + + // Framework defaults already trust loopback. If explicit trusted proxy/network + // config is provided, replace defaults with those values. + var configuredProxies = ParseCsv(builder.Configuration.GetValue("ForwardedHeaders:KnownProxies")); + var configuredNetworks = ParseCsv(builder.Configuration.GetValue("ForwardedHeaders:KnownNetworks")); + + if (configuredProxies.Count > 0 || configuredNetworks.Count > 0) + { + options.KnownIPNetworks.Clear(); + options.KnownProxies.Clear(); + + foreach (var proxy in configuredProxies) + { + if (IPAddress.TryParse(proxy, out var ip)) + { + options.KnownProxies.Add(ip); + } + else + { + Console.WriteLine($"⚠️ Invalid ForwardedHeaders known proxy ignored: {proxy}"); + } + } + + foreach (var network in configuredNetworks) + { + if (IPNetwork.TryParse(network, out var parsedNetwork)) + { + options.KnownIPNetworks.Add(parsedNetwork); + } + else + { + Console.WriteLine($"⚠️ Invalid ForwardedHeaders known network ignored: {network}"); + } + } + } }); -// Decode SquidWTF API base URLs once at startup -var squidWtfApiUrls = DecodeSquidWtfUrls(); -static List DecodeSquidWtfUrls() +// Legacy implementation intentionally retired. +// var encodedUrls = new[] { "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", ... }; + +static List ParseCsv(string? raw) { - var encodedUrls = new[] + if (string.IsNullOrWhiteSpace(raw)) { - "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", // triton - "aHR0cHM6Ly90aWRhbC5raW5vcGx1cy5vbmxpbmU=", // kinoplus - "aHR0cHM6Ly9oaWZpLXR3by5zcG90aXNhdmVyLm5ldA==", // spotisaver-two - "aHR0cHM6Ly9oaWZpLW9uZS5zcG90aXNhdmVyLm5ldA==", // spotisaver-one - "aHR0cHM6Ly93b2xmLnFxZGwuc2l0ZQ==", // wolf - "aHR0cDovL2h1bmQucXFkbC5zaXRl", // hund-http - "aHR0cHM6Ly9rYXR6ZS5xcWRsLnNpdGU=", // katze - "aHR0cHM6Ly92b2dlbC5xcWRsLnNpdGU=", // vogel - "aHR0cHM6Ly9tYXVzLnFxZGwuc2l0ZQ==", // maus - "aHR0cHM6Ly9ldS1jZW50cmFsLm1vbm9jaHJvbWUudGY=", // eu-central - "aHR0cHM6Ly91cy13ZXN0Lm1vbm9jaHJvbWUudGY=", // us-west - "aHR0cHM6Ly9hcnJhbi5tb25vY2hyb21lLnRm", // arran - "aHR0cHM6Ly9hcGkubW9ub2Nocm9tZS50Zg==", // api - "aHR0cHM6Ly9odW5kLnFxZGwuc2l0ZQ==" // hund - }; - - return encodedUrls - .Select(encoded => Encoding.UTF8.GetString(Convert.FromBase64String(encoded))) + return new List(); + } + + return raw + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Where(x => !string.IsNullOrWhiteSpace(x)) + .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); } +static string? GetConfiguredValue(IConfiguration configuration, params string[] keys) +{ + foreach (var key in keys) + { + var value = configuration.GetValue(key); + if (!string.IsNullOrWhiteSpace(value)) + { + return value; + } + } + + return null; +} + // Determine backend type FIRST var backendType = builder.Configuration.GetValue("Backend:Type"); // Configure Kestrel for large responses over VPN/Tailscale // Also configure admin port on 5275 (internal only, not exposed) +var bindAdminAnyIp = AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(builder.Configuration); builder.WebHost.ConfigureKestrel(serverOptions => { serverOptions.Limits.MaxResponseBufferSize = null; // Disable response buffering limit - serverOptions.Limits.MaxRequestBodySize = null; // Allow large request bodies + serverOptions.Limits.MaxRequestBodySize = null; // Let nginx enforce body limits serverOptions.Limits.MinResponseDataRate = null; // Disable minimum data rate for slow connections - + // Main proxy port (exposed) serverOptions.ListenAnyIP(8080); - - // Admin UI port (internal only - do NOT expose through reverse proxy) - serverOptions.ListenAnyIP(5275); + + // Admin UI port defaults to localhost-only. + // Override with Admin:BindAnyIp=true if required by your deployment. + if (bindAdminAnyIp) + { + Console.WriteLine("⚠️ Admin UI binding override enabled: listening on 0.0.0.0:5275"); + serverOptions.ListenAnyIP(5275); + } + else + { + Console.WriteLine("Admin UI listening on localhost:5275 (default)"); + serverOptions.ListenLocalhost(5275); + } }); // Add response compression for large JSON responses (helps with Tailscale/VPN MTU issues) @@ -109,7 +158,7 @@ builder.Services.AddControllers() // Add our custom provider that filters by backend type manager.FeatureProviders.Add(new BackendControllerFeatureProvider(backendType)); }); - + builder.Services.AddHttpClient(); builder.Services.ConfigureAll(options => { @@ -121,7 +170,7 @@ builder.Services.ConfigureAll(options => MaxAutomaticRedirections = 5 }; }); - + // Suppress verbose HTTP logging - these are logged at Debug level by default // but we want to reduce noise in production logs options.SuppressHandlerScope = true; @@ -139,6 +188,7 @@ builder.Services.AddScoped(); // Admin helper service (shared utilities for admin controllers) builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Configuration - register both settings, active one determined by backend type builder.Services.Configure( @@ -159,16 +209,16 @@ builder.Services.Configure( builder.Services.Configure(options => { builder.Configuration.GetSection("SpotifyImport").Bind(options); - + // Debug: Check what Bind() populated Console.WriteLine($"DEBUG: After Bind(), Playlists.Count = {options.Playlists.Count}"); #pragma warning disable CS0618 // Type or member is obsolete Console.WriteLine($"DEBUG: After Bind(), PlaylistIds.Count = {options.PlaylistIds.Count}"); Console.WriteLine($"DEBUG: After Bind(), PlaylistNames.Count = {options.PlaylistNames.Count}"); #pragma warning restore CS0618 - + // Parse SPOTIFY_IMPORT_PLAYLISTS env var (JSON array format) - // Format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],["Name2","SpotifyId2","JellyfinId2","first|last","cronSchedule"]] + // Format: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule","UserId?"],...] var playlistsEnv = builder.Configuration.GetValue("SpotifyImport:Playlists"); if (!string.IsNullOrWhiteSpace(playlistsEnv)) { @@ -181,25 +231,74 @@ builder.Services.Configure(options => { // Clear any playlists that Bind() may have incorrectly populated options.Playlists.Clear(); - + Console.WriteLine($"Parsed {playlistArrays.Length} playlists from JSON format"); foreach (var arr in playlistArrays) { if (arr.Length >= 2) { + var jellyfinId = string.Empty; + var localTracksPosition = LocalTracksPosition.First; + var syncSchedule = "0 8 * * *"; + string? userId = null; + + if (arr.Length >= 3) + { + var third = arr[2].Trim(); + var thirdIsPosition = third.Equals("first", StringComparison.OrdinalIgnoreCase) || + third.Equals("last", StringComparison.OrdinalIgnoreCase); + + if (thirdIsPosition) + { + localTracksPosition = third.Equals("last", StringComparison.OrdinalIgnoreCase) + ? LocalTracksPosition.Last + : LocalTracksPosition.First; + + if (arr.Length >= 4 && !string.IsNullOrWhiteSpace(arr[3])) + { + syncSchedule = arr[3].Trim(); + } + + if (arr.Length >= 5 && !string.IsNullOrWhiteSpace(arr[4])) + { + userId = arr[4].Trim(); + } + } + else + { + jellyfinId = third; + + if (arr.Length >= 4) + { + localTracksPosition = arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase) + ? LocalTracksPosition.Last + : LocalTracksPosition.First; + } + + if (arr.Length >= 5 && !string.IsNullOrWhiteSpace(arr[4])) + { + syncSchedule = arr[4].Trim(); + } + + if (arr.Length >= 6 && !string.IsNullOrWhiteSpace(arr[5])) + { + userId = arr[5].Trim(); + } + } + } + var config = new SpotifyPlaylistConfig { Name = arr[0].Trim(), Id = arr[1].Trim(), - JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "", - LocalTracksPosition = arr.Length >= 4 && - arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase) - ? LocalTracksPosition.Last - : LocalTracksPosition.First, - SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * *" + JellyfinId = jellyfinId, + LocalTracksPosition = localTracksPosition, + SyncSchedule = syncSchedule, + UserId = userId }; options.Playlists.Add(config); - Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition}, Schedule: {config.SyncSchedule})"); + var ownerDisplay = string.IsNullOrWhiteSpace(config.UserId) ? "global" : config.UserId; + Console.WriteLine($" Added: {config.Name} (Spotify: {config.Id}, Jellyfin: {config.JellyfinId}, Position: {config.LocalTracksPosition}, Schedule: {config.SyncSchedule}, Owner: {ownerDisplay})"); } } } @@ -211,7 +310,7 @@ builder.Services.Configure(options => catch (System.Text.Json.JsonException ex) { Console.WriteLine($"Warning: Failed to parse SPOTIFY_IMPORT_PLAYLISTS: {ex.Message}"); - Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\",\"cronSchedule\"],[\"Name2\",\"SpotifyId2\",\"JellyfinId2\",\"first|last\",\"cronSchedule\"]]"); + Console.WriteLine("Expected format: [[\"Name\",\"SpotifyId\",\"JellyfinId\",\"first|last\",\"cronSchedule\",\"UserId?\"],...]"); Console.WriteLine("Will try legacy format instead"); } } @@ -219,26 +318,26 @@ builder.Services.Configure(options => { Console.WriteLine("No SPOTIFY_IMPORT_PLAYLISTS env var found, will try legacy format"); } - + // Legacy support: Parse old SPOTIFY_IMPORT_PLAYLIST_IDS/NAMES env vars // Only used if new Playlists format is not configured // Check if we have legacy env vars to parse var playlistIdsEnv = builder.Configuration.GetValue("SpotifyImport:PlaylistIds"); var playlistNamesEnv = builder.Configuration.GetValue("SpotifyImport:PlaylistNames"); var hasLegacyConfig = !string.IsNullOrWhiteSpace(playlistIdsEnv) || !string.IsNullOrWhiteSpace(playlistNamesEnv); - + if (hasLegacyConfig && options.Playlists.Count == 0) { Console.WriteLine("Parsing legacy Spotify playlist format..."); - + #pragma warning disable CS0618 // Type or member is obsolete - + // Clear any auto-bound values from the Bind() call above // The auto-binder doesn't handle comma-separated strings correctly options.PlaylistIds.Clear(); options.PlaylistNames.Clear(); options.PlaylistLocalTracksPositions.Clear(); - + if (!string.IsNullOrWhiteSpace(playlistIdsEnv)) { options.PlaylistIds = playlistIdsEnv @@ -248,7 +347,7 @@ builder.Services.Configure(options => .ToList(); Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var"); } - + if (!string.IsNullOrWhiteSpace(playlistNamesEnv)) { options.PlaylistNames = playlistNamesEnv @@ -258,7 +357,7 @@ builder.Services.Configure(options => .ToList(); Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var"); } - + var playlistPositionsEnv = builder.Configuration.GetValue("SpotifyImport:PlaylistLocalTracksPositions"); if (!string.IsNullOrWhiteSpace(playlistPositionsEnv)) { @@ -273,14 +372,14 @@ builder.Services.Configure(options => { Console.WriteLine(" No playlist positions env var found, will use defaults"); } - + // Convert legacy format to new Playlists array Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format..."); for (int i = 0; i < options.PlaylistIds.Count; i++) { var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i]; var position = LocalTracksPosition.First; // Default - + // Parse position if provided if (i < options.PlaylistLocalTracksPositions.Count) { @@ -290,7 +389,7 @@ builder.Services.Configure(options => position = LocalTracksPosition.Last; } } - + options.Playlists.Add(new SpotifyPlaylistConfig { Name = name, @@ -307,14 +406,14 @@ builder.Services.Configure(options => // Clear it and re-parse properly Console.WriteLine($"DEBUG: Bind() incorrectly populated {options.Playlists.Count} playlists, clearing and re-parsing..."); options.Playlists.Clear(); - + #pragma warning disable CS0618 // Type or member is obsolete options.PlaylistIds.Clear(); options.PlaylistNames.Clear(); options.PlaylistLocalTracksPositions.Clear(); - + Console.WriteLine("Parsing legacy Spotify playlist format..."); - + if (!string.IsNullOrWhiteSpace(playlistIdsEnv)) { options.PlaylistIds = playlistIdsEnv @@ -324,7 +423,7 @@ builder.Services.Configure(options => .ToList(); Console.WriteLine($" Parsed {options.PlaylistIds.Count} playlist IDs from env var"); } - + if (!string.IsNullOrWhiteSpace(playlistNamesEnv)) { options.PlaylistNames = playlistNamesEnv @@ -334,7 +433,7 @@ builder.Services.Configure(options => .ToList(); Console.WriteLine($" Parsed {options.PlaylistNames.Count} playlist names from env var"); } - + var playlistPositionsEnv = builder.Configuration.GetValue("SpotifyImport:PlaylistLocalTracksPositions"); if (!string.IsNullOrWhiteSpace(playlistPositionsEnv)) { @@ -349,14 +448,14 @@ builder.Services.Configure(options => { Console.WriteLine(" No playlist positions env var found, will use defaults"); } - + // Convert legacy format to new Playlists array Console.WriteLine($" Converting {options.PlaylistIds.Count} playlists to new format..."); for (int i = 0; i < options.PlaylistIds.Count; i++) { var name = i < options.PlaylistNames.Count ? options.PlaylistNames[i] : options.PlaylistIds[i]; var position = LocalTracksPosition.First; // Default - + // Parse position if provided if (i < options.PlaylistLocalTracksPositions.Count) { @@ -366,7 +465,7 @@ builder.Services.Configure(options => position = LocalTracksPosition.Last; } } - + options.Playlists.Add(new SpotifyPlaylistConfig { Name = name, @@ -381,7 +480,7 @@ builder.Services.Configure(options => { Console.WriteLine($"Using new Playlists format: {options.Playlists.Count} playlists configured"); } - + // Log configuration at startup Console.WriteLine($"Spotify Import: Enabled={options.Enabled}, MatchingInterval={options.MatchingIntervalHours}h"); Console.WriteLine($"Spotify Import Playlists: {options.Playlists.Count} configured"); @@ -408,6 +507,7 @@ else } // Business services - shared across backends +builder.Services.AddSingleton(squidWtfEndpointCatalog); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -422,8 +522,7 @@ if (backendType == BackendType.Jellyfin) builder.Services.AddScoped(); builder.Services.AddSingleton(); builder.Services.AddScoped(); - builder.Services.AddScoped(); - + // Register JellyfinController as a service for dependency injection builder.Services.AddScoped(); } @@ -448,7 +547,7 @@ if (musicService == MusicService.Qobuz) builder.Services.AddSingleton(); builder.Services.AddSingleton(); } - + // Qobuz services (primary) - registered LAST to be injected by default builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -464,7 +563,7 @@ else if (musicService == MusicService.Deezer) builder.Services.AddSingleton(); builder.Services.AddSingleton(); } - + // Deezer services (primary, default) - registered LAST to be injected by default builder.Services.AddSingleton(); builder.Services.AddSingleton(); @@ -472,7 +571,7 @@ else if (musicService == MusicService.Deezer) else if (musicService == MusicService.SquidWTF) { // SquidWTF services - pass decoded URLs with fallback support - builder.Services.AddSingleton(sp => + builder.Services.AddSingleton(sp => new SquidWTFMetadataService( sp.GetRequiredService(), sp.GetRequiredService>(), @@ -480,7 +579,7 @@ else if (musicService == MusicService.SquidWTF) sp.GetRequiredService>(), sp.GetRequiredService(), squidWtfApiUrls, - sp.GetRequiredService())); + sp.GetService())); builder.Services.AddSingleton(sp => new SquidWTFDownloadService( sp.GetRequiredService(), @@ -492,7 +591,7 @@ else if (musicService == MusicService.SquidWTF) sp, sp.GetRequiredService>(), sp.GetRequiredService(), - squidWtfApiUrls)); + squidWtfStreamingUrls)); } // Register ParallelMetadataService to race all registered providers for faster searches @@ -518,6 +617,7 @@ builder.Services.AddSingleton(sp => sp.GetRequiredService>(), sp.GetRequiredService().CreateClient(), squidWtfApiUrls, + squidWtfStreamingUrls, sp.GetRequiredService(), sp.GetRequiredService>())); builder.Services.AddSingleton(); @@ -539,38 +639,38 @@ builder.Services.AddHostedService(); builder.Services.Configure(options => { builder.Configuration.GetSection("SpotifyApi").Bind(options); - + // Override from environment variables var enabled = builder.Configuration.GetValue("SpotifyApi:Enabled"); if (!string.IsNullOrEmpty(enabled)) { options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase); } - + var sessionCookie = builder.Configuration.GetValue("SpotifyApi:SessionCookie"); if (!string.IsNullOrEmpty(sessionCookie)) { options.SessionCookie = sessionCookie; } - + var sessionCookieSetDate = builder.Configuration.GetValue("SpotifyApi:SessionCookieSetDate"); if (!string.IsNullOrEmpty(sessionCookieSetDate)) { options.SessionCookieSetDate = sessionCookieSetDate; } - + var cacheDuration = builder.Configuration.GetValue("SpotifyApi:CacheDurationMinutes"); if (cacheDuration.HasValue) { options.CacheDurationMinutes = cacheDuration.Value; } - + var preferIsrc = builder.Configuration.GetValue("SpotifyApi:PreferIsrcMatching"); if (!string.IsNullOrEmpty(preferIsrc)) { options.PreferIsrcMatching = preferIsrc.Equals("true", StringComparison.OrdinalIgnoreCase); } - + // Log configuration (mask sensitive values) Console.WriteLine($"SpotifyApi Configuration:"); Console.WriteLine($" Enabled: {options.Enabled}"); @@ -580,6 +680,8 @@ builder.Services.Configure(options Console.WriteLine($" PreferIsrcMatching: {options.PreferIsrcMatching}"); }); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); // Register Spotify lyrics service (uses Spotify's color-lyrics API) builder.Services.AddSingleton(); @@ -609,6 +711,7 @@ builder.Services.AddHostedService(); builder.Services.AddHostedService(sp => sp.GetRequiredService()); +builder.Services.AddHostedService(); // Register lyrics prefetch service (prefetches lyrics for all playlist tracks) // DISABLED - No need to prefetch since Jellyfin and Spotify lyrics are fast @@ -625,33 +728,36 @@ builder.Services.Configure(options var lastFmSessionKey = builder.Configuration.GetValue("Scrobbling:LastFm:SessionKey"); var lastFmUsername = builder.Configuration.GetValue("Scrobbling:LastFm:Username"); var lastFmPassword = builder.Configuration.GetValue("Scrobbling:LastFm:Password"); - + options.Enabled = builder.Configuration.GetValue("Scrobbling:Enabled"); options.LocalTracksEnabled = builder.Configuration.GetValue("Scrobbling:LocalTracksEnabled"); + options.SyntheticLocalPlayedSignalEnabled = + builder.Configuration.GetValue("Scrobbling:SyntheticLocalPlayedSignalEnabled"); options.LastFm.Enabled = lastFmEnabled; - + // Only override hardcoded API credentials if explicitly set in config if (!string.IsNullOrEmpty(lastFmApiKey)) options.LastFm.ApiKey = lastFmApiKey; if (!string.IsNullOrEmpty(lastFmSharedSecret)) options.LastFm.SharedSecret = lastFmSharedSecret; - + // These don't have defaults, so set them normally options.LastFm.SessionKey = lastFmSessionKey ?? string.Empty; options.LastFm.Username = lastFmUsername; options.LastFm.Password = lastFmPassword; - + // ListenBrainz settings var listenBrainzEnabled = builder.Configuration.GetValue("Scrobbling:ListenBrainz:Enabled"); var listenBrainzUserToken = builder.Configuration.GetValue("Scrobbling:ListenBrainz:UserToken") ?? string.Empty; - + options.ListenBrainz.Enabled = listenBrainzEnabled; options.ListenBrainz.UserToken = listenBrainzUserToken; - + // Debug logging Console.WriteLine($"Scrobbling Configuration:"); Console.WriteLine($" Enabled: {options.Enabled}"); Console.WriteLine($" Local Tracks Enabled: {options.LocalTracksEnabled}"); + Console.WriteLine($" Synthetic Local Played Signal Enabled: {options.SyntheticLocalPlayedSignalEnabled}"); Console.WriteLine($" Last.fm Enabled: {options.LastFm.Enabled}"); Console.WriteLine($" Last.fm Username: {options.LastFm.Username ?? "(not set)"}"); Console.WriteLine($" Last.fm Session Key: {(string.IsNullOrEmpty(options.LastFm.SessionKey) ? "(not set)" : "***" + options.LastFm.SessionKey[^8..])}"); @@ -679,43 +785,107 @@ builder.Services.AddSingleton builder.Services.AddSingleton(); builder.Services.AddSingleton(); -// Register MusicBrainz service for metadata enrichment -builder.Services.Configure(options => +// Register MusicBrainz service for metadata enrichment (only if enabled) +var musicBrainzEnabled = builder.Configuration.GetValue("MusicBrainz:Enabled", false); +var musicBrainzEnabledEnv = builder.Configuration.GetValue("MusicBrainz:Enabled"); +if (!string.IsNullOrEmpty(musicBrainzEnabledEnv)) { - builder.Configuration.GetSection("MusicBrainz").Bind(options); - - // Override from environment variables - var enabled = builder.Configuration.GetValue("MusicBrainz:Enabled"); - if (!string.IsNullOrEmpty(enabled)) - { - options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase); - } - - var username = builder.Configuration.GetValue("MusicBrainz:Username"); - if (!string.IsNullOrEmpty(username)) - { - options.Username = username; - } - - var password = builder.Configuration.GetValue("MusicBrainz:Password"); - if (!string.IsNullOrEmpty(password)) - { - options.Password = password; - } -}); -builder.Services.AddSingleton(); + musicBrainzEnabled = musicBrainzEnabledEnv.Equals("true", StringComparison.OrdinalIgnoreCase); +} -// Register genre enrichment service -builder.Services.AddSingleton(); +if (musicBrainzEnabled) +{ + builder.Services.Configure(options => + { + builder.Configuration.GetSection("MusicBrainz").Bind(options); + + // Override from environment variables + var enabled = builder.Configuration.GetValue("MusicBrainz:Enabled"); + if (!string.IsNullOrEmpty(enabled)) + { + options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase); + } + + var username = builder.Configuration.GetValue("MusicBrainz:Username"); + if (!string.IsNullOrEmpty(username)) + { + options.Username = username; + } + + var password = builder.Configuration.GetValue("MusicBrainz:Password"); + if (!string.IsNullOrEmpty(password)) + { + options.Password = password; + } + }); + builder.Services.AddSingleton(); + builder.Services.AddSingleton(); + + Console.WriteLine("✅ MusicBrainz genre enrichment enabled"); +} +else +{ + Console.WriteLine("⏭️ MusicBrainz genre enrichment disabled"); +} builder.Services.AddCors(options => { + var corsAllowedOrigins = ParseCsv(GetConfiguredValue( + builder.Configuration, + "Cors:AllowedOrigins", + "CORS_ALLOWED_ORIGINS", + "CORS__ALLOWED_ORIGINS")); + + var corsAllowedMethods = ParseCsv(GetConfiguredValue( + builder.Configuration, + "Cors:AllowedMethods", + "CORS_ALLOWED_METHODS", + "CORS__ALLOWED_METHODS")); + if (corsAllowedMethods.Count == 0) + { + corsAllowedMethods = new List { "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD" }; + } + + var corsAllowedHeaders = ParseCsv(GetConfiguredValue( + builder.Configuration, + "Cors:AllowedHeaders", + "CORS_ALLOWED_HEADERS", + "CORS__ALLOWED_HEADERS")); + if (corsAllowedHeaders.Count == 0) + { + corsAllowedHeaders = new List + { + "Accept", + "Authorization", + "Content-Type", + "Range", + "X-Requested-With", + "X-Emby-Authorization", + "X-MediaBrowser-Token" + }; + } + + var corsAllowCredentials = + builder.Configuration.GetValue("Cors:AllowCredentials") + ?? builder.Configuration.GetValue("CORS_ALLOW_CREDENTIALS") + ?? builder.Configuration.GetValue("CORS__ALLOW_CREDENTIALS") + ?? false; + options.AddDefaultPolicy(policy => { - policy.AllowAnyOrigin() - .AllowAnyMethod() - .AllowAnyHeader() + policy.WithMethods(corsAllowedMethods.ToArray()) + .WithHeaders(corsAllowedHeaders.ToArray()) .WithExposedHeaders("X-Content-Duration", "X-Total-Count", "X-Nd-Authorization"); + + if (corsAllowedOrigins.Count > 0) + { + policy.WithOrigins(corsAllowedOrigins.ToArray()); + + if (corsAllowCredentials) + { + policy.AllowCredentials(); + } + } }); }); @@ -767,7 +937,9 @@ if (app.Environment.IsDevelopment()) app.UseHttpsRedirection(); // Serve static files only on admin port (5275) +app.UseMiddleware(); app.UseMiddleware(); +app.UseMiddleware(); app.UseAuthorization(); @@ -801,7 +973,8 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co // All admin controllers should always be registered (for admin UI) // This includes: AdminController, ConfigController, DiagnosticsController, DownloadsController, // PlaylistController, JellyfinAdminController, SpotifyAdminController, LyricsController, MappingController, ScrobblingAdminController - if (typeInfo.Name == "AdminController" || + if (typeInfo.Name == "AdminController" || + typeInfo.Name == "AdminAuthController" || typeInfo.Name == "ConfigController" || typeInfo.Name == "DiagnosticsController" || typeInfo.Name == "DownloadsController" || diff --git a/allstarr/Services/Admin/AdminAuthSessionService.cs b/allstarr/Services/Admin/AdminAuthSessionService.cs new file mode 100644 index 0000000..25b565d --- /dev/null +++ b/allstarr/Services/Admin/AdminAuthSessionService.cs @@ -0,0 +1,108 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; + +namespace allstarr.Services.Admin; + +public sealed class AdminAuthSession +{ + public required string SessionId { get; init; } + public required string UserId { get; init; } + public required string UserName { get; init; } + public required bool IsAdministrator { get; init; } + public required string JellyfinAccessToken { get; init; } + public string? JellyfinServerId { get; init; } + public required DateTime ExpiresAtUtc { get; init; } + public DateTime LastSeenUtc { get; set; } +} + +/// +/// In-memory authenticated admin sessions for the local Web UI. +/// +public class AdminAuthSessionService +{ + public const string SessionCookieName = "allstarr_admin_session"; + public const string HttpContextSessionItemKey = "__allstarr_admin_auth_session"; + + private static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12); + private readonly ConcurrentDictionary _sessions = new(); + + public AdminAuthSession CreateSession( + string userId, + string userName, + bool isAdministrator, + string jellyfinAccessToken, + string? jellyfinServerId) + { + RemoveExpiredSessions(); + + var now = DateTime.UtcNow; + var session = new AdminAuthSession + { + SessionId = GenerateSessionId(), + UserId = userId, + UserName = userName, + IsAdministrator = isAdministrator, + JellyfinAccessToken = jellyfinAccessToken, + JellyfinServerId = jellyfinServerId, + ExpiresAtUtc = now.Add(SessionLifetime), + LastSeenUtc = now + }; + + _sessions[session.SessionId] = session; + return session; + } + + public bool TryGetValidSession(string? sessionId, out AdminAuthSession session) + { + session = null!; + + if (string.IsNullOrWhiteSpace(sessionId)) + { + return false; + } + + if (!_sessions.TryGetValue(sessionId, out var existing)) + { + return false; + } + + if (existing.ExpiresAtUtc <= DateTime.UtcNow) + { + _sessions.TryRemove(sessionId, out _); + return false; + } + + existing.LastSeenUtc = DateTime.UtcNow; + session = existing; + return true; + } + + public void RemoveSession(string? sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId)) + { + return; + } + + _sessions.TryRemove(sessionId, out _); + } + + private void RemoveExpiredSessions() + { + var now = DateTime.UtcNow; + foreach (var kvp in _sessions) + { + if (kvp.Value.ExpiresAtUtc <= now) + { + _sessions.TryRemove(kvp.Key, out _); + } + } + } + + private static string GenerateSessionId() + { + Span bytes = stackalloc byte[32]; + RandomNumberGenerator.Fill(bytes); + return Convert.ToHexString(bytes).ToLowerInvariant(); + } +} diff --git a/allstarr/Services/Admin/AdminHelperService.cs b/allstarr/Services/Admin/AdminHelperService.cs index 362e315..465849f 100644 --- a/allstarr/Services/Admin/AdminHelperService.cs +++ b/allstarr/Services/Admin/AdminHelperService.cs @@ -20,7 +20,7 @@ public class AdminHelperService { _logger = logger; _jellyfinSettings = jellyfinSettings.Value; - _envFilePath = environment.IsDevelopment() + _envFilePath = environment.IsDevelopment() ? Path.Combine(environment.ContentRootPath, "..", ".env") : "/app/.env"; } @@ -33,12 +33,12 @@ public class AdminHelperService public async Task> ReadPlaylistsFromEnvFileAsync() { var playlists = new List(); - + if (!File.Exists(_envFilePath)) { return playlists; } - + try { var lines = await File.ReadAllLinesAsync(_envFilePath); @@ -47,30 +47,21 @@ public class AdminHelperService if (line.TrimStart().StartsWith("SPOTIFY_IMPORT_PLAYLISTS=")) { var value = line.Substring(line.IndexOf('=') + 1).Trim(); - + if (string.IsNullOrWhiteSpace(value) || value == "[]") { return playlists; } - + var playlistArrays = JsonSerializer.Deserialize(value); if (playlistArrays != null) { foreach (var arr in playlistArrays) { - if (arr.Length >= 2) + var parsed = ParsePlaylistConfigEntry(arr); + if (parsed != null) { - playlists.Add(new SpotifyPlaylistConfig - { - Name = arr[0].Trim(), - Id = arr[1].Trim(), - JellyfinId = arr.Length >= 3 ? arr[2].Trim() : "", - LocalTracksPosition = arr.Length >= 4 && - arr[3].Trim().Equals("last", StringComparison.OrdinalIgnoreCase) - ? LocalTracksPosition.Last - : LocalTracksPosition.First, - SyncSchedule = arr.Length >= 5 ? arr[4].Trim() : "0 8 * * *" - }); + playlists.Add(parsed); } } } @@ -82,10 +73,111 @@ public class AdminHelperService { _logger.LogError(ex, "Failed to read playlists from .env file"); } - + return playlists; } + public static string SerializePlaylistsForEnv(IEnumerable playlists) + { + var playlistArrays = playlists + .Select(ToEnvPlaylistArray) + .ToArray(); + + return JsonSerializer.Serialize(playlistArrays); + } + + private static string[] ToEnvPlaylistArray(SpotifyPlaylistConfig playlist) + { + var values = new List + { + playlist.Name ?? string.Empty, + playlist.Id ?? string.Empty, + playlist.JellyfinId ?? string.Empty, + playlist.LocalTracksPosition.ToString().ToLowerInvariant(), + string.IsNullOrWhiteSpace(playlist.SyncSchedule) ? "0 8 * * *" : playlist.SyncSchedule.Trim() + }; + + if (!string.IsNullOrWhiteSpace(playlist.UserId)) + { + values.Add(playlist.UserId.Trim()); + } + + return values.ToArray(); + } + + private static SpotifyPlaylistConfig? ParsePlaylistConfigEntry(string[] arr) + { + if (arr.Length < 2) + { + return null; + } + + var config = new SpotifyPlaylistConfig + { + Name = arr[0].Trim(), + Id = arr[1].Trim(), + JellyfinId = string.Empty, + LocalTracksPosition = LocalTracksPosition.First, + SyncSchedule = "0 8 * * *" + }; + + // Legacy format: ["Name","SpotifyId","first|last"] + if (arr.Length >= 3) + { + var third = arr[2].Trim(); + if (IsLocalTracksPositionToken(third)) + { + config.LocalTracksPosition = ParseLocalTracksPosition(third); + if (arr.Length >= 4 && !string.IsNullOrWhiteSpace(arr[3])) + { + config.SyncSchedule = arr[3].Trim(); + } + if (arr.Length >= 5 && !string.IsNullOrWhiteSpace(arr[4])) + { + config.UserId = arr[4].Trim(); + } + return config; + } + + config.JellyfinId = third; + } + + if (arr.Length >= 4) + { + config.LocalTracksPosition = ParseLocalTracksPosition(arr[3]); + } + + if (arr.Length >= 5 && !string.IsNullOrWhiteSpace(arr[4])) + { + config.SyncSchedule = arr[4].Trim(); + } + + if (arr.Length >= 6 && !string.IsNullOrWhiteSpace(arr[5])) + { + config.UserId = arr[5].Trim(); + } + + return config; + } + + private static bool IsLocalTracksPositionToken(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return false; + } + + return value.Trim().Equals("first", StringComparison.OrdinalIgnoreCase) || + value.Trim().Equals("last", StringComparison.OrdinalIgnoreCase); + } + + private static LocalTracksPosition ParseLocalTracksPosition(string? value) + { + return string.Equals(value?.Trim(), "last", StringComparison.OrdinalIgnoreCase) + ? LocalTracksPosition.Last + : LocalTracksPosition.First; + } + public static string MaskValue(string? value, int showLast = 0) { if (string.IsNullOrEmpty(value)) return "(not set)"; @@ -102,7 +194,7 @@ public class AdminHelperService { return Regex.IsMatch(key, @"^[A-Z_][A-Z0-9_]*$", RegexOptions.IgnoreCase); } - + /// /// Truncates a string for safe logging, adding ellipsis if truncated. /// @@ -110,13 +202,13 @@ public class AdminHelperService { if (string.IsNullOrEmpty(str)) return str ?? string.Empty; - + if (str.Length <= maxLength) return str; - + return str[..maxLength] + "..."; } - + /// /// Validates if a username is safe (no control characters or shell metacharacters). /// @@ -124,12 +216,12 @@ public class AdminHelperService { if (string.IsNullOrWhiteSpace(username)) return false; - + // Reject control characters and dangerous shell metacharacters var dangerousChars = new[] { '\n', '\r', '\t', ';', '|', '&', '`', '$', '(', ')' }; return !username.Any(c => char.IsControl(c) || dangerousChars.Contains(c)); } - + /// /// Validates if a password is safe (no control characters). /// @@ -137,11 +229,11 @@ public class AdminHelperService { if (string.IsNullOrWhiteSpace(password)) return false; - + // Reject control characters (except space which is allowed) return !password.Any(c => char.IsControl(c)); } - + /// /// Validates if a URL is safe (http or https only). /// @@ -149,14 +241,14 @@ public class AdminHelperService { if (string.IsNullOrWhiteSpace(urlString)) return false; - + if (!Uri.TryCreate(urlString, UriKind.Absolute, out var uri)) return false; - + // Only allow http and https return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; } - + /// /// Validates if a file path is safe (no shell metacharacters or control characters). /// @@ -164,12 +256,12 @@ public class AdminHelperService { if (string.IsNullOrWhiteSpace(pathString)) return false; - + // Reject control characters and dangerous shell metacharacters var dangerousChars = new[] { '\n', '\r', '\0', ';', '|', '&', '`', '$' }; return !pathString.Any(c => char.IsControl(c) || dangerousChars.Contains(c)); } - + /// /// Sanitizes HTML by escaping special characters to prevent XSS. /// @@ -177,7 +269,7 @@ public class AdminHelperService { if (string.IsNullOrEmpty(html)) return html ?? string.Empty; - + return html .Replace("&", "&") .Replace("<", "<") @@ -185,7 +277,7 @@ public class AdminHelperService .Replace("\"", """) .Replace("'", "'"); } - + /// /// Removes control characters from a string for safe logging/display. /// @@ -193,10 +285,10 @@ public class AdminHelperService { if (string.IsNullOrEmpty(str)) return str ?? string.Empty; - + return new string(str.Where(c => !char.IsControl(c)).ToArray()); } - + /// /// Quotes a value if it's not already quoted (for .env file values). /// @@ -204,13 +296,13 @@ public class AdminHelperService { if (string.IsNullOrWhiteSpace(value)) return value ?? string.Empty; - + if (value.StartsWith("\"") && value.EndsWith("\"")) return value; - + return $"\"{value}\""; } - + /// /// Strips surrounding quotes from a value (for reading .env file values). /// @@ -218,13 +310,13 @@ public class AdminHelperService { if (string.IsNullOrEmpty(value)) return value ?? string.Empty; - + if (value.StartsWith("\"") && value.EndsWith("\"") && value.Length >= 2) return value[1..^1]; - + return value; } - + /// /// Parses a line from .env file and returns key-value pair. /// @@ -233,16 +325,16 @@ public class AdminHelperService var eqIndex = line.IndexOf('='); if (eqIndex <= 0) return (string.Empty, string.Empty); - + var key = line[..eqIndex].Trim(); var value = line[(eqIndex + 1)..].Trim(); - + // Strip quotes from value value = StripQuotes(value); - + return (key, value); } - + /// /// Checks if an .env line should be skipped (comment or empty). /// @@ -296,18 +388,18 @@ public class AdminHelperService { return new BadRequestObjectResult(new { error = "No updates provided" }); } - + _logger.LogInformation("Config update requested: {Count} changes", updates.Count); - + try { if (!File.Exists(_envFilePath)) { _logger.LogWarning(".env file not found at {Path}, creating new file", _envFilePath); } - + var envContent = new Dictionary(); - + if (File.Exists(_envFilePath)) { var lines = await File.ReadAllLinesAsync(_envFilePath); @@ -315,7 +407,7 @@ public class AdminHelperService { if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) continue; - + var eqIndex = line.IndexOf('='); if (eqIndex > 0) { @@ -325,7 +417,7 @@ public class AdminHelperService } } } - + var appliedUpdates = new List(); foreach (var (key, value) in updates) { @@ -334,10 +426,10 @@ public class AdminHelperService _logger.LogWarning("Invalid env key rejected: {Key}", key); return new BadRequestObjectResult(new { error = $"Invalid environment variable key: {key}" }); } - + envContent[key] = value; appliedUpdates.Add(key); - + if (key == "SPOTIFY_API_SESSION_COOKIE" && !string.IsNullOrEmpty(value)) { var dateKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATE"; @@ -346,12 +438,12 @@ public class AdminHelperService appliedUpdates.Add(dateKey); } } - + var newContent = string.Join("\n", envContent.Select(kv => $"{kv.Key}={kv.Value}")); await File.WriteAllTextAsync(_envFilePath, newContent + "\n"); - + _logger.LogInformation("Config file updated successfully at {Path}", _envFilePath); - + return new OkObjectResult(new { message = "Configuration updated. Restart container to apply changes.", @@ -363,7 +455,7 @@ public class AdminHelperService catch (Exception ex) { _logger.LogError(ex, "Failed to update configuration at {Path}", _envFilePath); - return new ObjectResult(new { error = "Failed to update configuration", details = ex.Message }) + return new ObjectResult(new { error = "Failed to update configuration" }) { StatusCode = 500 }; @@ -376,35 +468,27 @@ public class AdminHelperService { var currentPlaylists = await ReadPlaylistsFromEnvFileAsync(); var playlist = currentPlaylists.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); - + if (playlist == null) { return new NotFoundObjectResult(new { error = $"Playlist '{playlistName}' not found" }); } - + currentPlaylists.Remove(playlist); - - var playlistsJson = JsonSerializer.Serialize( - currentPlaylists.Select(p => new[] { - p.Name, - p.Id, - p.JellyfinId, - p.LocalTracksPosition.ToString().ToLower(), - p.SyncSchedule ?? "0 8 * * *" - }).ToArray() - ); - + + var playlistsJson = SerializePlaylistsForEnv(currentPlaylists); + var updates = new Dictionary { ["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson }; - + return await UpdateEnvConfigAsync(updates); } catch (Exception ex) { _logger.LogError(ex, "Failed to remove playlist {Name}", playlistName); - return new ObjectResult(new { error = "Failed to remove playlist", details = ex.Message }) + return new ObjectResult(new { error = "Failed to remove playlist" }) { StatusCode = 500 }; @@ -412,29 +496,29 @@ public class AdminHelperService } public async Task SaveManualMappingToFileAsync( - string playlistName, - string spotifyId, - string? jellyfinId, - string? externalProvider, + string playlistName, + string spotifyId, + string? jellyfinId, + string? externalProvider, string? externalId) { try { var mappingsDir = "/app/cache/mappings"; Directory.CreateDirectory(mappingsDir); - + var safeName = SanitizeFileName(playlistName); var filePath = Path.Combine(mappingsDir, $"{safeName}_mappings.json"); - + // Load existing mappings var mappings = new Dictionary(); if (File.Exists(filePath)) { var json = await File.ReadAllTextAsync(filePath); - mappings = JsonSerializer.Deserialize>(json) + mappings = JsonSerializer.Deserialize>(json) ?? new Dictionary(); } - + // Add or update mapping mappings[spotifyId] = new Models.Admin.ManualMappingEntry { @@ -444,11 +528,11 @@ public class AdminHelperService ExternalId = externalId, CreatedAt = DateTime.UtcNow }; - + // Save back to file var updatedJson = JsonSerializer.Serialize(mappings, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(filePath, updatedJson); - + _logger.LogDebug("💾 Saved manual mapping to file: {Playlist} - {SpotifyId}", playlistName, spotifyId); } catch (Exception ex) @@ -468,10 +552,10 @@ public class AdminHelperService { var mappingsDir = "/app/cache/lyrics_mappings"; Directory.CreateDirectory(mappingsDir); - + var safeName = SanitizeFileName($"{artist}_{title}"); var filePath = Path.Combine(mappingsDir, $"{safeName}.json"); - + var mapping = new { artist, @@ -481,10 +565,10 @@ public class AdminHelperService lyricsId, createdAt = DateTime.UtcNow }; - + var json = JsonSerializer.Serialize(mapping, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(filePath, json); - + _logger.LogDebug("💾 Saved lyrics mapping to file: {Artist} - {Title} → {LyricsId}", artist, title, lyricsId); } catch (Exception ex) diff --git a/allstarr/Services/Admin/PlaylistTrackStatusResolver.cs b/allstarr/Services/Admin/PlaylistTrackStatusResolver.cs new file mode 100644 index 0000000..c4b1fd0 --- /dev/null +++ b/allstarr/Services/Admin/PlaylistTrackStatusResolver.cs @@ -0,0 +1,104 @@ +using allstarr.Models.Spotify; + +namespace allstarr.Services.Admin; + +/// +/// Resolves track status (local/external/missing) from ordered Spotify matched-track cache entries. +/// +public static class PlaylistTrackStatusResolver +{ + public static bool TryResolveFromMatchedTrack( + IReadOnlyDictionary matchedTracksBySpotifyId, + string? spotifyId, + out bool? isLocal, + out string? externalProvider) + { + isLocal = null; + externalProvider = null; + + if (matchedTracksBySpotifyId == null || matchedTracksBySpotifyId.Count == 0) + { + return false; + } + + if (string.IsNullOrWhiteSpace(spotifyId)) + { + return false; + } + + if (!matchedTracksBySpotifyId.TryGetValue(spotifyId, out var matched) || + matched?.MatchedSong == null) + { + return false; + } + + var matchType = matched.MatchType ?? string.Empty; + var isExplicitLocalMatch = matchType.Contains("local", StringComparison.OrdinalIgnoreCase); + var isExplicitExternalMatch = matchType.Contains("external", StringComparison.OrdinalIgnoreCase); + var providerFromSong = NormalizeExternalProvider(matched.MatchedSong.ExternalProvider) + ?? ExtractExternalProviderFromItemId(matched.MatchedSong.Id); + + // If we have an explicit external signature (provider or ext- ID prefix), + // trust that over a stale/incorrect local match type. + if (!string.IsNullOrWhiteSpace(providerFromSong)) + { + isLocal = false; + externalProvider = providerFromSong; + return true; + } + + if (isExplicitLocalMatch) + { + isLocal = true; + externalProvider = null; + return true; + } + + isLocal = isExplicitExternalMatch ? false : matched.MatchedSong.IsLocal; + + if (isLocal == false) + { + externalProvider = providerFromSong; + } + + return true; + } + + private static string? NormalizeExternalProvider(string? provider) + { + if (string.IsNullOrWhiteSpace(provider)) + { + return null; + } + + return provider.Trim().ToLowerInvariant() switch + { + "squidwtf" or "squid-wtf" or "squid_wtf" or "tidal" => "squidwtf", + "deezer" => "deezer", + "qobuz" => "qobuz", + var other => other + }; + } + + private static string? ExtractExternalProviderFromItemId(string? itemId) + { + if (string.IsNullOrWhiteSpace(itemId)) + { + return null; + } + + var trimmed = itemId.Trim(); + if (!trimmed.StartsWith("ext-", StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + var parts = trimmed.Split('-', 4, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length < 2) + { + return null; + } + + return NormalizeExternalProvider(parts[1]); + } +} diff --git a/allstarr/Services/Common/AdminNetworkBindingPolicy.cs b/allstarr/Services/Common/AdminNetworkBindingPolicy.cs new file mode 100644 index 0000000..a171177 --- /dev/null +++ b/allstarr/Services/Common/AdminNetworkBindingPolicy.cs @@ -0,0 +1,76 @@ +using Microsoft.Extensions.Configuration; +using System.Net; + +namespace allstarr.Services.Common; + +public static class AdminNetworkBindingPolicy +{ + private const string BindAnyIpKey = "Admin:BindAnyIp"; + private const string TrustedSubnetsKey = "Admin:TrustedSubnets"; + + /// + /// Returns whether the admin listener should bind to all interfaces. + /// Default is false (localhost-only). + /// + public static bool ShouldBindAdminAnyIp(IConfiguration configuration) + { + return configuration.GetValue(BindAnyIpKey); + } + + /// + /// Parses trusted subnet CIDRs from configuration. Format: "192.168.1.0/24,10.0.0.0/8". + /// + public static List ParseTrustedSubnets(IConfiguration configuration) + { + var raw = configuration.GetValue(TrustedSubnetsKey); + var networks = new List(); + + if (string.IsNullOrWhiteSpace(raw)) + { + return networks; + } + + var parts = raw.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + foreach (var part in parts) + { + if (IPNetwork.TryParse(part, out var network)) + { + networks.Add(network); + } + } + + return networks; + } + + /// + /// Checks whether a remote IP should be allowed to access the admin listener. + /// Loopback is always allowed. + /// + public static bool IsRemoteIpAllowed(IPAddress? remoteIp, IReadOnlyCollection trustedSubnets) + { + if (remoteIp == null) + { + return false; + } + + if (IPAddress.IsLoopback(remoteIp)) + { + return true; + } + + if (remoteIp.IsIPv4MappedToIPv6) + { + remoteIp = remoteIp.MapToIPv4(); + } + + foreach (var subnet in trustedSubnets) + { + if (subnet.Contains(remoteIp)) + { + return true; + } + } + + return false; + } +} diff --git a/allstarr/Services/Common/CacheKeyBuilder.cs b/allstarr/Services/Common/CacheKeyBuilder.cs index 33a80d5..005cea2 100644 --- a/allstarr/Services/Common/CacheKeyBuilder.cs +++ b/allstarr/Services/Common/CacheKeyBuilder.cs @@ -7,101 +7,208 @@ namespace allstarr.Services.Common; public static class CacheKeyBuilder { #region Search Keys - + public static string BuildSearchKey(string? searchTerm, string? itemTypes, int? limit, int? startIndex) { return $"search:{searchTerm?.ToLowerInvariant()}:{itemTypes}:{limit}:{startIndex}"; } - + + public static string BuildSearchKey( + string? searchTerm, + string? itemTypes, + int? limit, + int? startIndex, + string? parentId, + string? sortBy, + string? sortOrder, + bool? recursive, + string? userId) + { + var normalizedTerm = Normalize(searchTerm); + var normalizedItemTypes = Normalize(itemTypes); + var normalizedParentId = Normalize(parentId); + var normalizedSortBy = Normalize(sortBy); + var normalizedSortOrder = Normalize(sortOrder); + var normalizedUserId = Normalize(userId); + var normalizedRecursive = recursive.HasValue ? (recursive.Value ? "true" : "false") : string.Empty; + + return $"search:{normalizedTerm}:{normalizedItemTypes}:{limit}:{startIndex}:{normalizedParentId}:{normalizedSortBy}:{normalizedSortOrder}:{normalizedRecursive}:{normalizedUserId}"; + } + + private static string Normalize(string? value) + { + return string.IsNullOrWhiteSpace(value) + ? string.Empty + : value.Trim().ToLowerInvariant(); + } + #endregion - + #region Metadata Keys - + public static string BuildAlbumKey(string provider, string externalId) { return $"{provider}:album:{externalId}"; } - + public static string BuildArtistKey(string provider, string externalId) { return $"{provider}:artist:{externalId}"; } - + public static string BuildSongKey(string provider, string externalId) { return $"{provider}:song:{externalId}"; } - + #endregion - + #region Spotify Keys - + public static string BuildSpotifyPlaylistKey(string playlistName) { return $"spotify:playlist:{playlistName}"; } - + public static string BuildSpotifyPlaylistItemsKey(string playlistName) { return $"spotify:playlist:items:{playlistName}"; } - + + public static string BuildSpotifyPlaylistOrderedKey(string playlistName) + { + return $"spotify:playlist:ordered:{playlistName}"; + } + public static string BuildSpotifyMatchedTracksKey(string playlistName) { return $"spotify:matched:ordered:{playlistName}"; } - + + public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName) + { + return $"spotify:matched:{playlistName}"; + } + + public static string BuildSpotifyPlaylistStatsKey(string playlistName) + { + return $"spotify:playlist:stats:{playlistName}"; + } + + public static string BuildSpotifyPlaylistStatsPattern() + { + return "spotify:playlist:stats:*"; + } + public static string BuildSpotifyMissingTracksKey(string playlistName) { return $"spotify:missing:{playlistName}"; } - + public static string BuildSpotifyManualMappingKey(string playlist, string spotifyId) { return $"spotify:manual-map:{playlist}:{spotifyId}"; } - + public static string BuildSpotifyExternalMappingKey(string playlist, string spotifyId) { return $"spotify:external-map:{playlist}:{spotifyId}"; } - + + public static string BuildSpotifyGlobalMappingKey(string spotifyId) + { + return $"spotify:global-map:{spotifyId}"; + } + + public static string BuildSpotifyGlobalMappingsIndexKey() + { + return "spotify:global-map:all-ids"; + } + #endregion - + #region Lyrics Keys - + public static string BuildLyricsKey(string artist, string title, string? album, int? durationSeconds) { return $"lyrics:{artist}:{title}:{album}:{durationSeconds}"; } - + public static string BuildLyricsPlusKey(string artist, string title, string? album, int? durationSeconds) { return $"lyricsplus:{artist}:{title}:{album}:{durationSeconds}"; } - + public static string BuildLyricsManualMappingKey(string artist, string title) { return $"lyrics:manual-map:{artist}:{title}"; } - + + public static string BuildLyricsByIdKey(int id) + { + return $"lyrics:id:{id}"; + } + #endregion - + #region Playlist Keys - + public static string BuildPlaylistImageKey(string playlistId) { return $"playlist:image:{playlistId}"; } - + #endregion - + #region Genre Keys - + + public static string BuildGenreEnrichmentKey(string title, string artist) + { + return $"genre:{title}:{artist}"; + } + + public static string BuildGenreEnrichmentKey(string compositeCacheKey) + { + return $"genre:{compositeCacheKey}"; + } + public static string BuildGenreKey(string genre) { return $"genre:{genre.ToLowerInvariant()}"; } - + + #endregion + + #region MusicBrainz Keys + + public static string BuildMusicBrainzIsrcKey(string isrc) + { + return $"musicbrainz:isrc:{isrc}"; + } + + public static string BuildMusicBrainzSearchKey(string title, string artist, int limit) + { + return $"musicbrainz:search:{title.ToLowerInvariant()}:{artist.ToLowerInvariant()}:{limit}"; + } + + public static string BuildMusicBrainzMbidKey(string mbid) + { + return $"musicbrainz:mbid:{mbid}"; + } + + #endregion + + #region Odesli Keys + + public static string BuildOdesliTidalToSpotifyKey(string tidalTrackId) + { + return $"odesli:tidal-to-spotify:{tidalTrackId}"; + } + + public static string BuildOdesliUrlToSpotifyKey(string musicUrl) + { + return $"odesli:url-to-spotify:{musicUrl}"; + } + #endregion } diff --git a/allstarr/Services/Common/CacheWarmingService.cs b/allstarr/Services/Common/CacheWarmingService.cs index 7908e24..40dc65a 100644 --- a/allstarr/Services/Common/CacheWarmingService.cs +++ b/allstarr/Services/Common/CacheWarmingService.cs @@ -45,13 +45,13 @@ public class CacheWarmingService : IHostedService // Warm playlist cache playlistsWarmed = await WarmPlaylistCacheAsync(cancellationToken); - + // Warm manual mappings cache mappingsWarmed = await WarmManualMappingsCacheAsync(cancellationToken); - + // Warm lyrics mappings cache lyricsMappingsWarmed = await WarmLyricsMappingsCacheAsync(cancellationToken); - + // Warm lyrics cache lyricsWarmed = await WarmLyricsCacheAsync(cancellationToken); @@ -104,7 +104,7 @@ public class CacheWarmingService : IHostedService if (cacheEntry != null && !string.IsNullOrEmpty(cacheEntry.CacheKey)) { - var redisKey = $"genre:{cacheEntry.CacheKey}"; + var redisKey = CacheKeyBuilder.BuildGenreEnrichmentKey(cacheEntry.CacheKey); await _cache.SetAsync(redisKey, cacheEntry.Genre, CacheExtensions.GenreTTL); warmedCount++; } @@ -165,7 +165,7 @@ public class CacheWarmingService : IHostedService await _cache.SetAsync(redisKey, items, CacheExtensions.SpotifyPlaylistItemsTTL); warmedCount++; - _logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)", + _logger.LogDebug("🔥 Warmed playlist items cache for {Playlist} ({Count} items)", playlistName, items.Count); } } @@ -203,7 +203,7 @@ public class CacheWarmingService : IHostedService await _cache.SetAsync(redisKey, matchedTracks, CacheExtensions.SpotifyMatchedTracksTTL); warmedCount++; - _logger.LogInformation("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)", + _logger.LogInformation("🔥 Warmed matched tracks cache for {Playlist} ({Count} tracks)", playlistName, matchedTracks.Count); } } @@ -220,7 +220,7 @@ public class CacheWarmingService : IHostedService return warmedCount; } - + /// /// Warms manual mappings cache from file system. /// Manual mappings NEVER expire - they are permanent user decisions. @@ -256,21 +256,21 @@ public class CacheWarmingService : IHostedService if (!string.IsNullOrEmpty(mapping.JellyfinId)) { // Jellyfin mapping - var redisKey = $"spotify:manual-map:{playlistName}:{mapping.SpotifyId}"; + var redisKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, mapping.SpotifyId); await _cache.SetAsync(redisKey, mapping.JellyfinId); warmedCount++; } else if (!string.IsNullOrEmpty(mapping.ExternalProvider) && !string.IsNullOrEmpty(mapping.ExternalId)) { // External mapping - var redisKey = $"spotify:external-map:{playlistName}:{mapping.SpotifyId}"; + var redisKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, mapping.SpotifyId); var externalMapping = new { provider = mapping.ExternalProvider, id = mapping.ExternalId }; await _cache.SetAsync(redisKey, externalMapping); warmedCount++; } } - _logger.LogDebug("🔥 Warmed {Count} manual mappings for {Playlist}", + _logger.LogDebug("🔥 Warmed {Count} manual mappings for {Playlist}", mappings.Count, playlistName); } } @@ -287,7 +287,7 @@ public class CacheWarmingService : IHostedService return warmedCount; } - + /// /// Warms lyrics mappings cache from file system. /// Lyrics mappings NEVER expire - they are permanent user decisions. @@ -295,7 +295,7 @@ public class CacheWarmingService : IHostedService private async Task WarmLyricsMappingsCacheAsync(CancellationToken cancellationToken) { var mappingsFile = "/app/cache/lyrics_mappings.json"; - + if (!File.Exists(mappingsFile)) { return 0; @@ -314,7 +314,7 @@ public class CacheWarmingService : IHostedService break; // Store in Redis with NO EXPIRATION (permanent) - var redisKey = $"lyrics:manual-map:{mapping.Artist}:{mapping.Title}"; + var redisKey = CacheKeyBuilder.BuildLyricsManualMappingKey(mapping.Artist, mapping.Title); await _cache.SetStringAsync(redisKey, mapping.LyricsId.ToString()); } @@ -329,7 +329,7 @@ public class CacheWarmingService : IHostedService return 0; } - + /// /// Warms lyrics cache from file system using the LyricsPrefetchService. /// @@ -340,18 +340,18 @@ public class CacheWarmingService : IHostedService // Get the LyricsPrefetchService from DI using var scope = _serviceProvider.CreateScope(); var lyricsPrefetchService = scope.ServiceProvider.GetService(); - + if (lyricsPrefetchService != null) { await lyricsPrefetchService.WarmCacheFromFilesAsync(); - + // Count files to return warmed count if (Directory.Exists(LyricsCacheDirectory)) { return Directory.GetFiles(LyricsCacheDirectory, "*.json").Length; } } - + return 0; } catch (Exception ex) @@ -367,7 +367,7 @@ public class CacheWarmingService : IHostedService public string Genre { get; set; } = ""; public DateTime CachedAt { get; set; } } - + private class MatchedTrack { public int Position { get; set; } @@ -378,7 +378,7 @@ public class CacheWarmingService : IHostedService public string MatchType { get; set; } = ""; public Song? MatchedSong { get; set; } } - + private class ManualMappingEntry { public string SpotifyId { get; set; } = ""; @@ -387,7 +387,7 @@ public class CacheWarmingService : IHostedService public string? ExternalId { get; set; } public DateTime CreatedAt { get; set; } } - + private class LyricsMappingEntry { public string Artist { get; set; } = ""; diff --git a/allstarr/Services/Common/GenreEnrichmentService.cs b/allstarr/Services/Common/GenreEnrichmentService.cs index b8492cd..8030038 100644 --- a/allstarr/Services/Common/GenreEnrichmentService.cs +++ b/allstarr/Services/Common/GenreEnrichmentService.cs @@ -13,7 +13,6 @@ public class GenreEnrichmentService private readonly MusicBrainzService _musicBrainz; private readonly RedisCacheService _cache; private readonly ILogger _logger; - private const string GenreCachePrefix = "genre:"; private const string GenreCacheDirectory = "/app/cache/genres"; private static readonly TimeSpan GenreCacheDuration = TimeSpan.FromDays(30); @@ -25,7 +24,7 @@ public class GenreEnrichmentService _musicBrainz = musicBrainz; _cache = cache; _logger = logger; - + // Ensure cache directory exists Directory.CreateDirectory(GenreCacheDirectory); } @@ -43,15 +42,15 @@ public class GenreEnrichmentService } var cacheKey = $"{song.Title}:{song.Artist}"; - + // Check Redis cache first - var redisCacheKey = $"{GenreCachePrefix}{cacheKey}"; + var redisCacheKey = CacheKeyBuilder.BuildGenreEnrichmentKey(cacheKey); var cachedGenre = await _cache.GetAsync(redisCacheKey); - + if (cachedGenre != null) { song.Genre = cachedGenre; - _logger.LogDebug("Using Redis cached genre for {Title} - {Artist}: {Genre}", + _logger.LogDebug("Using Redis cached genre for {Title} - {Artist}: {Genre}", song.Title, song.Artist, cachedGenre); return; } @@ -63,7 +62,7 @@ public class GenreEnrichmentService song.Genre = fileCachedGenre; // Restore to Redis cache await _cache.SetAsync(redisCacheKey, fileCachedGenre, GenreCacheDuration); - _logger.LogDebug("Using file cached genre for {Title} - {Artist}: {Genre}", + _logger.LogDebug("Using file cached genre for {Title} - {Artist}: {Genre}", song.Title, song.Artist, fileCachedGenre); return; } @@ -72,17 +71,17 @@ public class GenreEnrichmentService try { var genres = await _musicBrainz.GetGenresForSongAsync(song.Title, song.Artist, song.Isrc); - + if (genres.Count > 0) { // Use the top genre song.Genre = genres[0]; - + // Cache in both Redis and file await _cache.SetAsync(redisCacheKey, song.Genre, GenreCacheDuration); await SaveToFileCacheAsync(cacheKey, song.Genre); - - _logger.LogInformation("Enriched {Title} - {Artist} with genre: {Genre}", + + _logger.LogInformation("Enriched {Title} - {Artist} with genre: {Genre}", song.Title, song.Artist, song.Genre); } else @@ -93,7 +92,7 @@ public class GenreEnrichmentService } catch (Exception ex) { - _logger.LogError(ex, "Failed to enrich genre for {Title} - {Artist}", + _logger.LogError(ex, "Failed to enrich genre for {Title} - {Artist}", song.Title, song.Artist); } } @@ -106,7 +105,7 @@ public class GenreEnrichmentService var tasks = songs .Where(s => string.IsNullOrEmpty(s.Genre)) .Select(s => EnrichSongGenreAsync(s)); - + await Task.WhenAll(tasks); } @@ -148,7 +147,7 @@ public class GenreEnrichmentService var json = await File.ReadAllTextAsync(filePath); var cacheEntry = JsonSerializer.Deserialize(json); - + return cacheEntry?.Genre; } catch (Exception ex) diff --git a/allstarr/Services/Common/OdesliService.cs b/allstarr/Services/Common/OdesliService.cs index c1477c9..57aeaf8 100644 --- a/allstarr/Services/Common/OdesliService.cs +++ b/allstarr/Services/Common/OdesliService.cs @@ -29,7 +29,7 @@ public class OdesliService public async Task ConvertTidalToSpotifyIdAsync(string tidalTrackId, CancellationToken cancellationToken = default) { // Check cache first (7 day TTL - these mappings don't change) - var cacheKey = $"odesli:tidal-to-spotify:{tidalTrackId}"; + var cacheKey = CacheKeyBuilder.BuildOdesliTidalToSpotifyKey(tidalTrackId); var cached = await _cache.GetAsync(cacheKey); if (!string.IsNullOrEmpty(cached)) { @@ -64,10 +64,10 @@ public class OdesliService { var spotifyId = match.Groups[1].Value; _logger.LogDebug("✓ Converted Tidal/{TidalId} → Spotify ID {SpotifyId}", tidalTrackId, spotifyId); - + // Cache for configurable duration await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL); - + return spotifyId; } } @@ -89,7 +89,7 @@ public class OdesliService public async Task ConvertUrlToSpotifyIdAsync(string musicUrl, CancellationToken cancellationToken = default) { // Check cache first - var cacheKey = $"odesli:url-to-spotify:{musicUrl}"; + var cacheKey = CacheKeyBuilder.BuildOdesliUrlToSpotifyKey(musicUrl); var cached = await _cache.GetAsync(cacheKey); if (!string.IsNullOrEmpty(cached)) { @@ -123,10 +123,10 @@ public class OdesliService { var spotifyId = match.Groups[1].Value; _logger.LogDebug("✓ Converted URL → Spotify ID {SpotifyId}", spotifyId); - + // Cache for configurable duration await _cache.SetAsync(cacheKey, spotifyId, CacheExtensions.OdesliLookupTTL); - + return spotifyId; } } diff --git a/allstarr/Services/Common/OutboundRequestGuard.cs b/allstarr/Services/Common/OutboundRequestGuard.cs new file mode 100644 index 0000000..045d055 --- /dev/null +++ b/allstarr/Services/Common/OutboundRequestGuard.cs @@ -0,0 +1,156 @@ +using System.Net; +using System.Net.Sockets; + +namespace allstarr.Services.Common; + +/// +/// Guards outbound HTTP(S) requests that are derived from external metadata. +/// Blocks local/private targets to reduce SSRF risk. +/// +public static class OutboundRequestGuard +{ + public static bool TryCreateSafeHttpUri(string? rawUrl, out Uri? safeUri, out string reason) + { + safeUri = null; + reason = "URL is empty"; + + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return false; + } + + if (!Uri.TryCreate(rawUrl, UriKind.Absolute, out var parsedUri)) + { + reason = "URL must be absolute"; + return false; + } + + if (!parsedUri.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) && + !parsedUri.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)) + { + reason = "Only HTTP/HTTPS URLs are allowed"; + return false; + } + + if (!string.IsNullOrEmpty(parsedUri.UserInfo)) + { + reason = "Userinfo in URL is not allowed"; + return false; + } + + if (parsedUri.HostNameType is UriHostNameType.IPv4 or UriHostNameType.IPv6) + { + if (!IPAddress.TryParse(parsedUri.Host, out var ipAddress)) + { + reason = "Invalid IP address host"; + return false; + } + + if (!IsPublicRoutableIp(ipAddress)) + { + reason = "Private/local IP hosts are not allowed"; + return false; + } + } + else + { + var host = parsedUri.Host.TrimEnd('.').ToLowerInvariant(); + if (host == "localhost" || + host == "localhost.localdomain" || + host.EndsWith(".localhost", StringComparison.Ordinal) || + host.EndsWith(".local", StringComparison.Ordinal)) + { + reason = "Local hostnames are not allowed"; + return false; + } + } + + safeUri = parsedUri; + reason = string.Empty; + return true; + } + + private static bool IsPublicRoutableIp(IPAddress ipAddress) + { + if (IPAddress.IsLoopback(ipAddress) || + ipAddress.Equals(IPAddress.Any) || + ipAddress.Equals(IPAddress.None) || + ipAddress.Equals(IPAddress.IPv6Any) || + ipAddress.Equals(IPAddress.IPv6None) || + ipAddress.Equals(IPAddress.IPv6Loopback)) + { + return false; + } + + if (ipAddress.IsIPv4MappedToIPv6) + { + return IsPublicRoutableIp(ipAddress.MapToIPv4()); + } + + if (ipAddress.AddressFamily == AddressFamily.InterNetworkV6) + { + if (ipAddress.IsIPv6Multicast || ipAddress.IsIPv6LinkLocal || ipAddress.IsIPv6SiteLocal) + { + return false; + } + + // Unique local addresses fc00::/7. + var bytes = ipAddress.GetAddressBytes(); + if ((bytes[0] & 0xFE) == 0xFC) + { + return false; + } + + return true; + } + + var ipv4Bytes = ipAddress.GetAddressBytes(); + if (ipv4Bytes.Length != 4) + { + return false; + } + + var first = ipv4Bytes[0]; + var second = ipv4Bytes[1]; + + if (first == 0 || first == 10 || first == 127) + { + return false; + } + + if (first == 169 && second == 254) + { + return false; + } + + if (first == 172 && second >= 16 && second <= 31) + { + return false; + } + + if (first == 192 && second == 168) + { + return false; + } + + // Carrier-grade NAT block 100.64.0.0/10. + if (first == 100 && second >= 64 && second <= 127) + { + return false; + } + + // Benchmarking block 198.18.0.0/15. + if (first == 198 && (second == 18 || second == 19)) + { + return false; + } + + // Multicast/reserved. + if (first >= 224) + { + return false; + } + + return true; + } +} diff --git a/allstarr/Services/Common/ParallelMetadataService.cs b/allstarr/Services/Common/ParallelMetadataService.cs index aad3985..678393e 100644 --- a/allstarr/Services/Common/ParallelMetadataService.cs +++ b/allstarr/Services/Common/ParallelMetadataService.cs @@ -24,7 +24,7 @@ public class ParallelMetadataService /// Races all providers and returns the first successful result. /// Falls back to next provider if first one fails. /// - public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20) + public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) { if (!_providers.Any()) { @@ -41,7 +41,7 @@ public class ParallelMetadataService try { var sw = System.Diagnostics.Stopwatch.StartNew(); - var result = await provider.SearchAllAsync(query, songLimit, albumLimit, artistLimit); + var result = await provider.SearchAllAsync(query, songLimit, albumLimit, artistLimit, cancellationToken); sw.Stop(); _logger.LogInformation("✅ {Provider} completed search in {Ms}ms ({Songs} songs, {Albums} albums, {Artists} artists)", @@ -82,7 +82,7 @@ public class ParallelMetadataService /// Searches for a specific song by title and artist across all providers in parallel. /// Returns the first successful match. /// - public async Task SearchSongAsync(string title, string artist, int limit = 5) + public async Task SearchSongAsync(string title, string artist, int limit = 5, CancellationToken cancellationToken = default) { if (!_providers.Any()) { @@ -97,7 +97,7 @@ public class ParallelMetadataService try { var sw = System.Diagnostics.Stopwatch.StartNew(); - var songs = await provider.SearchSongsAsync($"{title} {artist}", limit); + var songs = await provider.SearchSongsAsync($"{title} {artist}", limit, cancellationToken); sw.Stop(); var bestMatch = songs.FirstOrDefault(); diff --git a/allstarr/Services/Common/ProviderIdsEnricher.cs b/allstarr/Services/Common/ProviderIdsEnricher.cs new file mode 100644 index 0000000..f2022a6 --- /dev/null +++ b/allstarr/Services/Common/ProviderIdsEnricher.cs @@ -0,0 +1,113 @@ +using System.Text.Json; + +namespace allstarr.Services.Common; + +/// +/// Normalizes and enriches Jellyfin item ProviderIds metadata. +/// +public static class ProviderIdsEnricher +{ + public static void EnsureSpotifyProviderIds( + Dictionary item, + string? spotifyId, + string? spotifyAlbumId = null) + { + if (item == null) + { + return; + } + + if (string.IsNullOrWhiteSpace(spotifyId) && string.IsNullOrWhiteSpace(spotifyAlbumId)) + { + return; + } + + var providerIds = GetOrCreateProviderIds(item); + + if (!string.IsNullOrWhiteSpace(spotifyId) && !providerIds.ContainsKey("Spotify")) + { + providerIds["Spotify"] = spotifyId.Trim(); + } + + if (!string.IsNullOrWhiteSpace(spotifyAlbumId) && !providerIds.ContainsKey("SpotifyAlbum")) + { + providerIds["SpotifyAlbum"] = spotifyAlbumId.Trim(); + } + } + + private static Dictionary GetOrCreateProviderIds(Dictionary item) + { + if (!item.TryGetValue("ProviderIds", out var rawProviderIds) || rawProviderIds == null) + { + var created = new Dictionary(StringComparer.OrdinalIgnoreCase); + item["ProviderIds"] = created; + return created; + } + + if (rawProviderIds is Dictionary stringDict) + { + if (!ReferenceEquals(stringDict.Comparer, StringComparer.OrdinalIgnoreCase)) + { + var normalized = new Dictionary(stringDict, StringComparer.OrdinalIgnoreCase); + item["ProviderIds"] = normalized; + return normalized; + } + + return stringDict; + } + + var converted = new Dictionary(StringComparer.OrdinalIgnoreCase); + + if (rawProviderIds is Dictionary objectDict) + { + foreach (var (key, value) in objectDict) + { + var str = ConvertToString(value); + if (str != null) + { + converted[key] = str; + } + } + + item["ProviderIds"] = converted; + return converted; + } + + if (rawProviderIds is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object) + { + foreach (var prop in jsonElement.EnumerateObject()) + { + var str = prop.Value.ValueKind == JsonValueKind.String + ? prop.Value.GetString() + : prop.Value.GetRawText(); + + if (str != null) + { + converted[prop.Name] = str; + } + } + + item["ProviderIds"] = converted; + return converted; + } + + item["ProviderIds"] = converted; + return converted; + } + + private static string? ConvertToString(object? value) + { + if (value == null) + { + return null; + } + + return value switch + { + string s => s, + JsonElement { ValueKind: JsonValueKind.String } je => je.GetString(), + JsonElement je => je.GetRawText(), + _ => value.ToString() + }; + } +} diff --git a/allstarr/Services/Common/RedisCacheService.cs b/allstarr/Services/Common/RedisCacheService.cs index 2180298..c28dbd4 100644 --- a/allstarr/Services/Common/RedisCacheService.cs +++ b/allstarr/Services/Common/RedisCacheService.cs @@ -57,7 +57,7 @@ public class RedisCacheService try { var value = await _db!.StringGetAsync(key); - + if (value.HasValue) { _logger.LogDebug("Redis cache HIT: {Key}", key); @@ -103,12 +103,25 @@ public class RedisCacheService try { - var result = await _db!.StringSetAsync(key, value, expiry); - if (result) - { - _logger.LogDebug("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none"); - } - return result; + return await SetStringInternalAsync(key, value, expiry); + } + catch (RedisTimeoutException ex) + { + return await RetrySetAfterReconnectAsync( + key, + value, + expiry, + ex, + "Redis SET timeout for key: {Key}. Reconnecting and retrying once."); + } + catch (RedisConnectionException ex) + { + return await RetrySetAfterReconnectAsync( + key, + value, + expiry, + ex, + "Redis SET connection error for key: {Key}. Reconnecting and retrying once."); } catch (Exception ex) { @@ -117,6 +130,71 @@ public class RedisCacheService } } + private async Task SetStringInternalAsync(string key, string value, TimeSpan? expiry) + { + var result = await _db!.StringSetAsync(key, value, expiry); + if (result) + { + _logger.LogDebug("Redis cache SET: {Key} (TTL: {Expiry})", key, expiry?.ToString() ?? "none"); + } + else + { + _logger.LogWarning("Redis SET returned false for key: {Key}", key); + } + return result; + } + + private async Task RetrySetAfterReconnectAsync( + string key, + string value, + TimeSpan? expiry, + Exception ex, + string warningMessage) + { + _logger.LogWarning(ex, warningMessage, key); + + if (!TryReconnect()) + { + _logger.LogError("Redis reconnect failed; cannot retry SET for key: {Key}", key); + return false; + } + + try + { + return await SetStringInternalAsync(key, value, expiry); + } + catch (Exception retryEx) + { + _logger.LogError(retryEx, "Redis SET retry failed for key: {Key}", key); + return false; + } + } + + private bool TryReconnect() + { + lock (_lock) + { + if (!_settings.Enabled) + { + return false; + } + + try + { + _redis?.Dispose(); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Error disposing Redis connection during reconnect"); + } + + _redis = null; + _db = null; + InitializeConnection(); + return _db != null; + } + } + /// /// Sets a cached value by serializing it with TTL. /// @@ -182,7 +260,7 @@ public class RedisCacheService { var server = _redis!.GetServer(_redis.GetEndPoints().First()); var keys = server.Keys(pattern: pattern).ToArray(); - + if (keys.Length == 0) { _logger.LogDebug("No keys found matching pattern: {Pattern}", pattern); diff --git a/allstarr/Services/Common/RoundRobinFallbackHelper.cs b/allstarr/Services/Common/RoundRobinFallbackHelper.cs index 8469d10..cadee9c 100644 --- a/allstarr/Services/Common/RoundRobinFallbackHelper.cs +++ b/allstarr/Services/Common/RoundRobinFallbackHelper.cs @@ -6,6 +6,7 @@ namespace allstarr.Services.Common; /// public class RoundRobinFallbackHelper { + private const int PreferredFastEndpointCount = 2; private readonly List _apiUrls; private int _currentUrlIndex = 0; private readonly object _urlIndexLock = new object(); @@ -144,6 +145,40 @@ public class RoundRobinFallbackHelper return healthyEndpoints; } + private List BuildTryOrder(List endpointsToTry) + { + if (endpointsToTry.Count <= 1) + { + return endpointsToTry; + } + + // Prefer the fastest endpoints first (benchmark order), while still keeping + // all remaining endpoints available as fallback. + var preferredCount = Math.Min(PreferredFastEndpointCount, endpointsToTry.Count); + + int preferredStartIndex; + lock (_urlIndexLock) + { + preferredStartIndex = _currentUrlIndex % preferredCount; + _currentUrlIndex = (_currentUrlIndex + 1) % preferredCount; + } + + var ordered = new List(endpointsToTry.Count); + + for (int i = 0; i < preferredCount; i++) + { + var index = (preferredStartIndex + i) % preferredCount; + ordered.Add(endpointsToTry[index]); + } + + for (int i = preferredCount; i < endpointsToTry.Count; i++) + { + ordered.Add(endpointsToTry[i]); + } + + return ordered; + } + /// /// Updates the endpoint order based on benchmark results (fastest first). /// @@ -179,30 +214,23 @@ public class RoundRobinFallbackHelper { // Get healthy endpoints first (with caching to avoid excessive checks) var healthyEndpoints = await GetHealthyEndpointsAsync(); - - // Start with the next URL in round-robin to distribute load - var startIndex = 0; - lock (_urlIndexLock) - { - startIndex = _currentUrlIndex; - _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; - } - + // Try healthy endpoints first, then fall back to all if needed var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count ? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList() : healthyEndpoints; + + var orderedEndpoints = BuildTryOrder(endpointsToTry); - // Try all URLs starting from the round-robin selected one - for (int attempt = 0; attempt < endpointsToTry.Count; attempt++) + // Try preferred fast endpoints first, then full fallback pool. + for (int attempt = 0; attempt < orderedEndpoints.Count; attempt++) { - var urlIndex = (startIndex + attempt) % endpointsToTry.Count; - var baseUrl = endpointsToTry[urlIndex]; + var baseUrl = orderedEndpoints[attempt]; try { _logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})", - _serviceName, baseUrl, attempt + 1, endpointsToTry.Count); + _serviceName, baseUrl, attempt + 1, orderedEndpoints.Count); return await action(baseUrl); } catch (Exception ex) @@ -216,9 +244,9 @@ public class RoundRobinFallbackHelper _healthCache[baseUrl] = (false, DateTime.UtcNow); } - if (attempt == endpointsToTry.Count - 1) + if (attempt == orderedEndpoints.Count - 1) { - _logger.LogError("All {Count} {Service} endpoints failed", endpointsToTry.Count, _serviceName); + _logger.LogError("All {Count} {Service} endpoints failed", orderedEndpoints.Count, _serviceName); throw; } } @@ -302,30 +330,23 @@ public class RoundRobinFallbackHelper { // Get healthy endpoints first (with caching to avoid excessive checks) var healthyEndpoints = await GetHealthyEndpointsAsync(); - - // Start with the next URL in round-robin to distribute load - var startIndex = 0; - lock (_urlIndexLock) - { - startIndex = _currentUrlIndex; - _currentUrlIndex = (_currentUrlIndex + 1) % _apiUrls.Count; - } - + // Try healthy endpoints first, then fall back to all if needed var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count ? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList() : healthyEndpoints; + + var orderedEndpoints = BuildTryOrder(endpointsToTry); - // Try all URLs starting from the round-robin selected one - for (int attempt = 0; attempt < endpointsToTry.Count; attempt++) + // Try preferred fast endpoints first, then full fallback pool. + for (int attempt = 0; attempt < orderedEndpoints.Count; attempt++) { - var urlIndex = (startIndex + attempt) % endpointsToTry.Count; - var baseUrl = endpointsToTry[urlIndex]; + var baseUrl = orderedEndpoints[attempt]; try { _logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})", - _serviceName, baseUrl, attempt + 1, endpointsToTry.Count); + _serviceName, baseUrl, attempt + 1, orderedEndpoints.Count); return await action(baseUrl); } catch (Exception ex) @@ -339,10 +360,10 @@ public class RoundRobinFallbackHelper _healthCache[baseUrl] = (false, DateTime.UtcNow); } - if (attempt == endpointsToTry.Count - 1) + if (attempt == orderedEndpoints.Count - 1) { _logger.LogError("All {Count} {Service} endpoints failed, returning default value", - endpointsToTry.Count, _serviceName); + orderedEndpoints.Count, _serviceName); return defaultValue; } } diff --git a/allstarr/Services/Common/TrackParserBase.cs b/allstarr/Services/Common/TrackParserBase.cs new file mode 100644 index 0000000..5795519 --- /dev/null +++ b/allstarr/Services/Common/TrackParserBase.cs @@ -0,0 +1,47 @@ +using System.Text.Json; + +namespace allstarr.Services.Common; + +/// +/// Shared helpers for provider-specific track/album/artist parsers. +/// Keeps ID and date parsing behavior consistent across metadata services. +/// +public abstract class TrackParserBase +{ + protected static string BuildExternalSongId(string provider, string externalId) + { + return $"ext-{provider}-song-{externalId}"; + } + + protected static string BuildExternalAlbumId(string provider, string externalId) + { + return $"ext-{provider}-album-{externalId}"; + } + + protected static string BuildExternalArtistId(string provider, string externalId) + { + return $"ext-{provider}-artist-{externalId}"; + } + + protected static int? ParseYearFromDateString(string? dateString) + { + if (string.IsNullOrWhiteSpace(dateString) || dateString.Length < 4) + { + return null; + } + + return int.TryParse(dateString.Substring(0, 4), out var year) + ? year + : null; + } + + protected static string GetIdAsString(JsonElement idElement) + { + return idElement.ValueKind switch + { + JsonValueKind.Number => idElement.GetInt64().ToString(), + JsonValueKind.String => idElement.GetString() ?? string.Empty, + _ => string.Empty + }; + } +} diff --git a/allstarr/Services/Common/VersionUpgradePolicy.cs b/allstarr/Services/Common/VersionUpgradePolicy.cs new file mode 100644 index 0000000..8f87751 --- /dev/null +++ b/allstarr/Services/Common/VersionUpgradePolicy.cs @@ -0,0 +1,49 @@ +namespace allstarr.Services.Common; + +/// +/// Defines when a version change should trigger a full playlist rebuild. +/// +public static class VersionUpgradePolicy +{ + /// + /// Returns true when the current version is a major or minor upgrade over the previous version. + /// Patch-only upgrades and downgrades return false. + /// + public static bool ShouldTriggerRebuild(string previousVersion, string currentVersion, out string reason) + { + reason = "no rebuild required"; + + if (!Version.TryParse(previousVersion, out var previous)) + { + reason = "previous version is invalid"; + return false; + } + + if (!Version.TryParse(currentVersion, out var current)) + { + reason = "current version is invalid"; + return false; + } + + if (current.CompareTo(previous) <= 0) + { + reason = "version is not an upgrade"; + return false; + } + + if (current.Major > previous.Major) + { + reason = "major version upgrade"; + return true; + } + + if (current.Minor > previous.Minor) + { + reason = "minor version upgrade"; + return true; + } + + reason = "patch-only upgrade"; + return false; + } +} diff --git a/allstarr/Services/Common/VersionUpgradeRebuildService.cs b/allstarr/Services/Common/VersionUpgradeRebuildService.cs new file mode 100644 index 0000000..0af392a --- /dev/null +++ b/allstarr/Services/Common/VersionUpgradeRebuildService.cs @@ -0,0 +1,117 @@ +using allstarr.Models.Settings; +using allstarr.Services.Spotify; +using Microsoft.Extensions.Options; + +namespace allstarr.Services.Common; + +/// +/// Triggers a one-time full rebuild when the app is upgraded across major/minor versions. +/// +public class VersionUpgradeRebuildService : IHostedService +{ + private const string VersionStateFile = "/app/cache/version-state.txt"; + + private readonly SpotifyTrackMatchingService _matchingService; + private readonly SpotifyImportSettings _spotifyImportSettings; + private readonly ILogger _logger; + + public VersionUpgradeRebuildService( + SpotifyTrackMatchingService matchingService, + IOptions spotifyImportSettings, + ILogger logger) + { + _matchingService = matchingService; + _spotifyImportSettings = spotifyImportSettings.Value; + _logger = logger; + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + var currentVersion = AppVersion.Version; + var previousVersion = await ReadPreviousVersionAsync(cancellationToken); + + if (string.IsNullOrWhiteSpace(previousVersion)) + { + _logger.LogInformation("No prior version state found, saving current version {Version}", currentVersion); + await WriteCurrentVersionAsync(currentVersion, cancellationToken); + return; + } + + if (VersionUpgradePolicy.ShouldTriggerRebuild(previousVersion, currentVersion, out var reason)) + { + _logger.LogInformation( + "Detected {Reason}: {PreviousVersion} -> {CurrentVersion}", + reason, previousVersion, currentVersion); + + if (!_spotifyImportSettings.Enabled) + { + _logger.LogInformation("Skipping auto rebuild: Spotify import is disabled"); + } + else if (_spotifyImportSettings.Playlists.Count == 0) + { + _logger.LogInformation("Skipping auto rebuild: no Spotify playlists are configured"); + } + else + { + _logger.LogInformation("Triggering full rebuild for all playlists after version upgrade"); + try + { + await _matchingService.TriggerRebuildAllAsync(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade"); + } + } + } + else + { + _logger.LogDebug( + "Version upgrade check did not require rebuild: {PreviousVersion} -> {CurrentVersion} ({Reason})", + previousVersion, currentVersion, reason); + } + + await WriteCurrentVersionAsync(currentVersion, cancellationToken); + } + + public Task StopAsync(CancellationToken cancellationToken) + { + return Task.CompletedTask; + } + + private async Task ReadPreviousVersionAsync(CancellationToken cancellationToken) + { + try + { + if (!File.Exists(VersionStateFile)) + { + return null; + } + + return (await File.ReadAllTextAsync(VersionStateFile, cancellationToken)).Trim(); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not read version state file: {Path}", VersionStateFile); + return null; + } + } + + private async Task WriteCurrentVersionAsync(string version, CancellationToken cancellationToken) + { + try + { + var directory = Path.GetDirectoryName(VersionStateFile); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + await File.WriteAllTextAsync(VersionStateFile, version, cancellationToken); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Could not write version state file: {Path}", VersionStateFile); + } + } +} diff --git a/allstarr/Services/Deezer/DeezerMetadataService.cs b/allstarr/Services/Deezer/DeezerMetadataService.cs index 7d7f82d..9d6354c 100644 --- a/allstarr/Services/Deezer/DeezerMetadataService.cs +++ b/allstarr/Services/Deezer/DeezerMetadataService.cs @@ -12,7 +12,7 @@ namespace allstarr.Services.Deezer; /// /// Metadata service implementation using the Deezer API (free, no key required) /// -public class DeezerMetadataService : IMusicMetadataService +public class DeezerMetadataService : TrackParserBase, IMusicMetadataService { private readonly HttpClient _httpClient; private readonly SubsonicSettings _settings; @@ -20,7 +20,7 @@ public class DeezerMetadataService : IMusicMetadataService private const string BaseUrl = "https://api.deezer.com"; public DeezerMetadataService( - IHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, IOptions settings, GenreEnrichmentService? genreEnrichment = null) { @@ -29,18 +29,18 @@ public class DeezerMetadataService : IMusicMetadataService _genreEnrichment = genreEnrichment; } - public async Task> SearchSongsAsync(string query, int limit = 20) + public async Task> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { try { var url = $"{BaseUrl}/search/track?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var songs = new List(); if (result.RootElement.TryGetProperty("data", out var data)) { @@ -53,7 +53,7 @@ public class DeezerMetadataService : IMusicMetadataService } } } - + return songs; } catch @@ -62,18 +62,18 @@ public class DeezerMetadataService : IMusicMetadataService } } - public async Task> SearchAlbumsAsync(string query, int limit = 20) + public async Task> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { try { var url = $"{BaseUrl}/search/album?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var albums = new List(); if (result.RootElement.TryGetProperty("data", out var data)) { @@ -82,7 +82,7 @@ public class DeezerMetadataService : IMusicMetadataService albums.Add(ParseDeezerAlbum(album)); } } - + return albums; } catch @@ -91,18 +91,18 @@ public class DeezerMetadataService : IMusicMetadataService } } - public async Task> SearchArtistsAsync(string query, int limit = 20) + public async Task> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { try { var url = $"{BaseUrl}/search/artist?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var artists = new List(); if (result.RootElement.TryGetProperty("data", out var data)) { @@ -111,7 +111,7 @@ public class DeezerMetadataService : IMusicMetadataService artists.Add(ParseDeezerArtist(artist)); } } - + return artists; } catch @@ -120,15 +120,15 @@ public class DeezerMetadataService : IMusicMetadataService } } - public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20) + public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) { // Execute searches in parallel - var songsTask = SearchSongsAsync(query, songLimit); - var albumsTask = SearchAlbumsAsync(query, albumLimit); - var artistsTask = SearchArtistsAsync(query, artistLimit); - + var songsTask = SearchSongsAsync(query, songLimit, cancellationToken); + var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken); + var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken); + await Task.WhenAll(songsTask, albumsTask, artistsTask); - + return new SearchResult { Songs = await songsTask, @@ -137,23 +137,23 @@ public class DeezerMetadataService : IMusicMetadataService }; } - public async Task GetSongAsync(string externalProvider, string externalId) + public async Task GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "deezer") return null; - + var url = $"{BaseUrl}/track/{externalId}"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var track = JsonDocument.Parse(json).RootElement; - + if (track.TryGetProperty("error", out _)) return null; - + // For an individual track, get full metadata var song = ParseDeezerTrackFull(track); - + // Get additional info from album (genre, total track count, label, copyright) if (track.TryGetProperty("album", out var albumRef) && albumRef.TryGetProperty("id", out var albumIdEl)) @@ -162,33 +162,33 @@ public class DeezerMetadataService : IMusicMetadataService try { var albumUrl = $"{BaseUrl}/album/{albumId}"; - var albumResponse = await _httpClient.GetAsync(albumUrl); + var albumResponse = await _httpClient.GetAsync(albumUrl, cancellationToken); if (albumResponse.IsSuccessStatusCode) { - var albumJson = await albumResponse.Content.ReadAsStringAsync(); + var albumJson = await albumResponse.Content.ReadAsStringAsync(cancellationToken); var albumData = JsonDocument.Parse(albumJson).RootElement; - + // Genre - if (albumData.TryGetProperty("genres", out var genres) && + if (albumData.TryGetProperty("genres", out var genres) && genres.TryGetProperty("data", out var genresData) && genresData.GetArrayLength() > 0 && genresData[0].TryGetProperty("name", out var genreName)) { song.Genre = genreName.GetString(); } - + // Total track count if (albumData.TryGetProperty("nb_tracks", out var nbTracks)) { song.TotalTracks = nbTracks.GetInt32(); } - + // Label if (albumData.TryGetProperty("label", out var label)) { song.Label = label.GetString(); } - + // Cover art XL if not already set if (string.IsNullOrEmpty(song.CoverArtUrlLarge)) { @@ -208,7 +208,7 @@ public class DeezerMetadataService : IMusicMetadataService // If we can't get the album, continue with track info only } } - + // Enrich with MusicBrainz genres if missing if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre)) { @@ -225,26 +225,26 @@ public class DeezerMetadataService : IMusicMetadataService } }); } - + return song; } - public async Task GetAlbumAsync(string externalProvider, string externalId) + public async Task GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "deezer") return null; - + var url = $"{BaseUrl}/album/{externalId}"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var albumElement = JsonDocument.Parse(json).RootElement; - + if (albumElement.TryGetProperty("error", out _)) return null; - + var album = ParseDeezerAlbum(albumElement); - + // Get album songs if (albumElement.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("data", out var tracksData)) @@ -254,12 +254,12 @@ public class DeezerMetadataService : IMusicMetadataService { // Pass the album artist to ensure proper folder organization var song = ParseDeezerTrack(track, trackIndex, album.Artist); - + // Ensure album metadata is set (tracks in album response may not have full album object) song.Album = album.Title; song.AlbumId = album.Id; song.AlbumArtist = album.Artist; - + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) { album.Songs.Add(song); @@ -267,39 +267,39 @@ public class DeezerMetadataService : IMusicMetadataService trackIndex++; } } - + return album; } - public async Task GetArtistAsync(string externalProvider, string externalId) + public async Task GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "deezer") return null; - + var url = $"{BaseUrl}/artist/{externalId}"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var artist = JsonDocument.Parse(json).RootElement; - + if (artist.TryGetProperty("error", out _)) return null; - + return ParseDeezerArtist(artist); } - public async Task> GetArtistAlbumsAsync(string externalProvider, string externalId) + public async Task> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "deezer") return new List(); - + var url = $"{BaseUrl}/artist/{externalId}/albums"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var albums = new List(); if (result.RootElement.TryGetProperty("data", out var data)) { @@ -308,22 +308,22 @@ public class DeezerMetadataService : IMusicMetadataService albums.Add(ParseDeezerAlbum(album)); } } - + return albums; } - public async Task> GetArtistTracksAsync(string externalProvider, string externalId) + public async Task> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "deezer") return new List(); - + var url = $"{BaseUrl}/artist/{externalId}/top?limit=50"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var tracks = new List(); if (result.RootElement.TryGetProperty("data", out var data)) { @@ -332,45 +332,45 @@ public class DeezerMetadataService : IMusicMetadataService tracks.Add(ParseDeezerTrack(track)); } } - + return tracks; } private Song ParseDeezerTrack(JsonElement track, int? fallbackTrackNumber = null, string? albumArtist = null) { var externalId = track.GetProperty("id").GetInt64().ToString(); - + // Try to get track_position from API, fallback to provided index - int? trackNumber = track.TryGetProperty("track_position", out var trackPos) - ? trackPos.GetInt32() + int? trackNumber = track.TryGetProperty("track_position", out var trackPos) + ? trackPos.GetInt32() : fallbackTrackNumber; - + // Explicit content lyrics value - int? explicitContentLyrics = track.TryGetProperty("explicit_content_lyrics", out var ecl) - ? ecl.GetInt32() + int? explicitContentLyrics = track.TryGetProperty("explicit_content_lyrics", out var ecl) + ? ecl.GetInt32() : null; - + return new Song { - Id = $"ext-deezer-song-{externalId}", + Id = BuildExternalSongId("deezer", externalId), Title = track.GetProperty("title").GetString() ?? "", - Artist = track.TryGetProperty("artist", out var artist) - ? artist.GetProperty("name").GetString() ?? "" + Artist = track.TryGetProperty("artist", out var artist) + ? artist.GetProperty("name").GetString() ?? "" : "", - ArtistId = track.TryGetProperty("artist", out var artistForId) - ? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}" + ArtistId = track.TryGetProperty("artist", out var artistForId) + ? BuildExternalArtistId("deezer", artistForId.GetProperty("id").GetInt64().ToString()) : null, - Album = track.TryGetProperty("album", out var album) - ? album.GetProperty("title").GetString() ?? "" + Album = track.TryGetProperty("album", out var album) + ? album.GetProperty("title").GetString() ?? "" : "", - AlbumId = track.TryGetProperty("album", out var albumForId) - ? $"ext-deezer-album-{albumForId.GetProperty("id").GetInt64()}" + AlbumId = track.TryGetProperty("album", out var albumForId) + ? BuildExternalAlbumId("deezer", albumForId.GetProperty("id").GetInt64().ToString()) : null, - Duration = track.TryGetProperty("duration", out var duration) - ? duration.GetInt32() + Duration = track.TryGetProperty("duration", out var duration) + ? duration.GetInt32() : null, Track = trackNumber, - CoverArtUrl = track.TryGetProperty("album", out var albumForCover) && + CoverArtUrl = track.TryGetProperty("album", out var albumForCover) && albumForCover.TryGetProperty("cover_medium", out var cover) ? cover.GetString() : null, @@ -389,48 +389,40 @@ public class DeezerMetadataService : IMusicMetadataService private Song ParseDeezerTrackFull(JsonElement track) { var externalId = track.GetProperty("id").GetInt64().ToString(); - + // Track position et disc number - int? trackNumber = track.TryGetProperty("track_position", out var trackPos) - ? trackPos.GetInt32() + int? trackNumber = track.TryGetProperty("track_position", out var trackPos) + ? trackPos.GetInt32() : null; - int? discNumber = track.TryGetProperty("disk_number", out var diskNum) - ? diskNum.GetInt32() + int? discNumber = track.TryGetProperty("disk_number", out var diskNum) + ? diskNum.GetInt32() : null; - + // BPM int? bpm = track.TryGetProperty("bpm", out var bpmVal) && bpmVal.ValueKind == JsonValueKind.Number - ? (int)bpmVal.GetDouble() + ? (int)bpmVal.GetDouble() : null; - + // ISRC - string? isrc = track.TryGetProperty("isrc", out var isrcVal) - ? isrcVal.GetString() + string? isrc = track.TryGetProperty("isrc", out var isrcVal) + ? isrcVal.GetString() : null; - + // Release date from album string? releaseDate = null; int? year = null; if (track.TryGetProperty("release_date", out var relDate)) { releaseDate = relDate.GetString(); - if (!string.IsNullOrEmpty(releaseDate) && releaseDate.Length >= 4) - { - if (int.TryParse(releaseDate.Substring(0, 4), out var y)) - year = y; - } + year = ParseYearFromDateString(releaseDate); } - else if (track.TryGetProperty("album", out var albumForDate) && + else if (track.TryGetProperty("album", out var albumForDate) && albumForDate.TryGetProperty("release_date", out var albumRelDate)) { releaseDate = albumRelDate.GetString(); - if (!string.IsNullOrEmpty(releaseDate) && releaseDate.Length >= 4) - { - if (int.TryParse(releaseDate.Substring(0, 4), out var y)) - year = y; - } + year = ParseYearFromDateString(releaseDate); } - + // Contributors (all artists including features) var contributors = new List(); var contributorIds = new List(); @@ -438,7 +430,7 @@ public class DeezerMetadataService : IMusicMetadataService { foreach (var contrib in contribs.EnumerateArray()) { - if (contrib.TryGetProperty("name", out var contribName) && + if (contrib.TryGetProperty("name", out var contribName) && contrib.TryGetProperty("id", out var contribId)) { var name = contribName.GetString(); @@ -446,60 +438,60 @@ public class DeezerMetadataService : IMusicMetadataService if (!string.IsNullOrEmpty(name)) { contributors.Add(name); - contributorIds.Add($"ext-deezer-artist-{id}"); + contributorIds.Add(BuildExternalArtistId("deezer", id.ToString())); } } } } - + // Album artist (first artist from album, or main track artist) string? albumArtist = null; - if (track.TryGetProperty("album", out var albumForArtist) && + if (track.TryGetProperty("album", out var albumForArtist) && albumForArtist.TryGetProperty("artist", out var albumArtistEl)) { - albumArtist = albumArtistEl.TryGetProperty("name", out var aName) - ? aName.GetString() + albumArtist = albumArtistEl.TryGetProperty("name", out var aName) + ? aName.GetString() : null; } - + // Cover art URLs (different sizes) string? coverMedium = null; string? coverLarge = null; if (track.TryGetProperty("album", out var albumForCover)) { - coverMedium = albumForCover.TryGetProperty("cover_medium", out var cm) - ? cm.GetString() + coverMedium = albumForCover.TryGetProperty("cover_medium", out var cm) + ? cm.GetString() : null; - coverLarge = albumForCover.TryGetProperty("cover_xl", out var cxl) - ? cxl.GetString() + coverLarge = albumForCover.TryGetProperty("cover_xl", out var cxl) + ? cxl.GetString() : (albumForCover.TryGetProperty("cover_big", out var cb) ? cb.GetString() : null); } - + // Explicit content lyrics value - int? explicitContentLyrics = track.TryGetProperty("explicit_content_lyrics", out var ecl) - ? ecl.GetInt32() + int? explicitContentLyrics = track.TryGetProperty("explicit_content_lyrics", out var ecl) + ? ecl.GetInt32() : null; - + return new Song { - Id = $"ext-deezer-song-{externalId}", + Id = BuildExternalSongId("deezer", externalId), Title = track.GetProperty("title").GetString() ?? "", - Artist = track.TryGetProperty("artist", out var artist) - ? artist.GetProperty("name").GetString() ?? "" + Artist = track.TryGetProperty("artist", out var artist) + ? artist.GetProperty("name").GetString() ?? "" : "", - ArtistId = track.TryGetProperty("artist", out var artistForId) - ? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}" + ArtistId = track.TryGetProperty("artist", out var artistForId) + ? BuildExternalArtistId("deezer", artistForId.GetProperty("id").GetInt64().ToString()) : null, Artists = contributors.Count > 0 ? contributors : new List(), ArtistIds = contributorIds.Count > 0 ? contributorIds : new List(), - Album = track.TryGetProperty("album", out var album) - ? album.GetProperty("title").GetString() ?? "" + Album = track.TryGetProperty("album", out var album) + ? album.GetProperty("title").GetString() ?? "" : "", - AlbumId = track.TryGetProperty("album", out var albumForId) - ? $"ext-deezer-album-{albumForId.GetProperty("id").GetInt64()}" + AlbumId = track.TryGetProperty("album", out var albumForId) + ? BuildExternalAlbumId("deezer", albumForId.GetProperty("id").GetInt64().ToString()) : null, - Duration = track.TryGetProperty("duration", out var duration) - ? duration.GetInt32() + Duration = track.TryGetProperty("duration", out var duration) + ? duration.GetInt32() : null, Track = trackNumber, DiscNumber = discNumber, @@ -521,27 +513,27 @@ public class DeezerMetadataService : IMusicMetadataService private Album ParseDeezerAlbum(JsonElement album) { var externalId = album.GetProperty("id").GetInt64().ToString(); - + return new Album { - Id = $"ext-deezer-album-{externalId}", + Id = BuildExternalAlbumId("deezer", externalId), Title = album.GetProperty("title").GetString() ?? "", - Artist = album.TryGetProperty("artist", out var artist) - ? artist.GetProperty("name").GetString() ?? "" + Artist = album.TryGetProperty("artist", out var artist) + ? artist.GetProperty("name").GetString() ?? "" : "", - ArtistId = album.TryGetProperty("artist", out var artistForId) - ? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}" + ArtistId = album.TryGetProperty("artist", out var artistForId) + ? BuildExternalArtistId("deezer", artistForId.GetProperty("id").GetInt64().ToString()) : null, - Year = album.TryGetProperty("release_date", out var releaseDate) - ? int.TryParse(releaseDate.GetString()?.Split('-')[0], out var year) ? year : null + Year = album.TryGetProperty("release_date", out var releaseDate) + ? ParseYearFromDateString(releaseDate.GetString()) : null, - SongCount = album.TryGetProperty("nb_tracks", out var nbTracks) - ? nbTracks.GetInt32() + SongCount = album.TryGetProperty("nb_tracks", out var nbTracks) + ? nbTracks.GetInt32() : null, CoverArtUrl = album.TryGetProperty("cover_medium", out var cover) ? cover.GetString() : null, - Genre = album.TryGetProperty("genres", out var genres) && + Genre = album.TryGetProperty("genres", out var genres) && genres.TryGetProperty("data", out var genresData) && genresData.GetArrayLength() > 0 ? genresData[0].GetProperty("name").GetString() @@ -555,16 +547,16 @@ public class DeezerMetadataService : IMusicMetadataService private Artist ParseDeezerArtist(JsonElement artist) { var externalId = artist.GetProperty("id").GetInt64().ToString(); - + return new Artist { - Id = $"ext-deezer-artist-{externalId}", + Id = BuildExternalArtistId("deezer", externalId), Name = artist.GetProperty("name").GetString() ?? "", ImageUrl = artist.TryGetProperty("picture_medium", out var picture) ? picture.GetString() : null, - AlbumCount = artist.TryGetProperty("nb_album", out var nbAlbum) - ? nbAlbum.GetInt32() + AlbumCount = artist.TryGetProperty("nb_album", out var nbAlbum) + ? nbAlbum.GetInt32() : null, IsLocal = false, ExternalProvider = "deezer", @@ -572,18 +564,18 @@ public class DeezerMetadataService : IMusicMetadataService }; } - public async Task> SearchPlaylistsAsync(string query, int limit = 20) + public async Task> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { try { var url = $"{BaseUrl}/search/playlist?q={Uri.EscapeDataString(query)}&limit={limit}"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var playlists = new List(); if (result.RootElement.TryGetProperty("data", out var data)) { @@ -592,7 +584,7 @@ public class DeezerMetadataService : IMusicMetadataService playlists.Add(ParseDeezerPlaylist(playlist)); } } - + return playlists; } catch @@ -600,23 +592,23 @@ public class DeezerMetadataService : IMusicMetadataService return new List(); } } - - public async Task GetPlaylistAsync(string externalProvider, string externalId) + + public async Task GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "deezer") return null; - + try { var url = $"{BaseUrl}/playlist/{externalId}"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var playlistElement = JsonDocument.Parse(json).RootElement; - + if (playlistElement.TryGetProperty("error", out _)) return null; - + return ParseDeezerPlaylist(playlistElement); } catch @@ -624,30 +616,30 @@ public class DeezerMetadataService : IMusicMetadataService return null; } } - - public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId) + + public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "deezer") return new List(); - + try { var url = $"{BaseUrl}/playlist/{externalId}"; - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var playlistElement = JsonDocument.Parse(json).RootElement; - + if (playlistElement.TryGetProperty("error", out _)) return new List(); - + var songs = new List(); - + // Get playlist name for album field var playlistName = playlistElement.TryGetProperty("title", out var titleEl) ? titleEl.GetString() ?? "Unknown Playlist" : "Unknown Playlist"; - + if (playlistElement.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("data", out var tracksData)) { @@ -656,14 +648,14 @@ public class DeezerMetadataService : IMusicMetadataService { // For playlists, use the track's own artist (not a single album artist) var song = ParseDeezerTrack(track, trackIndex); - + // Override album name to be the playlist name song.Album = playlistName; - + // Playlists should not have disc numbers - always set to null // This prevents Jellyfin from splitting the playlist into multiple "discs" song.DiscNumber = null; - + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) { songs.Add(song); @@ -671,7 +663,7 @@ public class DeezerMetadataService : IMusicMetadataService trackIndex++; } } - + return songs; } catch @@ -683,7 +675,7 @@ public class DeezerMetadataService : IMusicMetadataService private ExternalPlaylist ParseDeezerPlaylist(JsonElement playlist) { var externalId = playlist.GetProperty("id").GetInt64().ToString(); - + // Get curator/creator name string? curatorName = null; if (playlist.TryGetProperty("user", out var user) && @@ -696,7 +688,7 @@ public class DeezerMetadataService : IMusicMetadataService { curatorName = creatorName.GetString(); } - + // Get creation date DateTime? createdDate = null; if (playlist.TryGetProperty("creation_date", out var creationDateEl)) @@ -707,27 +699,27 @@ public class DeezerMetadataService : IMusicMetadataService createdDate = date; } } - + return new ExternalPlaylist { Id = Common.PlaylistIdHelper.CreatePlaylistId("deezer", externalId), Name = playlist.GetProperty("title").GetString() ?? "", - Description = playlist.TryGetProperty("description", out var desc) - ? desc.GetString() + Description = playlist.TryGetProperty("description", out var desc) + ? desc.GetString() : null, CuratorName = curatorName, Provider = "deezer", ExternalId = externalId, - TrackCount = playlist.TryGetProperty("nb_tracks", out var nbTracks) - ? nbTracks.GetInt32() + TrackCount = playlist.TryGetProperty("nb_tracks", out var nbTracks) + ? nbTracks.GetInt32() : 0, - Duration = playlist.TryGetProperty("duration", out var duration) - ? duration.GetInt32() + Duration = playlist.TryGetProperty("duration", out var duration) + ? duration.GetInt32() : 0, - CoverUrl = playlist.TryGetProperty("picture_medium", out var picture) - ? picture.GetString() - : (playlist.TryGetProperty("picture_big", out var pictureBig) - ? pictureBig.GetString() + CoverUrl = playlist.TryGetProperty("picture_medium", out var picture) + ? picture.GetString() + : (playlist.TryGetProperty("picture_big", out var pictureBig) + ? pictureBig.GetString() : null), CreatedDate = createdDate }; diff --git a/allstarr/Services/IMusicMetadataService.cs b/allstarr/Services/IMusicMetadataService.cs index 25db101..b631381 100644 --- a/allstarr/Services/IMusicMetadataService.cs +++ b/allstarr/Services/IMusicMetadataService.cs @@ -17,70 +17,74 @@ public interface IMusicMetadataService /// /// Search term /// Maximum number of results + /// Cancellation token /// List of found songs - Task> SearchSongsAsync(string query, int limit = 20); + Task> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default); /// /// Searches for albums on external providers /// - Task> SearchAlbumsAsync(string query, int limit = 20); + Task> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default); /// /// Searches for artists on external providers /// - Task> SearchArtistsAsync(string query, int limit = 20); + Task> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default); /// /// Combined search (songs, albums, artists) /// - Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20); + Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default); /// /// Gets details of an external song /// - Task GetSongAsync(string externalProvider, string externalId); + Task GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); /// /// Gets details of an external album with its songs /// - Task GetAlbumAsync(string externalProvider, string externalId); + Task GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); /// /// Gets details of an external artist /// - Task GetArtistAsync(string externalProvider, string externalId); + Task GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); /// /// Gets an artist's albums /// - Task> GetArtistAlbumsAsync(string externalProvider, string externalId); + Task> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); /// /// Gets an artist's top tracks (not all songs, just popular tracks from the artist endpoint) /// - Task> GetArtistTracksAsync(string externalProvider, string externalId); + Task> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); /// /// Searches for playlists on external providers /// /// Search term /// Maximum number of results + /// Cancellation token /// List of found playlists - Task> SearchPlaylistsAsync(string query, int limit = 20); + Task> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default); /// /// Gets details of an external playlist (metadata only, not tracks) /// /// Provider name (e.g., "deezer", "qobuz") /// Playlist ID from the provider + /// Cancellation token /// Playlist details or null if not found - Task GetPlaylistAsync(string externalProvider, string externalId); + Task GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); /// /// Gets all tracks from an external playlist /// /// Provider name (e.g., "deezer", "qobuz") /// Playlist ID from the provider + /// Cancellation token /// List of songs in the playlist - Task> GetPlaylistTracksAsync(string externalProvider, string externalId); + Task> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default); } diff --git a/allstarr/Services/Jellyfin/JellyfinProxyService.cs b/allstarr/Services/Jellyfin/JellyfinProxyService.cs index ba08955..fc11082 100644 --- a/allstarr/Services/Jellyfin/JellyfinProxyService.cs +++ b/allstarr/Services/Jellyfin/JellyfinProxyService.cs @@ -113,7 +113,7 @@ public class JellyfinProxyService var parts = endpoint.Split('?', 2); var baseEndpoint = parts[0]; var existingQuery = parts[1]; - + // Parse existing query string var mergedParams = new Dictionary(); foreach (var param in existingQuery.Split('&')) @@ -124,7 +124,7 @@ public class JellyfinProxyService mergedParams[Uri.UnescapeDataString(kv[0])] = Uri.UnescapeDataString(kv[1]); } } - + // Merge with provided queryParams (provided params take precedence) if (queryParams != null) { @@ -133,19 +133,19 @@ public class JellyfinProxyService mergedParams[kv.Key] = kv.Value; } } - + var url = BuildUrl(baseEndpoint, mergedParams); return await GetJsonAsyncInternal(url, clientHeaders); } - + var finalUrl = BuildUrl(endpoint, queryParams); return await GetJsonAsyncInternal(finalUrl, clientHeaders); } - + private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders) { using var request = new HttpRequestMessage(HttpMethod.Get, url); - + // Forward client IP address to Jellyfin so it can identify the real client if (_httpContextAccessor.HttpContext != null) { @@ -156,33 +156,33 @@ public class JellyfinProxyService request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp); } } - + bool authHeaderAdded = false; - + // Check if this is a browser request for static assets (favicon, etc.) bool isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) || url.Contains("/web/", StringComparison.OrdinalIgnoreCase) || - (clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) && + (clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) && h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true && - clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) && + clientHeaders?.Any(h => h.Key.Equals("sec-fetch-dest", StringComparison.OrdinalIgnoreCase) && (h.Value.ToString().Contains("image", StringComparison.OrdinalIgnoreCase) || h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true); - + // Check if this is a public endpoint that doesn't require authentication bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) || url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) || url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase); - + // Forward authentication headers from client if provided if (clientHeaders != null && clientHeaders.Count > 0) { authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request); - + if (authHeaderAdded) { _logger.LogTrace("Forwarded authentication headers"); } - + // Check for api_key query parameter (some clients use this) if (!authHeaderAdded && url.Contains("api_key=", StringComparison.OrdinalIgnoreCase)) { @@ -190,23 +190,23 @@ public class JellyfinProxyService _logger.LogTrace("Using api_key from query string"); } } - + // Only log warnings for non-public, non-browser requests without auth if (!authHeaderAdded && !isBrowserStaticRequest && !isPublicEndpoint) { _logger.LogDebug("No client auth provided for {Url} - Jellyfin will handle authentication", url); } - + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var response = await _httpClient.SendAsync(request); - + var statusCode = (int)response.StatusCode; - + // Always parse the response, even for errors // The caller needs to see 401s so the client can re-authenticate var content = await response.Content.ReadAsStringAsync(); - + if (!response.IsSuccessStatusCode) { if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) @@ -218,7 +218,7 @@ public class JellyfinProxyService { _logger.LogError("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url); } - + // Try to parse error response to pass through to client if (!string.IsNullOrWhiteSpace(content)) { @@ -232,7 +232,7 @@ public class JellyfinProxyService // Not valid JSON, return null } } - + return (null, statusCode); } @@ -247,9 +247,9 @@ public class JellyfinProxyService public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders) { var url = BuildUrl(endpoint, null); - + using var request = new HttpRequestMessage(HttpMethod.Post, url); - + // Forward client IP address to Jellyfin so it can identify the real client if (_httpContextAccessor.HttpContext != null) { @@ -260,9 +260,9 @@ public class JellyfinProxyService request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp); } } - + // Handle special case for playback endpoints - // NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo + // NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo // DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body. var bodyToSend = body; if (string.IsNullOrWhiteSpace(body)) @@ -270,27 +270,27 @@ public class JellyfinProxyService bodyToSend = "{}"; _logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url); } - + request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json"); - + bool authHeaderAdded = false; bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase); - + // Forward authentication headers from client authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request); - + if (authHeaderAdded) { _logger.LogTrace("Forwarded authentication headers"); } - + // For authentication endpoints, credentials are in the body, not headers // For other endpoints without auth, let Jellyfin reject the request if (!authHeaderAdded && !isAuthEndpoint) { _logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url); } - + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); // DO NOT log the body for auth endpoints - it contains passwords! @@ -302,15 +302,15 @@ public class JellyfinProxyService { _logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length); } - + var response = await _httpClient.SendAsync(request); - + var statusCode = (int)response.StatusCode; - + if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(); - + // 401 is expected when tokens expire - don't spam logs if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized) { @@ -318,10 +318,10 @@ public class JellyfinProxyService } else { - _logger.LogError("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}", + _logger.LogError("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}", response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent); } - + // Try to parse error response as JSON to pass through to client if (!string.IsNullOrWhiteSpace(errorContent)) { @@ -335,7 +335,7 @@ public class JellyfinProxyService // Not valid JSON, return null } } - + return (null, statusCode); } @@ -352,13 +352,13 @@ public class JellyfinProxyService } var responseContent = await response.Content.ReadAsStringAsync(); - + // Handle empty responses if (string.IsNullOrWhiteSpace(responseContent)) { return (null, statusCode); } - + return (JsonDocument.Parse(responseContent), statusCode); } @@ -369,7 +369,7 @@ public class JellyfinProxyService public async Task<(byte[] Body, string? ContentType)> GetBytesAsync(string endpoint, Dictionary? queryParams = null) { var url = BuildUrl(endpoint, queryParams); - + using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Add("Authorization", GetAuthorizationHeader()); @@ -394,7 +394,7 @@ public class JellyfinProxyService public async Task<(Stream Stream, string? ContentType, long? ContentLength)> GetStreamAsync(string endpoint, Dictionary? queryParams = null) { var url = BuildUrl(endpoint, queryParams); - + using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Add("Authorization", GetAuthorizationHeader()); @@ -416,9 +416,9 @@ public class JellyfinProxyService public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders) { var url = BuildUrl(endpoint, null); - + using var request = new HttpRequestMessage(HttpMethod.Delete, url); - + // Forward client IP address to Jellyfin so it can identify the real client if (_httpContextAccessor.HttpContext != null) { @@ -429,12 +429,12 @@ public class JellyfinProxyService request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp); } } - + bool authHeaderAdded = false; - + // Forward authentication headers from client authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request); - + if (!authHeaderAdded) { _logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url); @@ -443,19 +443,19 @@ public class JellyfinProxyService { _logger.LogTrace("Forwarded authentication headers"); } - + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - + _logger.LogDebug("DELETE to Jellyfin: {Url}", url); - + var response = await _httpClient.SendAsync(request); - + var statusCode = (int)response.StatusCode; - + if (!response.IsSuccessStatusCode) { var errorContent = await response.Content.ReadAsStringAsync(); - _logger.LogError("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}", + _logger.LogError("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}", response.StatusCode, url, errorContent); return (null, statusCode); } @@ -467,13 +467,13 @@ public class JellyfinProxyService } var responseContent = await response.Content.ReadAsStringAsync(); - + // Handle empty responses if (string.IsNullOrWhiteSpace(responseContent)) { return (null, statusCode); } - + return (JsonDocument.Parse(responseContent), statusCode); } @@ -481,7 +481,7 @@ public class JellyfinProxyService /// Safely sends a GET request to the Jellyfin server, returning null on failure. /// public async Task<(byte[]? Body, string? ContentType, bool Success)> GetBytesSafeAsync( - string endpoint, + string endpoint, Dictionary? queryParams = null) { try @@ -535,7 +535,23 @@ public class JellyfinProxyService queryParams["includeItemTypes"] = string.Join(",", includeItemTypes); } - return await GetJsonAsync("Items", queryParams, clientHeaders); + var (body, statusCode) = await GetJsonAsync("Items", queryParams, clientHeaders); + + var count = 0; + if (body != null && body.RootElement.TryGetProperty("Items", out var itemsEl) && itemsEl.ValueKind == JsonValueKind.Array) + { + count = itemsEl.GetArrayLength(); + } + + _logger.LogInformation( + "SEARCH TRACE: JellyfinProxy.SearchAsync query='{Query}', includeItemTypes='{ItemTypes}', limit={Limit}, status={StatusCode}, returnedItems={ItemCount}", + searchTerm, + includeItemTypes == null ? "" : string.Join(",", includeItemTypes), + limit, + statusCode, + count); + + return (body, statusCode); } /// @@ -600,7 +616,7 @@ public class JellyfinProxyService public async Task<(JsonDocument? Body, int StatusCode)> GetItemAsync(string itemId, IHeaderDictionary? clientHeaders = null) { var queryParams = new Dictionary(); - + if (!string.IsNullOrEmpty(_settings.UserId)) { queryParams["userId"] = _settings.UserId; @@ -652,7 +668,7 @@ public class JellyfinProxyService public async Task<(JsonDocument? Body, int StatusCode)> GetArtistAsync(string artistIdOrName, IHeaderDictionary? clientHeaders = null) { var queryParams = new Dictionary(); - + if (!string.IsNullOrEmpty(_settings.UserId)) { queryParams["userId"] = _settings.UserId; @@ -747,7 +763,7 @@ public class JellyfinProxyService catch (Exception ex) { _logger.LogError(ex, "Error streaming from Jellyfin item {ItemId}", itemId); - return new ObjectResult(new { error = $"Error streaming: {ex.Message}" }) + return new ObjectResult(new { error = "Error streaming" }) { StatusCode = 500 }; @@ -761,11 +777,12 @@ public class JellyfinProxyService string itemId, string imageType = "Primary", int? maxWidth = null, - int? maxHeight = null) + int? maxHeight = null, + string? imageTag = null) { // Build cache key - var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}"; - + var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}:{imageTag}"; + // Try cache first var cached = await _cache.GetStringAsync(cacheKey); if (!string.IsNullOrEmpty(cached)) @@ -791,15 +808,21 @@ public class JellyfinProxyService queryParams["maxHeight"] = maxHeight.Value.ToString(); } + // Jellyfin uses `tag` for image cache busting when artwork changes. + if (!string.IsNullOrWhiteSpace(imageTag)) + { + queryParams["tag"] = imageTag; + } + var result = await GetBytesSafeAsync($"Items/{itemId}/Images/{imageType}", queryParams); - + // Cache for 7 days if successful if (result.Success && result.Body != null) { var cacheValue = $"{Convert.ToBase64String(result.Body)}|{result.ContentType}"; await _cache.SetStringAsync(cacheKey, cacheValue, CacheExtensions.ProxyImagesTTL); } - + return (result.Body, result.ContentType); } @@ -816,11 +839,11 @@ public class JellyfinProxyService return (false, null, null); } - var serverName = result.RootElement.TryGetProperty("ServerName", out var name) - ? name.GetString() + var serverName = result.RootElement.TryGetProperty("ServerName", out var name) + ? name.GetString() : null; - var version = result.RootElement.TryGetProperty("Version", out var ver) - ? ver.GetString() + var version = result.RootElement.TryGetProperty("Version", out var ver) + ? ver.GetString() : null; return (true, serverName, version); @@ -855,14 +878,14 @@ public class JellyfinProxyService { foreach (var item in items.EnumerateArray()) { - var collectionType = item.TryGetProperty("CollectionType", out var ct) - ? ct.GetString() + var collectionType = item.TryGetProperty("CollectionType", out var ct) + ? ct.GetString() : null; - + if (collectionType == "music") { - return item.TryGetProperty("Id", out var id) - ? id.GetString() + return item.TryGetProperty("Id", out var id) + ? id.GetString() : null; } } @@ -884,7 +907,7 @@ public class JellyfinProxyService if (queryParams != null && queryParams.Count > 0) { - var query = string.Join("&", queryParams.Select(kv => + var query = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); url = $"{url}?{query}"; } @@ -899,22 +922,22 @@ public class JellyfinProxyService public async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string endpoint, Dictionary? queryParams = null) { var url = BuildUrl(endpoint, queryParams); - + using var request = new HttpRequestMessage(HttpMethod.Get, url); - + // Use server's API key for authentication var authHeader = GetAuthorizationHeader(); request.Headers.TryAddWithoutValidation("X-Emby-Authorization", authHeader); - + request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); var response = await _httpClient.SendAsync(request); var statusCode = (int)response.StatusCode; var content = await response.Content.ReadAsStringAsync(); - + if (!response.IsSuccessStatusCode) { - _logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}", + _logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}", statusCode, url, content); return (null, statusCode); } diff --git a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs index ed73d49..5fb4e12 100644 --- a/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs +++ b/allstarr/Services/Jellyfin/JellyfinResponseBuilder.cs @@ -16,7 +16,7 @@ public class JellyfinResponseBuilder public IActionResult CreateItemsResponse(List songs) { var items = songs.Select(ConvertSongToJellyfinItem).ToList(); - + return CreateJsonResponse(new { Items = items, @@ -31,7 +31,7 @@ public class JellyfinResponseBuilder public IActionResult CreateAlbumsResponse(List albums) { var items = albums.Select(ConvertAlbumToJellyfinItem).ToList(); - + return CreateJsonResponse(new { Items = items, @@ -46,7 +46,7 @@ public class JellyfinResponseBuilder public IActionResult CreateArtistsResponse(List artists) { var items = artists.Select(ConvertArtistToJellyfinItem).ToList(); - + return CreateJsonResponse(new { Items = items, @@ -69,13 +69,13 @@ public class JellyfinResponseBuilder public IActionResult CreateAlbumResponse(Album album) { var albumItem = ConvertAlbumToJellyfinItem(album); - + // For album detail, include child items (songs) if (album.Songs.Count > 0) { albumItem["Children"] = album.Songs.Select(ConvertSongToJellyfinItem).ToList(); } - + return CreateJsonResponse(albumItem); } @@ -86,7 +86,7 @@ public class JellyfinResponseBuilder { var artistItem = ConvertArtistToJellyfinItem(artist); artistItem["Albums"] = albums.Select(ConvertAlbumToJellyfinItem).ToList(); - + return CreateJsonResponse(artistItem); } @@ -97,8 +97,8 @@ public class JellyfinResponseBuilder { var totalDuration = tracks.Sum(s => s.Duration ?? 0); - var curatorName = !string.IsNullOrEmpty(playlist.CuratorName) - ? playlist.CuratorName + var curatorName = !string.IsNullOrEmpty(playlist.CuratorName) + ? playlist.CuratorName : playlist.Provider; // Create artist items for the curator @@ -118,13 +118,13 @@ public class JellyfinResponseBuilder .Select(s => s.Genre!) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); - + // If no genres found, fallback to "Playlist" if (genres.Count == 0) { genres.Add("Playlist"); } - + var genreItems = genres.Select(g => new Dictionary { ["Name"] = g, @@ -202,7 +202,7 @@ public class JellyfinResponseBuilder List artists) { var searchHints = new List>(); - + // Add artists first foreach (var artist in artists) { @@ -219,7 +219,7 @@ public class JellyfinResponseBuilder } }); } - + // Add albums foreach (var album in albums) { @@ -238,7 +238,7 @@ public class JellyfinResponseBuilder } }); } - + // Add songs foreach (var song in songs) { @@ -257,7 +257,7 @@ public class JellyfinResponseBuilder } }); } - + return CreateJsonResponse(new { SearchHints = searchHints, @@ -299,28 +299,28 @@ public class JellyfinResponseBuilder var artistName = song.Artist; var albumName = song.Album; var artistNames = song.Artists.ToList(); - + if (!song.IsLocal) { songTitle = $"{song.Title} [S]"; - + // Also add [S] to artist and album names for consistency if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]")) { artistName = $"{artistName} [S]"; } - + if (!string.IsNullOrEmpty(albumName) && !albumName.EndsWith(" [S]")) { albumName = $"{albumName} [S]"; } - + // Add [S] to all artist names in the list - artistNames = artistNames.Select(a => + artistNames = artistNames.Select(a => !string.IsNullOrEmpty(a) && !a.EndsWith(" [S]") ? $"{a} [S]" : a ).ToList(); } - + var item = new Dictionary { ["Name"] = songTitle, @@ -339,8 +339,8 @@ public class JellyfinResponseBuilder ["Type"] = "Audio", ["ChannelId"] = (object?)null, ["ParentId"] = song.AlbumId, - ["Genres"] = !string.IsNullOrEmpty(song.Genre) - ? new[] { song.Genre } + ["Genres"] = !string.IsNullOrEmpty(song.Genre) + ? new[] { song.Genre } : new string[0], ["GenreItems"] = !string.IsNullOrEmpty(song.Genre) ? new[] @@ -412,17 +412,19 @@ public class JellyfinResponseBuilder // Add provider IDs for external content if (!song.IsLocal && !string.IsNullOrEmpty(song.ExternalProvider)) { + var supportsTranscoding = !ShouldDisableTranscoding(song.ExternalProvider); + item["ProviderIds"] = new Dictionary { [song.ExternalProvider] = song.ExternalId ?? "" }; - + if (!string.IsNullOrEmpty(song.Isrc)) { var providerIds = (Dictionary)item["ProviderIds"]!; providerIds["ISRC"] = song.Isrc; } - + // Add MediaSources with complete structure matching real Jellyfin item["MediaSources"] = new[] { @@ -442,7 +444,7 @@ public class JellyfinResponseBuilder ["IgnoreDts"] = false, ["IgnoreIndex"] = false, ["GenPtsInput"] = false, - ["SupportsTranscoding"] = true, + ["SupportsTranscoding"] = supportsTranscoding, ["SupportsDirectStream"] = true, ["SupportsDirectPlay"] = true, ["IsInfiniteStream"] = false, @@ -500,6 +502,13 @@ public class JellyfinResponseBuilder return item; } + private static bool ShouldDisableTranscoding(string provider) + { + return provider.Equals("deezer", StringComparison.OrdinalIgnoreCase) || + provider.Equals("qobuz", StringComparison.OrdinalIgnoreCase) || + provider.Equals("squidwtf", StringComparison.OrdinalIgnoreCase); + } + /// /// Converts an Album domain model to a Jellyfin item. /// @@ -511,7 +520,7 @@ public class JellyfinResponseBuilder { albumName = $"{album.Title} [S]"; } - + var item = new Dictionary { ["Name"] = albumName, @@ -519,8 +528,8 @@ public class JellyfinResponseBuilder ["Id"] = album.Id, ["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null, ["ChannelId"] = (object?)null, - ["Genres"] = !string.IsNullOrEmpty(album.Genre) - ? new[] { album.Genre } + ["Genres"] = !string.IsNullOrEmpty(album.Genre) + ? new[] { album.Genre } : new string[0], ["RunTimeTicks"] = 0, // Could calculate from songs ["ProductionYear"] = album.Year, @@ -601,7 +610,7 @@ public class JellyfinResponseBuilder { artistName = $"{artist.Name} [S]"; } - + var item = new Dictionary { ["Name"] = artistName, @@ -671,10 +680,10 @@ public class JellyfinResponseBuilder /// public Dictionary ConvertPlaylistToJellyfinItem(ExternalPlaylist playlist) { - var curatorName = !string.IsNullOrEmpty(playlist.CuratorName) - ? playlist.CuratorName + var curatorName = !string.IsNullOrEmpty(playlist.CuratorName) + ? playlist.CuratorName : playlist.Provider; - + var item = new Dictionary { ["Name"] = playlist.Name, @@ -720,10 +729,10 @@ public class JellyfinResponseBuilder } public Dictionary ConvertPlaylistToAlbumItem(ExternalPlaylist playlist) { - var curatorName = !string.IsNullOrEmpty(playlist.CuratorName) - ? playlist.CuratorName + var curatorName = !string.IsNullOrEmpty(playlist.CuratorName) + ? playlist.CuratorName : playlist.Provider; - + var item = new Dictionary { ["Name"] = $"{playlist.Name} [S/P]", @@ -776,13 +785,13 @@ public class JellyfinResponseBuilder [playlist.Provider] = playlist.ExternalId } }; - + if (playlist.CreatedDate.HasValue) { item["PremiereDate"] = playlist.CreatedDate.Value.ToString("o"); item["ProductionYear"] = playlist.CreatedDate.Value.Year; } - + return item; } } diff --git a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs index 93a559f..4a704bc 100644 --- a/allstarr/Services/Jellyfin/JellyfinSessionManager.cs +++ b/allstarr/Services/Jellyfin/JellyfinSessionManager.cs @@ -19,6 +19,7 @@ public class JellyfinSessionManager : IDisposable private readonly JellyfinSettings _settings; private readonly ILogger _logger; private readonly ConcurrentDictionary _sessions = new(); + private readonly ConcurrentDictionary _sessionInitLocks = new(); private readonly Timer _keepAliveTimer; public JellyfinSessionManager( @@ -32,7 +33,7 @@ public class JellyfinSessionManager : IDisposable // Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity) _keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10)); - + _logger.LogInformation("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support"); } @@ -48,34 +49,35 @@ public class JellyfinSessionManager : IDisposable return false; } - // Check if we already have this session tracked - if (_sessions.TryGetValue(deviceId, out var existingSession)) - { - existingSession.LastActivity = DateTime.UtcNow; - _logger.LogInformation("Session already exists for device {DeviceId}", deviceId); - - // Refresh capabilities to keep session alive - // If this returns false (401), the token expired and client needs to re-auth - var success = await PostCapabilitiesAsync(headers); - if (!success) - { - // Token expired - remove the stale session - _logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId); - await RemoveSessionAsync(deviceId); - return false; - } - - return true; - } - - _logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device); - + var initLock = _sessionInitLocks.GetOrAdd(deviceId, _ => new SemaphoreSlim(1, 1)); + await initLock.WaitAsync(); try { + // Check if we already have this session tracked + if (_sessions.TryGetValue(deviceId, out var existingSession)) + { + existingSession.LastActivity = DateTime.UtcNow; + _logger.LogInformation("Session already exists for device {DeviceId}", deviceId); + + // Refresh capabilities to keep session alive + // If this returns false (401), the token expired and client needs to re-auth + var refreshOk = await PostCapabilitiesAsync(headers); + if (!refreshOk) + { + // Token expired - remove the stale session + _logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId); + await RemoveSessionAsync(deviceId); + return false; + } + + return true; + } + + _logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device); + // Post session capabilities to Jellyfin - this creates the session - var success = await PostCapabilitiesAsync(headers); - - if (!success) + var createOk = await PostCapabilitiesAsync(headers); + if (!createOk) { // Token expired or invalid - client needs to re-authenticate _logger.LogError("Failed to create session for {DeviceId} - token may be expired", deviceId); @@ -85,10 +87,10 @@ public class JellyfinSessionManager : IDisposable _logger.LogInformation("Session created for {DeviceId}", deviceId); // Track this session - var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim() - ?? headers["X-Real-IP"].FirstOrDefault() + var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim() + ?? headers["X-Real-IP"].FirstOrDefault() ?? "Unknown"; - + _sessions[deviceId] = new SessionInfo { DeviceId = deviceId, @@ -110,6 +112,10 @@ public class JellyfinSessionManager : IDisposable _logger.LogError(ex, "Error creating session for {DeviceId}", deviceId); return false; } + finally + { + initLock.Release(); + } } /// @@ -168,7 +174,7 @@ public class JellyfinSessionManager : IDisposable _logger.LogError("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId); } } - + /// /// Updates the currently playing item for a session (for scrobbling on cleanup). /// @@ -179,11 +185,117 @@ public class JellyfinSessionManager : IDisposable session.LastPlayingItemId = itemId; session.LastPlayingPositionTicks = positionTicks; session.LastActivity = DateTime.UtcNow; - _logger.LogDebug("🎵 SESSION: Updated playing item for {DeviceId}: {ItemId} at {Position}", + _logger.LogDebug("🎵 SESSION: Updated playing item for {DeviceId}: {ItemId} at {Position}", deviceId, itemId, positionTicks); } } + /// + /// Marks that an explicit playback stop was received for this device+item. + /// Used to suppress duplicate inferred stop forwarding from progress transitions. + /// + public void MarkExplicitStop(string deviceId, string itemId) + { + if (_sessions.TryGetValue(deviceId, out var session)) + { + lock (session.SyncRoot) + { + session.LastExplicitStopItemId = itemId; + session.LastExplicitStopAtUtc = DateTime.UtcNow; + } + } + } + + /// + /// Returns true when an explicit stop for this device+item was recorded within the given time window. + /// + public bool WasRecentlyExplicitlyStopped(string deviceId, string itemId, TimeSpan within) + { + if (_sessions.TryGetValue(deviceId, out var session)) + { + lock (session.SyncRoot) + { + if (!string.Equals(session.LastExplicitStopItemId, itemId, StringComparison.Ordinal)) + { + return false; + } + + if (!session.LastExplicitStopAtUtc.HasValue) + { + return false; + } + + return (DateTime.UtcNow - session.LastExplicitStopAtUtc.Value) <= within; + } + } + + return false; + } + + /// + /// Returns true if a local played-signal was already sent for this device+item. + /// + public bool HasSentLocalPlayedSignal(string deviceId, string itemId) + { + if (_sessions.TryGetValue(deviceId, out var session)) + { + lock (session.SyncRoot) + { + return string.Equals(session.LastLocalPlayedSignalItemId, itemId, StringComparison.Ordinal); + } + } + + return false; + } + + /// + /// Marks that a local played-signal was sent for this device+item. + /// + public void MarkLocalPlayedSignalSent(string deviceId, string itemId) + { + if (_sessions.TryGetValue(deviceId, out var session)) + { + lock (session.SyncRoot) + { + session.LastLocalPlayedSignalItemId = itemId; + } + } + } + + /// + /// Returns true when a tracked session exists for this device. + /// + public bool HasSession(string deviceId) + { + return !string.IsNullOrWhiteSpace(deviceId) && _sessions.ContainsKey(deviceId); + } + + /// + /// Gets the last playing item id for a tracked session, if present. + /// + public string? GetLastPlayingItemId(string deviceId) + { + if (_sessions.TryGetValue(deviceId, out var session)) + { + return session.LastPlayingItemId; + } + + return null; + } + + /// + /// Gets last tracked playing item and position for a device, if present. + /// + public (string? ItemId, long? PositionTicks) GetLastPlayingState(string deviceId) + { + if (_sessions.TryGetValue(deviceId, out var session)) + { + return (session.LastPlayingItemId, session.LastPlayingPositionTicks); + } + + return (null, null); + } + /// /// Marks a session as potentially ended (e.g., after playback stops). /// The session will be cleaned up if no new activity occurs within the timeout. @@ -192,16 +304,16 @@ public class JellyfinSessionManager : IDisposable { if (_sessions.TryGetValue(deviceId, out var session)) { - _logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity", + _logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity", deviceId, timeout.TotalSeconds); - + _ = Task.Run(async () => { var markedTime = DateTime.UtcNow; await Task.Delay(timeout); - + // Check if there's been activity since we marked it - if (_sessions.TryGetValue(deviceId, out var currentSession) && + if (_sessions.TryGetValue(deviceId, out var currentSession) && currentSession.LastActivity <= markedTime) { _logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId); @@ -282,10 +394,10 @@ public class JellyfinSessionManager : IDisposable }; var stopJson = JsonSerializer.Serialize(stopPayload); await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers); - _logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})", + _logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})", deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks); } - + // Notify Jellyfin that the session is ending await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers); } @@ -327,9 +439,9 @@ public class JellyfinSessionManager : IDisposable // Use stored session headers instead of parameter (parameter might be disposed) var sessionHeaders = session.Headers; - + // Log available headers for debugging - _logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}", + _logger.LogDebug("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}", deviceId, string.Join(", ", sessionHeaders.Keys)); // Forward authentication headers from the CLIENT - this is critical for session to appear under the right user @@ -337,8 +449,7 @@ public class JellyfinSessionManager : IDisposable if (sessionHeaders.TryGetValue("X-Emby-Authorization", out var embyAuth)) { webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString()); - _logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}", - deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString()); + _logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}", deviceId); authFound = true; } else if (sessionHeaders.TryGetValue("Authorization", out var auth)) @@ -347,19 +458,18 @@ public class JellyfinSessionManager : IDisposable if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase)) { webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue); - _logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}: {Auth}", - deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue); + _logger.LogDebug("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}", + deviceId); authFound = true; } else { webSocket.Options.SetRequestHeader("Authorization", authValue); - _logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}: {Auth}", - deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue); + _logger.LogDebug("🔑 WEBSOCKET: Using Authorization for {DeviceId}", deviceId); authFound = true; } } - + if (!authFound) { // No client auth found - fall back to server API key as last resort @@ -374,7 +484,8 @@ public class JellyfinSessionManager : IDisposable } } - _logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl); + _logger.LogDebug("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, + jellyfinWsUrl.Split('?')[0]); // Set user agent webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}"); @@ -401,7 +512,7 @@ public class JellyfinSessionManager : IDisposable var buffer = new byte[1024 * 4]; var lastKeepAlive = DateTime.UtcNow; using var cts = new CancellationTokenSource(); - + while (webSocket.State == WebSocketState.Open && _sessions.ContainsKey(deviceId)) { try @@ -409,7 +520,7 @@ public class JellyfinSessionManager : IDisposable // Use a timeout so we can send keep-alive messages periodically using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token); timeoutCts.CancelAfter(TimeSpan.FromSeconds(30)); - + try { var result = await webSocket.ReceiveAsync(new ArraySegment(buffer), timeoutCts.Token); @@ -424,7 +535,7 @@ public class JellyfinSessionManager : IDisposable if (result.MessageType == WebSocketMessageType.Text) { var message = Encoding.UTF8.GetString(buffer, 0, result.Count); - + // Respond to KeepAlive requests from Jellyfin if (message.Contains("\"MessageType\":\"KeepAlive\"")) { @@ -438,7 +549,7 @@ public class JellyfinSessionManager : IDisposable else { // Log other message types at trace level - _logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}", + _logger.LogTrace("📥 WEBSOCKET: {DeviceId}: {Message}", deviceId, message.Length > 100 ? message[..100] + "..." : message); } } @@ -447,7 +558,7 @@ public class JellyfinSessionManager : IDisposable { // Timeout - this is expected, send keep-alive if needed } - + // Send periodic keep-alive every 30 seconds if (DateTime.UtcNow - lastKeepAlive > TimeSpan.FromSeconds(30)) { @@ -519,7 +630,7 @@ public class JellyfinSessionManager : IDisposable // Post capabilities again to keep session alive // If this returns false (401), the token has expired var success = await PostCapabilitiesAsync(session.Headers); - + if (!success) { _logger.LogWarning("Token expired for device {DeviceId} during keep-alive - marking for removal", session.DeviceId); @@ -544,7 +655,7 @@ public class JellyfinSessionManager : IDisposable var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(3)).ToList(); foreach (var stale in staleSessions) { - _logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)", + _logger.LogDebug("Removing stale session for {DeviceId} (inactive for {Minutes:F1} minutes)", stale.Key, (now - stale.Value.LastActivity).TotalMinutes); await RemoveSessionAsync(stale.Key); } @@ -562,6 +673,7 @@ public class JellyfinSessionManager : IDisposable private class SessionInfo { + public object SyncRoot { get; } = new(); public required string DeviceId { get; init; } public required string Client { get; init; } public required string Device { get; init; } @@ -572,12 +684,20 @@ public class JellyfinSessionManager : IDisposable public string? LastPlayingItemId { get; set; } public long? LastPlayingPositionTicks { get; set; } public string? ClientIp { get; set; } + public string? LastLocalPlayedSignalItemId { get; set; } + public string? LastExplicitStopItemId { get; set; } + public DateTime? LastExplicitStopAtUtc { get; set; } } public void Dispose() { _keepAliveTimer?.Dispose(); - + + foreach (var initLock in _sessionInitLocks.Values) + { + initLock.Dispose(); + } + // Close all WebSocket connections foreach (var session in _sessions.Values) { diff --git a/allstarr/Services/Lyrics/LrclibService.cs b/allstarr/Services/Lyrics/LrclibService.cs index fbb1338..8d4ae7b 100644 --- a/allstarr/Services/Lyrics/LrclibService.cs +++ b/allstarr/Services/Lyrics/LrclibService.cs @@ -33,23 +33,23 @@ public class LrclibService // Validate input parameters if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0) { - _logger.LogDebug("Invalid parameters for lyrics search: trackName={TrackName}, artistCount={ArtistCount}", + _logger.LogDebug("Invalid parameters for lyrics search: trackName={TrackName}, artistCount={ArtistCount}", trackName, artistNames?.Length ?? 0); return null; } - + var artistName = string.Join(", ", artistNames); - var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}"; - + var cacheKey = CacheKeyBuilder.BuildLyricsKey(artistName, trackName, albumName, durationSeconds); + // FIRST: Check for manual lyrics mapping - var manualMappingKey = $"lyrics:manual-map:{artistName}:{trackName}"; + var manualMappingKey = CacheKeyBuilder.BuildLyricsManualMappingKey(artistName, trackName); var manualLyricsIdStr = await _cache.GetStringAsync(manualMappingKey); - + if (!string.IsNullOrEmpty(manualLyricsIdStr) && int.TryParse(manualLyricsIdStr, out var manualLyricsId) && manualLyricsId > 0) { - _logger.LogInformation("✓ Manual lyrics mapping found for {Artist} - {Track}: Lyrics ID {Id}", + _logger.LogInformation("✓ Manual lyrics mapping found for {Artist} - {Track}: Lyrics ID {Id}", artistName, trackName, manualLyricsId); - + // Fetch lyrics by ID var manualLyrics = await GetLyricsByIdAsync(manualLyricsId); if (manualLyrics != null && !string.IsNullOrEmpty(manualLyrics.PlainLyrics)) @@ -60,11 +60,11 @@ public class LrclibService } else { - _logger.LogWarning("Manual lyrics mapping points to invalid ID {Id} for {Artist} - {Track}", + _logger.LogWarning("Manual lyrics mapping points to invalid ID {Id} for {Artist} - {Track}", manualLyricsId, artistName, trackName); } } - + // SECOND: Check standard cache var cached = await _cache.GetStringAsync(cacheKey); if (!string.IsNullOrEmpty(cached)) @@ -83,7 +83,7 @@ public class LrclibService { // Try searching with all artists joined (space-separated for better matching) var searchArtistName = string.Join(" ", artistNames); - + // First try search API for fuzzy matching (more forgiving) var searchUrl = $"{BaseUrl}/search?" + $"track_name={Uri.EscapeDataString(trackName)}&" + @@ -92,7 +92,7 @@ public class LrclibService _logger.LogDebug("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length); var searchResponse = await _httpClient.GetAsync(searchUrl); - + if (searchResponse.IsSuccessStatusCode) { var searchJson = await searchResponse.Content.ReadAsStringAsync(); @@ -108,27 +108,27 @@ public class LrclibService { // Calculate similarity scores var trackScore = CalculateSimilarity(trackName, result.TrackName ?? ""); - + // Count artists in the result var resultArtistCount = CountArtists(result.ArtistName ?? ""); var expectedArtistCount = artistNames.Length; - + // Artist matching - check if all our artists are present var artistScore = CalculateArtistSimilarity(artistNames, result.ArtistName ?? ""); - + // STRONG bonus for matching artist count (this is critical!) var artistCountBonus = resultArtistCount == expectedArtistCount ? 50.0 : 0.0; - + // Duration match (within 5 seconds is good) var durationDiff = result.Duration.HasValue ? Math.Abs(result.Duration.Value - durationSeconds) : 999; 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) ? 15.0 : 0.0; - + // Weighted score: track name important, artist match critical, artist count VERY important var totalScore = (trackScore * 0.3) + (artistScore * 0.3) + (durationScore * 0.15) + artistCountBonus + syncedBonus; - + _logger.LogDebug("Candidate: {Track} by {Artist} ({ArtistCount} artists) - Score: {Score:F1} (track:{TrackScore:F1}, artist:{ArtistScore:F1}, duration:{DurationScore:F1}, countBonus:{CountBonus:F1}, synced:{Synced})", result.TrackName, result.ArtistName, resultArtistCount, totalScore, trackScore, artistScore, durationScore, artistCountBonus, !string.IsNullOrEmpty(result.SyncedLyrics)); @@ -142,7 +142,7 @@ public class LrclibService // 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})", + _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 @@ -177,7 +177,7 @@ public class LrclibService _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); @@ -185,7 +185,7 @@ public class LrclibService } exactResponse.EnsureSuccessStatusCode(); - + var json = await exactResponse.Content.ReadAsStringAsync(); var lyrics = JsonSerializer.Deserialize(json, JsonOptions); @@ -209,7 +209,7 @@ public class LrclibService await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(exactResult, JsonOptions), CacheExtensions.LyricsTTL); _logger.LogInformation("Retrieved lyrics via exact match for {Artist} - {Track} (ID: {Id})", artistName, trackName, lyrics.Id); - + return exactResult; } catch (HttpRequestException ex) @@ -231,11 +231,11 @@ public class LrclibService { if (string.IsNullOrWhiteSpace(artistString)) return 0; - + // Split by common separators: comma, ampersand, " e " (Portuguese/Spanish "and") var separators = new[] { ',', '&' }; var parts = artistString.Split(separators, StringSplitOptions.RemoveEmptyEntries); - + // Also check for " e " pattern (like "Julia Michaels e Alessia Cara") var count = parts.Length; foreach (var part in parts) @@ -245,7 +245,7 @@ public class LrclibService count += part.Split(new[] { " e " }, StringSplitOptions.RemoveEmptyEntries).Length - 1; } } - + return Math.Max(1, count); } @@ -256,14 +256,14 @@ public class LrclibService { if (expectedArtists.Length == 0 || string.IsNullOrWhiteSpace(resultArtistString)) return 0; - + var resultLower = resultArtistString.ToLowerInvariant(); var matchedCount = 0; - + foreach (var artist in expectedArtists) { var artistLower = artist.ToLowerInvariant(); - + // Check if this artist appears in the result string if (resultLower.Contains(artistLower)) { @@ -274,7 +274,7 @@ public class LrclibService // Try token-based matching for partial matches var artistTokens = artistLower.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries); var matchedTokens = artistTokens.Count(token => resultLower.Contains(token)); - + // If most tokens match, count it as a partial match if (matchedTokens >= artistTokens.Length * 0.7) { @@ -282,7 +282,7 @@ public class LrclibService } } } - + // Return percentage of artists matched return (matchedCount * 100.0) / expectedArtists.Length; } @@ -320,14 +320,14 @@ public class LrclibService $"duration={durationSeconds}"; var response = await _httpClient.GetAsync(url); - + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } response.EnsureSuccessStatusCode(); - + var json = await response.Content.ReadAsStringAsync(); var lyrics = JsonSerializer.Deserialize(json, JsonOptions); @@ -357,8 +357,8 @@ public class LrclibService public async Task GetLyricsByIdAsync(int id) { - var cacheKey = $"lyrics:id:{id}"; - + var cacheKey = CacheKeyBuilder.BuildLyricsByIdKey(id); + var cached = await _cache.GetStringAsync(cacheKey); if (!string.IsNullOrEmpty(cached)) { @@ -376,14 +376,14 @@ public class LrclibService { var url = $"{BaseUrl}/get/{id}"; var response = await _httpClient.GetAsync(url); - + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { return null; } response.EnsureSuccessStatusCode(); - + var json = await response.Content.ReadAsStringAsync(); var lyrics = JsonSerializer.Deserialize(json, JsonOptions); @@ -405,7 +405,7 @@ public class LrclibService }; await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL); - + return result; } catch (Exception ex) diff --git a/allstarr/Services/Lyrics/LyricsPlusService.cs b/allstarr/Services/Lyrics/LyricsPlusService.cs index dd5e86f..6b2e8e8 100644 --- a/allstarr/Services/Lyrics/LyricsPlusService.cs +++ b/allstarr/Services/Lyrics/LyricsPlusService.cs @@ -37,14 +37,14 @@ public class LyricsPlusService // Validate input parameters if (string.IsNullOrWhiteSpace(trackName) || artistNames == null || artistNames.Length == 0) { - _logger.LogDebug("Invalid parameters for LyricsPlus search: trackName={TrackName}, artistCount={ArtistCount}", + _logger.LogDebug("Invalid parameters for LyricsPlus search: trackName={TrackName}, artistCount={ArtistCount}", trackName, artistNames?.Length ?? 0); return null; } - + var artistName = string.Join(", ", artistNames); - var cacheKey = $"lyricsplus:{artistName}:{trackName}:{albumName}:{durationSeconds}"; - + var cacheKey = CacheKeyBuilder.BuildLyricsPlusKey(artistName, trackName, albumName, durationSeconds); + // Check cache var cached = await _cache.GetStringAsync(cacheKey); if (!string.IsNullOrEmpty(cached)) @@ -63,24 +63,24 @@ public class LyricsPlusService { // Build URL with query parameters var url = $"{BaseUrl}?title={Uri.EscapeDataString(trackName)}&artist={Uri.EscapeDataString(artistName)}"; - + if (!string.IsNullOrEmpty(albumName)) { url += $"&album={Uri.EscapeDataString(albumName)}"; } - + if (durationSeconds > 0) { url += $"&duration={durationSeconds}"; } - + // Add sources: apple, lyricsplus, musixmatch, spotify, musixmatch-word url += "&source=apple,lyricsplus,musixmatch,spotify,musixmatch-word"; _logger.LogDebug("Fetching lyrics from LyricsPlus: {Url}", url); var response = await _httpClient.GetAsync(url); - + if (response.StatusCode == System.Net.HttpStatusCode.NotFound) { _logger.LogDebug("Lyrics not found on LyricsPlus for {Artist} - {Track}", artistName, trackName); @@ -88,7 +88,7 @@ public class LyricsPlusService } response.EnsureSuccessStatusCode(); - + var json = await response.Content.ReadAsStringAsync(); var lyricsResponse = JsonSerializer.Deserialize(json, JsonOptions); @@ -100,14 +100,14 @@ public class LyricsPlusService // Convert to LyricsInfo format var result = ConvertToLyricsInfo(lyricsResponse, trackName, artistName, albumName, durationSeconds); - + if (result != null) { await _cache.SetStringAsync(cacheKey, JsonSerializer.Serialize(result, JsonOptions), CacheExtensions.LyricsTTL); - _logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})", + _logger.LogInformation("✓ Retrieved lyrics from LyricsPlus for {Artist} - {Track} (type: {Type}, source: {Source})", artistName, trackName, lyricsResponse.Type, lyricsResponse.Metadata?.Source); } - + return result; } catch (HttpRequestException ex) @@ -166,7 +166,7 @@ public class LyricsPlusService private string ConvertLineTimingToLrc(List lines) { var lrcLines = new List(); - + foreach (var line in lines) { if (line.Time.HasValue) @@ -175,7 +175,7 @@ public class LyricsPlusService var mm = (int)timestamp.TotalMinutes; var ss = timestamp.Seconds; var cs = timestamp.Milliseconds / 10; // Convert to centiseconds - + lrcLines.Add($"[{mm:D2}:{ss:D2}.{cs:D2}]{line.Text}"); } else @@ -184,7 +184,7 @@ public class LyricsPlusService lrcLines.Add(line.Text); } } - + return string.Join("\n", lrcLines); } @@ -205,10 +205,10 @@ public class LyricsPlusService { [JsonPropertyName("type")] public string Type { get; set; } = string.Empty; // "Word", "Line", or "Static" - + [JsonPropertyName("metadata")] public LyricsPlusMetadata? Metadata { get; set; } - + [JsonPropertyName("lyrics")] public List Lyrics { get; set; } = new(); } @@ -217,10 +217,10 @@ public class LyricsPlusService { [JsonPropertyName("source")] public string? Source { get; set; } - + [JsonPropertyName("title")] public string? Title { get; set; } - + [JsonPropertyName("language")] public string? Language { get; set; } } @@ -229,13 +229,13 @@ public class LyricsPlusService { [JsonPropertyName("time")] public long? Time { get; set; } // Milliseconds - + [JsonPropertyName("duration")] public long? Duration { get; set; } - + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; - + [JsonPropertyName("syllabus")] public List? Syllabus { get; set; } } @@ -244,10 +244,10 @@ public class LyricsPlusService { [JsonPropertyName("time")] public long Time { get; set; } - + [JsonPropertyName("duration")] public long Duration { get; set; } - + [JsonPropertyName("text")] public string Text { get; set; } = string.Empty; } diff --git a/allstarr/Services/Lyrics/LyricsPrefetchService.cs b/allstarr/Services/Lyrics/LyricsPrefetchService.cs index d0a36e5..d4c716b 100644 --- a/allstarr/Services/Lyrics/LyricsPrefetchService.cs +++ b/allstarr/Services/Lyrics/LyricsPrefetchService.cs @@ -42,7 +42,7 @@ public class LyricsPrefetchService : BackgroundService protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("LyricsPrefetchService: Starting up..."); - + if (!_spotifySettings.Enabled) { _logger.LogInformation("Spotify playlist injection is DISABLED, lyrics prefetch will not run"); @@ -70,7 +70,7 @@ public class LyricsPrefetchService : BackgroundService while (!stoppingToken.IsCancellationRequested) { await Task.Delay(TimeSpan.FromHours(24), stoppingToken); - + try { await PrefetchAllPlaylistLyricsAsync(stoppingToken); @@ -85,7 +85,7 @@ public class LyricsPrefetchService : BackgroundService private async Task PrefetchAllPlaylistLyricsAsync(CancellationToken cancellationToken) { _logger.LogInformation("🎵 Starting lyrics prefetch for {Count} playlists", _spotifySettings.Playlists.Count); - + var totalFetched = 0; var totalCached = 0; var totalMissing = 0; @@ -107,12 +107,12 @@ public class LyricsPrefetchService : BackgroundService } } - _logger.LogInformation("✅ Lyrics prefetch complete: {Fetched} fetched, {Cached} already cached, {Missing} not found", + _logger.LogInformation("✅ Lyrics prefetch complete: {Fetched} fetched, {Cached} already cached, {Missing} not found", totalFetched, totalCached, totalMissing); } public async Task<(int Fetched, int Cached, int Missing)> PrefetchPlaylistLyricsAsync( - string playlistName, + string playlistName, CancellationToken cancellationToken) { _logger.LogDebug("Prefetching lyrics for playlist: {Playlist}", playlistName); @@ -127,7 +127,7 @@ public class LyricsPrefetchService : BackgroundService // Get the pre-built playlist items cache which includes Jellyfin item IDs for local tracks var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName); var playlistItems = await _cache.GetAsync>>(playlistItemsKey); - + // Build a map of Spotify ID -> Jellyfin Item ID for quick lookup var spotifyToJellyfinId = new Dictionary(); if (playlistItems != null) @@ -138,7 +138,7 @@ public class LyricsPrefetchService : BackgroundService if (item.TryGetValue("Id", out var idObj) && idObj != null) { var jellyfinId = idObj.ToString(); - + // Try to get Spotify provider ID if (item.TryGetValue("ProviderIds", out var providerIdsObj) && providerIdsObj != null) { @@ -155,8 +155,8 @@ public class LyricsPrefetchService : BackgroundService } } } - - _logger.LogInformation("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}", + + _logger.LogInformation("Found {Count} local Jellyfin tracks with Spotify IDs in playlist {Playlist}", spotifyToJellyfinId.Count, playlistName); } @@ -173,7 +173,11 @@ public class LyricsPrefetchService : BackgroundService // Check if lyrics are already cached // Use same cache key format as LrclibService: join all artists with ", " var artistName = string.Join(", ", track.Artists); - var cacheKey = $"lyrics:{artistName}:{track.Title}:{track.Album}:{track.DurationMs / 1000}"; + var cacheKey = CacheKeyBuilder.BuildLyricsKey( + artistName, + track.Title, + track.Album, + track.DurationMs / 1000); var existingLyrics = await _cache.GetStringAsync(cacheKey); if (!string.IsNullOrEmpty(existingLyrics)) @@ -191,9 +195,9 @@ public class LyricsPrefetchService : BackgroundService if (hasLocalLyrics) { cached++; - _logger.LogWarning("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch", + _logger.LogWarning("✓ Local Jellyfin lyrics found for {Artist} - {Track}, skipping external fetch", track.PrimaryArtist, track.Title); - + // Remove any previously cached LRCLib lyrics for this track var artistNameForRemoval = string.Join(", ", track.Artists); await RemoveCachedLyricsAsync(artistNameForRemoval, track.Title, track.Album, track.DurationMs / 1000); @@ -221,9 +225,9 @@ public class LyricsPrefetchService : BackgroundService if (lyrics != null) { fetched++; - _logger.LogInformation("✓ Fetched lyrics for {Artist} - {Track} (synced: {HasSynced})", + _logger.LogInformation("✓ Fetched lyrics for {Artist} - {Track} (synced: {HasSynced})", track.PrimaryArtist, track.Title, !string.IsNullOrEmpty(lyrics.SyncedLyrics)); - + // Save to file cache var artistNameForSave = string.Join(", ", track.Artists); await SaveLyricsToFileAsync(artistNameForSave, track.Title, track.Album, track.DurationMs / 1000, lyrics); @@ -244,7 +248,7 @@ public class LyricsPrefetchService : BackgroundService } } - _logger.LogDebug("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing", + _logger.LogDebug("Playlist {Playlist}: {Fetched} fetched, {Cached} cached, {Missing} missing", playlistName, fetched, cached, missing); return (fetched, cached, missing); @@ -300,7 +304,11 @@ public class LyricsPrefetchService : BackgroundService if (lyrics != null) { - var cacheKey = $"lyrics:{lyrics.ArtistName}:{lyrics.TrackName}:{lyrics.AlbumName}:{lyrics.Duration}"; + var cacheKey = CacheKeyBuilder.BuildLyricsKey( + lyrics.ArtistName, + lyrics.TrackName, + lyrics.AlbumName, + lyrics.Duration); await _cache.SetStringAsync(cacheKey, json, CacheExtensions.LyricsTTL); loaded++; } @@ -336,13 +344,13 @@ public class LyricsPrefetchService : BackgroundService try { // Remove from Redis cache - var cacheKey = $"lyrics:{artist}:{title}:{album}:{duration}"; + var cacheKey = CacheKeyBuilder.BuildLyricsKey(artist, title, album, duration); await _cache.DeleteAsync(cacheKey); - + // Remove from file cache var fileName = $"{SanitizeFileName(artist)}_{SanitizeFileName(title)}_{duration}.json"; var filePath = Path.Combine(_lyricsCacheDir, fileName); - + if (File.Exists(filePath)) { File.Delete(filePath); @@ -365,17 +373,17 @@ public class LyricsPrefetchService : BackgroundService { using var scope = _serviceProvider.CreateScope(); var spotifyLyricsService = scope.ServiceProvider.GetService(); - + if (spotifyLyricsService == null) { return null; } var spotifyLyrics = await spotifyLyricsService.GetLyricsByTrackIdAsync(spotifyTrackId); - + if (spotifyLyrics != null && spotifyLyrics.Lines.Count > 0) { - _logger.LogDebug("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)", + _logger.LogDebug("✓ Found Spotify lyrics for {Artist} - {Track} ({LineCount} lines)", artistName, trackTitle, spotifyLyrics.Lines.Count); return spotifyLyricsService.ToLyricsInfo(spotifyLyrics); } @@ -399,7 +407,7 @@ public class LyricsPrefetchService : BackgroundService { using var scope = _serviceProvider.CreateScope(); var proxyService = scope.ServiceProvider.GetService(); - + if (proxyService == null) { return false; @@ -408,13 +416,13 @@ public class LyricsPrefetchService : BackgroundService // Directly check if this track has lyrics using the item ID // Use internal method with server API key since this is a background operation var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal( - $"Audio/{jellyfinItemId}/Lyrics", + $"Audio/{jellyfinItemId}/Lyrics", null); - + if (lyricsResult != null && lyricsStatusCode == 200) { // Track has embedded lyrics in Jellyfin - _logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (ID: {JellyfinId})", + _logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (ID: {JellyfinId})", artistName, trackTitle, jellyfinItemId); return true; } @@ -438,7 +446,7 @@ public class LyricsPrefetchService : BackgroundService { using var scope = _serviceProvider.CreateScope(); var proxyService = scope.ServiceProvider.GetService(); - + if (proxyService == null) { return false; @@ -456,7 +464,7 @@ public class LyricsPrefetchService : BackgroundService }; var (searchResult, statusCode) = await proxyService.GetJsonAsyncInternal("Items", searchParams); - + if (searchResult == null || statusCode != 200) { // Track not found in local library @@ -464,7 +472,7 @@ public class LyricsPrefetchService : BackgroundService } // Check if we found any items - if (!searchResult.RootElement.TryGetProperty("Items", out var items) || + if (!searchResult.RootElement.TryGetProperty("Items", out var items) || items.GetArrayLength() == 0) { return false; @@ -474,7 +482,7 @@ public class LyricsPrefetchService : BackgroundService string? bestMatchId = null; foreach (var item in items.EnumerateArray()) { - if (!item.TryGetProperty("Name", out var nameEl) || + if (!item.TryGetProperty("Name", out var nameEl) || !item.TryGetProperty("Id", out var idEl)) { continue; @@ -482,7 +490,7 @@ public class LyricsPrefetchService : BackgroundService var itemTitle = nameEl.GetString() ?? ""; var itemId = idEl.GetString(); - + // Check if title matches (case-insensitive) if (itemTitle.Equals(trackTitle, StringComparison.OrdinalIgnoreCase)) { @@ -496,7 +504,7 @@ public class LyricsPrefetchService : BackgroundService break; // Exact match found } } - + // If no exact artist match but title matches, use it as fallback if (bestMatchId == null) { @@ -513,13 +521,13 @@ public class LyricsPrefetchService : BackgroundService // Check if this track has lyrics // Use internal method with server API key since this is a background operation var (lyricsResult, lyricsStatusCode) = await proxyService.GetJsonAsyncInternal( - $"Audio/{bestMatchId}/Lyrics", + $"Audio/{bestMatchId}/Lyrics", null); - + if (lyricsResult != null && lyricsStatusCode == 200) { // Track has embedded lyrics in Jellyfin - _logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (Jellyfin ID: {JellyfinId})", + _logger.LogDebug("Found embedded lyrics in Jellyfin for {Artist} - {Track} (Jellyfin ID: {JellyfinId})", artistName, trackTitle, bestMatchId); return true; } diff --git a/allstarr/Services/MusicBrainz/MusicBrainzService.cs b/allstarr/Services/MusicBrainz/MusicBrainzService.cs index ee166dd..3d6edd6 100644 --- a/allstarr/Services/MusicBrainz/MusicBrainzService.cs +++ b/allstarr/Services/MusicBrainz/MusicBrainzService.cs @@ -32,18 +32,18 @@ public class MusicBrainzService _httpClient = httpClientFactory.CreateClient(); _httpClient.DefaultRequestHeaders.Add("User-Agent", "Allstarr/1.0.3 (https://github.com/SoPat712/allstarr)"); _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); - + _settings = settings.Value; _cacheSettings = cacheSettings.Value; _cache = cache; _logger = logger; - + // Set up digest authentication if credentials provided if (!string.IsNullOrEmpty(_settings.Username) && !string.IsNullOrEmpty(_settings.Password)) { var credentials = Convert.ToBase64String( Encoding.ASCII.GetBytes($"{_settings.Username}:{_settings.Password}")); - _httpClient.DefaultRequestHeaders.Authorization = + _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Basic", credentials); } } @@ -59,7 +59,7 @@ public class MusicBrainzService } // Check cache first - var cacheKey = $"musicbrainz:isrc:{isrc}"; + var cacheKey = CacheKeyBuilder.BuildMusicBrainzIsrcKey(isrc); var cached = await _cache.GetAsync(cacheKey); if (cached != null) { @@ -75,7 +75,7 @@ public class MusicBrainzService _logger.LogDebug("MusicBrainz ISRC lookup: {Url}", url); var response = await _httpClient.GetAsync(url); - + if (!response.IsSuccessStatusCode) { _logger.LogWarning("MusicBrainz ISRC lookup failed: {StatusCode}", response.StatusCode); @@ -121,7 +121,7 @@ public class MusicBrainzService } // Check cache first - var cacheKey = $"musicbrainz:search:{title.ToLowerInvariant()}:{artist.ToLowerInvariant()}:{limit}"; + var cacheKey = CacheKeyBuilder.BuildMusicBrainzSearchKey(title, artist, limit); var cached = await _cache.GetAsync>(cacheKey); if (cached != null) { @@ -138,11 +138,11 @@ public class MusicBrainzService var encodedQuery = Uri.EscapeDataString(query); // Note: Search API doesn't support inc=genres, only returns basic info + MBIDs var url = $"{_settings.BaseUrl}/recording?query={encodedQuery}&fmt=json&limit={limit}"; - + _logger.LogDebug("MusicBrainz search: {Url}", url); var response = await _httpClient.GetAsync(url); - + if (!response.IsSuccessStatusCode) { _logger.LogWarning("MusicBrainz search failed: {StatusCode}", response.StatusCode); @@ -172,7 +172,7 @@ public class MusicBrainzService return new List(); } } - + /// /// Looks up a recording by MBID to get full details including genres. /// @@ -184,7 +184,7 @@ public class MusicBrainzService } // Check cache first - var cacheKey = $"musicbrainz:mbid:{mbid}"; + var cacheKey = CacheKeyBuilder.BuildMusicBrainzMbidKey(mbid); var cached = await _cache.GetAsync(cacheKey); if (cached != null) { @@ -200,7 +200,7 @@ public class MusicBrainzService _logger.LogDebug("MusicBrainz MBID lookup: {Url}", url); var response = await _httpClient.GetAsync(url); - + if (!response.IsSuccessStatusCode) { _logger.LogWarning("MusicBrainz MBID lookup failed: {StatusCode}", response.StatusCode); @@ -231,7 +231,7 @@ public class MusicBrainzService return null; } } - + /// /// Enriches a song with genre information from MusicBrainz. /// First tries ISRC lookup, then falls back to title/artist search + MBID lookup. @@ -256,7 +256,7 @@ public class MusicBrainzService { var recordings = await SearchRecordingsAsync(title, artist, limit: 1); var searchResult = recordings.FirstOrDefault(); - + // If we found a recording from search, do a full lookup by MBID to get genres if (searchResult != null && !string.IsNullOrEmpty(searchResult.Id)) { @@ -271,7 +271,7 @@ public class MusicBrainzService // Extract genres (prioritize official genres over tags) var genres = new List(); - + if (recording.Genres != null && recording.Genres.Count > 0) { // Get top genres by vote count @@ -338,7 +338,7 @@ public class MusicBrainzSearchResponse { [JsonPropertyName("recordings")] public List? Recordings { get; set; } - + [JsonPropertyName("count")] public int Count { get; set; } } @@ -350,25 +350,25 @@ public class MusicBrainzRecording { [JsonPropertyName("id")] public string? Id { get; set; } - + [JsonPropertyName("title")] public string? Title { get; set; } - + [JsonPropertyName("length")] public int? Length { get; set; } // in milliseconds - + [JsonPropertyName("artist-credit")] public List? ArtistCredit { get; set; } - + [JsonPropertyName("releases")] public List? Releases { get; set; } - + [JsonPropertyName("isrcs")] public List? Isrcs { get; set; } - + [JsonPropertyName("genres")] public List? Genres { get; set; } - + [JsonPropertyName("tags")] public List? Tags { get; set; } } @@ -380,7 +380,7 @@ public class MusicBrainzArtistCredit { [JsonPropertyName("name")] public string? Name { get; set; } - + [JsonPropertyName("artist")] public MusicBrainzArtist? Artist { get; set; } } @@ -392,7 +392,7 @@ public class MusicBrainzArtist { [JsonPropertyName("id")] public string? Id { get; set; } - + [JsonPropertyName("name")] public string? Name { get; set; } } @@ -404,10 +404,10 @@ public class MusicBrainzRelease { [JsonPropertyName("id")] public string? Id { get; set; } - + [JsonPropertyName("title")] public string? Title { get; set; } - + [JsonPropertyName("date")] public string? Date { get; set; } } @@ -419,10 +419,10 @@ public class MusicBrainzGenre { [JsonPropertyName("id")] public string? Id { get; set; } - + [JsonPropertyName("name")] public string? Name { get; set; } - + [JsonPropertyName("count")] public int Count { get; set; } } @@ -434,7 +434,7 @@ public class MusicBrainzTag { [JsonPropertyName("name")] public string? Name { get; set; } - + [JsonPropertyName("count")] public int Count { get; set; } } diff --git a/allstarr/Services/Qobuz/QobuzMetadataService.cs b/allstarr/Services/Qobuz/QobuzMetadataService.cs index 4a17e2e..69056fb 100644 --- a/allstarr/Services/Qobuz/QobuzMetadataService.cs +++ b/allstarr/Services/Qobuz/QobuzMetadataService.cs @@ -13,7 +13,7 @@ namespace allstarr.Services.Qobuz; /// Metadata service implementation using the Qobuz API /// Uses user authentication token instead of email/password /// -public class QobuzMetadataService : IMusicMetadataService +public class QobuzMetadataService : TrackParserBase, IMusicMetadataService { private readonly HttpClient _httpClient; private readonly SubsonicSettings _settings; @@ -22,11 +22,11 @@ public class QobuzMetadataService : IMusicMetadataService private readonly GenreEnrichmentService? _genreEnrichment; private readonly string? _userAuthToken; private readonly string? _userId; - + private const string BaseUrl = "https://www.qobuz.com/api.json/0.2/"; public QobuzMetadataService( - IHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, IOptions settings, IOptions qobuzSettings, QobuzBundleService bundleService, @@ -38,29 +38,29 @@ public class QobuzMetadataService : IMusicMetadataService _bundleService = bundleService; _logger = logger; _genreEnrichment = genreEnrichment; - + var qobuzConfig = qobuzSettings.Value; _userAuthToken = qobuzConfig.UserAuthToken; _userId = qobuzConfig.UserId; - + // Set up default headers - _httpClient.DefaultRequestHeaders.Add("User-Agent", + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"); } - public async Task> SearchSongsAsync(string query, int limit = 20) + public async Task> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { try { var appId = await _bundleService.GetAppIdAsync(); var url = $"{BaseUrl}track/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var songs = new List(); if (result.RootElement.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("items", out var items)) @@ -71,7 +71,7 @@ public class QobuzMetadataService : IMusicMetadataService songs.Add(song); } } - + return songs; } catch (Exception ex) @@ -81,19 +81,19 @@ public class QobuzMetadataService : IMusicMetadataService } } - public async Task> SearchAlbumsAsync(string query, int limit = 20) + public async Task> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { try { var appId = await _bundleService.GetAppIdAsync(); var url = $"{BaseUrl}album/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var albums = new List(); if (result.RootElement.TryGetProperty("albums", out var albumsData) && albumsData.TryGetProperty("items", out var items)) @@ -103,7 +103,7 @@ public class QobuzMetadataService : IMusicMetadataService albums.Add(ParseQobuzAlbum(album)); } } - + return albums; } catch (Exception ex) @@ -113,19 +113,19 @@ public class QobuzMetadataService : IMusicMetadataService } } - public async Task> SearchArtistsAsync(string query, int limit = 20) + public async Task> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { try { var appId = await _bundleService.GetAppIdAsync(); var url = $"{BaseUrl}artist/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var artists = new List(); if (result.RootElement.TryGetProperty("artists", out var artistsData) && artistsData.TryGetProperty("items", out var items)) @@ -135,7 +135,7 @@ public class QobuzMetadataService : IMusicMetadataService artists.Add(ParseQobuzArtist(artist)); } } - + return artists; } catch (Exception ex) @@ -145,14 +145,14 @@ public class QobuzMetadataService : IMusicMetadataService } } - public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20) + public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) { - var songsTask = SearchSongsAsync(query, songLimit); - var albumsTask = SearchAlbumsAsync(query, albumLimit); - var artistsTask = SearchArtistsAsync(query, artistLimit); - + var songsTask = SearchSongsAsync(query, songLimit, cancellationToken); + var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken); + var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken); + await Task.WhenAll(songsTask, albumsTask, artistsTask); - + return new SearchResult { Songs = await songsTask, @@ -161,25 +161,25 @@ public class QobuzMetadataService : IMusicMetadataService }; } - public async Task GetSongAsync(string externalProvider, string externalId) + public async Task GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "qobuz") return null; - + try { var appId = await _bundleService.GetAppIdAsync(); var url = $"{BaseUrl}track/get?track_id={externalId}&app_id={appId}"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var track = JsonDocument.Parse(json).RootElement; - + if (track.TryGetProperty("error", out _)) return null; - + var song = ParseQobuzTrackFull(track); - + // Enrich with MusicBrainz genres if missing if (_genreEnrichment != null && song != null && string.IsNullOrEmpty(song.Genre)) { @@ -196,7 +196,7 @@ public class QobuzMetadataService : IMusicMetadataService } }); } - + return song; } catch (Exception ex) @@ -206,25 +206,25 @@ public class QobuzMetadataService : IMusicMetadataService } } - public async Task GetAlbumAsync(string externalProvider, string externalId) + public async Task GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "qobuz") return null; - + try { var appId = await _bundleService.GetAppIdAsync(); var url = $"{BaseUrl}album/get?album_id={externalId}&app_id={appId}"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var albumElement = JsonDocument.Parse(json).RootElement; - + if (albumElement.TryGetProperty("error", out _)) return null; - + var album = ParseQobuzAlbum(albumElement); - + // Get album tracks if (albumElement.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("items", out var tracksData)) @@ -232,16 +232,16 @@ public class QobuzMetadataService : IMusicMetadataService foreach (var track in tracksData.EnumerateArray()) { var song = ParseQobuzTrack(track); - + // Ensure album metadata is set (tracks in album response may not have full album object) song.Album = album.Title; song.AlbumId = album.Id; song.AlbumArtist = album.Artist; - + album.Songs.Add(song); } } - + return album; } catch (Exception ex) @@ -251,23 +251,23 @@ public class QobuzMetadataService : IMusicMetadataService } } - public async Task GetArtistAsync(string externalProvider, string externalId) + public async Task GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "qobuz") return null; - + try { var appId = await _bundleService.GetAppIdAsync(); var url = $"{BaseUrl}artist/get?artist_id={externalId}&app_id={appId}"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var artist = JsonDocument.Parse(json).RootElement; - + if (artist.TryGetProperty("error", out _)) return null; - + return ParseQobuzArtist(artist); } catch (Exception ex) @@ -277,48 +277,48 @@ public class QobuzMetadataService : IMusicMetadataService } } - public async Task> GetArtistAlbumsAsync(string externalProvider, string externalId) + public async Task> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "qobuz") return new List(); - + try { var albums = new List(); var appId = await _bundleService.GetAppIdAsync(); int offset = 0; const int limit = 500; - + // Qobuz requires pagination for artist albums while (true) { var url = $"{BaseUrl}artist/get?artist_id={externalId}&app_id={appId}&limit={limit}&offset={offset}&extra=albums"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) break; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + if (!result.RootElement.TryGetProperty("albums", out var albumsData) || !albumsData.TryGetProperty("items", out var items)) { break; } - + var itemsArray = items.EnumerateArray().ToList(); if (itemsArray.Count == 0) break; - + foreach (var album in itemsArray) { albums.Add(ParseQobuzAlbum(album)); } - + // If we got less than the limit, we've reached the end if (itemsArray.Count < limit) break; - + offset += limit; } - + return albums; } catch (Exception ex) @@ -328,7 +328,7 @@ public class QobuzMetadataService : IMusicMetadataService } } - public async Task> GetArtistTracksAsync(string externalProvider, string externalId) + public async Task> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { // Qobuz doesn't have a dedicated "artist top tracks" endpoint // Return empty list - clients will need to browse albums instead @@ -336,19 +336,19 @@ public class QobuzMetadataService : IMusicMetadataService return new List(); } - public async Task> SearchPlaylistsAsync(string query, int limit = 20) + public async Task> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { try { var appId = await _bundleService.GetAppIdAsync(); var url = $"{BaseUrl}playlist/search?query={Uri.EscapeDataString(query)}&limit={limit}&app_id={appId}"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + var playlists = new List(); if (result.RootElement.TryGetProperty("playlists", out var playlistsData) && playlistsData.TryGetProperty("items", out var items)) @@ -358,7 +358,7 @@ public class QobuzMetadataService : IMusicMetadataService playlists.Add(ParseQobuzPlaylist(playlist)); } } - + return playlists; } catch (Exception ex) @@ -367,24 +367,24 @@ public class QobuzMetadataService : IMusicMetadataService return new List(); } } - - public async Task GetPlaylistAsync(string externalProvider, string externalId) + + public async Task GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "qobuz") return null; - + try { var appId = await _bundleService.GetAppIdAsync(); var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var playlistElement = JsonDocument.Parse(json).RootElement; - + if (playlistElement.TryGetProperty("error", out _)) return null; - + return ParseQobuzPlaylist(playlistElement); } catch (Exception ex) @@ -393,31 +393,31 @@ public class QobuzMetadataService : IMusicMetadataService return null; } } - - public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId) + + public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "qobuz") return new List(); - + try { var appId = await _bundleService.GetAppIdAsync(); var url = $"{BaseUrl}playlist/get?playlist_id={externalId}&app_id={appId}&extra=tracks"; - - var response = await GetWithAuthAsync(url); + + var response = await GetWithAuthAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var playlistElement = JsonDocument.Parse(json).RootElement; - + if (playlistElement.TryGetProperty("error", out _)) return new List(); - + var songs = new List(); - + // Get playlist name for album field var playlistName = playlistElement.TryGetProperty("name", out var nameEl) ? nameEl.GetString() ?? "Unknown Playlist" : "Unknown Playlist"; - + if (playlistElement.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("items", out var tracksData)) { @@ -426,20 +426,20 @@ public class QobuzMetadataService : IMusicMetadataService { // For playlists, use the track's own artist (not a single album artist) var song = ParseQobuzTrack(track); - + // Override album name to be the playlist name song.Album = playlistName; song.Track = trackIndex; - + // Playlists should not have disc numbers - always set to null // This prevents Jellyfin from splitting the playlist into multiple "discs" song.DiscNumber = null; - + songs.Add(song); trackIndex++; } } - + return songs; } catch (Exception ex) @@ -448,11 +448,11 @@ public class QobuzMetadataService : IMusicMetadataService return new List(); } } - + private ExternalPlaylist ParseQobuzPlaylist(JsonElement playlist) { var externalId = GetIdAsString(playlist.GetProperty("id")); - + // Get curator/creator name string? curatorName = null; if (playlist.TryGetProperty("owner", out var owner) && @@ -460,7 +460,7 @@ public class QobuzMetadataService : IMusicMetadataService { curatorName = ownerName.GetString(); } - + // Get creation date DateTime? createdDate = null; if (playlist.TryGetProperty("created_at", out var createdAtEl)) @@ -468,7 +468,7 @@ public class QobuzMetadataService : IMusicMetadataService var timestamp = createdAtEl.GetInt64(); createdDate = DateTimeOffset.FromUnixTimeSeconds(timestamp).DateTime; } - + // Get cover URL from images string? coverUrl = null; if (playlist.TryGetProperty("images300", out var images300)) @@ -487,7 +487,7 @@ public class QobuzMetadataService : IMusicMetadataService coverUrl = imagesArray[0].GetString(); } } - + return new ExternalPlaylist { Id = Common.PlaylistIdHelper.CreatePlaylistId("qobuz", externalId), @@ -511,43 +511,30 @@ public class QobuzMetadataService : IMusicMetadataService }; } - /// - /// Safely gets an ID value as a string, handling both number and string types from JSON - /// - private string GetIdAsString(JsonElement element) - { - return element.ValueKind switch - { - JsonValueKind.Number => element.GetInt64().ToString(), - JsonValueKind.String => element.GetString() ?? "", - _ => "" - }; - } - /// /// Makes an HTTP GET request with Qobuz authentication headers /// - private async Task GetWithAuthAsync(string url) + private async Task GetWithAuthAsync(string url, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Get, url); - + var appId = await _bundleService.GetAppIdAsync(); request.Headers.Add("X-App-Id", appId); - + if (!string.IsNullOrEmpty(_userAuthToken)) { request.Headers.Add("X-User-Auth-Token", _userAuthToken); } - - return await _httpClient.SendAsync(request); + + return await _httpClient.SendAsync(request, cancellationToken); } private Song ParseQobuzTrack(JsonElement track) { var externalId = GetIdAsString(track.GetProperty("id")); - + var title = track.GetProperty("title").GetString() ?? ""; - + // Add version to title if present (e.g., "Remastered", "Live") if (track.TryGetProperty("version", out var version)) { @@ -557,7 +544,7 @@ public class QobuzMetadataService : IMusicMetadataService title = $"{title} ({versionStr})"; } } - + // For classical music, prepend work name if (track.TryGetProperty("work", out var work)) { @@ -567,32 +554,32 @@ public class QobuzMetadataService : IMusicMetadataService title = $"{workStr}: {title}"; } } - + var performerName = track.TryGetProperty("performer", out var performer) ? performer.GetProperty("name").GetString() ?? "" : ""; - + var albumTitle = track.TryGetProperty("album", out var album) ? album.GetProperty("title").GetString() ?? "" : ""; - + var albumId = track.TryGetProperty("album", out var albumForId) - ? $"ext-qobuz-album-{GetIdAsString(albumForId.GetProperty("id"))}" + ? BuildExternalAlbumId("qobuz", GetIdAsString(albumForId.GetProperty("id"))) : null; - + // Get album artist var albumArtist = track.TryGetProperty("album", out var albumForArtist) && albumForArtist.TryGetProperty("artist", out var albumArtistEl) ? albumArtistEl.GetProperty("name").GetString() : performerName; - + return new Song { - Id = $"ext-qobuz-song-{externalId}", + Id = BuildExternalSongId("qobuz", externalId), Title = title, Artist = performerName, ArtistId = track.TryGetProperty("performer", out var performerForId) - ? $"ext-qobuz-artist-{GetIdAsString(performerForId.GetProperty("id"))}" + ? BuildExternalArtistId("qobuz", GetIdAsString(performerForId.GetProperty("id"))) : null, Album = albumTitle, AlbumId = albumId, @@ -616,24 +603,24 @@ public class QobuzMetadataService : IMusicMetadataService private Song ParseQobuzTrackFull(JsonElement track) { var song = ParseQobuzTrack(track); - + // Add additional metadata for full track if (track.TryGetProperty("composer", out var composer) && composer.TryGetProperty("name", out var composerName)) { song.Contributors = new List { composerName.GetString() ?? "" }; } - + if (track.TryGetProperty("isrc", out var isrc)) { song.Isrc = isrc.GetString(); } - + if (track.TryGetProperty("copyright", out var copyright)) { song.Copyright = FormatCopyright(copyright.GetString() ?? ""); } - + // Get release date from album if (track.TryGetProperty("album", out var album)) { @@ -641,39 +628,33 @@ public class QobuzMetadataService : IMusicMetadataService { var dateStr = releaseDate.GetString(); song.ReleaseDate = dateStr; - - if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4) - { - if (int.TryParse(dateStr.Substring(0, 4), out var year)) - { - song.Year = year; - } - } + + song.Year = ParseYearFromDateString(dateStr); } - + if (album.TryGetProperty("tracks_count", out var tracksCount)) { song.TotalTracks = tracksCount.GetInt32(); } - + if (album.TryGetProperty("genres_list", out var genres)) { song.Genre = FormatGenres(genres); } - + // Get large cover art song.CoverArtUrlLarge = GetLargeCoverArtUrl(album); } - + return song; } private Album ParseQobuzAlbum(JsonElement album) { var externalId = GetIdAsString(album.GetProperty("id")); - + var title = album.GetProperty("title").GetString() ?? ""; - + // Add version to title if present if (album.TryGetProperty("version", out var version)) { @@ -683,31 +664,25 @@ public class QobuzMetadataService : IMusicMetadataService title = $"{title} ({versionStr})"; } } - + var artistName = album.TryGetProperty("artist", out var artist) ? artist.GetProperty("name").GetString() ?? "" : ""; - + int? year = null; if (album.TryGetProperty("release_date_original", out var releaseDate)) { var dateStr = releaseDate.GetString(); - if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4) - { - if (int.TryParse(dateStr.Substring(0, 4), out var y)) - { - year = y; - } - } + year = ParseYearFromDateString(dateStr); } - + return new Album { - Id = $"ext-qobuz-album-{externalId}", + Id = BuildExternalAlbumId("qobuz", externalId), Title = title, Artist = artistName, ArtistId = album.TryGetProperty("artist", out var artistForId) - ? $"ext-qobuz-artist-{GetIdAsString(artistForId.GetProperty("id"))}" + ? BuildExternalArtistId("qobuz", GetIdAsString(artistForId.GetProperty("id"))) : null, Year = year, SongCount = album.TryGetProperty("tracks_count", out var tracksCount) @@ -726,10 +701,10 @@ public class QobuzMetadataService : IMusicMetadataService private Artist ParseQobuzArtist(JsonElement artist) { var externalId = GetIdAsString(artist.GetProperty("id")); - + return new Artist { - Id = $"ext-qobuz-artist-{externalId}", + Id = BuildExternalArtistId("qobuz", externalId), Name = artist.GetProperty("name").GetString() ?? "", ImageUrl = GetArtistImageUrl(artist), AlbumCount = artist.TryGetProperty("albums_count", out var albumsCount) @@ -751,7 +726,7 @@ public class QobuzMetadataService : IMusicMetadataService { element = album; } - + if (element.TryGetProperty("image", out var image)) { // Prefer thumbnail (230x230), fallback to small @@ -764,7 +739,7 @@ public class QobuzMetadataService : IMusicMetadataService return small.GetString(); } } - + return null; } @@ -780,7 +755,7 @@ public class QobuzMetadataService : IMusicMetadataService // Replace _600.jpg with _org.jpg for original quality return url?.Replace("_600.jpg", "_org.jpg"); } - + return null; } @@ -794,7 +769,7 @@ public class QobuzMetadataService : IMusicMetadataService { return large.GetString(); } - + return null; } @@ -805,7 +780,7 @@ public class QobuzMetadataService : IMusicMetadataService private string FormatGenres(JsonElement genresList) { var genres = new List(); - + foreach (var genre in genresList.EnumerateArray()) { var genreStr = genre.GetString(); @@ -823,7 +798,7 @@ public class QobuzMetadataService : IMusicMetadataService } } } - + return string.Join(", ", genres); } diff --git a/allstarr/Services/Scrobbling/ListenBrainzScrobblingService.cs b/allstarr/Services/Scrobbling/ListenBrainzScrobblingService.cs index 8babd8f..1653b8e 100644 --- a/allstarr/Services/Scrobbling/ListenBrainzScrobblingService.cs +++ b/allstarr/Services/Scrobbling/ListenBrainzScrobblingService.cs @@ -311,7 +311,7 @@ public class ListenBrainzScrobblingService : IScrobblingService } catch (HttpRequestException ex) { - _logger.LogError(ex, "HTTP request failed"); + _logger.LogWarning("HTTP request failed: {Message}", ex.Message); return ScrobbleResult.CreateError($"HTTP error: {ex.Message}", shouldRetry: true); } } diff --git a/allstarr/Services/Scrobbling/ScrobblingHelper.cs b/allstarr/Services/Scrobbling/ScrobblingHelper.cs index d971adf..e540e13 100644 --- a/allstarr/Services/Scrobbling/ScrobblingHelper.cs +++ b/allstarr/Services/Scrobbling/ScrobblingHelper.cs @@ -101,7 +101,8 @@ public class ScrobblingHelper DurationSeconds = durationSeconds, MusicBrainzId = musicBrainzId, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - IsExternal = isExternal + IsExternal = isExternal, + StartPositionSeconds = 0 }; } catch (Exception ex) @@ -119,7 +120,8 @@ public class ScrobblingHelper string artist, string? album = null, string? albumArtist = null, - int? durationSeconds = null) + int? durationSeconds = null, + int? startPositionSeconds = null) { if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist)) { @@ -134,7 +136,8 @@ public class ScrobblingHelper AlbumArtist = albumArtist, DurationSeconds = durationSeconds, Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(), - IsExternal = true // Explicitly mark as external + IsExternal = true, // Explicitly mark as external + StartPositionSeconds = startPositionSeconds }; } diff --git a/allstarr/Services/Scrobbling/ScrobblingOrchestrator.cs b/allstarr/Services/Scrobbling/ScrobblingOrchestrator.cs index 9300b07..e2a8651 100644 --- a/allstarr/Services/Scrobbling/ScrobblingOrchestrator.cs +++ b/allstarr/Services/Scrobbling/ScrobblingOrchestrator.cs @@ -48,6 +48,19 @@ public class ScrobblingOrchestrator { if (!_settings.Enabled) return; + + var existingSession = FindSession(deviceId, track.Artist, track.Title); + if (existingSession != null) + { + existingSession.LastActivity = DateTime.UtcNow; + _logger.LogDebug( + "Ignoring duplicate playback start for active session: {Artist} - {Track} (device: {DeviceId}, session: {SessionId})", + track.Artist, + track.Title, + deviceId, + existingSession.SessionId); + return; + } var sessionId = $"{deviceId}:{track.Artist}:{track.Title}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; @@ -73,21 +86,41 @@ public class ScrobblingOrchestrator /// /// Handles playback progress - checks if track should be scrobbled. /// - public async Task OnPlaybackProgressAsync(string deviceId, string artist, string title, int positionSeconds) + public async Task OnPlaybackProgressAsync(string deviceId, ScrobbleTrack track, int positionSeconds) { if (!_settings.Enabled) return; - // Find the session for this track - var session = _sessions.Values.FirstOrDefault(s => - s.DeviceId == deviceId && - s.Track.Artist == artist && - s.Track.Title == title); + // Find the session for this track. + // If we never saw a start event (client skipped it or metadata failed earlier), + // recover by creating a session from the first progress event. + var session = FindSession(deviceId, track.Artist, track.Title); if (session == null) { - _logger.LogDebug("No active session found for progress update: {Artist} - {Track}", artist, title); - return; + var inferredStartTime = DateTimeOffset.UtcNow.AddSeconds(-Math.Max(positionSeconds, 0)).ToUnixTimeSeconds(); + var recoveredTrack = track with { Timestamp = inferredStartTime }; + + var sessionId = $"{deviceId}:{track.Artist}:{track.Title}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}"; + session = new PlaybackSession + { + SessionId = sessionId, + DeviceId = deviceId, + Track = recoveredTrack, + StartTime = DateTime.UtcNow, + LastPositionSeconds = 0, + LastActivity = DateTime.UtcNow + }; + + _sessions[sessionId] = session; + + _logger.LogInformation( + "Recovered missing scrobble session from progress: {Artist} - {Track} (position: {Position}s)", + track.Artist, + track.Title, + positionSeconds); + + await SendNowPlayingAsync(session); } session.LastPositionSeconds = positionSeconds; @@ -97,7 +130,7 @@ public class ScrobblingOrchestrator if (!session.Scrobbled && session.ShouldScrobble()) { _logger.LogDebug("✓ Scrobble threshold reached for: {Artist} - {Track} (position: {Position}s)", - artist, title, positionSeconds); + track.Artist, track.Title, positionSeconds); await ScrobbleAsync(session); } } @@ -111,10 +144,7 @@ public class ScrobblingOrchestrator return; // Find and remove the session - var session = _sessions.Values.FirstOrDefault(s => - s.DeviceId == deviceId && - s.Track.Artist == artist && - s.Track.Title == title); + var session = FindSession(deviceId, artist, title); if (session == null) { @@ -241,13 +271,13 @@ public class ScrobblingOrchestrator { _logger.LogInformation("✓ Scrobbled to {Service}: {Artist} - {Track}", service.ServiceName, session.Track.Artist, session.Track.Title); - return; // Success, exit retry loop - prevents double scrobbling + return true; // Success, exit retry loop - prevents double scrobbling } else if (result.Ignored) { _logger.LogDebug("⊘ Scrobble skipped by {Service}: {Reason}", service.ServiceName, result.IgnoredReason); - return; // Ignored, don't retry + return true; // Ignored, don't retry } else if (result.ShouldRetry && attempt < maxRetries - 1) { @@ -259,7 +289,7 @@ public class ScrobblingOrchestrator { _logger.LogError("❌ Scrobble failed for {Service}: {Error} - No more retries", service.ServiceName, result.ErrorMessage); - return; // Don't retry or max retries reached + return false; // Don't retry or max retries reached } } catch (Exception ex) @@ -274,14 +304,38 @@ public class ScrobblingOrchestrator { _logger.LogError(ex, "❌ Error scrobbling to {Service} after {Max} attempts", service.ServiceName, maxRetries); + return false; } } } + + return false; }); - await Task.WhenAll(tasks); - session.Scrobbled = true; - _logger.LogDebug("Marked session as scrobbled: {SessionId}", session.SessionId); + var outcomes = await Task.WhenAll(tasks); + if (outcomes.Any(s => s)) + { + session.Scrobbled = true; + _logger.LogDebug("Marked session as scrobbled: {SessionId}", session.SessionId); + } + else + { + _logger.LogWarning( + "Scrobble failed for all enabled services: {Artist} - {Track}. Will retry on next progress/stop.", + session.Track.Artist, + session.Track.Title); + } + } + + private PlaybackSession? FindSession(string deviceId, string artist, string title) + { + return _sessions.Values + .Where(s => + s.DeviceId == deviceId && + string.Equals(s.Track.Artist, artist, StringComparison.OrdinalIgnoreCase) && + string.Equals(s.Track.Title, title, StringComparison.OrdinalIgnoreCase)) + .OrderByDescending(s => s.StartTime) + .FirstOrDefault(); } /// diff --git a/allstarr/Services/Spotify/SpotifyApiClient.cs b/allstarr/Services/Spotify/SpotifyApiClient.cs index ccc391e..4b67b75 100644 --- a/allstarr/Services/Spotify/SpotifyApiClient.cs +++ b/allstarr/Services/Spotify/SpotifyApiClient.cs @@ -3,6 +3,7 @@ using System.Net.Http.Headers; using System.Text; using System.Text.Json; using System.Text.Json.Serialization; +using System.Globalization; using allstarr.Models.Settings; using allstarr.Models.Spotify; using Microsoft.Extensions.Options; @@ -12,14 +13,14 @@ namespace allstarr.Services.Spotify; /// /// Client for accessing Spotify's APIs directly. -/// +/// /// Supports two modes: /// 1. Official API - For public playlists and standard operations /// 2. Web API (with session cookie) - For editorial/personalized playlists like Release Radar, Discover Weekly -/// +/// /// The session cookie (sp_dc) is required because Spotify's official API doesn't expose /// algorithmically generated "Made For You" playlists. -/// +/// /// Uses TOTP-based authentication similar to the Jellyfin Spotify Import plugin. /// public class SpotifyApiClient : IDisposable @@ -29,39 +30,39 @@ public class SpotifyApiClient : IDisposable private readonly HttpClient _httpClient; private readonly HttpClient _webApiClient; private readonly CookieContainer _cookieContainer; - + // Spotify API endpoints private const string OfficialApiBase = "https://api.spotify.com/v1"; private const string WebApiBase = "https://api-partner.spotify.com/pathfinder/v1"; private const string SpotifyBaseUrl = "https://open.spotify.com"; private const string TokenEndpoint = "https://open.spotify.com/api/token"; - + // URL for pre-scraped TOTP secrets (same as Jellyfin plugin uses) private const string TotpSecretsUrl = "https://raw.githubusercontent.com/xyloflake/spot-secrets-go/refs/heads/main/secrets/secretBytes.json"; - + // Web API access token (obtained via session cookie) private string? _webAccessToken; private DateTime _webTokenExpiry = DateTime.MinValue; private readonly SemaphoreSlim _tokenLock = new(1, 1); - + // Cached TOTP secrets private TotpSecret? _cachedTotpSecret; private DateTime _totpSecretFetchedAt = DateTime.MinValue; - + public SpotifyApiClient( ILogger logger, IOptions settings) { _logger = logger; _settings = settings.Value; - + // Client for official API _httpClient = new HttpClient { BaseAddress = new Uri(OfficialApiBase), Timeout = TimeSpan.FromSeconds(30) }; - + // Client for web API (requires session cookie) _cookieContainer = new CookieContainer(); var handler = new HttpClientHandler @@ -69,19 +70,19 @@ public class SpotifyApiClient : IDisposable UseCookies = true, CookieContainer = _cookieContainer }; - + if (!string.IsNullOrEmpty(_settings.SessionCookie)) { _cookieContainer.SetCookies( new Uri(SpotifyBaseUrl), $"sp_dc={_settings.SessionCookie}"); } - + _webApiClient = new HttpClient(handler) { Timeout = TimeSpan.FromSeconds(30) }; - + // Common headers for web API _webApiClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"); _webApiClient.DefaultRequestHeaders.Add("Accept", "application/json"); @@ -89,7 +90,7 @@ public class SpotifyApiClient : IDisposable _webApiClient.DefaultRequestHeaders.Add("app-platform", "WebPlayer"); _webApiClient.DefaultRequestHeaders.Add("spotify-app-version", "1.2.46.25.g7f189073"); } - + /// /// Gets an access token using the session cookie and TOTP authentication. /// This token can be used for both the official API and web API. @@ -101,7 +102,7 @@ public class SpotifyApiClient : IDisposable _logger.LogInformation("No Spotify session cookie configured"); return null; } - + await _tokenLock.WaitAsync(cancellationToken); try { @@ -110,9 +111,9 @@ public class SpotifyApiClient : IDisposable { return _webAccessToken; } - + _logger.LogInformation("Fetching new Spotify web access token using TOTP authentication"); - + // Fetch TOTP secrets if needed var totpSecret = await GetTotpSecretAsync(cancellationToken); if (totpSecret == null) @@ -120,7 +121,7 @@ public class SpotifyApiClient : IDisposable _logger.LogError("Failed to get TOTP secrets"); return null; } - + // Generate TOTP var totpResult = await GenerateTotpAsync(totpSecret, cancellationToken); if (totpResult == null) @@ -128,40 +129,40 @@ public class SpotifyApiClient : IDisposable _logger.LogError("Failed to generate TOTP"); return null; } - + var (otp, serverTime) = totpResult.Value; var clientTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - + // Build token URL with TOTP parameters var tokenUrl = $"{TokenEndpoint}?reason=init&productType=web-player&totp={otp}&totpServer={otp}&totpVer={totpSecret.Version}&sTime={serverTime}&cTime={clientTime}"; - + _logger.LogDebug("Requesting token from: {Url}", tokenUrl.Replace(otp, "***")); - + var response = await _webApiClient.GetAsync(tokenUrl, cancellationToken); - + if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogError("Failed to get Spotify access token: {StatusCode} - {Body}", response.StatusCode, errorBody); return null; } - + var json = await response.Content.ReadAsStringAsync(cancellationToken); var tokenResponse = JsonSerializer.Deserialize(json); - + if (tokenResponse == null || string.IsNullOrEmpty(tokenResponse.AccessToken)) { _logger.LogError("No access token in Spotify response: {Json}", json); return null; } - + if (tokenResponse.IsAnonymous) { _logger.LogWarning("Spotify returned anonymous token - session cookie may be invalid"); } - + _webAccessToken = tokenResponse.AccessToken; - + // Token typically expires in 1 hour, but we'll refresh early if (tokenResponse.ExpirationTimestampMs > 0) { @@ -173,8 +174,8 @@ public class SpotifyApiClient : IDisposable { _webTokenExpiry = DateTime.UtcNow.AddMinutes(55); } - - _logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}, anonymous: {IsAnonymous}", + + _logger.LogInformation("Obtained Spotify web access token, expires at {Expiry}, anonymous: {IsAnonymous}", _webTokenExpiry, tokenResponse.IsAnonymous); return _webAccessToken; } @@ -188,7 +189,7 @@ public class SpotifyApiClient : IDisposable _tokenLock.Release(); } } - + /// /// Fetches TOTP secrets from the pre-scraped secrets repository. /// @@ -199,31 +200,31 @@ public class SpotifyApiClient : IDisposable { return _cachedTotpSecret; } - + try { _logger.LogDebug("Fetching TOTP secrets from {Url}", TotpSecretsUrl); - + var response = await _webApiClient.GetAsync(TotpSecretsUrl, cancellationToken); if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to fetch TOTP secrets: {StatusCode}", response.StatusCode); return null; } - + var json = await response.Content.ReadAsStringAsync(cancellationToken); var secrets = JsonSerializer.Deserialize(json); - + if (secrets == null || secrets.Length == 0) { _logger.LogError("No TOTP secrets found in response"); return null; } - + // Use the newest version _cachedTotpSecret = secrets.OrderByDescending(s => s.Version).First(); _totpSecretFetchedAt = DateTime.UtcNow; - + _logger.LogDebug("Got TOTP secret version {Version}", _cachedTotpSecret.Version); return _cachedTotpSecret; } @@ -233,7 +234,7 @@ public class SpotifyApiClient : IDisposable return null; } } - + /// /// Generates a TOTP code using the secret and server time. /// Based on the Jellyfin plugin implementation. @@ -245,33 +246,33 @@ public class SpotifyApiClient : IDisposable // Get server time from Spotify via HEAD request var headRequest = new HttpRequestMessage(HttpMethod.Head, SpotifyBaseUrl); var response = await _webApiClient.SendAsync(headRequest, cancellationToken); - + if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to get Spotify server time: {StatusCode}", response.StatusCode); return null; } - + var serverTime = response.Headers.Date?.ToUnixTimeSeconds(); if (serverTime == null) { _logger.LogError("No Date header in Spotify response"); return null; } - + // Compute secret from cipher bytes // The secret bytes need to be transformed: XOR each byte with ((index % 33) + 9) var cipherBytes = secret.Secret.ToArray(); var transformedBytes = cipherBytes.Select((b, i) => (byte)(b ^ ((i % 33) + 9))).ToArray(); - + // Convert to UTF-8 string representation then back to bytes for TOTP var transformedString = string.Join("", transformedBytes.Select(b => b.ToString())); var utf8Bytes = Encoding.UTF8.GetBytes(transformedString); - + // Generate TOTP var totp = new Totp(utf8Bytes, step: 30, totpSize: 6); var otp = totp.ComputeTotp(DateTime.UnixEpoch.AddSeconds(serverTime.Value)); - + _logger.LogDebug("Generated TOTP for server time {ServerTime}", serverTime.Value); return (otp, serverTime.Value); } @@ -281,7 +282,7 @@ public class SpotifyApiClient : IDisposable return null; } } - + /// /// Fetches a playlist with all its tracks from Spotify using the GraphQL API. /// This matches the approach used by the Jellyfin Spotify Import plugin. @@ -293,14 +294,14 @@ public class SpotifyApiClient : IDisposable { // Extract ID from URI if needed (spotify:playlist:xxxxx or https://open.spotify.com/playlist/xxxxx) playlistId = ExtractPlaylistId(playlistId); - + var token = await GetWebAccessTokenAsync(cancellationToken); if (string.IsNullOrEmpty(token)) { _logger.LogError("Cannot fetch playlist without access token"); return null; } - + try { // Use GraphQL API (same as Jellyfin plugin) - more reliable and less rate-limited @@ -312,7 +313,7 @@ public class SpotifyApiClient : IDisposable return null; } } - + /// /// Fetch playlist using Spotify's GraphQL API (api-partner.spotify.com/pathfinder/v1/query) /// This is the same approach used by the Jellyfin Spotify Import plugin @@ -326,13 +327,13 @@ public class SpotifyApiClient : IDisposable var offset = 0; var totalTrackCount = pageLimit; var tracks = new List(); - + SpotifyPlaylist? playlist = null; - + while (tracks.Count < totalTrackCount && offset < totalTrackCount) { if (cancellationToken.IsCancellationRequested) break; - + // Build GraphQL query URL (same as Jellyfin plugin) var queryParams = new Dictionary { @@ -340,49 +341,49 @@ public class SpotifyApiClient : IDisposable { "variables", $"{{\"uri\":\"spotify:playlist:{playlistId}\",\"offset\":{offset},\"limit\":{pageLimit}}}" }, { "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"19ff1327c29e99c208c86d7a9d8f1929cfdf3d3202a0ff4253c821f1901aa94d\"}}" } }; - + var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); var url = $"{WebApiBase}/query?{queryString}"; - + var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - + var response = await _webApiClient.SendAsync(request, cancellationToken); - + // Handle 429 rate limiting with exponential backoff if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) { var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5); _logger.LogWarning("Spotify rate limit hit (429) when fetching playlist {PlaylistId}. Waiting {Seconds}s before retry...", playlistId, retryAfter.TotalSeconds); await Task.Delay(retryAfter, cancellationToken); - + // Retry the request response = await _webApiClient.SendAsync(request, cancellationToken); } - + if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to fetch playlist via GraphQL: {StatusCode}", response.StatusCode); return null; } - + var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); - + if (!doc.RootElement.TryGetProperty("data", out var data) || !data.TryGetProperty("playlistV2", out var playlistV2)) { _logger.LogError("Invalid GraphQL response structure"); return null; } - + // Parse playlist metadata on first iteration if (playlist == null) { playlist = ParseGraphQLPlaylist(playlistV2, playlistId); if (playlist == null) return null; } - + // Parse tracks from this page if (playlistV2.TryGetProperty("content", out var content)) { @@ -390,7 +391,7 @@ public class SpotifyApiClient : IDisposable { totalTrackCount = totalCount.GetInt32(); } - + if (content.TryGetProperty("items", out var items)) { foreach (var item in items.EnumerateArray()) @@ -403,41 +404,109 @@ public class SpotifyApiClient : IDisposable } } } - + offset += pageLimit; } - + if (playlist != null) { playlist.Tracks = tracks; playlist.TotalTracks = tracks.Count; + if (!playlist.CreatedAt.HasValue) + { + playlist.CreatedAt = tracks + .Where(t => t.AddedAt.HasValue) + .Select(t => t.AddedAt!.Value.ToUniversalTime()) + .DefaultIfEmpty() + .Min(); + + if (playlist.CreatedAt == default) + { + playlist.CreatedAt = null; + } + } _logger.LogInformation("Fetched playlist '{Name}' with {Count} tracks via GraphQL", playlist.Name, tracks.Count); } - + return playlist; } - + private SpotifyPlaylist? ParseGraphQLPlaylist(JsonElement playlistV2, string playlistId) { try { var name = playlistV2.TryGetProperty("name", out var n) ? n.GetString() : "Unknown Playlist"; var description = playlistV2.TryGetProperty("description", out var d) ? d.GetString() : null; - + + // Parse owner information string? ownerName = null; + string? ownerId = null; if (playlistV2.TryGetProperty("ownerV2", out var owner) && - owner.TryGetProperty("data", out var ownerData) && - ownerData.TryGetProperty("name", out var ownerNameProp)) + owner.TryGetProperty("data", out var ownerData)) { - ownerName = ownerNameProp.GetString(); + if (ownerData.TryGetProperty("name", out var ownerNameProp)) + { + ownerName = ownerNameProp.GetString(); + } + + if (ownerData.TryGetProperty("username", out var usernameProp)) + { + ownerId = usernameProp.GetString(); + } } - + + // Parse playlist image + string? imageUrl = null; + if (playlistV2.TryGetProperty("images", out var images) && + images.TryGetProperty("items", out var imageItems) && + imageItems.GetArrayLength() > 0) + { + var firstImage = imageItems[0]; + if (firstImage.TryGetProperty("sources", out var sources) && + sources.GetArrayLength() > 0) + { + var firstSource = sources[0]; + if (firstSource.TryGetProperty("url", out var urlProp)) + { + imageUrl = urlProp.GetString(); + } + } + } + + // Parse snapshot/revision ID + string? snapshotId = null; + if (playlistV2.TryGetProperty("revisionId", out var revisionIdProp)) + { + snapshotId = revisionIdProp.GetString(); + } + + var createdAt = TryGetSpotifyPlaylistCreatedAt(playlistV2); + + // Parse collaborative and public flags (may not always be present) + bool collaborative = false; + if (playlistV2.TryGetProperty("collaborative", out var collaborativeProp)) + { + collaborative = collaborativeProp.GetBoolean(); + } + + bool isPublic = false; + if (playlistV2.TryGetProperty("public", out var publicProp)) + { + isPublic = publicProp.GetBoolean(); + } + return new SpotifyPlaylist { SpotifyId = playlistId, Name = name ?? "Unknown Playlist", Description = description, OwnerName = ownerName, + OwnerId = ownerId, + ImageUrl = imageUrl, + SnapshotId = snapshotId, + Collaborative = collaborative, + Public = isPublic, + CreatedAt = createdAt, FetchedAt = DateTime.UtcNow, Tracks = new List() }; @@ -448,7 +517,7 @@ public class SpotifyApiClient : IDisposable return null; } } - + private SpotifyPlaylistTrack? ParseGraphQLTrack(JsonElement item, int position) { try @@ -458,17 +527,18 @@ public class SpotifyApiClient : IDisposable { return null; } - + var trackId = data.TryGetProperty("uri", out var uri) ? uri.GetString()?.Replace("spotify:track:", "") : null; var name = data.TryGetProperty("name", out var n) ? n.GetString() : null; - + if (string.IsNullOrEmpty(trackId) || string.IsNullOrEmpty(name)) { return null; } - - // Parse artists + + // Parse artists with IDs var artists = new List(); + var artistIds = new List(); if (data.TryGetProperty("artists", out var artistsObj) && artistsObj.TryGetProperty("items", out var artistItems)) { @@ -483,17 +553,35 @@ public class SpotifyApiClient : IDisposable artists.Add(artistNameStr); } } + + // Extract artist ID + if (artist.TryGetProperty("uri", out var artistUri)) + { + var artistId = artistUri.GetString()?.Replace("spotify:artist:", ""); + if (!string.IsNullOrEmpty(artistId)) + { + artistIds.Add(artistId); + } + } } } - - // Parse album + + // Parse album with ID string? albumName = null; - if (data.TryGetProperty("albumOfTrack", out var album) && - album.TryGetProperty("name", out var albumNameProp)) + string? albumId = null; + if (data.TryGetProperty("albumOfTrack", out var album)) { - albumName = albumNameProp.GetString(); + if (album.TryGetProperty("name", out var albumNameProp)) + { + albumName = albumNameProp.GetString(); + } + + if (album.TryGetProperty("uri", out var albumUri)) + { + albumId = albumUri.GetString()?.Replace("spotify:album:", ""); + } } - + // Parse duration int durationMs = 0; if (data.TryGetProperty("trackDuration", out var duration) && @@ -501,7 +589,7 @@ public class SpotifyApiClient : IDisposable { durationMs = durationMsProp.GetInt32(); } - + // Parse album art string? albumArtUrl = null; if (data.TryGetProperty("albumOfTrack", out var albumOfTrack) && @@ -509,22 +597,84 @@ public class SpotifyApiClient : IDisposable coverArt.TryGetProperty("sources", out var sources) && sources.GetArrayLength() > 0) { - var firstSource = sources[0]; - if (firstSource.TryGetProperty("url", out var urlProp)) + // Get the largest image (usually the last one, but let's find the biggest) + string? largestUrl = null; + int maxSize = 0; + foreach (var source in sources.EnumerateArray()) { - albumArtUrl = urlProp.GetString(); + if (source.TryGetProperty("url", out var urlProp) && + source.TryGetProperty("width", out var widthProp)) + { + var url = urlProp.GetString(); + var width = widthProp.GetInt32(); + if (width > maxSize && !string.IsNullOrEmpty(url)) + { + maxSize = width; + largestUrl = url; + } + } + } + albumArtUrl = largestUrl; + } + + // Parse explicit flag + bool isExplicit = false; + if (data.TryGetProperty("contentRating", out var contentRating) && + contentRating.TryGetProperty("label", out var label)) + { + isExplicit = label.GetString() == "EXPLICIT"; + } + + // Parse track and disc numbers + int trackNumber = 1; + if (data.TryGetProperty("trackNumber", out var trackNumProp)) + { + trackNumber = trackNumProp.GetInt32(); + } + + int discNumber = 1; + if (data.TryGetProperty("discNumber", out var discNumProp)) + { + discNumber = discNumProp.GetInt32(); + } + + // Parse playcount as popularity (convert to 0-100 scale) + int popularity = 0; + if (data.TryGetProperty("playcount", out var playcountProp)) + { + var playcountStr = playcountProp.GetString(); + if (!string.IsNullOrEmpty(playcountStr) && int.TryParse(playcountStr, out var playcount)) + { + // Convert playcount to popularity score (0-100) + // Using logarithmic scale: popularity = min(100, log10(playcount) * 12) + popularity = Math.Min(100, (int)(Math.Log10(Math.Max(1, playcount)) * 12)); } } - + + // Parse addedAt timestamp + DateTime? addedAt = null; + if (item.TryGetProperty("addedAt", out var addedAtObj) && + addedAtObj.TryGetProperty("isoString", out var isoString)) + { + addedAt = ParseSpotifyDateElement(isoString); + } + return new SpotifyPlaylistTrack { SpotifyId = trackId, Title = name, Artists = artists, + ArtistIds = artistIds, Album = albumName ?? string.Empty, + AlbumId = albumId ?? string.Empty, DurationMs = durationMs, Position = position, AlbumArtUrl = albumArtUrl, + Explicit = isExplicit, + TrackNumber = trackNumber, + DiscNumber = discNumber, + Popularity = popularity, + AddedAt = addedAt, Isrc = null // GraphQL doesn't return ISRC, we'll fetch it separately if needed }; } @@ -534,29 +684,29 @@ public class SpotifyApiClient : IDisposable return null; } } - + private async Task FetchPlaylistMetadataAsync( - string playlistId, - string token, + string playlistId, + string token, CancellationToken cancellationToken) { var url = $"{OfficialApiBase}/playlists/{playlistId}?fields=id,name,description,owner(display_name,id),images,collaborative,public,snapshot_id,tracks.total"; - + var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - + var response = await _httpClient.SendAsync(request, cancellationToken); - + if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to fetch playlist metadata: {StatusCode}", response.StatusCode); return null; } - + var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - + var playlist = new SpotifyPlaylist { SpotifyId = root.GetProperty("id").GetString() ?? playlistId, @@ -565,28 +715,29 @@ public class SpotifyApiClient : IDisposable SnapshotId = root.TryGetProperty("snapshot_id", out var snap) ? snap.GetString() : null, Collaborative = root.TryGetProperty("collaborative", out var collab) && collab.GetBoolean(), Public = root.TryGetProperty("public", out var pub) && pub.ValueKind != JsonValueKind.Null && pub.GetBoolean(), + CreatedAt = TryGetSpotifyPlaylistCreatedAt(root), FetchedAt = DateTime.UtcNow }; - + if (root.TryGetProperty("owner", out var owner)) { playlist.OwnerName = owner.TryGetProperty("display_name", out var dn) ? dn.GetString() : null; playlist.OwnerId = owner.TryGetProperty("id", out var oid) ? oid.GetString() : null; } - + if (root.TryGetProperty("images", out var images) && images.GetArrayLength() > 0) { playlist.ImageUrl = images[0].GetProperty("url").GetString(); } - + if (root.TryGetProperty("tracks", out var tracks) && tracks.TryGetProperty("total", out var total)) { playlist.TotalTracks = total.GetInt32(); } - + return playlist; } - + private async Task> FetchAllPlaylistTracksAsync( string playlistId, string token, @@ -595,28 +746,28 @@ public class SpotifyApiClient : IDisposable var allTracks = new List(); var offset = 0; const int limit = 100; // Spotify's max - + while (true) { var tracks = await FetchPlaylistTracksPageAsync(playlistId, token, offset, limit, cancellationToken); if (tracks == null || tracks.Count == 0) break; - + allTracks.AddRange(tracks); - + if (tracks.Count < limit) break; - + offset += limit; - + // Rate limiting if (_settings.RateLimitDelayMs > 0) { await Task.Delay(_settings.RateLimitDelayMs, cancellationToken); } } - + return allTracks; } - + private async Task?> FetchPlaylistTracksPageAsync( string playlistId, string token, @@ -627,60 +778,56 @@ public class SpotifyApiClient : IDisposable // Request fields needed for matching and ordering var fields = "items(added_at,track(id,name,album(id,name,images,release_date),artists(id,name),duration_ms,explicit,popularity,preview_url,disc_number,track_number,external_ids))"; var url = $"{OfficialApiBase}/playlists/{playlistId}/tracks?offset={offset}&limit={limit}&fields={fields}"; - + var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - + var response = await _httpClient.SendAsync(request, cancellationToken); - + if (!response.IsSuccessStatusCode) { _logger.LogError("Failed to fetch playlist tracks: {StatusCode}", response.StatusCode); return null; } - + var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - + if (!root.TryGetProperty("items", out var items)) { return new List(); } - + var tracks = new List(); var position = offset; - + foreach (var item in items.EnumerateArray()) { // Skip null tracks (can happen with deleted/unavailable tracks) - if (!item.TryGetProperty("track", out var trackElement) || + if (!item.TryGetProperty("track", out var trackElement) || trackElement.ValueKind == JsonValueKind.Null) { position++; continue; } - + var track = ParseTrack(trackElement, position); - + // Parse added_at timestamp - if (item.TryGetProperty("added_at", out var addedAt) && + if (item.TryGetProperty("added_at", out var addedAt) && addedAt.ValueKind != JsonValueKind.Null) { - var addedAtStr = addedAt.GetString(); - if (DateTime.TryParse(addedAtStr, out var addedAtDate)) - { - track.AddedAt = addedAtDate; - } + track.AddedAt = ParseSpotifyDateElement(addedAt); } - + tracks.Add(track); position++; } - + return tracks; } - + private SpotifyPlaylistTrack ParseTrack(JsonElement track, int position) { var result = new SpotifyPlaylistTrack @@ -691,28 +838,28 @@ public class SpotifyApiClient : IDisposable DurationMs = track.TryGetProperty("duration_ms", out var dur) ? dur.GetInt32() : 0, Explicit = track.TryGetProperty("explicit", out var exp) && exp.GetBoolean(), Popularity = track.TryGetProperty("popularity", out var pop) ? pop.GetInt32() : 0, - PreviewUrl = track.TryGetProperty("preview_url", out var prev) && prev.ValueKind != JsonValueKind.Null + PreviewUrl = track.TryGetProperty("preview_url", out var prev) && prev.ValueKind != JsonValueKind.Null ? prev.GetString() : null, DiscNumber = track.TryGetProperty("disc_number", out var disc) ? disc.GetInt32() : 1, TrackNumber = track.TryGetProperty("track_number", out var tn) ? tn.GetInt32() : 1 }; - + // Parse album if (track.TryGetProperty("album", out var album)) { - result.Album = album.TryGetProperty("name", out var albumName) + result.Album = album.TryGetProperty("name", out var albumName) ? albumName.GetString() ?? "" : ""; - result.AlbumId = album.TryGetProperty("id", out var albumId) + result.AlbumId = album.TryGetProperty("id", out var albumId) ? albumId.GetString() ?? "" : ""; - result.ReleaseDate = album.TryGetProperty("release_date", out var rd) + result.ReleaseDate = album.TryGetProperty("release_date", out var rd) ? rd.GetString() : null; - + if (album.TryGetProperty("images", out var images) && images.GetArrayLength() > 0) { result.AlbumArtUrl = images[0].GetProperty("url").GetString(); } } - + // Parse artists if (track.TryGetProperty("artists", out var artists)) { @@ -728,28 +875,28 @@ public class SpotifyApiClient : IDisposable } } } - + // Parse ISRC from external_ids if (track.TryGetProperty("external_ids", out var externalIds) && externalIds.TryGetProperty("isrc", out var isrc)) { result.Isrc = isrc.GetString(); } - + return result; } - + /// /// Searches for a user's playlists by name. /// Useful for finding playlists like "Release Radar" or "Discover Weekly" by their names. /// public async Task> SearchUserPlaylistsAsync( - string searchName, + string searchName, CancellationToken cancellationToken = default) { return await GetUserPlaylistsAsync(searchName, cancellationToken); } - + /// /// Gets all playlists from the user's library, optionally filtered by name. /// Uses GraphQL API which is less rate-limited than REST API. @@ -764,7 +911,7 @@ public class SpotifyApiClient : IDisposable { return new List(); } - + try { // Use GraphQL endpoint instead of REST API to avoid rate limiting @@ -772,7 +919,7 @@ public class SpotifyApiClient : IDisposable var playlists = new List(); var offset = 0; const int limit = 50; - + while (true) { // GraphQL query to fetch user playlists - using libraryV3 operation @@ -782,36 +929,36 @@ public class SpotifyApiClient : IDisposable { "variables", $"{{\"filters\":[\"Playlists\",\"By Spotify\"],\"order\":null,\"textFilter\":\"\",\"features\":[\"LIKED_SONGS\",\"YOUR_EPISODES\"],\"offset\":{offset},\"limit\":{limit}}}" }, { "extensions", "{\"persistedQuery\":{\"version\":1,\"sha256Hash\":\"50650f72ea32a99b5b46240bee22fea83024eec302478a9a75cfd05a0814ba99\"}}" } }; - + var queryString = string.Join("&", queryParams.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); var url = $"{WebApiBase}/query?{queryString}"; - + var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - + var response = await _webApiClient.SendAsync(request, cancellationToken); - + // Handle 429 rate limiting with exponential backoff if (response.StatusCode == System.Net.HttpStatusCode.TooManyRequests) { var retryAfter = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5); _logger.LogWarning("Spotify rate limit hit (429) when fetching library playlists. Waiting {Seconds}s before retry...", retryAfter.TotalSeconds); await Task.Delay(retryAfter, cancellationToken); - + // Retry the request response = await _httpClient.SendAsync(request, cancellationToken); } - + if (!response.IsSuccessStatusCode) { _logger.LogError("GraphQL user playlists request failed: {StatusCode}", response.StatusCode); break; } - + var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - + if (!root.TryGetProperty("data", out var data) || !data.TryGetProperty("me", out var me) || !me.TryGetProperty("libraryV3", out var library) || @@ -819,25 +966,25 @@ public class SpotifyApiClient : IDisposable { break; } - + // Get total count if (library.TryGetProperty("totalCount", out var totalCount)) { var total = totalCount.GetInt32(); if (total == 0) break; } - + var itemCount = 0; foreach (var item in items.EnumerateArray()) { itemCount++; - + if (!item.TryGetProperty("item", out var playlistItem) || !playlistItem.TryGetProperty("data", out var playlist)) { continue; } - + // Check __typename to filter out folders and only include playlists if (playlistItem.TryGetProperty("__typename", out var typename)) { @@ -848,7 +995,7 @@ public class SpotifyApiClient : IDisposable continue; } } - + // Get playlist URI/ID string? uri = null; if (playlistItem.TryGetProperty("uri", out var uriProp)) @@ -859,26 +1006,26 @@ public class SpotifyApiClient : IDisposable { uri = uriProp2.GetString(); } - + if (string.IsNullOrEmpty(uri)) continue; - + // Skip if not a playlist URI (e.g., folders have different URI format) if (!uri.StartsWith("spotify:playlist:", StringComparison.OrdinalIgnoreCase)) { continue; } - + var spotifyId = uri.Replace("spotify:playlist:", "", StringComparison.OrdinalIgnoreCase); - + var itemName = playlist.TryGetProperty("name", out var n) ? n.GetString() ?? "" : ""; - + // Check if name matches (case-insensitive) - if searchName is provided - if (!string.IsNullOrEmpty(searchName) && + if (!string.IsNullOrEmpty(searchName) && !itemName.Contains(searchName, StringComparison.OrdinalIgnoreCase)) { continue; } - + // Get track count if available - try multiple possible paths var trackCount = 0; if (playlist.TryGetProperty("content", out var content)) @@ -899,14 +1046,14 @@ public class SpotifyApiClient : IDisposable { trackCount = directTotalCount.GetInt32(); } - + // Log if we couldn't find track count for debugging if (trackCount == 0) { - _logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}", + _logger.LogDebug("Could not find track count for playlist {Name} (ID: {Id}). Response structure: {Json}", itemName, spotifyId, playlist.GetRawText()); } - + // Get owner name string? ownerName = null; if (playlist.TryGetProperty("ownerV2", out var ownerV2) && @@ -915,7 +1062,7 @@ public class SpotifyApiClient : IDisposable { ownerName = ownerNameProp.GetString(); } - + // Get image URL string? imageUrl = null; if (playlist.TryGetProperty("images", out var images) && @@ -933,7 +1080,7 @@ public class SpotifyApiClient : IDisposable } } } - + playlists.Add(new SpotifyPlaylist { SpotifyId = spotifyId, @@ -942,33 +1089,172 @@ public class SpotifyApiClient : IDisposable TotalTracks = trackCount, OwnerName = ownerName, ImageUrl = imageUrl, - SnapshotId = null + SnapshotId = null, + CreatedAt = TryGetSpotifyPlaylistCreatedAt(playlist) }); } - + if (itemCount < limit) break; offset += limit; - + // Add delay between pages to avoid rate limiting // Library fetching can be aggressive, so use a longer delay var delayMs = Math.Max(_settings.RateLimitDelayMs, 500); // Minimum 500ms between pages _logger.LogDebug("Waiting {DelayMs}ms before fetching next page of library playlists...", delayMs); await Task.Delay(delayMs, cancellationToken); } - - _logger.LogDebug("Found {Count} playlists{Filter} via GraphQL", - playlists.Count, + + _logger.LogDebug("Found {Count} playlists{Filter} via GraphQL", + playlists.Count, string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'"); return playlists; } catch (Exception ex) { - _logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL", + _logger.LogError(ex, "Error fetching user playlists{Filter} via GraphQL", string.IsNullOrEmpty(searchName) ? "" : $" matching '{searchName}'"); return new List(); } } - + + private static DateTime? TryGetSpotifyPlaylistCreatedAt(JsonElement playlistElement) + { + // Direct fields we may see across Spotify APIs. + foreach (var candidateField in new[] { "createdAt", "created_at", "creationDate", "dateCreated" }) + { + if (playlistElement.TryGetProperty(candidateField, out var candidate)) + { + var parsed = ParseSpotifyDateElement(candidate); + if (parsed.HasValue) + { + return parsed.Value; + } + } + } + + // GraphQL attributes as key/value entries. + if (playlistElement.TryGetProperty("attributes", out var attributes) && attributes.ValueKind == JsonValueKind.Array) + { + foreach (var attribute in attributes.EnumerateArray()) + { + if (!attribute.TryGetProperty("key", out var keyProp) || + !attribute.TryGetProperty("value", out var valueProp)) + { + continue; + } + + var key = keyProp.GetString(); + if (string.IsNullOrWhiteSpace(key)) + { + continue; + } + + if (!key.Contains("created", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var parsed = ParseSpotifyDateElement(valueProp); + if (parsed.HasValue) + { + return parsed.Value; + } + } + } + + return null; + } + + private static DateTime? ParseSpotifyDateElement(JsonElement value) + { + switch (value.ValueKind) + { + case JsonValueKind.String: + { + var stringValue = value.GetString(); + return ParseSpotifyDateString(stringValue); + } + case JsonValueKind.Number: + { + if (value.TryGetInt64(out var numericValue)) + { + return ParseSpotifyUnixTimestamp(numericValue); + } + + return null; + } + case JsonValueKind.Object: + { + // Common GraphQL style: { "isoString": "..." } + if (value.TryGetProperty("isoString", out var isoString)) + { + return ParseSpotifyDateElement(isoString); + } + + if (value.TryGetProperty("value", out var nestedValue)) + { + return ParseSpotifyDateElement(nestedValue); + } + + if (value.TryGetProperty("timestampMs", out var timestampMs)) + { + return ParseSpotifyDateElement(timestampMs); + } + + if (value.TryGetProperty("milliseconds", out var milliseconds)) + { + return ParseSpotifyDateElement(milliseconds); + } + + return null; + } + default: + return null; + } + } + + private static DateTime? ParseSpotifyDateString(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return null; + } + + if (DateTimeOffset.TryParse( + value, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out var parsedDateTimeOffset)) + { + return parsedDateTimeOffset.UtcDateTime; + } + + // Some attributes expose Unix timestamps as strings. + if (long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var timestamp)) + { + return ParseSpotifyUnixTimestamp(timestamp); + } + + return null; + } + + private static DateTime? ParseSpotifyUnixTimestamp(long value) + { + try + { + // Heuristic: values above this threshold are milliseconds. + var isMilliseconds = value > 10_000_000_000; + var utcDate = isMilliseconds + ? DateTimeOffset.FromUnixTimeMilliseconds(value).UtcDateTime + : DateTimeOffset.FromUnixTimeSeconds(value).UtcDateTime; + return utcDate; + } + catch + { + return null; + } + } + /// /// Gets the current user's profile to verify authentication is working. /// @@ -980,28 +1266,28 @@ public class SpotifyApiClient : IDisposable { return (false, null, null); } - + try { var request = new HttpRequestMessage(HttpMethod.Get, $"{OfficialApiBase}/me"); request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - + var response = await _httpClient.SendAsync(request, cancellationToken); - + if (!response.IsSuccessStatusCode) { var errorBody = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogWarning("Spotify /me endpoint returned {StatusCode}: {Body}", response.StatusCode, errorBody); return (false, null, null); } - + var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var root = doc.RootElement; - + var userId = root.TryGetProperty("id", out var id) ? id.GetString() : null; var displayName = root.TryGetProperty("display_name", out var dn) ? dn.GetString() : null; - + return (true, userId, displayName); } catch (Exception ex) @@ -1010,17 +1296,17 @@ public class SpotifyApiClient : IDisposable return (false, null, null); } } - + private static string ExtractPlaylistId(string input) { if (string.IsNullOrEmpty(input)) return input; - + // Handle spotify:playlist:xxxxx format if (input.StartsWith("spotify:playlist:")) { return input.Substring("spotify:playlist:".Length); } - + // Handle https://open.spotify.com/playlist/xxxxx format if (input.Contains("open.spotify.com/playlist/")) { @@ -1028,38 +1314,38 @@ public class SpotifyApiClient : IDisposable var end = input.IndexOf('?', start); return end > 0 ? input.Substring(start, end - start) : input.Substring(start); } - + return input; } - + public void Dispose() { _httpClient.Dispose(); _webApiClient.Dispose(); _tokenLock.Dispose(); } - + // Internal classes for JSON deserialization private class SpotifyTokenResponse { [JsonPropertyName("accessToken")] public string AccessToken { get; set; } = string.Empty; - + [JsonPropertyName("accessTokenExpirationTimestampMs")] public long ExpirationTimestampMs { get; set; } - + [JsonPropertyName("isAnonymous")] public bool IsAnonymous { get; set; } - + [JsonPropertyName("clientId")] public string ClientId { get; set; } = string.Empty; } - + private class TotpSecret { [JsonPropertyName("version")] public int Version { get; set; } - + [JsonPropertyName("secret")] public List Secret { get; set; } = new(); } diff --git a/allstarr/Services/Spotify/SpotifyApiClientFactory.cs b/allstarr/Services/Spotify/SpotifyApiClientFactory.cs new file mode 100644 index 0000000..e853beb --- /dev/null +++ b/allstarr/Services/Spotify/SpotifyApiClientFactory.cs @@ -0,0 +1,39 @@ +using allstarr.Models.Settings; +using Microsoft.Extensions.Options; + +namespace allstarr.Services.Spotify; + +/// +/// Creates SpotifyApiClient instances bound to a specific session cookie. +/// +public class SpotifyApiClientFactory +{ + private readonly ILoggerFactory _loggerFactory; + private readonly SpotifyApiSettings _baseSettings; + + public SpotifyApiClientFactory( + ILoggerFactory loggerFactory, + IOptions settings) + { + _loggerFactory = loggerFactory; + _baseSettings = settings.Value; + } + + public SpotifyApiClient Create(string sessionCookie) + { + var scopedSettings = new SpotifyApiSettings + { + Enabled = _baseSettings.Enabled, + SessionCookie = sessionCookie, + CacheDurationMinutes = _baseSettings.CacheDurationMinutes, + RateLimitDelayMs = _baseSettings.RateLimitDelayMs, + PreferIsrcMatching = _baseSettings.PreferIsrcMatching, + SessionCookieSetDate = _baseSettings.SessionCookieSetDate, + LyricsApiUrl = _baseSettings.LyricsApiUrl + }; + + return new SpotifyApiClient( + _loggerFactory.CreateLogger(), + Options.Create(scopedSettings)); + } +} diff --git a/allstarr/Services/Spotify/SpotifyMappingService.cs b/allstarr/Services/Spotify/SpotifyMappingService.cs index 9de7757..dec7bdf 100644 --- a/allstarr/Services/Spotify/SpotifyMappingService.cs +++ b/allstarr/Services/Spotify/SpotifyMappingService.cs @@ -12,8 +12,6 @@ public class SpotifyMappingService { private readonly RedisCacheService _cache; private readonly ILogger _logger; - private const string MappingKeyPrefix = "spotify:global-map:"; - private const string AllMappingsKey = "spotify:global-map:all-ids"; public SpotifyMappingService( RedisCacheService cache, @@ -28,14 +26,14 @@ public class SpotifyMappingService /// public async Task GetMappingAsync(string spotifyId) { - var key = $"{MappingKeyPrefix}{spotifyId}"; + var key = CacheKeyBuilder.BuildSpotifyGlobalMappingKey(spotifyId); var mapping = await _cache.GetAsync(key); - + if (mapping != null) { _logger.LogDebug("Found mapping for Spotify ID {SpotifyId}: {TargetType}", spotifyId, mapping.TargetType); } - + return mapping; } @@ -59,69 +57,69 @@ public class SpotifyMappingService return false; } - if (mapping.TargetType == "external" && + if (mapping.TargetType == "external" && (string.IsNullOrEmpty(mapping.ExternalProvider) || string.IsNullOrEmpty(mapping.ExternalId))) { _logger.LogWarning("Cannot save external mapping: ExternalProvider and ExternalId are required"); return false; } - var key = $"{MappingKeyPrefix}{mapping.SpotifyId}"; - + var key = CacheKeyBuilder.BuildSpotifyGlobalMappingKey(mapping.SpotifyId); + // Check if mapping already exists var existingMapping = await GetMappingAsync(mapping.SpotifyId); - + // RULE 1: Never overwrite manual mappings with auto mappings - if (existingMapping != null && - existingMapping.Source == "manual" && + if (existingMapping != null && + existingMapping.Source == "manual" && mapping.Source == "auto") { _logger.LogDebug("Skipping auto mapping for {SpotifyId} - manual mapping exists", mapping.SpotifyId); return false; } - + // RULE 2: Local always wins over external (even if existing is manual external) - if (existingMapping != null && - existingMapping.TargetType == "external" && + if (existingMapping != null && + existingMapping.TargetType == "external" && mapping.TargetType == "local") { _logger.LogInformation("🎉 UPGRADING: External → Local for {SpotifyId}", mapping.SpotifyId); // Allow the upgrade to proceed } - + // RULE 3: Don't downgrade local to external - if (existingMapping != null && - existingMapping.TargetType == "local" && + if (existingMapping != null && + existingMapping.TargetType == "local" && mapping.TargetType == "external") { _logger.LogDebug("Skipping external mapping for {SpotifyId} - local mapping exists", mapping.SpotifyId); return false; } - + // Set timestamps if (mapping.CreatedAt == default) { mapping.CreatedAt = DateTime.UtcNow; } - + // Preserve CreatedAt from existing mapping if (existingMapping != null) { mapping.CreatedAt = existingMapping.CreatedAt; } - + // Save mapping (permanent - no TTL) var success = await _cache.SetAsync(key, mapping, expiry: null); - + if (success) { // Add to set of all mapping IDs for enumeration await AddToAllMappingsSetAsync(mapping.SpotifyId); - + // Invalidate ALL playlist stats caches since this mapping could affect any playlist // This ensures the stats are recalculated on next request await InvalidateAllPlaylistStatsCachesAsync(); - + _logger.LogInformation( "Saved {Source} mapping: Spotify {SpotifyId} → {TargetType} {TargetId}", mapping.Source, @@ -130,7 +128,7 @@ public class SpotifyMappingService mapping.TargetType == "local" ? mapping.LocalId : $"{mapping.ExternalProvider}:{mapping.ExternalId}" ); } - + return success; } @@ -210,15 +208,15 @@ public class SpotifyMappingService /// public async Task DeleteMappingAsync(string spotifyId) { - var key = $"{MappingKeyPrefix}{spotifyId}"; + var key = CacheKeyBuilder.BuildSpotifyGlobalMappingKey(spotifyId); var success = await _cache.DeleteAsync(key); - + if (success) { await RemoveFromAllMappingsSetAsync(spotifyId); _logger.LogInformation("Deleted mapping for Spotify ID {SpotifyId}", spotifyId); } - + return success; } @@ -227,7 +225,7 @@ public class SpotifyMappingService /// public async Task> GetAllMappingIdsAsync() { - var json = await _cache.GetStringAsync(AllMappingsKey); + var json = await _cache.GetStringAsync(CacheKeyBuilder.BuildSpotifyGlobalMappingsIndexKey()); if (string.IsNullOrEmpty(json)) { return new List(); @@ -251,9 +249,9 @@ public class SpotifyMappingService { var allIds = await GetAllMappingIdsAsync(); var pagedIds = allIds.Skip(skip).Take(take).ToList(); - + var mappings = new List(); - + foreach (var spotifyId in pagedIds) { var mapping = await GetMappingAsync(spotifyId); @@ -262,7 +260,7 @@ public class SpotifyMappingService mappings.Add(mapping); } } - + return mappings; } @@ -288,7 +286,7 @@ public class SpotifyMappingService // Sample first 1000 to get stats (avoid loading all mappings) var sampleIds = allIds.Take(1000).ToList(); - + foreach (var spotifyId in sampleIds) { var mapping = await GetMappingAsync(spotifyId); @@ -330,23 +328,23 @@ public class SpotifyMappingService private async Task AddToAllMappingsSetAsync(string spotifyId) { var allIds = await GetAllMappingIdsAsync(); - + if (!allIds.Contains(spotifyId)) { allIds.Add(spotifyId); var json = JsonSerializer.Serialize(allIds); - await _cache.SetStringAsync(AllMappingsKey, json, expiry: null); + await _cache.SetStringAsync(CacheKeyBuilder.BuildSpotifyGlobalMappingsIndexKey(), json, expiry: null); } } private async Task RemoveFromAllMappingsSetAsync(string spotifyId) { var allIds = await GetAllMappingIdsAsync(); - + if (allIds.Remove(spotifyId)) { var json = JsonSerializer.Serialize(allIds); - await _cache.SetStringAsync(AllMappingsKey, json, expiry: null); + await _cache.SetStringAsync(CacheKeyBuilder.BuildSpotifyGlobalMappingsIndexKey(), json, expiry: null); } } @@ -358,14 +356,14 @@ public class SpotifyMappingService { try { - // Delete all keys matching the pattern "spotify:playlist:stats:*" + // Delete all keys matching the pattern from CacheKeyBuilder (currently not enumerated). // Note: This is a simple implementation that deletes known patterns // In production, you might want to track playlist names or use Redis SCAN - + // For now, we'll just log that stats should be recalculated // The stats will be recalculated on next request since they check global mappings _logger.LogDebug("Mapping changed - playlist stats will be recalculated on next request"); - + // Optionally: Delete the admin playlist summary cache to force immediate refresh var summaryFile = "/app/cache/admin_playlists_summary.json"; if (File.Exists(summaryFile)) diff --git a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs index 6ef6a5d..d00eadb 100644 --- a/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs +++ b/allstarr/Services/Spotify/SpotifyMissingTracksFetcher.cs @@ -16,6 +16,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService private readonly RedisCacheService _cache; private readonly ILogger _logger; private readonly IServiceProvider _serviceProvider; + private readonly SpotifySessionCookieService _spotifySessionCookieService; private bool _hasRunOnce = false; private Dictionary _playlistIdToName = new(); private const string CacheDirectory = "/app/cache/spotify"; @@ -27,6 +28,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService IHttpClientFactory httpClientFactory, RedisCacheService cache, IServiceProvider serviceProvider, + SpotifySessionCookieService spotifySessionCookieService, ILogger logger) { _spotifySettings = spotifySettings; @@ -35,6 +37,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService _httpClientFactory = httpClientFactory; _cache = cache; _serviceProvider = serviceProvider; + _spotifySessionCookieService = spotifySessionCookieService; _logger = logger; } @@ -51,20 +54,21 @@ public class SpotifyMissingTracksFetcher : BackgroundService { _logger.LogInformation("========================================"); _logger.LogInformation("SpotifyMissingTracksFetcher: Starting up..."); - + // Ensure cache directory exists Directory.CreateDirectory(CacheDirectory); - - // Check if SpotifyApi is enabled with a valid session cookie - // If so, SpotifyPlaylistFetcher will handle everything - we don't need to scrape Jellyfin - if (_spotifyApiSettings.Value.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.Value.SessionCookie)) + + // If Spotify API has any configured cookie (global or user-scoped), + // SpotifyPlaylistFetcher handles playlist loading and this legacy scraper can stay dormant. + if (_spotifyApiSettings.Value.Enabled && + await _spotifySessionCookieService.HasAnyConfiguredCookieAsync()) { - _logger.LogInformation("SpotifyApi is enabled with session cookie - using direct Spotify API instead of Jellyfin scraping"); + _logger.LogInformation("SpotifyApi has configured session cookie(s) - using direct Spotify API instead of Jellyfin scraping"); _logger.LogDebug("This service will remain dormant. SpotifyPlaylistFetcher is handling playlists."); _logger.LogInformation("========================================"); return; } - + if (!_spotifySettings.Value.Enabled) { _logger.LogInformation("Spotify playlist injection is DISABLED"); @@ -85,10 +89,10 @@ public class SpotifyMissingTracksFetcher : BackgroundService _logger.LogInformation("Spotify Import ENABLED"); _logger.LogInformation("Configured Playlists: {Count}", _spotifySettings.Value.Playlists.Count); _logger.LogInformation("Background check interval: 5 minutes"); - + // Fetch playlist names from Jellyfin await LoadPlaylistNamesAsync(); - + _logger.LogInformation("Configured Playlists:"); foreach (var kvp in _playlistIdToName) { @@ -145,17 +149,17 @@ public class SpotifyMissingTracksFetcher : BackgroundService // Check if we have recent cache files (within last 24 hours) var now = DateTime.UtcNow; var cacheThreshold = now.AddHours(-24); - + foreach (var playlistName in _playlistIdToName.Values) { var filePath = GetCacheFilePath(playlistName); - + if (!File.Exists(filePath)) { // Missing cache file for this playlist return true; } - + var fileTime = File.GetLastWriteTimeUtc(filePath); if (fileTime < cacheThreshold) { @@ -163,7 +167,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService return true; } } - + // All playlists have recent cache files return false; } @@ -171,7 +175,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService private async Task LoadPlaylistNamesAsync() { _playlistIdToName.Clear(); - + // Use configured playlists foreach (var playlist in _spotifySettings.Value.Playlists) { @@ -182,20 +186,20 @@ public class SpotifyMissingTracksFetcher : BackgroundService private async Task ShouldRunOnStartupAsync() { _logger.LogInformation("=== STARTUP CACHE CHECK ==="); - + var allPlaylistsHaveCache = true; - + foreach (var playlistName in _playlistIdToName.Values) { var filePath = GetCacheFilePath(playlistName); var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); - + // Check file cache if (File.Exists(filePath)) { var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath); _logger.LogDebug(" {Playlist}: Found file cache (age: {Age:F1}h)", playlistName, fileAge.TotalHours); - + // Load into Redis if not already there if (!await _cache.ExistsAsync(cacheKey)) { @@ -203,35 +207,35 @@ public class SpotifyMissingTracksFetcher : BackgroundService } continue; } - + // Check Redis cache if (await _cache.ExistsAsync(cacheKey)) { _logger.LogDebug(" {Playlist}: Found in Redis cache", playlistName); continue; } - + // No cache found for this playlist _logger.LogInformation(" {Playlist}: No cache found", playlistName); allPlaylistsHaveCache = false; } - + if (allPlaylistsHaveCache) { _logger.LogWarning("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ==="); return false; } - + _logger.LogInformation("=== WILL FETCH ON STARTUP ==="); 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 @@ -239,18 +243,18 @@ public class SpotifyMissingTracksFetcher : BackgroundService var filePath = GetCacheFilePath(playlistName); if (!File.Exists(filePath)) return; - + var json = await File.ReadAllTextAsync(filePath); var tracks = JsonSerializer.Deserialize>(json); - + if (tracks != null && tracks.Count > 0) { var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath); - + // No expiration - cache persists until next Jellyfin job generates new file await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); - _logger.LogDebug("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)", + _logger.LogDebug("Loaded {Count} tracks from file cache for {Playlist} (age: {Age:F1}h, no expiration)", tracks.Count, playlistName, fileAge.TotalHours); } } @@ -259,7 +263,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService _logger.LogError(ex, "Failed to load file cache for {Playlist}", playlistName); } } - + private async Task SaveToFileCache(string playlistName, List tracks) { try @@ -267,7 +271,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService var filePath = GetCacheFilePath(playlistName); var json = JsonSerializer.Serialize(tracks, new JsonSerializerOptions { WriteIndented = true }); await File.WriteAllTextAsync(filePath, json); - _logger.LogDebug("Saved {Count} tracks to file cache for {Playlist}", + _logger.LogDebug("Saved {Count} tracks to file cache for {Playlist}", tracks.Count, playlistName); } catch (Exception ex) @@ -280,16 +284,16 @@ public class SpotifyMissingTracksFetcher : BackgroundService { _logger.LogInformation("=== FETCHING MISSING TRACKS ==="); _logger.LogDebug("Processing {Count} playlists", _playlistIdToName.Count); - + // Track when we find files to optimize search for other playlists DateTime? firstFoundTime = null; var foundPlaylists = new HashSet(); - + foreach (var kvp in _playlistIdToName) { _logger.LogInformation("Fetching playlist: {Name}", kvp.Value); var foundTime = await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken, firstFoundTime); - + if (foundTime.HasValue) { foundPlaylists.Add(kvp.Value); @@ -300,8 +304,8 @@ public class SpotifyMissingTracksFetcher : BackgroundService } } } - - _logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ({Found}/{Total} playlists found) ===", + + _logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ({Found}/{Total} playlists found) ===", foundPlaylists.Count, _playlistIdToName.Count); } @@ -311,17 +315,17 @@ public class SpotifyMissingTracksFetcher : BackgroundService DateTime? hintTime = null) { var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); - + // Check if we have existing cache var existingTracks = await _cache.GetAsync>(cacheKey); var filePath = GetCacheFilePath(playlistName); - + if (File.Exists(filePath)) { var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath); _logger.LogInformation(" Existing cache file age: {Age:F1}h", fileAge.TotalHours); } - + if (existingTracks != null && existingTracks.Count > 0) { _logger.LogDebug(" Current cache has {Count} tracks, will search for newer file", existingTracks.Count); @@ -330,37 +334,37 @@ public class SpotifyMissingTracksFetcher : BackgroundService { _logger.LogDebug(" No existing cache, 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 null; } - + var httpClient = _httpClientFactory.CreateClient(); - + // Search starting from 24 hours ahead, going backwards for 72 hours // This handles timezone differences where the plugin may have run "in the future" from our perspective var now = DateTime.UtcNow; var searchStart = now.AddHours(24); // Start 24 hours from now var totalMinutesToSearch = 72 * 60; // 72 hours = 4320 minutes - + _logger.LogInformation(" Current UTC time: {Now:yyyy-MM-dd HH:mm}", now); _logger.LogInformation(" Search start: {Start:yyyy-MM-dd HH:mm} (24h ahead)", searchStart); _logger.LogInformation(" Searching backwards for 72 hours ({Minutes} minutes)", totalMinutesToSearch); var found = false; DateTime? foundFileTime = null; - + // If we have a hint time from another playlist, search ±1 hour around it first if (hintTime.HasValue) { _logger.LogInformation(" Hint: Searching ±1h around {Time:yyyy-MM-dd HH:mm} (from another playlist)", hintTime.Value); - + // Search ±60 minutes around the hint time for (var minuteOffset = 0; minuteOffset <= 60; minuteOffset++) { @@ -380,7 +384,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService return foundFileTime; } } - + // Try backward var timeBackward = hintTime.Value.AddMinutes(-minuteOffset); var resultBackward = await TryFetchMissingTracksFile(playlistName, timeBackward, jellyfinUrl, apiKey, httpClient, cancellationToken); @@ -392,20 +396,20 @@ public class SpotifyMissingTracksFetcher : BackgroundService return foundFileTime; } } - + _logger.LogInformation(" Not found within ±1h of hint, doing full search..."); } - + // Search from 24h ahead, going backwards minute by minute for 72 hours - _logger.LogInformation(" Searching from {Start:yyyy-MM-dd HH:mm} backwards to {End:yyyy-MM-dd HH:mm}...", + _logger.LogInformation(" Searching from {Start:yyyy-MM-dd HH:mm} backwards to {End:yyyy-MM-dd HH:mm}...", searchStart, searchStart.AddMinutes(-totalMinutesToSearch)); - + for (var minutesBehind = 0; minutesBehind <= totalMinutesToSearch; minutesBehind++) { if (cancellationToken.IsCancellationRequested) break; var time = searchStart.AddMinutes(-minutesBehind); - + var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken); if (result.found) { @@ -413,18 +417,18 @@ public class SpotifyMissingTracksFetcher : BackgroundService foundFileTime = result.fileTime; return foundFileTime; } - + // Small delay every 60 requests to avoid rate limiting if (minutesBehind > 0 && minutesBehind % 60 == 0) { await Task.Delay(TimeSpan.FromMilliseconds(100), cancellationToken); } } - + if (!found) { _logger.LogWarning(" ✗ Could not find new missing tracks file (searched +24h forward, -48h backward)"); - + // Keep the existing cache - don't let it expire if (existingTracks != null && existingTracks.Count > 0) { @@ -440,7 +444,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService { var json = await File.ReadAllTextAsync(filePath, cancellationToken); var tracks = JsonSerializer.Deserialize>(json); - + if (tracks != null && tracks.Count > 0) { await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); // No expiration @@ -457,7 +461,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService _logger.LogWarning(" No existing cache to keep - playlist will be empty until tracks are found"); } } - + return foundFileTime; } @@ -477,22 +481,22 @@ public class SpotifyMissingTracksFetcher : BackgroundService { // Log every request with the actual filename _logger.LogDebug("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm")); - + var response = await httpClient.GetAsync(url, cancellationToken); if (response.IsSuccessStatusCode) { var json = await response.Content.ReadAsStringAsync(cancellationToken); var tracks = ParseMissingTracks(json); - + if (tracks.Count > 0) { var cacheKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); - + // Save to both Redis and file with extended TTL until next job runs // Set to 365 days (effectively no expiration) - will be replaced when Jellyfin generates new file await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromDays(365)); await SaveToFileCache(playlistName, tracks); - + _logger.LogInformation( "✓ FOUND! Cached {Count} missing tracks for {Playlist} from {Filename}", tracks.Count, playlistName, filename); @@ -504,18 +508,18 @@ public class SpotifyMissingTracksFetcher : BackgroundService { _logger.LogError(ex, "Failed to fetch {Filename}", filename); } - + return (false, null); } private List ParseMissingTracks(string json) { var tracks = new List(); - + try { var doc = JsonDocument.Parse(json); - + foreach (var item in doc.RootElement.EnumerateArray()) { var track = new MissingTrack @@ -529,7 +533,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService .Where(a => !string.IsNullOrEmpty(a)) .ToList() }; - + if (!string.IsNullOrEmpty(track.Title)) { tracks.Add(track); @@ -540,7 +544,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService { _logger.LogError(ex, "Failed to parse missing tracks JSON"); } - + return tracks; } } diff --git a/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs index b427d12..5e52a96 100644 --- a/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs +++ b/allstarr/Services/Spotify/SpotifyPlaylistFetcher.cs @@ -2,20 +2,19 @@ using allstarr.Models.Settings; using allstarr.Models.Spotify; using allstarr.Services.Common; using Microsoft.Extensions.Options; -using System.Text.Json; using Cronos; namespace allstarr.Services.Spotify; /// /// Background service that fetches playlist tracks directly from Spotify's API. -/// +/// /// This replaces the Jellyfin Spotify Import plugin dependency with key advantages: /// - Track ordering is preserved (critical for playlists like Release Radar) /// - ISRC codes available for exact matching /// - Real-time data without waiting for plugin sync schedules /// - Full track metadata (duration, release date, etc.) -/// +/// /// CRON SCHEDULING: Playlists are fetched based on their cron schedules, not a global interval. /// Cache persists until next cron run to prevent excess Spotify API calls. /// @@ -25,27 +24,31 @@ public class SpotifyPlaylistFetcher : BackgroundService private readonly SpotifyApiSettings _spotifyApiSettings; private readonly SpotifyImportSettings _spotifyImportSettings; private readonly SpotifyApiClient _spotifyClient; + private readonly SpotifyApiClientFactory _spotifyClientFactory; + private readonly SpotifySessionCookieService _spotifySessionCookieService; private readonly RedisCacheService _cache; - - private const string CacheKeyPrefix = "spotify:playlist:"; - + // Track Spotify playlist IDs after discovery private readonly Dictionary _playlistNameToSpotifyId = new(); - + public SpotifyPlaylistFetcher( ILogger logger, IOptions spotifyApiSettings, IOptions spotifyImportSettings, SpotifyApiClient spotifyClient, + SpotifyApiClientFactory spotifyClientFactory, + SpotifySessionCookieService spotifySessionCookieService, RedisCacheService cache) { _logger = logger; _spotifyApiSettings = spotifyApiSettings.Value; _spotifyImportSettings = spotifyImportSettings.Value; _spotifyClient = spotifyClient; + _spotifyClientFactory = spotifyClientFactory; + _spotifySessionCookieService = spotifySessionCookieService; _cache = cache; } - + /// /// Gets the Spotify playlist tracks in order, using cache if available. /// Cache persists until next cron run to prevent excess API calls. @@ -54,29 +57,29 @@ public class SpotifyPlaylistFetcher : BackgroundService /// List of tracks in playlist order, or empty list if not found public async Task> GetPlaylistTracksAsync(string playlistName) { - var cacheKey = $"{CacheKeyPrefix}{playlistName}"; - + var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName); + var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName); + // Try Redis cache first var cached = await _cache.GetAsync(cacheKey); if (cached != null && cached.Tracks.Count > 0) { var age = DateTime.UtcNow - cached.FetchedAt; - + // Calculate if cache should still be valid based on cron schedule - var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName); var shouldRefresh = false; - + if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.SyncSchedule)) { try { var cron = CronExpression.Parse(playlistConfig.SyncSchedule); var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc); - + if (nextRun.HasValue && DateTime.UtcNow >= nextRun.Value) { shouldRefresh = true; - _logger.LogWarning("Cache expired for '{Name}' - next cron run was at {NextRun} UTC", + _logger.LogWarning("Cache expired for '{Name}' - next cron run was at {NextRun} UTC", playlistName, nextRun.Value); } } @@ -91,93 +94,127 @@ public class SpotifyPlaylistFetcher : BackgroundService // No cron schedule, use cache duration from settings shouldRefresh = age.TotalMinutes >= _spotifyApiSettings.CacheDurationMinutes; } - + if (!shouldRefresh) { - _logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)", + _logger.LogDebug("Using cached playlist '{Name}' ({Count} tracks, age: {Age:F1}m)", playlistName, cached.Tracks.Count, age.TotalMinutes); return cached.Tracks; } } - + // Cache miss or expired - need to fetch fresh from Spotify - // Try to use cached or configured Spotify playlist ID - if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId)) + var sessionCookie = await _spotifySessionCookieService.ResolveSessionCookieAsync(playlistConfig?.UserId); + if (string.IsNullOrWhiteSpace(sessionCookie)) { - // Check if we have a configured Spotify ID for this playlist - var config = _spotifyImportSettings.GetPlaylistByName(playlistName); - if (config != null && !string.IsNullOrEmpty(config.Id)) + _logger.LogWarning("No Spotify session cookie configured for playlist '{Name}' (user scope: {UserId})", + playlistName, playlistConfig?.UserId ?? "(global)"); + return cached?.Tracks ?? new List(); + } + + SpotifyApiClient spotifyClient = _spotifyClient; + SpotifyApiClient? scopedSpotifyClient = null; + + if (!string.Equals(sessionCookie, _spotifyApiSettings.SessionCookie, StringComparison.Ordinal)) + { + scopedSpotifyClient = _spotifyClientFactory.Create(sessionCookie); + spotifyClient = scopedSpotifyClient; + } + + try + { + // Try to use cached or configured Spotify playlist ID + if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId)) { - // Use the configured Spotify playlist ID directly - spotifyId = config.Id; - _playlistNameToSpotifyId[playlistName] = spotifyId; - _logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId); + // Check if we have a configured Spotify ID for this playlist + if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id)) + { + // Use the configured Spotify playlist ID directly + spotifyId = playlistConfig.Id; + _playlistNameToSpotifyId[playlistName] = spotifyId; + _logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId); + } + else + { + // No configured ID, try searching by name (works for public/followed playlists) + _logger.LogInformation("No configured Spotify ID for '{Name}', searching...", playlistName); + var playlists = await spotifyClient.SearchUserPlaylistsAsync(playlistName); + + var exactMatch = playlists.FirstOrDefault(p => + p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); + + if (exactMatch == null) + { + _logger.LogInformation("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName); + return cached?.Tracks ?? new List(); + } + + spotifyId = exactMatch.SpotifyId; + _playlistNameToSpotifyId[playlistName] = spotifyId; + _logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId); + } + } + + // Fetch the full playlist + var playlist = await spotifyClient.GetPlaylistAsync(spotifyId); + if (playlist == null || playlist.Tracks.Count == 0) + { + _logger.LogError("Failed to fetch playlist '{Name}' from Spotify", playlistName); + return cached?.Tracks ?? new List(); + } + + // Calculate cache expiration based on cron schedule + var playlistCfg = playlistConfig; + var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default + + if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule)) + { + try + { + var cron = CronExpression.Parse(playlistCfg.SyncSchedule); + var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc); + + if (nextRun.HasValue) + { + var timeUntilNextRun = nextRun.Value - DateTime.UtcNow; + // Add 5 minutes buffer + cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5); + + _logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)", + playlistName, nextRun.Value, timeUntilNextRun.TotalHours); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName); + } + } + + // Update Redis cache with cron-based expiration + var cacheWriteSucceeded = await _cache.SetAsync(cacheKey, playlist, cacheExpiration); + + if (cacheWriteSucceeded) + { + _logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)", + playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours); } else { - // No configured ID, try searching by name (works for public/followed playlists) - _logger.LogInformation("No configured Spotify ID for '{Name}', searching...", playlistName); - var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName); - - var exactMatch = playlists.FirstOrDefault(p => - p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); - - if (exactMatch == null) - { - _logger.LogInformation("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName); - return cached?.Tracks ?? new List(); - } - - spotifyId = exactMatch.SpotifyId; - _playlistNameToSpotifyId[playlistName] = spotifyId; - _logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId); + _logger.LogWarning( + "Fetched playlist '{Name}' with {Count} tracks, but Redis cache write failed (intended expiry: {Hours:F1}h)", + playlistName, + playlist.Tracks.Count, + cacheExpiration.TotalHours); } + + return playlist.Tracks; } - - // Fetch the full playlist - var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId); - if (playlist == null || playlist.Tracks.Count == 0) + finally { - _logger.LogError("Failed to fetch playlist '{Name}' from Spotify", playlistName); - return cached?.Tracks ?? new List(); + scopedSpotifyClient?.Dispose(); } - - // Calculate cache expiration based on cron schedule - var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName); - var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default - - if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule)) - { - try - { - var cron = CronExpression.Parse(playlistCfg.SyncSchedule); - var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc); - - if (nextRun.HasValue) - { - var timeUntilNextRun = nextRun.Value - DateTime.UtcNow; - // Add 5 minutes buffer - cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5); - - _logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)", - playlistName, nextRun.Value, timeUntilNextRun.TotalHours); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName); - } - } - - // Update Redis cache with cron-based expiration - await _cache.SetAsync(cacheKey, playlist, cacheExpiration); - - _logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)", - playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours); - - return playlist.Tracks; } - + /// /// Gets missing tracks for a playlist (tracks not found in Jellyfin library). /// This provides compatibility with the existing SpotifyMissingTracksFetcher interface. @@ -186,87 +223,91 @@ public class SpotifyPlaylistFetcher : BackgroundService /// Set of Spotify IDs that exist in Jellyfin library /// List of missing tracks with position preserved public async Task> GetMissingTracksAsync( - string playlistName, + string playlistName, HashSet jellyfinTrackIds) { var allTracks = await GetPlaylistTracksAsync(playlistName); - + // Filter to only tracks not in Jellyfin, preserving order return allTracks .Where(t => !jellyfinTrackIds.Contains(t.SpotifyId)) .ToList(); } - + /// /// Manual trigger to refresh a specific playlist. /// public async Task RefreshPlaylistAsync(string playlistName) { _logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName); - + // Clear cache to force refresh - var cacheKey = $"{CacheKeyPrefix}{playlistName}"; + var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName); await _cache.DeleteAsync(cacheKey); - + // Re-fetch await GetPlaylistTracksAsync(playlistName); } - + /// /// Manual trigger to refresh all configured playlists. /// public async Task TriggerFetchAsync() { _logger.LogInformation("Manual fetch triggered for all playlists"); - + foreach (var config in _spotifyImportSettings.Playlists) { await RefreshPlaylistAsync(config.Name); } } - + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("========================================"); _logger.LogInformation("SpotifyPlaylistFetcher: Starting up..."); - + if (!_spotifyApiSettings.Enabled) { _logger.LogInformation("Spotify API integration is DISABLED"); _logger.LogInformation("========================================"); return; } - - if (string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie)) + + if (!await _spotifySessionCookieService.HasAnyConfiguredCookieAsync()) { - _logger.LogError("Spotify session cookie not configured - cannot access editorial playlists"); + _logger.LogError("Spotify session cookie not configured (global or user-scoped) - cannot access editorial playlists"); _logger.LogInformation("========================================"); return; } - - // Verify we can get an access token (the most reliable auth check) - _logger.LogDebug("Attempting Spotify authentication..."); - var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken); - if (string.IsNullOrEmpty(token)) + + // Validate global fallback cookie if configured; user-scoped cookies are validated per playlist fetch. + if (!string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie)) { - _logger.LogError("Failed to get Spotify access token - check session cookie"); - _logger.LogInformation("========================================"); - return; + _logger.LogDebug("Attempting Spotify authentication using global fallback cookie..."); + var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken); + if (string.IsNullOrEmpty(token)) + { + _logger.LogWarning("Global fallback Spotify cookie failed validation. User-scoped cookies may still succeed."); + } } - + _logger.LogInformation("Spotify API ENABLED"); - _logger.LogInformation("Authenticated via sp_dc session cookie"); + _logger.LogInformation("Session cookie mode: {Mode}", + string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie) + ? "user-scoped only" + : "global fallback + optional user-scoped overrides"); _logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled"); _logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count); - + foreach (var playlist in _spotifyImportSettings.Playlists) { var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * *" : playlist.SyncSchedule; _logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule); } - + _logger.LogInformation("========================================"); - + // Cron-based refresh loop - only fetch when cron schedule triggers // This prevents excess Spotify API calls while (!stoppingToken.IsCancellationRequested) @@ -276,28 +317,28 @@ public class SpotifyPlaylistFetcher : BackgroundService // Check each playlist to see if it needs refreshing based on cron schedule var now = DateTime.UtcNow; var needsRefresh = new List(); - + foreach (var config in _spotifyImportSettings.Playlists) { var schedule = string.IsNullOrEmpty(config.SyncSchedule) ? "0 8 * * *" : config.SyncSchedule; - + try { var cron = CronExpression.Parse(schedule); - + // Check if we have cached data - var cacheKey = $"{CacheKeyPrefix}{config.Name}"; + var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(config.Name); var cached = await _cache.GetAsync(cacheKey); - + if (cached != null) { // Calculate when the next run should be after the last fetch var nextRun = cron.GetNextOccurrence(cached.FetchedAt, TimeZoneInfo.Utc); - + if (nextRun.HasValue && now >= nextRun.Value) { needsRefresh.Add(config.Name); - _logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}", + _logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}", config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value); } } @@ -312,20 +353,20 @@ public class SpotifyPlaylistFetcher : BackgroundService _logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", config.Name, schedule); } } - + // Fetch playlists that need refreshing if (needsRefresh.Count > 0) { _logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count); - + foreach (var playlistName in needsRefresh) { if (stoppingToken.IsCancellationRequested) break; - + try { await GetPlaylistTracksAsync(playlistName); - + // Rate limiting between playlists if (playlistName != needsRefresh.Last()) { @@ -338,10 +379,10 @@ public class SpotifyPlaylistFetcher : BackgroundService _logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName); } } - + _logger.LogInformation("=== FINISHED FETCHING PLAYLISTS ==="); } - + // Sleep for 1 hour before checking again await Task.Delay(TimeSpan.FromHours(1), stoppingToken); } @@ -352,30 +393,30 @@ public class SpotifyPlaylistFetcher : BackgroundService } } } - + private async Task FetchAllPlaylistsAsync(CancellationToken cancellationToken) { _logger.LogInformation("=== FETCHING SPOTIFY PLAYLISTS ==="); - + foreach (var config in _spotifyImportSettings.Playlists) { if (cancellationToken.IsCancellationRequested) break; - + try { var tracks = await GetPlaylistTracksAsync(config.Name); _logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count); - + // Log sample of track order for debugging if (tracks.Count > 0) { - _logger.LogDebug(" First track: #{Position} {Title} - {Artist}", + _logger.LogDebug(" First track: #{Position} {Title} - {Artist}", tracks[0].Position, tracks[0].Title, tracks[0].PrimaryArtist); - + if (tracks.Count > 1) { var last = tracks[^1]; - _logger.LogDebug(" Last track: #{Position} {Title} - {Artist}", + _logger.LogDebug(" Last track: #{Position} {Title} - {Artist}", last.Position, last.Title, last.PrimaryArtist); } } @@ -384,7 +425,7 @@ public class SpotifyPlaylistFetcher : BackgroundService { _logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name); } - + // Rate limiting between playlists - Spotify is VERY aggressive with rate limiting // Wait 3 seconds between each playlist to avoid 429 TooManyRequests errors if (config != _spotifyImportSettings.Playlists.Last()) @@ -393,7 +434,7 @@ public class SpotifyPlaylistFetcher : BackgroundService await Task.Delay(TimeSpan.FromSeconds(3), cancellationToken); } } - + _logger.LogInformation("=== FINISHED FETCHING SPOTIFY PLAYLISTS ==="); } } diff --git a/allstarr/Services/Spotify/SpotifySessionCookieService.cs b/allstarr/Services/Spotify/SpotifySessionCookieService.cs new file mode 100644 index 0000000..3a66681 --- /dev/null +++ b/allstarr/Services/Spotify/SpotifySessionCookieService.cs @@ -0,0 +1,198 @@ +using System.Text.Json; +using System.Globalization; +using allstarr.Models.Settings; +using allstarr.Services.Admin; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Options; + +namespace allstarr.Services.Spotify; + +/// +/// Stores and resolves Spotify session cookies in a user-scoped model. +/// +public class SpotifySessionCookieService +{ + private const string UserCookieMapKey = "SPOTIFY_API_SESSION_COOKIES"; + private const string UserCookieSetDatesKey = "SPOTIFY_API_SESSION_COOKIE_SET_DATES"; + + private readonly SpotifyApiSettings _spotifyApiSettings; + private readonly AdminHelperService _adminHelper; + private readonly ILogger _logger; + private readonly SemaphoreSlim _lock = new(1, 1); + + public SpotifySessionCookieService( + IOptions spotifyApiSettings, + AdminHelperService adminHelper, + ILogger logger) + { + _spotifyApiSettings = spotifyApiSettings.Value; + _adminHelper = adminHelper; + _logger = logger; + } + + public async Task ResolveSessionCookieAsync(string? userId) + { + if (!string.IsNullOrWhiteSpace(userId)) + { + var map = await ReadUserCookieMapAsync(); + var normalizedUserId = userId.Trim(); + if (map.TryGetValue(normalizedUserId, out var cookie) && + !string.IsNullOrWhiteSpace(cookie)) + { + return cookie; + } + } + + return string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie) + ? null + : _spotifyApiSettings.SessionCookie; + } + + public async Task HasAnyConfiguredCookieAsync() + { + if (!string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie)) + { + return true; + } + + var userCookieMap = await ReadUserCookieMapAsync(); + return userCookieMap.Values.Any(value => !string.IsNullOrWhiteSpace(value)); + } + + public async Task<(bool HasCookie, bool UsingGlobalFallback)> GetCookieStatusAsync(string? userId) + { + var userCookie = string.Empty; + if (!string.IsNullOrWhiteSpace(userId)) + { + var userCookieMap = await ReadUserCookieMapAsync(); + userCookieMap.TryGetValue(userId.Trim(), out userCookie); + } + + if (!string.IsNullOrWhiteSpace(userCookie)) + { + return (true, false); + } + + if (!string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie)) + { + return (true, true); + } + + return (false, false); + } + + public async Task GetCookieSetDateAsync(string userId) + { + var setDateMap = await ReadUserCookieSetDateMapAsync(); + if (!setDateMap.TryGetValue(userId.Trim(), out var isoDate) || + string.IsNullOrWhiteSpace(isoDate)) + { + return null; + } + + return DateTime.TryParse( + isoDate, + CultureInfo.InvariantCulture, + DateTimeStyles.RoundtripKind, + out var parsedDate) + ? parsedDate + : null; + } + + public async Task SetUserSessionCookieAsync(string userId, string sessionCookie) + { + if (string.IsNullOrWhiteSpace(userId)) + { + return new BadRequestObjectResult(new { error = "User ID is required" }); + } + + if (!AdminHelperService.IsValidPassword(sessionCookie)) + { + return new BadRequestObjectResult(new { error = "Invalid session cookie format" }); + } + + var normalizedUserId = userId.Trim(); + + await _lock.WaitAsync(); + try + { + var userCookieMap = await ReadUserCookieMapAsync(); + userCookieMap[normalizedUserId] = sessionCookie; + + var setDateMap = await ReadUserCookieSetDateMapAsync(); + setDateMap[normalizedUserId] = DateTime.UtcNow.ToString("o"); + + var updates = new Dictionary + { + [UserCookieMapKey] = JsonSerializer.Serialize(userCookieMap), + [UserCookieSetDatesKey] = JsonSerializer.Serialize(setDateMap) + }; + + return await _adminHelper.UpdateEnvConfigAsync(updates); + } + finally + { + _lock.Release(); + } + } + + private async Task> ReadUserCookieMapAsync() + { + return await ReadEnvJsonMapAsync(UserCookieMapKey); + } + + private async Task> ReadUserCookieSetDateMapAsync() + { + return await ReadEnvJsonMapAsync(UserCookieSetDatesKey); + } + + private async Task> ReadEnvJsonMapAsync(string envKey) + { + try + { + var envPath = _adminHelper.GetEnvFilePath(); + if (!File.Exists(envPath)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var lines = await File.ReadAllLinesAsync(envPath); + foreach (var line in lines) + { + if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#')) + { + continue; + } + + var eqIndex = line.IndexOf('='); + if (eqIndex <= 0) + { + continue; + } + + var key = line[..eqIndex].Trim(); + if (!key.Equals(envKey, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var value = AdminHelperService.StripQuotes(line[(eqIndex + 1)..].Trim()); + if (string.IsNullOrWhiteSpace(value)) + { + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + var parsed = JsonSerializer.Deserialize>(value); + return parsed != null + ? new Dictionary(parsed, StringComparer.OrdinalIgnoreCase) + : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to read Spotify user cookie map key {Key}", envKey); + } + + return new Dictionary(StringComparer.OrdinalIgnoreCase); + } +} diff --git a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs index 0b53262..5232686 100644 --- a/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs +++ b/allstarr/Services/Spotify/SpotifyTrackMatchingService.cs @@ -12,14 +12,16 @@ namespace allstarr.Services.Spotify; /// /// Background service that pre-matches Spotify tracks with external providers. -/// +/// /// Supports two modes: /// 1. Legacy mode: Uses MissingTrack from Jellyfin plugin (no ISRC, no ordering) /// 2. Direct API mode: Uses SpotifyPlaylistTrack (with ISRC and ordering) -/// +/// /// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching. -/// -/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers. +/// +/// CRON SCHEDULING: Each playlist has its own cron schedule. +/// When a playlist schedule is due, we run the same per-playlist rebuild workflow +/// used by the manual per-playlist "Rebuild" button. /// Manual refresh is always allowed. Cache persists until next cron run. /// public class SpotifyTrackMatchingService : BackgroundService @@ -33,7 +35,7 @@ public class SpotifyTrackMatchingService : BackgroundService private readonly IServiceProvider _serviceProvider; private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count) - + // Track last run time per playlist to prevent duplicate runs private readonly Dictionary _lastRunTimes = new(); private readonly TimeSpan _minimumRunInterval = TimeSpan.FromMinutes(5); // Cooldown between runs @@ -55,7 +57,7 @@ public class SpotifyTrackMatchingService : BackgroundService _serviceProvider = serviceProvider; _logger = logger; } - + /// /// Helper method to safely check if a dynamic cache result has a value /// Handles the case where JsonElement cannot be compared to null directly @@ -71,26 +73,26 @@ public class SpotifyTrackMatchingService : BackgroundService { _logger.LogInformation("========================================"); _logger.LogInformation("SpotifyTrackMatchingService: Starting up..."); - + if (!_spotifySettings.Enabled) { _logger.LogInformation("Spotify playlist injection is DISABLED, matching service will not run"); _logger.LogInformation("========================================"); return; } - - var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching + + var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching ? "ISRC-preferred" : "fuzzy"; _logger.LogInformation("Matching mode: {Mode}", matchMode); - _logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule"); - + _logger.LogInformation("Cron-based scheduling: each playlist runs independently"); + // Log all playlist schedules foreach (var playlist in _spotifySettings.Playlists) { var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * 1" : playlist.SyncSchedule; _logger.LogInformation(" - {Name}: {Schedule}", playlist.Name, schedule); } - + _logger.LogInformation("========================================"); // Wait a bit for the fetcher to run first @@ -112,81 +114,101 @@ public class SpotifyTrackMatchingService : BackgroundService { try { - // Calculate next run time for each playlist + // Calculate next run time for each playlist. + // Use a small grace window so we don't miss exact-minute cron runs when waking slightly late. var now = DateTime.UtcNow; + var schedulerReference = now.AddMinutes(-1); var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>(); - + foreach (var playlist in _spotifySettings.Playlists) { var schedule = string.IsNullOrEmpty(playlist.SyncSchedule) ? "0 8 * * *" : playlist.SyncSchedule; - + try { var cron = CronExpression.Parse(schedule); - var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc); - + var nextRun = cron.GetNextOccurrence(schedulerReference, TimeZoneInfo.Utc); + if (nextRun.HasValue) { nextRuns.Add((playlist.Name, nextRun.Value, cron)); } else { - _logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}", + _logger.LogWarning("Could not calculate next run for playlist {Name} with schedule {Schedule}", playlist.Name, schedule); } } catch (Exception ex) { - _logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", + _logger.LogError(ex, "Invalid cron schedule for playlist {Name}: {Schedule}", playlist.Name, schedule); } } - + if (nextRuns.Count == 0) { _logger.LogWarning("No valid cron schedules found, sleeping for 1 hour"); await Task.Delay(TimeSpan.FromHours(1), stoppingToken); continue; } - - // Find the next playlist that needs to run - var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First(); - var waitTime = nextPlaylist.NextRun - now; - - if (waitTime.TotalSeconds > 0) + + // Run all playlists that are currently due. + var duePlaylists = nextRuns + .Where(x => x.NextRun <= now) + .OrderBy(x => x.NextRun) + .ToList(); + + if (duePlaylists.Count == 0) { - _logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)", + // No playlist due yet: wait until the next scheduled run (or max 1 hour to re-check schedules) + var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First(); + var waitTime = nextPlaylist.NextRun - now; + + _logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)", nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes); - - // Wait until next run (or max 1 hour to re-check schedules) + var maxWait = TimeSpan.FromHours(1); var actualWait = waitTime > maxWait ? maxWait : waitTime; await Task.Delay(actualWait, stoppingToken); continue; } - - // Time to run this playlist - _logger.LogInformation("=== CRON TRIGGER: Running scheduled sync for {Playlist} ===", nextPlaylist.PlaylistName); - - // Check cooldown to prevent duplicate runs - if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun)) + + _logger.LogInformation( + "=== CRON TRIGGER: Running scheduled rebuild for {Count} due playlists ===", + duePlaylists.Count); + + var anySkippedForCooldown = false; + + foreach (var due in duePlaylists) { - var timeSinceLastRun = now - lastRun; - if (timeSinceLastRun < _minimumRunInterval) + if (stoppingToken.IsCancellationRequested) { - _logger.LogWarning("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)", - nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds); - await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); + break; + } + + _logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName); + + var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync( + due.PlaylistName, + stoppingToken, + trigger: "cron"); + + if (!rebuilt) + { + anySkippedForCooldown = true; continue; } + + _logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC", + due.PlaylistName, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc)); + } + + // Avoid a tight loop if one or more due playlists were skipped by cooldown. + if (anySkippedForCooldown) + { + await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken); } - - // Run full rebuild for this playlist (same as "Rebuild All Remote" button) - await RebuildSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken); - _lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow; - - _logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===", - nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc)); } catch (Exception ex) { @@ -195,46 +217,46 @@ public class SpotifyTrackMatchingService : BackgroundService } } } - + /// /// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches). - /// This is the unified method used by both cron scheduler and "Rebuild All Remote" button. + /// Used by individual per-playlist rebuild actions. /// private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken) { var playlist = _spotifySettings.Playlists .FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); - + if (playlist == null) { _logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName); return; } - + _logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName); - + // Clear cache for this playlist (same as "Rebuild All Remote" button) var keysToDelete = new[] { CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name), CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name), - $"spotify:matched:{playlist.Name}", // Legacy key + CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name), // Legacy key CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name), - $"spotify:playlist:items:{playlist.Name}", - $"spotify:playlist:ordered:{playlist.Name}", - $"spotify:playlist:stats:{playlist.Name}" + CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name), + CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name), + CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name) }; - + foreach (var key in keysToDelete) { await _cache.DeleteAsync(key); } - + _logger.LogInformation("Step 2/3: Fetching fresh data from Spotify for {Playlist}", playlistName); - + using var scope = _serviceProvider.CreateScope(); var metadataService = scope.ServiceProvider.GetRequiredService(); - + // Trigger fresh fetch from Spotify SpotifyPlaylistFetcher? playlistFetcher = null; if (_spotifyApiSettings.Enabled) @@ -246,9 +268,9 @@ public class SpotifyTrackMatchingService : BackgroundService await playlistFetcher.RefreshPlaylistAsync(playlist.Name); } } - + _logger.LogInformation("Step 3/3: Matching tracks for {Playlist}", playlistName); - + try { if (playlistFetcher != null) @@ -269,10 +291,10 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name); throw; } - + _logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName); } - + /// /// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify. /// Used for lightweight re-matching when only local library has changed. @@ -281,23 +303,23 @@ public class SpotifyTrackMatchingService : BackgroundService { var playlist = _spotifySettings.Playlists .FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); - + if (playlist == null) { _logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName); return; } - + using var scope = _serviceProvider.CreateScope(); var metadataService = scope.ServiceProvider.GetRequiredService(); - + // Check if we should use the new SpotifyPlaylistFetcher SpotifyPlaylistFetcher? playlistFetcher = null; if (_spotifyApiSettings.Enabled) { playlistFetcher = scope.ServiceProvider.GetService(); } - + try { if (playlistFetcher != null) @@ -322,38 +344,66 @@ public class SpotifyTrackMatchingService : BackgroundService /// /// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button). - /// This clears caches, fetches fresh data, and re-matches everything - same as cron job. + /// This clears caches, fetches fresh data, and re-matches everything immediately. /// public async Task TriggerRebuildAllAsync() { - _logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)"); + _logger.LogInformation("Manual full rebuild triggered for all playlists"); await RebuildAllPlaylistsAsync(CancellationToken.None); } - + /// /// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button). - /// This clears cache, fetches fresh data, and re-matches - same as cron job. + /// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist. /// public async Task TriggerRebuildForPlaylistAsync(string playlistName) { - _logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist} (same as cron job)", playlistName); - - // Check cooldown to prevent abuse + _logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName); + var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync( + playlistName, + CancellationToken.None, + trigger: "manual"); + + if (!rebuilt) + { + if (_lastRunTimes.TryGetValue(playlistName, out var lastRun)) + { + var timeSinceLastRun = DateTime.UtcNow - lastRun; + var remaining = _minimumRunInterval - timeSinceLastRun; + var remainingSeconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds)); + throw new InvalidOperationException( + $"Please wait {remainingSeconds} more seconds before rebuilding again"); + } + + throw new InvalidOperationException("Playlist rebuild skipped due to cooldown"); + } + } + + private async Task TryRunSinglePlaylistRebuildWithCooldownAsync( + string playlistName, + CancellationToken cancellationToken, + string trigger) + { if (_lastRunTimes.TryGetValue(playlistName, out var lastRun)) { var timeSinceLastRun = DateTime.UtcNow - lastRun; if (timeSinceLastRun < _minimumRunInterval) { - _logger.LogWarning("Skipping manual rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)", - playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds); - throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before rebuilding again"); + _logger.LogWarning( + "Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)", + trigger, + playlistName, + (int)timeSinceLastRun.TotalSeconds, + (int)_minimumRunInterval.TotalSeconds); + return false; } } - - await RebuildSinglePlaylistAsync(playlistName, CancellationToken.None); + + await RebuildSinglePlaylistAsync(playlistName, cancellationToken); _lastRunTimes[playlistName] = DateTime.UtcNow; + return true; } - + /// /// Public method to trigger lightweight matching for all playlists (called from controller). /// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify. @@ -364,7 +414,7 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogInformation("Manual track matching triggered for all playlists (bypassing cron schedules)"); await MatchAllPlaylistsAsync(CancellationToken.None); } - + /// /// Public method to trigger lightweight matching for a single playlist (called from "Re-match Local" button). /// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify. @@ -373,27 +423,17 @@ public class SpotifyTrackMatchingService : BackgroundService public async Task TriggerMatchingForPlaylistAsync(string playlistName) { _logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (lightweight, no cache clear)", playlistName); - - // Check cooldown to prevent abuse - if (_lastRunTimes.TryGetValue(playlistName, out var lastRun)) - { - var timeSinceLastRun = DateTime.UtcNow - lastRun; - if (timeSinceLastRun < _minimumRunInterval) - { - _logger.LogWarning("Skipping manual refresh for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)", - playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds); - throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before refreshing again"); - } - } - + + // Intentionally no cooldown here: this path should react immediately to + // local library changes and manual mapping updates without waiting for + // Spotify API cooldown windows. await MatchSinglePlaylistAsync(playlistName, CancellationToken.None); - _lastRunTimes[playlistName] = DateTime.UtcNow; } private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken) { _logger.LogInformation("=== STARTING FULL REBUILD FOR ALL PLAYLISTS ==="); - + var playlists = _spotifySettings.Playlists; if (playlists.Count == 0) { @@ -404,7 +444,7 @@ public class SpotifyTrackMatchingService : BackgroundService foreach (var playlist in playlists) { if (cancellationToken.IsCancellationRequested) break; - + try { await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken); @@ -414,14 +454,14 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogError(ex, "Error rebuilding playlist {Playlist}", playlist.Name); } } - + _logger.LogInformation("=== FINISHED FULL REBUILD FOR ALL PLAYLISTS ==="); } private async Task MatchAllPlaylistsAsync(CancellationToken cancellationToken) { _logger.LogInformation("=== STARTING TRACK MATCHING FOR ALL PLAYLISTS ==="); - + var playlists = _spotifySettings.Playlists; if (playlists.Count == 0) { @@ -432,7 +472,7 @@ public class SpotifyTrackMatchingService : BackgroundService foreach (var playlist in playlists) { if (cancellationToken.IsCancellationRequested) break; - + try { await MatchSinglePlaylistAsync(playlist.Name, cancellationToken); @@ -442,10 +482,10 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogError(ex, "Error matching tracks for playlist {Playlist}", playlist.Name); } } - + _logger.LogInformation("=== FINISHED TRACK MATCHING FOR ALL PLAYLISTS ==="); } - + /// /// New matching mode that uses ISRC when available for exact matches. /// Preserves track position for correct playlist ordering. @@ -459,7 +499,7 @@ public class SpotifyTrackMatchingService : BackgroundService CancellationToken cancellationToken) { var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName); - + // Get playlist tracks with full metadata including ISRC and position var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName); if (spotifyTracks.Count == 0) @@ -467,20 +507,20 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName); return; } - + // Get the Jellyfin playlist ID to check which tracks already exist var playlistConfig = _spotifySettings.Playlists .FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); - + HashSet existingSpotifyIds = new(); - + if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId)) { // Get existing tracks from Jellyfin playlist to avoid re-matching using var scope = _serviceProvider.CreateScope(); var proxyService = scope.ServiceProvider.GetService(); var jellyfinSettings = scope.ServiceProvider.GetService>()?.Value; - + if (proxyService != null && jellyfinSettings != null) { try @@ -497,12 +537,12 @@ public class SpotifyTrackMatchingService : BackgroundService { _logger.LogInformation("No UserId configured - may not be able to fetch existing playlist tracks for {Playlist}", playlistName); } - + var (existingTracksResponse, _) = await proxyService.GetJsonAsyncInternal( - playlistItemsUrl, + playlistItemsUrl, queryParams); - - if (existingTracksResponse != null && + + if (existingTracksResponse != null && existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) { foreach (var item in items.EnumerateArray()) @@ -517,7 +557,7 @@ public class SpotifyTrackMatchingService : BackgroundService } } } - _logger.LogInformation("Found {Count} tracks already in Jellyfin playlist {Playlist}, will skip matching these", + _logger.LogInformation("Found {Count} tracks already in Jellyfin playlist {Playlist}, will skip matching these", existingSpotifyIds.Count, playlistName); } else @@ -531,25 +571,25 @@ public class SpotifyTrackMatchingService : BackgroundService } } } - + // Filter to only tracks not already in Jellyfin var tracksToMatch = spotifyTracks .Where(t => !existingSpotifyIds.Contains(t.SpotifyId)) .ToList(); - + if (tracksToMatch.Count == 0) { - _logger.LogWarning("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching", + _logger.LogWarning("All {Count} tracks for {Playlist} already exist in Jellyfin, skipping matching", spotifyTracks.Count, playlistName); return; } - - _logger.LogWarning("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)", + + _logger.LogWarning("Matching {ToMatch}/{Total} tracks for {Playlist} (skipping {Existing} already in Jellyfin, ISRC: {IsrcEnabled}, AGGRESSIVE MODE)", tracksToMatch.Count, spotifyTracks.Count, playlistName, existingSpotifyIds.Count, _spotifyApiSettings.PreferIsrcMatching); - + // Check cache - use snapshot/timestamp to detect changes var existingMatched = await _cache.GetAsync>(matchedTracksKey); - + // CRITICAL: Skip matching if cache exists and is valid // Only re-match if cache is missing OR if we detect manual mappings that need to be applied if (existingMatched != null && existingMatched.Count > 0) @@ -559,15 +599,15 @@ public class SpotifyTrackMatchingService : BackgroundService foreach (var track in tracksToMatch) { // Check if this track has a manual mapping but isn't in the cached results - var manualMappingKey = $"spotify:manual-map:{playlistName}:{track.SpotifyId}"; + var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, track.SpotifyId); var manualMapping = await _cache.GetAsync(manualMappingKey); - - var externalMappingKey = $"spotify:external-map:{playlistName}:{track.SpotifyId}"; + + var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, track.SpotifyId); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); - + var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson); var isInCache = existingMatched.Any(m => m.SpotifyId == track.SpotifyId); - + // If track has manual mapping but isn't in cache, we need to rebuild if (hasManualMapping && !isInCache) { @@ -575,14 +615,14 @@ public class SpotifyTrackMatchingService : BackgroundService break; } } - + if (!hasNewManualMappings) { - _logger.LogWarning("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed", + _logger.LogWarning("✓ Playlist {Playlist} already has {Count} matched tracks cached (skipping {ToMatch} new tracks), no re-matching needed", playlistName, existingMatched.Count, tracksToMatch.Count); return; } - + _logger.LogInformation("New manual mappings detected for {Playlist}, rebuilding cache to apply them", playlistName); } @@ -593,7 +633,7 @@ public class SpotifyTrackMatchingService : BackgroundService using var scope = _serviceProvider.CreateScope(); var proxyService = scope.ServiceProvider.GetService(); var jellyfinSettings = scope.ServiceProvider.GetService>()?.Value; - + if (proxyService != null && jellyfinSettings != null) { try @@ -605,9 +645,9 @@ public class SpotifyTrackMatchingService : BackgroundService { queryParams["UserId"] = userId; } - + var (response, _) = await proxyService.GetJsonAsyncInternal(playlistItemsUrl, queryParams); - + if (response != null && response.RootElement.TryGetProperty("Items", out var items)) { foreach (var item in items.EnumerateArray()) @@ -622,7 +662,7 @@ public class SpotifyTrackMatchingService : BackgroundService }; jellyfinTracks.Add(song); } - _logger.LogInformation("📚 Loaded {Count} tracks from Jellyfin playlist {Playlist}", + _logger.LogInformation("📚 Loaded {Count} tracks from Jellyfin playlist {Playlist}", jellyfinTracks.Count, playlistName); } } @@ -632,42 +672,42 @@ public class SpotifyTrackMatchingService : BackgroundService } } } - + // PHASE 2: Match Jellyfin tracks → Spotify tracks using fuzzy matching - _logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify tracks", + _logger.LogInformation("🔍 Matching {JellyfinCount} Jellyfin tracks to {SpotifyCount} Spotify tracks", jellyfinTracks.Count, spotifyTracks.Count); - + var localMatches = new Dictionary(); var usedJellyfinIds = new HashSet(); var usedSpotifyIds = new HashSet(); - + // Build all possible matches with scores var allLocalCandidates = new List<(Song JellyfinTrack, SpotifyPlaylistTrack SpotifyTrack, double Score)>(); - + foreach (var jellyfinTrack in jellyfinTracks) { foreach (var spotifyTrack in spotifyTracks) { - var score = CalculateMatchScore(jellyfinTrack.Title, jellyfinTrack.Artist, + var score = CalculateMatchScore(jellyfinTrack.Title, jellyfinTrack.Artist, spotifyTrack.Title, spotifyTrack.PrimaryArtist); - + if (score >= 70) // Only consider good matches { allLocalCandidates.Add((jellyfinTrack, spotifyTrack, score)); } } } - + // Greedy assignment: best matches first foreach (var (jellyfinTrack, spotifyTrack, score) in allLocalCandidates.OrderByDescending(c => c.Score)) { if (usedJellyfinIds.Contains(jellyfinTrack.Id)) continue; if (usedSpotifyIds.Contains(spotifyTrack.SpotifyId)) continue; - + localMatches[spotifyTrack.SpotifyId] = (jellyfinTrack, spotifyTrack, score); usedJellyfinIds.Add(jellyfinTrack.Id); usedSpotifyIds.Add(spotifyTrack.SpotifyId); - + // Save local mapping var metadata = new TrackMetadata { @@ -677,29 +717,29 @@ public class SpotifyTrackMatchingService : BackgroundService ArtworkUrl = spotifyTrack.AlbumArtUrl, DurationMs = spotifyTrack.DurationMs }; - + await _mappingService.SaveLocalMappingAsync(spotifyTrack.SpotifyId, jellyfinTrack.Id, metadata); - - _logger.LogInformation(" ✓ Local: {SpotifyTitle} → {JellyfinTitle} (score: {Score:F1})", + + _logger.LogInformation(" ✓ Local: {SpotifyTitle} → {JellyfinTitle} (score: {Score:F1})", spotifyTrack.Title, jellyfinTrack.Title, score); } - - _logger.LogInformation("✅ Matched {LocalCount}/{SpotifyCount} Spotify tracks to local Jellyfin tracks", + + _logger.LogInformation("✅ Matched {LocalCount}/{SpotifyCount} Spotify tracks to local Jellyfin tracks", localMatches.Count, spotifyTracks.Count); - + // PHASE 3: For remaining unmatched Spotify tracks, search external providers var unmatchedSpotifyTracks = spotifyTracks .Where(t => !usedSpotifyIds.Contains(t.SpotifyId)) .ToList(); - - _logger.LogInformation("🔍 Searching external providers for {Count} unmatched tracks", + + _logger.LogInformation("🔍 Searching external providers for {Count} unmatched tracks", unmatchedSpotifyTracks.Count); - + var matchedTracks = new List(); var isrcMatches = 0; var fuzzyMatches = 0; var noMatch = 0; - + var allCandidates = new List<(SpotifyPlaylistTrack SpotifyTrack, Song MatchedSong, double Score, string MatchType)>(); // Process unmatched tracks in batches @@ -708,32 +748,32 @@ public class SpotifyTrackMatchingService : BackgroundService if (cancellationToken.IsCancellationRequested) break; var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList(); - + var batchTasks = batch.Select(async spotifyTrack => { try { var candidates = new List<(Song Song, double Score, string MatchType)>(); - + // Check global external mapping first var globalMapping = await _mappingService.GetMappingAsync(spotifyTrack.SpotifyId); if (globalMapping != null && globalMapping.TargetType == "external") { Song? mappedSong = null; - - if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) && + + if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) && !string.IsNullOrEmpty(globalMapping.ExternalId)) { mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId); } - + if (mappedSong != null) { candidates.Add((mappedSong, 100.0, "global-mapping-external")); return (spotifyTrack, candidates); } } - + // Try ISRC match if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc)) { @@ -743,13 +783,13 @@ public class SpotifyTrackMatchingService : BackgroundService candidates.Add((isrcSong, 100.0, "isrc")); } } - + // Fuzzy search external providers var fuzzySongs = await TryMatchByFuzzyMultipleAsync( - spotifyTrack.Title, - spotifyTrack.Artists, + spotifyTrack.Title, + spotifyTrack.Artists, metadataService); - + foreach (var (song, score) in fuzzySongs) { if (!song.IsLocal) // Only external tracks @@ -757,7 +797,7 @@ public class SpotifyTrackMatchingService : BackgroundService candidates.Add((song, score, "fuzzy-external")); } } - + return (spotifyTrack, candidates); } catch (Exception ex) @@ -768,7 +808,7 @@ public class SpotifyTrackMatchingService : BackgroundService }).ToList(); var batchResults = await Task.WhenAll(batchTasks); - + foreach (var result in batchResults) { foreach (var candidate in result.Item2) @@ -782,19 +822,19 @@ public class SpotifyTrackMatchingService : BackgroundService await Task.Delay(DelayBetweenSearchesMs, cancellationToken); } } - + // PHASE 4: Greedy assignment for external matches var usedSongIds = new HashSet(); var externalAssignments = new Dictionary(); - + foreach (var (spotifyTrack, song, score, matchType) in allCandidates.OrderByDescending(c => c.Score)) { if (externalAssignments.ContainsKey(spotifyTrack.SpotifyId)) continue; if (usedSongIds.Contains(song.Id)) continue; - + externalAssignments[spotifyTrack.SpotifyId] = (song, score, matchType); usedSongIds.Add(song.Id); - + // Save external mapping var metadata = new TrackMetadata { @@ -804,25 +844,25 @@ public class SpotifyTrackMatchingService : BackgroundService ArtworkUrl = spotifyTrack.AlbumArtUrl, DurationMs = spotifyTrack.DurationMs }; - + await _mappingService.SaveExternalMappingAsync( spotifyTrack.SpotifyId, song.ExternalProvider ?? "Unknown", song.ExternalId ?? song.Id, metadata); - + if (matchType == "isrc") isrcMatches++; else fuzzyMatches++; - - _logger.LogInformation(" ✓ External: {Title} → {Provider}:{ExternalId} (score: {Score:F1})", + + _logger.LogInformation(" ✓ External: {Title} → {Provider}:{ExternalId} (score: {Score:F1})", spotifyTrack.Title, song.ExternalProvider, song.ExternalId, score); } - + // PHASE 5: Build final matched tracks list (local + external) foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) { MatchedTrack? matched = null; - + // Check local matches first if (localMatches.TryGetValue(spotifyTrack.SpotifyId, out var localMatch)) { @@ -856,7 +896,7 @@ public class SpotifyTrackMatchingService : BackgroundService noMatch++; _logger.LogDebug(" #{Position} {Title} → no match", spotifyTrack.Position, spotifyTrack.Title); } - + if (matched != null) { matchedTracks.Add(matched); @@ -869,40 +909,40 @@ public class SpotifyTrackMatchingService : BackgroundService var statsLocalCount = localMatches.Count; var statsExternalCount = externalAssignments.Count; var statsMissingCount = spotifyTracks.Count - statsLocalCount - statsExternalCount; - + var stats = new Dictionary { ["local"] = statsLocalCount, ["external"] = statsExternalCount, ["missing"] = statsMissingCount }; - - var statsCacheKey = $"spotify:playlist:stats:{playlistName}"; + + var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlistName); await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30)); - - _logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing", + + _logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing", playlistName, statsLocalCount, statsExternalCount, statsMissingCount); - + // Calculate cache expiration: until next cron run (not just cache duration from settings) var playlist = _spotifySettings.Playlists .FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase)); - + var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours - + if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule)) { try { var cron = CronExpression.Parse(playlist.SyncSchedule); var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc); - + if (nextRun.HasValue) { var timeUntilNextRun = nextRun.Value - DateTime.UtcNow; // Add 5 minutes buffer to ensure cache doesn't expire before next run cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5); - - _logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)", + + _logger.LogInformation("Cache will persist until next cron run: {NextRun} UTC (in {Hours:F1} hours)", nextRun.Value, timeUntilNextRun.TotalHours); } } @@ -911,22 +951,22 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogError(ex, "Could not calculate next cron run for {Playlist}, using default cache duration", playlistName); } } - + // Cache matched tracks with position data until next cron run await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration); - + // Save matched tracks to file for persistence across restarts await SaveMatchedTracksToFileAsync(playlistName, matchedTracks); - + // Also update legacy cache for backward compatibility - var legacyKey = $"spotify:matched:{playlistName}"; + var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName); var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList(); await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration); - + _logger.LogInformation( - "✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h", + "✓ Cached {Matched}/{Total} tracks for {Playlist} via GREEDY ASSIGNMENT (ISRC: {Isrc}, Fuzzy: {Fuzzy}, No match: {NoMatch}) - cache expires in {Hours:F1}h", matchedTracks.Count, tracksToMatch.Count, playlistName, isrcMatches, fuzzyMatches, noMatch, cacheExpiration.TotalHours); - + // Pre-build playlist items cache for instant serving // This is what makes the UI show all matched tracks at once await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken); @@ -936,7 +976,7 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogInformation("No tracks matched for {Playlist}", playlistName); } } - + /// /// Returns multiple candidate matches with scores for greedy assignment. /// FOLLOWS OPTIMAL ORDER: @@ -947,8 +987,8 @@ public class SpotifyTrackMatchingService : BackgroundService /// Returns multiple candidates for greedy assignment. /// private async Task> TryMatchByFuzzyMultipleAsync( - string title, - List artists, + string title, + List artists, IMusicMetadataService metadataService) { try @@ -956,9 +996,9 @@ public class SpotifyTrackMatchingService : BackgroundService var primaryArtist = artists.FirstOrDefault() ?? ""; var titleStripped = FuzzyMatcher.StripDecorators(title); var query = $"{titleStripped} {primaryArtist}"; - + var allCandidates = new List<(Song Song, double Score)>(); - + // STEP 1: Search LOCAL Jellyfin library FIRST using var scope = _serviceProvider.CreateScope(); var proxyService = scope.ServiceProvider.GetService(); @@ -974,9 +1014,9 @@ public class SpotifyTrackMatchingService : BackgroundService ["recursive"] = "true", ["limit"] = "10" }; - + var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams); - + if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items)) { var localResults = new List(); @@ -985,7 +1025,7 @@ public class SpotifyTrackMatchingService : BackgroundService var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : ""; var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; var artist = ""; - + if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) { artist = artistsEl[0].GetString() ?? ""; @@ -994,7 +1034,7 @@ public class SpotifyTrackMatchingService : BackgroundService { artist = albumArtistEl.GetString() ?? ""; } - + localResults.Add(new Song { Id = id, @@ -1003,7 +1043,7 @@ public class SpotifyTrackMatchingService : BackgroundService IsLocal = true }); } - + if (localResults.Count > 0) { // Score local results @@ -1021,20 +1061,20 @@ public class SpotifyTrackMatchingService : BackgroundService x.ArtistScore, TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) }) - .Where(x => - x.TotalScore >= 40 || + .Where(x => + x.TotalScore >= 40 || (x.ArtistScore >= 70 && x.TitleScore >= 30) || x.TitleScore >= 85) .OrderByDescending(x => x.TotalScore) .Select(x => (x.Song, x.TotalScore)) .ToList(); - + allCandidates.AddRange(scoredLocal); - + // If we found good local matches, return them (don't search external) if (scoredLocal.Any(x => x.TotalScore >= 70)) { - _logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search", + _logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search", scoredLocal.Count, title); return allCandidates; } @@ -1046,10 +1086,10 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogWarning(ex, "Failed to search local library for '{Title}'", title); } } - + // STEP 2: Only search EXTERNAL if no good local match found var externalResults = await metadataService.SearchSongsAsync(query, limit: 10); - + if (externalResults.Count > 0) { var scoredExternal = externalResults @@ -1066,17 +1106,17 @@ public class SpotifyTrackMatchingService : BackgroundService x.ArtistScore, TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3) }) - .Where(x => - x.TotalScore >= 40 || + .Where(x => + x.TotalScore >= 40 || (x.ArtistScore >= 70 && x.TitleScore >= 30) || x.TitleScore >= 85) .OrderByDescending(x => x.TotalScore) .Select(x => (x.Song, x.TotalScore)) .ToList(); - + allCandidates.AddRange(scoredExternal); } - + return allCandidates; } catch @@ -1084,14 +1124,14 @@ public class SpotifyTrackMatchingService : BackgroundService return new List<(Song, double)>(); } } - + private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist) { var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTitle, jellyfinTitle); var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyArtist, jellyfinArtist); return (titleScore * 0.7) + (artistScore * 0.3); } - + /// /// Attempts to match a track by ISRC. /// SEARCHES LOCAL FIRST, then external if no local match found. @@ -1103,20 +1143,20 @@ public class SpotifyTrackMatchingService : BackgroundService // STEP 1: Search LOCAL Jellyfin library FIRST by ISRC // Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search // Local tracks will be found via fuzzy matching instead - + // STEP 2: Search EXTERNAL by ISRC var results = await metadataService.SearchSongsAsync($"isrc:{isrc}", limit: 1); if (results.Count > 0 && results[0].Isrc == isrc) { return results[0]; } - + // Some providers may not support isrc: prefix, try without results = await metadataService.SearchSongsAsync(isrc, limit: 5); - var exactMatch = results.FirstOrDefault(r => - !string.IsNullOrEmpty(r.Isrc) && + var exactMatch = results.FirstOrDefault(r => + !string.IsNullOrEmpty(r.Isrc) && r.Isrc.Equals(isrc, StringComparison.OrdinalIgnoreCase)); - + return exactMatch; } catch @@ -1124,7 +1164,7 @@ public class SpotifyTrackMatchingService : BackgroundService return null; } } - + /// /// Attempts to match a track by title and artist using AGGRESSIVE fuzzy matching. /// FOLLOWS OPTIMAL ORDER: @@ -1134,22 +1174,22 @@ public class SpotifyTrackMatchingService : BackgroundService /// PRIORITY: Match as many tracks as possible, even with lower confidence. /// private async Task TryMatchByFuzzyAsync( - string title, - List artists, + string title, + List artists, IMusicMetadataService metadataService) { try { var primaryArtist = artists.FirstOrDefault() ?? ""; - + // STEP 1: Strip decorators FIRST (before searching) var titleStripped = FuzzyMatcher.StripDecorators(title); var query = $"{titleStripped} {primaryArtist}"; - + var results = await metadataService.SearchSongsAsync(query, limit: 10); - + if (results.Count == 0) return null; - + // STEP 2-3: Score all results (substring + Levenshtein in CalculateSimilarityAggressive) var scoredResults = results .Select(song => new @@ -1169,11 +1209,11 @@ public class SpotifyTrackMatchingService : BackgroundService }) .OrderByDescending(x => x.TotalScore) .ToList(); - + var bestMatch = scoredResults.FirstOrDefault(); - + if (bestMatch == null) return null; - + // AGGRESSIVE: Accept matches with score >= 40 (was 50) if (bestMatch.TotalScore >= 40) { @@ -1181,7 +1221,7 @@ public class SpotifyTrackMatchingService : BackgroundService bestMatch.TotalScore, bestMatch.TitleScore, bestMatch.ArtistScore, title, bestMatch.Song.Title); return bestMatch.Song; } - + // SUPER AGGRESSIVE: If artist matches well (70+), accept even lower title scores // This handles cases like "a" → "a-blah" where artist is the same if (bestMatch.ArtistScore >= 70 && bestMatch.TitleScore >= 30) @@ -1190,7 +1230,7 @@ public class SpotifyTrackMatchingService : BackgroundService bestMatch.ArtistScore, bestMatch.TitleScore, title, bestMatch.Song.Title); return bestMatch.Song; } - + // ULTRA AGGRESSIVE: If title has high substring match (85+), accept it // This handles "luther" → "luther (feat. sza)" if (bestMatch.TitleScore >= 85) @@ -1199,7 +1239,7 @@ public class SpotifyTrackMatchingService : BackgroundService bestMatch.TitleScore, title, bestMatch.Song.Title); return bestMatch.Song; } - + return null; } catch @@ -1207,7 +1247,7 @@ public class SpotifyTrackMatchingService : BackgroundService return null; } } - + /// /// Legacy matching mode using MissingTrack from Jellyfin plugin. /// @@ -1217,13 +1257,13 @@ public class SpotifyTrackMatchingService : BackgroundService CancellationToken cancellationToken) { var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName); - var matchedTracksKey = $"spotify:matched:{playlistName}"; - + var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName); + // Check if we already have matched tracks cached var existingMatched = await _cache.GetAsync>(matchedTracksKey); if (existingMatched != null && existingMatched.Count > 0) { - _logger.LogWarning("Playlist {Playlist} already has {Count} matched tracks cached, skipping", + _logger.LogWarning("Playlist {Playlist} already has {Count} matched tracks cached, skipping", playlistName, existingMatched.Count); return; } @@ -1236,7 +1276,7 @@ public class SpotifyTrackMatchingService : BackgroundService return; } - _logger.LogWarning("Matching {Count} tracks for {Playlist} (with rate limiting)", + _logger.LogWarning("Matching {Count} tracks for {Playlist} (with rate limiting)", missingTracks.Count, playlistName); var matchedSongs = new List(); @@ -1250,7 +1290,7 @@ public class SpotifyTrackMatchingService : BackgroundService { var query = $"{track.Title} {track.PrimaryArtist}"; var results = await metadataService.SearchSongsAsync(query, limit: 5); - + if (results.Count > 0) { // Fuzzy match to find best result @@ -1272,26 +1312,26 @@ public class SpotifyTrackMatchingService : BackgroundService }) .OrderByDescending(x => x.TotalScore) .FirstOrDefault(); - + if (bestMatch != null && bestMatch.TotalScore >= 60) { matchedSongs.Add(bestMatch.Song); matchCount++; - + if (matchCount % 10 == 0) { - _logger.LogInformation("Matched {Count}/{Total} tracks for {Playlist}", + _logger.LogInformation("Matched {Count}/{Total} tracks for {Playlist}", matchCount, missingTracks.Count, playlistName); } } } - + // Rate limiting: delay between searches await Task.Delay(DelayBetweenSearchesMs, cancellationToken); } catch (Exception ex) { - _logger.LogError(ex, "Failed to match track: {Title} - {Artist}", + _logger.LogError(ex, "Failed to match track: {Title} - {Artist}", track.Title, track.PrimaryArtist); } } @@ -1300,7 +1340,7 @@ public class SpotifyTrackMatchingService : BackgroundService { // Cache matched tracks for configurable duration await _cache.SetAsync(matchedTracksKey, matchedSongs, CacheExtensions.SpotifyMatchedTracksTTL); - _logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}", + _logger.LogInformation("✓ Cached {Matched}/{Total} matched tracks for {Playlist}", matchedSongs.Count, missingTracks.Count, playlistName); } else @@ -1308,7 +1348,7 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogInformation("No tracks matched for {Playlist}", playlistName); } } - + /// /// Pre-builds the playlist items cache for instant serving. /// This combines local Jellyfin tracks with external matched tracks in the correct Spotify order. @@ -1325,56 +1365,65 @@ public class SpotifyTrackMatchingService : BackgroundService try { _logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName); - + if (string.IsNullOrEmpty(jellyfinPlaylistId)) { _logger.LogError("No Jellyfin playlist ID configured for {Playlist}, cannot pre-build cache", playlistName); return; } - + // Get existing tracks from Jellyfin playlist using var scope = _serviceProvider.CreateScope(); var proxyService = scope.ServiceProvider.GetService(); var responseBuilder = scope.ServiceProvider.GetService(); var jellyfinSettings = scope.ServiceProvider.GetService>()?.Value; - + if (proxyService == null || responseBuilder == null || jellyfinSettings == null) { _logger.LogWarning("Required services not available for pre-building cache"); return; } - + var userId = jellyfinSettings.UserId; if (string.IsNullOrEmpty(userId)) { _logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName); return; } - + // Create authentication headers for background service call var headers = new HeaderDictionary(); if (!string.IsNullOrEmpty(jellyfinSettings.ApiKey)) { headers["X-Emby-Authorization"] = $"MediaBrowser Token=\"{jellyfinSettings.ApiKey}\""; } - + // Request all fields that clients typically need (not just MediaSources) var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items?UserId={userId}&Fields=Genres,DateCreated,MediaSources,ParentId,People,Tags,SortName,ProviderIds"; var (existingTracksResponse, statusCode) = await proxyService.GetJsonAsync(playlistItemsUrl, null, headers); - + if (statusCode != 200 || existingTracksResponse == null) { _logger.LogError("Failed to fetch Jellyfin playlist items for {Playlist}: HTTP {StatusCode}", playlistName, statusCode); return; } - + // Index Jellyfin items by title+artist for matching var jellyfinItemsByName = new Dictionary(); - + if (existingTracksResponse.RootElement.TryGetProperty("Items", out var items)) { foreach (var item in items.EnumerateArray()) { + // Ignore synthetic external stubs when building local match candidates. + // They belong to allstarr and should not be treated as local Jellyfin tracks. + if (item.TryGetProperty("ServerId", out var serverIdEl) && + serverIdEl.ValueKind == JsonValueKind.String && + string.Equals(serverIdEl.GetString(), "allstarr", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; var artist = ""; if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0) @@ -1385,7 +1434,7 @@ public class SpotifyTrackMatchingService : BackgroundService { artist = albumArtistEl.GetString() ?? ""; } - + var key = $"{title}|{artist}".ToLowerInvariant(); if (!jellyfinItemsByName.ContainsKey(key)) { @@ -1393,7 +1442,7 @@ public class SpotifyTrackMatchingService : BackgroundService } } } - + // Build the final track list in correct Spotify order // PRIORITY: Local Jellyfin tracks FIRST, then external for unmatched only var finalItems = new List>(); @@ -1402,18 +1451,18 @@ public class SpotifyTrackMatchingService : BackgroundService var localUsedCount = 0; var externalUsedCount = 0; var manualExternalCount = 0; - + foreach (var spotifyTrack in spotifyTracks.OrderBy(t => t.Position)) { if (cancellationToken.IsCancellationRequested) break; - + JsonElement? matchedJellyfinItem = null; string? matchedKey = null; - + // FIRST: Check for manual Jellyfin mapping - var manualMappingKey = $"spotify:manual-map:{playlistName}:{spotifyTrack.SpotifyId}"; + var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, spotifyTrack.SpotifyId); var manualJellyfinId = await _cache.GetAsync(manualMappingKey); - + if (!string.IsNullOrEmpty(manualJellyfinId)) { // Find the Jellyfin item by ID @@ -1424,12 +1473,12 @@ public class SpotifyTrackMatchingService : BackgroundService { matchedJellyfinItem = item; matchedKey = kvp.Key; - _logger.LogInformation("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}", + _logger.LogInformation("✓ Using manual Jellyfin mapping for {Title}: Jellyfin ID {Id}", spotifyTrack.Title, manualJellyfinId); break; } } - + if (matchedJellyfinItem.HasValue) { // Use the raw Jellyfin item (preserves ALL metadata) @@ -1446,10 +1495,10 @@ public class SpotifyTrackMatchingService : BackgroundService { itemDict["ProviderIds"] = new Dictionary(); } - + // Handle ProviderIds which might be a JsonElement or Dictionary Dictionary? providerIds = null; - + if (itemDict["ProviderIds"] is Dictionary dict) { providerIds = dict; @@ -1465,16 +1514,19 @@ public class SpotifyTrackMatchingService : BackgroundService // Replace the JsonElement with the Dictionary itemDict["ProviderIds"] = providerIds; } - + if (providerIds != null && !providerIds.ContainsKey("Jellyfin")) { providerIds["Jellyfin"] = jellyfinId; - _logger.LogDebug("Added Jellyfin ID {JellyfinId} to manual mapped local track {Title}", + _logger.LogDebug("Added Jellyfin ID {JellyfinId} to manual mapped local track {Title}", jellyfinId, spotifyTrack.Title); } } } - + + ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId, + spotifyTrack.AlbumId); + finalItems.Add(itemDict); if (matchedKey != null) { @@ -1486,59 +1538,59 @@ public class SpotifyTrackMatchingService : BackgroundService continue; // Skip to next track } } - + // SECOND: Check for external manual mapping - var externalMappingKey = $"spotify:external-map:{playlistName}:{spotifyTrack.SpotifyId}"; + var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, spotifyTrack.SpotifyId); var externalMappingJson = await _cache.GetStringAsync(externalMappingKey); - + if (!string.IsNullOrEmpty(externalMappingJson)) { try { using var doc = JsonDocument.Parse(externalMappingJson); var root = doc.RootElement; - + string? provider = null; string? externalId = null; - + if (root.TryGetProperty("provider", out var providerEl)) { provider = providerEl.GetString(); } - + if (root.TryGetProperty("id", out var idEl)) { externalId = idEl.GetString(); } - + if (!string.IsNullOrEmpty(provider) && !string.IsNullOrEmpty(externalId)) { // Fetch full metadata from the provider instead of using minimal Spotify data Song? externalSong = null; - + try { using var metadataScope = _serviceProvider.CreateScope(); var metadataServiceForFetch = metadataScope.ServiceProvider.GetRequiredService(); externalSong = await metadataServiceForFetch.GetSongAsync(provider, externalId); - + if (externalSong != null) { - _logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}", + _logger.LogInformation("✓ Fetched full metadata for manual external mapping: {Title} by {Artist}", externalSong.Title, externalSong.Artist); } else { - _logger.LogError("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback", + _logger.LogError("Failed to fetch metadata for {Provider} ID {ExternalId}, using fallback", provider, externalId); } } catch (Exception ex) { - _logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback", + _logger.LogWarning(ex, "Error fetching metadata for {Provider} ID {ExternalId}, using fallback", provider, externalId); } - + // Fallback to minimal metadata if fetch failed if (externalSong == null) { @@ -1555,31 +1607,18 @@ public class SpotifyTrackMatchingService : BackgroundService ExternalId = externalId }; } - + // Convert external song to Jellyfin item format and add to finalItems var externalItem = responseBuilder.ConvertSongToJellyfinItem(externalSong); - - // Add Spotify ID to ProviderIds so lyrics can work - if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId)) - { - if (!externalItem.ContainsKey("ProviderIds")) - { - externalItem["ProviderIds"] = new Dictionary(); - } - - var providerIds = externalItem["ProviderIds"] as Dictionary; - if (providerIds != null && !providerIds.ContainsKey("Spotify")) - { - providerIds["Spotify"] = spotifyTrack.SpotifyId; - } - } - + ProviderIdsEnricher.EnsureSpotifyProviderIds(externalItem, spotifyTrack.SpotifyId, + spotifyTrack.AlbumId); + finalItems.Add(externalItem); externalUsedCount++; manualExternalCount++; matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external) - - _logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}", + + _logger.LogInformation("✓ Using manual external mapping for {Title}: {Provider} {ExternalId}", spotifyTrack.Title, provider, externalId); continue; // Skip to next track } @@ -1589,14 +1628,14 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogError(ex, "Failed to process external manual mapping for {Title}", spotifyTrack.Title); } } - + // THIRD: Try AGGRESSIVE fuzzy matching with local Jellyfin tracks (PRIORITY!) double bestScore = 0; - + foreach (var kvp in jellyfinItemsByName) { if (usedJellyfinItems.Contains(kvp.Key)) continue; - + var item = kvp.Value; var title = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : ""; var artist = ""; @@ -1604,18 +1643,18 @@ public class SpotifyTrackMatchingService : BackgroundService { artist = artistsEl[0].GetString() ?? ""; } - + // Use AGGRESSIVE matching with decorator stripping var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(spotifyTrack.Title, title); var artistScore = FuzzyMatcher.CalculateSimilarity(spotifyTrack.PrimaryArtist, artist); - + // Weight: 70% title, 30% artist (prioritize title matching) var totalScore = (titleScore * 0.7) + (artistScore * 0.3); - + // AGGRESSIVE: Accept score >= 40 (was 70) // Also accept if artist matches well (70+) and title is decent (30+) var isGoodMatch = totalScore >= 40 || (artistScore >= 70 && titleScore >= 30); - + if (totalScore > bestScore && isGoodMatch) { bestScore = totalScore; @@ -1623,7 +1662,7 @@ public class SpotifyTrackMatchingService : BackgroundService matchedKey = kvp.Key; } } - + if (matchedJellyfinItem.HasValue) { // Use the raw Jellyfin item (preserves ALL metadata) @@ -1640,10 +1679,10 @@ public class SpotifyTrackMatchingService : BackgroundService { itemDict["ProviderIds"] = new Dictionary(); } - + // Handle ProviderIds which might be a JsonElement or Dictionary Dictionary? providerIds = null; - + if (itemDict["ProviderIds"] is Dictionary dict) { providerIds = dict; @@ -1659,26 +1698,29 @@ public class SpotifyTrackMatchingService : BackgroundService // Replace the JsonElement with the Dictionary itemDict["ProviderIds"] = providerIds; } - + if (providerIds != null) { if (!providerIds.ContainsKey("Jellyfin")) { providerIds["Jellyfin"] = jellyfinId; } - + // Add Spotify ID for matching in track details endpoint if (!providerIds.ContainsKey("Spotify") && !string.IsNullOrEmpty(spotifyTrack.SpotifyId)) { providerIds["Spotify"] = spotifyTrack.SpotifyId; } - - _logger.LogDebug("Fuzzy matched local track {Title} with Jellyfin ID {Id} (score: {Score:F1})", + + _logger.LogDebug("Fuzzy matched local track {Title} with Jellyfin ID {Id} (score: {Score:F1})", spotifyTrack.Title, jellyfinId, bestScore); } } } - + + ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId, + spotifyTrack.AlbumId); + finalItems.Add(itemDict); if (matchedKey != null) { @@ -1696,33 +1738,20 @@ public class SpotifyTrackMatchingService : BackgroundService { // Convert external song to Jellyfin item format var externalItem = responseBuilder.ConvertSongToJellyfinItem(matched.MatchedSong); - - // Add Spotify ID to ProviderIds for matching in track details endpoint - if (!string.IsNullOrEmpty(spotifyTrack.SpotifyId)) - { - if (!externalItem.ContainsKey("ProviderIds")) - { - externalItem["ProviderIds"] = new Dictionary(); - } - - var providerIds = externalItem["ProviderIds"] as Dictionary; - if (providerIds != null && !providerIds.ContainsKey("Spotify")) - { - providerIds["Spotify"] = spotifyTrack.SpotifyId; - } - } - + ProviderIdsEnricher.EnsureSpotifyProviderIds(externalItem, spotifyTrack.SpotifyId, + spotifyTrack.AlbumId); + finalItems.Add(externalItem); matchedSpotifyIds.Add(spotifyTrack.SpotifyId); // Mark as matched (external) externalUsedCount++; - - _logger.LogDebug("Using external match for {Title}: {Provider}", + + _logger.LogDebug("Using external match for {Title}: {Provider}", spotifyTrack.Title, matched.MatchedSong.ExternalProvider); } // else: Track remains unmatched (not added to finalItems) } } - + if (finalItems.Count > 0) { // Enrich external tracks with genres from MusicBrainz @@ -1734,10 +1763,10 @@ public class SpotifyTrackMatchingService : BackgroundService if (genreEnrichment != null) { _logger.LogDebug("🎨 Enriching {Count} external tracks with genres from MusicBrainz...", externalUsedCount); - + // Extract external songs from externalMatchedTracks that were actually used var usedExternalSpotifyIds = finalItems - .Where(item => item.TryGetValue("Id", out var idObj) && + .Where(item => item.TryGetValue("Id", out var idObj) && idObj is string id && id.StartsWith("ext-")) .Select(item => { @@ -1751,17 +1780,17 @@ public class SpotifyTrackMatchingService : BackgroundService }) .Where(id => !string.IsNullOrEmpty(id)) .ToHashSet(); - + var externalSongs = externalMatchedTracks - .Where(t => t.MatchedSong != null && - !t.MatchedSong.IsLocal && + .Where(t => t.MatchedSong != null && + !t.MatchedSong.IsLocal && usedExternalSpotifyIds.Contains(t.SpotifyId)) .Select(t => t.MatchedSong!) .ToList(); - + // Enrich genres in parallel await genreEnrichment.EnrichSongsGenresAsync(externalSongs); - + // Update the genres in finalItems foreach (var item in finalItems) { @@ -1773,7 +1802,7 @@ public class SpotifyTrackMatchingService : BackgroundService { // Update Genres array item["Genres"] = new[] { song.Genre }; - + // Update GenreItems array item["GenreItems"] = new[] { @@ -1783,12 +1812,12 @@ public class SpotifyTrackMatchingService : BackgroundService ["Id"] = $"genre-{song.Genre.ToLowerInvariant()}" } }; - + _logger.LogDebug("✓ Enriched {Title} with genre: {Genre}", song.Title, song.Genre); } } } - + _logger.LogInformation("✅ Genre enrichment complete for {Playlist}", playlistName); } } @@ -1797,21 +1826,21 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogError(ex, "Failed to enrich genres for {Playlist}, continuing without genres", playlistName); } } - + // Save to Redis cache with same expiration as matched tracks (until next cron run) var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName); await _cache.SetAsync(cacheKey, finalItems, cacheExpiration); - + // Save to file cache for persistence await SavePlaylistItemsToFileAsync(playlistName, finalItems); - + var manualMappingInfo = ""; if (manualExternalCount > 0) { manualMappingInfo = $" [Manual external: {manualExternalCount}]"; } - - _logger.LogDebug("✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h", + + _logger.LogDebug("✅ Pre-built playlist cache for {Playlist}: {Total} tracks ({Local} LOCAL + {External} EXTERNAL){ManualInfo} - expires in {Hours:F1}h", playlistName, finalItems.Count, localUsedCount, externalUsedCount, manualMappingInfo, cacheExpiration.TotalHours); } else @@ -1824,7 +1853,7 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogError(ex, "Failed to pre-build playlist items cache for {Playlist}", playlistName); } } - + /// /// Saves playlist items to file cache for persistence across restarts. /// @@ -1834,13 +1863,13 @@ public class SpotifyTrackMatchingService : BackgroundService { var cacheDir = "/app/cache/spotify"; Directory.CreateDirectory(cacheDir); - + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); var filePath = Path.Combine(cacheDir, $"{safeName}_items.json"); - + var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true }); await System.IO.File.WriteAllTextAsync(filePath, json); - + _logger.LogDebug("💾 Saved {Count} playlist items to file cache: {Path}", items.Count, filePath); } catch (Exception ex) @@ -1848,7 +1877,7 @@ public class SpotifyTrackMatchingService : BackgroundService _logger.LogError(ex, "Failed to save playlist items to file for {Playlist}", playlistName); } } - + /// /// Saves matched tracks to file cache for persistence across restarts. /// @@ -1858,13 +1887,13 @@ public class SpotifyTrackMatchingService : BackgroundService { var cacheDir = "/app/cache/spotify"; Directory.CreateDirectory(cacheDir); - + var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars())); var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json"); - + var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true }); await System.IO.File.WriteAllTextAsync(filePath, json); - + _logger.LogInformation("💾 Saved {Count} matched tracks to file cache: {Path}", matchedTracks.Count, filePath); } catch (Exception ex) @@ -1873,4 +1902,3 @@ public class SpotifyTrackMatchingService : BackgroundService } } } - diff --git a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs index 3690792..f6981b9 100644 --- a/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFDownloadService.cs @@ -145,9 +145,16 @@ public class SquidWTFDownloadService : BaseDownloadService var url = $"{baseUrl}/track/?id={trackId}&quality={quality}"; + Logger.LogDebug("Requesting track download info: {Url}", url); + // Get download info from this endpoint var infoResponse = await _httpClient.GetAsync(url, cancellationToken); - infoResponse.EnsureSuccessStatusCode(); + + if (!infoResponse.IsSuccessStatusCode) + { + Logger.LogWarning("Track download request failed: {StatusCode} {Url}", infoResponse.StatusCode, url); + infoResponse.EnsureSuccessStatusCode(); + } var json = await infoResponse.Content.ReadAsStringAsync(cancellationToken); var doc = JsonDocument.Parse(json); @@ -251,7 +258,12 @@ public class SquidWTFDownloadService : BaseDownloadService Logger.LogDebug("Fetching track download info from: {Url}", url); var response = await _httpClient.GetAsync(url, cancellationToken); - response.EnsureSuccessStatusCode(); + + if (!response.IsSuccessStatusCode) + { + Logger.LogWarning("Track download info request failed: {StatusCode} {Url}", response.StatusCode, url); + response.EnsureSuccessStatusCode(); + } var json = await response.Content.ReadAsStringAsync(cancellationToken); var doc = JsonDocument.Parse(json); diff --git a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs index 8bde985..0f6810a 100644 --- a/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs +++ b/allstarr/Services/SquidWTF/SquidWTFMetadataService.cs @@ -13,10 +13,10 @@ namespace allstarr.Services.SquidWTF; /// /// Metadata service implementation using the SquidWTF API (free, no key required). -/// +/// /// SquidWTF is a proxy to Tidal's API that provides free access to Tidal's music catalog. /// This implementation follows the hifi-api specification documented at the forked repository. -/// +/// /// API Endpoints (per hifi-api spec): /// - GET /search/?s={query} - Search tracks (returns data.items array) /// - GET /search/?a={query} - Search artists (returns data.artists.items array) @@ -24,22 +24,23 @@ namespace allstarr.Services.SquidWTF; /// - GET /search/?p={query} - Search playlists (returns data.playlists.items array, undocumented) /// - GET /info/?id={trackId} - Get track metadata (returns data object with full track info) /// - GET /track/?id={trackId}&quality={quality} - Get track download info (returns manifest) +/// - GET /recommendations/?id={trackId} - Get recommended next/similar tracks /// - GET /album/?id={albumId} - Get album with tracks (undocumented, returns data.items array) /// - GET /artist/?f={artistId} - Get artist with albums (undocumented, returns albums.items array) /// - GET /playlist/?id={playlistId} - Get playlist with tracks (undocumented) -/// +/// /// Quality Options: /// - HI_RES_LOSSLESS: 24-bit/192kHz FLAC /// - LOSSLESS: 16-bit/44.1kHz FLAC /// - HIGH: 320kbps AAC /// - LOW: 96kbps AAC -/// +/// /// Response Structure: /// All responses follow: { "version": "2.0", "data": { ... } } /// Track objects include: id, title, duration, trackNumber, volumeNumber, explicit, bpm, isrc, /// artist (singular), artists (array), album (object with id, title, cover UUID) /// Cover art URLs: https://resources.tidal.com/images/{uuid-with-slashes}/{size}.jpg -/// +/// /// Features: /// - Round-robin load balancing across multiple mirror endpoints /// - Automatic failover to backup endpoints on failure @@ -49,7 +50,7 @@ namespace allstarr.Services.SquidWTF; /// - Parallel Spotify ID conversion via Odesli for lyrics matching /// -public class SquidWTFMetadataService : IMusicMetadataService +public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService { private readonly HttpClient _httpClient; private readonly SubsonicSettings _settings; @@ -59,7 +60,7 @@ public class SquidWTFMetadataService : IMusicMetadataService private readonly GenreEnrichmentService? _genreEnrichment; public SquidWTFMetadataService( - IHttpClientFactory httpClientFactory, + IHttpClientFactory httpClientFactory, IOptions settings, IOptions squidwtfSettings, ILogger logger, @@ -73,154 +74,280 @@ public class SquidWTFMetadataService : IMusicMetadataService _cache = cache; _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); _genreEnrichment = genreEnrichment; - + // Set up default headers - _httpClient.DefaultRequestHeaders.Add("User-Agent", + _httpClient.DefaultRequestHeaders.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0"); - + // Increase timeout for large artist/album responses (some artists have 100+ albums) _httpClient.Timeout = TimeSpan.FromMinutes(5); } - - - - public async Task> SearchSongsAsync(string query, int limit = 20) + + + + public async Task> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var allSongs = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) { - // Race top 3 fastest endpoints for search (latency-sensitive) - return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) => + var songs = await SearchSongsSingleQueryAsync(queryVariant, limit, cancellationToken); + foreach (var song in songs) { - // Use 's' parameter for track search as per hifi-api spec - var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}"; - var response = await _httpClient.GetAsync(url, ct); - - if (!response.IsSuccessStatusCode) + var key = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) { - throw new HttpRequestException($"HTTP {response.StatusCode}"); + continue; } - var json = await response.Content.ReadAsStringAsync(); - - // Check for error in response body - var result = JsonDocument.Parse(json); - if (result.RootElement.TryGetProperty("detail", out _) || - result.RootElement.TryGetProperty("error", out _)) + allSongs.Add(song); + if (allSongs.Count >= limit) { - throw new HttpRequestException("API returned error response"); + break; } + } - var songs = new List(); - // Per hifi-api spec: track search returns data.items array - if (result.RootElement.TryGetProperty("data", out var data) && - data.TryGetProperty("items", out var items)) - { - int count = 0; - foreach (var track in items.EnumerateArray()) - { - if (count >= limit) break; - - var song = ParseTidalTrack(track); - if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) - { - songs.Add(song); - } - count++; - } - } - return songs; - }); - } - - public async Task> SearchAlbumsAsync(string query, int limit = 20) - { - // Race top 3 fastest endpoints for search (latency-sensitive) - return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) => + if (allSongs.Count >= limit) { - // Use 'al' parameter for album search - // a= is for artists, al= is for albums, p= is for playlists - var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}"; - var response = await _httpClient.GetAsync(url, ct); - - if (!response.IsSuccessStatusCode) - { - throw new HttpRequestException($"HTTP {response.StatusCode}"); - } - - var json = await response.Content.ReadAsStringAsync(); - var result = JsonDocument.Parse(json); - - var albums = new List(); - // Per hifi-api spec: album search returns data.albums.items array - if (result.RootElement.TryGetProperty("data", out var data) && - data.TryGetProperty("albums", out var albumsObj) && - albumsObj.TryGetProperty("items", out var items)) - { - int count = 0; - foreach (var album in items.EnumerateArray()) - { - if (count >= limit) break; - - albums.Add(ParseTidalAlbum(album)); - count++; - } - } - - return albums; - }); + break; + } } - public async Task> SearchArtistsAsync(string query, int limit = 20) + _logger.LogInformation("✓ SQUIDWTF: Song search returned {Count} results", allSongs.Count); + return allSongs; + } + + public async Task> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var allAlbums = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) { - // Race top 3 fastest endpoints for search (latency-sensitive) - return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) => + var albums = await SearchAlbumsSingleQueryAsync(queryVariant, limit, cancellationToken); + foreach (var album in albums) { - // Per hifi-api spec: use 'a' parameter for artist search - var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}"; - _logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url); - - var response = await _httpClient.GetAsync(url, ct); - - if (!response.IsSuccessStatusCode) + var key = !string.IsNullOrWhiteSpace(album.ExternalId) ? album.ExternalId : album.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) { - _logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode); - throw new HttpRequestException($"HTTP {response.StatusCode}"); + continue; } - var json = await response.Content.ReadAsStringAsync(); - var result = JsonDocument.Parse(json); - - var artists = new List(); - // Per hifi-api spec: artist search returns data.artists.items array - if (result.RootElement.TryGetProperty("data", out var data) && - data.TryGetProperty("artists", out var artistsObj) && - artistsObj.TryGetProperty("items", out var items)) + allAlbums.Add(album); + if (allAlbums.Count >= limit) { - int count = 0; - foreach (var artist in items.EnumerateArray()) - { - if (count >= limit) break; - - var parsedArtist = ParseTidalArtist(artist); - artists.Add(parsedArtist); - _logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId); - count++; - } + break; } + } - _logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count); - return artists; - }); + if (allAlbums.Count >= limit) + { + break; + } } - - public async Task> SearchPlaylistsAsync(string query, int limit = 20) + + _logger.LogInformation("✓ SQUIDWTF: Album search returned {Count} results", allAlbums.Count); + return allAlbums; + } + + public async Task> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) + { + var allArtists = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var queryVariant in BuildSearchQueryVariants(query)) + { + var artists = await SearchArtistsSingleQueryAsync(queryVariant, limit, cancellationToken); + foreach (var artist in artists) + { + var key = !string.IsNullOrWhiteSpace(artist.ExternalId) ? artist.ExternalId : artist.Id; + if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key)) + { + continue; + } + + allArtists.Add(artist); + if (allArtists.Count >= limit) + { + break; + } + } + + if (allArtists.Count >= limit) + { + break; + } + } + + _logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", allArtists.Count); + return allArtists; + } + + private async Task> SearchSongsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) + { + // Use benchmark-ordered fallback (no endpoint racing). + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => + { + // Use 's' parameter for track search as per hifi-api spec + var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}"; + var response = await _httpClient.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP {response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + + // Check for error in response body + var result = JsonDocument.Parse(json); + if (result.RootElement.TryGetProperty("detail", out _) || + result.RootElement.TryGetProperty("error", out _)) + { + throw new HttpRequestException("API returned error response"); + } + + var songs = new List(); + // Per hifi-api spec: track search returns data.items array + if (result.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("items", out var items)) + { + int count = 0; + foreach (var track in items.EnumerateArray()) + { + if (count >= limit) break; + + var song = ParseTidalTrack(track); + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) + { + songs.Add(song); + } + count++; + } + } + return songs; + }, new List()); + } + + private async Task> SearchAlbumsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) + { + // Use benchmark-ordered fallback (no endpoint racing). + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => + { + // Use 'al' parameter for album search + // a= is for artists, al= is for albums, p= is for playlists + var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}"; + var response = await _httpClient.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + throw new HttpRequestException($"HTTP {response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var result = JsonDocument.Parse(json); + + var albums = new List(); + // Per hifi-api spec: album search returns data.albums.items array + if (result.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("albums", out var albumsObj) && + albumsObj.TryGetProperty("items", out var items)) + { + int count = 0; + foreach (var album in items.EnumerateArray()) + { + if (count >= limit) break; + + albums.Add(ParseTidalAlbum(album)); + count++; + } + } + + return albums; + }, new List()); + } + + private async Task> SearchArtistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken) + { + // Use benchmark-ordered fallback (no endpoint racing). + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => + { + // Per hifi-api spec: use 'a' parameter for artist search + var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}"; + _logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url); + + var response = await _httpClient.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode); + throw new HttpRequestException($"HTTP {response.StatusCode}"); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var result = JsonDocument.Parse(json); + + var artists = new List(); + // Per hifi-api spec: artist search returns data.artists.items array + if (result.RootElement.TryGetProperty("data", out var data) && + data.TryGetProperty("artists", out var artistsObj) && + artistsObj.TryGetProperty("items", out var items)) + { + int count = 0; + foreach (var artist in items.EnumerateArray()) + { + if (count >= limit) break; + + var parsedArtist = ParseTidalArtist(artist); + artists.Add(parsedArtist); + _logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId); + count++; + } + } + + return artists; + }, new List()); + } + + private static IReadOnlyList BuildSearchQueryVariants(string query) + { + var variants = new List(); + + AddVariant(variants, query); + + if (query.Contains('&')) + { + AddVariant(variants, query.Replace("&", " and ")); + } + + return variants; + } + + private static void AddVariant(List variants, string candidate) + { + var normalized = System.Text.RegularExpressions.Regex.Replace(candidate, @"\s+", " ").Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + return; + } + + if (!variants.Contains(normalized, StringComparer.OrdinalIgnoreCase)) + { + variants.Add(normalized); + } + } + + public async Task> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default) { return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => { // Per hifi-api spec: use 'p' parameter for playlist search var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}"; - var response = await _httpClient.GetAsync(url); + var response = await _httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); var playlists = new List(); @@ -233,7 +360,7 @@ public class SquidWTFMetadataService : IMusicMetadataService foreach(var playlist in items.EnumerateArray()) { if (count >= limit) break; - + try { playlists.Add(ParseTidalPlaylist(playlist)); @@ -250,17 +377,17 @@ public class SquidWTFMetadataService : IMusicMetadataService }, new List()); } - public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20) + public async Task SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default) { // Execute searches in parallel - var songsTask = SearchSongsAsync(query, songLimit); - var albumsTask = SearchAlbumsAsync(query, albumLimit); - var artistsTask = SearchArtistsAsync(query, artistLimit); - + var songsTask = SearchSongsAsync(query, songLimit, cancellationToken); + var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken); + var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken); + await Task.WhenAll(songsTask, albumsTask, artistsTask); - + var temp = new SearchResult - { + { Songs = await songsTask, Albums = await albumsTask, Artists = await artistsTask @@ -269,27 +396,27 @@ public class SquidWTFMetadataService : IMusicMetadataService return temp; } - public async Task GetSongAsync(string externalProvider, string externalId) + public async Task GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "squidwtf") return null; - + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => { // Per hifi-api spec: GET /info/?id={trackId} returns track metadata var url = $"{baseUrl}/info/?id={externalId}"; - - var response = await _httpClient.GetAsync(url); + + var response = await _httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + // Per hifi-api spec: response is { "version": "2.0", "data": { track object } } if (!result.RootElement.TryGetProperty("data", out var track)) return null; var song = ParseTidalTrackFull(track); - + // Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres) if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre)) { @@ -306,40 +433,124 @@ public class SquidWTFMetadataService : IMusicMetadataService } }); } - + // NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService) // This avoids redundant conversions and ensures it's done in parallel with the download - + return song; }, (Song?)null); } - public async Task GetAlbumAsync(string externalProvider, string externalId) + public async Task> GetTrackRecommendationsAsync(string externalId, int limit = 20, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(externalId)) return new List(); + + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => + { + var url = $"{baseUrl}/recommendations/?id={Uri.EscapeDataString(externalId)}"; + var response = await _httpClient.GetAsync(url, cancellationToken); + + if (!response.IsSuccessStatusCode) + { + _logger.LogDebug("SquidWTF recommendations request failed for track {TrackId} with status {StatusCode}", + externalId, response.StatusCode); + return new List(); + } + + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var result = JsonDocument.Parse(json); + + if (!result.RootElement.TryGetProperty("data", out var data) || + !data.TryGetProperty("items", out var items) || + items.ValueKind != JsonValueKind.Array) + { + return new List(); + } + + var songs = new List(); + var seenIds = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var recommendation in items.EnumerateArray()) + { + JsonElement track; + if (recommendation.TryGetProperty("track", out var wrappedTrack)) + { + track = wrappedTrack; + } + else + { + track = recommendation; + } + + if (!track.TryGetProperty("id", out _)) + { + continue; + } + + Song song; + try + { + song = ParseTidalTrack(track); + } + catch + { + continue; + } + + if (string.Equals(song.ExternalId, externalId, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + var songKey = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id; + if (string.IsNullOrWhiteSpace(songKey) || !seenIds.Add(songKey)) + { + continue; + } + + if (!ShouldIncludeSong(song)) + { + continue; + } + + songs.Add(song); + if (songs.Count >= limit) + { + break; + } + } + + _logger.LogDebug("SQUIDWTF: Recommendations returned {Count} songs for track {TrackId}", songs.Count, externalId); + return songs; + }, new List()); + } + + public async Task GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "squidwtf") return null; - + // Try cache first - var cacheKey = $"squidwtf:album:{externalId}"; + var cacheKey = CacheKeyBuilder.BuildAlbumKey("squidwtf", externalId); var cached = await _cache.GetAsync(cacheKey); if (cached != null) return cached; - + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => { // Note: hifi-api doesn't document album endpoint, but /album/?id={albumId} is commonly used var url = $"{baseUrl}/album/?id={externalId}"; - - var response = await _httpClient.GetAsync(url); + + var response = await _httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var result = JsonDocument.Parse(json); - + // Response structure: { "data": { album object with "items" array of tracks } } if (!result.RootElement.TryGetProperty("data", out var albumElement)) return null; var album = ParseTidalAlbum(albumElement); - + // Get album tracks from items array if (albumElement.TryGetProperty("items", out var tracks)) { @@ -356,49 +567,49 @@ public class SquidWTFMetadataService : IMusicMetadataService } } } - + // Cache for configurable duration await _cache.SetAsync(cacheKey, album, CacheExtensions.MetadataTTL); - - return album; + + return album; }, (Album?)null); } - - public async Task GetArtistAsync(string externalProvider, string externalId) + + public async Task GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "squidwtf") return null; - + _logger.LogDebug("GetArtistAsync called for SquidWTF artist {ExternalId}", externalId); - + // Try cache first - var cacheKey = $"squidwtf:artist:{externalId}"; + var cacheKey = CacheKeyBuilder.BuildArtistKey("squidwtf", externalId); var cached = await _cache.GetAsync(cacheKey); if (cached != null) { _logger.LogDebug("Returning cached artist {ArtistName}, ImageUrl: {ImageUrl}", cached.Name, cached.ImageUrl ?? "NULL"); return cached; } - + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => { // Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used - var url = $"{baseUrl}/artist/?f={externalId}"; + var url = $"{baseUrl}/artist/?f={externalId}"; _logger.LogDebug("Fetching artist from {Url}", url); - var response = await _httpClient.GetAsync(url); + var response = await _httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) { _logger.LogError("SquidWTF artist request failed with status {StatusCode}", response.StatusCode); return null; } - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug("SquidWTF artist response: {Json}", json.Length > 500 ? json.Substring(0, 500) + "..." : json); var result = JsonDocument.Parse(json); - + JsonElement? artistSource = null; int albumCount = 0; - + // Response structure: { "albums": { "items": [ album objects ] }, "tracks": [ track objects ] } // Extract artist info from albums.items[0].artist (most reliable source) if (result.RootElement.TryGetProperty("albums", out var albums) && @@ -412,9 +623,9 @@ public class SquidWTFMetadataService : IMusicMetadataService _logger.LogDebug("Found artist from albums, albumCount={AlbumCount}", albumCount); } } - + // Fallback: try to get artist from tracks[0].artists[0] - if (artistSource == null && + if (artistSource == null && result.RootElement.TryGetProperty("tracks", out var tracks) && tracks.GetArrayLength() > 0 && tracks[0].TryGetProperty("artists", out var artists) && @@ -426,20 +637,20 @@ public class SquidWTFMetadataService : IMusicMetadataService if (artistSource == null) { - _logger.LogDebug("Could not find artist data in response. Response keys: {Keys}", + _logger.LogDebug("Could not find artist data in response. Response keys: {Keys}", string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name))); return null; } var artistElement = artistSource.Value; - + // Extract picture UUID (may be null) string? pictureUuid = null; if (artistElement.TryGetProperty("picture", out var pictureEl) && pictureEl.ValueKind != JsonValueKind.Null) { pictureUuid = pictureEl.GetString(); } - + // Normalize artist data to include album count var normalizedArtist = new JsonObject { @@ -451,41 +662,41 @@ public class SquidWTFMetadataService : IMusicMetadataService using var doc = JsonDocument.Parse(normalizedArtist.ToJsonString()); var artist = ParseTidalArtist(doc.RootElement); - + _logger.LogDebug("Successfully parsed artist {ArtistName} with {AlbumCount} albums", artist.Name, albumCount); - + // Cache for configurable duration await _cache.SetAsync(cacheKey, artist, CacheExtensions.MetadataTTL); - + return artist; }, (Artist?)null); } - public async Task> GetArtistAlbumsAsync(string externalProvider, string externalId) + public async Task> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "squidwtf") return new List(); - + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => { _logger.LogDebug("GetArtistAlbumsAsync called for SquidWTF artist {ExternalId}", externalId); - + // Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used var url = $"{baseUrl}/artist/?f={externalId}"; _logger.LogDebug("Fetching artist albums from URL: {Url}", url); - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) { _logger.LogError("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode); return new List(); } - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug("SquidWTF artist albums response for {ExternalId}: {JsonLength} bytes", externalId, json.Length); var result = JsonDocument.Parse(json); - + var albums = new List(); - + // Response structure: { "albums": { "items": [ album objects ] } } if (result.RootElement.TryGetProperty("albums", out var albumsObj) && albumsObj.TryGetProperty("items", out var items)) @@ -493,7 +704,7 @@ public class SquidWTFMetadataService : IMusicMetadataService foreach (var album in items.EnumerateArray()) { var parsedAlbum = ParseTidalAlbum(album); - _logger.LogInformation("Parsed album: {AlbumTitle} by {ArtistName} (ArtistId: {ArtistId})", + _logger.LogInformation("Parsed album: {AlbumTitle} by {ArtistName} (ArtistId: {ArtistId})", parsedAlbum.Title, parsedAlbum.Artist, parsedAlbum.ArtistId); albums.Add(parsedAlbum); } @@ -503,36 +714,36 @@ public class SquidWTFMetadataService : IMusicMetadataService { _logger.LogWarning("No albums found in response for artist {ExternalId}", externalId); } - + return albums; }, new List()); } - public async Task> GetArtistTracksAsync(string externalProvider, string externalId) + public async Task> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "squidwtf") return new List(); - + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => { _logger.LogDebug("GetArtistTracksAsync called for SquidWTF artist {ExternalId}", externalId); - + // Same endpoint as albums - /artist/?f={artistId} returns both albums and tracks var url = $"{baseUrl}/artist/?f={externalId}"; _logger.LogDebug("Fetching artist tracks from URL: {Url}", url); - var response = await _httpClient.GetAsync(url); - + var response = await _httpClient.GetAsync(url, cancellationToken); + if (!response.IsSuccessStatusCode) { _logger.LogError("SquidWTF artist tracks request failed with status {StatusCode}", response.StatusCode); return new List(); } - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); _logger.LogDebug("SquidWTF artist tracks response for {ExternalId}: {JsonLength} bytes", externalId, json.Length); var result = JsonDocument.Parse(json); - + var tracks = new List(); - + // Response structure: { "tracks": [ track objects ] } if (result.RootElement.TryGetProperty("tracks", out var tracksArray)) { @@ -547,12 +758,12 @@ public class SquidWTFMetadataService : IMusicMetadataService { _logger.LogWarning("No tracks found in response for artist {ExternalId}", externalId); } - + return tracks; }, new List()); } - public async Task GetPlaylistAsync(string externalProvider, string externalId) + public async Task GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "squidwtf") return null; @@ -560,10 +771,10 @@ public class SquidWTFMetadataService : IMusicMetadataService { // Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used var url = $"{baseUrl}/playlist/?id={externalId}"; - var response = await _httpClient.GetAsync(url); + var response = await _httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return null; - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); var rootElement = JsonDocument.Parse(json).RootElement; // Check for error response @@ -577,24 +788,24 @@ public class SquidWTFMetadataService : IMusicMetadataService return ParseTidalPlaylist(playlistElement); }, (ExternalPlaylist?)null); } - - public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId) + + public async Task> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default) { if (externalProvider != "squidwtf") return new List(); - + return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => { // Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used var url = $"{baseUrl}/playlist/?id={externalId}"; - var response = await _httpClient.GetAsync(url); + var response = await _httpClient.GetAsync(url, cancellationToken); if (!response.IsSuccessStatusCode) return new List(); - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(cancellationToken); var playlistElement = JsonDocument.Parse(json).RootElement; - + // Check for error response if (playlistElement.TryGetProperty("error", out _)) return new List(); - + JsonElement? playlist = null; JsonElement? tracks = null; @@ -607,10 +818,10 @@ public class SquidWTFMetadataService : IMusicMetadataService if (playlistElement.TryGetProperty("items", out var tracksEl)) { tracks = tracksEl; - } - + } + var songs = new List(); - + // Get playlist name for album field var playlistName = playlist?.TryGetProperty("title", out var titleEl) == true ? titleEl.GetString() ?? "Unknown Playlist" @@ -624,17 +835,17 @@ public class SquidWTFMetadataService : IMusicMetadataService // Each item is wrapped: { "item": { track object } } if (!entry.TryGetProperty("item", out var track)) continue; - + // For playlists, use the track's own artist (not a single album artist) var song = ParseTidalTrack(track, trackIndex); - + // Override album name to be the playlist name song.Album = playlistName; - + // Playlists should not have disc numbers - always set to null // This prevents Jellyfin from splitting the playlist into multiple "discs" song.DiscNumber = null; - + if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter)) { songs.Add(song); @@ -648,6 +859,16 @@ public class SquidWTFMetadataService : IMusicMetadataService // --- Parser functions start here --- + private static string? BuildTidalImageUrl(string? imageId, string size) + { + if (string.IsNullOrWhiteSpace(imageId)) + { + return null; + } + + return $"https://resources.tidal.com/images/{imageId.Replace("-", "/")}/{size}.jpg"; + } + /// /// Parses a Tidal track object from hifi-api search/album/playlist responses. /// Per hifi-api spec, track objects contain: id, title, duration, trackNumber, volumeNumber, @@ -660,41 +881,68 @@ public class SquidWTFMetadataService : IMusicMetadataService { var externalId = track.GetProperty("id").GetInt64().ToString(); - // Explicit content lyrics value - idk if this will work int? explicitContentLyrics = track.TryGetProperty("explicit", out var ecl) && ecl.ValueKind == JsonValueKind.True ? 1 : 0; - - int? trackNumber = track.TryGetProperty("trackNumber", out var trackNum) - ? trackNum.GetInt32() + + var title = track.GetProperty("title").GetString() ?? ""; + if (track.TryGetProperty("version", out var version)) + { + var versionStr = version.GetString(); + if (!string.IsNullOrWhiteSpace(versionStr)) + { + title = $"{title} ({versionStr})"; + } + } + + int? trackNumber = track.TryGetProperty("trackNumber", out var trackNum) && trackNum.ValueKind == JsonValueKind.Number + ? trackNum.GetInt32() : fallbackTrackNumber; - - int? discNumber = track.TryGetProperty("volumeNumber", out var volNum) + + int? discNumber = track.TryGetProperty("volumeNumber", out var volNum) && volNum.ValueKind == JsonValueKind.Number ? volNum.GetInt32() : null; - + + int? bpm = track.TryGetProperty("bpm", out var bpmVal) && bpmVal.ValueKind == JsonValueKind.Number + ? (int)bpmVal.GetDouble() + : null; + + string? isrc = track.TryGetProperty("isrc", out var isrcVal) && isrcVal.ValueKind == JsonValueKind.String + ? isrcVal.GetString() + : null; + + string? releaseDate = track.TryGetProperty("streamStartDate", out var streamStartDate) && streamStartDate.ValueKind == JsonValueKind.String + ? streamStartDate.GetString() + : null; + int? year = ParseYearFromDateString(releaseDate); + // Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array) var allArtists = new List(); var allArtistIds = new List(); string artistName = ""; string? artistId = null; - + // Prefer the "artists" array as it includes all collaborators - if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0) + if (track.TryGetProperty("artists", out var artists) && artists.ValueKind == JsonValueKind.Array && artists.GetArrayLength() > 0) { foreach (var artistEl in artists.EnumerateArray()) { - var name = artistEl.GetProperty("name").GetString(); - var id = artistEl.GetProperty("id").GetInt64(); - if (!string.IsNullOrEmpty(name)) + if (!artistEl.TryGetProperty("name", out var nameElement) || + !artistEl.TryGetProperty("id", out var idElement)) + { + continue; + } + + var name = nameElement.GetString(); + var id = GetIdAsString(idElement); + if (!string.IsNullOrWhiteSpace(name) && !string.IsNullOrWhiteSpace(id)) { allArtists.Add(name); - allArtistIds.Add($"ext-squidwtf-artist-{id}"); + allArtistIds.Add(BuildExternalArtistId("squidwtf", id)); } } - - // First artist is the main artist + if (allArtists.Count > 0) { artistName = allArtists[0]; @@ -704,45 +952,133 @@ public class SquidWTFMetadataService : IMusicMetadataService // Fallback to singular "artist" field else if (track.TryGetProperty("artist", out var artist)) { - artistName = artist.GetProperty("name").GetString() ?? ""; - artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}"; - allArtists.Add(artistName); - allArtistIds.Add(artistId); + artistName = artist.TryGetProperty("name", out var artistNameEl) ? artistNameEl.GetString() ?? "" : ""; + if (artist.TryGetProperty("id", out var artistIdEl)) + { + var externalArtistId = GetIdAsString(artistIdEl); + if (!string.IsNullOrWhiteSpace(externalArtistId)) + { + artistId = BuildExternalArtistId("squidwtf", externalArtistId); + } + } + + if (!string.IsNullOrWhiteSpace(artistName)) + { + allArtists.Add(artistName); + } + + if (!string.IsNullOrWhiteSpace(artistId)) + { + allArtistIds.Add(artistId); + } } - + + var contributors = allArtists + .Skip(1) + .Where(a => !string.IsNullOrWhiteSpace(a)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + // Get album info string albumTitle = ""; string? albumId = null; string? coverArt = null; - + string? coverArtLarge = null; + string? albumArtist = null; + int? totalTracks = null; + string? copyright = track.TryGetProperty("copyright", out var copyrightVal) && copyrightVal.ValueKind == JsonValueKind.String + ? copyrightVal.GetString() + : null; + if (track.TryGetProperty("album", out var album)) { - albumTitle = album.GetProperty("title").GetString() ?? ""; - albumId = $"ext-squidwtf-album-{album.GetProperty("id").GetInt64()}"; - + if (album.TryGetProperty("title", out var albumTitleEl)) + { + albumTitle = albumTitleEl.GetString() ?? ""; + } + + if (album.TryGetProperty("id", out var albumIdEl)) + { + var externalAlbumId = GetIdAsString(albumIdEl); + if (!string.IsNullOrWhiteSpace(externalAlbumId)) + { + albumId = BuildExternalAlbumId("squidwtf", externalAlbumId); + } + } + if (album.TryGetProperty("cover", out var cover)) { - var coverGuid = cover.GetString()?.Replace("-", "/"); - coverArt = $"https://resources.tidal.com/images/{coverGuid}/320x320.jpg"; + var coverId = cover.GetString(); + coverArt = BuildTidalImageUrl(coverId, "320x320"); + coverArtLarge = BuildTidalImageUrl(coverId, "1280x1280"); + } + + if (album.TryGetProperty("numberOfTracks", out var numberOfTracks) && numberOfTracks.ValueKind == JsonValueKind.Number) + { + totalTracks = numberOfTracks.GetInt32(); + } + + if (album.TryGetProperty("releaseDate", out var albumReleaseDate) && albumReleaseDate.ValueKind == JsonValueKind.String) + { + var albumReleaseDateValue = albumReleaseDate.GetString(); + if (!string.IsNullOrWhiteSpace(albumReleaseDateValue)) + { + releaseDate = albumReleaseDateValue; + year = ParseYearFromDateString(albumReleaseDateValue); + } + } + + if (album.TryGetProperty("artist", out var albumArtistEl) && + albumArtistEl.TryGetProperty("name", out var albumArtistNameEl)) + { + albumArtist = albumArtistNameEl.GetString(); + } + else if (album.TryGetProperty("artists", out var albumArtistsEl) && + albumArtistsEl.ValueKind == JsonValueKind.Array && + albumArtistsEl.GetArrayLength() > 0 && + albumArtistsEl[0].TryGetProperty("name", out var firstAlbumArtistNameEl)) + { + albumArtist = firstAlbumArtistNameEl.GetString(); + } + + if (string.IsNullOrWhiteSpace(copyright) && + album.TryGetProperty("copyright", out var albumCopyright) && + albumCopyright.ValueKind == JsonValueKind.String) + { + copyright = albumCopyright.GetString(); } } - + + if (string.IsNullOrWhiteSpace(albumArtist)) + { + albumArtist = artistName; + } + return new Song { - Id = $"ext-squidwtf-song-{externalId}", - Title = track.GetProperty("title").GetString() ?? "", + Id = BuildExternalSongId("squidwtf", externalId), + Title = title, Artist = artistName, ArtistId = artistId, Artists = allArtists, ArtistIds = allArtistIds, Album = albumTitle, AlbumId = albumId, - Duration = track.TryGetProperty("duration", out var duration) - ? duration.GetInt32() + AlbumArtist = albumArtist, + Duration = track.TryGetProperty("duration", out var duration) && duration.ValueKind == JsonValueKind.Number + ? duration.GetInt32() : null, Track = trackNumber, DiscNumber = discNumber, + TotalTracks = totalTracks, + Year = year, + ReleaseDate = releaseDate, + Bpm = bpm, + Isrc = isrc, CoverArtUrl = coverArt, + CoverArtUrlLarge = coverArtLarge, + Contributors = contributors, + Copyright = copyright, IsLocal = false, ExternalProvider = "squidwtf", ExternalId = externalId, @@ -759,128 +1095,8 @@ public class SquidWTFMetadataService : IMusicMetadataService /// Parsed Song object with extended metadata private Song ParseTidalTrackFull(JsonElement track) { - var externalId = track.GetProperty("id").GetInt64().ToString(); - - // Explicit content lyrics value - idk if this will work - int? explicitContentLyrics = - track.TryGetProperty("explicit", out var ecl) && ecl.ValueKind == JsonValueKind.True - ? 1 - : 0; - - - int? trackNumber = track.TryGetProperty("trackNumber", out var trackNum) - ? trackNum.GetInt32() - : null; - - int? discNumber = track.TryGetProperty("volumeNumber", out var volNum) - ? volNum.GetInt32() - : null; - - int? bpm = track.TryGetProperty("bpm", out var bpmVal) && bpmVal.ValueKind == JsonValueKind.Number - ? bpmVal.GetInt32() - : null; - - string? isrc = track.TryGetProperty("isrc", out var isrcVal) - ? isrcVal.GetString() - : null; - - int? year = null; - if (track.TryGetProperty("streamStartDate", out var streamDate)) - { - var dateStr = streamDate.GetString(); - if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4) - { - if (int.TryParse(dateStr.Substring(0, 4), out var y)) - year = y; - } - } - - // Get all artists - prefer "artists" array for collaborations - var allArtists = new List(); - var allArtistIds = new List(); - string artistName = ""; - long artistIdNum = 0; - - if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0) - { - foreach (var artistEl in artists.EnumerateArray()) - { - var name = artistEl.GetProperty("name").GetString(); - var id = artistEl.GetProperty("id").GetInt64(); - if (!string.IsNullOrEmpty(name)) - { - allArtists.Add(name); - allArtistIds.Add($"ext-squidwtf-artist-{id}"); - } - } - - if (allArtists.Count > 0) - { - artistName = allArtists[0]; - artistIdNum = artists[0].GetProperty("id").GetInt64(); - } - } - else if (track.TryGetProperty("artist", out var artist)) - { - artistName = artist.GetProperty("name").GetString() ?? ""; - artistIdNum = artist.GetProperty("id").GetInt64(); - allArtists.Add(artistName); - allArtistIds.Add($"ext-squidwtf-artist-{artistIdNum}"); - } - - // Album artist - same as main artist for Tidal tracks - string? albumArtist = artistName; - - // Get album info - var album = track.GetProperty("album"); - string albumTitle = album.GetProperty("title").GetString() ?? ""; - long albumIdNum = album.GetProperty("id").GetInt64(); - - // Cover art URLs - string? coverArt = null; - string? coverArtLarge = null; - if (album.TryGetProperty("cover", out var cover)) - { - var coverGuid = cover.GetString()?.Replace("-", "/"); - coverArt = $"https://resources.tidal.com/images/{coverGuid}/320x320.jpg"; - coverArtLarge = $"https://resources.tidal.com/images/{coverGuid}/1280x1280.jpg"; - } - - // Copyright - string? copyright = track.TryGetProperty("copyright", out var copyrightVal) - ? copyrightVal.GetString() - : null; - - // Explicit content - bool isExplicit = track.TryGetProperty("explicit", out var explicitVal) && explicitVal.GetBoolean(); - - return new Song - { - Id = $"ext-squidwtf-song-{externalId}", - Title = track.GetProperty("title").GetString() ?? "", - Artist = artistName, - ArtistId = $"ext-squidwtf-artist-{artistIdNum}", - Artists = allArtists, - ArtistIds = allArtistIds, - Album = albumTitle, - AlbumId = $"ext-squidwtf-album-{albumIdNum}", - AlbumArtist = albumArtist, - Duration = track.TryGetProperty("duration", out var duration) - ? duration.GetInt32() - : null, - Track = trackNumber, - DiscNumber = discNumber, - Year = year, - Bpm = bpm, - Isrc = isrc, - CoverArtUrl = coverArt, - CoverArtUrlLarge = coverArtLarge, - Label = copyright, // Store copyright in label field - IsLocal = false, - ExternalProvider = "squidwtf", - ExternalId = externalId, - ExplicitContentLyrics = explicitContentLyrics - }; + // Full track payloads include all fields handled by ParseTidalTrack. + return ParseTidalTrack(track); } /// @@ -893,48 +1109,56 @@ public class SquidWTFMetadataService : IMusicMetadataService private Album ParseTidalAlbum(JsonElement album) { var externalId = album.GetProperty("id").GetInt64().ToString(); - + + var title = album.GetProperty("title").GetString() ?? ""; + if (album.TryGetProperty("version", out var version)) + { + var versionStr = version.GetString(); + if (!string.IsNullOrWhiteSpace(versionStr)) + { + title = $"{title} ({versionStr})"; + } + } + int? year = null; if (album.TryGetProperty("releaseDate", out var releaseDate)) { - var dateStr = releaseDate.GetString(); - if (!string.IsNullOrEmpty(dateStr) && dateStr.Length >= 4) - { - if (int.TryParse(dateStr.Substring(0, 4), out var y)) - year = y; - } + year = ParseYearFromDateString(releaseDate.GetString()); } - + else if (album.TryGetProperty("streamStartDate", out var streamStartDate)) + { + year = ParseYearFromDateString(streamStartDate.GetString()); + } + string? coverArt = null; if (album.TryGetProperty("cover", out var cover)) { - var coverGuid = cover.GetString()?.Replace("-", "/"); - coverArt = $"https://resources.tidal.com/images/{coverGuid}/320x320.jpg"; + coverArt = BuildTidalImageUrl(cover.GetString(), "320x320"); } - + // Get artist name string artistName = ""; string? artistId = null; if (album.TryGetProperty("artist", out var artist)) { artistName = artist.GetProperty("name").GetString() ?? ""; - artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}"; + artistId = BuildExternalArtistId("squidwtf", GetIdAsString(artist.GetProperty("id"))); } else if (album.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0) { artistName = artists[0].GetProperty("name").GetString() ?? ""; - artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}"; + artistId = BuildExternalArtistId("squidwtf", GetIdAsString(artists[0].GetProperty("id"))); } - + return new Album { - Id = $"ext-squidwtf-album-{externalId}", - Title = album.GetProperty("title").GetString() ?? "", + Id = BuildExternalAlbumId("squidwtf", externalId), + Title = title, Artist = artistName, ArtistId = artistId, Year = year, - SongCount = album.TryGetProperty("numberOfTracks", out var trackCount) - ? trackCount.GetInt32() + SongCount = album.TryGetProperty("numberOfTracks", out var trackCount) && trackCount.ValueKind == JsonValueKind.Number + ? trackCount.GetInt32() : null, CoverArtUrl = coverArt, IsLocal = false, @@ -954,22 +1178,19 @@ public class SquidWTFMetadataService : IMusicMetadataService { var externalId = artist.GetProperty("id").GetInt64().ToString(); var artistName = artist.GetProperty("name").GetString() ?? ""; - - string? imageUrl = null; - if (artist.TryGetProperty("picture", out var picture)) + + var imageUrl = artist.TryGetProperty("picture", out var picture) + ? BuildTidalImageUrl(picture.GetString(), "320x320") + : null; + + if (!string.IsNullOrWhiteSpace(imageUrl)) { - var pictureUuid = picture.GetString(); - if (!string.IsNullOrEmpty(pictureUuid)) - { - var pictureGuid = pictureUuid.Replace("-", "/"); - imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/320x320.jpg"; - _logger.LogDebug("Artist {ArtistName} picture: {ImageUrl}", artistName, imageUrl); - } + _logger.LogDebug("Artist {ArtistName} picture: {ImageUrl}", artistName, imageUrl); } - + return new Artist { - Id = $"ext-squidwtf-artist-{externalId}", + Id = BuildExternalArtistId("squidwtf", externalId), Name = artistName, ImageUrl = imageUrl, AlbumCount = artist.TryGetProperty("albums_count", out var albumsCount) @@ -980,7 +1201,7 @@ public class SquidWTFMetadataService : IMusicMetadataService ExternalId = externalId }; } - + /// /// Parses a Tidal playlist from hifi-api /playlist/ endpoint response. /// Per hifi-api spec (undocumented), response structure is: @@ -1008,14 +1229,14 @@ public class SquidWTFMetadataService : IMusicMetadataService else if (creator.TryGetProperty("id", out var id)) { // Handle both string and number types for creator.id - var idValue = id.ValueKind == JsonValueKind.Number - ? id.GetInt32().ToString() + var idValue = id.ValueKind == JsonValueKind.Number + ? id.GetInt32().ToString() : id.GetString(); - - // If creator ID is 0 or empty, it's a TIDAL-curated playlist + + // If creator ID is 0/empty, treat as unknown and allow promotedArtists fallback. if (idValue == "0" || string.IsNullOrEmpty(idValue)) { - curatorName = "TIDAL"; + curatorName = null; } else { @@ -1023,7 +1244,16 @@ public class SquidWTFMetadataService : IMusicMetadataService } } } - + + if (string.IsNullOrWhiteSpace(curatorName) && + playlistElement.TryGetProperty("promotedArtists", out var promotedArtists) && + promotedArtists.ValueKind == JsonValueKind.Array && + promotedArtists.GetArrayLength() > 0 && + promotedArtists[0].TryGetProperty("name", out var promotedArtistName)) + { + curatorName = promotedArtistName.GetString(); + } + // Final fallback: if still no curator name, use TIDAL if (string.IsNullOrEmpty(curatorName)) { @@ -1041,30 +1271,48 @@ public class SquidWTFMetadataService : IMusicMetadataService } } + if (createdDate == null && + playlistElement.TryGetProperty("lastUpdated", out var lastUpdatedEl) && + DateTime.TryParse(lastUpdatedEl.GetString(), out var lastUpdatedDate)) + { + createdDate = lastUpdatedDate; + } + + if (createdDate == null && + playlistElement.TryGetProperty("lastItemAddedAt", out var lastItemAddedAtEl) && + DateTime.TryParse(lastItemAddedAtEl.GetString(), out var lastItemAddedAtDate)) + { + createdDate = lastItemAddedAtDate; + } + // Get playlist image URL string? imageUrl = null; if (playlistElement.TryGetProperty("squareImage", out var picture)) { - var pictureGuid = picture.GetString()?.Replace("-", "/"); - imageUrl = $"https://resources.tidal.com/images/{pictureGuid}/1080x1080.jpg"; - // Maybe later add support for potential fallbacks if this size isn't available + imageUrl = BuildTidalImageUrl(picture.GetString(), "1080x1080"); + } + + if (string.IsNullOrWhiteSpace(imageUrl) && + playlistElement.TryGetProperty("image", out var image)) + { + imageUrl = BuildTidalImageUrl(image.GetString(), "1080x1080"); } return new ExternalPlaylist { Id = Common.PlaylistIdHelper.CreatePlaylistId("squidwtf", externalId), Name = playlistElement.GetProperty("title").GetString() ?? "", - Description = playlistElement.TryGetProperty("description", out var desc) - ? desc.GetString() + Description = playlistElement.TryGetProperty("description", out var desc) + ? desc.GetString() : null, CuratorName = curatorName, Provider = "squidwtf", ExternalId = externalId, - TrackCount = playlistElement.TryGetProperty("numberOfTracks", out var nbTracks) - ? nbTracks.GetInt32() + TrackCount = playlistElement.TryGetProperty("numberOfTracks", out var nbTracks) + ? nbTracks.GetInt32() : 0, - Duration = playlistElement.TryGetProperty("duration", out var duration) - ? duration.GetInt32() + Duration = playlistElement.TryGetProperty("duration", out var duration) + ? duration.GetInt32() : 0, CoverUrl = imageUrl, CreatedDate = createdDate @@ -1082,21 +1330,21 @@ public class SquidWTFMetadataService : IMusicMetadataService // If no explicit content info, include the song if (song.ExplicitContentLyrics == null) return true; - + return _settings.ExplicitFilter switch { // All: No filtering, include everything ExplicitFilter.All => true, - + // ExplicitOnly: Exclude clean/edited versions (value 3) // Include: 0 (naturally clean), 1 (explicit), 2 (not applicable), 6/7 (unknown) ExplicitFilter.ExplicitOnly => song.ExplicitContentLyrics != 3, - + // CleanOnly: Only show clean content // Include: 0 (naturally clean), 3 (clean/edited version) // Exclude: 1 (explicit) ExplicitFilter.CleanOnly => song.ExplicitContentLyrics != 1, - + _ => true }; } @@ -1115,21 +1363,21 @@ public class SquidWTFMetadataService : IMusicMetadataService { var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}"; var response = await _httpClient.GetAsync(url, ct); - + if (!response.IsSuccessStatusCode) { return null; } - - var json = await response.Content.ReadAsStringAsync(); + + var json = await response.Content.ReadAsStringAsync(ct); var result = JsonDocument.Parse(json); - - if (result.RootElement.TryGetProperty("detail", out _) || + + if (result.RootElement.TryGetProperty("detail", out _) || result.RootElement.TryGetProperty("error", out _)) { return null; } - + if (result.RootElement.TryGetProperty("data", out var data) && data.TryGetProperty("items", out var items)) { @@ -1142,7 +1390,7 @@ public class SquidWTFMetadataService : IMusicMetadataService } } } - + return null; } catch diff --git a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs index 58f93a7..75e1ae7 100644 --- a/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs +++ b/allstarr/Services/SquidWTF/SquidWTFStartupValidator.cs @@ -1,4 +1,3 @@ -using System.Text; using System.Text.Json; using Microsoft.Extensions.Options; using allstarr.Models.Settings; @@ -13,24 +12,29 @@ namespace allstarr.Services.SquidWTF; public class SquidWTFStartupValidator : BaseStartupValidator { private readonly SquidWTFSettings _settings; - private readonly RoundRobinFallbackHelper _fallbackHelper; + private readonly List _apiUrls; + private readonly List _streamingUrls; + private readonly RoundRobinFallbackHelper _apiFallbackHelper; + private readonly RoundRobinFallbackHelper _streamingFallbackHelper; private readonly EndpointBenchmarkService _benchmarkService; - private readonly ILogger _logger; public override string ServiceName => "SquidWTF"; public SquidWTFStartupValidator( IOptions settings, HttpClient httpClient, - List apiUrls, + List apiUrls, + List streamingUrls, EndpointBenchmarkService benchmarkService, ILogger logger) : base(httpClient) { _settings = settings.Value; - _fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF"); + _apiUrls = apiUrls; + _streamingUrls = streamingUrls; + _apiFallbackHelper = new RoundRobinFallbackHelper(_apiUrls, logger, "SquidWTF API"); + _streamingFallbackHelper = new RoundRobinFallbackHelper(_streamingUrls, logger, "SquidWTF Streaming"); _benchmarkService = benchmarkService; - _logger = logger; } @@ -50,74 +54,14 @@ public class SquidWTFStartupValidator : BaseStartupValidator WriteStatus("SquidWTF Quality", quality, ConsoleColor.Cyan); - // Benchmark all endpoints to determine fastest - var apiUrls = _fallbackHelper.EndpointCount > 0 - ? Enumerable.Range(0, _fallbackHelper.EndpointCount).Select(_ => "").ToList() // Placeholder, we'll get actual URLs from fallback helper - : new List(); + WriteStatus("SquidWTF API Endpoints", _apiUrls.Count.ToString(), ConsoleColor.Cyan); + WriteStatus("SquidWTF Streaming Endpoints", _streamingUrls.Count.ToString(), ConsoleColor.Cyan); - // Get the actual API URLs by reflection (not ideal, but works for now) - var fallbackHelperType = _fallbackHelper.GetType(); - var apiUrlsField = fallbackHelperType.GetField("_apiUrls", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); - if (apiUrlsField != null) - { - apiUrls = (List)apiUrlsField.GetValue(_fallbackHelper)!; - } + await BenchmarkEndpointPoolAsync("API", _apiUrls, _apiFallbackHelper, cancellationToken); + await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken); - if (apiUrls.Count > 1) - { - WriteStatus("Benchmarking Endpoints", $"{apiUrls.Count} endpoints", ConsoleColor.Cyan); - - var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync( - apiUrls, - async (endpoint, ct) => - { - try - { - // 5 second timeout per ping - mark slow endpoints as failed - using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); - timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); - - var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token); - return response.IsSuccessStatusCode; - } - catch - { - return false; - } - }, - pingCount: 5, - cancellationToken); - - if (orderedEndpoints.Count > 0) - { - _fallbackHelper.SetEndpointOrder(orderedEndpoints); - - // Show top 5 endpoints with their metrics - var topEndpoints = orderedEndpoints.Take(5).ToList(); - WriteDetail($"Fastest endpoint: {topEndpoints.First()}"); - - if (topEndpoints.Count > 1) - { - WriteDetail("Top 5 endpoints by average latency:"); - for (int i = 0; i < topEndpoints.Count; i++) - { - var endpoint = topEndpoints[i]; - var metrics = _benchmarkService.GetMetrics(endpoint); - if (metrics != null) - { - WriteDetail($" {i + 1}. {endpoint} - {metrics.AverageResponseMs}ms avg ({metrics.SuccessRate:P0} success)"); - } - else - { - WriteDetail($" {i + 1}. {endpoint}"); - } - } - } - } - } - - // Test connectivity with fallback - var result = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) => + // Validate API endpoints and search functionality. + var apiResult = await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) => { var response = await _httpClient.GetAsync(baseUrl, cancellationToken); @@ -135,9 +79,97 @@ public class SquidWTFStartupValidator : BaseStartupValidator { throw new HttpRequestException($"HTTP {(int)response.StatusCode}"); } - }, ValidationResult.Failure("-1", "All SquidWTF endpoints failed")); + }, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed")); - return result; + if (!apiResult.IsValid) + { + return apiResult; + } + + // Validate streaming endpoints independently to avoid API-only endpoints for streaming. + var streamingResult = await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) => + { + var response = await _httpClient.GetAsync(baseUrl, cancellationToken); + + if (response.IsSuccessStatusCode) + { + WriteStatus("SquidWTF Streaming", $"REACHABLE ({baseUrl})", ConsoleColor.Green); + return ValidationResult.Success("SquidWTF streaming endpoint validation completed"); + } + + throw new HttpRequestException($"HTTP {(int)response.StatusCode}"); + }, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed")); + + if (!streamingResult.IsValid) + { + return streamingResult; + } + + return ValidationResult.Success("SquidWTF API and streaming validation completed"); + } + + private async Task BenchmarkEndpointPoolAsync( + string poolName, + List endpoints, + RoundRobinFallbackHelper fallbackHelper, + CancellationToken cancellationToken) + { + if (endpoints.Count <= 1) + { + return; + } + + WriteStatus($"Benchmarking {poolName} endpoints", $"{endpoints.Count} endpoints", ConsoleColor.Cyan); + + var orderedEndpoints = await _benchmarkService.BenchmarkEndpointsAsync( + endpoints, + async (endpoint, ct) => + { + try + { + // 5 second timeout per ping - mark slow endpoints as failed. + using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(ct); + timeoutCts.CancelAfter(TimeSpan.FromSeconds(5)); + + var response = await _httpClient.GetAsync(endpoint, timeoutCts.Token); + return response.IsSuccessStatusCode; + } + catch + { + return false; + } + }, + pingCount: 5, + cancellationToken); + + if (orderedEndpoints.Count == 0) + { + WriteDetail($"No healthy {poolName} endpoints detected during benchmark"); + return; + } + + fallbackHelper.SetEndpointOrder(orderedEndpoints); + + var topEndpoints = orderedEndpoints.Take(5).ToList(); + WriteDetail($"Fastest {poolName} endpoint: {topEndpoints.First()}"); + + if (topEndpoints.Count > 1) + { + WriteDetail($"Top {topEndpoints.Count} {poolName} endpoints by average latency:"); + for (int i = 0; i < topEndpoints.Count; i++) + { + var endpoint = topEndpoints[i]; + var metrics = _benchmarkService.GetMetrics(endpoint); + if (metrics != null) + { + WriteDetail($" {i + 1}. {endpoint} - {metrics.AverageResponseMs}ms avg ({metrics.SuccessRate:P0} success)"); + } + else + { + WriteDetail($" {i + 1}. {endpoint}"); + } + } + } } private async Task ValidateSearchFunctionality(string baseUrl, CancellationToken cancellationToken) @@ -205,4 +237,4 @@ public class SquidWTFStartupValidator : BaseStartupValidator WriteDetail($"Could not verify search: {ex.Message}"); } } -} \ No newline at end of file +} diff --git a/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs b/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs new file mode 100644 index 0000000..4e98a66 --- /dev/null +++ b/allstarr/Services/SquidWTF/SquidWtfEndpointCatalog.cs @@ -0,0 +1,40 @@ +namespace allstarr.Services.SquidWTF; + +/// +/// Holds the discovered SquidWTF endpoint pools. +/// API endpoints are used for metadata/search calls. +/// Streaming endpoints are used for /track/ and audio streaming calls. +/// +public sealed class SquidWtfEndpointCatalog +{ + public SquidWtfEndpointCatalog(List apiUrls, List streamingUrls) + { + if (apiUrls == null) + { + throw new ArgumentNullException(nameof(apiUrls)); + } + + if (streamingUrls == null) + { + throw new ArgumentNullException(nameof(streamingUrls)); + } + + if (apiUrls.Count == 0) + { + throw new ArgumentException("API URL list cannot be empty.", nameof(apiUrls)); + } + + if (streamingUrls.Count == 0) + { + throw new ArgumentException("Streaming URL list cannot be empty.", nameof(streamingUrls)); + } + + ApiUrls = apiUrls; + StreamingUrls = streamingUrls; + LoadedAtUtc = DateTime.UtcNow; + } + + public List ApiUrls { get; } + public List StreamingUrls { get; } + public DateTime LoadedAtUtc { get; } +} diff --git a/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs b/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs new file mode 100644 index 0000000..b5f740b --- /dev/null +++ b/allstarr/Services/SquidWTF/SquidWtfEndpointDiscovery.cs @@ -0,0 +1,172 @@ +using System.Text.Json; + +namespace allstarr.Services.SquidWTF; + +public static class SquidWtfEndpointDiscovery +{ + public static readonly IReadOnlyList SourceUrls = new[] + { + "https://tidal-uptime.jiffy-puffs-1j.workers.dev/", + "https://tidal-uptime.props-76styles.workers.dev/" + }; + + public static async Task DiscoverAsync(CancellationToken cancellationToken = default) + { + using var httpClient = new HttpClient + { + Timeout = TimeSpan.FromSeconds(10) + }; + + var feeds = new List(); + + foreach (var sourceUrl in SourceUrls) + { + try + { + var json = await httpClient.GetStringAsync(sourceUrl, cancellationToken); + feeds.Add(ParseFeed(json)); + } + catch (Exception ex) + { + Console.WriteLine($"⚠️ Failed to load SquidWTF endpoint feed from {sourceUrl}: {ex.Message}"); + } + } + + if (feeds.Count == 0) + { + throw new InvalidOperationException("Could not load SquidWTF endpoint feeds from any source URL."); + } + + var orderedFeeds = feeds + .OrderByDescending(f => f.LastUpdated) + .ToList(); + + var downUrls = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (var feed in orderedFeeds) + { + foreach (var downUrl in feed.DownUrls) + { + downUrls.Add(downUrl); + } + } + + var apiUrls = MergeDistinctUrls(orderedFeeds.Select(f => f.ApiUrls)) + .Where(url => !downUrls.Contains(url)) + .ToList(); + + var streamingUrls = MergeDistinctUrls(orderedFeeds.Select(f => f.StreamingUrls)) + .Where(url => !downUrls.Contains(url)) + .ToList(); + + if (apiUrls.Count == 0) + { + throw new InvalidOperationException("SquidWTF endpoint feed returned zero API endpoints."); + } + + if (streamingUrls.Count == 0) + { + throw new InvalidOperationException("SquidWTF endpoint feed returned zero streaming endpoints."); + } + + Console.WriteLine($"Loaded SquidWTF endpoints from uptime feeds: api={apiUrls.Count}, streaming={streamingUrls.Count}"); + + return new SquidWtfEndpointCatalog(apiUrls, streamingUrls); + } + + private static EndpointFeed ParseFeed(string json) + { + using var document = JsonDocument.Parse(json); + var root = document.RootElement; + + DateTimeOffset lastUpdated = DateTimeOffset.MinValue; + if (root.TryGetProperty("lastUpdated", out var lastUpdatedElement) && + lastUpdatedElement.ValueKind == JsonValueKind.String && + DateTimeOffset.TryParse(lastUpdatedElement.GetString(), out var parsedLastUpdated)) + { + lastUpdated = parsedLastUpdated; + } + + var apiUrls = ParseUrlList(root, "api"); + var streamingUrls = ParseUrlList(root, "streaming"); + var downUrls = ParseUrlList(root, "down"); + + return new EndpointFeed(lastUpdated, apiUrls, streamingUrls, downUrls); + } + + private static List ParseUrlList(JsonElement root, string propertyName) + { + if (!root.TryGetProperty(propertyName, out var element) || element.ValueKind != JsonValueKind.Array) + { + return new List(); + } + + var urls = new List(); + foreach (var item in element.EnumerateArray()) + { + string? rawUrl = null; + + if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("url", out var urlElement)) + { + rawUrl = urlElement.GetString(); + } + else if (item.ValueKind == JsonValueKind.String) + { + rawUrl = item.GetString(); + } + + if (TryNormalizeUrl(rawUrl, out var normalizedUrl)) + { + urls.Add(normalizedUrl); + } + } + + return urls; + } + + private static IEnumerable MergeDistinctUrls(IEnumerable> lists) + { + var seen = new HashSet(StringComparer.OrdinalIgnoreCase); + + foreach (var list in lists) + { + foreach (var url in list) + { + if (seen.Add(url)) + { + yield return url; + } + } + } + } + + private static bool TryNormalizeUrl(string? rawUrl, out string normalizedUrl) + { + normalizedUrl = string.Empty; + + if (string.IsNullOrWhiteSpace(rawUrl)) + { + return false; + } + + var trimmed = rawUrl.Trim(); + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) + { + return false; + } + + if (!uri.Scheme.Equals("http", StringComparison.OrdinalIgnoreCase) && + !uri.Scheme.Equals("https", StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + normalizedUrl = trimmed.TrimEnd('/'); + return true; + } + + private sealed record EndpointFeed( + DateTimeOffset LastUpdated, + List ApiUrls, + List StreamingUrls, + List DownUrls); +} diff --git a/allstarr/Services/Subsonic/SubsonicProxyService.cs b/allstarr/Services/Subsonic/SubsonicProxyService.cs index 3bd3cba..de532dd 100644 --- a/allstarr/Services/Subsonic/SubsonicProxyService.cs +++ b/allstarr/Services/Subsonic/SubsonicProxyService.cs @@ -26,25 +26,25 @@ public class SubsonicProxyService /// Relays a request to the Subsonic server and returns the response. /// public async Task<(byte[] Body, string? ContentType)> RelayAsync( - string endpoint, + string endpoint, Dictionary parameters) { - var query = string.Join("&", parameters.Select(kv => + var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); var url = $"{_subsonicSettings.Url}/{endpoint}?{query}"; - + HttpResponseMessage response = await _httpClient.GetAsync(url); response.EnsureSuccessStatusCode(); - + var body = await response.Content.ReadAsByteArrayAsync(); var contentType = response.Content.Headers.ContentType?.ToString(); - + // Trigger GC for large files to prevent memory leaks if (body.Length > 1024 * 1024) // 1MB threshold { GC.Collect(2, GCCollectionMode.Optimized, blocking: false); } - + return (body, contentType); } @@ -52,7 +52,7 @@ public class SubsonicProxyService /// Safely relays a request to the Subsonic server, returning null on failure. /// public async Task<(byte[]? Body, string? ContentType, bool Success)> RelaySafeAsync( - string endpoint, + string endpoint, Dictionary parameters) { try @@ -93,14 +93,14 @@ public class SubsonicProxyService StatusCode = 500 }; } - + var incomingRequest = httpContext.Request; var outgoingResponse = httpContext.Response; - var query = string.Join("&", parameters.Select(kv => + var query = string.Join("&", parameters.Select(kv => $"{Uri.EscapeDataString(kv.Key)}={Uri.EscapeDataString(kv.Value)}")); var url = $"{_subsonicSettings.Url}/rest/stream?{query}"; - + using var request = new HttpRequestMessage(HttpMethod.Get, url); // Forward Range headers for progressive streaming support (iOS clients) @@ -108,17 +108,17 @@ public class SubsonicProxyService { request.Headers.TryAddWithoutValidation("Range", range.ToArray()); } - + if (incomingRequest.Headers.TryGetValue("If-Range", out var ifRange)) { request.Headers.TryAddWithoutValidation("If-Range", ifRange.ToArray()); } - + var response = await _httpClient.SendAsync( - request, - HttpCompletionOption.ResponseHeadersRead, + request, + HttpCompletionOption.ResponseHeadersRead, cancellationToken); - + if (!response.IsSuccessStatusCode) { return new StatusCodeResult((int)response.StatusCode); @@ -139,15 +139,15 @@ public class SubsonicProxyService var stream = await response.Content.ReadAsStreamAsync(cancellationToken); var contentType = response.Content.Headers.ContentType?.ToString() ?? "audio/mpeg"; - + return new FileStreamResult(stream, contentType) { EnableRangeProcessing = true }; } - catch (Exception ex) + catch (Exception) { - return new ObjectResult(new { error = $"Error streaming from Subsonic: {ex.Message}" }) + return new ObjectResult(new { error = "Error streaming from Subsonic" }) { StatusCode = 500 }; diff --git a/allstarr/allstarr.csproj b/allstarr/allstarr.csproj index 0a31c70..1d20344 100644 --- a/allstarr/allstarr.csproj +++ b/allstarr/allstarr.csproj @@ -5,9 +5,15 @@ enable enable allstarr - 1.1.1 - 1.1.1.0 - 1.1.1.0 + + + $([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', 'AppVersion.cs')) + $([System.IO.File]::ReadAllText('$(AppVersionFile)')) + $([System.Text.RegularExpressions.Regex]::Match('$(AppVersionText)', 'Version\s*=\s*\"([0-9]+\.[0-9]+\.[0-9]+)\"').Groups[1].Value) + + $(AppVersion) + $(AppVersion).0 + $(AppVersion).0 diff --git a/allstarr/appsettings.json b/allstarr/appsettings.json index 587fe2a..442a55a 100644 --- a/allstarr/appsettings.json +++ b/allstarr/appsettings.json @@ -8,11 +8,22 @@ } }, "Debug": { - "LogAllRequests": false + "LogAllRequests": false, + "RedactSensitiveRequestValues": false }, "Backend": { "Type": "Subsonic" }, + "Admin": { + "BindAnyIp": false, + "TrustedSubnets": "" + }, + "Cors": { + "AllowedOrigins": "", + "AllowedMethods": "GET,POST,PUT,PATCH,DELETE,OPTIONS,HEAD", + "AllowedHeaders": "Accept,Authorization,Content-Type,Range,X-Requested-With,X-Emby-Authorization,X-MediaBrowser-Token", + "AllowCredentials": false + }, "Subsonic": { "Url": "http://localhost:4533", "MusicService": "SquidWTF", @@ -55,7 +66,7 @@ "ConnectionString": "localhost:6379" }, "Cache": { - "SearchResultsMinutes": 120, + "SearchResultsMinutes": 1, "PlaylistImagesHours": 168, "SpotifyPlaylistItemsHours": 168, "SpotifyMatchedTracksDays": 30, diff --git a/allstarr/wwwroot/index.html b/allstarr/wwwroot/index.html index bc65dcd..22f8585 100644 --- a/allstarr/wwwroot/index.html +++ b/allstarr/wwwroot/index.html @@ -17,16 +17,39 @@ style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss -
+
+
+

Sign In With Jellyfin

+

Use your Jellyfin account to access the local Allstarr admin UI.

+
+ + + + + + + + +
+
+
+ +