Compare commits

...

23 Commits

Author SHA1 Message Date
joshpatra 639070556a v1.3.0-beta.1: Fixed double scrobbling, inferring stops much better, fixed playlist cron rebuilding, stale injected playlist artwork, and search cache TTL 2026-03-06 01:54:58 -05:00
joshpatra 00a5d152a5 v1.2.1-beta.1: 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, General bug fixes and optimizations
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-26 11:16:51 -05:00
joshpatra 1ba6135115 Merge branch 'main' into beta
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-21 00:25:40 -05:00
joshpatra ec994773dd Merge branch 'main' into beta 2026-02-20 20:02:55 -05:00
joshpatra 39c8f16b59 v1.1.3-beta.1: version bump, removed duplicate method; this is why we run tests... 2026-02-20 20:01:22 -05:00
joshpatra a6a423d5a1 v1.1.1-beta-1: fix: redid logic for sync schedule in playlist injection, made a constant for versioning, fixed external artist album and track fetching
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 18:57:10 -05:00
joshpatra 899451d405 v1.1.0-beta.1: fix: Scrobbling to LastFM and Listenbrainz, fixed transparent proxying, added playlists to search (shown as albums), shows all libraries and only require library id for injected playlists; refactor: rewrote all the MD's basically, split up JellyfinController in separate files, dozens of other smaller changes
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 01:22:26 -05:00
joshpatra 8d6dd7ccf1 v1.0.3-beta.1: Refactored all large files, Fixed the cron schedule bug, hardened security, added global mapping for much more stable matchings
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-16 14:59:21 -05:00
joshpatra ebdd8d4e2a v1.0.2-beta.1: WebUI refactored for better understanding, gitignore updated
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 23:17:08 -05:00
joshpatra e4599a419e v1.0.1-beta.1: fixed and rewrote caching, WebUI fixes, logging fixes 2026-02-11 16:54:30 -05:00
joshpatra 86290dff0d v1.0.0-beta.1: initial beta release
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 10:16:09 -05:00
joshpatra 0a9e528418 v1.3.0: Bump version to 1.3.0
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 00:01:06 -05:00
joshpatra f74728fc73 fix: use MBID lookup for MusicBrainz genre enrichment
Search API doesn't return genres even with inc=genres parameter.
Now doing search to get MBID, then lookup by MBID to get genres.
2026-02-10 23:52:14 -05:00
joshpatra 87467be61b feat: add LyricsPlus API with modular orchestrator architecture
Add multi-source lyrics support with clean, modular architecture for easier debugging and maintenance.

New Features:
- LyricsPlusService: Multi-source lyrics API (Apple Music, Spotify, Musixmatch)
- LyricsOrchestrator: Priority-based coordinator for all lyrics sources
- Modular service architecture with independent error handling
- Word-level and line-level timing support with LRC conversion

Architecture:
- Priority chain: Spotify → LyricsPlus → LRCLib
- Each service logs independently (→ Trying, ✓ Found,  Not found)
- Fallback continues even if one service fails
- Easy to add new sources or modify priority

Benefits:
- Easier debugging with clear service-level logs
- Better maintainability with separated concerns
- More reliable with graceful fallback handling
- Extensible for future lyrics sources
2026-02-10 23:02:17 -05:00
joshpatra 713ecd4ec8 v1.2.6: fix search result ordering to prioritize local tracks
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-10 13:36:06 -05:00
joshpatra 0ff1e3a428 v1.2.5: fix genre enrichment blocking cover art loading 2026-02-10 12:56:43 -05:00
joshpatra cef18b9482 v1.2.5: prioritize local tracks and optimize genre enrichment
Local tracks now appear first in search results with +10 score boost. Genre enrichment is non-blocking for faster cover art and playback.
2026-02-10 12:50:52 -05:00
joshpatra 1bfe30b216 v1.2.4: stop racing SquidWTF endpoints for better throughput
Use round-robin instead of racing to enable parallel processing of 12 tracks simultaneously (one per endpoint) instead of racing all endpoints for each track.
2026-02-10 12:14:38 -05:00
joshpatra c9c82a650d v1.2.3: fix Spotify playlist metadata fields
Complete Jellyfin item structure for external tracks with all requested fields including PlaylistItemId, DateCreated, ParentId, Tags, People, and SortName.
2026-02-10 11:56:12 -05:00
joshpatra d0a7dbcc96 v1.2.2: fix metadata loss in Spotify playlists
Spotify playlist tracks were missing genres, composers, and other metadata because the proxy only requested MediaSources field instead of passing through all client-requested fields.
2026-02-10 11:01:38 -05:00
joshpatra 9c9a827a91 v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

## Fixes
- Make GenreEnrichmentService optional to fix test failures
- All 225 tests passing

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:29:49 -05:00
joshpatra 96889738df v1.2.1: MusicBrainz genre enrichment + cleanup
## Features
- Implement automatic MusicBrainz genre enrichment for all external sources
  - Deezer: Enriches when genre missing
  - Qobuz: Enriches when genre missing
  - SquidWTF/Tidal: Always enriches (Tidal doesn't provide genres)
- Use ISRC codes for exact matching, fallback to title/artist search
- Cache results in Redis (30 days) + file cache for performance
- Respect MusicBrainz rate limits (1 req/sec)

## Cleanup
- Remove unused Spotify API ClientId and ClientSecret settings
- Simplify Spotify API configuration

This ensures all external tracks have genre metadata for better
organization and filtering in music clients.
2026-02-10 10:25:41 -05:00
joshpatra f3c791496e v1.2.0: Spotify playlist improvements and admin UI fixes
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
Enhanced Spotify playlist integration with GraphQL API, fixed track counts and folder filtering, improved session IP tracking with X-Forwarded-For support, and added per-playlist cron scheduling.
2026-02-09 18:17:15 -05:00
127 changed files with 18679 additions and 7254 deletions
+41 -1
View File
@@ -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
+9
View File
@@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<!--
Default NuGet vulnerability audit off to avoid NU1900 in offline/local environments.
Re-enable explicitly with: dotnet test -p:NuGetAudit=true
-->
<NuGetAudit Condition="'$(NuGetAudit)' == ''">false</NuGetAudit>
</PropertyGroup>
</Project>
+1
View File
@@ -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
+213
View File
@@ -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<OkObjectResult>(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<UnauthorizedObjectResult>(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<OkObjectResult>(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<OkObjectResult>(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<IHttpClientFactory>();
httpClientFactory.Setup(x => x.CreateClient(It.IsAny<string>())).Returns(new HttpClient(handler));
var logger = new Mock<ILogger<AdminAuthController>>();
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<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
public DelegateHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
{
_handler = handler;
}
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
return _handler(request, cancellationToken);
}
}
}
@@ -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<AdminAuthenticationMiddleware>.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<AdminAuthenticationMiddleware>.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<AdminAuthenticationMiddleware>.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<AdminAuthenticationMiddleware>.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<AdminAuthenticationMiddleware>.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<string> 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();
}
}
@@ -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<string, string?>(), 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<string, string?>(), 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<string, string?>
{
["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<string, string?>(), 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<string, string?> configValues,
out Func<bool> 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<AdminNetworkAllowlistMiddleware>.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;
}
}
@@ -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<string, string?>())
.Build();
var bindAnyIp = AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(configuration);
Assert.False(bindAnyIp);
}
[Fact]
public void ShouldBindAdminAnyIp_ReturnsTrue_WhenConfigured()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Admin:BindAnyIp"] = "true"
})
.Build();
var bindAnyIp = AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(configuration);
Assert.True(bindAnyIp);
}
[Fact]
public void ParseTrustedSubnets_ReturnsOnlyValidNetworks()
{
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["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<IPNetwork>();
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);
}
}
@@ -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"), "<html>ok</html>");
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"), "<html>ok</html>");
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<bool> nextInvoked)
{
var invoked = false;
nextInvoked = () => invoked;
var environment = new Mock<Microsoft.AspNetCore.Hosting.IWebHostEnvironment>();
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);
}
}
}
-167
View File
@@ -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<ILogger<ApiKeyAuthFilter>> _loggerMock;
private readonly IOptions<JellyfinSettings> _options;
public ApiKeyAuthFilterTests()
{
_loggerMock = new Mock<ILogger<ApiKeyAuthFilter>>();
_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<IFilterMetadata>(), new Dictionary<string, object?>(), controller: new object());
return (execContext, actionContext);
}
private static ActionExecutionDelegate CreateNext(ActionContext actionContext, Action onInvoke)
{
return () =>
{
onInvoke();
var executedContext = new ActionExecutedContext(actionContext, new List<IFilterMetadata>(), 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<UnauthorizedObjectResult>(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<UnauthorizedObjectResult>(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<UnauthorizedObjectResult>(ctx.Result);
}
}
+87
View File
@@ -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);
}
}
+71
View File
@@ -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"));
}
}
@@ -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<string, string> { ["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<BadRequestObjectResult>(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<NotFoundObjectResult>(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<string, string?>? configValues = null)
{
var logger = new Mock<ILogger<ConfigController>>();
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(configValues ?? new Dictionary<string, string?>())
.Build();
var webHostEnvironment = new Mock<IWebHostEnvironment>();
webHostEnvironment.SetupGet(e => e.EnvironmentName).Returns(Environments.Development);
webHostEnvironment.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory());
var helperLogger = new Mock<ILogger<AdminHelperService>>();
var helperService = new AdminHelperService(
helperLogger.Object,
Options.Create(new JellyfinSettings()),
webHostEnvironment.Object);
var redisLogger = new Mock<ILogger<RedisCacheService>>();
var redisCache = new RedisCacheService(
Options.Create(new RedisSettings
{
Enabled = false,
ConnectionString = "localhost:6379"
}),
redisLogger.Object);
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
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<ObjectResult>(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());
}
}
@@ -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<BadRequestObjectResult>(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<BadRequestObjectResult>(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<FileStreamResult>(result);
}
finally
{
DeleteTestRoot(testRoot);
}
}
private static DownloadsController CreateController(string downloadsRoot)
{
var config = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>
{
["Library:DownloadPath"] = downloadsRoot
})
.Build();
return new DownloadsController(
NullLogger<DownloadsController>.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);
}
}
}
@@ -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));
}
}
+88 -29
View File
@@ -1,4 +1,5 @@
using System.Diagnostics;
using System.Text.RegularExpressions;
using Xunit;
namespace allstarr.Tests;
@@ -68,6 +69,29 @@ public class JavaScriptSyntaxTests
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()
{
@@ -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);
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");
// 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);
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}");
// Check that the file has proper initialization
Assert.Contains("DOMContentLoaded", content);
Assert.Contains("window.fetchStatus();", content);
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]
@@ -125,6 +176,33 @@ public class JavaScriptSyntaxTests
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;
}
}
@@ -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<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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<string, string>
{
["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<Task<HttpResponseMessage>>("SendAsync",
ItExpr.IsAny<HttpRequestMessage>(),
ItExpr.IsAny<CancellationToken>())
.Callback<HttpRequestMessage, CancellationToken>((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]
@@ -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=<redacted>", masked);
Assert.Contains("query=hello", masked);
Assert.Contains("x-emby-token=<redacted>", masked);
Assert.Contains("AuthToken=<redacted>", 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<string>(result);
}
}
@@ -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<object[]>(result["MediaSources"]);
var mediaSource = Assert.IsType<Dictionary<string, object?>>(mediaSources[0]);
Assert.False(Assert.IsType<bool>(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<object[]>(result["MediaSources"]);
var mediaSource = Assert.IsType<Dictionary<string, object?>>(mediaSources[0]);
Assert.True(Assert.IsType<bool>(mediaSource["SupportsTranscoding"]));
}
[Fact]
public void ConvertAlbumToJellyfinItem_SetsCorrectFields()
{
@@ -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<string?>("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<string?>("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<string?>("GetEffectiveSearchTerm", bound, raw);
Assert.Equal("Love & Hyperbole", effective);
}
private static T InvokePrivateStatic<T>(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!;
}
}
@@ -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);
}
}
+64
View File
@@ -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
}
};
}
}
@@ -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<string, MatchedTrack>(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<string, MatchedTrack>(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<string, MatchedTrack>(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<string, MatchedTrack>(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);
}
}
@@ -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<string, object?>
{
["Name"] = "As the World Caves In",
["ProviderIds"] = null
};
ProviderIdsEnricher.EnsureSpotifyProviderIds(item, "2xXNLutYAOELYVObYb1C1S", "album-123");
var providerIds = Assert.IsType<Dictionary<string, string>>(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<string, object?>
{
["ProviderIds"] = doc.RootElement.Clone()
};
ProviderIdsEnricher.EnsureSpotifyProviderIds(item, "2xXNLutYAOELYVObYb1C1S", null);
var providerIds = Assert.IsType<Dictionary<string, string>>(item["ProviderIds"]);
Assert.Equal("cde0216ad42ece9b66e2626a744e8283", providerIds["Jellyfin"]);
Assert.Equal("2xXNLutYAOELYVObYb1C1S", providerIds["Spotify"]);
}
[Fact]
public void EnsureSpotifyProviderIds_WhenSpotifyAlreadyExists_DoesNotOverwrite()
{
var providerIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["Spotify"] = "existing-spid"
};
var item = new Dictionary<string, object?>
{
["ProviderIds"] = providerIds
};
ProviderIdsEnricher.EnsureSpotifyProviderIds(item, "new-spid", "album-1");
var normalized = Assert.IsType<Dictionary<string, string>>(item["ProviderIds"]);
Assert.Equal("existing-spid", normalized["Spotify"]);
Assert.Equal("album-1", normalized["SpotifyAlbum"]);
}
}
+64
View File
@@ -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<HttpRequestException>(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<InvalidOperationException>(async () =>
await RetryHelper.RetryWithBackoffAsync(async () =>
{
attempts++;
await Task.Yield();
throw new InvalidOperationException("fatal");
}, NullLogger.Instance, maxRetries: 3, initialDelayMs: 1));
Assert.Equal(1, attempts);
}
}
+163 -143
View File
@@ -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<IOptions<ScrobblingSettings>> _mockSettings;
private readonly Mock<IConfiguration> _mockConfiguration;
private readonly Mock<ILogger<ScrobblingAdminController>> _mockLogger;
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
private readonly ScrobblingAdminController _controller;
public ScrobblingAdminControllerTests()
{
_mockSettings = new Mock<IOptions<ScrobblingSettings>>();
_mockConfiguration = new Mock<IConfiguration>();
_mockLogger = new Mock<ILogger<ScrobblingAdminController>>();
_mockHttpClientFactory = new Mock<IHttpClientFactory>();
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<OkObjectResult>(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<BadRequestObjectResult>(result);
Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode);
}
[Fact]
public async Task AuthenticateLastFm_WhenSessionSaveFails_DoesNotExposeSessionKey()
{
var sessionKey = "super-secret-session-key";
var successXml = $"<lfm status='ok'><session><name>testuser</name><key>{sessionKey}</key></session></lfm>";
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<ObjectResult>(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 = "<lfm status='ok'><session><name>testuser</name><key>secret-session-key</key></session></lfm>";
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<OkObjectResult>(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<ObjectResult>(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<ILogger<AdminHelperService>>();
var webHostEnvironment = new Mock<Microsoft.AspNetCore.Hosting.IWebHostEnvironment>();
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&ampersand", "pass&ampersand")]
[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<IOptions<ScrobblingSettings>>();
mockSettings.Setup(s => s.Value).Returns(settings);
// Act
var result = _controller.DebugAuth(request) as OkObjectResult;
var configuration = new ConfigurationBuilder()
.AddInMemoryCollection(new Dictionary<string, string?>())
.Build();
// Assert
Assert.NotNull(result);
Assert.Equal(200, result.StatusCode);
Assert.NotNull(result.Value);
var logger = new Mock<ILogger<ScrobblingAdminController>>();
var httpClientFactory = new Mock<IHttpClientFactory>();
// 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 httpClient = new HttpClient(new StubHttpMessageHandler(httpResponse));
httpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).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<HttpResponseMessage> SendAsync(
HttpRequestMessage request,
CancellationToken cancellationToken)
{
return Task.FromResult(_response);
}
}
}
@@ -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<IScrobblingService>();
service.SetupGet(s => s.IsEnabled).Returns(true);
service.SetupGet(s => s.ServiceName).Returns("MockService");
service.Setup(s => s.UpdateNowPlayingAsync(It.IsAny<ScrobbleTrack>(), It.IsAny<CancellationToken>()))
.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<ScrobbleTrack>(), It.IsAny<CancellationToken>()),
Times.Once);
}
private static ScrobblingOrchestrator CreateOrchestrator(IScrobblingService service)
{
var settings = Options.Create(new ScrobblingSettings
{
Enabled = true
});
var logger = Mock.Of<ILogger<ScrobblingOrchestrator>>();
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
};
}
}
+85
View File
@@ -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<SpotifyPlaylist?>(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<SpotifyPlaylistTrack?>(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<T>(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!;
}
}
@@ -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;
@@ -339,4 +342,223 @@ public class SquidWTFMetadataServiceTests
// Assert
Assert.NotNull(service);
}
[Fact]
public void BuildSearchQueryVariants_WithAmpersand_AddsAndVariant()
{
var variants = InvokePrivateStaticMethod<IReadOnlyList<string>>(
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<IReadOnlyList<string>>(
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<Song>(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<Song>(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<allstarr.Models.Subsonic.ExternalPlaylist>(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<Album>(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<T>(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<T>(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!;
}
}
+27
View File
@@ -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);
}
}
@@ -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);
}
}
+1 -1
View File
@@ -9,5 +9,5 @@ public static class AppVersion
/// <summary>
/// Current application version.
/// </summary>
public const string Version = "1.1.3";
public const string Version = "1.3.0";
}
+206
View File
@@ -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<AdminAuthController> _logger;
public AdminAuthController(
IOptions<JellyfinSettings> jellyfinSettings,
IHttpClientFactory httpClientFactory,
AdminAuthSessionService sessionService,
ILogger<AdminAuthController> logger)
{
_jellyfinSettings = jellyfinSettings.Value;
_httpClient = httpClientFactory.CreateClient();
_sessionService = sessionService;
_logger = logger;
}
[HttpPost("login")]
public async Task<IActionResult> 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; }
}
}
+319 -44
View File
@@ -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> spotifyImportSettings,
IOptions<ScrobblingSettings> 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<IActionResult> GetConfig()
{
var envVars = await ReadEnvSettingsAsync();
var backendType = GetEnvString(
envVars,
"BACKEND_TYPE",
_configuration.GetValue<string>("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<string>("Backend:Type") ?? "Jellyfin",
musicService = _configuration.GetValue<string>("MusicService") ?? "SquidWTF",
explicitFilter = _configuration.GetValue<string>("ExplicitFilter") ?? "All",
enableExternalPlaylists = _configuration.GetValue<bool>("EnableExternalPlaylists", false),
playlistsDirectory = _configuration.GetValue<string>("PlaylistsDirectory") ?? "(not set)",
redisEnabled = _configuration.GetValue<bool>("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<bool>("Redis:Enabled", false)),
debug = new
{
logAllRequests = _configuration.GetValue<bool>("Debug:LogAllRequests", false)
logAllRequests = GetEnvBool(envVars, "DEBUG_LOG_ALL_REQUESTS", _configuration.GetValue<bool>("Debug:LogAllRequests", false)),
redactSensitiveRequestValues = GetEnvBool(
envVars,
"DEBUG_REDACT_SENSITIVE_REQUEST_VALUES",
_configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false))
},
admin = new
{
bindAnyIp = GetEnvBool(envVars, "ADMIN_BIND_ANY_IP", AdminNetworkBindingPolicy.ShouldBindAdminAnyIp(_configuration)),
trustedSubnets = GetEnvString(envVars, "ADMIN_TRUSTED_SUBNETS", _configuration.GetValue<string>("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<int>("Cache:SearchResultsMinutes", 1)),
playlistImagesHours = GetEnvInt(envVars, "CACHE_PLAYLIST_IMAGES_HOURS", _configuration.GetValue<int>("Cache:PlaylistImagesHours", 168)),
spotifyPlaylistItemsHours = GetEnvInt(envVars, "CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS", _configuration.GetValue<int>("Cache:SpotifyPlaylistItemsHours", 168)),
spotifyMatchedTracksDays = GetEnvInt(envVars, "CACHE_SPOTIFY_MATCHED_TRACKS_DAYS", _configuration.GetValue<int>("Cache:SpotifyMatchedTracksDays", 30)),
lyricsDays = GetEnvInt(envVars, "CACHE_LYRICS_DAYS", _configuration.GetValue<int>("Cache:LyricsDays", 14)),
genreDays = GetEnvInt(envVars, "CACHE_GENRE_DAYS", _configuration.GetValue<int>("Cache:GenreDays", 30)),
metadataDays = GetEnvInt(envVars, "CACHE_METADATA_DAYS", _configuration.GetValue<int>("Cache:MetadataDays", 7)),
odesliLookupDays = GetEnvInt(envVars, "CACHE_ODESLI_LOOKUP_DAYS", _configuration.GetValue<int>("Cache:OdesliLookupDays", 60)),
proxyImagesDays = GetEnvInt(envVars, "CACHE_PROXY_IMAGES_DAYS", _configuration.GetValue<int>("Cache:ProxyImagesDays", 14))
},
scrobbling = await GetScrobblingSettingsFromEnvAsync()
});
}
private async Task<Dictionary<string, string>> ReadEnvSettingsAsync()
{
var envVars = new Dictionary<string, string>(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<string, string> 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<string, string> 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<string, string> envVars, string key, int fallback)
{
if (!envVars.TryGetValue(key, out var rawValue))
{
return fallback;
}
return int.TryParse(rawValue, out var parsed) ? parsed : fallback;
}
/// <summary>
/// Read scrobbling settings directly from .env file for real-time updates
/// </summary>
@@ -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,
@@ -191,6 +378,12 @@ public class ConfigController : ControllerBase
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,
@@ -254,6 +449,12 @@ public class ConfigController : ControllerBase
[HttpPost("config")]
public async Task<IActionResult> 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" });
@@ -355,17 +556,14 @@ public class ConfigController : ControllerBase
_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()
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()
error = "Failed to update configuration"
});
}
}
@@ -445,6 +643,12 @@ public class ConfigController : ControllerBase
[HttpPost("restart")]
public async Task<IActionResult> RestartContainer()
{
var adminCheck = RequireAdministratorForSensitiveOperation("container restart");
if (adminCheck != null)
{
return adminCheck;
}
_logger.LogDebug("Container restart requested from admin UI");
try
@@ -519,7 +723,6 @@ public class ConfigController : ControllerBase
_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"
});
}
@@ -531,6 +734,12 @@ public class ConfigController : ControllerBase
[HttpPost("config/init-cookie-date")]
public async Task<IActionResult> 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))
{
@@ -561,6 +770,22 @@ public class ConfigController : ControllerBase
[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()))
@@ -576,7 +801,7 @@ public class ConfigController : ControllerBase
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" });
}
}
@@ -586,6 +811,12 @@ public class ConfigController : ControllerBase
[HttpPost("import-env")]
public async Task<IActionResult> 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" });
@@ -630,10 +861,54 @@ public class ConfigController : ControllerBase
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<bool>("Admin:EnableEnvExport"))
{
return true;
}
if (_configuration.GetValue<bool>("ADMIN__ENABLE_ENV_EXPORT"))
{
return true;
}
return _configuration.GetValue<bool>("ADMIN_ENABLE_ENV_EXPORT");
}
/// <summary>
/// Gets detailed memory usage statistics for debugging.
/// </summary>
+69 -31
View File
@@ -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<string> _squidWtfApiUrls;
private static int _urlIndex = 0;
private static readonly object _urlIndexLock = new();
@@ -35,6 +41,8 @@ public class DiagnosticsController : ControllerBase
IOptions<DeezerSettings> deezerSettings,
IOptions<QobuzSettings> qobuzSettings,
IOptions<SquidWTFSettings> squidWtfSettings,
SpotifySessionCookieService spotifySessionCookieService,
SquidWtfEndpointCatalog squidWtfEndpointCatalog,
RedisCacheService cache)
{
_logger = logger;
@@ -45,46 +53,36 @@ public class DiagnosticsController : ControllerBase
_deezerSettings = deezerSettings.Value;
_qobuzSettings = qobuzSettings.Value;
_squidWtfSettings = squidWtfSettings.Value;
_spotifySessionCookieService = spotifySessionCookieService;
_cache = cache;
_squidWtfApiUrls = DecodeSquidWtfUrls();
}
private static List<string> 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<IActionResult> 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;
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 (_spotifyApiSettings.Enabled && !string.IsNullOrEmpty(_spotifyApiSettings.SessionCookie))
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)
{
@@ -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
},
@@ -129,6 +128,18 @@ 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;
}
/// <summary>
/// Get a random SquidWTF base URL for searching (round-robin)
/// </summary>
@@ -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" });
}
}
@@ -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" });
}
}
/// <summary>
/// Gets current active scrobbling sessions for debugging.
/// </summary>
[HttpGet("scrobbling-sessions")]
public IActionResult GetScrobblingSessions()
{
try
{
var scrobblingOrchestrator = HttpContext.RequestServices.GetService<ScrobblingOrchestrator>();
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" });
}
}
+56 -15
View File
@@ -99,14 +99,9 @@ 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);
var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"));
// Security: Ensure the path is within the kept directory
var normalizedFullPath = Path.GetFullPath(fullPath);
var normalizedKeptPath = Path.GetFullPath(keptPath);
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
if (!TryResolvePathUnderRoot(keptPath, path, out var fullPath))
{
return BadRequest(new { error = "Invalid path" });
}
@@ -120,7 +115,9 @@ public class DownloadsController : ControllerBase
// 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())
{
@@ -156,14 +153,9 @@ 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);
var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"));
// Security: Ensure the path is within the kept directory
var normalizedFullPath = Path.GetFullPath(fullPath);
var normalizedKeptPath = Path.GetFullPath(keptPath);
if (!normalizedFullPath.StartsWith(normalizedKeptPath))
if (!TryResolvePathUnderRoot(keptPath, path, out var fullPath))
{
return BadRequest(new { error = "Invalid path" });
}
@@ -239,6 +231,55 @@ public class DownloadsController : ControllerBase
}
}
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;
}
/// <summary>
/// Gets all Spotify track mappings (paginated)
/// </summary>
+254 -10
View File
@@ -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<Dictionary<string, object>>();
var spotifyPlaylistCreatedDates = new Dictionary<string, DateTime?>(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<List<MatchedTrack>>(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<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(string playlistName)
{
try
{
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
var cachedPlaylist = await _cache.GetAsync<SpotifyPlaylist>(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<string, object> 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;
}
/// <summary>
/// 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.
/// </summary>
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<string>();
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}=<redacted>");
}
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);
}
/// <summary>
/// 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).
/// </summary>
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;
}
/// <summary>
/// Recovers SearchTerm directly from raw query string.
/// Handles malformed clients that do not URL-encode '&' inside SearchTerm.
/// </summary>
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);
}
/// <summary>
/// Uses model-bound SearchTerm when valid; falls back to raw query recovery when needed.
/// </summary>
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();
+258 -73
View File
@@ -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<SpotifyPlaylistConfig> 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<IActionResult> GetJellyfinUsers()
{
@@ -81,7 +188,7 @@ public class JellyfinAdminController : ControllerBase
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" });
}
}
@@ -130,7 +237,7 @@ public class JellyfinAdminController : ControllerBase
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" });
}
}
@@ -145,18 +252,32 @@ public class JellyfinAdminController : ControllerBase
return BadRequest(new { error = "Jellyfin URL or API key not configured" });
}
try
if (!TryGetCurrentSession(out var session))
{
// Build URL with optional userId filter
var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount";
if (!string.IsNullOrEmpty(userId))
{
url += $"&UserId={userId}";
return Unauthorized(new { error = "Authentication required" });
}
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
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
{
var url = $"{_jellyfinSettings.Url}/Items?IncludeItemTypes=Playlist&Recursive=true&Fields=ProviderIds,ChildCount,RecursiveItemCount,SongCount";
if (!string.IsNullOrWhiteSpace(requestedUserId))
{
url += $"&UserId={Uri.EscapeDataString(requestedUserId)}";
}
var request = CreateJellyfinRequestForSession(HttpMethod.Get, url, session);
var response = await _jellyfinHttpClient.SendAsync(request);
if (!response.IsSuccessStatusCode)
@@ -170,8 +291,6 @@ public class JellyfinAdminController : ControllerBase
using var doc = JsonDocument.Parse(json);
var playlists = new List<object>();
// Read current playlists from .env file for accurate linked status
var configuredPlaylists = await _helperService.ReadPlaylistsFromEnvFileAsync();
if (doc.RootElement.TryGetProperty("Items", out var items))
@@ -181,30 +300,41 @@ public class JellyfinAdminController : ControllerBase
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;
var allLinkedForPlaylist = configuredPlaylists
.Where(p => p.JellyfinId.Equals(id, StringComparison.OrdinalIgnoreCase))
.ToList();
// Only fetch detailed track stats for configured Spotify playlists
// This avoids expensive queries for large non-Spotify playlists
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
: childCount;
@@ -216,6 +346,9 @@ public class JellyfinAdminController : ControllerBase
trackCount = actualTrackCount,
linkedSpotifyId,
isConfigured,
isLinkedByAnotherUser,
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
allLinkedForPlaylist.FirstOrDefault()?.UserId,
localTracks = trackStats.LocalTracks,
externalTracks = trackStats.ExternalTracks,
externalAvailable = trackStats.ExternalAvailable
@@ -228,25 +361,30 @@ public class JellyfinAdminController : ControllerBase
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" });
}
}
/// <summary>
/// Get track statistics for a playlist (local vs external)
/// </summary>
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;
// Non-admin users are always scoped to their own Jellyfin user.
var userId = string.IsNullOrWhiteSpace(requestedUserId)
? (session.IsAdministrator ? _jellyfinSettings.UserId : session.UserId)
: requestedUserId.Trim();
// If no user configured, try to get the first user
if (string.IsNullOrEmpty(userId))
// 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)
@@ -267,7 +405,7 @@ public class JellyfinAdminController : ControllerBase
}
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)
@@ -339,62 +477,83 @@ public class JellyfinAdminController : ControllerBase
[HttpPost("jellyfin/playlists/{jellyfinPlaylistId}/link")]
public async Task<IActionResult> 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);
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);
// Read current playlists from .env file (not in-memory config which is stale)
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" });
}
// Check if already configured by name
var existingByName = currentPlaylists
.FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
return BadRequest(new { error = "This Jellyfin playlist is already linked by another user" });
}
var existingByName = currentPlaylists
.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<string, string>
@@ -409,11 +568,45 @@ public class JellyfinAdminController : ControllerBase
/// <summary>
/// Unlink a playlist (remove from configuration)
/// </summary>
[HttpDelete("jellyfin/playlists/{name}/unlink")]
public async Task<IActionResult> UnlinkPlaylist(string name)
[HttpDelete("jellyfin/playlists/{jellyfinPlaylistId}/unlink")]
public async Task<IActionResult> 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<string, string>
{
["SPOTIFY_IMPORT_PLAYLISTS"] = playlistsJson
};
return await _helperService.UpdateEnvConfigAsync(updates);
}
/// <summary>
@@ -449,15 +642,7 @@ public class JellyfinAdminController : ControllerBase
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
{
@@ -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" });
}
}
@@ -115,7 +115,7 @@ 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" });
}
}
@@ -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.
/// </summary>
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);
}
}
@@ -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<string, DateTime> 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 = itemIdProp.GetString();
}
itemId = ParsePlaybackItemId(doc.RootElement);
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
playSessionId = ParsePlaybackSessionId(doc.RootElement);
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
// Only update session for local tracks
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
if (string.IsNullOrWhiteSpace(itemId))
{
var (isExt, _, _) = _localLibraryService.ParseSongId(itemId);
if (!isExt)
{
_sessionManager.UpdateActivity(deviceId);
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
_logger.LogWarning(
"⚠️ Playback progress missing item id after parsing. Payload keys: {Keys}",
string.Join(", ", doc.RootElement.EnumerateObject().Select(p => p.Name)));
}
// Scrobble progress check (both local and external)
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
{
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<string?> 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;
}
/// <summary>
/// Reports playback stopped. Handles both local and external tracks.
/// </summary>
@@ -561,34 +1190,49 @@ public partial class JellyfinController
Request.Body.Position = 0;
_logger.LogInformation("⏹️ Playback STOPPED reported");
_logger.LogDebug("📤 Sending playback stop body: {Body}", body);
_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,6 +1599,193 @@ 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
@@ -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();
+783 -120
View File
@@ -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<object>(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";
}
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<object>(), 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<ExternalPlaylist>());
_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<Dictionary<string, object?>>();
var jellyfinAlbumItems = new List<Dictionary<string, object?>>();
var jellyfinArtistItems = new List<Dictionary<string, object?>>();
// 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<string, object?> 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<Dictionary<string, object?>>();
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);
var top3Local = allSongs.Where(IsLocalItem).Take(3).ToList();
if (top3Local.Count > 0)
{
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} LOCAL search results", top3Local.Count);
foreach (var songItem in top3)
foreach (var songItem in top3Local)
{
if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl &&
songItem.TryGetValue("Artists", out var artistsObj) &&
artistsObj is JsonElement artistsEl &&
artistsEl.GetArrayLength() > 0)
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)
{
var title = nameEl.GetString() ?? "";
var artist = artistsEl[0].GetString() ?? "";
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,11 +519,30 @@ 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);
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...");
@@ -524,6 +605,35 @@ public partial class JellyfinController
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<string, string>(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);
}
/// <summary>
/// Quick search endpoint. Works with /Search/Hints and /Users/{userId}/Search/Hints.
/// </summary>
@@ -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<string, string>(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<string, string> 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<Dictionary<string, object?>> ApplyRequestedAlbumOrderingIfApplicable(
List<Dictionary<string, object?>> 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<string, object?> left,
Dictionary<string, object?> right,
IReadOnlyList<string> 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<string, object?> left, Dictionary<string, object?> 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<string, object?> 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<string, object?> 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;
}
/// <summary>
/// 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.
/// </summary>
private List<Dictionary<string, object?>> InterleaveByScore(
List<Dictionary<string, object?>> primaryItems,
List<Dictionary<string, object?>> 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<Dictionary<string, object?>>(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;
}
/// <summary>
/// Calculates query relevance for a search item.
/// Title is primary; metadata context is secondary and down-weighted.
/// </summary>
private double CalculateItemRelevanceScore(string query, Dictionary<string, object?> 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<string> 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<string> TokenizeForCoverage(string text)
{
if (string.IsNullOrWhiteSpace(text))
{
return Array.Empty<string>();
}
var normalized = NormalizeForCoverage(text);
var allTokens = normalized
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
.Distinct(StringComparer.Ordinal)
.ToList();
if (allTokens.Count == 0)
{
return Array.Empty<string>();
}
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<char>(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);
}
/// <summary>
/// Extracts the name/title from a Jellyfin item dictionary.
/// </summary>
private string GetItemName(Dictionary<string, object?> item)
{
return GetItemStringValue(item, "Name");
}
private string BuildItemSearchText(Dictionary<string, object?> item, string title)
{
var parts = new List<string>();
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<string> SearchStopWords = new(StringComparer.Ordinal)
{
"a",
"an",
"and",
"at",
"for",
"in",
"of",
"on",
"the",
"to",
"with",
"feat",
"ft"
};
private static void AddDistinct(List<string> values, string? value)
{
if (string.IsNullOrWhiteSpace(value))
{
return;
}
if (!values.Contains(value, StringComparer.OrdinalIgnoreCase))
{
values.Add(value);
}
}
private string GetItemStringValue(Dictionary<string, object?> 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<string> GetItemStringList(Dictionary<string, object?> 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<string> stringValues)
{
foreach (var text in stringValues)
{
if (!string.IsNullOrWhiteSpace(text))
{
yield return text;
}
}
yield break;
}
if (value is IEnumerable<object?> objectValues)
{
foreach (var objectValue in objectValues)
{
var text = objectValue?.ToString();
if (!string.IsNullOrWhiteSpace(text))
{
yield return text;
}
}
}
}
#endregion
}
@@ -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<string, string>();
}
// Enhance with additional Spotify metadata
ProviderIdsEnricher.EnsureSpotifyProviderIds(externalItem, spotifyTrack.SpotifyId,
spotifyTrack.AlbumId);
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
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<string, object?> item,
DateTime? addedAt)
{
if (!addedAt.HasValue)
{
return;
}
item["DateCreated"] = addedAt.Value.ToUniversalTime().ToString("yyyy-MM-ddTHH:mm:ss.fffffffZ");
}
/// <summary>
/// <summary>
/// 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");
try
{
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
_logger.LogDebug(" Copied track to kept folder: {Path}", keptFilePath);
_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
_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 art to kept folder");
_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)
{
+304 -98
View File
@@ -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,11 +135,20 @@ 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);
}
@@ -146,24 +156,24 @@ public partial class JellyfinController : ControllerBase
/// <summary>
/// Gets an external item (song, album, or artist).
/// </summary>
private async Task<IActionResult> GetExternalItem(string provider, string? type, string externalId)
private async Task<IActionResult> 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)
@@ -176,10 +186,10 @@ public partial class JellyfinController : ControllerBase
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
/// <summary>
/// Gets child items for an external parent (album tracks or artist albums).
/// </summary>
private async Task<IActionResult> GetExternalChildItems(string provider, string type, string externalId, string? includeItemTypes)
private async Task<IActionResult> 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}",
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
// Check if asking for audio (album tracks or artist songs)
if (itemTypes?.Contains("Audio") == true)
{
if (type == "album")
// 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)))
{
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
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.CreateItemsResponse(album.Songs);
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
});
}
else if (type == "artist")
// 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<Song>());
}
_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");
@@ -250,7 +279,79 @@ public partial class JellyfinController : ControllerBase
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
return _responseBuilder.CreateItemsResponse(new List<Song>());
}
private async Task<IActionResult> GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes)
private List<Song> ApplySongSortAndPagingForCurrentRequest(IReadOnlyCollection<Song> 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<string> 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<string> ParseSortFields(string sortBy)
{
if (string.IsNullOrWhiteSpace(sortBy))
{
return new List<string>();
}
return sortBy
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.Where(field => !string.IsNullOrWhiteSpace(field))
.ToList();
}
private async Task<IActionResult> 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<List<Artist>> 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<object>(),
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<string, string>(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);
}
/// <summary>
@@ -418,7 +552,7 @@ public partial class JellyfinController : ControllerBase
.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<Album>();
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,7 +616,8 @@ public partial class JellyfinController : ControllerBase
itemId,
imageType,
maxWidth,
maxHeight);
maxHeight,
tag);
if (imageBytes == null || contentType == null)
{
@@ -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,14 +685,28 @@ 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)
@@ -565,7 +716,8 @@ public partial class JellyfinController : ControllerBase
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;
}
@@ -577,12 +729,13 @@ public partial class JellyfinController : ControllerBase
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();
}
@@ -783,11 +936,7 @@ public partial class JellyfinController : ControllerBase
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);
var isRawSquidTrackId = !isExternal && long.TryParse(itemId, out _);
var squidTrackId = provider?.Equals("squidwtf", StringComparison.OrdinalIgnoreCase) == true
? externalId
: (isRawSquidTrackId ? itemId : null);
if (isExternal)
if (isExternal || !string.IsNullOrWhiteSpace(squidTrackId))
{
// Check if this is an artist
if (itemId.Contains("-artist-", StringComparison.OrdinalIgnoreCase))
@@ -825,6 +978,39 @@ public partial class JellyfinController : ControllerBase
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<object>(),
TotalRecordCount = 0
});
}
// Get the original song to find similar content
var song = await _metadataService.GetSongAsync(provider!, externalId!);
if (song == null)
@@ -842,7 +1028,8 @@ public partial class JellyfinController : ControllerBase
// 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();
@@ -869,24 +1056,15 @@ public partial class JellyfinController : ControllerBase
? $"Artists/{itemId}/Similar"
: $"Items/{itemId}/Similar";
var queryParams = new Dictionary<string, string>
// 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;
endpoint = $"{endpoint}{Request.QueryString.Value}";
}
if (!string.IsNullOrEmpty(userId))
{
queryParams["userId"] = userId;
}
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, queryParams, Request.Headers);
return HandleProxyResponse(result, statusCode, new { Items = Array.Empty<object>(), TotalRecordCount = 0 });
return HandleProxyResponse(result, statusCode);
}
/// <summary>
@@ -973,25 +1151,19 @@ public partial class JellyfinController : ControllerBase
}
}
// For local items, proxy to Jellyfin
var queryParams = new Dictionary<string, string>
{
["limit"] = limit.ToString()
};
// 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 (!string.IsNullOrEmpty(fields))
if (Request.QueryString.HasValue)
{
queryParams["fields"] = fields;
endpoint = $"{endpoint}{Request.QueryString.Value}";
}
if (!string.IsNullOrEmpty(userId))
{
queryParams["userId"] = userId;
}
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
var (result, statusCode) = await _proxyService.GetJsonAsync($"Songs/{itemId}/InstantMix", queryParams, Request.Headers);
return HandleProxyResponse(result, statusCode, new { Items = Array.Empty<object>(), TotalRecordCount = 0 });
return HandleProxyResponse(result, statusCode);
}
#endregion
@@ -1047,7 +1219,8 @@ public partial class JellyfinController : ControllerBase
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
{
@@ -1203,9 +1376,11 @@ public partial class JellyfinController : ControllerBase
{
// 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;
@@ -1218,7 +1393,7 @@ public partial class JellyfinController : ControllerBase
// Log request details for debugging
_logger.LogDebug("POST request to {Path}: Method={Method}, ContentType={ContentType}, ContentLength={ContentLength}",
fullPath, Request.Method, Request.ContentType, Request.ContentLength);
safePathForLogs, Request.Method, Request.ContentType, Request.ContentLength);
// Read body using StreamReader with proper encoding
string body;
@@ -1233,22 +1408,13 @@ public partial class JellyfinController : ControllerBase
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}")));
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);
}
safePathForLogs, body.Length, Request.ContentType);
}
(result, statusCode) = await _proxyService.PostJsonAsync(fullPath, body, Request.Headers);
@@ -1294,9 +1460,7 @@ public partial class JellyfinController : ControllerBase
// 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
/// <summary>
/// Checks if an item dictionary represents a local Jellyfin item (not external).
/// </summary>
private bool IsLocalItem(Dictionary<string, object?> 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);
}
/// <summary>
/// Converts a JsonElement to a Dictionary while properly preserving nested objects and arrays.
/// This prevents metadata from being stripped when deserializing Jellyfin responses.
+2 -2
View File
@@ -206,7 +206,7 @@ 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" });
}
}
@@ -246,7 +246,7 @@ 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" });
}
}
+8 -1
View File
@@ -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<MappingController> _logger;
private readonly RedisCacheService _cache;
private readonly AdminHelperService _adminHelper;
private readonly SpotifyMappingService _mappingService;
public MappingController(
ILogger<MappingController> logger,
RedisCacheService cache,
AdminHelperService adminHelper)
AdminHelperService adminHelper,
SpotifyMappingService mappingService)
{
_logger = logger;
_cache = cache;
_adminHelper = adminHelper;
_mappingService = mappingService;
}
@@ -145,6 +149,9 @@ public class MappingController : ControllerBase
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" });
}
catch (Exception ex)
+348 -74
View File
@@ -18,7 +18,6 @@ namespace allstarr.Controllers;
public class PlaylistController : ControllerBase
{
private readonly ILogger<PlaylistController> _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<PlaylistController> logger,
IConfiguration configuration,
IOptions<JellyfinSettings> jellyfinSettings,
IOptions<SpotifyImportSettings> 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;
@@ -600,6 +597,33 @@ public class PlaylistController : ControllerBase
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
var tracksWithStatus = new List<object>();
var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase);
try
{
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(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
@@ -708,23 +732,48 @@ public class PlaylistController : ControllerBase
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);
}
}
// 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
var globalMappingExt = await _mappingService.GetMappingAsync(track.SpotifyId);
if (globalMappingExt != null && globalMappingExt.Source == "manual")
{
isManualMapping = true;
@@ -759,36 +808,12 @@ public class PlaylistController : ControllerBase
{
_logger.LogDebug("Track {Title} has ProviderIds: {Keys}", track.Title, string.Join(", ", providerIds.Keys));
// Check for external provider keys (case-insensitive)
// External providers: squidwtf, deezer, qobuz, tidal
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));
externalProvider = ResolveExternalProviderFromProviderIds(providerIds);
if (hasSquidWTF)
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
{
@@ -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,14 +873,39 @@ 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)
// 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)", track.Title, track.SpotifyId);
_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);
@@ -892,12 +942,6 @@ public class PlaylistController : ControllerBase
// 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<List<MatchedTrack>>(fallbackMatchedTracksKey);
var fallbackMatchedSpotifyIds = new HashSet<string>(
fallbackMatchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
);
foreach (var track in spotifyTracks)
{
bool? isLocal = null;
@@ -934,7 +978,7 @@ public class PlaylistController : ControllerBase
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
{
@@ -954,6 +1002,11 @@ public class PlaylistController : ControllerBase
}
}
if (isLocal == false)
{
externalProvider = NormalizeExternalProviderForDisplay(externalProvider);
}
tracksWithStatus.Add(new
{
position = track.Position,
@@ -1035,7 +1088,7 @@ public class PlaylistController : ControllerBase
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" });
}
}
@@ -1090,7 +1143,7 @@ public class PlaylistController : ControllerBase
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" });
}
}
@@ -1102,7 +1155,7 @@ public class PlaylistController : ControllerBase
public async Task<IActionResult> 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)
{
@@ -1111,7 +1164,7 @@ public class PlaylistController : ControllerBase
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
@@ -1119,14 +1172,14 @@ public class PlaylistController : ControllerBase
return Ok(new
{
message = $"Rebuilding {decodedName} from scratch (same as cron job)",
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" });
}
}
@@ -1194,11 +1247,11 @@ 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)
{
@@ -1207,6 +1260,61 @@ public class PlaylistController : ControllerBase
}
}
/// <summary>
/// Search external provider tracks for manual mapping.
/// </summary>
[HttpGet("external/search")]
public async Task<IActionResult> 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<IMusicMetadataService>();
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" });
}
}
/// <summary>
/// Get track details by Jellyfin ID (for URL-based mapping)
/// </summary>
@@ -1270,7 +1378,15 @@ public class PlaylistController : ControllerBase
_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)
{
@@ -1309,6 +1425,7 @@ public class PlaylistController : ControllerBase
try
{
string? normalizedProvider = null;
string? normalizedExternalId = null;
if (hasJellyfinMapping)
{
@@ -1327,14 +1444,41 @@ 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!);
await _helperService.SaveManualMappingToFileAsync(decodedName, request.SpotifyId, null, normalizedProvider, normalizedExternalId);
_logger.LogInformation("Manual external mapping saved: {Playlist} - Spotify {SpotifyId} → {Provider} {ExternalId}",
decodedName, request.SpotifyId, normalizedProvider, request.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
@@ -1392,7 +1536,7 @@ public class PlaylistController : ControllerBase
try
{
var metadataService = HttpContext.RequestServices.GetRequiredService<IMusicMetadataService>();
var externalSong = await metadataService.GetSongAsync(normalizedProvider, request.ExternalId!);
var externalSong = await metadataService.GetSongAsync(normalizedProvider, normalizedExternalId!);
if (externalSong != null)
{
@@ -1404,7 +1548,7 @@ public class PlaylistController : ControllerBase
else
{
_logger.LogError("Failed to fetch external track metadata for {Provider} ID {Id}",
normalizedProvider, request.ExternalId);
normalizedProvider, normalizedExternalId);
}
}
catch (Exception ex)
@@ -1442,15 +1586,29 @@ 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
@@ -1488,18 +1646,134 @@ 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<string, string> 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;
}
/// <summary>
/// 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.
/// </summary>
[HttpPost("playlists/rebuild-all")]
public async Task<IActionResult> 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)
{
@@ -1509,12 +1783,12 @@ public class PlaylistController : ControllerBase
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" });
}
}
@@ -51,6 +51,7 @@ public class ScrobblingAdminController : ControllerBase
{
Enabled = _settings.Enabled,
LocalTracksEnabled = _settings.LocalTracksEnabled,
SyntheticLocalPlayedSignalEnabled = _settings.SyntheticLocalPlayedSignalEnabled,
LastFm = new
{
Enabled = _settings.LastFm.Enabled,
@@ -59,7 +60,7 @@ public class ScrobblingAdminController : ControllerBase
HasSessionKey = !string.IsNullOrEmpty(_settings.LastFm.SessionKey),
Username = _settings.LastFm.Username,
UsingHardcodedCredentials = hasApiCredentials &&
_settings.LastFm.ApiKey == "cb3bdcd415fcb40cd572b137b2b255f5"
_settings.LastFm.ApiKey == LastFmSettings.DefaultApiKey
},
ListenBrainz = new
{
@@ -92,11 +93,6 @@ public class ScrobblingAdminController : ControllerBase
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
@@ -112,15 +108,12 @@ public class ScrobblingAdminController : ControllerBase
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);
@@ -167,16 +160,14 @@ public class ScrobblingAdminController : ControllerBase
{
_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
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,7 +175,7 @@ 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" });
}
}
@@ -281,7 +272,7 @@ 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" });
}
}
@@ -311,7 +302,7 @@ 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" });
}
}
@@ -364,10 +355,9 @@ public class ScrobblingAdminController : ControllerBase
{
_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,
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."
});
}
@@ -382,7 +372,7 @@ 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" });
}
}
@@ -435,62 +425,10 @@ 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" });
}
}
/// <summary>
/// Debug endpoint to test authentication parameters without actually calling Last.fm.
/// Shows what would be sent to Last.fm for debugging.
/// </summary>
[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<string, string>
{
["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<string, string> parameters, string sharedSecret)
{
var sorted = parameters.OrderBy(kvp => kvp.Key);
@@ -516,12 +454,6 @@ 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; }
+142 -8
View File
@@ -19,6 +19,8 @@ public class SpotifyAdminController : ControllerBase
{
private readonly ILogger<SpotifyAdminController> _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<SpotifyAdminController> 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,24 +53,72 @@ public class SpotifyAdminController : ControllerBase
}
[HttpGet("spotify/user-playlists")]
public async Task<IActionResult> GetSpotifyUserPlaylists()
public async Task<IActionResult> 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<string>(
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)
{
@@ -86,8 +140,82 @@ public class SpotifyAdminController : ControllerBase
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<IActionResult> 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<IActionResult> 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
});
}
/// <summary>
@@ -424,7 +552,7 @@ public class SpotifyAdminController : ControllerBase
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" });
}
}
@@ -534,4 +662,10 @@ public class SpotifyAdminController : ControllerBase
}
}
public class SetSpotifySessionCookieRequest
{
public required string SessionCookie { get; set; }
public string? UserId { get; set; }
}
}
+6 -3
View File
@@ -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" });
}
}
@@ -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");
}
}
@@ -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");
}
}
}
-66
View File
@@ -1,66 +0,0 @@
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Filters;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
namespace allstarr.Filters;
/// <summary>
/// Simple API key authentication filter for admin endpoints.
/// Validates against Jellyfin API key via query parameter or header.
/// </summary>
public class ApiKeyAuthFilter : IAsyncActionFilter
{
private readonly JellyfinSettings _settings;
private readonly ILogger<ApiKeyAuthFilter> _logger;
public ApiKeyAuthFilter(
IOptions<JellyfinSettings> settings,
ILogger<ApiKeyAuthFilter> 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);
}
}
@@ -0,0 +1,127 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using allstarr.Services.Admin;
namespace allstarr.Middleware;
/// <summary>
/// Enforces Jellyfin-authenticated local sessions for admin API endpoints on port 5275.
/// </summary>
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<AdminAuthenticationMiddleware> _logger;
public AdminAuthenticationMiddleware(
RequestDelegate next,
AdminAuthSessionService sessionService,
ILogger<AdminAuthenticationMiddleware> 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."
}));
}
}
@@ -0,0 +1,52 @@
using System.Net;
using allstarr.Services.Common;
namespace allstarr.Middleware;
/// <summary>
/// Restricts admin port (5275) access to loopback and configured trusted subnets.
/// </summary>
public class AdminNetworkAllowlistMiddleware
{
private const int AdminPort = 5275;
private readonly RequestDelegate _next;
private readonly ILogger<AdminNetworkAllowlistMiddleware> _logger;
private readonly List<IPNetwork> _trustedSubnets;
public AdminNetworkAllowlistMiddleware(
RequestDelegate next,
IConfiguration configuration,
ILogger<AdminNetworkAllowlistMiddleware> 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."
});
}
}
@@ -1,6 +1,3 @@
using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders;
namespace allstarr.Middleware;
/// <summary>
@@ -12,6 +9,8 @@ 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,
@@ -19,6 +18,13 @@ public class AdminStaticFilesMiddleware
{
_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)
@@ -29,10 +35,16 @@ public class AdminStaticFilesMiddleware
{
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";
@@ -41,13 +53,19 @@ public class AdminStaticFilesMiddleware
}
}
// 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;
}
}
@@ -56,6 +74,44 @@ public class AdminStaticFilesMiddleware
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();
@@ -24,11 +24,14 @@ public class RequestLoggingMiddleware
// Log initialization status
var initialValue = _configuration.GetValue<bool>("Debug:LogAllRequests");
var initialRedactionValue = _configuration.GetValue<bool>("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
{
@@ -40,6 +43,7 @@ public class RequestLoggingMiddleware
{
// Check configuration on every request to allow dynamic toggling
var logAllRequests = _configuration.GetValue<bool>("Debug:LogAllRequests");
var redactSensitiveValues = _configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false);
if (!logAllRequests)
{
@@ -49,10 +53,13 @@ public class RequestLoggingMiddleware
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)"}");
@@ -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"))
{
@@ -153,4 +163,24 @@ public class RequestLoggingMiddleware
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}=<redacted>")
.ToArray();
return "?" + string.Join("&", redactedParts);
}
}
+28 -24
View File
@@ -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);
@@ -271,22 +287,10 @@ public class WebSocketProxyMiddleware
{
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
+2 -1
View File
@@ -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
@@ -5,6 +5,8 @@ namespace allstarr.Models.Scrobbling;
/// </summary>
public class PlaybackSession
{
private const int ExternalStartToleranceSeconds = 5;
/// <summary>
/// Unique identifier for this playback session.
/// </summary>
@@ -58,6 +60,11 @@ public class PlaybackSession
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
@@ -52,4 +52,10 @@ public record ScrobbleTrack
/// ListenBrainz only scrobbles external tracks.
/// </summary>
public bool IsExternal { get; init; } = false;
/// <summary>
/// Playback position in seconds when this listen started.
/// Used to prevent scrobbling resumed external tracks that did not start near the beginning.
/// </summary>
public int? StartPositionSeconds { get; init; }
}
+2 -2
View File
@@ -8,9 +8,9 @@ public class CacheSettings
{
/// <summary>
/// Search results cache duration in minutes.
/// Default: 120 minutes (2 hours)
/// Default: 1 minute (60 seconds)
/// </summary>
public int SearchResultsMinutes { get; set; } = 120;
public int SearchResultsMinutes { get; set; } = 1;
/// <summary>
/// Playlist cover images cache duration in hours.
@@ -5,7 +5,7 @@ namespace allstarr.Models.Settings;
/// </summary>
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; }
+23 -2
View File
@@ -1,3 +1,5 @@
using System.Text;
namespace allstarr.Models.Settings;
/// <summary>
@@ -16,6 +18,12 @@ public class ScrobblingSettings
/// </summary>
public bool LocalTracksEnabled { get; set; }
/// <summary>
/// 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.
/// </summary>
public bool SyntheticLocalPlayedSignalEnabled { get; set; }
/// <summary>
/// Last.fm settings.
/// </summary>
@@ -32,6 +40,14 @@ public class ScrobblingSettings
/// </summary>
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);
/// <summary>
/// Whether Last.fm scrobbling is enabled.
/// </summary>
@@ -42,14 +58,14 @@ public class LastFmSettings
/// Uses hardcoded credentials from Jellyfin Last.fm plugin for convenience.
/// Users can override by setting SCROBBLING_LASTFM_API_KEY in .env
/// </summary>
public string ApiKey { get; set; } = "cb3bdcd415fcb40cd572b137b2b255f5";
public string ApiKey { get; set; } = DefaultApiKey;
/// <summary>
/// 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
/// </summary>
public string SharedSecret { get; set; } = "3a08f9fad6ddc4c35b0dce0062cecb5e";
public string SharedSecret { get; set; } = DefaultSharedSecret;
/// <summary>
/// Last.fm session key (obtained via Mobile Authentication).
@@ -67,6 +83,11 @@ public class LastFmSettings
/// Only used for authentication, not stored in plaintext in production.
/// </summary>
public string? Password { get; set; }
private static string DecodeBase64(string encoded)
{
return Encoding.UTF8.GetString(Convert.FromBase64String(encoded));
}
}
/// <summary>
@@ -53,6 +53,12 @@ public class SpotifyPlaylistConfig
/// Default: "0 8 * * *" (daily at 8 AM)
/// </summary>
public string SyncSchedule { get; set; } = "0 8 * * *";
/// <summary>
/// Optional Jellyfin user owner for this playlist link.
/// Null/empty means legacy/global playlist configuration.
/// </summary>
public string? UserId { get; set; }
}
/// <summary>
@@ -78,8 +84,8 @@ public class SpotifyImportSettings
/// <summary>
/// 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.
/// </summary>
public List<SpotifyPlaylistConfig> Playlists { get; set; } = new();
@@ -186,6 +186,12 @@ public class SpotifyPlaylist
/// Snapshot ID for change detection (Spotify's playlist version identifier)
/// </summary>
public string? SnapshotId { get; set; }
/// <summary>
/// Playlist creation date when provided by Spotify.
/// If unavailable, this may be inferred from track AddedAt timestamps.
/// </summary>
public DateTime? CreatedAt { get; set; }
}
/// <summary>
+223 -50
View File
@@ -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<ForwardedHeadersOptions>(options =>
{
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)
// Keep a bounded chain by default; configurable for multi-hop proxy setups.
options.ForwardLimit = builder.Configuration.GetValue<int?>("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<string>("ForwardedHeaders:KnownProxies"));
var configuredNetworks = ParseCsv(builder.Configuration.GetValue<string>("ForwardedHeaders:KnownNetworks"));
if (configuredProxies.Count > 0 || configuredNetworks.Count > 0)
{
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;
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<string> DecodeSquidWtfUrls()
{
var encodedUrls = new[]
{
"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
};
// Legacy implementation intentionally retired.
// var encodedUrls = new[] { "aHR0cHM6Ly90cml0b24uc3F1aWQud3Rm", ... };
return encodedUrls
.Select(encoded => Encoding.UTF8.GetString(Convert.FromBase64String(encoded)))
static List<string> ParseCsv(string? raw)
{
if (string.IsNullOrWhiteSpace(raw))
{
return new List<string>();
}
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<string>(key);
if (!string.IsNullOrWhiteSpace(value))
{
return value;
}
}
return null;
}
// Determine backend type FIRST
var backendType = builder.Configuration.GetValue<BackendType>("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)
// 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)
@@ -139,6 +188,7 @@ builder.Services.AddScoped<allstarr.Filters.AdminPortFilter>();
// Admin helper service (shared utilities for admin controllers)
builder.Services.AddSingleton<allstarr.Services.Admin.AdminHelperService>();
builder.Services.AddSingleton<allstarr.Services.Admin.AdminAuthSessionService>();
// Configuration - register both settings, active one determined by backend type
builder.Services.Configure<SubsonicSettings>(
@@ -168,7 +218,7 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
#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<string>("SpotifyImport:Playlists");
if (!string.IsNullOrWhiteSpace(playlistsEnv))
{
@@ -187,19 +237,68 @@ builder.Services.Configure<SpotifyImportSettings>(options =>
{
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<SpotifyImportSettings>(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");
}
}
@@ -408,6 +507,7 @@ else
}
// Business services - shared across backends
builder.Services.AddSingleton(squidWtfEndpointCatalog);
builder.Services.AddSingleton<RedisCacheService>();
builder.Services.AddSingleton<OdesliService>();
builder.Services.AddSingleton<ILocalLibraryService, LocalLibraryService>();
@@ -422,7 +522,6 @@ if (backendType == BackendType.Jellyfin)
builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>();
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
// Register JellyfinController as a service for dependency injection
builder.Services.AddScoped<allstarr.Controllers.JellyfinController>();
@@ -480,7 +579,7 @@ else if (musicService == MusicService.SquidWTF)
sp.GetRequiredService<ILogger<SquidWTFMetadataService>>(),
sp.GetRequiredService<RedisCacheService>(),
squidWtfApiUrls,
sp.GetRequiredService<GenreEnrichmentService>()));
sp.GetService<GenreEnrichmentService>()));
builder.Services.AddSingleton<IDownloadService>(sp =>
new SquidWTFDownloadService(
sp.GetRequiredService<IHttpClientFactory>(),
@@ -492,7 +591,7 @@ else if (musicService == MusicService.SquidWTF)
sp,
sp.GetRequiredService<ILogger<SquidWTFDownloadService>>(),
sp.GetRequiredService<OdesliService>(),
squidWtfApiUrls));
squidWtfStreamingUrls));
}
// Register ParallelMetadataService to race all registered providers for faster searches
@@ -518,6 +617,7 @@ builder.Services.AddSingleton<IStartupValidator>(sp =>
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
squidWtfApiUrls,
squidWtfStreamingUrls,
sp.GetRequiredService<EndpointBenchmarkService>(),
sp.GetRequiredService<ILogger<SquidWTFStartupValidator>>()));
builder.Services.AddSingleton<IStartupValidator, LyricsStartupValidator>();
@@ -580,6 +680,8 @@ builder.Services.Configure<allstarr.Models.Settings.SpotifyApiSettings>(options
Console.WriteLine($" PreferIsrcMatching: {options.PreferIsrcMatching}");
});
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClient>();
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyApiClientFactory>();
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifySessionCookieService>();
// Register Spotify lyrics service (uses Spotify's color-lyrics API)
builder.Services.AddSingleton<allstarr.Services.Lyrics.SpotifyLyricsService>();
@@ -609,6 +711,7 @@ builder.Services.AddHostedService<allstarr.Services.Spotify.SpotifyMissingTracks
// Register Spotify track matching service (pre-matches tracks with rate limiting)
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyTrackMatchingService>();
builder.Services.AddHostedService(sp => sp.GetRequiredService<allstarr.Services.Spotify.SpotifyTrackMatchingService>());
builder.Services.AddHostedService<VersionUpgradeRebuildService>();
// Register lyrics prefetch service (prefetches lyrics for all playlist tracks)
// DISABLED - No need to prefetch since Jellyfin and Spotify lyrics are fast
@@ -628,6 +731,8 @@ builder.Services.Configure<allstarr.Models.Settings.ScrobblingSettings>(options
options.Enabled = builder.Configuration.GetValue<bool>("Scrobbling:Enabled");
options.LocalTracksEnabled = builder.Configuration.GetValue<bool>("Scrobbling:LocalTracksEnabled");
options.SyntheticLocalPlayedSignalEnabled =
builder.Configuration.GetValue<bool>("Scrobbling:SyntheticLocalPlayedSignalEnabled");
options.LastFm.Enabled = lastFmEnabled;
// Only override hardcoded API credentials if explicitly set in config
@@ -652,6 +757,7 @@ builder.Services.Configure<allstarr.Models.Settings.ScrobblingSettings>(options
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,7 +785,16 @@ builder.Services.AddSingleton<IScrobblingService, ListenBrainzScrobblingService>
builder.Services.AddSingleton<ScrobblingOrchestrator>();
builder.Services.AddSingleton<ScrobblingHelper>();
// Register MusicBrainz service for metadata enrichment
// Register MusicBrainz service for metadata enrichment (only if enabled)
var musicBrainzEnabled = builder.Configuration.GetValue<bool>("MusicBrainz:Enabled", false);
var musicBrainzEnabledEnv = builder.Configuration.GetValue<string>("MusicBrainz:Enabled");
if (!string.IsNullOrEmpty(musicBrainzEnabledEnv))
{
musicBrainzEnabled = musicBrainzEnabledEnv.Equals("true", StringComparison.OrdinalIgnoreCase);
}
if (musicBrainzEnabled)
{
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
{
builder.Configuration.GetSection("MusicBrainz").Bind(options);
@@ -704,18 +819,73 @@ builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options
}
});
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
// Register genre enrichment service
builder.Services.AddSingleton<allstarr.Services.Common.GenreEnrichmentService>();
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<string> { "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<string>
{
"Accept",
"Authorization",
"Content-Type",
"Range",
"X-Requested-With",
"X-Emby-Authorization",
"X-MediaBrowser-Token"
};
}
var corsAllowCredentials =
builder.Configuration.GetValue<bool?>("Cors:AllowCredentials")
?? builder.Configuration.GetValue<bool?>("CORS_ALLOW_CREDENTIALS")
?? builder.Configuration.GetValue<bool?>("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<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
app.UseMiddleware<allstarr.Middleware.AdminStaticFilesMiddleware>();
app.UseMiddleware<allstarr.Middleware.AdminAuthenticationMiddleware>();
app.UseAuthorization();
@@ -802,6 +974,7 @@ class BackendControllerFeatureProvider : Microsoft.AspNetCore.Mvc.Controllers.Co
// This includes: AdminController, ConfigController, DiagnosticsController, DownloadsController,
// PlaylistController, JellyfinAdminController, SpotifyAdminController, LyricsController, MappingController, ScrobblingAdminController
if (typeInfo.Name == "AdminController" ||
typeInfo.Name == "AdminAuthController" ||
typeInfo.Name == "ConfigController" ||
typeInfo.Name == "DiagnosticsController" ||
typeInfo.Name == "DownloadsController" ||
@@ -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; }
}
/// <summary>
/// In-memory authenticated admin sessions for the local Web UI.
/// </summary>
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<string, AdminAuthSession> _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<byte> bytes = stackalloc byte[32];
RandomNumberGenerator.Fill(bytes);
return Convert.ToHexString(bytes).ToLowerInvariant();
}
}
+107 -23
View File
@@ -58,19 +58,10 @@ public class AdminHelperService
{
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);
}
}
}
@@ -86,6 +77,107 @@ public class AdminHelperService
return playlists;
}
public static string SerializePlaylistsForEnv(IEnumerable<SpotifyPlaylistConfig> playlists)
{
var playlistArrays = playlists
.Select(ToEnvPlaylistArray)
.ToArray();
return JsonSerializer.Serialize(playlistArrays);
}
private static string[] ToEnvPlaylistArray(SpotifyPlaylistConfig playlist)
{
var values = new List<string>
{
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)";
@@ -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
};
@@ -384,15 +476,7 @@ public class AdminHelperService
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<string, string>
{
@@ -404,7 +488,7 @@ public class AdminHelperService
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
};
@@ -0,0 +1,104 @@
using allstarr.Models.Spotify;
namespace allstarr.Services.Admin;
/// <summary>
/// Resolves track status (local/external/missing) from ordered Spotify matched-track cache entries.
/// </summary>
public static class PlaylistTrackStatusResolver
{
public static bool TryResolveFromMatchedTrack(
IReadOnlyDictionary<string, MatchedTrack> 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]);
}
}
@@ -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";
/// <summary>
/// Returns whether the admin listener should bind to all interfaces.
/// Default is false (localhost-only).
/// </summary>
public static bool ShouldBindAdminAnyIp(IConfiguration configuration)
{
return configuration.GetValue<bool>(BindAnyIpKey);
}
/// <summary>
/// Parses trusted subnet CIDRs from configuration. Format: "192.168.1.0/24,10.0.0.0/8".
/// </summary>
public static List<IPNetwork> ParseTrustedSubnets(IConfiguration configuration)
{
var raw = configuration.GetValue<string>(TrustedSubnetsKey);
var networks = new List<IPNetwork>();
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;
}
/// <summary>
/// Checks whether a remote IP should be allowed to access the admin listener.
/// Loopback is always allowed.
/// </summary>
public static bool IsRemoteIpAllowed(IPAddress? remoteIp, IReadOnlyCollection<IPNetwork> 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;
}
}
+107
View File
@@ -13,6 +13,35 @@ public static class CacheKeyBuilder
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
@@ -46,11 +75,31 @@ public static class CacheKeyBuilder
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}";
@@ -66,6 +115,16 @@ public static class CacheKeyBuilder
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
@@ -85,6 +144,11 @@ public static class CacheKeyBuilder
return $"lyrics:manual-map:{artist}:{title}";
}
public static string BuildLyricsByIdKey(int id)
{
return $"lyrics:id:{id}";
}
#endregion
#region Playlist Keys
@@ -98,10 +162,53 @@ public static class CacheKeyBuilder
#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
}
@@ -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++;
}
@@ -256,14 +256,14 @@ 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++;
@@ -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());
}
@@ -13,7 +13,6 @@ public class GenreEnrichmentService
private readonly MusicBrainzService _musicBrainz;
private readonly RedisCacheService _cache;
private readonly ILogger<GenreEnrichmentService> _logger;
private const string GenreCachePrefix = "genre:";
private const string GenreCacheDirectory = "/app/cache/genres";
private static readonly TimeSpan GenreCacheDuration = TimeSpan.FromDays(30);
@@ -45,7 +44,7 @@ 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<string>(redisCacheKey);
if (cachedGenre != null)
+2 -2
View File
@@ -29,7 +29,7 @@ public class OdesliService
public async Task<string?> 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<string>(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
@@ -89,7 +89,7 @@ public class OdesliService
public async Task<string?> 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<string>(cacheKey);
if (!string.IsNullOrEmpty(cached))
{
@@ -0,0 +1,156 @@
using System.Net;
using System.Net.Sockets;
namespace allstarr.Services.Common;
/// <summary>
/// Guards outbound HTTP(S) requests that are derived from external metadata.
/// Blocks local/private targets to reduce SSRF risk.
/// </summary>
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;
}
}
@@ -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.
/// </summary>
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
public async Task<SearchResult> 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.
/// </summary>
public async Task<Song?> SearchSongAsync(string title, string artist, int limit = 5)
public async Task<Song?> 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();
@@ -0,0 +1,113 @@
using System.Text.Json;
namespace allstarr.Services.Common;
/// <summary>
/// Normalizes and enriches Jellyfin item ProviderIds metadata.
/// </summary>
public static class ProviderIdsEnricher
{
public static void EnsureSpotifyProviderIds(
Dictionary<string, object?> 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<string, string> GetOrCreateProviderIds(Dictionary<string, object?> item)
{
if (!item.TryGetValue("ProviderIds", out var rawProviderIds) || rawProviderIds == null)
{
var created = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
item["ProviderIds"] = created;
return created;
}
if (rawProviderIds is Dictionary<string, string> stringDict)
{
if (!ReferenceEquals(stringDict.Comparer, StringComparer.OrdinalIgnoreCase))
{
var normalized = new Dictionary<string, string>(stringDict, StringComparer.OrdinalIgnoreCase);
item["ProviderIds"] = normalized;
return normalized;
}
return stringDict;
}
var converted = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (rawProviderIds is Dictionary<string, object?> 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()
};
}
}
+83 -5
View File
@@ -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 await SetStringInternalAsync(key, value, expiry);
}
return result;
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<bool> 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<bool> 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;
}
}
/// <summary>
/// Sets a cached value by serializing it with TTL.
/// </summary>
@@ -6,6 +6,7 @@ namespace allstarr.Services.Common;
/// </summary>
public class RoundRobinFallbackHelper
{
private const int PreferredFastEndpointCount = 2;
private readonly List<string> _apiUrls;
private int _currentUrlIndex = 0;
private readonly object _urlIndexLock = new object();
@@ -144,6 +145,40 @@ public class RoundRobinFallbackHelper
return healthyEndpoints;
}
private List<string> BuildTryOrder(List<string> 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<string>(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;
}
/// <summary>
/// Updates the endpoint order based on benchmark results (fastest first).
/// </summary>
@@ -180,29 +215,22 @@ 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;
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
var orderedEndpoints = BuildTryOrder(endpointsToTry);
// 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;
}
}
@@ -303,29 +331,22 @@ 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;
// Try all URLs starting from the round-robin selected one
for (int attempt = 0; attempt < endpointsToTry.Count; attempt++)
var orderedEndpoints = BuildTryOrder(endpointsToTry);
// 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;
}
}
@@ -0,0 +1,47 @@
using System.Text.Json;
namespace allstarr.Services.Common;
/// <summary>
/// Shared helpers for provider-specific track/album/artist parsers.
/// Keeps ID and date parsing behavior consistent across metadata services.
/// </summary>
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
};
}
}
@@ -0,0 +1,49 @@
namespace allstarr.Services.Common;
/// <summary>
/// Defines when a version change should trigger a full playlist rebuild.
/// </summary>
public static class VersionUpgradePolicy
{
/// <summary>
/// Returns true when the current version is a major or minor upgrade over the previous version.
/// Patch-only upgrades and downgrades return false.
/// </summary>
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;
}
}
@@ -0,0 +1,117 @@
using allstarr.Models.Settings;
using allstarr.Services.Spotify;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Common;
/// <summary>
/// Triggers a one-time full rebuild when the app is upgraded across major/minor versions.
/// </summary>
public class VersionUpgradeRebuildService : IHostedService
{
private const string VersionStateFile = "/app/cache/version-state.txt";
private readonly SpotifyTrackMatchingService _matchingService;
private readonly SpotifyImportSettings _spotifyImportSettings;
private readonly ILogger<VersionUpgradeRebuildService> _logger;
public VersionUpgradeRebuildService(
SpotifyTrackMatchingService matchingService,
IOptions<SpotifyImportSettings> spotifyImportSettings,
ILogger<VersionUpgradeRebuildService> 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<string?> 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);
}
}
}
@@ -12,7 +12,7 @@ namespace allstarr.Services.Deezer;
/// <summary>
/// Metadata service implementation using the Deezer API (free, no key required)
/// </summary>
public class DeezerMetadataService : IMusicMetadataService
public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
{
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _settings;
@@ -29,16 +29,16 @@ public class DeezerMetadataService : IMusicMetadataService
_genreEnrichment = genreEnrichment;
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
public async Task<List<Song>> 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<Song>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var songs = new List<Song>();
@@ -62,16 +62,16 @@ public class DeezerMetadataService : IMusicMetadataService
}
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
public async Task<List<Album>> 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<Album>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
@@ -91,16 +91,16 @@ public class DeezerMetadataService : IMusicMetadataService
}
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
public async Task<List<Artist>> 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<Artist>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
@@ -120,12 +120,12 @@ public class DeezerMetadataService : IMusicMetadataService
}
}
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
public async Task<SearchResult> 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);
@@ -137,16 +137,16 @@ public class DeezerMetadataService : IMusicMetadataService
};
}
public async Task<Song?> GetSongAsync(string externalProvider, string externalId)
public async Task<Song?> 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;
@@ -162,10 +162,10 @@ 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
@@ -229,16 +229,16 @@ public class DeezerMetadataService : IMusicMetadataService
return song;
}
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId)
public async Task<Album?> 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;
@@ -271,16 +271,16 @@ public class DeezerMetadataService : IMusicMetadataService
return album;
}
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId)
public async Task<Artist?> 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;
@@ -288,16 +288,16 @@ public class DeezerMetadataService : IMusicMetadataService
return ParseDeezerArtist(artist);
}
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId)
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "deezer") return new List<Album>();
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<Album>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
@@ -312,16 +312,16 @@ public class DeezerMetadataService : IMusicMetadataService
return albums;
}
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId)
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "deezer") return new List<Song>();
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<Song>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var tracks = new List<Song>();
@@ -352,19 +352,19 @@ public class DeezerMetadataService : IMusicMetadataService
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() ?? ""
: "",
ArtistId = track.TryGetProperty("artist", out var artistForId)
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
? BuildExternalArtistId("deezer", artistForId.GetProperty("id").GetInt64().ToString())
: null,
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()}"
? BuildExternalAlbumId("deezer", albumForId.GetProperty("id").GetInt64().ToString())
: null,
Duration = track.TryGetProperty("duration", out var duration)
? duration.GetInt32()
@@ -414,21 +414,13 @@ public class DeezerMetadataService : IMusicMetadataService
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) &&
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)
@@ -446,7 +438,7 @@ public class DeezerMetadataService : IMusicMetadataService
if (!string.IsNullOrEmpty(name))
{
contributors.Add(name);
contributorIds.Add($"ext-deezer-artist-{id}");
contributorIds.Add(BuildExternalArtistId("deezer", id.ToString()));
}
}
}
@@ -482,13 +474,13 @@ public class DeezerMetadataService : IMusicMetadataService
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() ?? ""
: "",
ArtistId = track.TryGetProperty("artist", out var artistForId)
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
? BuildExternalArtistId("deezer", artistForId.GetProperty("id").GetInt64().ToString())
: null,
Artists = contributors.Count > 0 ? contributors : new List<string>(),
ArtistIds = contributorIds.Count > 0 ? contributorIds : new List<string>(),
@@ -496,7 +488,7 @@ public class DeezerMetadataService : IMusicMetadataService
? album.GetProperty("title").GetString() ?? ""
: "",
AlbumId = track.TryGetProperty("album", out var albumForId)
? $"ext-deezer-album-{albumForId.GetProperty("id").GetInt64()}"
? BuildExternalAlbumId("deezer", albumForId.GetProperty("id").GetInt64().ToString())
: null,
Duration = track.TryGetProperty("duration", out var duration)
? duration.GetInt32()
@@ -524,16 +516,16 @@ public class DeezerMetadataService : IMusicMetadataService
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() ?? ""
: "",
ArtistId = album.TryGetProperty("artist", out var artistForId)
? $"ext-deezer-artist-{artistForId.GetProperty("id").GetInt64()}"
? 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
? ParseYearFromDateString(releaseDate.GetString())
: null,
SongCount = album.TryGetProperty("nb_tracks", out var nbTracks)
? nbTracks.GetInt32()
@@ -558,7 +550,7 @@ public class DeezerMetadataService : IMusicMetadataService
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()
@@ -572,16 +564,16 @@ public class DeezerMetadataService : IMusicMetadataService
};
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
public async Task<List<ExternalPlaylist>> 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<ExternalPlaylist>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
@@ -601,18 +593,18 @@ public class DeezerMetadataService : IMusicMetadataService
}
}
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
public async Task<ExternalPlaylist?> 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;
@@ -625,18 +617,18 @@ public class DeezerMetadataService : IMusicMetadataService
}
}
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "deezer") return new List<Song>();
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<Song>();
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<Song>();
+16 -12
View File
@@ -17,70 +17,74 @@ public interface IMusicMetadataService
/// </summary>
/// <param name="query">Search term</param>
/// <param name="limit">Maximum number of results</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of found songs</returns>
Task<List<Song>> SearchSongsAsync(string query, int limit = 20);
Task<List<Song>> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default);
/// <summary>
/// Searches for albums on external providers
/// </summary>
Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20);
Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default);
/// <summary>
/// Searches for artists on external providers
/// </summary>
Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20);
Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default);
/// <summary>
/// Combined search (songs, albums, artists)
/// </summary>
Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20);
Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default);
/// <summary>
/// Gets details of an external song
/// </summary>
Task<Song?> GetSongAsync(string externalProvider, string externalId);
Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets details of an external album with its songs
/// </summary>
Task<Album?> GetAlbumAsync(string externalProvider, string externalId);
Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets details of an external artist
/// </summary>
Task<Artist?> GetArtistAsync(string externalProvider, string externalId);
Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an artist's albums
/// </summary>
Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId);
Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets an artist's top tracks (not all songs, just popular tracks from the artist endpoint)
/// </summary>
Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId);
Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Searches for playlists on external providers
/// </summary>
/// <param name="query">Search term</param>
/// <param name="limit">Maximum number of results</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of found playlists</returns>
Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20);
Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default);
/// <summary>
/// Gets details of an external playlist (metadata only, not tracks)
/// </summary>
/// <param name="externalProvider">Provider name (e.g., "deezer", "qobuz")</param>
/// <param name="externalId">Playlist ID from the provider</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>Playlist details or null if not found</returns>
Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId);
Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
/// <summary>
/// Gets all tracks from an external playlist
/// </summary>
/// <param name="externalProvider">Provider name (e.g., "deezer", "qobuz")</param>
/// <param name="externalId">Playlist ID from the provider</param>
/// <param name="cancellationToken">Cancellation token</param>
/// <returns>List of songs in the playlist</returns>
Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId);
Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default);
}
@@ -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);
}
/// <summary>
@@ -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,10 +777,11 @@ 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);
@@ -791,6 +808,12 @@ 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
@@ -412,6 +412,8 @@ 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<string, string>
{
[song.ExternalProvider] = song.ExternalId ?? ""
@@ -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);
}
/// <summary>
/// Converts an Album domain model to a Jellyfin item.
/// </summary>
@@ -19,6 +19,7 @@ public class JellyfinSessionManager : IDisposable
private readonly JellyfinSettings _settings;
private readonly ILogger<JellyfinSessionManager> _logger;
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private readonly ConcurrentDictionary<string, SemaphoreSlim> _sessionInitLocks = new();
private readonly Timer _keepAliveTimer;
public JellyfinSessionManager(
@@ -48,6 +49,10 @@ public class JellyfinSessionManager : IDisposable
return false;
}
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))
{
@@ -56,8 +61,8 @@ public class JellyfinSessionManager : IDisposable
// 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)
var refreshOk = await PostCapabilitiesAsync(headers);
if (!refreshOk)
{
// Token expired - remove the stale session
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
@@ -70,12 +75,9 @@ public class JellyfinSessionManager : IDisposable
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
try
{
// 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);
@@ -110,6 +112,10 @@ public class JellyfinSessionManager : IDisposable
_logger.LogError(ex, "Error creating session for {DeviceId}", deviceId);
return false;
}
finally
{
initLock.Release();
}
}
/// <summary>
@@ -184,6 +190,112 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <summary>
/// Marks that an explicit playback stop was received for this device+item.
/// Used to suppress duplicate inferred stop forwarding from progress transitions.
/// </summary>
public void MarkExplicitStop(string deviceId, string itemId)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
lock (session.SyncRoot)
{
session.LastExplicitStopItemId = itemId;
session.LastExplicitStopAtUtc = DateTime.UtcNow;
}
}
}
/// <summary>
/// Returns true when an explicit stop for this device+item was recorded within the given time window.
/// </summary>
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;
}
/// <summary>
/// Returns true if a local played-signal was already sent for this device+item.
/// </summary>
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;
}
/// <summary>
/// Marks that a local played-signal was sent for this device+item.
/// </summary>
public void MarkLocalPlayedSignalSent(string deviceId, string itemId)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
lock (session.SyncRoot)
{
session.LastLocalPlayedSignalItemId = itemId;
}
}
}
/// <summary>
/// Returns true when a tracked session exists for this device.
/// </summary>
public bool HasSession(string deviceId)
{
return !string.IsNullOrWhiteSpace(deviceId) && _sessions.ContainsKey(deviceId);
}
/// <summary>
/// Gets the last playing item id for a tracked session, if present.
/// </summary>
public string? GetLastPlayingItemId(string deviceId)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
return session.LastPlayingItemId;
}
return null;
}
/// <summary>
/// Gets last tracked playing item and position for a device, if present.
/// </summary>
public (string? ItemId, long? PositionTicks) GetLastPlayingState(string deviceId)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
return (session.LastPlayingItemId, session.LastPlayingPositionTicks);
}
return (null, null);
}
/// <summary>
/// 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.
@@ -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,15 +458,14 @@ 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;
}
}
@@ -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}");
@@ -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)
{
+3 -3
View File
@@ -39,10 +39,10 @@ public class LrclibService
}
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)
@@ -357,7 +357,7 @@ public class LrclibService
public async Task<LyricsInfo?> GetLyricsByIdAsync(int id)
{
var cacheKey = $"lyrics:id:{id}";
var cacheKey = CacheKeyBuilder.BuildLyricsByIdKey(id);
var cached = await _cache.GetStringAsync(cacheKey);
if (!string.IsNullOrEmpty(cached))
@@ -43,7 +43,7 @@ public class LyricsPlusService
}
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);
@@ -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))
@@ -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,7 +344,7 @@ 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
@@ -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<MusicBrainzRecording>(cacheKey);
if (cached != null)
{
@@ -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<List<MusicBrainzRecording>>(cacheKey);
if (cached != null)
{
@@ -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<MusicBrainzRecording>(cacheKey);
if (cached != null)
{
+46 -71
View File
@@ -13,7 +13,7 @@ namespace allstarr.Services.Qobuz;
/// Metadata service implementation using the Qobuz API
/// Uses user authentication token instead of email/password
/// </summary>
public class QobuzMetadataService : IMusicMetadataService
public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
{
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _settings;
@@ -48,17 +48,17 @@ public class QobuzMetadataService : IMusicMetadataService
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:83.0) Gecko/20100101 Firefox/83.0");
}
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
public async Task<List<Song>> 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<Song>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var songs = new List<Song>();
@@ -81,17 +81,17 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
public async Task<List<Album>> 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<Album>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
@@ -113,17 +113,17 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
public async Task<List<Artist>> 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<Artist>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
@@ -145,11 +145,11 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
public async Task<SearchResult> 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);
@@ -161,7 +161,7 @@ public class QobuzMetadataService : IMusicMetadataService
};
}
public async Task<Song?> GetSongAsync(string externalProvider, string externalId)
public async Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "qobuz") return null;
@@ -170,10 +170,10 @@ public class QobuzMetadataService : IMusicMetadataService
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;
@@ -206,7 +206,7 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId)
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "qobuz") return null;
@@ -215,10 +215,10 @@ public class QobuzMetadataService : IMusicMetadataService
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;
@@ -251,7 +251,7 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId)
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "qobuz") return null;
@@ -260,10 +260,10 @@ public class QobuzMetadataService : IMusicMetadataService
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;
@@ -277,7 +277,7 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId)
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "qobuz") return new List<Album>();
@@ -293,10 +293,10 @@ public class QobuzMetadataService : IMusicMetadataService
{
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) ||
@@ -328,7 +328,7 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId)
public async Task<List<Song>> 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,17 +336,17 @@ public class QobuzMetadataService : IMusicMetadataService
return new List<Song>();
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
public async Task<List<ExternalPlaylist>> 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<ExternalPlaylist>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
@@ -368,7 +368,7 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "qobuz") return null;
@@ -377,10 +377,10 @@ public class QobuzMetadataService : IMusicMetadataService
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;
@@ -394,7 +394,7 @@ public class QobuzMetadataService : IMusicMetadataService
}
}
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "qobuz") return new List<Song>();
@@ -403,10 +403,10 @@ public class QobuzMetadataService : IMusicMetadataService
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<Song>();
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<Song>();
@@ -511,23 +511,10 @@ public class QobuzMetadataService : IMusicMetadataService
};
}
/// <summary>
/// Safely gets an ID value as a string, handling both number and string types from JSON
/// </summary>
private string GetIdAsString(JsonElement element)
{
return element.ValueKind switch
{
JsonValueKind.Number => element.GetInt64().ToString(),
JsonValueKind.String => element.GetString() ?? "",
_ => ""
};
}
/// <summary>
/// Makes an HTTP GET request with Qobuz authentication headers
/// </summary>
private async Task<HttpResponseMessage> GetWithAuthAsync(string url)
private async Task<HttpResponseMessage> GetWithAuthAsync(string url, CancellationToken cancellationToken = default)
{
using var request = new HttpRequestMessage(HttpMethod.Get, url);
@@ -539,7 +526,7 @@ public class QobuzMetadataService : IMusicMetadataService
request.Headers.Add("X-User-Auth-Token", _userAuthToken);
}
return await _httpClient.SendAsync(request);
return await _httpClient.SendAsync(request, cancellationToken);
}
private Song ParseQobuzTrack(JsonElement track)
@@ -577,7 +564,7 @@ public class QobuzMetadataService : IMusicMetadataService
: "";
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
@@ -588,11 +575,11 @@ public class QobuzMetadataService : IMusicMetadataService
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,
@@ -642,13 +629,7 @@ 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))
@@ -692,22 +673,16 @@ public class QobuzMetadataService : IMusicMetadataService
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)
@@ -729,7 +704,7 @@ public class QobuzMetadataService : IMusicMetadataService
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)
@@ -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);
}
}
@@ -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
};
}
@@ -49,6 +49,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()}";
var session = new PlaybackSession
@@ -73,21 +86,41 @@ public class ScrobblingOrchestrator
/// <summary>
/// Handles playback progress - checks if track should be scrobbled.
/// </summary>
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,15 +304,39 @@ public class ScrobblingOrchestrator
{
_logger.LogError(ex, "❌ Error scrobbling to {Service} after {Max} attempts",
service.ServiceName, maxRetries);
return false;
}
}
}
return false;
});
await Task.WhenAll(tasks);
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();
}
/// <summary>
/// Cleans up stale sessions (inactive for more than 10 minutes).
+301 -15
View File
@@ -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;
@@ -411,6 +412,19 @@ public class SpotifyApiClient : IDisposable
{
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);
}
@@ -424,20 +438,75 @@ public class SpotifyApiClient : IDisposable
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))
{
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<SpotifyPlaylistTrack>()
};
@@ -467,8 +536,9 @@ public class SpotifyApiClient : IDisposable
return null;
}
// Parse artists
// Parse artists with IDs
var artists = new List<string>();
var artistIds = new List<string>();
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))
{
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) &&
@@ -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
};
}
@@ -565,6 +715,7 @@ 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
};
@@ -667,11 +818,7 @@ public class SpotifyApiClient : IDisposable
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);
@@ -942,7 +1089,8 @@ public class SpotifyApiClient : IDisposable
TotalTracks = trackCount,
OwnerName = ownerName,
ImageUrl = imageUrl,
SnapshotId = null
SnapshotId = null,
CreatedAt = TryGetSpotifyPlaylistCreatedAt(playlist)
});
}
@@ -969,6 +1117,144 @@ public class SpotifyApiClient : IDisposable
}
}
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;
}
}
/// <summary>
/// Gets the current user's profile to verify authentication is working.
/// </summary>
@@ -0,0 +1,39 @@
using allstarr.Models.Settings;
using Microsoft.Extensions.Options;
namespace allstarr.Services.Spotify;
/// <summary>
/// Creates SpotifyApiClient instances bound to a specific session cookie.
/// </summary>
public class SpotifyApiClientFactory
{
private readonly ILoggerFactory _loggerFactory;
private readonly SpotifyApiSettings _baseSettings;
public SpotifyApiClientFactory(
ILoggerFactory loggerFactory,
IOptions<SpotifyApiSettings> 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<SpotifyApiClient>(),
Options.Create(scopedSettings));
}
}
@@ -12,8 +12,6 @@ public class SpotifyMappingService
{
private readonly RedisCacheService _cache;
private readonly ILogger<SpotifyMappingService> _logger;
private const string MappingKeyPrefix = "spotify:global-map:";
private const string AllMappingsKey = "spotify:global-map:all-ids";
public SpotifyMappingService(
RedisCacheService cache,
@@ -28,7 +26,7 @@ public class SpotifyMappingService
/// </summary>
public async Task<SpotifyTrackMapping?> GetMappingAsync(string spotifyId)
{
var key = $"{MappingKeyPrefix}{spotifyId}";
var key = CacheKeyBuilder.BuildSpotifyGlobalMappingKey(spotifyId);
var mapping = await _cache.GetAsync<SpotifyTrackMapping>(key);
if (mapping != null)
@@ -66,7 +64,7 @@ public class SpotifyMappingService
return false;
}
var key = $"{MappingKeyPrefix}{mapping.SpotifyId}";
var key = CacheKeyBuilder.BuildSpotifyGlobalMappingKey(mapping.SpotifyId);
// Check if mapping already exists
var existingMapping = await GetMappingAsync(mapping.SpotifyId);
@@ -210,7 +208,7 @@ public class SpotifyMappingService
/// </summary>
public async Task<bool> DeleteMappingAsync(string spotifyId)
{
var key = $"{MappingKeyPrefix}{spotifyId}";
var key = CacheKeyBuilder.BuildSpotifyGlobalMappingKey(spotifyId);
var success = await _cache.DeleteAsync(key);
if (success)
@@ -227,7 +225,7 @@ public class SpotifyMappingService
/// </summary>
public async Task<List<string>> GetAllMappingIdsAsync()
{
var json = await _cache.GetStringAsync(AllMappingsKey);
var json = await _cache.GetStringAsync(CacheKeyBuilder.BuildSpotifyGlobalMappingsIndexKey());
if (string.IsNullOrEmpty(json))
{
return new List<string>();
@@ -335,7 +333,7 @@ public class SpotifyMappingService
{
allIds.Add(spotifyId);
var json = JsonSerializer.Serialize(allIds);
await _cache.SetStringAsync(AllMappingsKey, json, expiry: null);
await _cache.SetStringAsync(CacheKeyBuilder.BuildSpotifyGlobalMappingsIndexKey(), json, expiry: null);
}
}
@@ -346,7 +344,7 @@ public class SpotifyMappingService
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,7 +356,7 @@ 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
@@ -16,6 +16,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
private readonly RedisCacheService _cache;
private readonly ILogger<SpotifyMissingTracksFetcher> _logger;
private readonly IServiceProvider _serviceProvider;
private readonly SpotifySessionCookieService _spotifySessionCookieService;
private bool _hasRunOnce = false;
private Dictionary<string, string> _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<SpotifyMissingTracksFetcher> logger)
{
_spotifySettings = spotifySettings;
@@ -35,6 +37,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_httpClientFactory = httpClientFactory;
_cache = cache;
_serviceProvider = serviceProvider;
_spotifySessionCookieService = spotifySessionCookieService;
_logger = logger;
}
@@ -55,11 +58,12 @@ public class SpotifyMissingTracksFetcher : BackgroundService
// 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;
@@ -2,7 +2,6 @@ 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;
@@ -25,10 +24,10 @@ 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<string, string> _playlistNameToSpotifyId = new();
@@ -37,12 +36,16 @@ public class SpotifyPlaylistFetcher : BackgroundService
IOptions<SpotifyApiSettings> spotifyApiSettings,
IOptions<SpotifyImportSettings> 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;
}
@@ -54,7 +57,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
public async Task<List<SpotifyPlaylistTrack>> 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<SpotifyPlaylist>(cacheKey);
@@ -63,7 +67,6 @@ public class SpotifyPlaylistFetcher : BackgroundService
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))
@@ -101,15 +104,33 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
// Cache miss or expired - need to fetch fresh from Spotify
var sessionCookie = await _spotifySessionCookieService.ResolveSessionCookieAsync(playlistConfig?.UserId);
if (string.IsNullOrWhiteSpace(sessionCookie))
{
_logger.LogWarning("No Spotify session cookie configured for playlist '{Name}' (user scope: {UserId})",
playlistName, playlistConfig?.UserId ?? "(global)");
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
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))
{
// Check if we have a configured Spotify ID for this playlist
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (config != null && !string.IsNullOrEmpty(config.Id))
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = config.Id;
spotifyId = playlistConfig.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
@@ -117,7 +138,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
{
// 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 playlists = await spotifyClient.SearchUserPlaylistsAsync(playlistName);
var exactMatch = playlists.FirstOrDefault(p =>
p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
@@ -135,7 +156,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
// Fetch the full playlist
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
var playlist = await spotifyClient.GetPlaylistAsync(spotifyId);
if (playlist == null || playlist.Tracks.Count == 0)
{
_logger.LogError("Failed to fetch playlist '{Name}' from Spotify", playlistName);
@@ -143,7 +164,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
// Calculate cache expiration based on cron schedule
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
var playlistCfg = playlistConfig;
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
@@ -170,13 +191,29 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
// Update Redis cache with cron-based expiration
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
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
{
_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;
}
finally
{
scopedSpotifyClient?.Dispose();
}
}
/// <summary>
/// Gets missing tracks for a playlist (tracks not found in Jellyfin library).
@@ -205,7 +242,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
_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
@@ -237,25 +274,29 @@ public class SpotifyPlaylistFetcher : BackgroundService
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...");
// Validate global fallback cookie if configured; user-scoped cookies are validated per playlist fetch.
if (!string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie))
{
_logger.LogDebug("Attempting Spotify authentication using global fallback cookie...");
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
if (string.IsNullOrEmpty(token))
{
_logger.LogError("Failed to get Spotify access token - check session cookie");
_logger.LogInformation("========================================");
return;
_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);
@@ -286,7 +327,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
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<SpotifyPlaylist>(cacheKey);
if (cached != null)
@@ -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;
/// <summary>
/// Stores and resolves Spotify session cookies in a user-scoped model.
/// </summary>
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<SpotifySessionCookieService> _logger;
private readonly SemaphoreSlim _lock = new(1, 1);
public SpotifySessionCookieService(
IOptions<SpotifyApiSettings> spotifyApiSettings,
AdminHelperService adminHelper,
ILogger<SpotifySessionCookieService> logger)
{
_spotifyApiSettings = spotifyApiSettings.Value;
_adminHelper = adminHelper;
_logger = logger;
}
public async Task<string?> 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<bool> 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<DateTime?> 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<IActionResult> 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<string, string>
{
[UserCookieMapKey] = JsonSerializer.Serialize(userCookieMap),
[UserCookieSetDatesKey] = JsonSerializer.Serialize(setDateMap)
};
return await _adminHelper.UpdateEnvConfigAsync(updates);
}
finally
{
_lock.Release();
}
}
private async Task<Dictionary<string, string>> ReadUserCookieMapAsync()
{
return await ReadEnvJsonMapAsync(UserCookieMapKey);
}
private async Task<Dictionary<string, string>> ReadUserCookieSetDateMapAsync()
{
return await ReadEnvJsonMapAsync(UserCookieSetDatesKey);
}
private async Task<Dictionary<string, string>> ReadEnvJsonMapAsync(string envKey)
{
try
{
var envPath = _adminHelper.GetEnvFilePath();
if (!File.Exists(envPath))
{
return new Dictionary<string, string>(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<string, string>(StringComparer.OrdinalIgnoreCase);
}
var parsed = JsonSerializer.Deserialize<Dictionary<string, string>>(value);
return parsed != null
? new Dictionary<string, string>(parsed, StringComparer.OrdinalIgnoreCase)
: new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to read Spotify user cookie map key {Key}", envKey);
}
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
}
@@ -19,7 +19,9 @@ namespace allstarr.Services.Spotify;
///
/// 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.
/// </summary>
public class SpotifyTrackMatchingService : BackgroundService
@@ -82,7 +84,7 @@ public class SpotifyTrackMatchingService : BackgroundService
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)
@@ -112,8 +114,10 @@ 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)
@@ -123,7 +127,7 @@ public class SpotifyTrackMatchingService : BackgroundService
try
{
var cron = CronExpression.Parse(schedule);
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
var nextRun = cron.GetNextOccurrence(schedulerReference, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
@@ -149,44 +153,62 @@ public class SpotifyTrackMatchingService : BackgroundService
continue;
}
// Find the next playlist that needs to run
// Run all playlists that are currently due.
var duePlaylists = nextRuns
.Where(x => x.NextRun <= now)
.OrderBy(x => x.NextRun)
.ToList();
if (duePlaylists.Count == 0)
{
// 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;
if (waitTime.TotalSeconds > 0)
{
_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);
_logger.LogInformation(
"=== CRON TRIGGER: Running scheduled rebuild for {Count} due playlists ===",
duePlaylists.Count);
// Check cooldown to prevent duplicate runs
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
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));
}
// 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));
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
if (anySkippedForCooldown)
{
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
}
}
catch (Exception ex)
{
@@ -198,7 +220,7 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// 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.
/// </summary>
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
{
@@ -218,11 +240,11 @@ public class SpotifyTrackMatchingService : BackgroundService
{
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)
@@ -322,36 +344,64 @@ public class SpotifyTrackMatchingService : BackgroundService
/// <summary>
/// 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.
/// </summary>
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);
}
/// <summary>
/// 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.
/// </summary>
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
{
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist} (same as cron job)", playlistName);
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName);
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
playlistName,
CancellationToken.None,
trigger: "manual");
// Check cooldown to prevent abuse
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<bool> 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;
}
/// <summary>
@@ -374,20 +424,10 @@ public class SpotifyTrackMatchingService : BackgroundService
{
_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)
@@ -559,10 +599,10 @@ 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<string>(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);
@@ -877,7 +917,7 @@ public class SpotifyTrackMatchingService : BackgroundService
["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",
@@ -919,7 +959,7 @@ public class SpotifyTrackMatchingService : BackgroundService
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);
@@ -1217,7 +1257,7 @@ 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<List<Song>>(matchedTracksKey);
@@ -1375,6 +1415,15 @@ public class SpotifyTrackMatchingService : BackgroundService
{
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)
@@ -1411,7 +1460,7 @@ public class SpotifyTrackMatchingService : BackgroundService
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<string>(manualMappingKey);
if (!string.IsNullOrEmpty(manualJellyfinId))
@@ -1475,6 +1524,9 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId,
spotifyTrack.AlbumId);
finalItems.Add(itemDict);
if (matchedKey != null)
{
@@ -1488,7 +1540,7 @@ public class SpotifyTrackMatchingService : BackgroundService
}
// 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))
@@ -1558,21 +1610,8 @@ public class SpotifyTrackMatchingService : BackgroundService
// 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<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
if (providerIds != null && !providerIds.ContainsKey("Spotify"))
{
providerIds["Spotify"] = spotifyTrack.SpotifyId;
}
}
ProviderIdsEnricher.EnsureSpotifyProviderIds(externalItem, spotifyTrack.SpotifyId,
spotifyTrack.AlbumId);
finalItems.Add(externalItem);
externalUsedCount++;
@@ -1679,6 +1718,9 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId,
spotifyTrack.AlbumId);
finalItems.Add(itemDict);
if (matchedKey != null)
{
@@ -1696,21 +1738,8 @@ 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<string, string>();
}
var providerIds = externalItem["ProviderIds"] as Dictionary<string, string>;
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)
@@ -1873,4 +1902,3 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
}
@@ -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);
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);
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);
@@ -24,6 +24,7 @@ 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)
@@ -49,7 +50,7 @@ namespace allstarr.Services.SquidWTF;
/// - Parallel Spotify ID conversion via Odesli for lyrics matching
/// </summary>
public class SquidWTFMetadataService : IMusicMetadataService
public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
{
private readonly HttpClient _httpClient;
private readonly SubsonicSettings _settings;
@@ -84,21 +85,120 @@ public class SquidWTFMetadataService : IMusicMetadataService
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20)
public async Task<List<Song>> SearchSongsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
// Race top 3 fastest endpoints for search (latency-sensitive)
return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) =>
var allSongs = new List<Song>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var queryVariant in BuildSearchQueryVariants(query))
{
var songs = await SearchSongsSingleQueryAsync(queryVariant, limit, cancellationToken);
foreach (var song in songs)
{
var key = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key))
{
continue;
}
allSongs.Add(song);
if (allSongs.Count >= limit)
{
break;
}
}
if (allSongs.Count >= limit)
{
break;
}
}
_logger.LogInformation("✓ SQUIDWTF: Song search returned {Count} results", allSongs.Count);
return allSongs;
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var allAlbums = new List<Album>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var queryVariant in BuildSearchQueryVariants(query))
{
var albums = await SearchAlbumsSingleQueryAsync(queryVariant, limit, cancellationToken);
foreach (var album in albums)
{
var key = !string.IsNullOrWhiteSpace(album.ExternalId) ? album.ExternalId : album.Id;
if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key))
{
continue;
}
allAlbums.Add(album);
if (allAlbums.Count >= limit)
{
break;
}
}
if (allAlbums.Count >= limit)
{
break;
}
}
_logger.LogInformation("✓ SQUIDWTF: Album search returned {Count} results", allAlbums.Count);
return allAlbums;
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20, CancellationToken cancellationToken = default)
{
var allArtists = new List<Artist>();
var seenIds = new HashSet<string>(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<List<Song>> 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, ct);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
// Check for error in response body
var result = JsonDocument.Parse(json);
@@ -127,25 +227,25 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
}
return songs;
});
}, new List<Song>());
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
private async Task<List<Album>> SearchAlbumsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
{
// Race top 3 fastest endpoints for search (latency-sensitive)
return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) =>
// 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, ct);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
@@ -165,19 +265,19 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
return albums;
});
}, new List<Album>());
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
private async Task<List<Artist>> SearchArtistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
{
// Race top 3 fastest endpoints for search (latency-sensitive)
return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) =>
// 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, ct);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -185,7 +285,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
@@ -206,21 +306,48 @@ public class SquidWTFMetadataService : IMusicMetadataService
}
}
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
return artists;
});
}, new List<Artist>());
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
private static IReadOnlyList<string> BuildSearchQueryVariants(string query)
{
var variants = new List<string>();
AddVariant(variants, query);
if (query.Contains('&'))
{
AddVariant(variants, query.Replace("&", " and "));
}
return variants;
}
private static void AddVariant(List<string> 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<List<ExternalPlaylist>> 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<ExternalPlaylist>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var playlists = new List<ExternalPlaylist>();
@@ -250,12 +377,12 @@ public class SquidWTFMetadataService : IMusicMetadataService
}, new List<ExternalPlaylist>());
}
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20)
public async Task<SearchResult> 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);
@@ -269,7 +396,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return temp;
}
public async Task<Song?> GetSongAsync(string externalProvider, string externalId)
public async Task<Song?> GetSongAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "squidwtf") return null;
@@ -278,10 +405,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
// 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 } }
@@ -314,12 +441,96 @@ public class SquidWTFMetadataService : IMusicMetadataService
}, (Song?)null);
}
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId)
public async Task<List<Song>> GetTrackRecommendationsAsync(string externalId, int limit = 20, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(externalId)) return new List<Song>();
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<Song>();
}
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<Song>();
}
var songs = new List<Song>();
var seenIds = new HashSet<string>(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<Song>());
}
public async Task<Album?> 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<Album>(cacheKey);
if (cached != null) return cached;
@@ -328,10 +539,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
// 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 } }
@@ -364,14 +575,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
}, (Album?)null);
}
public async Task<Artist?> GetArtistAsync(string externalProvider, string externalId)
public async Task<Artist?> 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<Artist>(cacheKey);
if (cached != null)
{
@@ -385,14 +596,14 @@ public class SquidWTFMetadataService : IMusicMetadataService
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);
@@ -461,7 +672,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}, (Artist?)null);
}
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId)
public async Task<List<Album>> GetArtistAlbumsAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "squidwtf") return new List<Album>();
@@ -472,7 +683,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// Note: hifi-api doesn't document artist endpoint, but /artist/?f={artistId} is commonly used
var url = $"{baseUrl}/artist/?f={externalId}";
_logger.LogDebug("Fetching artist albums from URL: {Url}", url);
var response = await _httpClient.GetAsync(url);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
@@ -480,7 +691,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return new List<Album>();
}
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);
@@ -508,7 +719,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}, new List<Album>());
}
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId)
public async Task<List<Song>> GetArtistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "squidwtf") return new List<Song>();
@@ -519,7 +730,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
// 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)
{
@@ -527,7 +738,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
return new List<Song>();
}
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);
@@ -552,7 +763,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}, new List<Song>());
}
public async Task<ExternalPlaylist?> GetPlaylistAsync(string externalProvider, string externalId)
public async Task<ExternalPlaylist?> 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
@@ -578,7 +789,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
}, (ExternalPlaylist?)null);
}
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId)
public async Task<List<Song>> GetPlaylistTracksAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
{
if (externalProvider != "squidwtf") return new List<Song>();
@@ -586,10 +797,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 new List<Song>();
var json = await response.Content.ReadAsStringAsync();
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var playlistElement = JsonDocument.Parse(json).RootElement;
// Check for error response
@@ -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";
}
/// <summary>
/// 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,20 +881,42 @@ 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)
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<string>();
var allArtistIds = new List<string>();
@@ -681,20 +924,25 @@ public class SquidWTFMetadataService : IMusicMetadataService
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()}";
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)
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
/// <returns>Parsed Song object with extended metadata</returns>
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<string>();
var allArtistIds = new List<string>();
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);
}
/// <summary>
@@ -894,22 +1110,30 @@ public class SquidWTFMetadataService : IMusicMetadataService
{
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
@@ -918,22 +1142,22 @@ public class SquidWTFMetadataService : IMusicMetadataService
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)
SongCount = album.TryGetProperty("numberOfTracks", out var trackCount) && trackCount.ValueKind == JsonValueKind.Number
? trackCount.GetInt32()
: null,
CoverArtUrl = coverArt,
@@ -955,21 +1179,18 @@ 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);
}
}
return new Artist
{
Id = $"ext-squidwtf-artist-{externalId}",
Id = BuildExternalArtistId("squidwtf", externalId),
Name = artistName,
ImageUrl = imageUrl,
AlbumCount = artist.TryGetProperty("albums_count", out var albumsCount)
@@ -1012,10 +1233,10 @@ public class SquidWTFMetadataService : IMusicMetadataService
? 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
{
@@ -1024,6 +1245,15 @@ 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,13 +1271,31 @@ 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
@@ -1121,7 +1369,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
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 _) ||
@@ -1,4 +1,3 @@
using System.Text;
using System.Text.Json;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
@@ -13,9 +12,11 @@ namespace allstarr.Services.SquidWTF;
public class SquidWTFStartupValidator : BaseStartupValidator
{
private readonly SquidWTFSettings _settings;
private readonly RoundRobinFallbackHelper _fallbackHelper;
private readonly List<string> _apiUrls;
private readonly List<string> _streamingUrls;
private readonly RoundRobinFallbackHelper _apiFallbackHelper;
private readonly RoundRobinFallbackHelper _streamingFallbackHelper;
private readonly EndpointBenchmarkService _benchmarkService;
private readonly ILogger<SquidWTFStartupValidator> _logger;
public override string ServiceName => "SquidWTF";
@@ -23,14 +24,17 @@ public class SquidWTFStartupValidator : BaseStartupValidator
IOptions<SquidWTFSettings> settings,
HttpClient httpClient,
List<string> apiUrls,
List<string> streamingUrls,
EndpointBenchmarkService benchmarkService,
ILogger<SquidWTFStartupValidator> 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<string>();
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<string>)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<string> 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)
@@ -0,0 +1,40 @@
namespace allstarr.Services.SquidWTF;
/// <summary>
/// Holds the discovered SquidWTF endpoint pools.
/// API endpoints are used for metadata/search calls.
/// Streaming endpoints are used for /track/ and audio streaming calls.
/// </summary>
public sealed class SquidWtfEndpointCatalog
{
public SquidWtfEndpointCatalog(List<string> apiUrls, List<string> 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<string> ApiUrls { get; }
public List<string> StreamingUrls { get; }
public DateTime LoadedAtUtc { get; }
}
@@ -0,0 +1,172 @@
using System.Text.Json;
namespace allstarr.Services.SquidWTF;
public static class SquidWtfEndpointDiscovery
{
public static readonly IReadOnlyList<string> SourceUrls = new[]
{
"https://tidal-uptime.jiffy-puffs-1j.workers.dev/",
"https://tidal-uptime.props-76styles.workers.dev/"
};
public static async Task<SquidWtfEndpointCatalog> DiscoverAsync(CancellationToken cancellationToken = default)
{
using var httpClient = new HttpClient
{
Timeout = TimeSpan.FromSeconds(10)
};
var feeds = new List<EndpointFeed>();
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<string>(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<string> ParseUrlList(JsonElement root, string propertyName)
{
if (!root.TryGetProperty(propertyName, out var element) || element.ValueKind != JsonValueKind.Array)
{
return new List<string>();
}
var urls = new List<string>();
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<string> MergeDistinctUrls(IEnumerable<List<string>> lists)
{
var seen = new HashSet<string>(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<string> ApiUrls,
List<string> StreamingUrls,
List<string> DownUrls);
}
@@ -145,9 +145,9 @@ public class SubsonicProxyService
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
};
+9 -3
View File
@@ -5,9 +5,15 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<RootNamespace>allstarr</RootNamespace>
<Version>1.1.1</Version>
<AssemblyVersion>1.1.1.0</AssemblyVersion>
<FileVersion>1.1.1.0</FileVersion>
<!-- Keep build/package version in sync with AppVersion.cs -->
<AppVersionFile>$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', 'AppVersion.cs'))</AppVersionFile>
<AppVersionText>$([System.IO.File]::ReadAllText('$(AppVersionFile)'))</AppVersionText>
<AppVersion>$([System.Text.RegularExpressions.Regex]::Match('$(AppVersionText)', 'Version\s*=\s*\"([0-9]+\.[0-9]+\.[0-9]+)\"').Groups[1].Value)</AppVersion>
<Version>$(AppVersion)</Version>
<AssemblyVersion>$(AppVersion).0</AssemblyVersion>
<FileVersion>$(AppVersion).0</FileVersion>
</PropertyGroup>
<ItemGroup>
+13 -2
View File
@@ -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,
+109 -46
View File
@@ -17,17 +17,40 @@
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
</div>
<div class="container">
<div class="auth-gate" id="auth-gate">
<div class="auth-card">
<h2>Sign In With Jellyfin</h2>
<p>Use your Jellyfin account to access the local Allstarr admin UI.</p>
<form id="auth-login-form" autocomplete="off">
<label for="auth-username">Username</label>
<input id="auth-username" type="text" required>
<label for="auth-password">Password</label>
<input id="auth-password" type="password" required>
<button class="primary" type="submit">Sign In</button>
<div class="auth-error" id="auth-error" role="alert"></div>
</form>
</div>
</div>
<div class="container" id="main-container" style="display:none;">
<header>
<h1>
Allstarr <span class="version" id="version">Loading...</span>
</h1>
<div class="header-actions">
<div class="auth-user" id="auth-user-display" style="display:none;">
Signed in as <strong id="auth-user-name">-</strong>
</div>
<button id="auth-logout-btn" onclick="logoutAdminSession()" style="display:none;">Logout</button>
<div id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
</div>
</div>
</header>
<div class="tabs">
@@ -88,7 +111,8 @@
<h2>
Quick Actions
</h2>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<div id="dashboard-guidance" class="guidance-stack"></div>
<div class="card-actions-row">
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
<button onclick="clearCache()">Clear Cache</button>
<button onclick="openAddPlaylist()">Add Playlist</button>
@@ -112,8 +136,9 @@
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
reliable.
</p>
<div id="jellyfin-guidance" class="guidance-stack"></div>
<div style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
<div id="jellyfin-user-filter" style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
<label
style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
@@ -128,16 +153,14 @@
<thead>
<tr>
<th>Name</th>
<th>Local</th>
<th>External</th>
<th>Linked Spotify ID</th>
<th>Tracks</th>
<th>Status</th>
<th>Actions</th>
<th>...</th>
</tr>
</thead>
<tbody id="jellyfin-playlist-table-body">
<tr>
<td colspan="6" class="loading">
<td colspan="4" class="loading">
<span class="spinner"></span> Loading Jellyfin playlists...
</td>
</tr>
@@ -149,16 +172,15 @@
<!-- Active Playlists Tab -->
<div class="tab-content" id="tab-playlists">
<!-- Warning Banner (hidden by default) -->
<div id="matching-warning-banner"
style="display:none;background:#f59e0b;color:#000;padding:16px;border-radius:8px;margin-bottom:16px;font-weight:600;text-align:center;box-shadow:0 4px 6px rgba(0,0,0,0.1);">
<div id="matching-warning-banner" class="guidance-banner warning matching-progress-banner" style="display:none;">
⚠️ TRACK MATCHING IN PROGRESS - Please wait for matching to complete before making changes to playlists
or mappings!
</div>
<div class="card">
<h2>Playlist Injection Settings</h2>
<div style="background: rgba(59, 130, 246, 0.15); border: 1px solid var(--primary); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-secondary); font-size: 0.9rem;">
️ Music Library ID is required for injecting external playlists into Jellyfin. This tells Allstarr which library to inject playlists into. Get it from your Jellyfin library URL.
<div class="guidance-banner info compact">
️ Music Library ID is required for playlist injection. Get it from your Jellyfin music library URL.
</div>
<div class="config-section">
<div class="config-item">
@@ -178,41 +200,40 @@
title="Re-match tracks when local library changed (uses cached Spotify data)">Rematch All</button>
<button onclick="refreshPlaylists()"
title="Fetch the latest playlist data from Spotify without re-matching tracks">Refresh All</button>
<button onclick="refreshAndMatchAll()"
<button class="primary" onclick="refreshAndMatchAll()"
title="Rebuild all playlists when Spotify playlists changed (clears cache, fetches fresh data, re-matches)"
style="background:var(--accent);border-color:var(--accent);">Rebuild All</button>
>Rebuild All</button>
</div>
</h2>
<!-- Info box explaining the differences -->
<div style="background: rgba(139, 92, 246, 0.1); border: 1px solid rgba(139, 92, 246, 0.3); border-radius: 6px; padding: 14px; margin-bottom: 16px; font-size: 0.9rem;">
<div style="font-weight: 600; margin-bottom: 8px; color: var(--text-primary);">📋 Button Guide:</div>
<div style="display: grid; gap: 8px; color: var(--text-secondary);">
<div><strong style="color: var(--text-primary);">Rematch:</strong> Re-match tracks when your <em>local Jellyfin library</em> changed (fast, uses cached Spotify data)</div>
<div><strong style="color: var(--text-primary);">Refresh:</strong> Fetch latest data from Spotify without re-matching (updates track counts only)</div>
<div><strong style="color: var(--accent);">Rebuild:</strong> Full rebuild when <em>Spotify playlist</em> changed (clears cache, fetches fresh data, re-matches everything - same as scheduled cron job)</div>
<details class="advanced-section">
<summary>Advanced: Rematch vs Refresh vs Rebuild</summary>
<div class="advanced-section-content">
<div class="advanced-guide-list">
<div><strong>Rematch</strong>: Use when your <em>local Jellyfin library</em> changed. Fast and uses cached Spotify data.</div>
<div><strong>Refresh</strong>: Pull fresh Spotify playlist items without re-matching.</div>
<div><strong>Rebuild</strong>: Full reset when <em>Spotify playlist content</em> changed. Clears cache and re-matches everything.</div>
</div>
</div>
</details>
<p style="color: var(--text-secondary); margin-bottom: 12px;">
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
service.
</p>
<div id="playlists-guidance" class="guidance-stack"></div>
<table class="playlist-table">
<thead>
<tr>
<th>Name</th>
<th>Spotify ID</th>
<th>Sync Schedule</th>
<th>Tracks</th>
<th>Completion</th>
<th>Cache Age</th>
<th>Actions</th>
<th>Status</th>
<th>...</th>
</tr>
</thead>
<tbody id="playlist-table-body">
<tr>
<td colspan="7" class="loading">
<td colspan="4" class="loading">
<span class="spinner"></span> Loading playlists...
</td>
</tr>
@@ -372,6 +393,11 @@
<span class="value" id="local-tracks-enabled-value">-</span>
<button onclick="toggleLocalTracksEnabled()">Toggle</button>
</div>
<div class="config-item">
<span class="label">Synthetic Local Played Signal</span>
<span class="value" id="synthetic-local-played-signal-enabled-value">-</span>
<button onclick="toggleSyntheticLocalPlayedSignalEnabled()">Toggle</button>
</div>
</div>
<div style="background: rgba(255, 193, 7, 0.15); border: 1px solid #ffc107; border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
@@ -379,6 +405,7 @@
<br><a href="https://github.com/danielfariati/jellyfin-plugin-lastfm" target="_blank" style="color: var(--accent);">Last.fm Plugin</a>
<br><a href="https://github.com/lyarenei/jellyfin-plugin-listenbrainz" target="_blank" style="color: var(--accent);">ListenBrainz Plugin</a>
<br>This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz).
<br><strong>Default:</strong> keep Synthetic Local Played Signal disabled to avoid duplicate plugin scrobbles.
</div>
</div>
@@ -490,7 +517,7 @@
<span class="label">Explicit Filter</span>
<span class="value" id="config-explicit-filter">-</span>
<button
onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'Explicit', 'Clean'])">Edit</button>
onclick="openEditSetting('EXPLICIT_FILTER', 'Explicit Filter', 'select', 'Filter explicit content', ['All', 'ExplicitOnly', 'CleanOnly'])">Edit</button>
</div>
<div class="config-item">
<span class="label">Enable External Playlists</span>
@@ -513,18 +540,43 @@
</div>
<div class="card">
<h2>Debug Settings</h2>
<h2>Admin Network Settings</h2>
<div
style="background: rgba(245, 158, 11, 0.12); border: 1px solid var(--warning); border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-secondary); font-size: 0.9rem;">
Keep admin UI on localhost by default. If you enable LAN bind, restrict access with trusted CIDR
subnets (for example: <code>192.168.1.0/24,10.0.0.0/8</code>).
</div>
<div class="config-section">
<div class="config-item">
<span class="label">Bind Admin UI On LAN</span>
<span class="value" id="config-admin-bind-any-ip">-</span>
<button onclick="openEditSetting('ADMIN_BIND_ANY_IP', 'Bind Admin UI On LAN', 'toggle')">Edit</button>
</div>
<div class="config-item">
<span class="label">Trusted Subnets (CIDR)</span>
<span class="value" id="config-admin-trusted-subnets">-</span>
<button onclick="openEditSetting('ADMIN_TRUSTED_SUBNETS', 'Trusted Subnets (CIDR)', 'text', 'Comma-separated CIDRs allowed on admin port 5275. Example: 192.168.1.0/24,10.0.0.0/8')">Edit</button>
</div>
</div>
</div>
<div class="card">
<details class="advanced-section">
<summary>Advanced: Debug Settings</summary>
<div class="advanced-section-content">
<div class="config-section">
<div class="config-item">
<span class="label">Log All Requests</span>
<span class="value" id="config-debug-log-requests">-</span>
<button onclick="openEditSetting('DEBUG_LOG_ALL_REQUESTS', 'Log All Requests', 'toggle', 'Enable detailed logging of every HTTP request (useful for debugging client issues)')">Edit</button>
</div>
<div style="background: rgba(59, 130, 246, 0.15); border: 1px solid var(--primary); border-radius: 6px; padding: 12px; margin-top: 12px; color: var(--text-secondary); font-size: 0.9rem;">
️ When enabled, logs every incoming request with method, path, headers, and response status. Auth tokens are automatically masked for security.
<div class="guidance-banner info compact" style="margin-top: 12px;">
️ When enabled, logs every incoming request with method, path, headers, and response status. Auth tokens are automatically masked.
</div>
</div>
</div>
</details>
</div>
<div class="card">
<h2>Spotify API Settings</h2>
@@ -601,7 +653,7 @@
<span class="label">Enabled</span>
<span class="value" id="config-musicbrainz-enabled">-</span>
<button
onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'select', '', ['true', 'false'])">Edit</button>
onclick="openEditSetting('MUSICBRAINZ_ENABLED', 'MusicBrainz Enabled', 'toggle')">Edit</button>
</div>
<div class="config-item">
<span class="label">Username</span>
@@ -705,66 +757,69 @@
<span class="label">Search Results (minutes)</span>
<span class="value" id="config-cache-search">-</span>
<button
onclick="openEditCacheSetting('SearchResultsMinutes', 'Search Results Cache (minutes)', 'How long to cache search results')">Edit</button>
onclick="openEditCacheSetting('CACHE_SEARCH_RESULTS_MINUTES', 'Search Results Cache (minutes)', 'How long to cache search results')">Edit</button>
</div>
<div class="config-item">
<span class="label">Playlist Images (hours)</span>
<span class="value" id="config-cache-playlist-images">-</span>
<button
onclick="openEditCacheSetting('PlaylistImagesHours', 'Playlist Images Cache (hours)', 'How long to cache playlist cover images')">Edit</button>
onclick="openEditCacheSetting('CACHE_PLAYLIST_IMAGES_HOURS', 'Playlist Images Cache (hours)', 'How long to cache playlist cover images')">Edit</button>
</div>
<div class="config-item">
<span class="label">Spotify Playlist Items (hours)</span>
<span class="value" id="config-cache-spotify-items">-</span>
<button
onclick="openEditCacheSetting('SpotifyPlaylistItemsHours', 'Spotify Playlist Items Cache (hours)', 'How long to cache Spotify playlist data')">Edit</button>
onclick="openEditCacheSetting('CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS', 'Spotify Playlist Items Cache (hours)', 'How long to cache Spotify playlist data')">Edit</button>
</div>
<div class="config-item">
<span class="label">Spotify Matched Tracks (days)</span>
<span class="value" id="config-cache-matched-tracks">-</span>
<button
onclick="openEditCacheSetting('SpotifyMatchedTracksDays', 'Matched Tracks Cache (days)', 'How long to cache Spotify ID to track mappings')">Edit</button>
onclick="openEditCacheSetting('CACHE_SPOTIFY_MATCHED_TRACKS_DAYS', 'Matched Tracks Cache (days)', 'How long to cache Spotify ID to track mappings')">Edit</button>
</div>
<div class="config-item">
<span class="label">Lyrics (days)</span>
<span class="value" id="config-cache-lyrics">-</span>
<button
onclick="openEditCacheSetting('LyricsDays', 'Lyrics Cache (days)', 'How long to cache fetched lyrics')">Edit</button>
onclick="openEditCacheSetting('CACHE_LYRICS_DAYS', 'Lyrics Cache (days)', 'How long to cache fetched lyrics')">Edit</button>
</div>
<div class="config-item">
<span class="label">Genre Data (days)</span>
<span class="value" id="config-cache-genres">-</span>
<button
onclick="openEditCacheSetting('GenreDays', 'Genre Cache (days)', 'How long to cache genre information')">Edit</button>
onclick="openEditCacheSetting('CACHE_GENRE_DAYS', 'Genre Cache (days)', 'How long to cache genre information')">Edit</button>
</div>
<div class="config-item">
<span class="label">External Metadata (days)</span>
<span class="value" id="config-cache-metadata">-</span>
<button
onclick="openEditCacheSetting('MetadataDays', 'Metadata Cache (days)', 'How long to cache SquidWTF/Deezer/Qobuz metadata')">Edit</button>
onclick="openEditCacheSetting('CACHE_METADATA_DAYS', 'Metadata Cache (days)', 'How long to cache SquidWTF/Deezer/Qobuz metadata')">Edit</button>
</div>
<div class="config-item">
<span class="label">Odesli Lookups (days)</span>
<span class="value" id="config-cache-odesli">-</span>
<button
onclick="openEditCacheSetting('OdesliLookupDays', 'Odesli Lookup Cache (days)', 'How long to cache Odesli URL conversions')">Edit</button>
onclick="openEditCacheSetting('CACHE_ODESLI_LOOKUP_DAYS', 'Odesli Lookup Cache (days)', 'How long to cache Odesli URL conversions')">Edit</button>
</div>
<div class="config-item">
<span class="label">Proxy Images (days)</span>
<span class="value" id="config-cache-proxy-images">-</span>
<button
onclick="openEditCacheSetting('ProxyImagesDays', 'Proxy Images Cache (days)', 'How long to cache proxied Jellyfin images')">Edit</button>
onclick="openEditCacheSetting('CACHE_PROXY_IMAGES_DAYS', 'Proxy Images Cache (days)', 'How long to cache proxied Jellyfin images')">Edit</button>
</div>
</div>
</div>
<div class="card">
<div class="card" id="config-backup-card">
<h2>Configuration Backup</h2>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
<p style="color: var(--text-secondary); margin-bottom: 16px;" id="config-backup-description">
Export your .env configuration for backup or import a previously saved configuration.
</p>
<p style="color: var(--warning); margin-bottom: 12px; display: none;" id="export-env-disabled-hint">
.env export is disabled by default for security.
</p>
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
<button onclick="exportEnv()">📥 Export .env</button>
<button id="export-env-btn" onclick="exportEnv()">📥 Export .env</button>
<button onclick="document.getElementById('import-env-input').click()">📤 Import .env</button>
<input type="file" id="import-env-input" accept=".env" style="display:none"
onchange="importEnv(event)">
@@ -930,7 +985,7 @@
<!-- Manual Track Mapping Modal -->
<div class="modal" id="manual-map-modal">
<div class="modal-content" style="max-width: 600px;">
<div class="modal-content" style="max-width: 760px; width: min(94vw, 760px);">
<h3>Map Track to External Provider</h3>
<p style="color: var(--text-secondary); margin-bottom: 16px;">
Map this track to an external provider (SquidWTF, Deezer, or Qobuz). For local Jellyfin tracks, use the
@@ -956,12 +1011,20 @@
<option value="Qobuz">Qobuz</option>
</select>
</div>
<div class="form-group">
<label>Search External Provider</label>
<input type="text" id="map-external-search" placeholder="Search for track name or artist...">
<button onclick="searchExternalTracks()" style="margin-top: 8px; width: 100%;">🔍 Search</button>
</div>
<div id="map-external-results" style="max-height: 240px; overflow-y: auto; margin-top: 8px; margin-bottom: 12px;">
<p style="color: var(--text-secondary); text-align: center; padding: 20px;">Enter search terms and click Search</p>
</div>
<div class="form-group">
<label>External Provider ID</label>
<input type="text" id="map-external-id" placeholder="Enter the provider-specific track ID..."
oninput="validateExternalMapping()">
<small style="color: var(--text-secondary); display: block; margin-top: 4px;">
For SquidWTF: Use the track ID from the search results or URL<br>
For SquidWTF: Use a numeric track ID or full track URL<br>
For Deezer: Use the track ID from Deezer URLs<br>
For Qobuz: Use the track ID from Qobuz URLs
</small>
+445 -322
View File
@@ -1,345 +1,468 @@
// API calls
export async function fetchStatus() {
const res = await fetch('/api/admin/status');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function fetchPlaylists() {
const res = await fetch('/api/admin/playlists');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function fetchPlaylistTracks(name) {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/tracks`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function fetchTrackMappings() {
const res = await fetch('/api/admin/mappings/tracks');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function deleteTrackMapping(playlist, spotifyId) {
const res = await fetch(
`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`,
{ method: 'DELETE' }
);
if (!res.ok) {
async function readErrorMessage(res, fallback) {
try {
const error = await res.json();
throw new Error(error.error || 'Failed to remove mapping');
return error.error || error.message || fallback;
} catch {
return fallback;
}
return await res.json();
}
export async function fetchDownloads() {
const res = await fetch('/api/admin/downloads');
async function requestJson(
url,
options = {},
fallbackError = "Request failed",
) {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
throw new Error(await readErrorMessage(res, fallbackError));
}
return await res.json();
}
export async function deleteDownload(path) {
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
method: 'DELETE'
});
async function requestBlob(
url,
options = {},
fallbackError = "Request failed",
) {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function fetchConfig() {
const res = await fetch('/api/admin/config');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function updateConfig(key, value) {
const res = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to update setting');
}
return await res.json();
}
export async function fetchJellyfinUsers() {
const res = await fetch('/api/admin/jellyfin/users');
if (!res.ok) return null;
return await res.json();
}
export async function fetchJellyfinPlaylists(userId = null) {
let url = '/api/admin/jellyfin/playlists';
if (userId) url += '?userId=' + encodeURIComponent(userId);
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function fetchSpotifyUserPlaylists() {
const res = await fetch('/api/admin/spotify/user-playlists');
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to fetch Spotify playlists');
}
return await res.json();
}
export async function linkPlaylist(jellyfinId, spotifyId, syncSchedule) {
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ spotifyPlaylistId: spotifyId, syncSchedule })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to link playlist');
}
return await res.json();
}
export async function unlinkPlaylist(name) {
const res = await fetch(`/api/admin/jellyfin/playlists/${encodeURIComponent(name)}/unlink`, {
method: 'DELETE'
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to unlink playlist');
}
return await res.json();
}
export async function refreshPlaylists() {
const res = await fetch('/api/admin/playlists/refresh', { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function refreshPlaylist(name) {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/refresh`, { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function clearPlaylistCache(name) {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`, { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function matchPlaylistTracks(name) {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}/match`, { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function matchAllPlaylists() {
const res = await fetch('/api/admin/playlists/match-all', { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function rebuildAllPlaylists() {
const res = await fetch('/api/admin/playlists/rebuild-all', { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function clearCache() {
const res = await fetch('/api/admin/cache/clear', { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function restartContainer() {
const res = await fetch('/api/admin/restart', { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function fetchEndpointUsage(top = 50) {
const res = await fetch(`/api/admin/debug/endpoint-usage?top=${top}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function clearEndpointUsage() {
const res = await fetch('/api/admin/debug/endpoint-usage', { method: 'DELETE' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function addPlaylist(name, spotifyId) {
const res = await fetch('/api/admin/playlists', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, spotifyId })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to add playlist');
}
return await res.json();
}
export async function removePlaylist(name) {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(name)}`, {
method: 'DELETE'
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to remove playlist');
}
return await res.json();
}
export async function editPlaylistSchedule(playlistName, syncSchedule) {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ syncSchedule })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to update schedule');
}
return await res.json();
}
export async function searchJellyfin(query) {
const res = await fetch(`/api/admin/jellyfin/search?query=${encodeURIComponent(query)}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function getJellyfinTrack(jellyfinId) {
const res = await fetch(`/api/admin/jellyfin/track/${jellyfinId}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function saveTrackMapping(playlistName, mapping) {
const res = await fetch(`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(mapping)
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to save mapping');
}
return await res.json();
}
export async function saveLyricsMapping(artist, title, album, durationSeconds, lyricsId) {
const res = await fetch('/api/admin/lyrics/map', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ artist, title, album, durationSeconds, lyricsId })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to save lyrics mapping');
}
return await res.json();
}
export async function updateConfigSetting(key, value) {
const res = await fetch('/api/admin/config', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ updates: { [key]: value } })
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to update setting');
}
return await res.json();
}
export async function initCookieDate() {
const res = await fetch('/api/admin/config/init-cookie-date', { method: 'POST' });
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
}
export async function exportEnv() {
const res = await fetch('/api/admin/export-env');
if (!res.ok) {
throw new Error('Export failed');
throw new Error(await readErrorMessage(res, fallbackError));
}
return await res.blob();
}
export async function importEnv(file) {
const formData = new FormData();
formData.append('file', file);
const res = await fetch('/api/admin/import-env', {
method: 'POST',
body: formData
});
async function requestOptionalJson(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to import .env file');
return null;
}
return await res.json();
}
function asJsonBody(payload) {
return {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
};
}
export async function fetchAdminSession() {
return requestJson(
"/api/admin/auth/me",
{ cache: "no-store" },
"Failed to fetch admin session",
);
}
export async function loginAdminSession(username, password) {
return requestJson(
"/api/admin/auth/login",
asJsonBody({ username, password }),
"Authentication failed",
);
}
export async function logoutAdminSession() {
return requestJson(
"/api/admin/auth/logout",
{ method: "POST" },
"Failed to logout",
);
}
export async function fetchStatus() {
return requestJson("/api/admin/status", {}, "Failed to fetch status");
}
export async function fetchPlaylists() {
return requestJson(
"/api/admin/playlists",
{},
"Failed to fetch playlists",
);
}
export async function fetchPlaylistTracks(name) {
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}/tracks`,
{},
"Failed to fetch playlist tracks",
);
}
export async function fetchTrackMappings() {
return requestJson(
"/api/admin/mappings/tracks",
{},
"Failed to fetch track mappings",
);
}
export async function deleteTrackMapping(playlist, spotifyId) {
return requestJson(
`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`,
{ method: "DELETE" },
"Failed to remove mapping",
);
}
export async function fetchDownloads() {
return requestJson(
"/api/admin/downloads",
{},
"Failed to fetch downloads",
);
}
export async function deleteDownload(path) {
return requestJson(
`/api/admin/downloads?path=${encodeURIComponent(path)}`,
{ method: "DELETE" },
"Failed to delete download",
);
}
export async function fetchConfig() {
return requestJson(
"/api/admin/config",
{ cache: "no-store" },
"Failed to fetch config",
);
}
export async function updateConfig(key, value) {
return requestJson(
"/api/admin/config",
asJsonBody({ key, value }),
"Failed to update setting",
);
}
export async function fetchJellyfinUsers() {
return requestOptionalJson("/api/admin/jellyfin/users");
}
export async function fetchJellyfinPlaylists(userId = null) {
let url = "/api/admin/jellyfin/playlists";
if (userId) {
url += "?userId=" + encodeURIComponent(userId);
}
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
}
export async function fetchSpotifyUserPlaylists(userId = null) {
let url = "/api/admin/spotify/user-playlists";
if (userId) {
url += "?userId=" + encodeURIComponent(userId);
}
return requestJson(url, {}, "Failed to fetch Spotify playlists");
}
export async function fetchSpotifySessionCookieStatus(userId = null) {
let url = "/api/admin/spotify/session-cookie/status";
if (userId) {
url += "?userId=" + encodeURIComponent(userId);
}
return requestJson(
url,
{},
"Failed to fetch Spotify session cookie status",
);
}
export async function setSpotifySessionCookie(sessionCookie, userId = null) {
const payload = { sessionCookie };
if (userId) {
payload.userId = userId;
}
return requestJson(
"/api/admin/spotify/session-cookie",
asJsonBody(payload),
"Failed to save Spotify session cookie",
);
}
export async function linkPlaylist(
jellyfinId,
spotifyId,
syncSchedule,
userId,
) {
const payload = { spotifyPlaylistId: spotifyId, syncSchedule };
if (userId) {
payload.userId = userId;
}
return requestJson(
`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/link`,
asJsonBody(payload),
"Failed to link playlist",
);
}
export async function unlinkPlaylist(jellyfinId) {
return requestJson(
`/api/admin/jellyfin/playlists/${encodeURIComponent(jellyfinId)}/unlink`,
{ method: "DELETE" },
"Failed to unlink playlist",
);
}
export async function refreshPlaylists() {
return requestJson(
"/api/admin/playlists/refresh",
{ method: "POST" },
"Failed to refresh playlists",
);
}
export async function refreshPlaylist(name) {
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}/refresh`,
{ method: "POST" },
"Failed to refresh playlist",
);
}
export async function clearPlaylistCache(name) {
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`,
{ method: "POST" },
"Failed to clear playlist cache",
);
}
export async function matchPlaylistTracks(name) {
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}/match`,
{ method: "POST" },
"Failed to match playlist tracks",
);
}
export async function matchAllPlaylists() {
return requestJson(
"/api/admin/playlists/match-all",
{ method: "POST" },
"Failed to match all playlists",
);
}
export async function rebuildAllPlaylists() {
return requestJson(
"/api/admin/playlists/rebuild-all",
{ method: "POST" },
"Failed to rebuild all playlists",
);
}
export async function clearCache() {
return requestJson(
"/api/admin/cache/clear",
{ method: "POST" },
"Failed to clear cache",
);
}
export async function restartContainer() {
return requestJson(
"/api/admin/restart",
{ method: "POST" },
"Failed to restart container",
);
}
export async function fetchEndpointUsage(top = 50) {
return requestJson(
`/api/admin/debug/endpoint-usage?top=${top}`,
{},
"Failed to fetch endpoint usage",
);
}
export async function clearEndpointUsage() {
return requestJson(
"/api/admin/debug/endpoint-usage",
{ method: "DELETE" },
"Failed to clear endpoint usage",
);
}
export async function addPlaylist(name, spotifyId) {
return requestJson(
"/api/admin/playlists",
asJsonBody({ name, spotifyId }),
"Failed to add playlist",
);
}
export async function removePlaylist(name) {
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}`,
{ method: "DELETE" },
"Failed to remove playlist",
);
}
export async function editPlaylistSchedule(playlistName, syncSchedule) {
return requestJson(
`/api/admin/playlists/${encodeURIComponent(playlistName)}/schedule`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ syncSchedule }),
},
"Failed to update schedule",
);
}
export async function searchJellyfin(query) {
return requestJson(
`/api/admin/jellyfin/search?query=${encodeURIComponent(query)}`,
{},
"Failed to search Jellyfin",
);
}
export async function searchExternalTracks(query, provider = "squidwtf") {
return requestJson(
`/api/admin/external/search?query=${encodeURIComponent(query)}&provider=${encodeURIComponent(provider)}`,
{},
"Failed to search external provider",
);
}
export async function getJellyfinTrack(jellyfinId) {
return requestJson(
`/api/admin/jellyfin/track/${jellyfinId}`,
{},
"Failed to fetch Jellyfin track",
);
}
export async function saveTrackMapping(playlistName, mapping) {
return requestJson(
`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`,
asJsonBody(mapping),
"Failed to save mapping",
);
}
export async function saveLyricsMapping(
artist,
title,
album,
durationSeconds,
lyricsId,
) {
return requestJson(
"/api/admin/lyrics/map",
asJsonBody({ artist, title, album, durationSeconds, lyricsId }),
"Failed to save lyrics mapping",
);
}
export async function updateConfigSetting(key, value) {
return requestJson(
"/api/admin/config",
asJsonBody({ updates: { [key]: value } }),
"Failed to update setting",
);
}
export async function initCookieDate() {
return requestJson(
"/api/admin/config/init-cookie-date",
{ method: "POST" },
"Failed to initialize cookie date",
);
}
export async function exportEnv() {
return requestBlob(
"/api/admin/export-env",
{},
"Export failed",
);
}
export async function importEnv(file) {
const formData = new FormData();
formData.append("file", file);
return requestJson(
"/api/admin/import-env",
{
method: "POST",
body: formData,
},
"Failed to import .env file",
);
}
export async function getSquidWTFBaseUrl() {
const res = await fetch('/api/admin/squidwtf-base-url');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
return requestJson(
"/api/admin/squidwtf-base-url",
{},
"Failed to fetch SquidWTF base URL",
);
}
return await res.json();
export async function fetchScrobblingStatus() {
return requestJson(
"/api/admin/scrobbling/status",
{},
"Failed to fetch scrobbling status",
);
}
export async function updateLocalTracksScrobbling(enabled) {
return requestJson(
"/api/admin/scrobbling/local-tracks/update",
asJsonBody({ enabled }),
"Failed to update local track scrobbling",
);
}
export async function authenticateLastFm() {
return requestJson(
"/api/admin/scrobbling/lastfm/authenticate",
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
},
"Failed to authenticate with Last.fm",
);
}
export async function testLastFmConnection() {
return requestJson(
"/api/admin/scrobbling/lastfm/test",
{ method: "POST" },
"Failed to test Last.fm connection",
);
}
export async function validateListenBrainzToken(userToken) {
return requestJson(
"/api/admin/scrobbling/listenbrainz/validate",
asJsonBody({ userToken }),
"Failed to validate ListenBrainz token",
);
}
export async function testListenBrainzConnection() {
return requestJson(
"/api/admin/scrobbling/listenbrainz/test",
{ method: "POST" },
"Failed to test ListenBrainz connection",
);
}
+262
View File
@@ -0,0 +1,262 @@
import { showToast } from "./utils.js";
import * as API from "./api.js";
let isAuthenticated = false;
let authRecoveryInProgress = false;
let currentSessionUser = null;
let stopDashboardRefresh = () => {};
let loadDashboardData = async () => {};
let switchTab = () => {};
let onUnauthenticated = () => {};
function setAuthenticatedState(authenticated, user = null) {
isAuthenticated = authenticated;
currentSessionUser = authenticated ? user : null;
if (!authenticated) {
onUnauthenticated();
}
const authGate = document.getElementById("auth-gate");
const mainContainer = document.getElementById("main-container");
const authUserDisplay = document.getElementById("auth-user-display");
const authUserName = document.getElementById("auth-user-name");
const logoutButton = document.getElementById("auth-logout-btn");
const authError = document.getElementById("auth-error");
if (
!authGate ||
!mainContainer ||
!authUserDisplay ||
!authUserName ||
!logoutButton
) {
return;
}
authGate.style.display = authenticated ? "none" : "flex";
mainContainer.style.display = authenticated ? "block" : "none";
authUserDisplay.style.display = authenticated ? "block" : "none";
logoutButton.style.display = authenticated ? "inline-block" : "none";
authUserName.textContent = user?.name || "-";
if (authError) {
authError.textContent = "";
}
applyAuthorizationScope();
}
function isAdminSession() {
return !!currentSessionUser?.isAdministrator;
}
function getDefaultTabForSession() {
return isAdminSession() ? "dashboard" : "jellyfin-playlists";
}
function ensureValidActiveTab() {
const activeTab = document.querySelector(".tab.active");
const activeContent = document.querySelector(".tab-content.active");
if (!activeTab || !activeContent) {
switchTab(getDefaultTabForSession());
}
}
function applyAuthorizationScope() {
const isAdmin = isAdminSession();
const adminOnlyTabs = [
"dashboard",
"playlists",
"kept",
"scrobbling",
"config",
"endpoints",
];
adminOnlyTabs.forEach((tabName) => {
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
const content = document.getElementById(`tab-${tabName}`);
if (tab) {
tab.style.display = isAdmin ? "" : "none";
if (!isAdmin) {
tab.classList.remove("active");
}
}
if (content) {
content.style.display = isAdmin ? "" : "none";
if (!isAdmin) {
content.classList.remove("active");
}
}
});
const userFilter = document.getElementById("jellyfin-user-filter");
if (userFilter) {
userFilter.style.display = isAdmin ? "flex" : "none";
}
const statusIndicator = document.getElementById("status-indicator");
if (statusIndicator) {
statusIndicator.style.display = isAdmin ? "" : "none";
}
if (isAuthenticated && !isAdmin) {
switchTab("jellyfin-playlists");
}
if (isAuthenticated) {
ensureValidActiveTab();
}
}
function patchFetchForAuthRecovery() {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
const response = await nativeFetch(...args);
const url = typeof args[0] === "string" ? args[0] : args[0]?.url || "";
if (
response.status === 401 &&
url.includes("/api/admin") &&
!url.includes("/api/admin/auth/") &&
!authRecoveryInProgress
) {
window.dispatchEvent(new CustomEvent("admin-auth-required"));
}
return response;
};
}
async function ensureAdminSession() {
try {
const session = await API.fetchAdminSession();
if (!session?.authenticated) {
setAuthenticatedState(false);
return false;
}
setAuthenticatedState(true, session.user);
return true;
} catch {
setAuthenticatedState(false);
return false;
}
}
async function handleAuthRequired(
message = "Session expired. Please sign in again.",
) {
if (authRecoveryInProgress) {
return;
}
authRecoveryInProgress = true;
stopDashboardRefresh();
setAuthenticatedState(false);
const authError = document.getElementById("auth-error");
if (authError) {
authError.textContent = message;
}
try {
await API.logoutAdminSession();
} catch {
// Ignore logout errors during auth recovery.
} finally {
authRecoveryInProgress = false;
}
}
async function logoutAdminSession() {
stopDashboardRefresh();
try {
await API.logoutAdminSession();
} catch {
// Ignore logout errors; always clear local UI auth state.
}
setAuthenticatedState(false);
showToast("Signed out", "success");
}
function wireLoginForm() {
const loginForm = document.getElementById("auth-login-form");
if (!loginForm) {
return;
}
loginForm.addEventListener("submit", async (event) => {
event.preventDefault();
const usernameInput = document.getElementById("auth-username");
const passwordInput = document.getElementById("auth-password");
const authError = document.getElementById("auth-error");
const username = usernameInput?.value?.trim() || "";
const password = passwordInput?.value || "";
if (!username || !password) {
if (authError) {
authError.textContent = "Username and password are required.";
}
return;
}
try {
if (authError) {
authError.textContent = "";
}
const result = await API.loginAdminSession(username, password);
if (passwordInput) {
passwordInput.value = "";
}
setAuthenticatedState(true, result.user);
switchTab(getDefaultTabForSession());
await loadDashboardData();
} catch (error) {
if (authError) {
authError.textContent = error.message || "Authentication failed.";
}
}
});
}
async function bootstrapAuth() {
const hasSession = await ensureAdminSession();
if (hasSession) {
await loadDashboardData();
} else {
const usernameInput = document.getElementById("auth-username");
usernameInput?.focus();
}
}
export function initAuthSession(options) {
stopDashboardRefresh = options.stopDashboardRefresh;
loadDashboardData = options.loadDashboardData;
switchTab = options.switchTab;
onUnauthenticated = options.onUnauthenticated;
patchFetchForAuthRecovery();
wireLoginForm();
window.addEventListener("admin-auth-required", () => {
handleAuthRequired();
});
window.logoutAdminSession = logoutAdminSession;
return {
isAuthenticated: () => isAuthenticated,
isAdminSession,
getCurrentUserId: () => currentSessionUser?.id || null,
bootstrapAuth,
};
}
+411
View File
@@ -0,0 +1,411 @@
import { escapeHtml, showToast, formatCookieAge } from "./utils.js";
import * as API from "./api.js";
import * as UI from "./ui.js";
import { renderCookieAge } from "./settings-editor.js";
import { runAction } from "./operations.js";
let playlistAutoRefreshInterval = null;
let dashboardRefreshInterval = null;
let isAuthenticated = () => false;
let isAdminSession = () => false;
let getCurrentUserId = () => null;
let onCookieNeedsInit = async () => {};
let setCurrentConfigState = () => {};
let syncConfigUiExtras = () => {};
let loadScrobblingConfig = () => {};
async function fetchStatus() {
try {
const data = await API.fetchStatus();
UI.updateStatusUI(data);
const hasCookie = data.spotify.hasCookie;
const age = formatCookieAge(data.spotify.cookieSetDate, hasCookie);
renderCookieAge("spotify-cookie-age", age);
renderCookieAge("config-cookie-age", age);
if (age.needsInit) {
console.log("Cookie exists but date not set, initializing...");
onCookieNeedsInit();
}
} catch (error) {
console.error("Failed to fetch status:", error);
showToast("Failed to fetch status: " + error.message, "error");
UI.showErrorState(error.message);
}
}
async function fetchPlaylists(silent = false) {
try {
const data = await API.fetchPlaylists();
UI.updatePlaylistsUI(data);
} catch (error) {
if (!silent) {
console.error("Failed to fetch playlists:", error);
showToast("Failed to fetch playlists", "error");
}
}
}
async function fetchTrackMappings() {
try {
const data = await API.fetchTrackMappings();
UI.updateTrackMappingsUI(data);
} catch (error) {
console.error("Failed to fetch track mappings:", error);
showToast("Failed to fetch track mappings", "error");
}
}
function bindMissingTrackActionButtons(tbody) {
tbody.querySelectorAll(".missing-track-search-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const query = btn.getAttribute("data-query") || "";
const provider = btn.getAttribute("data-provider") || "squidwtf";
if (typeof window.searchProvider === "function") {
window.searchProvider(query, provider);
}
});
});
tbody.querySelectorAll(".missing-track-local-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const playlistName = btn.getAttribute("data-playlist") || "";
const position = Number.parseInt(
btn.getAttribute("data-position") || "0",
10,
);
const title = btn.getAttribute("data-title") || "";
const artist = btn.getAttribute("data-artist") || "";
const spotifyId = btn.getAttribute("data-spotify-id") || "";
if (typeof window.openMapToLocal === "function") {
window.openMapToLocal(
playlistName,
Number.isFinite(position) ? position : 0,
title,
artist,
spotifyId,
);
}
});
});
tbody.querySelectorAll(".missing-track-external-btn").forEach((btn) => {
btn.addEventListener("click", () => {
const playlistName = btn.getAttribute("data-playlist") || "";
const position = Number.parseInt(
btn.getAttribute("data-position") || "0",
10,
);
const title = btn.getAttribute("data-title") || "";
const artist = btn.getAttribute("data-artist") || "";
const spotifyId = btn.getAttribute("data-spotify-id") || "";
if (typeof window.openMapToExternal === "function") {
window.openMapToExternal(
playlistName,
Number.isFinite(position) ? position : 0,
title,
artist,
spotifyId,
);
}
});
});
}
async function fetchMissingTracks() {
try {
const data = await API.fetchPlaylists();
const tbody = document.getElementById("missing-tracks-table-body");
const missingTracks = [];
for (const playlist of data.playlists) {
if (playlist.externalMissing > 0) {
try {
const tracksData = await API.fetchPlaylistTracks(playlist.name);
const missing = tracksData.tracks.filter((t) => t.isLocal === null);
missing.forEach((t) => {
missingTracks.push({
playlist: playlist.name,
...t,
});
});
} catch (err) {
console.error(`Failed to fetch tracks for ${playlist.name}:`, err);
}
}
}
document.getElementById("missing-total").textContent = missingTracks.length;
if (missingTracks.length === 0) {
tbody.innerHTML =
'<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">🎉 No missing tracks! All tracks are matched.</td></tr>';
return;
}
tbody.innerHTML = missingTracks
.map((t) => {
const artist =
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
const searchQuery = `${t.title} ${artist}`;
const trackPosition = Number.isFinite(t.position)
? Number(t.position)
: 0;
return `
<tr>
<td><strong>${escapeHtml(t.playlist)}</strong></td>
<td>${escapeHtml(t.title)}</td>
<td>${escapeHtml(artist)}</td>
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : "-"}</td>
<td class="mapping-actions-cell">
<button class="map-action-btn map-action-search missing-track-search-btn"
data-query="${escapeHtml(searchQuery)}"
data-provider="squidwtf">🔍 Search</button>
<button class="map-action-btn map-action-local missing-track-local-btn"
data-playlist="${escapeHtml(t.playlist)}"
data-position="${trackPosition}"
data-title="${escapeHtml(t.title)}"
data-artist="${escapeHtml(artist)}"
data-spotify-id="${escapeHtml(t.spotifyId || "")}">Map to Local</button>
<button class="map-action-btn map-action-external missing-track-external-btn"
data-playlist="${escapeHtml(t.playlist)}"
data-position="${trackPosition}"
data-title="${escapeHtml(t.title)}"
data-artist="${escapeHtml(artist)}"
data-spotify-id="${escapeHtml(t.spotifyId || "")}">Map to External</button>
</td>
</tr>
`;
})
.join("");
bindMissingTrackActionButtons(tbody);
} catch (error) {
console.error("Failed to fetch missing tracks:", error);
showToast("Failed to fetch missing tracks", "error");
}
}
async function fetchDownloads() {
try {
const data = await API.fetchDownloads();
const tbody = document.getElementById("downloads-table-body");
document.getElementById("downloads-count").textContent = data.count;
document.getElementById("downloads-size").textContent =
data.totalSizeFormatted;
if (data.count === 0) {
tbody.innerHTML =
'<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
return;
}
tbody.innerHTML = data.files
.map((f) => {
return `
<tr data-path="${escapeHtml(f.path)}">
<td><strong>${escapeHtml(f.artist)}</strong></td>
<td>${escapeHtml(f.album)}</td>
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
<td>
<button onclick="downloadFile('${escapeJs(f.path)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button onclick="deleteDownload('${escapeJs(f.path)}')"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td>
</tr>
`;
})
.join("");
} catch (error) {
console.error("Failed to fetch downloads:", error);
showToast("Failed to fetch downloads", "error");
}
}
async function fetchConfig() {
try {
const data = await API.fetchConfig();
setCurrentConfigState(data);
UI.updateConfigUI(data);
syncConfigUiExtras(data);
} catch (error) {
console.error("Failed to fetch config:", error);
}
}
async function fetchJellyfinPlaylists() {
const tbody = document.getElementById("jellyfin-playlist-table-body");
tbody.innerHTML =
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
try {
const userId = isAdminSession()
? document.getElementById("jellyfin-user-select")?.value
: null;
const data = await API.fetchJellyfinPlaylists(userId);
UI.updateJellyfinPlaylistsUI(data);
} catch (error) {
console.error("Failed to fetch Jellyfin playlists:", error);
tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to fetch playlists</td></tr>';
}
}
async function fetchJellyfinUsers() {
if (!isAdminSession()) {
return;
}
try {
const data = await API.fetchJellyfinUsers();
if (data) {
UI.updateJellyfinUsersUI(data, getCurrentUserId());
}
} catch (error) {
console.error("Failed to fetch users:", error);
}
}
async function fetchEndpointUsage() {
try {
const topSelect = document.getElementById("endpoints-top-select");
const top = topSelect ? topSelect.value : 50;
const data = await API.fetchEndpointUsage(top);
UI.updateEndpointUsageUI(data);
} catch (error) {
console.error("Failed to fetch endpoint usage:", error);
const tbody = document.getElementById("endpoints-table-body");
tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to load endpoint usage data</td></tr>';
}
}
async function clearEndpointUsage() {
const result = await runAction({
confirmMessage:
"Are you sure you want to clear all endpoint usage data? This cannot be undone.",
task: () => API.clearEndpointUsage(),
success: (data) => data.message || "Endpoint usage data cleared",
error: "Failed to clear endpoint usage data",
});
if (result) {
fetchEndpointUsage();
}
}
function startPlaylistAutoRefresh() {
if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval);
}
playlistAutoRefreshInterval = setInterval(() => {
const playlistsTab = document.getElementById("tab-playlists");
if (playlistsTab && playlistsTab.classList.contains("active")) {
fetchPlaylists(true);
}
}, 5000);
}
function stopPlaylistAutoRefresh() {
if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval);
playlistAutoRefreshInterval = null;
}
}
function stopDashboardRefresh() {
if (dashboardRefreshInterval) {
clearInterval(dashboardRefreshInterval);
dashboardRefreshInterval = null;
}
stopPlaylistAutoRefresh();
}
function startDashboardRefresh() {
stopDashboardRefresh();
startPlaylistAutoRefresh();
dashboardRefreshInterval = setInterval(() => {
if (!isAuthenticated()) {
return;
}
if (isAdminSession()) {
fetchStatus();
fetchPlaylists();
fetchTrackMappings();
fetchMissingTracks();
fetchDownloads();
const endpointsTab = document.getElementById("tab-endpoints");
if (endpointsTab && endpointsTab.classList.contains("active")) {
fetchEndpointUsage();
}
} else {
fetchJellyfinPlaylists();
}
}, 30000);
}
async function loadDashboardData() {
if (isAdminSession()) {
await Promise.allSettled([
fetchStatus(),
fetchPlaylists(),
fetchTrackMappings(),
fetchMissingTracks(),
fetchDownloads(),
fetchConfig(),
fetchEndpointUsage(),
]);
// Ensure user filter defaults are populated before loading Link Playlists rows.
await fetchJellyfinUsers();
await fetchJellyfinPlaylists();
loadScrobblingConfig();
} else {
await Promise.allSettled([fetchJellyfinPlaylists()]);
}
startDashboardRefresh();
}
export function initDashboardData(options) {
isAuthenticated = options.isAuthenticated;
isAdminSession = options.isAdminSession;
getCurrentUserId = options.getCurrentUserId || (() => null);
onCookieNeedsInit = options.onCookieNeedsInit;
setCurrentConfigState = options.setCurrentConfigState;
syncConfigUiExtras = options.syncConfigUiExtras;
loadScrobblingConfig = options.loadScrobblingConfig;
window.fetchStatus = fetchStatus;
window.fetchPlaylists = fetchPlaylists;
window.fetchTrackMappings = fetchTrackMappings;
window.fetchMissingTracks = fetchMissingTracks;
window.fetchDownloads = fetchDownloads;
window.fetchConfig = fetchConfig;
window.fetchJellyfinPlaylists = fetchJellyfinPlaylists;
window.fetchJellyfinUsers = fetchJellyfinUsers;
window.fetchEndpointUsage = fetchEndpointUsage;
window.clearEndpointUsage = clearEndpointUsage;
return {
stopDashboardRefresh,
startDashboardRefresh,
loadDashboardData,
fetchPlaylists,
fetchTrackMappings,
fetchDownloads,
fetchJellyfinPlaylists,
fetchConfig,
fetchStatus,
};
}
+462 -190
View File
@@ -1,194 +1,271 @@
// Helper functions for complex UI operations
import { escapeHtml, escapeJs, showToast, capitalizeProvider } from './utils.js';
import * as API from './api.js';
import { openModal, closeModal } from './modals.js';
let searchTimeout = null;
import {
escapeHtml,
escapeJs,
showToast,
capitalizeProvider,
} from "./utils.js";
import * as API from "./api.js";
import { openModal, closeModal } from "./modals.js";
// View tracks modal
export async function viewTracks(name) {
document.getElementById('tracks-modal-title').textContent = name + ' - Tracks';
document.getElementById('tracks-list').innerHTML = '<div class="loading"><span class="spinner"></span> Loading tracks...</div>';
openModal('tracks-modal');
document.getElementById("tracks-modal-title").textContent =
name + " - Tracks";
document.getElementById("tracks-list").innerHTML =
'<div class="loading"><span class="spinner"></span> Loading tracks...</div>';
openModal("tracks-modal");
try {
const data = await API.fetchPlaylistTracks(name);
if (!data || !data.tracks) {
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
document.getElementById("tracks-list").innerHTML =
'<p style="text-align:center;color:var(--error);padding:40px;">Invalid data received from server</p>';
return;
}
if (data.tracks.length === 0) {
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
document.getElementById("tracks-list").innerHTML =
'<p style="text-align:center;color:var(--text-secondary);padding:40px;">No tracks found</p>';
return;
}
document.getElementById('tracks-list').innerHTML = data.tracks.map((t, index) => {
let statusBadge = '';
let mapButton = '';
let lyricsBadge = '';
document.getElementById("tracks-list").innerHTML = data.tracks
.map((t, index) => {
let statusBadge = "";
let mapButton = "";
let lyricsBadge = "";
if (t.hasLyrics) {
lyricsBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:#3b82f6;color:white;"><span class="status-dot" style="background:white;"></span>Lyrics</span>';
lyricsBadge =
'<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:#3b82f6;color:white;"><span class="status-dot" style="background:white;"></span>Lyrics</span>';
}
if (t.isLocal === true) {
statusBadge = '<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
if (t.isManualMapping && t.manualMappingType === 'jellyfin') {
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
statusBadge =
'<span class="status-badge success" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>Local</span>';
if (t.isManualMapping && t.manualMappingType === "jellyfin") {
statusBadge +=
'<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
}
} else if (t.isLocal === false) {
const provider = capitalizeProvider(t.externalProvider) || 'External';
const provider = capitalizeProvider(t.externalProvider) || "External";
statusBadge = `<span class="status-badge info" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;"><span class="status-dot"></span>${escapeHtml(provider)}</span>`;
if (t.isManualMapping && t.manualMappingType === 'external') {
statusBadge += '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
if (t.isManualMapping && t.manualMappingType === "external") {
statusBadge +=
'<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:4px;background:var(--info);color:white;"><span class="status-dot" style="background:white;"></span>Manual</span>';
}
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
const firstArtist =
t.artists && t.artists.length > 0 ? t.artists[0] : "";
mapButton = `<button class="map-action-btn map-action-local map-track-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-title="${escapeHtml(t.title || "")}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
<button class="small map-external-btn"
data-spotify-id="${escapeHtml(t.spotifyId || "")}"
>Map to Local</button>
<button class="map-action-btn map-action-external map-external-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-title="${escapeHtml(t.title || "")}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
data-spotify-id="${escapeHtml(t.spotifyId || "")}"
>Map to External</button>`;
} else {
statusBadge = '<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:rgba(245, 158, 11, 0.2);color:#f59e0b;"><span class="status-dot" style="background:#f59e0b;"></span>Missing</span>';
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
statusBadge =
'<span class="status-badge" style="font-size:0.75rem;padding:2px 8px;margin-left:8px;background:rgba(245, 158, 11, 0.2);color:#f59e0b;"><span class="status-dot" style="background:#f59e0b;"></span>Missing</span>';
const firstArtist =
t.artists && t.artists.length > 0 ? t.artists[0] : "";
mapButton = `<button class="map-action-btn map-action-local map-track-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-title="${escapeHtml(t.title || "")}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:8px;font-size:0.75rem;padding:4px 8px;">Map to Local</button>
<button class="small map-external-btn"
data-spotify-id="${escapeHtml(t.spotifyId || "")}"
>Map to Local</button>
<button class="map-action-btn map-action-external map-external-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || '')}"
data-title="${escapeHtml(t.title || "")}"
data-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || '')}"
style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>`;
data-spotify-id="${escapeHtml(t.spotifyId || "")}"
>Map to External</button>`;
}
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
const firstArtist =
t.artists && t.artists.length > 0 ? t.artists[0] : "";
const searchLinkText = `${t.title} - ${firstArtist}`;
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
const externalSearchLink =
t.isLocal === false && t.searchQuery && t.externalProvider
? `<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', '${escapeJs(t.externalProvider)}'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
: "";
const missingSearchLink =
t.isLocal === null && t.searchQuery
? `<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', 'squidwtf'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
: "";
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || '')}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || "")}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
return `
<div class="track-item" data-position="${t.position}">
<span class="track-position">${index + 1}</span>
<div class="track-info">
<h4>${escapeHtml(t.title)}${statusBadge}${lyricsBadge}${mapButton}${lyricsMapButton}</h4>
<span class="artists">${escapeHtml((t.artists || []).join(', '))}</span>
<span class="artists">${escapeHtml((t.artists || []).join(", "))}</span>
</div>
<div class="track-meta">
${t.album ? escapeHtml(t.album) : ''}
${t.isrc ? '<br><small>ISRC: ' + t.isrc + '</small>' : ''}
${t.isLocal === false && t.searchQuery && t.externalProvider ? '<br><small style="color:var(--accent)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'' + escapeJs(t.externalProvider) + '\'); return false;" style="color:var(--accent);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
${t.isLocal === null && t.searchQuery ? '<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider(\'' + escapeJs(t.searchQuery) + '\', \'squidwtf\'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ' + escapeHtml(searchLinkText) + '</a></small>' : ''}
${t.album ? escapeHtml(t.album) : ""}
${t.isrc ? "<br><small>ISRC: " + t.isrc + "</small>" : ""}
${externalSearchLink}
${missingSearchLink}
</div>
</div>
`;
}).join('');
})
.join("");
// Add event listeners
document.querySelectorAll('.map-track-btn').forEach(btn => {
btn.addEventListener('click', function() {
const playlistName = this.getAttribute('data-playlist-name');
const position = parseInt(this.getAttribute('data-position'));
const title = this.getAttribute('data-title');
const artist = this.getAttribute('data-artist');
const spotifyId = this.getAttribute('data-spotify-id');
document.querySelectorAll(".map-track-btn").forEach((btn) => {
btn.addEventListener("click", function () {
const playlistName = this.getAttribute("data-playlist-name");
const position = parseInt(this.getAttribute("data-position"));
const title = this.getAttribute("data-title");
const artist = this.getAttribute("data-artist");
const spotifyId = this.getAttribute("data-spotify-id");
openManualMap(playlistName, position, title, artist, spotifyId);
});
});
document.querySelectorAll('.map-external-btn').forEach(btn => {
btn.addEventListener('click', function() {
const playlistName = this.getAttribute('data-playlist-name');
const position = parseInt(this.getAttribute('data-position'));
const title = this.getAttribute('data-title');
const artist = this.getAttribute('data-artist');
const spotifyId = this.getAttribute('data-spotify-id');
document.querySelectorAll(".map-external-btn").forEach((btn) => {
btn.addEventListener("click", function () {
const playlistName = this.getAttribute("data-playlist-name");
const position = parseInt(this.getAttribute("data-position"));
const title = this.getAttribute("data-title");
const artist = this.getAttribute("data-artist");
const spotifyId = this.getAttribute("data-spotify-id");
openExternalMap(playlistName, position, title, artist, spotifyId);
});
});
} catch (error) {
console.error('Error in viewTracks:', error);
document.getElementById('tracks-list').innerHTML = '<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' + error.message + '</p>';
console.error("Error in viewTracks:", error);
document.getElementById("tracks-list").innerHTML =
'<p style="text-align:center;color:var(--error);padding:40px;">Failed to load tracks: ' +
escapeHtml(error?.message || "Unknown error") +
"</p>";
}
}
// Manual mapping to local Jellyfin track
export function openManualMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('manual-map-title').textContent = `${title} - ${artist}`;
document.getElementById('manual-map-playlist').value = playlistName;
document.getElementById('manual-map-position').value = position;
document.getElementById('manual-map-spotify-id').value = spotifyId;
document.getElementById('jellyfin-search-query').value = `${title} ${artist}`;
document.getElementById('jellyfin-results').innerHTML = '<p style="color:var(--text-secondary);text-align:center;padding:20px;">Enter search terms and click Search</p>';
openModal('manual-map-modal');
export function openManualMap(
playlistName,
position,
title,
artist,
spotifyId,
) {
document.getElementById("local-map-spotify-title").textContent = title;
document.getElementById("local-map-spotify-artist").textContent = artist;
document.getElementById("local-map-position").textContent = String(
position ?? "",
);
document.getElementById("local-map-playlist-name").value = playlistName;
document.getElementById("local-map-spotify-id").value = spotifyId;
document.getElementById("local-map-jellyfin-id").value = "";
document.getElementById("local-map-search").value =
`${title} ${artist}`.trim();
document.getElementById("local-map-results").innerHTML =
'<p style="color:var(--text-secondary);text-align:center;padding:20px;">Enter search terms and click Search</p>';
const saveBtn = document.getElementById("local-map-save-btn");
if (saveBtn) {
saveBtn.disabled = true;
}
openModal("local-map-modal");
}
// Manual mapping to external provider
export function openExternalMap(playlistName, position, title, artist, spotifyId) {
document.getElementById('external-map-title').textContent = `${title} - ${artist}`;
document.getElementById('external-map-playlist').value = playlistName;
document.getElementById('external-map-position').value = position;
document.getElementById('external-map-spotify-id').value = spotifyId;
document.getElementById('external-map-external-id').value = '';
document.getElementById('external-map-provider').value = 'squidwtf';
openModal('external-map-modal');
export function openExternalMap(
playlistName,
position,
title,
artist,
spotifyId,
) {
document.getElementById("map-spotify-title").textContent = title;
document.getElementById("map-spotify-artist").textContent = artist;
document.getElementById("map-position").textContent = String(position ?? "");
document.getElementById("map-playlist-name").value = playlistName;
document.getElementById("map-spotify-id").value = spotifyId;
document.getElementById("map-external-id").value = "";
const searchInput = document.getElementById("map-external-search");
if (searchInput) {
searchInput.value = `${title} ${artist}`.trim();
}
const resultsDiv = document.getElementById("map-external-results");
if (resultsDiv) {
resultsDiv.innerHTML =
'<p style="color:var(--text-secondary);text-align:center;padding:20px;">Enter search terms and click Search</p>';
}
document.getElementById("map-external-provider").value = "SquidWTF";
const saveBtn = document.getElementById("map-save-btn");
if (saveBtn) {
saveBtn.disabled = true;
}
openModal("manual-map-modal");
}
// Search Jellyfin for tracks
export async function searchJellyfinTracks() {
const query = document.getElementById('jellyfin-search-query').value.trim();
const query = document.getElementById("local-map-search").value.trim();
if (!query) {
showToast('Please enter a search query', 'error');
showToast("Please enter a search query", "error");
return;
}
const resultsDiv = document.getElementById('jellyfin-results');
resultsDiv.innerHTML = '<div class="loading"><span class="spinner"></span> Searching...</div>';
const resultsDiv = document.getElementById("local-map-results");
resultsDiv.innerHTML =
'<div class="loading"><span class="spinner"></span> Searching...</div>';
try {
const data = await API.searchJellyfin(query);
if (!data.results || data.results.length === 0) {
resultsDiv.innerHTML = '<p style="color:var(--text-secondary);text-align:center;padding:20px;">No results found</p>';
const results = data.results || data.tracks || [];
if (results.length === 0) {
resultsDiv.innerHTML =
'<p style="color:var(--text-secondary);text-align:center;padding:20px;">No results found</p>';
return;
}
resultsDiv.innerHTML = data.results.map(track => {
resultsDiv.innerHTML = results
.map((track) => {
const id = track.id || "";
const title = track.name || track.title || "Unknown";
const artist = track.artist || "";
const album = track.album || "";
return `
<div class="jellyfin-result" onclick="selectJellyfinTrack('${escapeJs(track.id)}')">
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" onclick="selectJellyfinTrack('${escapeJs(id)}')">
<div>
<strong>${escapeHtml(track.name)}</strong>
<strong>${escapeHtml(title)}</strong>
<br>
<span style="color:var(--text-secondary);">${escapeHtml(track.artist || '')}</span>
${track.album ? '<br><small>' + escapeHtml(track.album) + '</small>' : ''}
<span style="color:var(--text-secondary);">${escapeHtml(artist)}</span>
${album ? "<br><small>" + escapeHtml(album) + "</small>" : ""}
</div>
<div style="font-family:monospace;font-size:0.75rem;color:var(--text-secondary);">
${track.id}
${id}
</div>
</div>
`;
}).join('');
})
.join("");
} catch (error) {
console.error('Search error:', error);
resultsDiv.innerHTML = '<p style="color:var(--error);text-align:center;padding:20px;">Search failed: ' + error.message + '</p>';
console.error("Search error:", error);
resultsDiv.innerHTML =
'<p style="color:var(--error);text-align:center;padding:20px;">Search failed: ' +
escapeHtml(error?.message || "Unknown error") +
"</p>";
}
}
@@ -196,36 +273,211 @@ export async function searchJellyfinTracks() {
export async function selectJellyfinTrack(jellyfinId) {
try {
const data = await API.getJellyfinTrack(jellyfinId);
const selectedTrack = data.track || data;
const selectedTitle = selectedTrack?.name || selectedTrack?.title || "Track";
document.getElementById('manual-map-jellyfin-id').value = jellyfinId;
document.getElementById('manual-map-preview').innerHTML = `
<strong>Selected:</strong> ${escapeHtml(data.track.name)}<br>
<span style="color:var(--text-secondary);">Artist: ${escapeHtml(data.track.artist || 'Unknown')}</span><br>
${data.track.album ? '<span style="color:var(--text-secondary);">Album: ' + escapeHtml(data.track.album) + '</span>' : ''}
`;
showToast('Track selected. Click "Save Mapping" to confirm.', 'success');
} catch (error) {
console.error('Failed to fetch track details:', error);
showToast('Failed to fetch track details', 'error');
document.getElementById("local-map-jellyfin-id").value = jellyfinId;
const saveBtn = document.getElementById("local-map-save-btn");
if (saveBtn) {
saveBtn.disabled = false;
}
const selectedRow = Array.from(
document.querySelectorAll(".jellyfin-result"),
).find((row) => row.getAttribute("data-jellyfin-id") === jellyfinId);
if (selectedRow) {
document.querySelectorAll(".jellyfin-result").forEach((row) => {
row.style.background = "";
row.style.border = "";
});
selectedRow.style.background = "var(--bg-tertiary)";
selectedRow.style.border = "1px solid var(--accent)";
}
showToast(
`Track selected: ${selectedTitle}. Click "Save Mapping" to confirm.`,
"success",
);
} catch (error) {
console.error("Failed to fetch track details:", error);
showToast("Failed to fetch track details", "error");
}
}
export async function searchExternalTracks() {
const query =
document.getElementById("map-external-search")?.value.trim() || "";
const provider = (
document.getElementById("map-external-provider")?.value || "SquidWTF"
).toLowerCase();
if (!query) {
showToast("Please enter a search query", "error");
return;
}
const resultsDiv = document.getElementById("map-external-results");
if (!resultsDiv) {
return;
}
resultsDiv.innerHTML =
'<div class="loading"><span class="spinner"></span> Searching...</div>';
try {
const data = await API.searchExternalTracks(query, provider);
const results = data.results || [];
if (results.length === 0) {
resultsDiv.innerHTML =
'<p style="color:var(--text-secondary);text-align:center;padding:20px;">No results found</p>';
return;
}
resultsDiv.innerHTML = results
.map((track, index) => {
const id = String(track.externalId || track.id || "");
const title = track.title || "Unknown";
const artist = track.artist || "";
const album = track.album || "";
const providerName = track.externalProvider || provider;
const externalUrl = track.url || "";
return `
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}" onclick="selectExternalTrack(${index}, '${escapeJs(id)}', '${escapeJs(title)}', '${escapeJs(artist)}', '${escapeJs(providerName)}', '${escapeJs(externalUrl)}')">
<div>
<strong>${escapeHtml(title)}</strong>
<br>
<span style="color:var(--text-secondary);">${escapeHtml(artist)}</span>
${album ? "<br><small>" + escapeHtml(album) + "</small>" : ""}
</div>
<div class="external-result-id">
${escapeHtml(id)}
</div>
</div>
`;
})
.join("");
} catch (error) {
console.error("External search error:", error);
resultsDiv.innerHTML =
'<p style="color:var(--error);text-align:center;padding:20px;">Search failed: ' +
escapeHtml(error?.message || "Unknown error") +
"</p>";
}
}
function normalizeExternalIdForProvider(externalId, provider) {
const normalizedProvider = (provider || "").trim().toLowerCase();
const trimmedId = String(externalId || "").trim();
if (!trimmedId) {
return "";
}
if (normalizedProvider !== "squidwtf") {
return trimmedId;
}
if (/^\d+$/.test(trimmedId)) {
return trimmedId;
}
try {
const url = new URL(trimmedId);
const queryId = url.searchParams.get("id")?.trim() || "";
if (/^\d+$/.test(queryId)) {
return queryId;
}
const pathSegments = url.pathname.split("/").filter(Boolean);
const lastSegment = pathSegments[pathSegments.length - 1] || "";
if (/^\d+$/.test(lastSegment)) {
return lastSegment;
}
} catch {
return trimmedId;
}
return trimmedId;
}
export function selectExternalTrack(
resultIndex,
externalId,
title,
artist,
provider,
externalUrl,
) {
const externalIdInput = document.getElementById("map-external-id");
const providerSelect = document.getElementById("map-external-provider");
if (!externalIdInput || !providerSelect) {
return;
}
const normalizedProvider = (provider || "").toLowerCase();
const providerOptionValue =
normalizedProvider === "squidwtf" || normalizedProvider === "tidal"
? "SquidWTF"
: normalizedProvider === "deezer"
? "Deezer"
: normalizedProvider === "qobuz"
? "Qobuz"
: providerSelect.value;
providerSelect.value = providerOptionValue;
const selectedProvider = providerOptionValue.toLowerCase();
const normalizedExternalId = normalizeExternalIdForProvider(
externalId,
selectedProvider,
);
externalIdInput.value = normalizedExternalId;
validateExternalMapping(normalizedExternalId, selectedProvider);
const rows = document.querySelectorAll(".external-result");
rows.forEach((row) => {
row.classList.remove("selected");
});
const selectedRow = Number.isInteger(resultIndex)
? document.querySelector(
`.external-result[data-result-index="${resultIndex}"]`,
)
: Array.from(rows).find(
(row) => row.getAttribute("data-external-id") === externalId,
);
if (selectedRow) {
selectedRow.classList.add("selected");
}
const providerLabel = capitalizeProvider(selectedProvider || normalizedProvider || provider);
const idHint = normalizedExternalId ? ` Using ID ${normalizedExternalId}.` : "";
const linkHint = externalUrl ? " URL available." : "";
showToast(
`Track selected: ${title} by ${artist} (${providerLabel}).${idHint}${linkHint}`,
"success",
);
}
// Save local (Jellyfin) mapping
export async function saveLocalMapping() {
const playlistName = document.getElementById('manual-map-playlist').value;
const position = parseInt(document.getElementById('manual-map-position').value);
const spotifyId = document.getElementById('manual-map-spotify-id').value;
const jellyfinId = document.getElementById('manual-map-jellyfin-id').value;
const playlistName = document.getElementById("local-map-playlist-name").value;
const position = parseInt(
document.getElementById("local-map-position").textContent || "0",
);
const spotifyId = document.getElementById("local-map-spotify-id").value;
const jellyfinId = document.getElementById("local-map-jellyfin-id").value;
if (!jellyfinId) {
showToast('Please select a Jellyfin track first', 'error');
showToast("Please select a Jellyfin track first", "error");
return;
}
const saveBtn = document.getElementById('manual-map-save-btn');
const saveBtn = document.getElementById("local-map-save-btn");
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.textContent = "Saving...";
saveBtn.disabled = true;
try {
@@ -233,16 +485,16 @@ export async function saveLocalMapping() {
position,
spotifyId,
jellyfinId,
type: 'jellyfin'
type: "jellyfin",
});
showToast('✓ Mapping saved successfully', 'success');
closeModal('manual-map-modal');
showToast("✓ Mapping saved successfully", "success");
closeModal("local-map-modal");
if (window.fetchPlaylists) window.fetchPlaylists();
if (window.fetchTrackMappings) window.fetchTrackMappings();
} catch (error) {
showToast(error.message || 'Failed to save mapping', 'error');
showToast(error.message || "Failed to save mapping", "error");
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
@@ -251,14 +503,18 @@ export async function saveLocalMapping() {
// Save external provider mapping
export async function saveManualMapping() {
const playlistName = document.getElementById('external-map-playlist').value;
const position = parseInt(document.getElementById('external-map-position').value);
const spotifyId = document.getElementById('external-map-spotify-id').value;
const externalId = document.getElementById('external-map-external-id').value.trim();
const provider = document.getElementById('external-map-provider').value;
const playlistName = document.getElementById("map-playlist-name").value;
const position = parseInt(
document.getElementById("map-position").textContent || "0",
);
const spotifyId = document.getElementById("map-spotify-id").value;
const externalId = document.getElementById("map-external-id").value.trim();
const provider = (
document.getElementById("map-external-provider").value || ""
).toLowerCase();
if (!externalId) {
showToast('Please enter an external track ID', 'error');
showToast("Please enter an external track ID", "error");
return;
}
@@ -266,9 +522,9 @@ export async function saveManualMapping() {
return;
}
const saveBtn = document.getElementById('external-map-save-btn');
const saveBtn = document.getElementById("map-save-btn");
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.textContent = "Saving...";
saveBtn.disabled = true;
try {
@@ -277,110 +533,126 @@ export async function saveManualMapping() {
spotifyId,
externalId,
externalProvider: provider,
type: 'external'
type: "external",
});
showToast('✓ External mapping saved successfully', 'success');
closeModal('external-map-modal');
showToast("✓ External mapping saved successfully", "success");
closeModal("manual-map-modal");
if (window.fetchPlaylists) window.fetchPlaylists();
if (window.fetchTrackMappings) window.fetchTrackMappings();
} catch (error) {
showToast(error.message || 'Failed to save mapping', 'error');
showToast(error.message || "Failed to save mapping", "error");
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// Extract Jellyfin ID from URL or raw ID
export function extractJellyfinId() {
const input = document.getElementById('manual-map-jellyfin-url').value.trim();
if (!input) return;
let jellyfinId = '';
if (input.includes('/')) {
const match = input.match(/[a-f0-9]{32}/i);
if (match) {
jellyfinId = match[0];
}
} else if (/^[a-f0-9]{32}$/i.test(input)) {
jellyfinId = input;
}
if (jellyfinId) {
document.getElementById('manual-map-jellyfin-id').value = jellyfinId;
selectJellyfinTrack(jellyfinId);
} else {
showToast('Invalid Jellyfin ID or URL format', 'error');
}
}
// Validate external mapping ID format
export function validateExternalMapping(externalId, provider) {
if (provider === 'squidwtf') {
if (!/^https?:\/\//.test(externalId)) {
showToast('SquidWTF requires a full URL from the search results', 'error');
// Support inline validation calls from HTML oninput/onchange handlers.
if (typeof externalId !== "string" || typeof provider !== "string") {
externalId =
document.getElementById("map-external-id")?.value?.trim() || "";
provider = (
document.getElementById("map-external-provider")?.value || ""
).toLowerCase();
} else {
provider = provider.toLowerCase();
}
if (!externalId) {
const saveBtn = document.getElementById("map-save-btn");
if (saveBtn) {
saveBtn.disabled = true;
}
return false;
}
} else if (provider === 'deezer') {
if (!/^\d+$/.test(externalId) && !externalId.startsWith('http')) {
showToast('Deezer ID should be numeric or a full URL', 'error');
return false;
let valid = true;
if (provider === "squidwtf") {
if (!/^\d+$/.test(externalId) && !/^https?:\/\//.test(externalId)) {
showToast("SquidWTF ID should be numeric or a full URL", "error");
valid = false;
}
} else if (provider === 'qobuz') {
if (!externalId.includes('/') && !/^\d+$/.test(externalId)) {
showToast('Qobuz ID format appears invalid', 'error');
return false;
} else if (provider === "deezer") {
if (!/^\d+$/.test(externalId) && !externalId.startsWith("http")) {
showToast("Deezer ID should be numeric or a full URL", "error");
valid = false;
}
} else if (provider === "qobuz") {
if (!externalId.includes("/") && !/^\d+$/.test(externalId)) {
showToast("Qobuz ID format appears invalid", "error");
valid = false;
}
}
return true;
const saveBtn = document.getElementById("map-save-btn");
if (saveBtn) {
saveBtn.disabled = !valid;
}
return valid;
}
// Open lyrics mapping modal
export function openLyricsMap(artist, title, album, durationSeconds) {
document.getElementById('lyrics-map-artist').textContent = artist;
document.getElementById('lyrics-map-title').textContent = title;
document.getElementById('lyrics-map-album').textContent = album || '(No album)';
document.getElementById('lyrics-map-artist-value').value = artist;
document.getElementById('lyrics-map-title-value').value = title;
document.getElementById('lyrics-map-album-value').value = album || '';
document.getElementById('lyrics-map-duration').value = durationSeconds;
document.getElementById('lyrics-map-id').value = '';
document.getElementById("lyrics-map-artist").textContent = artist;
document.getElementById("lyrics-map-title").textContent = title;
document.getElementById("lyrics-map-album").textContent =
album || "(No album)";
document.getElementById("lyrics-map-artist-value").value = artist;
document.getElementById("lyrics-map-title-value").value = title;
document.getElementById("lyrics-map-album-value").value = album || "";
document.getElementById("lyrics-map-duration").value = durationSeconds;
document.getElementById("lyrics-map-id").value = "";
openModal('lyrics-map-modal');
openModal("lyrics-map-modal");
}
// Save lyrics mapping
export async function saveLyricsMapping() {
const artist = document.getElementById('lyrics-map-artist-value').value;
const title = document.getElementById('lyrics-map-title-value').value;
const album = document.getElementById('lyrics-map-album-value').value;
const durationSeconds = parseInt(document.getElementById('lyrics-map-duration').value);
const lyricsId = parseInt(document.getElementById('lyrics-map-id').value);
const artist = document.getElementById("lyrics-map-artist-value").value;
const title = document.getElementById("lyrics-map-title-value").value;
const album = document.getElementById("lyrics-map-album-value").value;
const durationSeconds = parseInt(
document.getElementById("lyrics-map-duration").value,
);
const lyricsId = parseInt(document.getElementById("lyrics-map-id").value);
if (!lyricsId || lyricsId <= 0) {
showToast('Please enter a valid lyrics ID', 'error');
showToast("Please enter a valid lyrics ID", "error");
return;
}
const saveBtn = document.getElementById('lyrics-map-save-btn');
const saveBtn = document.getElementById("lyrics-map-save-btn");
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.textContent = "Saving...";
saveBtn.disabled = true;
try {
const data = await API.saveLyricsMapping(artist, title, album, durationSeconds, lyricsId);
const data = await API.saveLyricsMapping(
artist,
title,
album,
durationSeconds,
lyricsId,
);
if (data.cached && data.lyrics) {
showToast(`✓ Lyrics mapped and cached: ${data.lyrics.trackName} by ${data.lyrics.artistName}`, 'success', 5000);
showToast(
`✓ Lyrics mapped and cached: ${data.lyrics.trackName} by ${data.lyrics.artistName}`,
"success",
5000,
);
} else {
showToast('✓ Lyrics mapping saved successfully', 'success');
showToast("✓ Lyrics mapping saved successfully", "success");
}
closeModal('lyrics-map-modal');
closeModal("lyrics-map-modal");
} catch (error) {
showToast(error.message || 'Failed to save lyrics mapping', 'error');
showToast(error.message || "Failed to save lyrics mapping", "error");
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
@@ -393,10 +665,10 @@ export async function searchProvider(query, provider) {
const data = await API.getSquidWTFBaseUrl();
const baseUrl = data.baseUrl; // Use the actual property name from API
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
window.open(searchUrl, '_blank');
window.open(searchUrl, "_blank");
} catch (error) {
console.error('Failed to get SquidWTF base URL:', error);
console.error("Failed to get SquidWTF base URL:", error);
// Fallback to first encoded URL (triton)
showToast('Failed to get SquidWTF URL, using fallback', 'warning');
showToast("Failed to get SquidWTF URL, using fallback", "warning");
}
}
+104 -1147
View File
@@ -1,22 +1,43 @@
// Main entry point - ES6 modules
import {
escapeHtml,
escapeJs,
showToast,
capitalizeProvider,
} from "./utils.js";
import * as API from "./api.js";
import { openModal, closeModal, setupModalBackdropClose } from "./modals.js";
import {
viewTracks,
openManualMap,
openExternalMap,
searchJellyfinTracks,
selectJellyfinTrack,
saveLocalMapping,
saveManualMapping,
searchExternalTracks,
selectExternalTrack,
validateExternalMapping,
openLyricsMap,
saveLyricsMapping,
searchProvider,
} from "./helpers.js";
import {
initSettingsEditor,
setCurrentConfigState,
syncConfigUiExtras,
} from "./settings-editor.js";
import { initDashboardData } from "./dashboard-data.js";
import { initOperations } from "./operations.js";
import {
initPlaylistAdmin,
resetPlaylistAdminState,
} from "./playlist-admin.js";
import { initScrobblingAdmin } from "./scrobbling-admin.js";
import { initAuthSession } from "./auth-session.js";
import { escapeHtml, escapeJs, showToast, formatCookieAge, capitalizeProvider } from './utils.js';
import * as API from './api.js';
import * as UI from './ui.js';
import { openModal, closeModal, setupModalBackdropClose } from './modals.js';
import { viewTracks, openManualMap, openExternalMap, searchJellyfinTracks, selectJellyfinTrack, saveLocalMapping, saveManualMapping, extractJellyfinId, validateExternalMapping, openLyricsMap, saveLyricsMapping, searchProvider } from './helpers.js';
// Global state
let currentEditKey = null;
let currentEditType = null;
let currentEditOptions = null;
let cookieDateInitialized = false;
let restartRequired = false;
let playlistAutoRefreshInterval = null;
let currentLinkMode = 'select';
let spotifyUserPlaylists = [];
// Make functions globally available for onclick handlers
window.showToast = showToast;
window.escapeHtml = escapeHtml;
window.escapeJs = escapeJs;
@@ -24,35 +45,36 @@ window.openModal = openModal;
window.closeModal = closeModal;
window.capitalizeProvider = capitalizeProvider;
// Restart banner
window.showRestartBanner = function () {
restartRequired = true;
document.getElementById('restart-banner').classList.add('active');
document.getElementById("restart-banner")?.classList.add("active");
};
window.dismissRestartBanner = function () {
document.getElementById('restart-banner').classList.remove('active');
document.getElementById("restart-banner")?.classList.remove("active");
};
// Tab switching
window.switchTab = function (tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(c => c.classList.remove('active'));
document
.querySelectorAll(".tab")
.forEach((tab) => tab.classList.remove("active"));
document
.querySelectorAll(".tab-content")
.forEach((content) => content.classList.remove("active"));
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
const content = document.getElementById('tab-' + tabName);
const content = document.getElementById(`tab-${tabName}`);
if (tab && content) {
tab.classList.add('active');
content.classList.add('active');
tab.classList.add("active");
content.classList.add("active");
window.location.hash = tabName;
}
};
// Initialize cookie date
async function initCookieDate() {
if (cookieDateInitialized) {
console.log('Cookie date already initialized, skipping');
console.log("Cookie date already initialized, skipping");
return;
}
@@ -60,1169 +82,104 @@ async function initCookieDate() {
try {
await API.initCookieDate();
console.log('Cookie date initialized successfully - restart container to apply');
showToast('Cookie date set. Restart container to apply changes.', 'success');
console.log(
"Cookie date initialized successfully - restart container to apply",
);
showToast(
"Cookie date set. Restart container to apply changes.",
"success",
);
} catch (error) {
console.error('Failed to init cookie date:', error);
console.error("Failed to init cookie date:", error);
cookieDateInitialized = false;
}
}
// Fetch and update status
window.fetchStatus = async function() {
try {
const data = await API.fetchStatus();
UI.updateStatusUI(data);
// Update cookie age
const cookieAgeEl = document.getElementById('spotify-cookie-age');
if (cookieAgeEl) {
const hasCookie = data.spotify.hasCookie;
const age = formatCookieAge(data.spotify.cookieSetDate, hasCookie);
cookieAgeEl.innerHTML = `<span class="${age.class}">${age.text}</span><br><small style="color:var(--text-secondary)">${age.detail}</small>`;
if (age.needsInit) {
console.log('Cookie exists but date not set, initializing...');
initCookieDate();
}
}
} catch (error) {
console.error('Failed to fetch status:', error);
showToast('Failed to fetch status: ' + error.message, 'error');
UI.showErrorState(error.message);
}
};
// Fetch playlists
window.fetchPlaylists = async function(silent = false) {
try {
const data = await API.fetchPlaylists();
UI.updatePlaylistsUI(data);
} catch (error) {
if (!silent) {
console.error('Failed to fetch playlists:', error);
showToast('Failed to fetch playlists', 'error');
}
}
};
// Fetch track mappings
window.fetchTrackMappings = async function() {
try {
const data = await API.fetchTrackMappings();
UI.updateTrackMappingsUI(data);
} catch (error) {
console.error('Failed to fetch track mappings:', error);
showToast('Failed to fetch track mappings', 'error');
}
};
// Delete track mapping
window.deleteTrackMapping = async function(playlist, spotifyId) {
if (!confirm(`Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`)) {
return;
}
try {
await API.deleteTrackMapping(playlist, spotifyId);
showToast('Mapping removed successfully', 'success');
await window.fetchTrackMappings();
} catch (error) {
console.error('Failed to delete mapping:', error);
showToast(error.message || 'Failed to remove mapping', 'error');
}
};
// Fetch missing tracks
window.fetchMissingTracks = async function() {
try {
const data = await API.fetchPlaylists();
const tbody = document.getElementById('missing-tracks-table-body');
const missingTracks = [];
// Collect all missing tracks from all playlists
for (const playlist of data.playlists) {
if (playlist.externalMissing > 0) {
try {
const tracksData = await API.fetchPlaylistTracks(playlist.name);
const missing = tracksData.tracks.filter(t => t.isLocal === null);
missing.forEach(t => {
missingTracks.push({
playlist: playlist.name,
...t
initSettingsEditor({
fetchConfig: async () => window.fetchConfig?.(),
fetchStatus: async () => window.fetchStatus?.(),
showRestartBanner: window.showRestartBanner,
});
initScrobblingAdmin({
showRestartBanner: window.showRestartBanner,
});
} catch (err) {
console.error(`Failed to fetch tracks for ${playlist.name}:`, err);
}
}
}
// Update summary
document.getElementById('missing-total').textContent = missingTracks.length;
if (missingTracks.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">🎉 No missing tracks! All tracks are matched.</td></tr>';
return;
}
tbody.innerHTML = missingTracks.map(t => {
const artist = (t.artists && t.artists.length > 0) ? t.artists.join(', ') : '';
const searchQuery = `${t.title} ${artist}`;
return `
<tr>
<td><strong>${escapeHtml(t.playlist)}</strong></td>
<td>${escapeHtml(t.title)}</td>
<td>${escapeHtml(artist)}</td>
<td style="color:var(--text-secondary);">${t.album ? escapeHtml(t.album) : '-'}</td>
<td>
<button onclick="searchProvider('${escapeJs(searchQuery)}', 'squidwtf')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">🔍 Search</button>
<button onclick="openMapToLocal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--success);border-color:var(--success);">Map to Local</button>
<button onclick="openMapToExternal('${escapeJs(t.playlist)}', '${escapeJs(t.spotifyId)}', '${escapeJs(t.title)}', '${escapeJs(artist)}')"
style="font-size:0.75rem;padding:4px 8px;background:var(--warning);border-color:var(--warning);">Map to External</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch missing tracks:', error);
showToast('Failed to fetch missing tracks', 'error');
}
};
// Fetch downloads
window.fetchDownloads = async function() {
try {
const data = await API.fetchDownloads();
const tbody = document.getElementById('downloads-table-body');
document.getElementById('downloads-count').textContent = data.count;
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
if (data.count === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
return;
}
tbody.innerHTML = data.files.map(f => {
return `
<tr data-path="${escapeHtml(f.path)}">
<td><strong>${escapeHtml(f.artist)}</strong></td>
<td>${escapeHtml(f.album)}</td>
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
<td>
<button onclick="downloadFile('${escapeJs(f.path)}')"
style="margin-right:4px;font-size:0.75rem;padding:4px 8px;background:var(--accent);border-color:var(--accent);">Download</button>
<button onclick="deleteDownload('${escapeJs(f.path)}')"
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
</td>
</tr>
`;
}).join('');
} catch (error) {
console.error('Failed to fetch downloads:', error);
showToast('Failed to fetch downloads', 'error');
}
};
window.downloadFile = function(path) {
try {
window.open(`/api/admin/downloads/file?path=${encodeURIComponent(path)}`, '_blank');
} catch (error) {
console.error('Failed to download file:', error);
showToast('Failed to download file', 'error');
}
};
window.downloadAllKept = function() {
try {
window.open('/api/admin/downloads/all', '_blank');
showToast('Preparing download archive...', 'info');
} catch (error) {
console.error('Failed to download all files:', error);
showToast('Failed to download all files', 'error');
}
};
window.deleteDownload = async function(path) {
if (!confirm(`Delete this file?\n\n${path}\n\nThis action cannot be undone.`)) {
return;
}
try {
await API.deleteDownload(path);
showToast('File deleted successfully', 'success');
const escapedPath = path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
const row = document.querySelector(`tr[data-path="${escapedPath}"]`);
if (row) row.remove();
await window.fetchDownloads();
} catch (error) {
console.error('Failed to delete file:', error);
showToast(error.message || 'Failed to delete file', 'error');
}
};
// Fetch config
window.fetchConfig = async function() {
try {
const data = await API.fetchConfig();
UI.updateConfigUI(data);
} catch (error) {
console.error('Failed to fetch config:', error);
}
};
// Fetch Jellyfin playlists
window.fetchJellyfinPlaylists = async function() {
const tbody = document.getElementById('jellyfin-playlist-table-body');
tbody.innerHTML = '<tr><td colspan="6" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
try {
const userId = document.getElementById('jellyfin-user-select')?.value;
const data = await API.fetchJellyfinPlaylists(userId);
UI.updateJellyfinPlaylistsUI(data);
} catch (error) {
console.error('Failed to fetch Jellyfin playlists:', error);
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--error);padding:40px;">Failed to fetch playlists</td></tr>';
}
};
// Fetch Jellyfin users
window.fetchJellyfinUsers = async function() {
try {
const data = await API.fetchJellyfinUsers();
if (data) {
UI.updateJellyfinUsersUI(data);
}
} catch (error) {
console.error('Failed to fetch users:', error);
}
};
// Refresh playlists (fetch from Spotify without re-matching)
window.refreshPlaylists = async function() {
try {
showToast('Refreshing playlists...', 'success');
const data = await API.refreshPlaylists();
showToast(data.message, 'success');
setTimeout(window.fetchPlaylists, 2000);
} catch (error) {
showToast('Failed to refresh playlists', 'error');
}
};
// Refresh single playlist (fetch from Spotify without re-matching)
window.refreshPlaylist = async function(name) {
try {
showToast(`Refreshing ${name} from Spotify...`, 'info');
const data = await API.refreshPlaylist(name);
showToast(`${data.message}`, 'success');
setTimeout(window.fetchPlaylists, 2000);
} catch (error) {
showToast('Failed to refresh playlist', 'error');
}
};
// Clear playlist cache (individual "Rebuild Remote" button)
window.clearPlaylistCache = async function(name) {
if (!confirm(`Rebuild "${name}" from scratch?\n\nThis will:\n• Clear all caches\n• Fetch fresh Spotify playlist data\n• Re-match all tracks\n\nThis is the SAME process as the scheduled cron job.\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`)) return;
try {
document.getElementById('matching-warning-banner').style.display = 'block';
showToast(`Rebuilding ${name} from scratch...`, 'info');
const data = await API.clearPlaylistCache(name);
showToast(`${data.message}`, 'success', 5000);
UI.showPlaylistRebuildingIndicator(name);
setTimeout(() => {
window.fetchPlaylists();
document.getElementById('matching-warning-banner').style.display = 'none';
}, 3000);
} catch (error) {
showToast('Failed to clear cache', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
};
// Match playlist tracks
window.matchPlaylistTracks = async function(name) {
try {
document.getElementById('matching-warning-banner').style.display = 'block';
showToast(`Re-matching local tracks for ${name}...`, 'info');
const data = await API.matchPlaylistTracks(name);
showToast(`${data.message}`, 'success');
setTimeout(() => {
window.fetchPlaylists();
document.getElementById('matching-warning-banner').style.display = 'none';
}, 2000);
} catch (error) {
showToast('Failed to re-match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
};
// Match all playlists
window.matchAllPlaylists = async function() {
if (!confirm('Re-match local tracks for ALL playlists?\n\nUse this when your local library has changed.\n\nThis may take a few minutes.')) return;
try {
document.getElementById('matching-warning-banner').style.display = 'block';
showToast('Matching tracks for all playlists...', 'success');
const data = await API.matchAllPlaylists();
showToast(`${data.message}`, 'success');
setTimeout(() => {
window.fetchPlaylists();
document.getElementById('matching-warning-banner').style.display = 'none';
}, 2000);
} catch (error) {
showToast('Failed to match tracks', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
};
// Refresh and match all (Rebuild All Remote button)
window.refreshAndMatchAll = async function() {
if (!confirm('Rebuild all playlists from scratch?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Re-match all tracks against local library and external providers\n\nThis is the SAME process as the scheduled cron job.\n\nThis may take several minutes.')) return;
try {
document.getElementById('matching-warning-banner').style.display = 'block';
showToast('Starting full rebuild (same as cron job)...', 'info', 3000);
// Call the unified rebuild endpoint
const data = await API.rebuildAllPlaylists();
showToast(`✓ Full rebuild complete!`, 'success', 5000);
setTimeout(() => {
window.fetchPlaylists();
document.getElementById('matching-warning-banner').style.display = 'none';
}, 3000);
} catch (error) {
showToast('Failed to complete rebuild', 'error');
document.getElementById('matching-warning-banner').style.display = 'none';
}
};
// Clear cache
window.clearCache = async function() {
if (!confirm('Clear all cached playlist data?')) return;
try {
const data = await API.clearCache();
showToast(data.message, 'success');
window.fetchPlaylists();
} catch (error) {
showToast('Failed to clear cache', 'error');
}
};
// Export/Import env
window.exportEnv = async function() {
try {
const blob = await API.exportEnv();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `.env.backup.${new Date().toISOString().split('T')[0]}`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(a);
showToast('.env file exported successfully', 'success');
} catch (error) {
showToast('Failed to export .env file', 'error');
}
};
window.importEnv = async function(event) {
const file = event.target.files[0];
if (!file) return;
if (!confirm('Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.')) {
event.target.value = '';
return;
}
try {
const data = await API.importEnv(file);
showToast(data.message, 'success');
} catch (error) {
showToast(error.message || 'Failed to import .env file', 'error');
}
event.target.value = '';
};
// Restart container
window.restartContainer = async function() {
if (!confirm('Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.')) {
return;
}
try {
await API.restartContainer();
document.getElementById('restart-overlay').classList.add('active');
document.getElementById('restart-status').textContent = 'Stopping container...';
setTimeout(() => {
document.getElementById('restart-status').textContent = 'Waiting for server to come back...';
checkServerAndReload();
}, 3000);
} catch (error) {
showToast('Failed to restart container', 'error');
}
};
async function checkServerAndReload() {
let attempts = 0;
const maxAttempts = 60;
const checkHealth = async () => {
try {
const res = await fetch('/api/admin/status', {
method: 'GET',
cache: 'no-store'
const dashboard = initDashboardData({
isAuthenticated: () => authSession?.isAuthenticated() ?? false,
isAdminSession: () => authSession?.isAdminSession() ?? false,
getCurrentUserId: () => authSession?.getCurrentUserId?.() ?? null,
onCookieNeedsInit: initCookieDate,
setCurrentConfigState,
syncConfigUiExtras,
loadScrobblingConfig: () => window.loadScrobblingConfig?.(),
});
if (res.ok) {
document.getElementById('restart-status').textContent = 'Server is back! Reloading...';
window.dismissRestartBanner();
setTimeout(() => window.location.reload(), 500);
return;
}
} catch (e) {
// Server still restarting
}
attempts++;
document.getElementById('restart-status').textContent = `Waiting for server to come back... (${attempts}s)`;
initOperations({
fetchPlaylists: dashboard.fetchPlaylists,
fetchTrackMappings: dashboard.fetchTrackMappings,
fetchDownloads: dashboard.fetchDownloads,
});
if (attempts < maxAttempts) {
setTimeout(checkHealth, 1000);
} else {
document.getElementById('restart-overlay').classList.remove('active');
showToast('Server may still be restarting. Please refresh manually.', 'warning');
}
};
initPlaylistAdmin({
isAdminSession: () => authSession?.isAdminSession() ?? false,
showRestartBanner: window.showRestartBanner,
fetchPlaylists: dashboard.fetchPlaylists,
fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists,
});
checkHealth();
}
const authSession = initAuthSession({
stopDashboardRefresh: dashboard.stopDashboardRefresh,
loadDashboardData: dashboard.loadDashboardData,
switchTab: window.switchTab,
onUnauthenticated: () => {
resetPlaylistAdminState();
setCurrentConfigState(null);
},
});
// Link mode switching
window.switchLinkMode = function(mode) {
currentLinkMode = mode;
const selectGroup = document.getElementById('link-select-group');
const manualGroup = document.getElementById('link-manual-group');
const selectBtn = document.getElementById('select-mode-btn');
const manualBtn = document.getElementById('manual-mode-btn');
if (mode === 'select') {
selectGroup.style.display = 'block';
manualGroup.style.display = 'none';
selectBtn.classList.add('primary');
manualBtn.classList.remove('primary');
} else {
selectGroup.style.display = 'none';
manualGroup.style.display = 'block';
selectBtn.classList.remove('primary');
manualBtn.classList.add('primary');
}
};
// Open link playlist modal
window.openLinkPlaylist = async function(jellyfinId, name) {
document.getElementById('link-jellyfin-id').value = jellyfinId;
document.getElementById('link-jellyfin-name').value = name;
document.getElementById('link-spotify-id').value = '';
window.switchLinkMode('select');
if (spotifyUserPlaylists.length === 0) {
const select = document.getElementById('link-spotify-select');
select.innerHTML = '<option value="">Loading playlists...</option>';
try {
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists();
const availablePlaylists = spotifyUserPlaylists.filter(p => !p.isLinked);
if (availablePlaylists.length === 0) {
select.innerHTML = '<option value="">No playlists available</option>';
window.switchLinkMode('manual');
} else {
select.innerHTML = '<option value="">-- Select a playlist --</option>' +
availablePlaylists.map(p =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`
).join('');
}
} catch (error) {
select.innerHTML = '<option value="">Failed to load playlists</option>';
window.switchLinkMode('manual');
}
}
openModal('link-playlist-modal');
};
// Link playlist
window.linkPlaylist = async function() {
const jellyfinId = document.getElementById('link-jellyfin-id').value;
const name = document.getElementById('link-jellyfin-name').value;
const syncSchedule = document.getElementById('link-sync-schedule').value.trim();
if (!syncSchedule) {
showToast('Sync schedule is required', 'error');
return;
}
const cronParts = syncSchedule.split(/\s+/);
if (cronParts.length !== 5) {
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
return;
}
let spotifyId = '';
if (currentLinkMode === 'select') {
spotifyId = document.getElementById('link-spotify-select').value;
if (!spotifyId) {
showToast('Please select a Spotify playlist', 'error');
return;
}
} else {
spotifyId = document.getElementById('link-spotify-id').value.trim();
if (!spotifyId) {
showToast('Spotify Playlist ID is required', 'error');
return;
}
}
// Clean Spotify ID
let cleanSpotifyId = spotifyId;
if (spotifyId.startsWith('spotify:playlist:')) {
cleanSpotifyId = spotifyId.replace('spotify:playlist:', '');
} else if (spotifyId.includes('spotify.com/playlist/')) {
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
if (match) cleanSpotifyId = match[1];
}
cleanSpotifyId = cleanSpotifyId.split('?')[0].split('#')[0].replace(/\/$/, '');
try {
await API.linkPlaylist(jellyfinId, cleanSpotifyId, syncSchedule);
showToast('Playlist linked!', 'success');
window.showRestartBanner();
closeModal('link-playlist-modal');
spotifyUserPlaylists = [];
window.fetchPlaylists();
} catch (error) {
showToast(error.message || 'Failed to link playlist', 'error');
}
};
// Unlink playlist
window.unlinkPlaylist = async function(name) {
if (!confirm(`Unlink playlist "${name}"? This will stop filling in missing tracks.`)) return;
try {
await API.unlinkPlaylist(name);
showToast('Playlist unlinked.', 'success');
window.showRestartBanner();
spotifyUserPlaylists = [];
window.fetchPlaylists();
} catch (error) {
showToast(error.message || 'Failed to unlink playlist', 'error');
}
};
// Add playlist
window.openAddPlaylist = function() {
document.getElementById('new-playlist-name').value = '';
document.getElementById('new-playlist-id').value = '';
openModal('add-playlist-modal');
};
window.addPlaylist = async function() {
const name = document.getElementById('new-playlist-name').value.trim();
const id = document.getElementById('new-playlist-id').value.trim();
if (!name || !id) {
showToast('Name and ID are required', 'error');
return;
}
try {
await API.addPlaylist(name, id);
showToast('Playlist added.', 'success');
window.showRestartBanner();
closeModal('add-playlist-modal');
} catch (error) {
showToast(error.message || 'Failed to add playlist', 'error');
}
};
// Edit playlist schedule
window.editPlaylistSchedule = async function(playlistName, currentSchedule) {
const newSchedule = prompt(`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * * = Daily 8 AM\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`, currentSchedule);
if (!newSchedule || newSchedule === currentSchedule) return;
const cronParts = newSchedule.trim().split(/\s+/);
if (cronParts.length !== 5) {
showToast('Invalid cron format. Expected: minute hour day month dayofweek', 'error');
return;
}
try {
await API.editPlaylistSchedule(playlistName, newSchedule.trim());
showToast('Sync schedule updated!', 'success');
window.showRestartBanner();
window.fetchPlaylists();
} catch (error) {
console.error('Failed to update schedule:', error);
showToast(error.message || 'Failed to update schedule', 'error');
}
};
// Remove playlist
window.removePlaylist = async function(name) {
if (!confirm(`Remove playlist "${name}"?`)) return;
try {
await API.removePlaylist(name);
showToast('Playlist removed.', 'success');
window.showRestartBanner();
window.fetchPlaylists();
} catch (error) {
showToast(error.message || 'Failed to remove playlist', 'error');
}
};
// View tracks
window.viewTracks = viewTracks;
// Manual mapping functions
window.openManualMap = openManualMap;
window.openExternalMap = openExternalMap;
window.openMapToLocal = openManualMap; // Alias for compatibility
window.openMapToExternal = openExternalMap; // Alias for compatibility
window.openMapToLocal = openManualMap;
window.openMapToExternal = openExternalMap;
window.searchJellyfinTracks = searchJellyfinTracks;
window.selectJellyfinTrack = selectJellyfinTrack;
window.saveLocalMapping = saveLocalMapping;
window.saveManualMapping = saveManualMapping;
window.extractJellyfinId = extractJellyfinId;
window.searchExternalTracks = searchExternalTracks;
window.selectExternalTrack = selectExternalTrack;
window.validateExternalMapping = validateExternalMapping;
// Lyrics mapping
window.openLyricsMap = openLyricsMap;
window.saveLyricsMapping = saveLyricsMapping;
// Search provider
window.searchProvider = searchProvider;
// Settings editing
window.openEditSetting = function(envKey, label, inputType, helpText = '', options = []) {
currentEditKey = envKey;
currentEditType = inputType;
currentEditOptions = options;
document.addEventListener("DOMContentLoaded", () => {
console.log("🚀 Allstarr Admin UI (Modular) loaded");
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
document.getElementById('edit-setting-label').textContent = label;
const helpEl = document.getElementById('edit-setting-help');
if (helpText) {
helpEl.textContent = helpText;
helpEl.style.display = 'block';
} else {
helpEl.style.display = 'none';
}
const container = document.getElementById('edit-setting-input-container');
if (inputType === 'toggle') {
container.innerHTML = `
<select id="edit-setting-value">
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
`;
} else if (inputType === 'select') {
container.innerHTML = `
<select id="edit-setting-value">
${options.map(opt => `<option value="${opt}">${opt}</option>`).join('')}
</select>
`;
} else if (inputType === 'password') {
container.innerHTML = `<input type="password" id="edit-setting-value" placeholder="Enter new value" autocomplete="off">`;
} else if (inputType === 'number') {
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value">`;
} else {
container.innerHTML = `<input type="text" id="edit-setting-value" placeholder="Enter value">`;
}
openModal('edit-setting-modal');
};
window.openEditCacheSetting = function(settingKey, label, helpText) {
currentEditKey = settingKey;
currentEditType = 'number';
document.getElementById('edit-setting-title').textContent = 'Edit ' + label;
document.getElementById('edit-setting-label').textContent = label;
const helpEl = document.getElementById('edit-setting-help');
if (helpText) {
helpEl.textContent = helpText + ' (Requires restart to apply)';
helpEl.style.display = 'block';
} else {
helpEl.style.display = 'none';
}
const container = document.getElementById('edit-setting-input-container');
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value" min="1">`;
openModal('edit-setting-modal');
};
window.saveEditSetting = async function() {
const value = document.getElementById('edit-setting-value').value.trim();
if (!value && currentEditType !== 'toggle') {
showToast('Value is required', 'error');
return;
}
try {
await API.updateConfigSetting(currentEditKey, value);
showToast('Setting updated.', 'success');
window.showRestartBanner();
closeModal('edit-setting-modal');
window.fetchConfig();
window.fetchStatus();
} catch (error) {
showToast(error.message || 'Failed to update setting', 'error');
}
};
// Endpoint usage
window.fetchEndpointUsage = async function() {
try {
const topSelect = document.getElementById('endpoints-top-select');
const top = topSelect ? topSelect.value : 50;
const data = await API.fetchEndpointUsage(top);
UI.updateEndpointUsageUI(data);
} catch (error) {
console.error('Failed to fetch endpoint usage:', error);
const tbody = document.getElementById('endpoints-table-body');
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--error);padding:40px;">Failed to load endpoint usage data</td></tr>';
}
};
window.clearEndpointUsage = async function() {
if (!confirm('Are you sure you want to clear all endpoint usage data? This cannot be undone.')) {
return;
}
try {
const data = await API.clearEndpointUsage();
showToast(data.message || 'Endpoint usage data cleared', 'success');
window.fetchEndpointUsage();
} catch (error) {
console.error('Failed to clear endpoint usage:', error);
showToast('Failed to clear endpoint usage data', 'error');
}
};
// Auto-refresh functionality
function startPlaylistAutoRefresh() {
if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval);
}
playlistAutoRefreshInterval = setInterval(() => {
const playlistsTab = document.getElementById('tab-playlists');
if (playlistsTab && playlistsTab.classList.contains('active')) {
window.fetchPlaylists(true);
}
}, 5000);
}
function stopPlaylistAutoRefresh() {
if (playlistAutoRefreshInterval) {
clearInterval(playlistAutoRefreshInterval);
playlistAutoRefreshInterval = null;
}
}
// Initialize on load
document.addEventListener('DOMContentLoaded', () => {
console.log('🚀 Allstarr Admin UI (Modular) loaded');
// Setup tab switching
document.querySelectorAll('.tab').forEach(tab => {
tab.addEventListener('click', () => {
document.querySelectorAll(".tab").forEach((tab) => {
tab.addEventListener("click", () => {
window.switchTab(tab.dataset.tab);
});
});
// Restore tab from URL hash
const hash = window.location.hash.substring(1);
if (hash) {
window.switchTab(hash);
}
// Setup modal backdrop close
setupModalBackdropClose();
// Initial data load
window.fetchStatus();
window.fetchPlaylists();
window.fetchTrackMappings();
window.fetchMissingTracks();
window.fetchDownloads();
window.fetchJellyfinUsers();
window.fetchJellyfinPlaylists();
window.fetchConfig();
window.fetchEndpointUsage();
// Start auto-refresh
startPlaylistAutoRefresh();
// Load scrobbling config immediately on page load
loadScrobblingConfig();
// Also reload when scrobbling tab is clicked
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
if (scrobblingTab) {
scrobblingTab.addEventListener('click', function() {
loadScrobblingConfig();
});
}
// Auto-refresh every 30 seconds
setInterval(() => {
window.fetchStatus();
window.fetchPlaylists();
window.fetchTrackMappings();
window.fetchMissingTracks();
window.fetchDownloads();
const endpointsTab = document.getElementById('tab-endpoints');
if (endpointsTab && endpointsTab.classList.contains('active')) {
window.fetchEndpointUsage();
}
}, 30000);
});
console.log('✅ Main.js module loaded');
// ===== SCROBBLING FUNCTIONS =====
window.loadScrobblingConfig = async function() {
try {
const response = await fetch('/api/admin/config', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
// Update scrobbling enabled
document.getElementById('scrobbling-enabled-value').textContent = data.scrobbling.enabled ? 'Enabled' : 'Disabled';
// Update local tracks enabled
document.getElementById('local-tracks-enabled-value').textContent = data.scrobbling.localTracksEnabled ? 'Enabled' : 'Disabled';
// Update Last.fm config
document.getElementById('lastfm-enabled-value').textContent = data.scrobbling.lastFm.enabled ? 'Enabled' : 'Disabled';
// Username - show actual value or "Not Set"
const username = data.scrobbling.lastFm.username;
document.getElementById('lastfm-username-value').textContent = (username && username !== '(not set)') ? username : 'Not Set';
// Password - show if set (masked)
const password = data.scrobbling.lastFm.password;
document.getElementById('lastfm-password-value').textContent = (password && password !== '(not set)') ? '••••••••' : 'Not Set';
// Session key - show first 32 chars if exists
const sessionKey = data.scrobbling.lastFm.sessionKey;
if (sessionKey && sessionKey !== '(not set)' && !sessionKey.startsWith('••••')) {
document.getElementById('lastfm-session-key-value').textContent = sessionKey.substring(0, 32) + '...';
} else if (sessionKey && sessionKey.startsWith('••••')) {
// It's masked, show it as is
document.getElementById('lastfm-session-key-value').textContent = sessionKey;
} else {
document.getElementById('lastfm-session-key-value').textContent = 'Not Set';
}
// Status - check if API Key and Secret are set
const hasApiKey = data.scrobbling.lastFm.apiKey && data.scrobbling.lastFm.apiKey !== '(not set)' && !data.scrobbling.lastFm.apiKey.startsWith('(not set)');
const hasSecret = data.scrobbling.lastFm.sharedSecret && data.scrobbling.lastFm.sharedSecret !== '(not set)' && !data.scrobbling.lastFm.sharedSecret.startsWith('(not set)');
const hasUsername = username && username !== '(not set)';
const hasPassword = password && password !== '(not set)';
const hasSessionKey = sessionKey && sessionKey !== '(not set)' && sessionKey.length > 0;
let status = '';
if (data.scrobbling.lastFm.enabled && hasSessionKey) {
status = '<span style="color: var(--success);">✓ Configured & Enabled</span>';
} else if (hasApiKey && hasSecret && hasUsername && hasPassword && !hasSessionKey) {
status = '<span style="color: var(--warning);">⚠️ Ready to Authenticate</span>';
} else if (hasApiKey && hasSecret && (!hasUsername || !hasPassword)) {
status = '<span style="color: var(--warning);">⚠️ Needs Username & Password</span>';
} else if (!hasApiKey || !hasSecret) {
status = '<span style="color: var(--success);">✓ Using hardcoded credentials</span>';
} else {
status = '<span style="color: var(--muted);">○ Not Configured</span>';
}
document.getElementById('lastfm-status-value').innerHTML = status;
// Update ListenBrainz config
document.getElementById('listenbrainz-enabled-value').textContent = data.scrobbling.listenBrainz.enabled ? 'Enabled' : 'Disabled';
const hasToken = data.scrobbling.listenBrainz.userToken && data.scrobbling.listenBrainz.userToken !== '(not set)';
document.getElementById('listenbrainz-token-value').textContent = hasToken ? '••••••••' : 'Not Set';
// ListenBrainz status
let lbStatus = '';
if (data.scrobbling.listenBrainz.enabled && hasToken) {
lbStatus = '<span style="color: var(--success);">✓ Configured & Enabled</span>';
} else if (hasToken && !data.scrobbling.listenBrainz.enabled) {
lbStatus = '<span style="color: var(--warning);">⚠️ Token Set (Not Enabled)</span>';
} else if (!hasToken && data.scrobbling.listenBrainz.enabled) {
lbStatus = '<span style="color: var(--warning);">⚠️ Enabled (No Token)</span>';
} else {
lbStatus = '<span style="color: var(--muted);">○ Not Configured</span>';
}
document.getElementById('listenbrainz-status-value').innerHTML = lbStatus;
} catch (error) {
console.error('Failed to load scrobbling config:', error);
showToast('Failed to load scrobbling configuration: ' + error.message, 'error');
}
};
window.toggleScrobblingEnabled = async function() {
try {
const response = await fetch('/api/admin/config', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
const data = await response.json();
const newValue = !data.scrobbling.enabled;
await API.updateConfigSetting('SCROBBLING_ENABLED', newValue.toString());
showToast(`Scrobbling ${newValue ? 'enabled' : 'disabled'}`, 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to toggle scrobbling: ' + error.message, 'error');
}
};
window.toggleLocalTracksEnabled = async function() {
try {
const response = await fetch('/api/admin/scrobbling/status', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
const data = await response.json();
const newValue = !data.localTracksEnabled;
const updateResponse = await fetch('/api/admin/scrobbling/local-tracks/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('apiKey') || ''
},
body: JSON.stringify({ enabled: newValue })
});
if (!updateResponse.ok) {
const error = await updateResponse.json();
throw new Error(error.error || 'Failed to update setting');
}
const result = await updateResponse.json();
showToast(result.message || `Local track scrobbling ${newValue ? 'enabled' : 'disabled'}`, 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to toggle local track scrobbling: ' + error.message, 'error');
}
};
window.toggleLastFmEnabled = async function() {
try {
const response = await fetch('/api/admin/config', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
const data = await response.json();
const newValue = !data.scrobbling.lastFm.enabled;
await API.updateConfigSetting('SCROBBLING_LASTFM_ENABLED', newValue.toString());
showToast(`Last.fm ${newValue ? 'enabled' : 'disabled'}`, 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to toggle Last.fm: ' + error.message, 'error');
}
};
window.toggleListenBrainzEnabled = async function() {
try {
const response = await fetch('/api/admin/config', {
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
const data = await response.json();
const newValue = !data.scrobbling.listenBrainz.enabled;
await API.updateConfigSetting('SCROBBLING_LISTENBRAINZ_ENABLED', newValue.toString());
showToast(`ListenBrainz ${newValue ? 'enabled' : 'disabled'}`, 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to toggle ListenBrainz: ' + error.message, 'error');
}
};
window.editLastFmUsername = async function() {
const value = prompt('Enter your Last.fm username:');
if (value === null) return;
try {
await API.updateConfigSetting('SCROBBLING_LASTFM_USERNAME', value.trim());
showToast('Last.fm username updated', 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to update username: ' + error.message, 'error');
}
};
window.editLastFmPassword = async function() {
const value = prompt('Enter your Last.fm password:\n\nThis is stored encrypted and only used for authentication.');
if (value === null) return;
try {
await API.updateConfigSetting('SCROBBLING_LASTFM_PASSWORD', value.trim());
showToast('Last.fm password updated', 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to update password: ' + error.message, 'error');
}
};
window.editListenBrainzToken = async function() {
const value = prompt('Enter your ListenBrainz User Token:\n\nGet from https://listenbrainz.org/profile/');
if (value === null) return;
try {
await API.updateConfigSetting('SCROBBLING_LISTENBRAINZ_USER_TOKEN', value.trim());
showToast('ListenBrainz token updated', 'success');
await loadScrobblingConfig();
} catch (error) {
showToast('Failed to update token: ' + error.message, 'error');
}
};
window.authenticateLastFm = async function() {
try {
showToast('Authenticating with Last.fm...', 'info');
const response = await fetch('/api/admin/scrobbling/lastfm/authenticate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('apiKey') || ''
scrobblingTab.addEventListener("click", () => {
if (authSession.isAuthenticated()) {
window.loadScrobblingConfig();
}
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const data = await response.json();
showToast('✓ Authentication successful! Session key saved. Please restart the container.', 'success', 5000);
window.showRestartBanner();
// Reload config to show updated session key
await loadScrobblingConfig();
} catch (error) {
console.error('Failed to authenticate:', error);
showToast('Authentication failed: ' + error.message, 'error');
}
};
window.testLastFmConnection = async function() {
try {
const response = await fetch('/api/admin/scrobbling/lastfm/test', {
method: 'POST',
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
authSession.bootstrapAuth();
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const data = await response.json();
showToast(`✓ Last.fm connection successful! User: ${data.username}, Scrobbles: ${data.playcount}`, 'success');
} catch (error) {
console.error('Failed to test connection:', error);
showToast('Failed to test connection: ' + error.message, 'error');
}
};
window.validateListenBrainzToken = async function() {
const token = prompt('Enter your ListenBrainz User Token:\n\nGet from https://listenbrainz.org/settings/');
if (!token) return;
try {
showToast('Validating ListenBrainz token...', 'info');
const response = await fetch('/api/admin/scrobbling/listenbrainz/validate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': localStorage.getItem('apiKey') || ''
},
body: JSON.stringify({ userToken: token.trim() })
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const data = await response.json();
showToast(`✓ Token validated! User: ${data.username}. Please restart the container.`, 'success', 5000);
window.showRestartBanner();
// Reload config to show updated token
await loadScrobblingConfig();
} catch (error) {
console.error('Failed to validate token:', error);
showToast('Validation failed: ' + error.message, 'error');
}
};
window.testListenBrainzConnection = async function() {
try {
const response = await fetch('/api/admin/scrobbling/listenbrainz/test', {
method: 'POST',
headers: { 'X-API-Key': localStorage.getItem('apiKey') || '' }
});
if (!response.ok) {
const error = await response.json();
throw new Error(error.error || `HTTP ${response.status}`);
}
const data = await response.json();
showToast(`✓ ListenBrainz connection successful! User: ${data.username}`, 'success');
} catch (error) {
console.error('Failed to test connection:', error);
showToast('Failed to test connection: ' + error.message, 'error');
}
};
console.log("✅ Main.js module loaded");
+382
View File
@@ -0,0 +1,382 @@
import { showToast } from "./utils.js";
import * as API from "./api.js";
let fetchPlaylists = async () => {};
let fetchTrackMappings = async () => {};
let fetchDownloads = async () => {};
function setMatchingBannerVisible(visible) {
const banner = document.getElementById("matching-warning-banner");
if (banner) {
banner.style.display = visible ? "block" : "none";
}
}
export async function runAction({
task,
success,
error,
onDone,
before,
after,
confirmMessage,
}) {
if (confirmMessage && !confirm(confirmMessage)) {
return null;
}
try {
if (before) {
await before();
}
const result = await task();
if (success) {
const message = typeof success === "function" ? success(result) : success;
if (message) {
showToast(message, "success");
}
}
return result;
} catch (err) {
const message = typeof error === "function" ? error(err) : error;
showToast(message || err.message || "Action failed", "error");
return null;
} finally {
if (after) {
await after();
}
if (onDone) {
await onDone();
}
}
}
function downloadFile(path) {
try {
window.open(
`/api/admin/downloads/file?path=${encodeURIComponent(path)}`,
"_blank",
);
} catch (error) {
console.error("Failed to download file:", error);
showToast("Failed to download file", "error");
}
}
function downloadAllKept() {
try {
window.open("/api/admin/downloads/all", "_blank");
showToast("Preparing download archive...", "info");
} catch (error) {
console.error("Failed to download all files:", error);
showToast("Failed to download all files", "error");
}
}
async function deleteDownload(path) {
const result = await runAction({
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
task: async () => {
await API.deleteDownload(path);
const escapedPath = path.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
const row = document.querySelector(`tr[data-path="${escapedPath}"]`);
if (row) {
row.remove();
}
return true;
},
success: "File deleted successfully",
error: (err) => err.message || "Failed to delete file",
});
if (result) {
await fetchDownloads();
}
}
async function deleteTrackMapping(playlist, spotifyId) {
const confirmMessage = `Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`;
const result = await runAction({
confirmMessage,
task: () => API.deleteTrackMapping(playlist, spotifyId),
success: "Mapping removed successfully",
error: (err) => err.message || "Failed to remove mapping",
});
if (result) {
await fetchTrackMappings();
}
}
async function refreshPlaylists() {
showToast("Refreshing playlists...", "info");
const result = await runAction({
task: () => API.refreshPlaylists(),
success: (data) => data.message,
error: "Failed to refresh playlists",
});
if (result) {
setTimeout(fetchPlaylists, 2000);
}
}
async function refreshPlaylist(name) {
showToast(`Refreshing ${name} from Spotify...`, "info");
const result = await runAction({
task: () => API.refreshPlaylist(name),
success: (data) => `${data.message}`,
error: "Failed to refresh playlist",
});
if (result) {
setTimeout(fetchPlaylists, 2000);
}
}
async function clearPlaylistCache(name) {
const result = await runAction({
confirmMessage: `Rebuild "${name}" from scratch?\n\nThis will:\n• Clear all caches\n• Fetch fresh Spotify playlist data\n• Re-match all tracks\n\nThis uses the same workflow as that playlist's scheduled cron rebuild.\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`,
before: async () => {
setMatchingBannerVisible(true);
showToast(`Rebuilding ${name} from scratch...`, "info");
},
task: () => API.clearPlaylistCache(name),
success: (data) => `${data.message}`,
error: "Failed to clear cache",
});
if (result) {
setTimeout(() => {
fetchPlaylists();
setMatchingBannerVisible(false);
}, 3000);
} else {
setMatchingBannerVisible(false);
}
}
async function matchPlaylistTracks(name) {
const result = await runAction({
before: async () => {
setMatchingBannerVisible(true);
showToast(`Re-matching local tracks for ${name}...`, "info");
},
task: () => API.matchPlaylistTracks(name),
success: (data) => `${data.message}`,
error: "Failed to re-match tracks",
});
if (result) {
setTimeout(() => {
fetchPlaylists();
setMatchingBannerVisible(false);
}, 2000);
} else {
setMatchingBannerVisible(false);
}
}
async function matchAllPlaylists() {
const result = await runAction({
confirmMessage:
"Re-match local tracks for ALL playlists?\n\nUse this when your local library has changed.\n\nThis may take a few minutes.",
before: async () => {
setMatchingBannerVisible(true);
showToast("Matching tracks for all playlists...", "info");
},
task: () => API.matchAllPlaylists(),
success: (data) => `${data.message}`,
error: "Failed to match tracks",
});
if (result) {
setTimeout(() => {
fetchPlaylists();
setMatchingBannerVisible(false);
}, 2000);
} else {
setMatchingBannerVisible(false);
}
}
async function refreshAndMatchAll() {
const result = await runAction({
confirmMessage:
"Rebuild all playlists from scratch?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Re-match all tracks against local library and external providers\n\nThis is a manual bulk rebuild across all playlists.\n\nThis may take several minutes.",
before: async () => {
setMatchingBannerVisible(true);
showToast("Starting full rebuild for all playlists...", "info", 3000);
},
task: () => API.rebuildAllPlaylists(),
success: "✓ Full rebuild complete!",
error: "Failed to complete rebuild",
});
if (result) {
setTimeout(() => {
fetchPlaylists();
setMatchingBannerVisible(false);
}, 3000);
} else {
setMatchingBannerVisible(false);
}
}
async function clearCache() {
const result = await runAction({
confirmMessage: "Clear all cached playlist data?",
task: () => API.clearCache(),
success: (data) => data.message,
error: "Failed to clear cache",
});
if (result) {
await fetchPlaylists();
}
}
async function exportEnv() {
const result = await runAction({
task: async () => {
const blob = await API.exportEnv();
const url = window.URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = `.env.backup.${new Date().toISOString().split("T")[0]}`;
document.body.appendChild(anchor);
anchor.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(anchor);
return true;
},
success: ".env file exported successfully",
error: (err) => err.message || "Failed to export .env file",
});
return result;
}
async function importEnv(event) {
const file = event.target.files[0];
if (!file) {
return;
}
const result = await runAction({
confirmMessage:
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.",
task: () => API.importEnv(file),
success: (data) => data.message,
error: (err) => err.message || "Failed to import .env file",
});
event.target.value = "";
return result;
}
async function restartContainer() {
if (
!confirm(
"Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
)
) {
return;
}
const result = await runAction({
task: () => API.restartContainer(),
error: "Failed to restart container",
});
if (!result) {
return;
}
document.getElementById("restart-overlay")?.classList.add("active");
const statusEl = document.getElementById("restart-status");
if (statusEl) {
statusEl.textContent = "Stopping container...";
}
setTimeout(() => {
if (statusEl) {
statusEl.textContent = "Waiting for server to come back...";
}
checkServerAndReload();
}, 3000);
}
async function checkServerAndReload() {
let attempts = 0;
const maxAttempts = 60;
const checkHealth = async () => {
try {
const res = await fetch("/api/admin/status", {
method: "GET",
cache: "no-store",
});
if (res.ok) {
const statusEl = document.getElementById("restart-status");
if (statusEl) {
statusEl.textContent = "Server is back! Reloading...";
}
window.dismissRestartBanner();
setTimeout(() => window.location.reload(), 500);
return;
}
} catch {
// Server still restarting.
}
attempts += 1;
const statusEl = document.getElementById("restart-status");
if (statusEl) {
statusEl.textContent = `Waiting for server to come back... (${attempts}s)`;
}
if (attempts < maxAttempts) {
setTimeout(checkHealth, 1000);
} else {
document.getElementById("restart-overlay")?.classList.remove("active");
showToast(
"Server may still be restarting. Please refresh manually.",
"warning",
);
}
};
checkHealth();
}
export function initOperations(options) {
fetchPlaylists = options.fetchPlaylists;
fetchTrackMappings = options.fetchTrackMappings;
fetchDownloads = options.fetchDownloads;
window.runAction = runAction;
window.deleteTrackMapping = deleteTrackMapping;
window.downloadFile = downloadFile;
window.downloadAllKept = downloadAllKept;
window.deleteDownload = deleteDownload;
window.refreshPlaylists = refreshPlaylists;
window.refreshPlaylist = refreshPlaylist;
window.clearPlaylistCache = clearPlaylistCache;
window.matchPlaylistTracks = matchPlaylistTracks;
window.matchAllPlaylists = matchAllPlaylists;
window.refreshAndMatchAll = refreshAndMatchAll;
window.clearCache = clearCache;
window.exportEnv = exportEnv;
window.importEnv = importEnv;
window.restartContainer = restartContainer;
return {
runAction,
};
}
+304
View File
@@ -0,0 +1,304 @@
import { escapeHtml, escapeJs, showToast } from "./utils.js";
import * as API from "./api.js";
import { openModal, closeModal } from "./modals.js";
let currentLinkMode = "select";
let spotifyUserPlaylists = [];
let spotifyUserPlaylistsScopeUserId = null;
let isAdminSession = () => false;
let showRestartBanner = () => {};
let fetchPlaylists = async () => {};
let fetchJellyfinPlaylists = async () => {};
function switchLinkMode(mode) {
currentLinkMode = mode;
const selectGroup = document.getElementById("link-select-group");
const manualGroup = document.getElementById("link-manual-group");
const selectBtn = document.getElementById("select-mode-btn");
const manualBtn = document.getElementById("manual-mode-btn");
if (!selectGroup || !manualGroup || !selectBtn || !manualBtn) {
return;
}
if (mode === "select") {
selectGroup.style.display = "block";
manualGroup.style.display = "none";
selectBtn.classList.add("primary");
manualBtn.classList.remove("primary");
} else {
selectGroup.style.display = "none";
manualGroup.style.display = "block";
selectBtn.classList.remove("primary");
manualBtn.classList.add("primary");
}
}
function cleanSpotifyPlaylistId(spotifyId) {
let cleanSpotifyId = spotifyId;
if (spotifyId.startsWith("spotify:playlist:")) {
cleanSpotifyId = spotifyId.replace("spotify:playlist:", "");
} else if (spotifyId.includes("spotify.com/playlist/")) {
const match = spotifyId.match(/playlist\/([a-zA-Z0-9]+)/);
if (match) {
cleanSpotifyId = match[1];
}
}
return cleanSpotifyId.split("?")[0].split("#")[0].replace(/\/$/, "");
}
async function openLinkPlaylist(jellyfinId, name) {
document.getElementById("link-jellyfin-id").value = jellyfinId;
document.getElementById("link-jellyfin-name").value = name;
document.getElementById("link-spotify-id").value = "";
switchLinkMode("select");
const selectedUserId = isAdminSession()
? document.getElementById("jellyfin-user-select")?.value || null
: null;
if (
spotifyUserPlaylists.length === 0 ||
spotifyUserPlaylistsScopeUserId !== selectedUserId
) {
const select = document.getElementById("link-spotify-select");
if (select) {
select.innerHTML = '<option value="">Loading playlists...</option>';
}
try {
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists(selectedUserId);
spotifyUserPlaylistsScopeUserId = selectedUserId;
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
if (!select) {
openModal("link-playlist-modal");
return;
}
if (availablePlaylists.length === 0) {
select.innerHTML = '<option value="">No playlists available</option>';
switchLinkMode("manual");
} else {
select.innerHTML =
'<option value="">-- Select a playlist --</option>' +
availablePlaylists
.map(
(p) =>
`<option value="${escapeHtml(p.id)}">${escapeHtml(p.name)} (${p.trackCount} tracks)</option>`,
)
.join("");
}
} catch {
const select = document.getElementById("link-spotify-select");
if (select) {
select.innerHTML = '<option value="">Failed to load playlists</option>';
}
switchLinkMode("manual");
}
}
openModal("link-playlist-modal");
}
async function linkPlaylist() {
const jellyfinId = document.getElementById("link-jellyfin-id").value;
const syncSchedule = document.getElementById("link-sync-schedule").value.trim();
if (!syncSchedule) {
showToast("Sync schedule is required", "error");
return;
}
const cronParts = syncSchedule.split(/\s+/);
if (cronParts.length !== 5) {
showToast(
"Invalid cron format. Expected: minute hour day month dayofweek",
"error",
);
return;
}
let spotifyId = "";
if (currentLinkMode === "select") {
spotifyId = document.getElementById("link-spotify-select").value;
if (!spotifyId) {
showToast("Please select a Spotify playlist", "error");
return;
}
} else {
spotifyId = document.getElementById("link-spotify-id").value.trim();
if (!spotifyId) {
showToast("Spotify Playlist ID is required", "error");
return;
}
}
try {
const selectedUserId = isAdminSession()
? document.getElementById("jellyfin-user-select")?.value || null
: null;
await API.linkPlaylist(
jellyfinId,
cleanSpotifyPlaylistId(spotifyId),
syncSchedule,
selectedUserId,
);
showToast("Playlist linked!", "success");
if (isAdminSession()) {
showRestartBanner();
} else {
showToast(
"Ask an administrator to restart Allstarr to apply changes.",
"warning",
);
}
closeModal("link-playlist-modal");
resetPlaylistAdminState();
if (isAdminSession()) {
await fetchPlaylists();
}
await fetchJellyfinPlaylists();
} catch (error) {
showToast(error.message || "Failed to link playlist", "error");
}
}
async function unlinkPlaylist(playlistIdentifier, name = null) {
const displayName = name || playlistIdentifier;
if (
!confirm(
`Unlink playlist "${displayName}"? This will stop filling in missing tracks.`,
)
) {
return;
}
try {
await API.unlinkPlaylist(playlistIdentifier);
showToast("Playlist unlinked.", "success");
if (isAdminSession()) {
showRestartBanner();
} else {
showToast(
"Ask an administrator to restart Allstarr to apply changes.",
"warning",
);
}
resetPlaylistAdminState();
if (isAdminSession()) {
await fetchPlaylists();
}
await fetchJellyfinPlaylists();
} catch (error) {
showToast(error.message || "Failed to unlink playlist", "error");
}
}
function openAddPlaylist() {
document.getElementById("new-playlist-name").value = "";
document.getElementById("new-playlist-id").value = "";
openModal("add-playlist-modal");
}
async function addPlaylist() {
const name = document.getElementById("new-playlist-name").value.trim();
const id = document.getElementById("new-playlist-id").value.trim();
if (!name || !id) {
showToast("Name and ID are required", "error");
return;
}
try {
await API.addPlaylist(name, id);
showToast("Playlist added.", "success");
showRestartBanner();
closeModal("add-playlist-modal");
} catch (error) {
showToast(error.message || "Failed to add playlist", "error");
}
}
async function editPlaylistSchedule(playlistName, currentSchedule) {
const newSchedule = prompt(
`Edit sync schedule for "${playlistName}"\n\nCron format: minute hour day month dayofweek\nExamples:\n• 0 8 * * * = Daily 8 AM\n• 0 8 * * 1 = Monday 8 AM\n• 0 6 * * * = Daily 6 AM\n• 0 20 * * 5 = Friday 8 PM\n\nUse https://crontab.guru/ to build your schedule`,
currentSchedule,
);
if (!newSchedule || newSchedule === currentSchedule) {
return;
}
const cronParts = newSchedule.trim().split(/\s+/);
if (cronParts.length !== 5) {
showToast(
"Invalid cron format. Expected: minute hour day month dayofweek",
"error",
);
return;
}
try {
await API.editPlaylistSchedule(playlistName, newSchedule.trim());
showToast("Sync schedule updated!", "success");
showRestartBanner();
await fetchPlaylists();
} catch (error) {
console.error("Failed to update schedule:", error);
showToast(error.message || "Failed to update schedule", "error");
}
}
async function removePlaylist(name) {
if (!confirm(`Remove playlist "${name}"?`)) {
return;
}
try {
await API.removePlaylist(name);
showToast("Playlist removed.", "success");
showRestartBanner();
await fetchPlaylists();
} catch (error) {
showToast(error.message || "Failed to remove playlist", "error");
}
}
export function resetPlaylistAdminState() {
currentLinkMode = "select";
spotifyUserPlaylists = [];
spotifyUserPlaylistsScopeUserId = null;
}
export function initPlaylistAdmin(options) {
isAdminSession = options.isAdminSession;
showRestartBanner = options.showRestartBanner;
fetchPlaylists = options.fetchPlaylists;
fetchJellyfinPlaylists = options.fetchJellyfinPlaylists;
window.switchLinkMode = switchLinkMode;
window.openLinkPlaylist = openLinkPlaylist;
window.linkPlaylist = linkPlaylist;
window.unlinkPlaylist = unlinkPlaylist;
window.openAddPlaylist = openAddPlaylist;
window.addPlaylist = addPlaylist;
window.editPlaylistSchedule = editPlaylistSchedule;
window.removePlaylist = removePlaylist;
return {
resetPlaylistAdminState,
};
}
+360
View File
@@ -0,0 +1,360 @@
import { showToast } from "./utils.js";
import * as API from "./api.js";
import { runAction } from "./operations.js";
let showRestartBanner = () => {};
async function runScrobblingAction({
task,
success,
error,
before,
reload = true,
onSuccess,
}) {
const result = await runAction({
task,
success,
error,
before,
});
if (!result) {
return null;
}
if (reload) {
await loadScrobblingConfig();
}
if (onSuccess) {
await onSuccess(result);
}
return result;
}
function parseBoolean(value) {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value !== 0;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on", "enabled"].includes(normalized)) {
return true;
}
if (["false", "0", "no", "off", "disabled"].includes(normalized)) {
return false;
}
}
return false;
}
async function loadScrobblingConfig() {
try {
const data = await API.fetchConfig();
document.getElementById("scrobbling-enabled-value").textContent = data
.scrobbling.enabled
? "Enabled"
: "Disabled";
document.getElementById("local-tracks-enabled-value").textContent = data
.scrobbling.localTracksEnabled
? "Enabled"
: "Disabled";
document.getElementById(
"synthetic-local-played-signal-enabled-value",
).textContent = data.scrobbling.syntheticLocalPlayedSignalEnabled
? "Enabled"
: "Disabled";
document.getElementById("lastfm-enabled-value").textContent = data
.scrobbling.lastFm.enabled
? "Enabled"
: "Disabled";
const username = data.scrobbling.lastFm.username;
document.getElementById("lastfm-username-value").textContent =
username && username !== "(not set)" ? username : "Not Set";
const password = data.scrobbling.lastFm.password;
document.getElementById("lastfm-password-value").textContent =
password && password !== "(not set)" ? "••••••••" : "Not Set";
const sessionKey = data.scrobbling.lastFm.sessionKey;
if (
sessionKey &&
sessionKey !== "(not set)" &&
!sessionKey.startsWith("••••")
) {
document.getElementById("lastfm-session-key-value").textContent =
sessionKey.substring(0, 32) + "...";
} else if (sessionKey && sessionKey.startsWith("••••")) {
document.getElementById("lastfm-session-key-value").textContent =
sessionKey;
} else {
document.getElementById("lastfm-session-key-value").textContent =
"Not Set";
}
const hasApiKey =
data.scrobbling.lastFm.apiKey &&
data.scrobbling.lastFm.apiKey !== "(not set)" &&
!data.scrobbling.lastFm.apiKey.startsWith("(not set)");
const hasSecret =
data.scrobbling.lastFm.sharedSecret &&
data.scrobbling.lastFm.sharedSecret !== "(not set)" &&
!data.scrobbling.lastFm.sharedSecret.startsWith("(not set)");
const hasUsername = username && username !== "(not set)";
const hasPassword = password && password !== "(not set)";
const hasSessionKey =
sessionKey && sessionKey !== "(not set)" && sessionKey.length > 0;
let status = "";
if (data.scrobbling.lastFm.enabled && hasSessionKey) {
status =
'<span style="color: var(--success);">✓ Configured & Enabled</span>';
} else if (
hasApiKey &&
hasSecret &&
hasUsername &&
hasPassword &&
!hasSessionKey
) {
status =
'<span style="color: var(--warning);">⚠️ Ready to Authenticate</span>';
} else if (hasApiKey && hasSecret && (!hasUsername || !hasPassword)) {
status =
'<span style="color: var(--warning);">⚠️ Needs Username & Password</span>';
} else if (!hasApiKey || !hasSecret) {
status =
'<span style="color: var(--success);">✓ Using hardcoded credentials</span>';
} else {
status = '<span style="color: var(--muted);">○ Not Configured</span>';
}
document.getElementById("lastfm-status-value").innerHTML = status;
document.getElementById("listenbrainz-enabled-value").textContent = data
.scrobbling.listenBrainz.enabled
? "Enabled"
: "Disabled";
const hasToken =
data.scrobbling.listenBrainz.userToken &&
data.scrobbling.listenBrainz.userToken !== "(not set)";
document.getElementById("listenbrainz-token-value").textContent = hasToken
? "••••••••"
: "Not Set";
let lbStatus = "";
if (data.scrobbling.listenBrainz.enabled && hasToken) {
lbStatus =
'<span style="color: var(--success);">✓ Configured & Enabled</span>';
} else if (hasToken && !data.scrobbling.listenBrainz.enabled) {
lbStatus =
'<span style="color: var(--warning);">⚠️ Token Set (Not Enabled)</span>';
} else if (!hasToken && data.scrobbling.listenBrainz.enabled) {
lbStatus =
'<span style="color: var(--warning);">⚠️ Enabled (No Token)</span>';
} else {
lbStatus = '<span style="color: var(--muted);">○ Not Configured</span>';
}
document.getElementById("listenbrainz-status-value").innerHTML = lbStatus;
} catch (error) {
console.error("Failed to load scrobbling config:", error);
showToast(
"Failed to load scrobbling configuration: " + error.message,
"error",
);
}
}
async function toggleScrobblingSetting(envKey, label, selector) {
await runScrobblingAction({
task: async () => {
const data = await API.fetchConfig();
const currentValue = parseBoolean(selector(data));
const newValue = !currentValue;
await API.updateConfigSetting(envKey, newValue.toString());
return newValue;
},
success: (newValue) => `${label} ${newValue ? "enabled" : "disabled"}`,
error: (error) => `Failed to toggle ${label}: ${error.message}`,
});
}
async function toggleScrobblingEnabled() {
await toggleScrobblingSetting(
"SCROBBLING_ENABLED",
"Scrobbling",
(config) => config?.scrobbling?.enabled,
);
}
async function toggleLocalTracksEnabled() {
await runScrobblingAction({
task: async () => {
const data = await API.fetchScrobblingStatus();
const newValue = !data.localTracksEnabled;
return API.updateLocalTracksScrobbling(newValue);
},
success: (result) => result.message || "Local track scrobbling updated",
error: (error) =>
"Failed to toggle local track scrobbling: " + error.message,
});
}
async function toggleSyntheticLocalPlayedSignalEnabled() {
await toggleScrobblingSetting(
"SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED",
"Synthetic local played signal",
(config) => config?.scrobbling?.syntheticLocalPlayedSignalEnabled,
);
}
async function toggleLastFmEnabled() {
await toggleScrobblingSetting(
"SCROBBLING_LASTFM_ENABLED",
"Last.fm",
(config) => config?.scrobbling?.lastFm?.enabled,
);
}
async function toggleListenBrainzEnabled() {
await toggleScrobblingSetting(
"SCROBBLING_LISTENBRAINZ_ENABLED",
"ListenBrainz",
(config) => config?.scrobbling?.listenBrainz?.enabled,
);
}
async function editLastFmUsername() {
const value = prompt("Enter your Last.fm username:");
if (value === null) {
return;
}
await runScrobblingAction({
task: () =>
API.updateConfigSetting("SCROBBLING_LASTFM_USERNAME", value.trim()),
success: "Last.fm username updated",
error: (error) => "Failed to update username: " + error.message,
});
}
async function editLastFmPassword() {
const value = prompt(
"Enter your Last.fm password:\n\nThis is stored encrypted and only used for authentication.",
);
if (value === null) {
return;
}
await runScrobblingAction({
task: () =>
API.updateConfigSetting("SCROBBLING_LASTFM_PASSWORD", value.trim()),
success: "Last.fm password updated",
error: (error) => "Failed to update password: " + error.message,
});
}
async function editListenBrainzToken() {
const value = prompt(
"Enter your ListenBrainz User Token:\n\nGet from https://listenbrainz.org/profile/",
);
if (value === null) {
return;
}
await runScrobblingAction({
task: () =>
API.updateConfigSetting(
"SCROBBLING_LISTENBRAINZ_USER_TOKEN",
value.trim(),
),
success: "ListenBrainz token updated",
error: (error) => "Failed to update token: " + error.message,
});
}
async function authenticateLastFm() {
await runScrobblingAction({
before: async () => {
showToast("Authenticating with Last.fm...", "info");
},
task: () => API.authenticateLastFm(),
success:
"✓ Authentication successful! Session key saved. Please restart the container.",
error: (error) => "Authentication failed: " + error.message,
onSuccess: async () => {
showRestartBanner();
},
});
}
async function testLastFmConnection() {
await runAction({
task: () => API.testLastFmConnection(),
success: (data) =>
`✓ Last.fm connection successful! User: ${data.username}, Scrobbles: ${data.playcount}`,
error: (error) => "Failed to test connection: " + error.message,
});
}
async function validateListenBrainzToken() {
const token = prompt(
"Enter your ListenBrainz User Token:\n\nGet from https://listenbrainz.org/settings/",
);
if (!token) {
return;
}
await runScrobblingAction({
before: async () => {
showToast("Validating ListenBrainz token...", "info");
},
task: () => API.validateListenBrainzToken(token.trim()),
success: (data) =>
`✓ Token validated! User: ${data.username}. Please restart the container.`,
error: (error) => "Validation failed: " + error.message,
onSuccess: async () => {
showRestartBanner();
},
});
}
async function testListenBrainzConnection() {
await runAction({
task: () => API.testListenBrainzConnection(),
success: (data) =>
`✓ ListenBrainz connection successful! User: ${data.username}`,
error: (error) => "Failed to test connection: " + error.message,
});
}
export function initScrobblingAdmin(options) {
showRestartBanner = options.showRestartBanner;
window.loadScrobblingConfig = loadScrobblingConfig;
window.toggleScrobblingEnabled = toggleScrobblingEnabled;
window.toggleLocalTracksEnabled = toggleLocalTracksEnabled;
window.toggleSyntheticLocalPlayedSignalEnabled =
toggleSyntheticLocalPlayedSignalEnabled;
window.toggleLastFmEnabled = toggleLastFmEnabled;
window.toggleListenBrainzEnabled = toggleListenBrainzEnabled;
window.editLastFmUsername = editLastFmUsername;
window.editLastFmPassword = editLastFmPassword;
window.editListenBrainzToken = editListenBrainzToken;
window.authenticateLastFm = authenticateLastFm;
window.testLastFmConnection = testLastFmConnection;
window.validateListenBrainzToken = validateListenBrainzToken;
window.testListenBrainzConnection = testListenBrainzConnection;
}
+662
View File
@@ -0,0 +1,662 @@
import { escapeHtml, showToast, formatCookieAge } from "./utils.js";
import * as API from "./api.js";
import * as UI from "./ui.js";
import { openModal, closeModal } from "./modals.js";
let currentEditKey = null;
let currentEditType = null;
let currentConfigState = null;
let refreshConfig = async () => {};
let refreshStatus = async () => {};
let showRestartBanner = () => {};
const SETTING_KEY_ALIASES = {
SearchResultsMinutes: "CACHE_SEARCH_RESULTS_MINUTES",
PlaylistImagesHours: "CACHE_PLAYLIST_IMAGES_HOURS",
SpotifyPlaylistItemsHours: "CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS",
SpotifyMatchedTracksDays: "CACHE_SPOTIFY_MATCHED_TRACKS_DAYS",
LyricsDays: "CACHE_LYRICS_DAYS",
GenreDays: "CACHE_GENRE_DAYS",
MetadataDays: "CACHE_METADATA_DAYS",
OdesliLookupDays: "CACHE_ODESLI_LOOKUP_DAYS",
ProxyImagesDays: "CACHE_PROXY_IMAGES_DAYS",
};
function ensureConfigSection(config, sectionName) {
if (!config[sectionName] || typeof config[sectionName] !== "object") {
config[sectionName] = {};
}
return config[sectionName];
}
function parseBoolean(value) {
if (typeof value === "boolean") {
return value;
}
if (typeof value === "number") {
return value !== 0;
}
if (typeof value === "string") {
const normalized = value.trim().toLowerCase();
if (["true", "1", "yes", "on", "enabled"].includes(normalized)) {
return true;
}
if (["false", "0", "no", "off", "disabled"].includes(normalized)) {
return false;
}
}
return false;
}
function toToggleValue(value) {
return parseBoolean(value) ? "true" : "false";
}
function parseInteger(value, fallback) {
const parsed = Number.parseInt(String(value), 10);
if (Number.isFinite(parsed)) {
return parsed;
}
return fallback;
}
function textBinding(getter, setter) {
return {
get: getter,
set: setter,
};
}
function toggleBinding(getter, setter) {
return {
get: getter,
set: (config, value) => setter(config, parseBoolean(value)),
toInput: toToggleValue,
fromInput: toToggleValue,
};
}
function numberBinding(getter, setter, fallbackValue) {
return {
get: getter,
set: (config, value) => {
const currentValue = Number(getter(config));
const fallback = Number.isFinite(currentValue)
? currentValue
: fallbackValue;
setter(config, parseInteger(value, fallback));
},
};
}
const SETTINGS_REGISTRY = {
JELLYFIN_LIBRARY_ID: textBinding(
(config) => config?.jellyfin?.libraryId ?? "",
(config, value) => {
ensureConfigSection(config, "jellyfin").libraryId = value;
},
),
BACKEND_TYPE: textBinding(
(config) => config?.backendType ?? "Jellyfin",
(config, value) => {
config.backendType = value;
},
),
MUSIC_SERVICE: textBinding(
(config) => config?.musicService ?? "SquidWTF",
(config, value) => {
config.musicService = value;
},
),
STORAGE_MODE: textBinding(
(config) => config?.library?.storageMode ?? "Cache",
(config, value) => {
ensureConfigSection(config, "library").storageMode = value;
},
),
CACHE_DURATION_HOURS: numberBinding(
(config) => config?.library?.cacheDurationHours ?? 24,
(config, value) => {
ensureConfigSection(config, "library").cacheDurationHours = value;
},
24,
),
DOWNLOAD_MODE: textBinding(
(config) => config?.library?.downloadMode ?? "Track",
(config, value) => {
ensureConfigSection(config, "library").downloadMode = value;
},
),
EXPLICIT_FILTER: textBinding(
(config) => config?.explicitFilter ?? "All",
(config, value) => {
config.explicitFilter = value;
},
),
ENABLE_EXTERNAL_PLAYLISTS: toggleBinding(
(config) => config?.enableExternalPlaylists ?? false,
(config, value) => {
config.enableExternalPlaylists = value;
},
),
PLAYLISTS_DIRECTORY: textBinding(
(config) => config?.playlistsDirectory ?? "",
(config, value) => {
config.playlistsDirectory = value;
},
),
REDIS_ENABLED: toggleBinding(
(config) => config?.redisEnabled ?? false,
(config, value) => {
config.redisEnabled = value;
},
),
ADMIN_BIND_ANY_IP: toggleBinding(
(config) => config?.admin?.bindAnyIp ?? false,
(config, value) => {
ensureConfigSection(config, "admin").bindAnyIp = value;
},
),
ADMIN_TRUSTED_SUBNETS: textBinding(
(config) => config?.admin?.trustedSubnets ?? "",
(config, value) => {
ensureConfigSection(config, "admin").trustedSubnets = value;
},
),
DEBUG_LOG_ALL_REQUESTS: toggleBinding(
(config) => config?.debug?.logAllRequests ?? false,
(config, value) => {
ensureConfigSection(config, "debug").logAllRequests = value;
},
),
SPOTIFY_API_ENABLED: toggleBinding(
(config) => config?.spotifyApi?.enabled ?? false,
(config, value) => {
ensureConfigSection(config, "spotifyApi").enabled = value;
},
),
SPOTIFY_API_SESSION_COOKIE: textBinding(
() => "",
() => {
// Sensitive values are intentionally never read back into the editor.
},
),
SPOTIFY_API_CACHE_DURATION_MINUTES: numberBinding(
(config) => config?.spotifyApi?.cacheDurationMinutes ?? 60,
(config, value) => {
ensureConfigSection(config, "spotifyApi").cacheDurationMinutes = value;
},
60,
),
SPOTIFY_API_PREFER_ISRC_MATCHING: toggleBinding(
(config) => config?.spotifyApi?.preferIsrcMatching ?? true,
(config, value) => {
ensureConfigSection(config, "spotifyApi").preferIsrcMatching = value;
},
),
DEEZER_ARL: textBinding(
() => "",
() => {
// Sensitive values are intentionally never read back into the editor.
},
),
DEEZER_QUALITY: textBinding(
(config) => config?.deezer?.quality ?? "FLAC",
(config, value) => {
ensureConfigSection(config, "deezer").quality = value;
},
),
SQUIDWTF_QUALITY: textBinding(
(config) => config?.squidWtf?.quality ?? "LOSSLESS",
(config, value) => {
ensureConfigSection(config, "squidWtf").quality = value;
},
),
MUSICBRAINZ_ENABLED: toggleBinding(
(config) => config?.musicBrainz?.enabled ?? false,
(config, value) => {
ensureConfigSection(config, "musicBrainz").enabled = value;
},
),
MUSICBRAINZ_USERNAME: textBinding(
(config) => config?.musicBrainz?.username ?? "",
(config, value) => {
ensureConfigSection(config, "musicBrainz").username = value;
},
),
MUSICBRAINZ_PASSWORD: textBinding(
() => "",
() => {
// Sensitive values are intentionally never read back into the editor.
},
),
QOBUZ_USER_AUTH_TOKEN: textBinding(
() => "",
() => {
// Sensitive values are intentionally never read back into the editor.
},
),
QOBUZ_QUALITY: textBinding(
(config) => config?.qobuz?.quality ?? "FLAC",
(config, value) => {
ensureConfigSection(config, "qobuz").quality = value;
},
),
JELLYFIN_URL: textBinding(
(config) => config?.jellyfin?.url ?? "",
(config, value) => {
ensureConfigSection(config, "jellyfin").url = value;
},
),
JELLYFIN_API_KEY: textBinding(
() => "",
() => {
// Sensitive values are intentionally never read back into the editor.
},
),
JELLYFIN_USER_ID: textBinding(
(config) => config?.jellyfin?.userId ?? "",
(config, value) => {
ensureConfigSection(config, "jellyfin").userId = value;
},
),
LIBRARY_DOWNLOAD_PATH: textBinding(
(config) => config?.library?.downloadPath ?? "",
(config, value) => {
ensureConfigSection(config, "library").downloadPath = value;
},
),
LIBRARY_KEPT_PATH: textBinding(
(config) => config?.library?.keptPath ?? "",
(config, value) => {
ensureConfigSection(config, "library").keptPath = value;
},
),
SPOTIFY_IMPORT_ENABLED: toggleBinding(
(config) => config?.spotifyImport?.enabled ?? false,
(config, value) => {
ensureConfigSection(config, "spotifyImport").enabled = value;
},
),
SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS: numberBinding(
(config) => config?.spotifyImport?.matchingIntervalHours ?? 24,
(config, value) => {
ensureConfigSection(config, "spotifyImport").matchingIntervalHours =
value;
},
24,
),
CACHE_SEARCH_RESULTS_MINUTES: numberBinding(
(config) => config?.cache?.searchResultsMinutes ?? 120,
(config, value) => {
ensureConfigSection(config, "cache").searchResultsMinutes = value;
},
120,
),
CACHE_PLAYLIST_IMAGES_HOURS: numberBinding(
(config) => config?.cache?.playlistImagesHours ?? 168,
(config, value) => {
ensureConfigSection(config, "cache").playlistImagesHours = value;
},
168,
),
CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS: numberBinding(
(config) => config?.cache?.spotifyPlaylistItemsHours ?? 168,
(config, value) => {
ensureConfigSection(config, "cache").spotifyPlaylistItemsHours = value;
},
168,
),
CACHE_SPOTIFY_MATCHED_TRACKS_DAYS: numberBinding(
(config) => config?.cache?.spotifyMatchedTracksDays ?? 30,
(config, value) => {
ensureConfigSection(config, "cache").spotifyMatchedTracksDays = value;
},
30,
),
CACHE_LYRICS_DAYS: numberBinding(
(config) => config?.cache?.lyricsDays ?? 14,
(config, value) => {
ensureConfigSection(config, "cache").lyricsDays = value;
},
14,
),
CACHE_GENRE_DAYS: numberBinding(
(config) => config?.cache?.genreDays ?? 30,
(config, value) => {
ensureConfigSection(config, "cache").genreDays = value;
},
30,
),
CACHE_METADATA_DAYS: numberBinding(
(config) => config?.cache?.metadataDays ?? 7,
(config, value) => {
ensureConfigSection(config, "cache").metadataDays = value;
},
7,
),
CACHE_ODESLI_LOOKUP_DAYS: numberBinding(
(config) => config?.cache?.odesliLookupDays ?? 60,
(config, value) => {
ensureConfigSection(config, "cache").odesliLookupDays = value;
},
60,
),
CACHE_PROXY_IMAGES_DAYS: numberBinding(
(config) => config?.cache?.proxyImagesDays ?? 14,
(config, value) => {
ensureConfigSection(config, "cache").proxyImagesDays = value;
},
14,
),
};
function resolveSettingKey(settingKey) {
return SETTING_KEY_ALIASES[settingKey] || settingKey;
}
function getSettingBinding(settingKey) {
const resolvedKey = resolveSettingKey(settingKey);
return { resolvedKey, binding: SETTINGS_REGISTRY[resolvedKey] };
}
function getSettingEditorValue(settingKey, inputType) {
if (inputType === "password" || !currentConfigState) {
return "";
}
const { binding } = getSettingBinding(settingKey);
if (!binding || typeof binding.get !== "function") {
return "";
}
const currentValue = binding.get(currentConfigState);
if (binding.toInput) {
return binding.toInput(currentValue);
}
if (currentValue === null || currentValue === undefined) {
return "";
}
if (typeof currentValue === "string") {
const normalized = currentValue.trim().toLowerCase();
if (normalized === "(not set)" || normalized === "-") {
return "";
}
}
return String(currentValue);
}
function normalizeSettingValueForSave(settingKey, inputType, rawValue) {
const { binding } = getSettingBinding(settingKey);
if (binding?.fromInput) {
return binding.fromInput(rawValue);
}
if (inputType === "toggle") {
return toToggleValue(rawValue);
}
return rawValue;
}
function applySettingValueLocally(settingKey, normalizedValue) {
if (!currentConfigState) {
return;
}
const { binding } = getSettingBinding(settingKey);
if (!binding || typeof binding.set !== "function") {
return;
}
binding.set(currentConfigState, normalizedValue);
UI.updateConfigUI(currentConfigState);
syncConfigUiExtras(currentConfigState);
}
function saveSettingRequiresRestart(settingKey) {
return settingKey !== "SPOTIFY_API_SESSION_COOKIE";
}
async function persistSettingUpdate(settingKey, value) {
if (settingKey === "SPOTIFY_API_SESSION_COOKIE") {
return API.setSpotifySessionCookie(value);
}
return API.updateConfigSetting(settingKey, value);
}
function setSelectToCurrentValue(selectEl, currentValue) {
if (!selectEl || currentValue === null || currentValue === undefined) {
return;
}
const normalizedCurrentValue = String(currentValue).toLowerCase();
const matchedOption = Array.from(selectEl.options).find(
(option) => option.value.toLowerCase() === normalizedCurrentValue,
);
if (matchedOption) {
selectEl.value = matchedOption.value;
}
}
function setConfigTextValue(elementId, value) {
const element = document.getElementById(elementId);
if (element) {
element.textContent = value;
}
}
export function renderCookieAge(elementId, age) {
const element = document.getElementById(elementId);
if (!element) {
return;
}
element.innerHTML = `<span class="${age.class}">${age.text}</span><br><small style="color:var(--text-secondary)">${age.detail}</small>`;
}
function hasConfiguredCookie(maskedCookieValue) {
const normalized = String(maskedCookieValue || "")
.trim()
.toLowerCase();
return (
normalized.length > 0 && normalized !== "(not set)" && normalized !== "-"
);
}
export function syncConfigUiExtras(config) {
if (!config) {
return;
}
setConfigTextValue(
"config-musicbrainz-username",
config.musicBrainz?.username || "(not set)",
);
setConfigTextValue(
"config-musicbrainz-password",
config.musicBrainz?.password || "(not set)",
);
setConfigTextValue(
"config-cache-search",
String(config.cache?.searchResultsMinutes ?? 120),
);
const configHasCookie = hasConfiguredCookie(config.spotifyApi?.sessionCookie);
const configCookieAge = formatCookieAge(
config.spotifyApi?.sessionCookieSetDate,
configHasCookie,
);
renderCookieAge("config-cookie-age", configCookieAge);
const cacheDurationRow = document.getElementById("cache-duration-row");
if (cacheDurationRow) {
cacheDurationRow.style.display =
config.library?.storageMode === "Cache" ? "" : "none";
}
const exportButton = document.getElementById("export-env-btn");
const exportDisabledHint = document.getElementById(
"export-env-disabled-hint",
);
const allowEnvExport = config.admin?.allowEnvExport === true;
if (exportButton) {
exportButton.disabled = !allowEnvExport;
exportButton.title = allowEnvExport
? ""
: "Disabled by server policy (ADMIN__ENABLE_ENV_EXPORT=false)";
}
if (exportDisabledHint) {
exportDisabledHint.style.display = allowEnvExport ? "none" : "";
}
}
function openEditSetting(envKey, label, inputType, helpText = "", options = []) {
currentEditKey = resolveSettingKey(envKey);
currentEditType = inputType;
document.getElementById("edit-setting-title").textContent = "Edit " + label;
document.getElementById("edit-setting-label").textContent = label;
const helpEl = document.getElementById("edit-setting-help");
if (helpText) {
helpEl.textContent = helpText;
helpEl.style.display = "block";
} else {
helpEl.style.display = "none";
}
const container = document.getElementById("edit-setting-input-container");
const currentValue = getSettingEditorValue(currentEditKey, inputType);
if (inputType === "toggle") {
container.innerHTML = `
<select id="edit-setting-value">
<option value="true">Enabled</option>
<option value="false">Disabled</option>
</select>
`;
setSelectToCurrentValue(
document.getElementById("edit-setting-value"),
currentValue,
);
} else if (inputType === "select") {
const optionHtml = options
.map((option) => {
const optionValue = String(option);
return `<option value="${escapeHtml(optionValue)}">${escapeHtml(optionValue)}</option>`;
})
.join("");
container.innerHTML = `
<select id="edit-setting-value">
${optionHtml}
</select>
`;
setSelectToCurrentValue(
document.getElementById("edit-setting-value"),
currentValue,
);
} else if (inputType === "password") {
container.innerHTML = `<input type="password" id="edit-setting-value" placeholder="Enter new value" autocomplete="off">`;
} else if (inputType === "number") {
container.innerHTML = `<input type="number" id="edit-setting-value" placeholder="Enter value">`;
const inputEl = document.getElementById("edit-setting-value");
if (inputEl) {
inputEl.value = currentValue;
}
} else {
container.innerHTML = `<input type="text" id="edit-setting-value" placeholder="Enter value">`;
const inputEl = document.getElementById("edit-setting-value");
if (inputEl) {
inputEl.value = currentValue;
}
}
openModal("edit-setting-modal");
}
function openEditCacheSetting(settingKey, label, helpText) {
const suffix = " (Requires restart to apply)";
const help = helpText ? `${helpText}${suffix}` : `Cache setting${suffix}`;
openEditSetting(settingKey, label, "number", help);
const inputEl = document.getElementById("edit-setting-value");
if (inputEl) {
inputEl.min = "1";
}
}
async function saveEditSetting() {
const inputEl = document.getElementById("edit-setting-value");
if (!inputEl) {
showToast("Setting input is not available", "error");
return;
}
const rawValue = inputEl.value.trim();
if (
!rawValue &&
currentEditType !== "toggle" &&
currentEditType !== "select"
) {
showToast("Value is required", "error");
return;
}
if (currentEditType === "number" && Number.isNaN(Number(rawValue))) {
showToast("Please enter a valid number", "error");
return;
}
const value = normalizeSettingValueForSave(
currentEditKey,
currentEditType,
rawValue,
);
try {
await persistSettingUpdate(currentEditKey, value);
applySettingValueLocally(currentEditKey, value);
showToast("Setting updated.", "success");
if (saveSettingRequiresRestart(currentEditKey)) {
showRestartBanner();
}
closeModal("edit-setting-modal");
await Promise.allSettled([refreshConfig(), refreshStatus()]);
} catch (error) {
showToast(error.message || "Failed to update setting", "error");
}
}
export function setCurrentConfigState(config) {
currentConfigState = config;
}
export function initSettingsEditor(options) {
refreshConfig = options.fetchConfig;
refreshStatus = options.fetchStatus;
showRestartBanner = options.showRestartBanner;
window.openEditSetting = openEditSetting;
window.openEditCacheSetting = openEditCacheSetting;
window.saveEditSetting = saveEditSetting;
return {
setCurrentConfigState,
syncConfigUiExtras,
};
}
+643 -187
View File
@@ -1,157 +1,444 @@
// UI updates and DOM manipulation
import { escapeHtml, escapeJs, capitalizeProvider } from './utils.js';
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
let rowMenuHandlersBound = false;
function bindRowMenuHandlers() {
if (rowMenuHandlersBound) {
return;
}
document.addEventListener("click", () => {
closeAllRowMenus();
});
rowMenuHandlersBound = true;
}
function closeAllRowMenus(exceptId = null) {
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
if (!exceptId || menu.id !== exceptId) {
menu.classList.remove("open");
}
});
}
function closeRowMenu(event, menuId) {
if (event) {
event.stopPropagation();
}
const menu = document.getElementById(menuId);
if (menu) {
menu.classList.remove("open");
}
}
function toggleRowMenu(event, menuId) {
if (event) {
event.stopPropagation();
}
const menu = document.getElementById(menuId);
if (!menu) {
return;
}
const isOpen = menu.classList.contains("open");
closeAllRowMenus(menuId);
menu.classList.toggle("open", !isOpen);
}
function toggleDetailsRow(event, detailsRowId) {
if (event) {
event.stopPropagation();
}
const detailsRow = document.getElementById(detailsRowId);
if (!detailsRow) {
return;
}
const isHidden = detailsRow.hasAttribute("hidden");
if (isHidden) {
detailsRow.removeAttribute("hidden");
} else {
detailsRow.setAttribute("hidden", "");
}
const isExpanded = isHidden;
document
.querySelectorAll(`[data-details-target="${detailsRowId}"]`)
.forEach((trigger) => {
trigger.setAttribute("aria-expanded", String(isExpanded));
if (trigger.classList.contains("details-trigger")) {
trigger.textContent = isExpanded ? "Hide" : "Details";
}
});
const parentRow = document.querySelector(
`tr[data-details-row="${detailsRowId}"]`,
);
if (parentRow) {
parentRow.classList.toggle("expanded", isExpanded);
}
}
function onCompactRowClick(event, detailsRowId) {
if (event.target.closest("button, a, .row-actions-menu")) {
return;
}
toggleDetailsRow(null, detailsRowId);
}
function renderGuidance(containerId, entries) {
const container = document.getElementById(containerId);
if (!container) {
return;
}
if (!entries || entries.length === 0) {
container.innerHTML = "";
return;
}
container.innerHTML = entries
.map((entry) => {
const tone =
entry.tone === "warning"
? "warning"
: entry.tone === "success"
? "success"
: "info";
const defaultIcon =
tone === "warning" ? "⚠️" : tone === "success" ? "✔" : "️";
const icon = escapeHtml(entry.icon || defaultIcon);
const title = escapeHtml(entry.title || "");
const detail = entry.detail
? `<div class="guidance-detail">${escapeHtml(entry.detail)}</div>`
: "";
return `
<div class="guidance-banner ${tone}">
<span>${icon}</span>
<div class="guidance-content">
<div class="guidance-title">${title}</div>
${detail}
</div>
</div>
`;
})
.join("");
}
function getPlaylistStatusSummary(playlist) {
const spotifyTotal = playlist.trackCount || 0;
const localCount = playlist.localTracks || 0;
const externalMatched = playlist.externalMatched || 0;
const externalMissing = playlist.externalMissing || 0;
const totalPlayable = playlist.totalPlayable || localCount + externalMatched;
const completionPct =
spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0;
let statusClass = "info";
let statusLabel = "In Progress";
if (spotifyTotal === 0) {
statusClass = "neutral";
statusLabel = "No Tracks";
} else if (externalMissing > 0) {
statusClass = "warning";
statusLabel = `${externalMissing} Missing`;
} else if (completionPct >= 100) {
statusClass = "success";
statusLabel = "Complete";
} else {
statusClass = "info";
statusLabel = `${completionPct}% Matched`;
}
const completionClass =
completionPct >= 100 ? "success" : externalMissing > 0 ? "warning" : "info";
return {
spotifyTotal,
localCount,
externalMatched,
externalMissing,
totalPlayable,
completionPct,
statusClass,
statusLabel,
completionClass,
};
}
if (typeof window !== "undefined") {
window.toggleRowMenu = toggleRowMenu;
window.closeRowMenu = closeRowMenu;
window.toggleDetailsRow = toggleDetailsRow;
window.onCompactRowClick = onCompactRowClick;
}
bindRowMenuHandlers();
export function updateStatusUI(data) {
const versionEl = document.getElementById('version');
if (versionEl) versionEl.textContent = 'v' + data.version;
const versionEl = document.getElementById("version");
if (versionEl) versionEl.textContent = "v" + data.version;
const backendTypeEl = document.getElementById('backend-type');
const backendTypeEl = document.getElementById("backend-type");
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
const jellyfinUrlEl = document.getElementById('jellyfin-url');
if (jellyfinUrlEl) jellyfinUrlEl.textContent = data.jellyfinUrl || '-';
const jellyfinUrlEl = document.getElementById("jellyfin-url");
if (jellyfinUrlEl) jellyfinUrlEl.textContent = data.jellyfinUrl || "-";
const playlistCountEl = document.getElementById('playlist-count');
if (playlistCountEl) playlistCountEl.textContent = data.spotifyImport.playlistCount;
const playlistCountEl = document.getElementById("playlist-count");
if (playlistCountEl) {
playlistCountEl.textContent = data.spotifyImport.playlistCount;
}
const cacheDurationEl = document.getElementById('cache-duration');
if (cacheDurationEl) cacheDurationEl.textContent = data.spotify.cacheDurationMinutes + ' min';
const cacheDurationEl = document.getElementById("cache-duration");
if (cacheDurationEl) {
cacheDurationEl.textContent = data.spotify.cacheDurationMinutes + " min";
}
const isrcMatchingEl = document.getElementById('isrc-matching');
if (isrcMatchingEl) isrcMatchingEl.textContent = data.spotify.preferIsrcMatching ? 'Enabled' : 'Disabled';
const isrcMatchingEl = document.getElementById("isrc-matching");
if (isrcMatchingEl) {
isrcMatchingEl.textContent = data.spotify.preferIsrcMatching
? "Enabled"
: "Disabled";
}
const spotifyUserEl = document.getElementById('spotify-user');
if (spotifyUserEl) spotifyUserEl.textContent = data.spotify.user || '-';
const spotifyUserEl = document.getElementById("spotify-user");
if (spotifyUserEl) spotifyUserEl.textContent = data.spotify.user || "-";
const statusBadge = document.getElementById('spotify-status');
const authStatus = document.getElementById('spotify-auth-status');
const statusBadge = document.getElementById("spotify-status");
const authStatus = document.getElementById("spotify-auth-status");
const guidance = [];
if (data.spotify.authStatus === 'configured') {
if (data.spotify.authStatus === "configured") {
if (statusBadge) {
statusBadge.className = 'status-badge success';
statusBadge.className = "status-badge success";
statusBadge.innerHTML = '<span class="status-dot"></span>Spotify Ready';
}
if (authStatus) {
authStatus.textContent = 'Cookie Set';
authStatus.className = 'stat-value success';
authStatus.textContent = "Cookie Set";
authStatus.className = "stat-value success";
}
} else if (data.spotify.authStatus === 'missing_cookie') {
guidance.push({
tone: "success",
title: "Spotify is connected and ready.",
detail: "Use Rebuild only when Spotify playlist content changes.",
});
} else if (data.spotify.authStatus === "missing_cookie") {
if (statusBadge) {
statusBadge.className = 'status-badge warning';
statusBadge.className = "status-badge warning";
statusBadge.innerHTML = '<span class="status-dot"></span>Cookie Missing';
}
if (authStatus) {
authStatus.textContent = 'No Cookie';
authStatus.className = 'stat-value warning';
authStatus.textContent = "No Cookie";
authStatus.className = "stat-value warning";
}
guidance.push({
tone: "warning",
title: "Spotify session cookie is missing.",
detail: "Open Configuration > Spotify API Settings and add sp_dc.",
});
} else {
if (statusBadge) {
statusBadge.className = 'status-badge';
statusBadge.className = "status-badge info";
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
}
if (authStatus) {
authStatus.textContent = 'Not Configured';
authStatus.className = 'stat-value';
authStatus.textContent = "Not Configured";
authStatus.className = "stat-value info";
}
guidance.push({
tone: "info",
title: "Spotify is not configured yet.",
detail:
"Enable Spotify API and set a valid session cookie to link playlists.",
});
}
renderGuidance("dashboard-guidance", guidance);
}
export function updatePlaylistsUI(data) {
const tbody = document.getElementById('playlist-table-body');
const tbody = document.getElementById("playlist-table-body");
const playlists = data.playlists || [];
if (data.playlists.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Jellyfin Playlists tab.</td></tr>';
if (playlists.length === 0) {
tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
renderGuidance("playlists-guidance", [
{
tone: "info",
title: "No injected playlists yet.",
detail:
"Go to Link Playlists and connect a Jellyfin playlist to Spotify.",
},
]);
return;
}
tbody.innerHTML = data.playlists.map(p => {
const spotifyTotal = p.trackCount || 0;
const localCount = p.localTracks || 0;
const externalMatched = p.externalMatched || 0;
const externalMissing = p.externalMissing || 0;
const totalPlayable = p.totalPlayable || (localCount + externalMatched);
const missingTotal = playlists.reduce(
(total, playlist) => total + (playlist.externalMissing || 0),
0,
);
const incompleteCount = playlists.reduce((total, playlist) => {
const summary = getPlaylistStatusSummary(playlist);
return total + (summary.completionPct < 100 ? 1 : 0);
}, 0);
let statsHtml = `<span class="track-count">${totalPlayable}/${spotifyTotal}</span>`;
let breakdownParts = [];
if (localCount > 0) {
breakdownParts.push(`<span style="color:var(--success)">${localCount} Local</span>`);
const guidance = [];
if (missingTotal > 0) {
const playlistsWithMissing = playlists.filter(
(playlist) => (playlist.externalMissing || 0) > 0,
).length;
guidance.push({
tone: "warning",
title: `${missingTotal} tracks still need attention across ${playlistsWithMissing} playlists.`,
detail:
"Open a row and use ... > Rematch, then map any tracks that still cannot be matched.",
});
} else if (incompleteCount > 0) {
guidance.push({
tone: "info",
title: `${incompleteCount} playlists are still syncing.`,
detail: "Use Rematch when your local library changed.",
});
} else {
guidance.push({
tone: "success",
title: "All injected playlists are fully matched.",
detail: "No action needed right now.",
});
}
if (externalMatched > 0) {
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} External</span>`);
guidance.push({
tone: "info",
title: "Use Rebuild only when Spotify playlist content changed.",
detail: "Use Rematch when your local library changed.",
});
renderGuidance("playlists-guidance", guidance);
tbody.innerHTML = playlists
.map((playlist, index) => {
const summary = getPlaylistStatusSummary(playlist);
const detailsRowId = `playlist-details-${index}`;
const menuId = `playlist-menu-${index}`;
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
const escapedPlaylistName = escapeJs(playlist.name);
const escapedSyncSchedule = escapeJs(syncSchedule);
const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
`<span class="status-pill info">${summary.externalMatched} External</span>`,
];
if (summary.externalMissing > 0) {
breakdownBadges.push(
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
);
}
if (externalMissing > 0) {
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} Missing</span>`);
}
const breakdown = breakdownParts.length > 0
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
: '';
const completionPct = spotifyTotal > 0 ? Math.round((totalPlayable / spotifyTotal) * 100) : 0;
const localPct = spotifyTotal > 0 ? Math.round((localCount / spotifyTotal) * 100) : 0;
const externalPct = spotifyTotal > 0 ? Math.round((externalMatched / spotifyTotal) * 100) : 0;
const missingPct = spotifyTotal > 0 ? Math.round((externalMissing / spotifyTotal) * 100) : 0;
const completionColor = completionPct === 100 ? 'var(--success)' : completionPct >= 80 ? 'var(--accent)' : 'var(--warning)';
const syncSchedule = p.syncSchedule || '0 8 * * *';
return `
<tr>
<td><strong>${escapeHtml(p.name)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.id || '-'}</td>
<td style="font-family:monospace;font-size:0.85rem;">
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
<td>
<div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong>
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
</div>
</td>
<td>
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
<div class="meta-text">${summary.completionPct}% playable</div>
</td>
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
<td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
<div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
onclick="toggleRowMenu(event, '${menuId}')">...</button>
<div class="row-actions-menu" id="${menuId}" role="menu">
<button onclick="closeRowMenu(event, '${menuId}'); viewTracks('${escapedPlaylistName}')">View Tracks</button>
<button onclick="closeRowMenu(event, '${menuId}'); refreshPlaylist('${escapedPlaylistName}')">Refresh</button>
<button onclick="closeRowMenu(event, '${menuId}'); matchPlaylistTracks('${escapedPlaylistName}')">Rematch</button>
<button onclick="closeRowMenu(event, '${menuId}'); clearPlaylistCache('${escapedPlaylistName}')">Rebuild</button>
<button onclick="closeRowMenu(event, '${menuId}'); editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit Schedule</button>
<hr>
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); removePlaylist('${escapedPlaylistName}')">Remove Playlist</button>
</div>
</div>
</td>
</tr>
<tr id="${detailsRowId}" class="details-row" hidden>
<td colspan="4">
<div class="details-panel">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Sync Schedule</span>
<span class="detail-value mono">
${escapeHtml(syncSchedule)}
<button onclick="editPlaylistSchedule('${escapeJs(p.name)}', '${escapeJs(syncSchedule)}')" style="margin-left:4px;font-size:0.75rem;padding:2px 6px;">Edit</button>
</td>
<td>${statsHtml}${breakdown}</td>
<td>
<div style="display:flex;align-items:center;gap:8px;">
<div style="flex:1;background:var(--bg-tertiary);height:12px;border-radius:6px;overflow:hidden;display:flex;">
<div style="width:${localPct}%;height:100%;background:#10b981;transition:width 0.3s;" title="${localCount} local tracks"></div>
<div style="width:${externalPct}%;height:100%;background:#3b82f6;transition:width 0.3s;" title="${externalMatched} external tracks"></div>
<div style="width:${missingPct}%;height:100%;background:#f59e0b;transition:width 0.3s;" title="${externalMissing} missing tracks"></div>
<button class="inline-action-link" onclick="editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit</button>
</span>
</div>
<div class="detail-item">
<span class="detail-label">Cache Age</span>
<span class="detail-value">${escapeHtml(playlist.cacheAge || "-")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Track Breakdown</span>
<span class="detail-value">${breakdownBadges.join(" ")}</span>
</div>
<div class="detail-item">
<span class="detail-label">Completion</span>
<div class="completion-bar">
<div class="completion-fill ${summary.completionClass}" style="width:${Math.max(0, Math.min(summary.completionPct, 100))}%;"></div>
</div>
</div>
</div>
<span style="font-size:0.85rem;color:${completionColor};font-weight:500;min-width:40px;">${completionPct}%</span>
</div>
</td>
<td class="cache-age">${p.cacheAge || '-'}</td>
<td>
<button onclick="matchPlaylistTracks('${escapeJs(p.name)}')" title="Re-match when local Jellyfin library changed">Rematch</button>
<button onclick="refreshPlaylist('${escapeJs(p.name)}')" title="Fetch latest from Spotify without re-matching">Refresh</button>
<button onclick="clearPlaylistCache('${escapeJs(p.name)}')" title="Rebuild when Spotify playlist changed (same as cron job)" style="background:var(--accent);border-color:var(--accent);">Rebuild</button>
<button onclick="viewTracks('${escapeJs(p.name)}')">View</button>
<button class="danger" onclick="removePlaylist('${escapeJs(p.name)}')">Remove</button>
</td>
</tr>
`;
}).join('');
})
.join("");
}
export function updateTrackMappingsUI(data) {
document.getElementById('mappings-total').textContent = data.externalCount || 0;
document.getElementById('mappings-external').textContent = data.externalCount || 0;
document.getElementById("mappings-total").textContent =
data.externalCount || 0;
document.getElementById("mappings-external").textContent =
data.externalCount || 0;
const tbody = document.getElementById('mappings-table-body');
const tbody = document.getElementById("mappings-table-body");
if (data.mappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
tbody.innerHTML =
'<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No manual mappings found.</td></tr>';
return;
}
const externalMappings = data.mappings.filter(m => m.type === 'external');
const externalMappings = data.mappings.filter((m) => m.type === "external");
if (externalMappings.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found.</td></tr>';
tbody.innerHTML =
'<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No external mappings found.</td></tr>';
return;
}
tbody.innerHTML = externalMappings.map(m => {
const typeColor = 'var(--success)';
tbody.innerHTML = externalMappings
.map((m) => {
const typeColor = "var(--success)";
const typeBadge = `<span style="display:inline-block;padding:2px 8px;border-radius:4px;font-size:0.8rem;background:${typeColor}20;color:${typeColor};font-weight:500;">external</span>`;
const targetDisplay = `<span style="font-family:monospace;font-size:0.85rem;color:var(--success);">${m.externalProvider}/${m.externalId}</span>`;
const createdDate = m.createdAt ? new Date(m.createdAt).toLocaleString() : '-';
const createdDate = m.createdAt
? new Date(m.createdAt).toLocaleString()
: "-";
return `
<tr>
@@ -165,21 +452,25 @@ export function updateTrackMappingsUI(data) {
</td>
</tr>
`;
}).join('');
})
.join("");
}
export function updateDownloadsUI(data) {
const tbody = document.getElementById('downloads-table-body');
const tbody = document.getElementById("downloads-table-body");
document.getElementById('downloads-count').textContent = data.count;
document.getElementById('downloads-size').textContent = data.totalSizeFormatted;
document.getElementById("downloads-count").textContent = data.count;
document.getElementById("downloads-size").textContent =
data.totalSizeFormatted;
if (data.count === 0) {
tbody.innerHTML = '<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
tbody.innerHTML =
'<tr><td colspan="5" style="text-align:center;color:var(--text-secondary);padding:40px;">No downloaded files found.</td></tr>';
return;
}
tbody.innerHTML = data.files.map(f => {
tbody.innerHTML = data.files
.map((f) => {
return `
<tr data-path="${escapeHtml(f.path)}">
<td><strong>${escapeHtml(f.artist)}</strong></td>
@@ -194,125 +485,281 @@ export function updateDownloadsUI(data) {
</td>
</tr>
`;
}).join('');
})
.join("");
}
export function updateConfigUI(data) {
document.getElementById('config-backend-type').textContent = data.backendType || 'Jellyfin';
document.getElementById('config-music-service').textContent = data.musicService || 'SquidWTF';
document.getElementById('config-storage-mode').textContent = data.library?.storageMode || 'Cache';
document.getElementById('config-cache-duration-hours').textContent = data.library?.cacheDurationHours || '24';
document.getElementById('config-download-mode').textContent = data.library?.downloadMode || 'Track';
document.getElementById('config-explicit-filter').textContent = data.explicitFilter || 'All';
document.getElementById('config-enable-external-playlists').textContent = data.enableExternalPlaylists ? 'Yes' : 'No';
document.getElementById('config-playlists-directory').textContent = data.playlistsDirectory || '(not set)';
document.getElementById('config-redis-enabled').textContent = data.redisEnabled ? 'Yes' : 'No';
document.getElementById('config-debug-log-requests').textContent = data.debug?.logAllRequests ? 'Enabled' : 'Disabled';
document.getElementById("config-backend-type").textContent =
data.backendType || "Jellyfin";
document.getElementById("config-music-service").textContent =
data.musicService || "SquidWTF";
document.getElementById("config-storage-mode").textContent =
data.library?.storageMode || "Cache";
document.getElementById("config-cache-duration-hours").textContent =
data.library?.cacheDurationHours || "24";
document.getElementById("config-download-mode").textContent =
data.library?.downloadMode || "Track";
document.getElementById("config-explicit-filter").textContent =
data.explicitFilter || "All";
document.getElementById("config-enable-external-playlists").textContent =
data.enableExternalPlaylists ? "Yes" : "No";
document.getElementById("config-playlists-directory").textContent =
data.playlistsDirectory || "(not set)";
document.getElementById("config-redis-enabled").textContent =
data.redisEnabled ? "Yes" : "No";
document.getElementById("config-debug-log-requests").textContent = data.debug
?.logAllRequests
? "Enabled"
: "Disabled";
document.getElementById("config-admin-bind-any-ip").textContent = data.admin
?.bindAnyIp
? "Enabled"
: "Disabled";
document.getElementById("config-admin-trusted-subnets").textContent =
data.admin?.trustedSubnets?.trim() || "(localhost only)";
document.getElementById('config-spotify-enabled').textContent = data.spotifyApi.enabled ? 'Yes' : 'No';
document.getElementById('config-spotify-cookie').textContent = data.spotifyApi.sessionCookie;
document.getElementById('config-cache-duration').textContent = data.spotifyApi.cacheDurationMinutes + ' minutes';
document.getElementById('config-isrc-matching').textContent = data.spotifyApi.preferIsrcMatching ? 'Enabled' : 'Disabled';
document.getElementById("config-spotify-enabled").textContent = data
.spotifyApi.enabled
? "Yes"
: "No";
document.getElementById("config-spotify-cookie").textContent =
data.spotifyApi.sessionCookie;
document.getElementById("config-cache-duration").textContent =
data.spotifyApi.cacheDurationMinutes + " minutes";
document.getElementById("config-isrc-matching").textContent = data.spotifyApi
.preferIsrcMatching
? "Enabled"
: "Disabled";
document.getElementById('config-deezer-arl').textContent = data.deezer.arl || '(not set)';
document.getElementById('config-deezer-quality').textContent = data.deezer.quality;
document.getElementById('config-squid-quality').textContent = data.squidWtf.quality;
document.getElementById('config-musicbrainz-enabled').textContent = data.musicBrainz.enabled ? 'Yes' : 'No';
document.getElementById('config-qobuz-token').textContent = data.qobuz.userAuthToken || '(not set)';
document.getElementById('config-qobuz-quality').textContent = data.qobuz.quality || 'FLAC';
document.getElementById('config-jellyfin-url').textContent = data.jellyfin.url || '-';
document.getElementById('config-jellyfin-api-key').textContent = data.jellyfin.apiKey;
document.getElementById('config-jellyfin-user-id').textContent = data.jellyfin.userId || '(not set)';
document.getElementById('config-jellyfin-library-id').textContent = data.jellyfin.libraryId || '-';
document.getElementById('config-download-path').textContent = data.library?.downloadPath || './downloads';
document.getElementById('config-kept-path').textContent = data.library?.keptPath || '/app/kept';
document.getElementById('config-spotify-import-enabled').textContent = data.spotifyImport?.enabled ? 'Yes' : 'No';
document.getElementById('config-matching-interval').textContent = (data.spotifyImport?.matchingIntervalHours || 24) + ' hours';
document.getElementById("config-deezer-arl").textContent =
data.deezer.arl || "(not set)";
document.getElementById("config-deezer-quality").textContent =
data.deezer.quality;
document.getElementById("config-squid-quality").textContent =
data.squidWtf.quality;
document.getElementById("config-musicbrainz-enabled").textContent = data
.musicBrainz.enabled
? "Yes"
: "No";
document.getElementById("config-qobuz-token").textContent =
data.qobuz.userAuthToken || "(not set)";
document.getElementById("config-qobuz-quality").textContent =
data.qobuz.quality || "FLAC";
document.getElementById("config-jellyfin-url").textContent =
data.jellyfin.url || "-";
document.getElementById("config-jellyfin-api-key").textContent =
data.jellyfin.apiKey;
document.getElementById("config-jellyfin-user-id").textContent =
data.jellyfin.userId || "(not set)";
document.getElementById("config-jellyfin-library-id").textContent =
data.jellyfin.libraryId || "-";
document.getElementById("config-download-path").textContent =
data.library?.downloadPath || "./downloads";
document.getElementById("config-kept-path").textContent =
data.library?.keptPath || "/app/kept";
document.getElementById("config-spotify-import-enabled").textContent = data
.spotifyImport?.enabled
? "Yes"
: "No";
document.getElementById("config-matching-interval").textContent =
(data.spotifyImport?.matchingIntervalHours || 24) + " hours";
if (data.cache) {
document.getElementById('config-cache-playlist-images').textContent = data.cache.playlistImagesHours || '168';
document.getElementById('config-cache-spotify-items').textContent = data.cache.spotifyPlaylistItemsHours || '168';
document.getElementById('config-cache-matched-tracks').textContent = data.cache.spotifyMatchedTracksDays || '30';
document.getElementById('config-cache-lyrics').textContent = data.cache.lyricsDays || '14';
document.getElementById('config-cache-genres').textContent = data.cache.genreDays || '30';
document.getElementById('config-cache-metadata').textContent = data.cache.metadataDays || '7';
document.getElementById('config-cache-odesli').textContent = data.cache.odesliLookupDays || '60';
document.getElementById('config-cache-proxy-images').textContent = data.cache.proxyImagesDays || '14';
document.getElementById("config-cache-playlist-images").textContent =
data.cache.playlistImagesHours || "168";
document.getElementById("config-cache-spotify-items").textContent =
data.cache.spotifyPlaylistItemsHours || "168";
document.getElementById("config-cache-matched-tracks").textContent =
data.cache.spotifyMatchedTracksDays || "30";
document.getElementById("config-cache-lyrics").textContent =
data.cache.lyricsDays || "14";
document.getElementById("config-cache-genres").textContent =
data.cache.genreDays || "30";
document.getElementById("config-cache-metadata").textContent =
data.cache.metadataDays || "7";
document.getElementById("config-cache-odesli").textContent =
data.cache.odesliLookupDays || "60";
document.getElementById("config-cache-proxy-images").textContent =
data.cache.proxyImagesDays || "14";
}
}
export function updateJellyfinPlaylistsUI(data) {
const tbody = document.getElementById('jellyfin-playlist-table-body');
const tbody = document.getElementById("jellyfin-playlist-table-body");
const playlists = data.playlists || [];
if (data.playlists.length === 0) {
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
if (playlists.length === 0) {
tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists found in Jellyfin</td></tr>';
renderGuidance("jellyfin-guidance", [
{
tone: "info",
title: "No Jellyfin playlists found.",
detail: "Create playlists in Jellyfin first, then link them here.",
},
]);
return;
}
tbody.innerHTML = data.playlists.map(p => {
const statusBadge = p.isConfigured
? '<span class="status-badge success"><span class="status-dot"></span>Linked</span>'
: '<span class="status-badge"><span class="status-dot"></span>Not Linked</span>';
const unlinkedCount = playlists.filter(
(playlist) => !playlist.isConfigured,
).length;
renderGuidance(
"jellyfin-guidance",
unlinkedCount > 0
? [
{
tone: "warning",
title: `${unlinkedCount} playlists are not linked to Spotify yet.`,
detail: "Open a row, then use ... > Link to Spotify.",
},
]
: [
{
tone: "success",
title: "All visible Jellyfin playlists are linked.",
detail: "No linking action needed right now.",
},
],
);
const actionButton = p.isConfigured
? `<button class="danger" onclick="unlinkPlaylist('${escapeJs(p.name)}')">Unlink</button>`
: `<button class="primary" onclick="openLinkPlaylist('${escapeJs(p.id)}', '${escapeJs(p.name)}')">Link to Spotify</button>`;
tbody.innerHTML = playlists
.map((playlist, index) => {
const detailsRowId = `jellyfin-details-${index}`;
const menuId = `jellyfin-menu-${index}`;
const localCount = playlist.localTracks || 0;
const externalCount = playlist.externalTracks || 0;
const externalAvailable = playlist.externalAvailable || 0;
const escapedId = escapeJs(playlist.id);
const escapedName = escapeJs(playlist.name);
const statusClass = playlist.isConfigured ? "success" : "info";
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
const localCount = p.localTracks || 0;
const externalCount = p.externalTracks || 0;
const externalAvail = p.externalAvailable || 0;
const actionButtons = playlist.isConfigured
? `
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); unlinkPlaylist('${escapedId}', '${escapedName}')">Unlink from Spotify</button>
`
: `
<button onclick="closeRowMenu(event, '${menuId}'); openLinkPlaylist('${escapedId}', '${escapedName}')">Link to Spotify</button>
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
`;
return `
<tr data-playlist-id="${escapeHtml(p.id)}">
<td><strong>${escapeHtml(p.name)}</strong></td>
<td class="track-count">${localCount}</td>
<td class="track-count">${externalCount > 0 ? `${externalAvail}/${externalCount}` : '-'}</td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${p.linkedSpotifyId || '-'}</td>
<td>${statusBadge}</td>
<td>${actionButton}</td>
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
<td>
<div class="name-cell">
<strong>${escapeHtml(playlist.name)}</strong>
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
</div>
</td>
<td>
<span class="track-count">${localCount + externalAvailable}</span>
<div class="meta-text">L ${localCount} E ${externalAvailable}/${externalCount}</div>
</td>
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
<td class="row-controls">
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="false"
onclick="toggleDetailsRow(event, '${detailsRowId}')">Details</button>
<div class="row-actions-wrap">
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
onclick="toggleRowMenu(event, '${menuId}')">...</button>
<div class="row-actions-menu" id="${menuId}" role="menu">
${actionButtons}
</div>
</div>
</td>
</tr>
<tr id="${detailsRowId}" class="details-row" hidden>
<td colspan="4">
<div class="details-panel">
<div class="details-grid">
<div class="detail-item">
<span class="detail-label">Local Tracks</span>
<span class="detail-value">${localCount}</span>
</div>
<div class="detail-item">
<span class="detail-label">External Tracks</span>
<span class="detail-value">${externalAvailable}/${externalCount}</span>
</div>
<div class="detail-item">
<span class="detail-label">Linked Spotify ID</span>
<span class="detail-value mono">${escapeHtml(playlist.linkedSpotifyId || "-")}</span>
</div>
</div>
</div>
</td>
</tr>
`;
}).join('');
})
.join("");
}
export function updateJellyfinUsersUI(data) {
const select = document.getElementById('jellyfin-user-select');
select.innerHTML = '<option value="">All Users</option>' +
data.users.map(u => `<option value="${u.id}">${escapeHtml(u.name)}</option>`).join('');
export function updateJellyfinUsersUI(data, preferredUserId = null) {
const select = document.getElementById("jellyfin-user-select");
if (!select) {
return;
}
const normalizedPreferredUserId = preferredUserId?.trim() || "";
select.innerHTML =
'<option value="">All Users</option>' +
data.users
.map((u) => `<option value="${u.id}">${escapeHtml(u.name)}</option>`)
.join("");
if (normalizedPreferredUserId) {
const matchingOption = Array.from(select.options).find(
(option) => option.value === normalizedPreferredUserId,
);
if (matchingOption) {
select.value = normalizedPreferredUserId;
return;
}
}
select.value = "";
}
export function updateEndpointUsageUI(data) {
document.getElementById('endpoints-total-requests').textContent = data.totalRequests?.toLocaleString() || '0';
document.getElementById('endpoints-unique-count').textContent = data.totalEndpoints?.toLocaleString() || '0';
document.getElementById("endpoints-total-requests").textContent =
data.totalRequests?.toLocaleString() || "0";
document.getElementById("endpoints-unique-count").textContent =
data.totalEndpoints?.toLocaleString() || "0";
const mostCalled = data.endpoints && data.endpoints.length > 0
const mostCalled =
data.endpoints && data.endpoints.length > 0
? data.endpoints[0].endpoint
: '-';
document.getElementById('endpoints-most-called').textContent = mostCalled;
: "-";
document.getElementById("endpoints-most-called").textContent = mostCalled;
const tbody = document.getElementById('endpoints-table-body');
const tbody = document.getElementById("endpoints-table-body");
if (!data.endpoints || data.endpoints.length === 0) {
tbody.innerHTML = '<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet.</td></tr>';
tbody.innerHTML =
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No endpoint usage data available yet.</td></tr>';
return;
}
tbody.innerHTML = data.endpoints.map((ep, index) => {
const percentage = data.totalRequests > 0
tbody.innerHTML = data.endpoints
.map((ep, index) => {
const percentage =
data.totalRequests > 0
? ((ep.count / data.totalRequests) * 100).toFixed(1)
: '0.0';
: "0.0";
let countColor = 'var(--text-primary)';
if (ep.count > 1000) countColor = 'var(--error)';
else if (ep.count > 100) countColor = 'var(--warning)';
else if (ep.count > 10) countColor = 'var(--accent)';
let countColor = "var(--text-primary)";
if (ep.count > 1000) countColor = "var(--error)";
else if (ep.count > 100) countColor = "var(--warning)";
else if (ep.count > 10) countColor = "var(--accent)";
let endpointDisplay = ep.endpoint;
if (ep.endpoint.includes('/stream')) {
if (ep.endpoint.includes("/stream")) {
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes('/Playing')) {
} else if (ep.endpoint.includes("/Playing")) {
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes('/Search')) {
} else if (ep.endpoint.includes("/Search")) {
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
} else {
endpointDisplay = escapeHtml(ep.endpoint);
@@ -326,28 +773,36 @@ export function updateEndpointUsageUI(data) {
<td style="text-align:right;color:var(--text-secondary)">${percentage}%</td>
</tr>
`;
}).join('');
})
.join("");
}
export function showErrorState(message) {
const statusBadge = document.getElementById('spotify-status');
const statusBadge = document.getElementById("spotify-status");
if (statusBadge) {
statusBadge.className = 'status-badge error';
statusBadge.className = "status-badge error";
statusBadge.innerHTML = '<span class="status-dot"></span>Connection Error';
}
const authStatus = document.getElementById('spotify-auth-status');
if (authStatus) authStatus.textContent = 'Error';
const authStatus = document.getElementById("spotify-auth-status");
if (authStatus) authStatus.textContent = "Error";
renderGuidance("dashboard-guidance", [
{
tone: "warning",
title: "Unable to load dashboard status.",
detail: "Check connectivity and refresh the page.",
},
]);
}
export function showPlaylistRebuildingIndicator(playlistName) {
const playlistCards = document.querySelectorAll('.playlist-card');
const playlistCards = document.querySelectorAll(".playlist-card");
for (const card of playlistCards) {
const nameEl = card.querySelector('h3');
const nameEl = card.querySelector("h3");
if (nameEl && nameEl.textContent.trim() === playlistName) {
const existingIndicator = card.querySelector('.rebuilding-indicator');
const existingIndicator = card.querySelector(".rebuilding-indicator");
if (!existingIndicator) {
const indicator = document.createElement('div');
indicator.className = 'rebuilding-indicator';
const indicator = document.createElement("div");
indicator.className = "rebuilding-indicator";
indicator.style.cssText = `
position: absolute;
top: 8px;
@@ -363,8 +818,9 @@ export function showPlaylistRebuildingIndicator(playlistName) {
gap: 4px;
z-index: 10;
`;
indicator.innerHTML = '<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
card.style.position = 'relative';
indicator.innerHTML =
'<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
card.style.position = "relative";
card.appendChild(indicator);
setTimeout(() => {
+7 -1
View File
@@ -7,7 +7,13 @@ export function escapeHtml(text) {
}
export function escapeJs(text) {
return text.replace(/'/g, "\\'").replace(/"/g, '\\"').replace(/\n/g, '\\n');
return String(text ?? "")
.replace(/\\/g, "\\\\")
.replace(/&/g, "&amp;")
.replace(/'/g, "\\'")
.replace(/"/g, "&quot;")
.replace(/\r/g, "\\r")
.replace(/\n/g, "\\n");
}
export function showToast(message, type = 'success', duration = 3000) {
+255 -16
View File
@@ -1,8 +1,8 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotify Track Mappings - Allstarr</title>
<style>
:root {
@@ -26,7 +26,9 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
Oxygen, Ubuntu, sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
@@ -156,17 +158,33 @@
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
padding: 6px 10px;
border-radius: 999px;
cursor: pointer;
font-size: 0.85rem;
font-size: 0.78rem;
transition: all 0.2s;
}
.action-btn:hover {
background: var(--bg-secondary);
border-color: var(--accent);
color: var(--accent);
transform: translateY(-1px);
}
.action-btn.local {
background: rgba(63, 185, 80, 0.18);
border-color: rgba(63, 185, 80, 0.5);
color: #78d487;
}
.action-btn.external {
background: rgba(210, 153, 34, 0.18);
border-color: rgba(210, 153, 34, 0.5);
color: #e8ba5d;
}
.action-btn.danger {
background: rgba(248, 81, 73, 0.16);
border-color: rgba(248, 81, 73, 0.45);
color: #f57f78;
}
.action-btn.danger:hover {
@@ -177,6 +195,7 @@
.actions-cell {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
table {
@@ -277,7 +296,9 @@
}
.mono {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
font-family:
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
monospace;
font-size: 0.85rem;
color: var(--text-secondary);
}
@@ -341,6 +362,138 @@
margin-bottom: 8px;
color: var(--text-primary);
}
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
display: none;
align-items: center;
justify-content: center;
padding: 20px;
z-index: 1000;
}
.modal-overlay.active {
display: flex;
}
.modal-card {
width: min(760px, 100%);
max-height: 90vh;
overflow: auto;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 18px;
}
.modal-card h3 {
margin-bottom: 10px;
}
.modal-track-info {
margin-bottom: 14px;
padding: 10px 12px;
border-radius: 8px;
background: var(--bg-tertiary);
border: 1px solid var(--border);
}
.modal-row {
margin-bottom: 12px;
}
.modal-row label {
display: block;
margin-bottom: 6px;
color: var(--text-secondary);
font-size: 0.86rem;
}
.modal-row input,
.modal-row select {
width: 100%;
padding: 8px 10px;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-tertiary);
color: var(--text-primary);
}
.modal-actions {
margin-top: 14px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.modal-actions .primary {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.modal-actions .primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.local-results {
max-height: 280px;
overflow: auto;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(13, 17, 23, 0.36);
}
.local-result {
display: flex;
justify-content: space-between;
gap: 10px;
padding: 10px 12px;
border-bottom: 1px solid var(--border);
cursor: pointer;
}
.local-result:last-child {
border-bottom: none;
}
.local-result:hover {
background: var(--bg-tertiary);
}
.local-result.selected {
border-left: 2px solid var(--accent);
background: rgba(88, 166, 255, 0.12);
}
.local-result .meta {
color: var(--text-secondary);
font-size: 0.82rem;
}
.toast {
position: fixed;
right: 20px;
bottom: 20px;
z-index: 1200;
padding: 10px 14px;
border-radius: 8px;
border: 1px solid var(--border);
background: var(--bg-secondary);
}
.toast.success {
border-color: rgba(63, 185, 80, 0.6);
color: var(--success);
}
.toast.error {
border-color: rgba(248, 81, 73, 0.6);
color: var(--error);
}
</style>
<script src="/spotify-mappings.js" defer></script>
</head>
@@ -348,9 +501,7 @@
<div class="container">
<header>
<h1>Spotify Track Mappings</h1>
<a href="/" class="back-link">
← Back to Dashboard
</a>
<a href="/" class="back-link"> ← Back to Dashboard </a>
</header>
<div class="stats-grid" id="stats">
@@ -392,7 +543,12 @@
<option value="auto">Auto Only</option>
</select>
</div>
<input type="text" class="search-box" placeholder="Search by title, artist, or Spotify ID..." id="search">
<input
type="text"
class="search-box"
placeholder="Search by title, artist, or Spotify ID..."
id="search"
/>
</div>
</div>
@@ -400,12 +556,95 @@
<div class="loading">Loading mappings...</div>
</div>
<div class="pagination" id="pagination" style="display: none;">
<div class="pagination" id="pagination" style="display: none">
<button id="prev-btn">← Previous</button>
<span class="page-info" id="page-info">Page 1 of 1</span>
<button id="next-btn">Next →</button>
</div>
</div>
</div>
<div class="modal-overlay" id="local-map-modal">
<div class="modal-card">
<h3>Map Spotify Track to Local Jellyfin Track</h3>
<div class="modal-track-info">
<strong id="local-map-title"></strong><br />
<span
style="color: var(--text-secondary)"
id="local-map-artist"
></span
><br />
<span class="mono" id="local-map-spotify-id"></span>
</div>
<div class="modal-row">
<label for="local-map-search"
>Search Jellyfin Library</label
>
<div style="display: flex; gap: 8px">
<input
id="local-map-search"
type="text"
placeholder="Track title or artist"
/>
<button id="local-map-search-btn" class="action-btn">
Search
</button>
</div>
</div>
<div class="local-results" id="local-map-results">
<div class="loading" style="padding: 16px">
Search to find matching local tracks.
</div>
</div>
<div class="modal-actions">
<button id="local-map-cancel-btn">Cancel</button>
<button id="local-map-save-btn" class="primary" disabled>
Save Mapping
</button>
</div>
</div>
</div>
<div class="modal-overlay" id="external-map-modal">
<div class="modal-card">
<h3>Map Spotify Track to External Provider</h3>
<div class="modal-track-info">
<strong id="external-map-title"></strong><br />
<span
style="color: var(--text-secondary)"
id="external-map-artist"
></span
><br />
<span class="mono" id="external-map-spotify-id"></span>
</div>
<div class="modal-row">
<label for="external-map-provider">Provider</label>
<select id="external-map-provider">
<option value="squidwtf">SquidWTF</option>
<option value="deezer">Deezer</option>
<option value="qobuz">Qobuz</option>
</select>
</div>
<div class="modal-row">
<label for="external-map-id">External ID</label>
<input
id="external-map-id"
type="text"
placeholder="Provider track ID or URL"
/>
</div>
<div class="modal-actions">
<button id="external-map-cancel-btn">Cancel</button>
<button id="external-map-save-btn" class="primary" disabled>
Save Mapping
</button>
</div>
</div>
</div>
</body>
</html>
+434 -160
View File
@@ -4,13 +4,35 @@
let currentPage = 1;
const pageSize = 50;
let currentFilters = {
targetType: 'all',
source: 'all',
search: '',
targetType: "all",
source: "all",
search: "",
sortBy: null,
sortOrder: 'asc'
sortOrder: "asc",
};
let localMapContext = null;
let localMapResults = [];
let localMapSelectedIndex = -1;
let externalMapContext = null;
function showToast(message, type = "success", duration = 3000) {
const toast = document.createElement("div");
toast.className = `toast ${type}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => toast.remove(), duration);
}
async function readErrorMessage(response, fallback) {
try {
const data = await response.json();
return data.error || data.message || fallback;
} catch {
return fallback;
}
}
/**
* Loads mappings from the API with current filters and pagination
*/
@@ -20,37 +42,46 @@ async function loadMappings() {
const params = new URLSearchParams({
page: currentPage,
pageSize: pageSize,
enrichMetadata: true
enrichMetadata: true,
});
if (currentFilters.targetType !== 'all') {
params.append('targetType', currentFilters.targetType);
if (currentFilters.targetType !== "all") {
params.append("targetType", currentFilters.targetType);
}
if (currentFilters.source !== 'all') {
params.append('source', currentFilters.source);
if (currentFilters.source !== "all") {
params.append("source", currentFilters.source);
}
if (currentFilters.search) {
params.append('search', currentFilters.search);
params.append("search", currentFilters.search);
}
if (currentFilters.sortBy) {
params.append('sortBy', currentFilters.sortBy);
params.append('sortOrder', currentFilters.sortOrder);
params.append("sortBy", currentFilters.sortBy);
params.append("sortOrder", currentFilters.sortOrder);
}
const response = await fetch(`/api/admin/spotify/mappings?${params}`);
if (!response.ok) throw new Error('Failed to load mappings');
if (!response.ok) {
throw new Error(
await readErrorMessage(response, "Failed to load mappings"),
);
}
const data = await response.json();
// Update stats (using PascalCase from C# API)
document.getElementById('stat-total').textContent = data.stats.TotalMappings.toLocaleString();
document.getElementById('stat-local').textContent = data.stats.LocalMappings.toLocaleString();
document.getElementById('stat-external').textContent = data.stats.ExternalMappings.toLocaleString();
document.getElementById('stat-manual').textContent = data.stats.ManualMappings.toLocaleString();
document.getElementById('stat-auto').textContent = data.stats.AutoMappings.toLocaleString();
document.getElementById("stat-total").textContent =
data.stats.TotalMappings.toLocaleString();
document.getElementById("stat-local").textContent =
data.stats.LocalMappings.toLocaleString();
document.getElementById("stat-external").textContent =
data.stats.ExternalMappings.toLocaleString();
document.getElementById("stat-manual").textContent =
data.stats.ManualMappings.toLocaleString();
document.getElementById("stat-auto").textContent =
data.stats.AutoMappings.toLocaleString();
// Update pagination
updatePagination(data.pagination);
@@ -58,9 +89,9 @@ async function loadMappings() {
// Render table
renderMappings(data.mappings);
} catch (error) {
console.error('Error loading mappings:', error);
document.getElementById('content').innerHTML =
`<div class="error">Failed to load mappings: ${error.message}</div>`;
console.error("Error loading mappings:", error);
document.getElementById("content").innerHTML =
`<div class="error">Failed to load mappings: ${escapeHtml(error.message || "Unknown error")}</div>`;
}
}
@@ -68,18 +99,19 @@ async function loadMappings() {
* Updates pagination controls
*/
function updatePagination(pagination) {
document.getElementById('page-info').textContent =
document.getElementById("page-info").textContent =
`Page ${pagination.page} of ${pagination.totalPages} (${pagination.totalCount} total)`;
document.getElementById('prev-btn').disabled = currentPage === 1;
document.getElementById('next-btn').disabled = currentPage === pagination.totalPages;
document.getElementById('pagination').style.display = 'flex';
document.getElementById("prev-btn").disabled = currentPage === 1;
document.getElementById("next-btn").disabled =
currentPage === pagination.totalPages;
document.getElementById("pagination").style.display = "flex";
}
/**
* Renders the mappings table
*/
function renderMappings(mappings) {
const content = document.getElementById('content');
const content = document.getElementById("content");
if (mappings.length === 0) {
content.innerHTML = `
@@ -91,20 +123,26 @@ function renderMappings(mappings) {
return;
}
const rows = mappings.map(mapping => {
const rows = mappings
.map((mapping) => {
const metadata = mapping.Metadata || {};
const artworkUrl = metadata.ArtworkUrl || '/placeholder.png';
const title = metadata.Title || 'Unknown Track';
const artist = metadata.Artist || 'Unknown Artist';
const targetInfo = mapping.TargetType === 'local'
const artworkUrl = metadata.ArtworkUrl || "/placeholder.png";
const title = metadata.Title || "Unknown Track";
const artist = metadata.Artist || "Unknown Artist";
const targetInfo =
mapping.TargetType === "local"
? mapping.LocalId
: `${mapping.ExternalProvider}:${mapping.ExternalId}`;
const escapedSpotifyId = escapeHtml(escapeJs(mapping.SpotifyId || ""));
const escapedTitle = escapeHtml(escapeJs(title));
const escapedArtist = escapeHtml(escapeJs(artist));
return `
<tr>
<td>
<div class="track-info">
<img src="${artworkUrl}" alt="${title}" class="track-artwork"
<img src="${artworkUrl}" alt="${escapeHtml(title)}" class="track-artwork"
onerror="this.src='/placeholder.png'">
<div class="track-details">
<div class="track-title">${escapeHtml(title)}</div>
@@ -113,54 +151,55 @@ function renderMappings(mappings) {
</div>
</td>
<td>
<span class="mono">${mapping.SpotifyId}</span>
<span class="mono">${escapeHtml(mapping.SpotifyId)}</span>
</td>
<td>
<span class="badge ${mapping.TargetType}">${mapping.TargetType}</span>
<span class="badge ${mapping.TargetType}">${escapeHtml(mapping.TargetType)}</span>
</td>
<td>
<span class="mono">${targetInfo}</span>
<span class="mono">${escapeHtml(targetInfo)}</span>
</td>
<td>
<span class="badge ${mapping.Source}">${mapping.Source}</span>
<span class="badge ${mapping.Source}">${escapeHtml(mapping.Source)}</span>
</td>
<td>
<span class="mono">${new Date(mapping.CreatedAt).toLocaleDateString()}</span>
</td>
<td>
<div class="actions-cell">
<button class="action-btn" onclick="mapToLocal('${mapping.SpotifyId}', '${escapeHtml(title)}', '${escapeHtml(artist)}')">
<button class="action-btn local" onclick="openLocalMapModal('${escapedSpotifyId}', '${escapedTitle}', '${escapedArtist}')">
Map to Local
</button>
<button class="action-btn" onclick="mapToExternal('${mapping.SpotifyId}', '${escapeHtml(title)}', '${escapeHtml(artist)}')">
<button class="action-btn external" onclick="openExternalMapModal('${escapedSpotifyId}', '${escapedTitle}', '${escapedArtist}')">
Map to External
</button>
<button class="action-btn danger" onclick="deleteMapping('${mapping.SpotifyId}', '${escapeHtml(title)}')">
<button class="action-btn danger" onclick="deleteMapping('${escapedSpotifyId}', '${escapedTitle}')">
Delete
</button>
</div>
</td>
</tr>
`;
}).join('');
})
.join("");
const sortIndicator = (column) => {
if (currentFilters.sortBy === column) {
return currentFilters.sortOrder === 'asc' ? ' ▲' : ' ▼';
return currentFilters.sortOrder === "asc" ? " ▲" : " ▼";
}
return '';
return "";
};
content.innerHTML = `
<table>
<thead>
<tr>
<th class="sortable" onclick="sortBy('title')">Track${sortIndicator('title')}</th>
<th class="sortable" onclick="sortBy('spotifyid')">Spotify ID${sortIndicator('spotifyid')}</th>
<th class="sortable" onclick="sortBy('type')">Type${sortIndicator('type')}</th>
<th class="sortable" onclick="sortBy('title')">Track${sortIndicator("title")}</th>
<th class="sortable" onclick="sortBy('spotifyid')">Spotify ID${sortIndicator("spotifyid")}</th>
<th class="sortable" onclick="sortBy('type')">Type${sortIndicator("type")}</th>
<th>Target ID</th>
<th class="sortable" onclick="sortBy('source')">Source${sortIndicator('source')}</th>
<th class="sortable" onclick="sortBy('created')">Created${sortIndicator('created')}</th>
<th class="sortable" onclick="sortBy('source')">Source${sortIndicator("source")}</th>
<th class="sortable" onclick="sortBy('created')">Created${sortIndicator("created")}</th>
<th>Actions</th>
</tr>
</thead>
@@ -177,11 +216,12 @@ function renderMappings(mappings) {
function sortBy(column) {
if (currentFilters.sortBy === column) {
// Toggle sort order
currentFilters.sortOrder = currentFilters.sortOrder === 'asc' ? 'desc' : 'asc';
currentFilters.sortOrder =
currentFilters.sortOrder === "asc" ? "desc" : "asc";
} else {
// New column, default to ascending
currentFilters.sortBy = column;
currentFilters.sortOrder = 'asc';
currentFilters.sortOrder = "asc";
}
currentPage = 1; // Reset to first page
@@ -192,137 +232,307 @@ function sortBy(column) {
* Applies filters and reloads mappings
*/
function applyFilters() {
currentFilters.targetType = document.getElementById('filter-type').value;
currentFilters.source = document.getElementById('filter-source').value;
currentFilters.search = document.getElementById('search').value;
currentFilters.targetType = document.getElementById("filter-type").value;
currentFilters.source = document.getElementById("filter-source").value;
currentFilters.search = document.getElementById("search").value;
currentPage = 1; // Reset to first page when filtering
loadMappings();
}
function toggleModal(modalId, shouldOpen) {
const modal = document.getElementById(modalId);
if (!modal) {
return;
}
if (shouldOpen) {
modal.classList.add("active");
} else {
modal.classList.remove("active");
}
}
function openLocalMapModal(spotifyId, title, artist) {
localMapContext = { spotifyId, title, artist };
localMapResults = [];
localMapSelectedIndex = -1;
document.getElementById("local-map-title").textContent = title;
document.getElementById("local-map-artist").textContent = artist;
document.getElementById("local-map-spotify-id").textContent = spotifyId;
document.getElementById("local-map-search").value =
`${title} ${artist}`.trim();
document.getElementById("local-map-save-btn").disabled = true;
document.getElementById("local-map-results").innerHTML =
'<div class="loading" style="padding:16px;">Search to find matching local tracks.</div>';
toggleModal("local-map-modal", true);
}
function closeLocalMapModal() {
toggleModal("local-map-modal", false);
localMapContext = null;
localMapResults = [];
localMapSelectedIndex = -1;
}
function normalizeLocalTrack(track) {
return {
id: track.id || track.Id || "",
title: track.title || track.name || track.Name || "Unknown Track",
artist:
track.artist ||
track.Artist ||
(Array.isArray(track.artists) ? track.artists[0] || "" : ""),
album: track.album || track.Album || "",
};
}
function renderLocalMapResults() {
const resultsContainer = document.getElementById("local-map-results");
if (!localMapResults.length) {
resultsContainer.innerHTML =
'<div class="loading" style="padding:16px;">No local tracks found for this search.</div>';
return;
}
resultsContainer.innerHTML = localMapResults
.map((track, index) => {
const selectedClass = index === localMapSelectedIndex ? " selected" : "";
return `
<div class="local-result${selectedClass}" data-index="${index}">
<div>
<strong>${escapeHtml(track.title)}</strong>
<div class="meta">${escapeHtml(track.artist || "Unknown Artist")}</div>
<div class="meta">${escapeHtml(track.album || "Unknown Album")}</div>
</div>
<div class="mono">${escapeHtml(track.id)}</div>
</div>
`;
})
.join("");
Array.from(resultsContainer.querySelectorAll(".local-result")).forEach(
(row) => {
row.addEventListener("click", () => {
const index = Number(row.getAttribute("data-index"));
localMapSelectedIndex = index;
document.getElementById("local-map-save-btn").disabled = false;
renderLocalMapResults();
});
},
);
}
async function searchLocalTracks() {
const query = document.getElementById("local-map-search").value.trim();
if (!query) {
showToast("Enter a search query first.", "error");
return;
}
const resultsContainer = document.getElementById("local-map-results");
resultsContainer.innerHTML =
'<div class="loading" style="padding:16px;">Searching local library...</div>';
try {
const response = await fetch(
`/api/admin/jellyfin/search?query=${encodeURIComponent(query)}`,
);
if (!response.ok) {
throw new Error(await readErrorMessage(response, "Search failed"));
}
const data = await response.json();
const rawTracks = Array.isArray(data.tracks)
? data.tracks
: Array.isArray(data.results)
? data.results
: [];
localMapResults = rawTracks
.map(normalizeLocalTrack)
.filter((track) => track.id);
localMapSelectedIndex = -1;
document.getElementById("local-map-save-btn").disabled = true;
renderLocalMapResults();
} catch (error) {
console.error("Local search failed:", error);
resultsContainer.innerHTML = `<div class="error" style="margin:10px;">${escapeHtml(error.message || "Search failed")}</div>`;
}
}
async function saveLocalMap() {
if (
!localMapContext ||
localMapSelectedIndex < 0 ||
localMapSelectedIndex >= localMapResults.length
) {
showToast("Select a local track first.", "error");
return;
}
const selectedTrack = localMapResults[localMapSelectedIndex];
const saveBtn = document.getElementById("local-map-save-btn");
saveBtn.disabled = true;
const originalText = saveBtn.textContent;
saveBtn.textContent = "Saving...";
try {
const response = await fetch("/api/admin/spotify/mappings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
SpotifyId: localMapContext.spotifyId,
TargetType: "local",
LocalId: selectedTrack.id,
Metadata: {
Title: selectedTrack.title || localMapContext.title,
Artist: selectedTrack.artist || localMapContext.artist,
Album: selectedTrack.album || "",
},
}),
});
if (!response.ok) {
throw new Error(
await readErrorMessage(response, "Failed to save mapping"),
);
}
closeLocalMapModal();
showToast(`Mapped to local track: ${selectedTrack.title}`, "success");
await loadMappings();
} catch (error) {
console.error("Error saving local mapping:", error);
showToast(error.message || "Failed to save local mapping", "error");
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
function openExternalMapModal(spotifyId, title, artist) {
externalMapContext = { spotifyId, title, artist };
document.getElementById("external-map-title").textContent = title;
document.getElementById("external-map-artist").textContent = artist;
document.getElementById("external-map-spotify-id").textContent = spotifyId;
document.getElementById("external-map-provider").value = "squidwtf";
document.getElementById("external-map-id").value = "";
document.getElementById("external-map-save-btn").disabled = true;
toggleModal("external-map-modal", true);
}
function closeExternalMapModal() {
toggleModal("external-map-modal", false);
externalMapContext = null;
}
function validateExternalMapForm() {
const externalId = document.getElementById("external-map-id").value.trim();
document.getElementById("external-map-save-btn").disabled = !externalId;
}
async function saveExternalMap() {
if (!externalMapContext) {
return;
}
const provider = document
.getElementById("external-map-provider")
.value.trim()
.toLowerCase();
const externalId = document.getElementById("external-map-id").value.trim();
if (!externalId) {
showToast("Enter an external ID first.", "error");
return;
}
const saveBtn = document.getElementById("external-map-save-btn");
saveBtn.disabled = true;
const originalText = saveBtn.textContent;
saveBtn.textContent = "Saving...";
try {
const response = await fetch("/api/admin/spotify/mappings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
SpotifyId: externalMapContext.spotifyId,
TargetType: "external",
ExternalProvider: provider,
ExternalId: externalId,
Metadata: {
Title: externalMapContext.title,
Artist: externalMapContext.artist,
},
}),
});
if (!response.ok) {
throw new Error(
await readErrorMessage(response, "Failed to save mapping"),
);
}
closeExternalMapModal();
showToast(`Mapped to external track: ${provider}:${externalId}`, "success");
await loadMappings();
} catch (error) {
console.error("Error saving external mapping:", error);
showToast(error.message || "Failed to save external mapping", "error");
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
/**
* Escapes HTML to prevent XSS
*/
function escapeHtml(text) {
const div = document.createElement('div');
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
}
/**
* Maps a Spotify track to a local Jellyfin track
*/
async function mapToLocal(spotifyId, title, artist) {
const query = prompt(`Search Jellyfin for "${title}" by ${artist}:`, `${title} ${artist}`);
if (!query) return;
try {
const response = await fetch(`/api/admin/jellyfin/search?query=${encodeURIComponent(query)}`);
if (!response.ok) throw new Error('Search failed');
const data = await response.json();
if (data.tracks.length === 0) {
alert('No tracks found in Jellyfin. Try a different search query.');
return;
}
// Show selection dialog
const trackList = data.tracks.map((t, i) =>
`${i + 1}. ${t.title} by ${t.artist} (${t.album || 'Unknown Album'})`
).join('\n');
const selection = prompt(`Found ${data.tracks.length} tracks:\n\n${trackList}\n\nEnter track number (1-${data.tracks.length}):`, '1');
if (!selection) return;
const index = parseInt(selection) - 1;
if (index < 0 || index >= data.tracks.length) {
alert('Invalid selection');
return;
}
const selectedTrack = data.tracks[index];
// Save mapping
const saveResponse = await fetch(`/api/admin/spotify/mappings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
SpotifyId: spotifyId,
TargetType: 'local',
LocalId: selectedTrack.id,
Metadata: {
Title: selectedTrack.title,
Artist: selectedTrack.artist,
Album: selectedTrack.album
}
})
});
if (!saveResponse.ok) throw new Error('Failed to save mapping');
alert(`✓ Mapped to local track: ${selectedTrack.title}`);
loadMappings(); // Reload
} catch (error) {
console.error('Error mapping to local:', error);
alert(`Failed to map to local: ${error.message}`);
}
}
/**
* Maps a Spotify track to an external provider track
*/
async function mapToExternal(spotifyId, title, artist) {
const provider = prompt('Enter external provider (squidwtf, deezer, qobuz):', 'squidwtf');
if (!provider) return;
const externalId = prompt(`Enter ${provider} track ID:`, '');
if (!externalId) return;
try {
const saveResponse = await fetch(`/api/admin/spotify/mappings`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
SpotifyId: spotifyId,
TargetType: 'external',
ExternalProvider: provider,
ExternalId: externalId,
Metadata: {
Title: title,
Artist: artist
}
})
});
if (!saveResponse.ok) throw new Error('Failed to save mapping');
alert(`✓ Mapped to external track: ${provider}:${externalId}`);
loadMappings(); // Reload
} catch (error) {
console.error('Error mapping to external:', error);
alert(`Failed to map to external: ${error.message}`);
}
function escapeJs(text) {
return String(text)
.replace(/\\/g, "\\\\")
.replace(/'/g, "\\'")
.replace(/\"/g, '\\"')
.replace(/\n/g, "\\n");
}
/**
* Deletes a Spotify track mapping
*/
async function deleteMapping(spotifyId, title) {
if (!confirm(`Delete mapping for "${title}"?`)) return;
if (!confirm(`Delete mapping for "${title}"?`)) {
return;
}
try {
const response = await fetch(`/api/admin/spotify/mappings/${spotifyId}`, {
method: 'DELETE'
method: "DELETE",
});
if (!response.ok) throw new Error('Failed to delete mapping');
if (!response.ok) {
throw new Error(
await readErrorMessage(response, "Failed to delete mapping"),
);
}
alert(`Deleted mapping for "${title}"`);
loadMappings(); // Reload
showToast(`Deleted mapping for "${title}"`, "success");
await loadMappings();
} catch (error) {
console.error('Error deleting mapping:', error);
alert(`Failed to delete mapping: ${error.message}`);
console.error("Error deleting mapping:", error);
showToast(error.message || "Failed to delete mapping", "error");
}
}
@@ -332,31 +542,95 @@ async function deleteMapping(spotifyId, title) {
function initializeEventListeners() {
// Search with debounce
let searchTimeout;
document.getElementById('search').addEventListener('input', () => {
document.getElementById("search").addEventListener("input", () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 300);
});
// Filter dropdowns
document.getElementById('filter-type').addEventListener('change', applyFilters);
document.getElementById('filter-source').addEventListener('change', applyFilters);
document
.getElementById("filter-type")
.addEventListener("change", applyFilters);
document
.getElementById("filter-source")
.addEventListener("change", applyFilters);
// Pagination
document.getElementById('prev-btn').addEventListener('click', () => {
document.getElementById("prev-btn").addEventListener("click", () => {
if (currentPage > 1) {
currentPage--;
loadMappings();
}
});
document.getElementById('next-btn').addEventListener('click', () => {
document.getElementById("next-btn").addEventListener("click", () => {
currentPage++;
loadMappings();
});
// Local map modal
document
.getElementById("local-map-cancel-btn")
.addEventListener("click", closeLocalMapModal);
document
.getElementById("local-map-search-btn")
.addEventListener("click", searchLocalTracks);
document
.getElementById("local-map-save-btn")
.addEventListener("click", saveLocalMap);
document
.getElementById("local-map-search")
.addEventListener("keydown", (event) => {
if (event.key === "Enter") {
event.preventDefault();
searchLocalTracks();
}
});
// External map modal
document
.getElementById("external-map-cancel-btn")
.addEventListener("click", closeExternalMapModal);
document
.getElementById("external-map-id")
.addEventListener("input", validateExternalMapForm);
document
.getElementById("external-map-provider")
.addEventListener("change", validateExternalMapForm);
document
.getElementById("external-map-save-btn")
.addEventListener("click", saveExternalMap);
// Backdrop close
document
.getElementById("local-map-modal")
.addEventListener("click", (event) => {
if (event.target.id === "local-map-modal") {
closeLocalMapModal();
}
});
document
.getElementById("external-map-modal")
.addEventListener("click", (event) => {
if (event.target.id === "external-map-modal") {
closeExternalMapModal();
}
});
// Escape to close modals
document.addEventListener("keydown", (event) => {
if (event.key !== "Escape") {
return;
}
closeLocalMapModal();
closeExternalMapModal();
});
}
// Initialize on page load
document.addEventListener('DOMContentLoaded', () => {
document.addEventListener("DOMContentLoaded", () => {
initializeEventListeners();
loadMappings();
});
+493 -18
View File
@@ -19,13 +19,56 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, Ubuntu,
sans-serif;
background: var(--bg-primary);
color: var(--text-primary);
line-height: 1.5;
min-height: 100vh;
}
.auth-gate {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.auth-card {
width: min(420px, 100%);
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 10px;
padding: 24px;
}
.auth-card h2 {
margin-bottom: 8px;
}
.auth-card p {
color: var(--text-secondary);
margin-bottom: 16px;
}
.auth-card form {
display: grid;
gap: 10px;
}
.auth-card label {
color: var(--text-secondary);
font-size: 0.85rem;
}
.auth-error {
min-height: 20px;
color: var(--error);
font-size: 0.85rem;
}
.container {
max-width: 1200px;
margin: 0 auto;
@@ -41,6 +84,17 @@ header {
margin-bottom: 30px;
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
}
.auth-user {
color: var(--text-secondary);
font-size: 0.85rem;
}
h1 {
font-size: 1.8rem;
font-weight: 600;
@@ -65,10 +119,22 @@ h1 .version {
font-weight: 500;
}
.status-badge.success { background: rgba(63, 185, 80, 0.2); color: var(--success); }
.status-badge.warning { background: rgba(210, 153, 34, 0.2); color: var(--warning); }
.status-badge.error { background: rgba(248, 81, 73, 0.2); color: var(--error); }
.status-badge.info { background: rgba(59, 130, 246, 0.2); color: #3b82f6; }
.status-badge.success {
background: rgba(63, 185, 80, 0.2);
color: var(--success);
}
.status-badge.warning {
background: rgba(210, 153, 34, 0.2);
color: var(--warning);
}
.status-badge.error {
background: rgba(248, 81, 73, 0.2);
color: var(--error);
}
.status-badge.info {
background: rgba(59, 130, 246, 0.2);
color: #3b82f6;
}
.status-dot {
width: 8px;
@@ -124,9 +190,24 @@ h1 .version {
font-weight: 500;
}
.stat-value.success { color: var(--success); }
.stat-value.warning { color: var(--warning); }
.stat-value.error { color: var(--error); }
.stat-value.success {
color: var(--success);
}
.stat-value.warning {
color: var(--warning);
}
.stat-value.error {
color: var(--error);
}
.stat-value.info {
color: var(--accent);
}
.card-actions-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
button {
background: var(--bg-tertiary);
@@ -153,6 +234,11 @@ button.primary:hover {
background: var(--accent-hover);
}
button.secondary {
background: transparent;
border-color: var(--border);
}
button.danger {
background: rgba(248, 81, 73, 0.15);
border-color: var(--error);
@@ -163,6 +249,41 @@ button.danger:hover {
background: rgba(248, 81, 73, 0.3);
}
.map-action-btn {
font-size: 0.76rem;
line-height: 1.1;
padding: 4px 8px;
margin-left: 6px;
border-radius: 999px;
border: 1px solid var(--border);
}
.map-action-btn:hover {
transform: translateY(-1px);
}
.map-action-search {
background: rgba(88, 166, 255, 0.18);
border-color: rgba(88, 166, 255, 0.5);
color: #9ecbff;
}
.map-action-local {
background: rgba(63, 185, 80, 0.18);
border-color: rgba(63, 185, 80, 0.5);
color: #78d487;
}
.map-action-external {
background: rgba(210, 153, 34, 0.18);
border-color: rgba(210, 153, 34, 0.5);
color: #e8ba5d;
}
.mapping-actions-cell {
white-space: nowrap;
}
.playlist-table {
width: 100%;
border-collapse: collapse;
@@ -187,7 +308,8 @@ button.danger:hover {
.playlist-table .track-count {
font-family: monospace;
color: var(--accent);
color: var(--text-primary);
font-weight: 600;
}
.playlist-table .cache-age {
@@ -195,13 +317,299 @@ button.danger:hover {
font-size: 0.85rem;
}
.compact-row {
cursor: pointer;
}
.compact-row .name-cell {
display: grid;
gap: 2px;
}
.meta-text {
color: var(--text-secondary);
font-size: 0.8rem;
}
.meta-text.subtle-mono {
font-family:
ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", monospace;
}
.status-pill {
display: inline-flex;
align-items: center;
border: 1px solid var(--border);
border-radius: 999px;
padding: 2px 10px;
font-size: 0.78rem;
font-weight: 600;
white-space: nowrap;
}
.status-pill.success {
border-color: rgba(63, 185, 80, 0.55);
color: var(--success);
background: rgba(63, 185, 80, 0.14);
}
.status-pill.warning {
border-color: rgba(210, 153, 34, 0.55);
color: var(--warning);
background: rgba(210, 153, 34, 0.14);
}
.status-pill.info {
border-color: rgba(88, 166, 255, 0.55);
color: var(--accent);
background: rgba(88, 166, 255, 0.12);
}
.status-pill.neutral {
border-color: var(--border);
color: var(--text-secondary);
background: transparent;
}
.row-controls {
width: 120px;
text-align: right;
white-space: nowrap;
position: relative;
}
.icon-btn {
padding: 4px 10px;
font-size: 0.78rem;
min-width: 62px;
}
.icon-btn.menu-trigger {
min-width: 36px;
font-weight: 700;
letter-spacing: 1px;
}
.row-actions-wrap {
position: relative;
display: inline-block;
margin-left: 6px;
}
.row-actions-menu {
position: absolute;
right: 0;
top: calc(100% + 6px);
z-index: 40;
display: none;
min-width: 190px;
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
.row-actions-menu.open {
display: grid;
}
.row-actions-menu button {
width: 100%;
text-align: left;
border: none;
background: transparent;
padding: 8px 10px;
font-size: 0.85rem;
}
.row-actions-menu button:hover {
background: var(--bg-tertiary);
}
.row-actions-menu hr {
border: 0;
border-top: 1px solid var(--border);
margin: 4px 0;
}
.row-actions-menu .danger-item {
color: var(--error);
}
.details-row:hover td {
background: transparent;
}
.details-row[hidden] {
display: none;
}
.details-panel {
margin: 10px 0 14px;
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border);
background: rgba(13, 17, 23, 0.35);
}
.details-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px 14px;
}
.detail-item {
display: grid;
gap: 4px;
min-width: 0;
}
.detail-label {
font-size: 0.74rem;
color: var(--text-secondary);
text-transform: uppercase;
letter-spacing: 0.04em;
}
.detail-value {
font-size: 0.9rem;
}
.detail-value.mono {
font-family:
ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono", monospace;
}
.completion-bar {
width: 100%;
max-width: 260px;
height: 10px;
border-radius: 999px;
overflow: hidden;
background: var(--bg-tertiary);
}
.completion-fill {
height: 100%;
background: var(--accent);
}
.completion-fill.success {
background: var(--success);
}
.completion-fill.warning {
background: var(--warning);
}
.inline-action-link {
margin-left: 6px;
padding: 2px 8px;
font-size: 0.75rem;
}
.guidance-stack {
display: grid;
gap: 10px;
margin-bottom: 14px;
}
.guidance-banner {
display: flex;
align-items: flex-start;
gap: 8px;
border-radius: 8px;
border: 1px solid var(--border);
padding: 10px 12px;
font-size: 0.88rem;
}
.guidance-banner.compact {
margin-bottom: 12px;
}
.guidance-banner.success {
border-color: rgba(63, 185, 80, 0.45);
background: rgba(63, 185, 80, 0.11);
color: var(--success);
}
.guidance-banner.warning {
border-color: rgba(210, 153, 34, 0.6);
background: rgba(210, 153, 34, 0.14);
color: var(--warning);
}
.guidance-banner.info {
border-color: rgba(88, 166, 255, 0.5);
background: rgba(88, 166, 255, 0.1);
color: var(--accent);
}
.guidance-banner .guidance-content {
color: var(--text-primary);
}
.guidance-banner .guidance-title {
font-weight: 600;
}
.guidance-banner .guidance-detail {
margin-top: 2px;
color: var(--text-secondary);
}
.matching-progress-banner {
margin-bottom: 16px;
}
.advanced-section {
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(13, 17, 23, 0.2);
}
.advanced-section summary {
list-style: none;
cursor: pointer;
padding: 12px 14px;
font-weight: 600;
}
.advanced-section summary::-webkit-details-marker {
display: none;
}
.advanced-section summary::before {
content: "▸";
margin-right: 8px;
color: var(--text-secondary);
}
.advanced-section[open] summary::before {
content: "▾";
}
.advanced-section-content {
padding: 0 14px 14px;
}
.advanced-guide-list {
display: grid;
gap: 8px;
color: var(--text-secondary);
font-size: 0.9rem;
}
.input-group {
display: flex;
gap: 8px;
margin-top: 16px;
}
input, select {
input,
select {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
@@ -210,7 +618,8 @@ input, select {
font-size: 0.9rem;
}
input:focus, select:focus {
input:focus,
select:focus {
outline: none;
border-color: var(--accent);
}
@@ -278,14 +687,28 @@ input::placeholder {
animation: slideIn 0.3s ease;
}
.toast.success { border-color: var(--success); }
.toast.error { border-color: var(--error); }
.toast.warning { border-color: var(--warning); }
.toast.info { border-color: var(--accent); }
.toast.success {
border-color: var(--success);
}
.toast.error {
border-color: var(--error);
}
.toast.warning {
border-color: var(--warning);
}
.toast.info {
border-color: var(--accent);
}
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
.restart-overlay {
@@ -484,6 +907,56 @@ input::placeholder {
font-size: 0.8rem;
}
.external-result {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 10px;
padding-left: 14px;
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 8px;
cursor: pointer;
background: var(--bg-primary);
position: relative;
transition: border-color 0.15s ease, background-color 0.15s ease,
box-shadow 0.15s ease;
}
.external-result::before {
content: "";
position: absolute;
top: 6px;
bottom: 6px;
left: 0;
width: 4px;
border-radius: 4px;
background: transparent;
transition: background-color 0.15s ease;
}
.external-result:hover {
background: var(--bg-tertiary);
border-color: var(--accent-hover);
}
.external-result.selected {
background: rgba(88, 166, 255, 0.14);
border-color: var(--accent);
box-shadow: 0 0 0 1px rgba(88, 166, 255, 0.35);
}
.external-result.selected::before {
background: var(--accent);
}
.external-result-id {
font-family: monospace;
font-size: 0.75rem;
color: var(--text-secondary);
text-align: right;
}
.loading {
display: flex;
align-items: center;
@@ -503,5 +976,7 @@ input::placeholder {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
+72
View File
@@ -0,0 +1,72 @@
# Admin UI Modularity Guide
This document defines the modular JavaScript architecture for `allstarr/wwwroot/js` and the guardrails future agents should follow.
## Goals
- Keep admin UI code split by feature and responsibility.
- Centralize request handling and async UI action handling.
- Minimize `window.*` globals to only those required by inline HTML handlers.
- Keep polling and refresh lifecycle in one place.
## Current Module Map
- `main.js`: Composition root only. Wires modules, shared globals, and bootstrap lifecycle.
- `auth-session.js`: Auth/session state, role-based scope, login/logout wiring, 401 recovery handling.
- `dashboard-data.js`: Polling lifecycle + data loading/render orchestration.
- `operations.js`: Shared `runAction` helper + non-domain operational actions.
- `settings-editor.js`: Settings registry, modal editor rendering, local config state sync.
- `playlist-admin.js`: Playlist linking and admin CRUD.
- `scrobbling-admin.js`: Scrobbling configuration actions and UI state updates.
- `api.js`: API transport layer wrappers and endpoint functions.
## Required Patterns
### 1) Request Layer Rules
- All HTTP requests must go through `api.js`.
- `api.js` owns low-level `fetch` usage (`requestJson`, `requestBlob`, `requestOptionalJson`).
- Feature modules should call `API.*` methods and avoid direct `fetch`.
### 2) Action Flow Rules
- UI actions with toast/error handling should use `runAction(...)` from `operations.js`.
- If an action always reloads scrobbling UI state, use `runScrobblingAction(...)` in `scrobbling-admin.js`.
### 3) Polling Rules
- Polling timers must stay in `dashboard-data.js`.
- New background refresh loops should be added to existing refresh lifecycle, not separate timers in other modules.
### 4) Global Surface Rules
- Expose only `window.*` members needed by current inline HTML (`onclick`, `onchange`, `oninput`) or legacy UI templates.
- Keep new feature logic module-scoped and expose narrow entry points in `init*` functions.
## Adding New Admin UI Behavior
1. Add/extend endpoint method in `api.js`.
2. Implement feature logic in the relevant module (`*-admin.js`, `dashboard-data.js`, etc.).
3. Prefer `runAction(...)` for async UI operations.
4. Export/init through module `init*` only.
5. Wire it from `main.js` if cross-module dependencies are needed.
6. Add/adjust tests in `allstarr.Tests/JavaScriptSyntaxTests.cs`.
## Tests That Enforce This Architecture
`allstarr.Tests/JavaScriptSyntaxTests.cs` includes checks for:
- Module existence and syntax.
- Coordinator bootstrap expectations.
- API request centralization (`fetch` calls constrained to helper functions in `api.js`).
- Scrobbling module prohibition on direct `fetch`.
## Fast Validation Commands
```bash
# Full suite
dotnet test allstarr.sln
# JS architecture/syntax focused
dotnet test allstarr.Tests/allstarr.Tests.csproj --filter JavaScriptSyntaxTests
```
+12
View File
@@ -68,6 +68,9 @@ services:
- ASPNETCORE_ENVIRONMENT=Production
# Backend type: Subsonic or Jellyfin (default: Subsonic)
- Backend__Type=${BACKEND_TYPE:-Subsonic}
# Admin network controls (port 5275)
- Admin__BindAnyIp=${ADMIN_BIND_ANY_IP:-false}
- Admin__TrustedSubnets=${ADMIN_TRUSTED_SUBNETS:-}
# ===== REDIS CACHE =====
- Redis__ConnectionString=redis:6379
@@ -92,6 +95,7 @@ services:
- Subsonic__StorageMode=${STORAGE_MODE:-Permanent}
- Subsonic__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
- Subsonic__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
- Subsonic__PlaylistsDirectory=${PLAYLISTS_DIRECTORY:-playlists}
# ===== JELLYFIN BACKEND =====
- Jellyfin__Url=${JELLYFIN_URL:-http://localhost:8096}
@@ -105,12 +109,14 @@ services:
- Jellyfin__StorageMode=${STORAGE_MODE:-Permanent}
- Jellyfin__CacheDurationHours=${CACHE_DURATION_HOURS:-1}
- Jellyfin__EnableExternalPlaylists=${ENABLE_EXTERNAL_PLAYLISTS:-true}
- Jellyfin__PlaylistsDirectory=${PLAYLISTS_DIRECTORY:-playlists}
# ===== SPOTIFY PLAYLIST INJECTION (JELLYFIN ONLY) =====
- SpotifyImport__Enabled=${SPOTIFY_IMPORT_ENABLED:-false}
- SpotifyImport__SyncStartHour=${SPOTIFY_IMPORT_SYNC_START_HOUR:-16}
- SpotifyImport__SyncStartMinute=${SPOTIFY_IMPORT_SYNC_START_MINUTE:-15}
- SpotifyImport__SyncWindowHours=${SPOTIFY_IMPORT_SYNC_WINDOW_HOURS:-2}
- SpotifyImport__MatchingIntervalHours=${SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS:-24}
- SpotifyImport__Playlists=${SPOTIFY_IMPORT_PLAYLISTS:-}
- SpotifyImport__PlaylistIds=${SPOTIFY_IMPORT_PLAYLIST_IDS:-}
- SpotifyImport__PlaylistNames=${SPOTIFY_IMPORT_PLAYLIST_NAMES:-}
@@ -128,6 +134,8 @@ services:
# ===== SCROBBLING (LAST.FM, LISTENBRAINZ) =====
- Scrobbling__Enabled=${SCROBBLING_ENABLED:-false}
- Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false}
- Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false}
- Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false}
- Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
@@ -139,6 +147,7 @@ services:
# ===== DEBUG SETTINGS =====
- Debug__LogAllRequests=${DEBUG_LOG_ALL_REQUESTS:-false}
- Debug__RedactSensitiveRequestValues=${DEBUG_REDACT_SENSITIVE_REQUEST_VALUES:-false}
# ===== SHARED =====
- Library__DownloadPath=/app/downloads
@@ -149,6 +158,9 @@ services:
- Qobuz__UserAuthToken=${QOBUZ_USER_AUTH_TOKEN:-}
- Qobuz__UserId=${QOBUZ_USER_ID:-}
- Qobuz__Quality=${QOBUZ_QUALITY:-FLAC}
- MusicBrainz__Enabled=${MUSICBRAINZ_ENABLED:-true}
- MusicBrainz__Username=${MUSICBRAINZ_USERNAME:-}
- MusicBrainz__Password=${MUSICBRAINZ_PASSWORD:-}
volumes:
- ${DOWNLOAD_PATH:-./downloads}:/app/downloads
- ${KEPT_PATH:-./kept}:/app/kept