Compare commits

..

7 Commits

Author SHA1 Message Date
joshpatra c9344a6832 v1.2.1-beta.1: Massive WebUI cleanup, Fixed/Stabilized scrobbling (WIP), 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 2026-02-26 10:58:56 -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 dfac3c4d60 Update issue templates
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-21 00:24:59 -05:00
joshpatra ec994773dd Merge branch 'main' into beta 2026-02-20 20:02:55 -05:00
joshpatra f3091624ec v1.1.3: version bump, removed duplicate method; this is why we run tests... 2026-02-20 20:02:27 -05:00
joshpatra 375b7c6909 v1.1.1: SCROBBLING, fixed and rewrote caching, refactored and fixed WebUI, fixed logs, fixed cron scheduling bugs, hardened security, added Global Mappings, made the proxy more 'transparent', added playlists from Tidal to search
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-20 18:59:37 -05:00
joshpatra 40338ce25f v1.0.0: Lots of WebUI fixes, API fixes, refactored all of caching, general bug fixes, redid all log messages
Docker Build & Push / build-and-test (push) Has been cancelled
Docker Build & Push / docker (push) Has been cancelled
2026-02-11 23:24:40 -05:00
123 changed files with 17764 additions and 7159 deletions
+31
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
+48
View File
@@ -0,0 +1,48 @@
---
name: Bug Report
about: Create a report to help us fix stuff
title: "[BUG] Issue with ..."
labels: bug
assignees: SoPat712
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Details (please complete the following information):**
- Version [e.g. v1.1.3]
- Client [e.g. Feishin]
<details>
<summary>Please paste your docker-compose.yaml in between the tickmarks</summary>
```yaml
```
</details>
<details>
<summary>Please paste your .env file REDACTED OF ALL PASSWORDS in between the tickmarks:</summary>
```env
```
</details>
**Additional context**
Add any other context about the problem here.
+20
View File
@@ -0,0 +1,20 @@
---
name: Feature Request
about: Suggest an idea for this project
title: "[FEATURE] Please add ..."
labels: enhancement
assignees: SoPat712
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.
+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()
{
@@ -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);
}
}
@@ -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);
}
}
}
+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.2.1";
}
+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; }
}
}
+305 -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,113 @@ 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))
},
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 +175,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", 120)),
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>
@@ -254,6 +435,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 +542,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 +629,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 +709,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 +720,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 +756,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 +787,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 +797,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 +847,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>
+210 -8
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);
@@ -93,11 +95,22 @@ public partial class JellyfinController
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,89 @@ public partial class JellyfinController
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
}
/// <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();
+256 -71
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";
return Unauthorized(new { error = "Authentication required" });
}
if (!string.IsNullOrEmpty(userId))
var requestedUserId = string.IsNullOrWhiteSpace(userId) ? null : userId.Trim();
if (!session.IsAdministrator)
{
if (!string.IsNullOrWhiteSpace(requestedUserId) && !UserIdsEqual(requestedUserId, session.UserId))
{
url += $"&UserId={userId}";
return StatusCode(StatusCodes.Status403Forbidden,
new { error = "You can only view your own Jellyfin playlists" });
}
var request = _helperService.CreateJellyfinRequest(HttpMethod.Get, url);
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" });
}
return BadRequest(new { error = "This Jellyfin playlist is already linked by another user" });
}
// Check if already configured by name
var existingByName = currentPlaylists
.FirstOrDefault(p => p.Name.Equals(request.Name, StringComparison.OrdinalIgnoreCase));
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (existingByName != null)
{
return BadRequest(new { error = $"Playlist name '{request.Name}' is already configured" });
return BadRequest(new { error = $"Playlist name '{playlistName}' is already configured" });
}
// Add the playlist to configuration
currentPlaylists.Add(new SpotifyPlaylistConfig
{
Name = request.Name,
Id = request.SpotifyPlaylistId,
Name = playlistName!,
Id = request.SpotifyPlaylistId.Trim(),
JellyfinId = jellyfinPlaylistId,
LocalTracksPosition = LocalTracksPosition.First, // Use Spotify order
SyncSchedule = request.SyncSchedule ?? "0 8 * * *" // Default to daily 8 AM
LocalTracksPosition = LocalTracksPosition.First,
SyncSchedule = syncSchedule,
UserId = ownerUserId
});
// Convert to JSON format for env var: [["Name","SpotifyId","JellyfinId","first|last","cronSchedule"],...]
var playlistsJson = JsonSerializer.Serialize(
currentPlaylists.Select(p => new[] {
p.Name,
p.Id,
p.JellyfinId,
p.LocalTracksPosition.ToString().ToLower(),
p.SyncSchedule ?? "0 8 * * *"
}).ToArray()
);
// Update .env file
var playlistsJson = AdminHelperService.SerializePlaylistsForEnv(currentPlaylists);
var updateRequest = new ConfigUpdateRequest
{
Updates = new Dictionary<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,5 @@
using System.Text.Json;
using System.Globalization;
using allstarr.Models.Scrobbling;
using Microsoft.AspNetCore.Mvc;
@@ -24,15 +25,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,7 +50,7 @@ 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);
@@ -104,23 +105,18 @@ public partial class JellyfinController
string? itemName = null;
long? positionTicks = 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);
// 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 +134,46 @@ 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);
}
}
}
// 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 +181,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 +225,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
@@ -230,6 +261,12 @@ public partial class JellyfinController
});
}
if (sessionReady)
{
_sessionManager.UpdateActivity(deviceId!);
_sessionManager.UpdatePlayingItem(deviceId!, itemId, positionTicks);
}
return NoContent();
}
@@ -238,7 +275,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)
{
@@ -278,7 +315,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 +346,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 +362,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 +374,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 +433,28 @@ 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;
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
itemId = ParsePlaybackItemId(doc.RootElement);
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
if (string.IsNullOrWhiteSpace(itemId))
{
itemId = itemIdProp.GetString();
_logger.LogWarning(
"⚠️ Playback progress missing item id after parsing. Payload keys: {Keys}",
string.Join(", ", doc.RootElement.EnumerateObject().Select(p => p.Name)));
}
if (doc.RootElement.TryGetProperty("PositionTicks", out var posProp))
{
positionTicks = posProp.GetInt64();
}
// Only update session for local tracks
// Scrobble progress check (both local and external)
if (!string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId))
{
var (isExt, _, _) = _localLibraryService.ParseSongId(itemId);
if (!isExt)
{
_sessionManager.UpdateActivity(deviceId);
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
}
// Scrobble progress check (both local and external)
if (_scrobblingOrchestrator != null && _scrobblingHelper != null && positionTicks.HasValue)
{
_ = Task.Run(async () =>
@@ -446,6 +485,11 @@ public partial class JellyfinController
durationSeconds: song.Duration
);
}
else
{
_logger.LogDebug("Could not fetch metadata for external track progress: {Provider}/{ExternalId}",
provider, externalId);
}
}
else
{
@@ -457,8 +501,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 +518,105 @@ 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 = !string.IsNullOrWhiteSpace(previousItemId) &&
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
var inferredStart = !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)
{
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);
if (track != null)
{
await _scrobblingOrchestrator.OnPlaybackStartAsync(deviceId, track);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Failed to scrobble inferred external track playback start");
}
});
}
}
}
// For external tracks, report progress with ghost UUID to Jellyfin
var ghostUuid = GenerateUuidFromString(itemId);
@@ -510,6 +652,99 @@ 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 = !string.IsNullOrWhiteSpace(previousItemId) &&
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
var inferredStart = !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)
{
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");
}
});
}
}
// 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 +758,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 +777,274 @@ 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)
{
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.LocalTracksEnabled || _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 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>
@@ -560,35 +1063,48 @@ public partial class JellyfinController
Request.Body.Position = 0;
_logger.LogInformation("⏹️ Playback STOPPED reported");
_logger.LogDebug("📤 Sending playback stop body: {Body}", body);
_logger.LogInformation("⏹️ Playback STOPPED reported");
_logger.LogDebug("📤 Sending playback stop body ({BodyLength} bytes)", body.Length);
// Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body);
string? itemId = null;
string? itemName = null;
long? positionTicks = null;
string? deviceId = null;
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);
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))
@@ -750,7 +1266,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 +1279,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 +1299,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 +1307,10 @@ public partial class JellyfinController
if (statusCode == 204 || statusCode == 200)
{
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
if (!string.IsNullOrWhiteSpace(deviceId))
{
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
}
}
else if (statusCode == 401)
{
@@ -862,11 +1372,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 +1394,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 +1423,121 @@ 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? 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();
+625 -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);
}
}
@@ -207,17 +241,11 @@ public partial class JellyfinController
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
@@ -247,15 +275,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 +301,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 +310,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 +366,80 @@ 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);
_logger.LogDebug(
"Merged and sorted results by score: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
mergedSongs.Count, mergedAlbumsAndPlaylists.Count, mergedArtists.Count);
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
allSongs.Count, mergedAlbumsAndPlaylists.Count, allArtists.Count);
// Pre-fetch lyrics for top 3 songs in background (don't await)
if (_lrclibService != null && mergedSongs.Count > 0)
// Pre-fetch lyrics for top 3 LOCAL songs in background (don't await)
// Skip external tracks to avoid spamming LRCLIB with malformed titles
if (_lrclibService != null && allSongs.Count > 0)
{
_ = Task.Run(async () =>
{
try
{
var top3 = mergedSongs.Take(3).ToList();
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} search results", top3.Count);
foreach (var songItem in top3)
var top3Local = allSongs.Where(IsLocalItem).Take(3).ToList();
if (top3Local.Count > 0)
{
if (songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl &&
songItem.TryGetValue("Artists", out var artistsObj) &&
artistsObj is JsonElement artistsEl &&
artistsEl.GetArrayLength() > 0)
_logger.LogDebug("🎵 Pre-fetching lyrics for top {Count} LOCAL search results", top3Local.Count);
foreach (var songItem in top3Local)
{
var title = nameEl.GetString() ?? "";
var artist = artistsEl[0].GetString() ?? "";
var title = songItem.TryGetValue("Name", out var nameObj) && nameObj is JsonElement nameEl ? nameEl.GetString() ?? "" : songItem["Name"]?.ToString() ?? "";
var artist = "";
if (songItem.TryGetValue("Artists", out var artistsObj) && artistsObj is JsonElement artistsEl && artistsEl.GetArrayLength() > 0)
{
artist = artistsEl[0].GetString() ?? "";
}
else if (songItem.TryGetValue("Artists", out var artistsListObj) && artistsListObj is object[] artistsList && artistsList.Length > 0)
{
artist = artistsList[0]?.ToString() ?? "";
}
if (!string.IsNullOrEmpty(title) && !string.IsNullOrEmpty(artist))
{
@@ -422,8 +463,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 +476,19 @@ public partial class JellyfinController
if (itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio"))
{
_logger.LogDebug("Adding {Count} songs to results", mergedSongs.Count);
items.AddRange(mergedSongs);
_logger.LogDebug("Adding {Count} songs to results", allSongs.Count);
items.AddRange(allSongs);
}
var includesSongs = itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("Audio");
var includesAlbums = itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicAlbum") || itemTypes.Contains("Playlist");
var includesArtists = itemTypes == null || itemTypes.Length == 0 || itemTypes.Contains("MusicArtist");
var externalHasRequestedTypeResults =
(includesSongs && externalSongItems.Count > 0) ||
(includesAlbums && (externalAlbumItems.Count > 0 || mergedPlaylistItems.Count > 0)) ||
(includesArtists && externalArtistItems.Count > 0);
// Apply pagination
var pagedItems = items.Skip(startIndex).Take(limit).ToList();
@@ -457,10 +507,29 @@ public partial class JellyfinController
// Cache search results in Redis (15 min TTL, no file persistence)
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(searchTerm, includeItemTypes, limit, startIndex);
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
if (externalHasRequestedTypeResults)
{
var cacheKey = CacheKeyBuilder.BuildSearchKey(
searchTerm,
includeItemTypes,
limit,
startIndex,
parentId,
sortBy,
Request.Query["SortOrder"].ToString(),
recursive,
userId);
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
CacheExtensions.SearchResultsTTL.TotalMinutes);
}
else
{
_logger.LogInformation(
"SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})",
cleanQuery,
includeItemTypes ?? string.Empty);
}
}
_logger.LogDebug("About to serialize response...");
@@ -524,6 +593,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 +633,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 +645,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 +672,407 @@ 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);
}
/// <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");
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
_logger.LogDebug("✓ Copied track to kept folder: {Path}", keptFilePath);
try
{
System.IO.File.Copy(sourceFilePath, keptFilePath, overwrite: false);
_logger.LogInformation("📋 Copied track (fallback): {Source} → {Destination}", sourceFilePath, keptFilePath);
}
catch (IOException copyEx) when (copyEx.Message.Contains("already exists") || System.IO.File.Exists(keptFilePath))
{
// Race condition on copy fallback
_logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath);
await MarkTrackAsFavoritedAsync(itemId, song);
return;
}
}
// Also create hard link for cover art if it exists
@@ -492,20 +516,35 @@ public partial class JellyfinController
if (process != null)
{
await process.WaitForExitAsync();
_logger.LogDebug("Created hard link for cover art");
_logger.LogDebug("🔗 Created hard link for cover: {Source} → {Destination}", sourceCoverPath, keptCoverPath);
}
}
else
{
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("Copied cover art to kept folder");
_logger.LogDebug("📋 Copied cover: {Source} → {Destination}", sourceCoverPath, keptCoverPath);
}
}
catch
catch (IOException ex) when (ex.Message.Contains("already exists") || System.IO.File.Exists(keptCoverPath))
{
// Race condition - cover already exists
_logger.LogDebug("Cover art already exists (race condition)");
}
catch (Exception ex)
{
// Fall back to copy if hard link fails
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("Copied cover art to kept folder");
_logger.LogDebug(ex, "Failed to create hard link for cover, falling back to copy");
try
{
System.IO.File.Copy(sourceCoverPath, keptCoverPath, overwrite: false);
_logger.LogDebug("📋 Copied cover (fallback): {Source} → {Destination}", sourceCoverPath, keptCoverPath);
}
catch (IOException copyEx) when (copyEx.Message.Contains("already exists") || System.IO.File.Exists(keptCoverPath))
{
// Race condition on copy fallback
_logger.LogDebug("Cover art already exists (race condition on copy)");
}
}
}
}
@@ -558,7 +597,7 @@ public partial class JellyfinController
await Task.WhenAll(downloadTasks);
_logger.LogInformation("Finished downloading album: {Artist} - {Album}", album.Artist, album.Title);
_logger.LogInformation("Finished downloading album: {Artist} - {Album}", album.Artist, album.Title);
}
catch (Exception ex)
{
+208 -86
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,7 +199,7 @@ 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);
@@ -202,7 +212,7 @@ public partial class JellyfinController : ControllerBase
if (type == "album")
{
_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");
@@ -214,7 +224,14 @@ public partial class JellyfinController : ControllerBase
{
// 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);
}
@@ -226,8 +243,8 @@ public partial class JellyfinController : ControllerBase
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 +267,7 @@ 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 async Task<IActionResult> GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
{
var itemTypes = ParseItemTypes(includeItemTypes);
@@ -263,7 +280,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 +288,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 +332,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 +381,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 +468,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 +476,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)
@@ -539,7 +589,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 +599,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 +630,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 +643,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 +850,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 +871,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 +892,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 +942,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 +970,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 +1065,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 +1133,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 +1290,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 +1307,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 +1322,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);
@@ -1305,15 +1385,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)
+344 -70
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)
isLocal = null;
externalProvider = null;
_logger.LogDebug("✗ Track {Title} ({SpotifyId}) is MISSING (not in cache, no manual mapping)", track.Title, track.SpotifyId);
// Fall back to ordered matched-tracks cache so auto local/external matches
// are shown correctly even when playlist item cache lacks Spotify ProviderIds.
if (PlaylistTrackStatusResolver.TryResolveFromMatchedTrack(
matchedTracksBySpotifyId,
track.SpotifyId,
out var resolvedIsLocal,
out var resolvedExternalProvider))
{
isLocal = resolvedIsLocal;
externalProvider = resolvedExternalProvider;
_logger.LogDebug(
"✓ Track {Title} ({SpotifyId}) resolved from matched cache as {Type}",
track.Title,
track.SpotifyId,
isLocal == true ? "local" : "external");
}
else
{
isLocal = null;
externalProvider = null;
_logger.LogDebug(
"✗ Track {Title} ({SpotifyId}) is MISSING (not in cache, no manual mapping, no matched cache)",
track.Title, track.SpotifyId);
}
}
}
AddTrack:
if (isLocal == false)
{
externalProvider = NormalizeExternalProviderForDisplay(externalProvider);
}
// Check lyrics status
var cacheKey = $"lyrics:{track.PrimaryArtist}:{track.Title}:{track.Album}:{track.DurationMs / 1000}";
var existingLyrics = await _cache.GetStringAsync(cacheKey);
@@ -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" });
}
}
@@ -1126,7 +1179,7 @@ public class PlaylistController : ControllerBase
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,10 +1646,126 @@ 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.
@@ -1514,7 +1788,7 @@ public class PlaylistController : ControllerBase
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" });
}
}
@@ -59,7 +59,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 +92,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 +107,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 +159,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 +174,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 +271,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 +301,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 +354,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 +371,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 +424,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 +453,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();
@@ -49,10 +49,11 @@ public class RequestLoggingMiddleware
var stopwatch = Stopwatch.StartNew();
var request = context.Request;
var maskedQueryString = BuildMaskedQueryString(request.QueryString.Value);
// Log request details
var requestLog = new StringBuilder();
requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{request.QueryString}");
requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{maskedQueryString}");
requestLog.AppendLine($" Host: {request.Host}");
requestLog.AppendLine($" Content-Type: {request.ContentType ?? "(none)"}");
requestLog.AppendLine($" Content-Length: {request.ContentLength?.ToString() ?? "(none)"}");
@@ -153,4 +154,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);
}
}
@@ -271,22 +271,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,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; }
+17 -2
View File
@@ -1,3 +1,5 @@
using System.Text;
namespace allstarr.Models.Settings;
/// <summary>
@@ -32,6 +34,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 +52,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 +77,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>
+242 -72
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)
options.KnownIPNetworks.Clear();
options.KnownProxies.Clear();
// Keep a bounded chain by default; configurable for multi-hop proxy setups.
options.ForwardLimit = builder.Configuration.GetValue<int?>("ForwardedHeaders:ForwardLimit") ?? 2;
// Trust X-Forwarded-* headers from any source
// Only do this if your reverse proxy is properly configured and trusted
options.ForwardLimit = null;
// 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();
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)
serverOptions.ListenAnyIP(5275);
// Admin UI port defaults to localhost-only.
// Override with Admin:BindAnyIp=true if required by your deployment.
if (bindAdminAnyIp)
{
Console.WriteLine("⚠️ Admin UI binding override enabled: listening on 0.0.0.0:5275");
serverOptions.ListenAnyIP(5275);
}
else
{
Console.WriteLine("Admin UI listening on localhost:5275 (default)");
serverOptions.ListenLocalhost(5275);
}
});
// Add response compression for large JSON responses (helps with Tailscale/VPN MTU issues)
@@ -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
@@ -679,43 +782,107 @@ builder.Services.AddSingleton<IScrobblingService, ListenBrainzScrobblingService>
builder.Services.AddSingleton<ScrobblingOrchestrator>();
builder.Services.AddSingleton<ScrobblingHelper>();
// Register MusicBrainz service for metadata enrichment
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
// 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))
{
builder.Configuration.GetSection("MusicBrainz").Bind(options);
musicBrainzEnabled = musicBrainzEnabledEnv.Equals("true", StringComparison.OrdinalIgnoreCase);
}
// Override from environment variables
var enabled = builder.Configuration.GetValue<string>("MusicBrainz:Enabled");
if (!string.IsNullOrEmpty(enabled))
if (musicBrainzEnabled)
{
builder.Services.Configure<allstarr.Models.Settings.MusicBrainzSettings>(options =>
{
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
}
builder.Configuration.GetSection("MusicBrainz").Bind(options);
var username = builder.Configuration.GetValue<string>("MusicBrainz:Username");
if (!string.IsNullOrEmpty(username))
{
options.Username = username;
}
// Override from environment variables
var enabled = builder.Configuration.GetValue<string>("MusicBrainz:Enabled");
if (!string.IsNullOrEmpty(enabled))
{
options.Enabled = enabled.Equals("true", StringComparison.OrdinalIgnoreCase);
}
var password = builder.Configuration.GetValue<string>("MusicBrainz:Password");
if (!string.IsNullOrEmpty(password))
{
options.Password = password;
}
});
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
var username = builder.Configuration.GetValue<string>("MusicBrainz:Username");
if (!string.IsNullOrEmpty(username))
{
options.Username = username;
}
// Register genre enrichment service
builder.Services.AddSingleton<allstarr.Services.Common.GenreEnrichmentService>();
var password = builder.Configuration.GetValue<string>("MusicBrainz:Password");
if (!string.IsNullOrEmpty(password))
{
options.Password = password;
}
});
builder.Services.AddSingleton<allstarr.Services.MusicBrainz.MusicBrainzService>();
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 +934,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 +971,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()
};
}
}
+84 -6
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 result;
return await SetStringInternalAsync(key, value, expiry);
}
catch (RedisTimeoutException ex)
{
return await RetrySetAfterReconnectAsync(
key,
value,
expiry,
ex,
"Redis SET timeout for key: {Key}. Reconnecting and retrying once.");
}
catch (RedisConnectionException ex)
{
return await RetrySetAfterReconnectAsync(
key,
value,
expiry,
ex,
"Redis SET connection error for key: {Key}. Reconnecting and retrying once.");
}
catch (Exception ex)
{
@@ -117,6 +130,71 @@ public class RedisCacheService
}
}
private async Task<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
};
@@ -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,34 +49,35 @@ public class JellyfinSessionManager : IDisposable
return false;
}
// Check if we already have this session tracked
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive
// If this returns false (401), the token expired and client needs to re-auth
var success = await PostCapabilitiesAsync(headers);
if (!success)
{
// Token expired - remove the stale session
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
return true;
}
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
var initLock = _sessionInitLocks.GetOrAdd(deviceId, _ => new SemaphoreSlim(1, 1));
await initLock.WaitAsync();
try
{
// Post session capabilities to Jellyfin - this creates the session
var success = await PostCapabilitiesAsync(headers);
// Check if we already have this session tracked
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
if (!success)
// Refresh capabilities to keep session alive
// If this returns false (401), the token expired and client needs to re-auth
var refreshOk = await PostCapabilitiesAsync(headers);
if (!refreshOk)
{
// Token expired - remove the stale session
_logger.LogWarning("Token expired for device {DeviceId} - removing session", deviceId);
await RemoveSessionAsync(deviceId);
return false;
}
return true;
}
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
// Post session capabilities to Jellyfin - this creates the session
var 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,70 @@ public class JellyfinSessionManager : IDisposable
}
}
/// <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 +407,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 +416,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 +442,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 +631,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 +642,18 @@ 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 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);
}
}
@@ -73,21 +73,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 +117,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 +131,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 +258,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 +276,7 @@ public class ScrobblingOrchestrator
{
_logger.LogError("❌ Scrobble failed for {Service}: {Error} - No more retries",
service.ServiceName, result.ErrorMessage);
return; // Don't retry or max retries reached
return false; // Don't retry or max retries reached
}
}
catch (Exception ex)
@@ -274,14 +291,38 @@ public class ScrobblingOrchestrator
{
_logger.LogError(ex, "❌ Error scrobbling to {Service} after {Max} attempts",
service.ServiceName, maxRetries);
return false;
}
}
}
return false;
});
await Task.WhenAll(tasks);
session.Scrobbled = true;
_logger.LogDebug("Marked session as scrobbled: {SessionId}", session.SessionId);
var outcomes = await Task.WhenAll(tasks);
if (outcomes.Any(s => s))
{
session.Scrobbled = true;
_logger.LogDebug("Marked session as scrobbled: {SessionId}", session.SessionId);
}
else
{
_logger.LogWarning(
"Scrobble failed for all enabled services: {Artist} - {Track}. Will retry on next progress/stop.",
session.Track.Artist,
session.Track.Title);
}
}
private PlaybackSession? FindSession(string deviceId, string artist, string title)
{
return _sessions.Values
.Where(s =>
s.DeviceId == deviceId &&
string.Equals(s.Track.Artist, artist, StringComparison.OrdinalIgnoreCase) &&
string.Equals(s.Track.Title, title, StringComparison.OrdinalIgnoreCase))
.OrderByDescending(s => s.StartTime)
.FirstOrDefault();
}
/// <summary>
+303 -17
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,12 +438,61 @@ 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))
{
ownerName = ownerNameProp.GetString();
if (ownerData.TryGetProperty("name", out var ownerNameProp))
{
ownerName = ownerNameProp.GetString();
}
if (ownerData.TryGetProperty("username", out var usernameProp))
{
ownerId = usernameProp.GetString();
}
}
// Parse playlist image
string? imageUrl = null;
if (playlistV2.TryGetProperty("images", out var images) &&
images.TryGetProperty("items", out var imageItems) &&
imageItems.GetArrayLength() > 0)
{
var firstImage = imageItems[0];
if (firstImage.TryGetProperty("sources", out var sources) &&
sources.GetArrayLength() > 0)
{
var firstSource = sources[0];
if (firstSource.TryGetProperty("url", out var urlProp))
{
imageUrl = urlProp.GetString();
}
}
}
// Parse snapshot/revision ID
string? snapshotId = null;
if (playlistV2.TryGetProperty("revisionId", out var revisionIdProp))
{
snapshotId = revisionIdProp.GetString();
}
var createdAt = TryGetSpotifyPlaylistCreatedAt(playlistV2);
// Parse collaborative and public flags (may not always be present)
bool collaborative = false;
if (playlistV2.TryGetProperty("collaborative", out var collaborativeProp))
{
collaborative = collaborativeProp.GetBoolean();
}
bool isPublic = false;
if (playlistV2.TryGetProperty("public", out var publicProp))
{
isPublic = publicProp.GetBoolean();
}
return new SpotifyPlaylist
@@ -438,6 +501,12 @@ public class SpotifyApiClient : IDisposable
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,15 +553,33 @@ public class SpotifyApiClient : IDisposable
artists.Add(artistNameStr);
}
}
// Extract artist ID
if (artist.TryGetProperty("uri", out var artistUri))
{
var artistId = artistUri.GetString()?.Replace("spotify:artist:", "");
if (!string.IsNullOrEmpty(artistId))
{
artistIds.Add(artistId);
}
}
}
}
// Parse album
// Parse album with ID
string? albumName = null;
if (data.TryGetProperty("albumOfTrack", out var album) &&
album.TryGetProperty("name", out var albumNameProp))
string? albumId = null;
if (data.TryGetProperty("albumOfTrack", out var album))
{
albumName = albumNameProp.GetString();
if (album.TryGetProperty("name", out var albumNameProp))
{
albumName = albumNameProp.GetString();
}
if (album.TryGetProperty("uri", out var albumUri))
{
albumId = albumUri.GetString()?.Replace("spotify:album:", "");
}
}
// Parse duration
@@ -509,11 +597,66 @@ 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
@@ -521,10 +664,17 @@ public class SpotifyApiClient : IDisposable
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,81 +104,115 @@ public class SpotifyPlaylistFetcher : BackgroundService
}
// Cache miss or expired - need to fetch fresh from Spotify
// Try to use cached or configured Spotify playlist ID
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
var sessionCookie = await _spotifySessionCookieService.ResolveSessionCookieAsync(playlistConfig?.UserId);
if (string.IsNullOrWhiteSpace(sessionCookie))
{
// Check if we have a configured Spotify ID for this playlist
var config = _spotifyImportSettings.GetPlaylistByName(playlistName);
if (config != null && !string.IsNullOrEmpty(config.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = config.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
else
{
// No configured ID, try searching by name (works for public/followed playlists)
_logger.LogInformation("No configured Spotify ID for '{Name}', searching...", playlistName);
var playlists = await _spotifyClient.SearchUserPlaylistsAsync(playlistName);
var exactMatch = playlists.FirstOrDefault(p =>
p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
if (exactMatch == null)
{
_logger.LogInformation("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
spotifyId = exactMatch.SpotifyId;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
}
}
// Fetch the full playlist
var playlist = await _spotifyClient.GetPlaylistAsync(spotifyId);
if (playlist == null || playlist.Tracks.Count == 0)
{
_logger.LogError("Failed to fetch playlist '{Name}' from Spotify", playlistName);
_logger.LogWarning("No Spotify session cookie configured for playlist '{Name}' (user scope: {UserId})",
playlistName, playlistConfig?.UserId ?? "(global)");
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
// Calculate cache expiration based on cron schedule
var playlistCfg = _spotifyImportSettings.GetPlaylistByName(playlistName);
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
SpotifyApiClient spotifyClient = _spotifyClient;
SpotifyApiClient? scopedSpotifyClient = null;
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
if (!string.Equals(sessionCookie, _spotifyApiSettings.SessionCookie, StringComparison.Ordinal))
{
try
{
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
// Add 5 minutes buffer
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
}
scopedSpotifyClient = _spotifyClientFactory.Create(sessionCookie);
spotifyClient = scopedSpotifyClient;
}
// Update Redis cache with cron-based expiration
await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
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
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
{
// Use the configured Spotify playlist ID directly
spotifyId = playlistConfig.Id;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
}
else
{
// No configured ID, try searching by name (works for public/followed playlists)
_logger.LogInformation("No configured Spotify ID for '{Name}', searching...", playlistName);
var playlists = await spotifyClient.SearchUserPlaylistsAsync(playlistName);
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
var exactMatch = playlists.FirstOrDefault(p =>
p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
return playlist.Tracks;
if (exactMatch == null)
{
_logger.LogInformation("Could not find Spotify playlist named '{Name}' - try configuring the Spotify playlist ID", playlistName);
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
spotifyId = exactMatch.SpotifyId;
_playlistNameToSpotifyId[playlistName] = spotifyId;
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
}
}
// Fetch the full playlist
var playlist = await spotifyClient.GetPlaylistAsync(spotifyId);
if (playlist == null || playlist.Tracks.Count == 0)
{
_logger.LogError("Failed to fetch playlist '{Name}' from Spotify", playlistName);
return cached?.Tracks ?? new List<SpotifyPlaylistTrack>();
}
// Calculate cache expiration based on cron schedule
var playlistCfg = playlistConfig;
var cacheExpiration = TimeSpan.FromMinutes(_spotifyApiSettings.CacheDurationMinutes * 2); // Default
if (playlistCfg != null && !string.IsNullOrEmpty(playlistCfg.SyncSchedule))
{
try
{
var cron = CronExpression.Parse(playlistCfg.SyncSchedule);
var nextRun = cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc);
if (nextRun.HasValue)
{
var timeUntilNextRun = nextRun.Value - DateTime.UtcNow;
// Add 5 minutes buffer
cacheExpiration = timeUntilNextRun + TimeSpan.FromMinutes(5);
_logger.LogInformation("Playlist '{Name}' cache will persist until next cron run: {NextRun} UTC (in {Hours:F1}h)",
playlistName, nextRun.Value, timeUntilNextRun.TotalHours);
}
}
catch (Exception ex)
{
_logger.LogError(ex, "Could not calculate next cron run for '{Name}', using default cache duration", playlistName);
}
}
// Update Redis cache with cron-based expiration
var cacheWriteSucceeded = await _cache.SetAsync(cacheKey, playlist, cacheExpiration);
if (cacheWriteSucceeded)
{
_logger.LogInformation("Fetched and cached playlist '{Name}' with {Count} tracks (expires in {Hours:F1}h)",
playlistName, playlist.Tracks.Count, cacheExpiration.TotalHours);
}
else
{
_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>
@@ -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...");
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
if (string.IsNullOrEmpty(token))
// Validate global fallback cookie if configured; user-scoped cookies are validated per playlist fetch.
if (!string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie))
{
_logger.LogError("Failed to get Spotify access token - check session cookie");
_logger.LogInformation("========================================");
return;
_logger.LogDebug("Attempting Spotify authentication using global fallback cookie...");
var token = await _spotifyClient.GetWebAccessTokenAsync(stoppingToken);
if (string.IsNullOrEmpty(token))
{
_logger.LogWarning("Global fallback Spotify cookie failed validation. User-scoped cookies may still succeed.");
}
}
_logger.LogInformation("Spotify API ENABLED");
_logger.LogInformation("Authenticated via sp_dc session cookie");
_logger.LogInformation("Session cookie mode: {Mode}",
string.IsNullOrWhiteSpace(_spotifyApiSettings.SessionCookie)
? "user-scoped only"
: "global fallback + optional user-scoped overrides");
_logger.LogInformation("ISRC matching: {Enabled}", _spotifyApiSettings.PreferIsrcMatching ? "enabled" : "disabled");
_logger.LogInformation("Configured Playlists: {Count}", _spotifyImportSettings.Playlists.Count);
@@ -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);
}
}
@@ -218,11 +218,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)
@@ -374,20 +374,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 +549,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 +867,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 +909,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 +1207,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 +1365,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 +1410,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 +1474,9 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId,
spotifyTrack.AlbumId);
finalItems.Add(itemDict);
if (matchedKey != null)
{
@@ -1488,7 +1490,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 +1560,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 +1668,9 @@ public class SpotifyTrackMatchingService : BackgroundService
}
}
ProviderIdsEnricher.EnsureSpotifyProviderIds(itemDict, spotifyTrack.SpotifyId,
spotifyTrack.AlbumId);
finalItems.Add(itemDict);
if (matchedKey != null)
{
@@ -1696,21 +1688,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 +1852,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);
infoResponse.EnsureSuccessStatusCode();
if (!infoResponse.IsSuccessStatusCode)
{
Logger.LogWarning("Track download request failed: {StatusCode} {Url}", infoResponse.StatusCode, url);
infoResponse.EnsureSuccessStatusCode();
}
var json = await infoResponse.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
@@ -251,7 +258,12 @@ public class SquidWTFDownloadService : BaseDownloadService
Logger.LogDebug("Fetching track download info from: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
response.EnsureSuccessStatusCode();
if (!response.IsSuccessStatusCode)
{
Logger.LogWarning("Track download info request failed: {StatusCode} {Url}", response.StatusCode, url);
response.EnsureSuccessStatusCode();
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var doc = JsonDocument.Parse(json);
@@ -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,143 +85,269 @@ 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)
{
var allSongs = new List<Song>();
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var queryVariant in BuildSearchQueryVariants(query))
{
// Race top 3 fastest endpoints for search (latency-sensitive)
return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) =>
var songs = await SearchSongsSingleQueryAsync(queryVariant, limit, cancellationToken);
foreach (var song in songs)
{
// Use 's' parameter for track search as per hifi-api spec
var url = $"{baseUrl}/search/?s={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
var key = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key))
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
continue;
}
var json = await response.Content.ReadAsStringAsync();
// Check for error in response body
var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
allSongs.Add(song);
if (allSongs.Count >= limit)
{
throw new HttpRequestException("API returned error response");
break;
}
}
var songs = new List<Song>();
// Per hifi-api spec: track search returns data.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var track in items.EnumerateArray())
{
if (count >= limit) break;
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
songs.Add(song);
}
count++;
}
}
return songs;
});
if (allSongs.Count >= limit)
{
break;
}
}
public async Task<List<Album>> SearchAlbumsAsync(string query, int limit = 20)
_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))
{
// Race top 3 fastest endpoints for search (latency-sensitive)
return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) =>
var albums = await SearchAlbumsSingleQueryAsync(queryVariant, limit, cancellationToken);
foreach (var album in albums)
{
// Use 'al' parameter for album search
// a= is for artists, al= is for albums, p= is for playlists
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
var key = !string.IsNullOrWhiteSpace(album.ExternalId) ? album.ExternalId : album.Id;
if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key))
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
continue;
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
// Per hifi-api spec: album search returns data.albums.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items))
allAlbums.Add(album);
if (allAlbums.Count >= limit)
{
int count = 0;
foreach (var album in items.EnumerateArray())
{
if (count >= limit) break;
albums.Add(ParseTidalAlbum(album));
count++;
}
break;
}
}
return albums;
});
if (allAlbums.Count >= limit)
{
break;
}
}
public async Task<List<Artist>> SearchArtistsAsync(string query, int limit = 20)
_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))
{
// Race top 3 fastest endpoints for search (latency-sensitive)
return await _fallbackHelper.RaceTopEndpointsAsync(3, async (baseUrl, ct) =>
var artists = await SearchArtistsSingleQueryAsync(queryVariant, limit, cancellationToken);
foreach (var artist in artists)
{
// Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url, ct);
if (!response.IsSuccessStatusCode)
var key = !string.IsNullOrWhiteSpace(artist.ExternalId) ? artist.ExternalId : artist.Id;
if (string.IsNullOrWhiteSpace(key) || !seenIds.Add(key))
{
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
throw new HttpRequestException($"HTTP {response.StatusCode}");
continue;
}
var json = await response.Content.ReadAsStringAsync();
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
// Per hifi-api spec: artist search returns data.artists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("artists", out var artistsObj) &&
artistsObj.TryGetProperty("items", out var items))
allArtists.Add(artist);
if (allArtists.Count >= limit)
{
int count = 0;
foreach (var artist in items.EnumerateArray())
{
if (count >= limit) break;
var parsedArtist = ParseTidalArtist(artist);
artists.Add(parsedArtist);
_logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId);
count++;
}
break;
}
}
_logger.LogInformation("✓ SQUIDWTF: Artist search returned {Count} results", artists.Count);
return artists;
});
if (allArtists.Count >= limit)
{
break;
}
}
public async Task<List<ExternalPlaylist>> SearchPlaylistsAsync(string query, int limit = 20)
_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, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
// Check for error in response body
var result = JsonDocument.Parse(json);
if (result.RootElement.TryGetProperty("detail", out _) ||
result.RootElement.TryGetProperty("error", out _))
{
throw new HttpRequestException("API returned error response");
}
var songs = new List<Song>();
// Per hifi-api spec: track search returns data.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var track in items.EnumerateArray())
{
if (count >= limit) break;
var song = ParseTidalTrack(track);
if (ExplicitContentFilter.ShouldIncludeSong(song, _settings.ExplicitFilter))
{
songs.Add(song);
}
count++;
}
}
return songs;
}, new List<Song>());
}
private async Task<List<Album>> SearchAlbumsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
{
// Use benchmark-ordered fallback (no endpoint racing).
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Use 'al' parameter for album search
// a= is for artists, al= is for albums, p= is for playlists
var url = $"{baseUrl}/search/?al={Uri.EscapeDataString(query)}";
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var albums = new List<Album>();
// Per hifi-api spec: album search returns data.albums.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("albums", out var albumsObj) &&
albumsObj.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var album in items.EnumerateArray())
{
if (count >= limit) break;
albums.Add(ParseTidalAlbum(album));
count++;
}
}
return albums;
}, new List<Album>());
}
private async Task<List<Artist>> SearchArtistsSingleQueryAsync(string query, int limit, CancellationToken cancellationToken)
{
// Use benchmark-ordered fallback (no endpoint racing).
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
{
// Per hifi-api spec: use 'a' parameter for artist search
var url = $"{baseUrl}/search/?a={Uri.EscapeDataString(query)}";
_logger.LogDebug("🔍 SQUIDWTF: Searching artists with URL: {Url}", url);
var response = await _httpClient.GetAsync(url, cancellationToken);
if (!response.IsSuccessStatusCode)
{
_logger.LogWarning("⚠️ SQUIDWTF: Artist search failed with status {StatusCode}", response.StatusCode);
throw new HttpRequestException($"HTTP {response.StatusCode}");
}
var json = await response.Content.ReadAsStringAsync(cancellationToken);
var result = JsonDocument.Parse(json);
var artists = new List<Artist>();
// Per hifi-api spec: artist search returns data.artists.items array
if (result.RootElement.TryGetProperty("data", out var data) &&
data.TryGetProperty("artists", out var artistsObj) &&
artistsObj.TryGetProperty("items", out var items))
{
int count = 0;
foreach (var artist in items.EnumerateArray())
{
if (count >= limit) break;
var parsedArtist = ParseTidalArtist(artist);
artists.Add(parsedArtist);
_logger.LogDebug("🎤 SQUIDWTF: Found artist: {Name} (ID: {Id})", parsedArtist.Name, parsedArtist.ExternalId);
count++;
}
}
return artists;
}, new List<Artist>());
}
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()}";
allArtists.Add(artistName);
allArtistIds.Add(artistId);
artistName = artist.TryGetProperty("name", out var artistNameEl) ? artistNameEl.GetString() ?? "" : "";
if (artist.TryGetProperty("id", out var artistIdEl))
{
var externalArtistId = GetIdAsString(artistIdEl);
if (!string.IsNullOrWhiteSpace(externalArtistId))
{
artistId = BuildExternalArtistId("squidwtf", externalArtistId);
}
}
if (!string.IsNullOrWhiteSpace(artistName))
{
allArtists.Add(artistName);
}
if (!string.IsNullOrWhiteSpace(artistId))
{
allArtistIds.Add(artistId);
}
}
var contributors = allArtists
.Skip(1)
.Where(a => !string.IsNullOrWhiteSpace(a))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
// Get album info
string albumTitle = "";
string? albumId = null;
string? coverArt = null;
string? coverArtLarge = null;
string? albumArtist = null;
int? totalTracks = null;
string? copyright = track.TryGetProperty("copyright", out var copyrightVal) && copyrightVal.ValueKind == JsonValueKind.String
? copyrightVal.GetString()
: null;
if (track.TryGetProperty("album", out var album))
{
albumTitle = album.GetProperty("title").GetString() ?? "";
albumId = $"ext-squidwtf-album-{album.GetProperty("id").GetInt64()}";
if (album.TryGetProperty("title", out var albumTitleEl))
{
albumTitle = albumTitleEl.GetString() ?? "";
}
if (album.TryGetProperty("id", out var albumIdEl))
{
var externalAlbumId = GetIdAsString(albumIdEl);
if (!string.IsNullOrWhiteSpace(externalAlbumId))
{
albumId = BuildExternalAlbumId("squidwtf", externalAlbumId);
}
}
if (album.TryGetProperty("cover", out var cover))
{
var coverGuid = cover.GetString()?.Replace("-", "/");
coverArt = $"https://resources.tidal.com/images/{coverGuid}/320x320.jpg";
var coverId = cover.GetString();
coverArt = BuildTidalImageUrl(coverId, "320x320");
coverArtLarge = BuildTidalImageUrl(coverId, "1280x1280");
}
if (album.TryGetProperty("numberOfTracks", out var numberOfTracks) && numberOfTracks.ValueKind == JsonValueKind.Number)
{
totalTracks = numberOfTracks.GetInt32();
}
if (album.TryGetProperty("releaseDate", out var albumReleaseDate) && albumReleaseDate.ValueKind == JsonValueKind.String)
{
var albumReleaseDateValue = albumReleaseDate.GetString();
if (!string.IsNullOrWhiteSpace(albumReleaseDateValue))
{
releaseDate = albumReleaseDateValue;
year = ParseYearFromDateString(albumReleaseDateValue);
}
}
if (album.TryGetProperty("artist", out var albumArtistEl) &&
albumArtistEl.TryGetProperty("name", out var albumArtistNameEl))
{
albumArtist = albumArtistNameEl.GetString();
}
else if (album.TryGetProperty("artists", out var albumArtistsEl) &&
albumArtistsEl.ValueKind == JsonValueKind.Array &&
albumArtistsEl.GetArrayLength() > 0 &&
albumArtistsEl[0].TryGetProperty("name", out var firstAlbumArtistNameEl))
{
albumArtist = firstAlbumArtistNameEl.GetString();
}
if (string.IsNullOrWhiteSpace(copyright) &&
album.TryGetProperty("copyright", out var albumCopyright) &&
albumCopyright.ValueKind == JsonValueKind.String)
{
copyright = albumCopyright.GetString();
}
}
if (string.IsNullOrWhiteSpace(albumArtist))
{
albumArtist = artistName;
}
return new Song
{
Id = $"ext-squidwtf-song-{externalId}",
Title = track.GetProperty("title").GetString() ?? "",
Id = BuildExternalSongId("squidwtf", externalId),
Title = title,
Artist = artistName,
ArtistId = artistId,
Artists = allArtists,
ArtistIds = allArtistIds,
Album = albumTitle,
AlbumId = albumId,
Duration = track.TryGetProperty("duration", out var duration)
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);
}
_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>
+10
View File
@@ -13,6 +13,16 @@
"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",
+112 -55
View File
@@ -17,16 +17,39 @@
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 id="status-indicator">
<span class="status-badge" id="spotify-status">
<span class="status-dot"></span>
<span>Loading...</span>
</span>
<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>
@@ -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>
</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>
@@ -490,7 +511,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,19 +534,44 @@
</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">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>
<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 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="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 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>
<div
@@ -601,7 +647,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 +751,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 +979,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 +1005,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>
+361 -238
View File
@@ -1,345 +1,468 @@
// API calls
async function readErrorMessage(res, fallback) {
try {
const error = await res.json();
return error.error || error.message || fallback;
} catch {
return fallback;
}
}
async function requestJson(
url,
options = {},
fallbackError = "Request failed",
) {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(await readErrorMessage(res, fallbackError));
}
return await res.json();
}
async function requestBlob(
url,
options = {},
fallbackError = "Request failed",
) {
const res = await fetch(url, options);
if (!res.ok) {
throw new Error(await readErrorMessage(res, fallbackError));
}
return await res.blob();
}
async function requestOptionalJson(url, options = {}) {
const res = await fetch(url, options);
if (!res.ok) {
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() {
const res = await fetch('/api/admin/status');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
return requestJson("/api/admin/status", {}, "Failed to fetch status");
}
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();
return requestJson(
"/api/admin/playlists",
{},
"Failed to fetch playlists",
);
}
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();
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}/tracks`,
{},
"Failed to fetch playlist tracks",
);
}
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();
return requestJson(
"/api/admin/mappings/tracks",
{},
"Failed to fetch track mappings",
);
}
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) {
const error = await res.json();
throw new Error(error.error || 'Failed to remove mapping');
}
return await res.json();
return requestJson(
`/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`,
{ method: "DELETE" },
"Failed to remove mapping",
);
}
export async function fetchDownloads() {
const res = await fetch('/api/admin/downloads');
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
return requestJson(
"/api/admin/downloads",
{},
"Failed to fetch downloads",
);
}
export async function deleteDownload(path) {
const res = await fetch(`/api/admin/downloads?path=${encodeURIComponent(path)}`, {
method: 'DELETE'
});
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
return requestJson(
`/api/admin/downloads?path=${encodeURIComponent(path)}`,
{ method: "DELETE" },
"Failed to delete download",
);
}
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();
return requestJson(
"/api/admin/config",
{ cache: "no-store" },
"Failed to fetch config",
);
}
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();
return requestJson(
"/api/admin/config",
asJsonBody({ key, value }),
"Failed to update setting",
);
}
export async function fetchJellyfinUsers() {
const res = await fetch('/api/admin/jellyfin/users');
if (!res.ok) return null;
return await res.json();
return requestOptionalJson("/api/admin/jellyfin/users");
}
export async function fetchJellyfinPlaylists(userId = null) {
let url = '/api/admin/jellyfin/playlists';
if (userId) url += '?userId=' + encodeURIComponent(userId);
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();
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
}
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 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 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 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 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 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() {
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();
return requestJson(
"/api/admin/playlists/refresh",
{ method: "POST" },
"Failed to refresh playlists",
);
}
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();
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}/refresh`,
{ method: "POST" },
"Failed to refresh playlist",
);
}
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();
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}/clear-cache`,
{ method: "POST" },
"Failed to clear playlist cache",
);
}
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();
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}/match`,
{ method: "POST" },
"Failed to match playlist tracks",
);
}
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();
return requestJson(
"/api/admin/playlists/match-all",
{ method: "POST" },
"Failed to match all playlists",
);
}
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();
return requestJson(
"/api/admin/playlists/rebuild-all",
{ method: "POST" },
"Failed to rebuild all playlists",
);
}
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();
return requestJson(
"/api/admin/cache/clear",
{ method: "POST" },
"Failed to clear cache",
);
}
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();
return requestJson(
"/api/admin/restart",
{ method: "POST" },
"Failed to restart container",
);
}
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();
return requestJson(
`/api/admin/debug/endpoint-usage?top=${top}`,
{},
"Failed to fetch endpoint usage",
);
}
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();
return requestJson(
"/api/admin/debug/endpoint-usage",
{ method: "DELETE" },
"Failed to clear endpoint usage",
);
}
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();
return requestJson(
"/api/admin/playlists",
asJsonBody({ name, spotifyId }),
"Failed to add playlist",
);
}
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();
return requestJson(
`/api/admin/playlists/${encodeURIComponent(name)}`,
{ method: "DELETE" },
"Failed to remove playlist",
);
}
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();
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) {
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();
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) {
const res = await fetch(`/api/admin/jellyfin/track/${jellyfinId}`);
if (!res.ok) {
throw new Error(`HTTP ${res.status}: ${res.statusText}`);
}
return await res.json();
return requestJson(
`/api/admin/jellyfin/track/${jellyfinId}`,
{},
"Failed to fetch Jellyfin track",
);
}
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();
return requestJson(
`/api/admin/playlists/${encodeURIComponent(playlistName)}/map`,
asJsonBody(mapping),
"Failed to save mapping",
);
}
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 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) {
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();
return requestJson(
"/api/admin/config",
asJsonBody({ updates: { [key]: value } }),
"Failed to update setting",
);
}
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();
return requestJson(
"/api/admin/config/init-cookie-date",
{ method: "POST" },
"Failed to initialize cookie date",
);
}
export async function exportEnv() {
const res = await fetch('/api/admin/export-env');
if (!res.ok) {
throw new Error('Export failed');
}
return await res.blob();
return requestBlob(
"/api/admin/export-env",
{},
"Export failed",
);
}
export async function importEnv(file) {
const formData = new FormData();
formData.append('file', file);
const formData = new FormData();
formData.append("file", file);
const res = await fetch('/api/admin/import-env', {
method: 'POST',
body: formData
});
if (!res.ok) {
const error = await res.json();
throw new Error(error.error || 'Failed to import .env file');
}
return await res.json();
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 await res.json();
return requestJson(
"/api/admin/squidwtf-base-url",
{},
"Failed to fetch SquidWTF base URL",
);
}
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,
};
}
+585 -313
View File
@@ -1,402 +1,674 @@
// 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);
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>';
return;
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>';
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>';
return;
}
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>';
}
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>';
return;
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>';
}
} else if (t.isLocal === false) {
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>';
}
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-artist="${escapeHtml(firstArtist)}"
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-artist="${escapeHtml(firstArtist)}"
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="map-action-btn map-action-local map-track-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
data-title="${escapeHtml(t.title || "")}"
data-artist="${escapeHtml(firstArtist)}"
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-artist="${escapeHtml(firstArtist)}"
data-spotify-id="${escapeHtml(t.spotifyId || "")}"
>Map to External</button>`;
}
document.getElementById('tracks-list').innerHTML = data.tracks.map((t, index) => {
let statusBadge = '';
let mapButton = '';
let lyricsBadge = '';
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>`
: "";
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>';
}
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>`;
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>';
}
} else if (t.isLocal === false) {
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>';
}
const firstArtist = (t.artists && t.artists.length > 0) ? t.artists[0] : '';
mapButton = `<button class="small map-track-btn"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
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-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
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>`;
} 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"
data-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
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-playlist-name="${escapeHtml(name)}"
data-position="${t.position}"
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>`;
}
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 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 `
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');
openManualMap(playlistName, position, title, artist, spotifyId);
});
});
// 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");
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');
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>';
}
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: ' +
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();
if (!query) {
showToast('Please enter a search query', 'error');
return;
const query = document.getElementById("local-map-search").value.trim();
if (!query) {
showToast("Please enter a search query", "error");
return;
}
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);
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;
}
const resultsDiv = document.getElementById('jellyfin-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>';
return;
}
resultsDiv.innerHTML = data.results.map(track => {
return `
<div class="jellyfin-result" onclick="selectJellyfinTrack('${escapeJs(track.id)}')">
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" 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('');
} catch (error) {
console.error('Search error:', error);
resultsDiv.innerHTML = '<p style="color:var(--error);text-align:center;padding:20px;">Search failed: ' + error.message + '</p>';
}
})
.join("");
} catch (error) {
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>";
}
}
// Select a Jellyfin track from search results
export async function selectJellyfinTrack(jellyfinId) {
try {
const data = await API.getJellyfinTrack(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');
return;
}
if (!jellyfinId) {
showToast("Please select a Jellyfin track first", "error");
return;
}
const saveBtn = document.getElementById('manual-map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
const saveBtn = document.getElementById("local-map-save-btn");
const originalText = saveBtn.textContent;
saveBtn.textContent = "Saving...";
saveBtn.disabled = true;
try {
await API.saveTrackMapping(playlistName, {
position,
spotifyId,
jellyfinId,
type: 'jellyfin'
});
try {
await API.saveTrackMapping(playlistName, {
position,
spotifyId,
jellyfinId,
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');
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
if (window.fetchPlaylists) window.fetchPlaylists();
if (window.fetchTrackMappings) window.fetchTrackMappings();
} catch (error) {
showToast(error.message || "Failed to save mapping", "error");
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// 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');
return;
}
if (!externalId) {
showToast("Please enter an external track ID", "error");
return;
}
if (!validateExternalMapping(externalId, provider)) {
return;
}
if (!validateExternalMapping(externalId, provider)) {
return;
}
const saveBtn = document.getElementById('external-map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
const saveBtn = document.getElementById("map-save-btn");
const originalText = saveBtn.textContent;
saveBtn.textContent = "Saving...";
saveBtn.disabled = true;
try {
await API.saveTrackMapping(playlistName, {
position,
spotifyId,
externalId,
externalProvider: provider,
type: 'external'
});
try {
await API.saveTrackMapping(playlistName, {
position,
spotifyId,
externalId,
externalProvider: provider,
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');
} 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');
}
if (window.fetchPlaylists) window.fetchPlaylists();
if (window.fetchTrackMappings) window.fetchTrackMappings();
} catch (error) {
showToast(error.message || "Failed to save mapping", "error");
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// 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');
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;
}
} else if (provider === 'qobuz') {
if (!externalId.includes('/') && !/^\d+$/.test(externalId)) {
showToast('Qobuz ID format appears invalid', 'error');
return false;
}
// 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 true;
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 === "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;
}
}
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');
return;
}
const saveBtn = document.getElementById('lyrics-map-save-btn');
const originalText = saveBtn.textContent;
saveBtn.textContent = 'Saving...';
saveBtn.disabled = true;
try {
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);
} else {
showToast('✓ Lyrics mapping saved successfully', 'success');
}
closeModal('lyrics-map-modal');
} catch (error) {
showToast(error.message || 'Failed to save lyrics mapping', 'error');
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
if (!lyricsId || lyricsId <= 0) {
showToast("Please enter a valid lyrics ID", "error");
return;
}
const saveBtn = document.getElementById("lyrics-map-save-btn");
const originalText = saveBtn.textContent;
saveBtn.textContent = "Saving...";
saveBtn.disabled = true;
try {
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,
);
} else {
showToast("✓ Lyrics mapping saved successfully", "success");
}
closeModal("lyrics-map-modal");
} catch (error) {
showToast(error.message || "Failed to save lyrics mapping", "error");
} finally {
saveBtn.textContent = originalText;
saveBtn.disabled = false;
}
}
// Search provider (open in new tab)
export async function searchProvider(query, provider) {
try {
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');
} catch (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');
}
try {
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");
} catch (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");
}
}
+140 -1183
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,1205 +45,141 @@ window.openModal = openModal;
window.closeModal = closeModal;
window.capitalizeProvider = capitalizeProvider;
// Restart banner
window.showRestartBanner = function() {
restartRequired = true;
document.getElementById('restart-banner').classList.add('active');
window.showRestartBanner = function () {
restartRequired = true;
document.getElementById("restart-banner")?.classList.add("active");
};
window.dismissRestartBanner = function() {
document.getElementById('restart-banner').classList.remove('active');
window.dismissRestartBanner = function () {
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'));
window.switchTab = function (tabName) {
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 tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
const content = document.getElementById(`tab-${tabName}`);
if (tab && content) {
tab.classList.add('active');
content.classList.add('active');
window.location.hash = tabName;
}
if (tab && content) {
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');
return;
}
if (cookieDateInitialized) {
console.log("Cookie date already initialized, skipping");
return;
}
cookieDateInitialized = true;
cookieDateInitialized = true;
try {
await API.initCookieDate();
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);
cookieDateInitialized = false;
}
try {
await API.initCookieDate();
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);
cookieDateInitialized = false;
}
}
// Fetch and update status
window.fetchStatus = async function() {
try {
const data = await API.fetchStatus();
UI.updateStatusUI(data);
initSettingsEditor({
fetchConfig: async () => window.fetchConfig?.(),
fetchStatus: async () => window.fetchStatus?.(),
showRestartBanner: window.showRestartBanner,
});
initScrobblingAdmin({
showRestartBanner: window.showRestartBanner,
});
const dashboard = initDashboardData({
isAuthenticated: () => authSession?.isAuthenticated() ?? false,
isAdminSession: () => authSession?.isAdminSession() ?? false,
getCurrentUserId: () => authSession?.getCurrentUserId?.() ?? null,
onCookieNeedsInit: initCookieDate,
setCurrentConfigState,
syncConfigUiExtras,
loadScrobblingConfig: () => window.loadScrobblingConfig?.(),
});
initOperations({
fetchPlaylists: dashboard.fetchPlaylists,
fetchTrackMappings: dashboard.fetchTrackMappings,
fetchDownloads: dashboard.fetchDownloads,
});
initPlaylistAdmin({
isAdminSession: () => authSession?.isAdminSession() ?? false,
showRestartBanner: window.showRestartBanner,
fetchPlaylists: dashboard.fetchPlaylists,
fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists,
});
const authSession = initAuthSession({
stopDashboardRefresh: dashboard.stopDashboardRefresh,
loadDashboardData: dashboard.loadDashboardData,
switchTab: window.switchTab,
onUnauthenticated: () => {
resetPlaylistAdminState();
setCurrentConfigState(null);
},
});
// 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
});
});
} 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'
});
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)`;
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();
}
// 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', () => {
window.switchTab(tab.dataset.tab);
});
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);
}
const hash = window.location.hash.substring(1);
if (hash) {
window.switchTab(hash);
}
// Setup modal backdrop close
setupModalBackdropClose();
setupModalBackdropClose();
// Initial data load
window.fetchStatus();
window.fetchPlaylists();
window.fetchTrackMappings();
window.fetchMissingTracks();
window.fetchDownloads();
window.fetchJellyfinUsers();
window.fetchJellyfinPlaylists();
window.fetchConfig();
window.fetchEndpointUsage();
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
if (scrobblingTab) {
scrobblingTab.addEventListener("click", () => {
if (authSession.isAuthenticated()) {
window.loadScrobblingConfig();
}
});
}
// 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);
authSession.bootstrapAuth();
});
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') || ''
}
});
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') || '' }
});
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 is the SAME process as the scheduled cron job.\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 the SAME process as the scheduled cron job.\n\nThis may take several minutes.",
before: async () => {
setMatchingBannerVisible(true);
showToast("Starting full rebuild (same as cron job)...", "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,
};
}
+344
View File
@@ -0,0 +1,344 @@
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("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 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.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,
};
}
+712 -256
View File
@@ -1,159 +1,446 @@
// 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');
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
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 (statusBadge) {
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';
}
} else if (data.spotify.authStatus === 'missing_cookie') {
if (statusBadge) {
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';
}
} else {
if (statusBadge) {
statusBadge.className = 'status-badge';
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
}
if (authStatus) {
authStatus.textContent = 'Not Configured';
authStatus.className = 'stat-value';
}
if (data.spotify.authStatus === "configured") {
if (statusBadge) {
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";
}
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.innerHTML = '<span class="status-dot"></span>Cookie Missing';
}
if (authStatus) {
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 info";
statusBadge.innerHTML = '<span class="status-dot"></span>Not Configured';
}
if (authStatus) {
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>';
return;
}
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>`;
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.",
});
}
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);
let breakdownParts = [];
if (localCount > 0) {
breakdownParts.push(`<span style="color:var(--success)">${localCount} Local</span>`);
}
if (externalMatched > 0) {
breakdownParts.push(`<span style="color:var(--accent)">${externalMatched} External</span>`);
}
if (externalMissing > 0) {
breakdownParts.push(`<span style="color:var(--warning)">${externalMissing} Missing</span>`);
}
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 breakdown = breakdownParts.length > 0
? `<br><small style="color:var(--text-secondary)">${breakdownParts.join(' • ')}</small>`
: '';
const breakdownBadges = [
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
`<span class="status-pill info">${summary.externalMatched} External</span>`,
];
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)';
if (summary.externalMissing > 0) {
breakdownBadges.push(
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
);
}
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;">
${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>
return `
<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>
<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('');
</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 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>
</div>
</td>
</tr>
`;
})
.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>';
return;
}
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>';
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>';
return;
}
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>';
return;
}
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() : '-';
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()
: "-";
return `
return `
<tr>
<td><strong>${escapeHtml(m.playlist)}</strong></td>
<td style="font-family:monospace;font-size:0.85rem;color:var(--text-secondary);">${m.spotifyId}</td>
@@ -165,22 +452,26 @@ 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>';
return;
}
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 `
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>
@@ -194,131 +485,287 @@ 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';
}
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";
}
}
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>';
return;
}
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>
return `
<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
? data.endpoints[0].endpoint
: '-';
document.getElementById('endpoints-most-called').textContent = mostCalled;
const mostCalled =
data.endpoints && data.endpoints.length > 0
? data.endpoints[0].endpoint
: "-";
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>';
return;
}
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>';
return;
}
tbody.innerHTML = data.endpoints.map((ep, index) => {
const percentage = data.totalRequests > 0
? ((ep.count / data.totalRequests) * 100).toFixed(1)
: '0.0';
tbody.innerHTML = data.endpoints
.map((ep, index) => {
const percentage =
data.totalRequests > 0
? ((ep.count / data.totalRequests) * 100).toFixed(1)
: "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')) {
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes('/Playing')) {
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes('/Search')) {
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
} else {
endpointDisplay = escapeHtml(ep.endpoint);
}
let endpointDisplay = ep.endpoint;
if (ep.endpoint.includes("/stream")) {
endpointDisplay = `<span style="color:var(--success)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes("/Playing")) {
endpointDisplay = `<span style="color:var(--accent)">${escapeHtml(ep.endpoint)}</span>`;
} else if (ep.endpoint.includes("/Search")) {
endpointDisplay = `<span style="color:var(--warning)">${escapeHtml(ep.endpoint)}</span>`;
} else {
endpointDisplay = escapeHtml(ep.endpoint);
}
return `
return `
<tr>
<td style="color:var(--text-secondary);text-align:center;">${index + 1}</td>
<td style="font-family:monospace;font-size:0.85rem;">${endpointDisplay}</td>
@@ -326,29 +773,37 @@ 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');
if (statusBadge) {
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 statusBadge = document.getElementById("spotify-status");
if (statusBadge) {
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";
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');
for (const card of playlistCards) {
const nameEl = card.querySelector('h3');
if (nameEl && nameEl.textContent.trim() === playlistName) {
const existingIndicator = card.querySelector('.rebuilding-indicator');
if (!existingIndicator) {
const indicator = document.createElement('div');
indicator.className = 'rebuilding-indicator';
indicator.style.cssText = `
const playlistCards = document.querySelectorAll(".playlist-card");
for (const card of playlistCards) {
const nameEl = card.querySelector("h3");
if (nameEl && nameEl.textContent.trim() === playlistName) {
const existingIndicator = card.querySelector(".rebuilding-indicator");
if (!existingIndicator) {
const indicator = document.createElement("div");
indicator.className = "rebuilding-indicator";
indicator.style.cssText = `
position: absolute;
top: 8px;
right: 8px;
@@ -363,15 +818,16 @@ 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';
card.appendChild(indicator);
indicator.innerHTML =
'<span class="spinner" style="width: 10px; height: 10px;"></span>Rebuilding...';
card.style.position = "relative";
card.appendChild(indicator);
setTimeout(() => {
indicator.remove();
}, 30000);
}
break;
}
setTimeout(() => {
indicator.remove();
}, 30000);
}
break;
}
}
}
+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) {
+588 -349
View File
@@ -1,411 +1,650 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spotify Track Mappings - Allstarr</title>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #f0f6fc;
--text-secondary: #8b949e;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--success: #3fb950;
--warning: #d29922;
--error: #f85149;
--border: #30363d;
}
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Spotify Track Mappings - Allstarr</title>
<style>
:root {
--bg-primary: #0d1117;
--bg-secondary: #161b22;
--bg-tertiary: #21262d;
--text-primary: #f0f6fc;
--text-secondary: #8b949e;
--accent: #58a6ff;
--accent-hover: #79c0ff;
--success: #3fb950;
--warning: #d29922;
--error: #f85149;
--border: #30363d;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
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;
}
body {
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;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
padding: 20px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
}
header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px 0;
border-bottom: 1px solid var(--border);
margin-bottom: 30px;
}
h1 {
font-size: 1.8rem;
font-weight: 600;
}
h1 {
font-size: 1.8rem;
font-weight: 600;
}
.back-link {
color: var(--accent);
text-decoration: none;
display: flex;
align-items: center;
gap: 6px;
}
.back-link {
color: var(--accent);
text-decoration: none;
display: flex;
align-items: center;
gap: 6px;
}
.back-link:hover {
color: var(--accent-hover);
}
.back-link:hover {
color: var(--accent-hover);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 30px;
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 30px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.stat-card {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
padding: 20px;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 8px;
}
.stat-label {
color: var(--text-secondary);
font-size: 0.9rem;
margin-bottom: 8px;
}
.stat-value {
font-size: 2rem;
font-weight: 600;
color: var(--accent);
}
.stat-value {
font-size: 2rem;
font-weight: 600;
color: var(--accent);
}
.mappings-table {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.mappings-table {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
}
.table-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.table-header {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.table-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.table-controls {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 12px;
}
.table-header h2 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 12px;
}
.table-header h2 {
font-size: 1.1rem;
font-weight: 600;
margin-bottom: 12px;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
}
.filters {
display: flex;
gap: 12px;
align-items: center;
}
.filter-select {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary);
cursor: pointer;
}
.filter-select {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary);
cursor: pointer;
}
.filter-select:focus {
outline: none;
border-color: var(--accent);
}
.filter-select:focus {
outline: none;
border-color: var(--accent);
}
.search-box {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary);
flex: 1;
max-width: 400px;
}
.search-box {
background: var(--bg-tertiary);
border: 1px solid var(--border);
border-radius: 6px;
padding: 8px 12px;
color: var(--text-primary);
flex: 1;
max-width: 400px;
}
.search-box:focus {
outline: none;
border-color: var(--accent);
}
.search-box:focus {
outline: none;
border-color: var(--accent);
}
.action-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 6px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
}
.action-btn {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 6px 10px;
border-radius: 999px;
cursor: pointer;
font-size: 0.78rem;
transition: all 0.2s;
}
.action-btn:hover {
background: var(--bg-secondary);
border-color: var(--accent);
color: var(--accent);
}
.action-btn:hover {
transform: translateY(-1px);
}
.action-btn.danger:hover {
border-color: var(--error);
color: var(--error);
}
.action-btn.local {
background: rgba(63, 185, 80, 0.18);
border-color: rgba(63, 185, 80, 0.5);
color: #78d487;
}
.actions-cell {
display: flex;
gap: 8px;
}
.action-btn.external {
background: rgba(210, 153, 34, 0.18);
border-color: rgba(210, 153, 34, 0.5);
color: #e8ba5d;
}
table {
width: 100%;
border-collapse: collapse;
}
.action-btn.danger {
background: rgba(248, 81, 73, 0.16);
border-color: rgba(248, 81, 73, 0.45);
color: #f57f78;
}
thead {
background: var(--bg-tertiary);
}
.action-btn.danger:hover {
border-color: var(--error);
color: var(--error);
}
th {
text-align: left;
padding: 12px 20px;
font-weight: 600;
font-size: 0.9rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
.actions-cell {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
th.sortable {
cursor: pointer;
user-select: none;
transition: color 0.2s;
}
table {
width: 100%;
border-collapse: collapse;
}
th.sortable:hover {
color: var(--accent);
}
thead {
background: var(--bg-tertiary);
}
td {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
th {
text-align: left;
padding: 12px 20px;
font-weight: 600;
font-size: 0.9rem;
color: var(--text-secondary);
border-bottom: 1px solid var(--border);
}
tr:last-child td {
border-bottom: none;
}
th.sortable {
cursor: pointer;
user-select: none;
transition: color 0.2s;
}
tbody tr:hover {
background: var(--bg-tertiary);
}
th.sortable:hover {
color: var(--accent);
}
.track-info {
display: flex;
align-items: center;
gap: 12px;
}
td {
padding: 16px 20px;
border-bottom: 1px solid var(--border);
}
.track-artwork {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
background: var(--bg-tertiary);
}
tr:last-child td {
border-bottom: none;
}
.track-details {
flex: 1;
}
tbody tr:hover {
background: var(--bg-tertiary);
}
.track-title {
font-weight: 500;
margin-bottom: 4px;
}
.track-info {
display: flex;
align-items: center;
gap: 12px;
}
.track-artist {
color: var(--text-secondary);
font-size: 0.9rem;
}
.track-artwork {
width: 48px;
height: 48px;
border-radius: 4px;
object-fit: cover;
background: var(--bg-tertiary);
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.track-details {
flex: 1;
}
.badge.local {
background: rgba(63, 185, 80, 0.2);
color: var(--success);
}
.track-title {
font-weight: 500;
margin-bottom: 4px;
}
.badge.external {
background: rgba(88, 166, 255, 0.2);
color: var(--accent);
}
.track-artist {
color: var(--text-secondary);
font-size: 0.9rem;
}
.badge.manual {
background: rgba(210, 153, 34, 0.2);
color: var(--warning);
}
.badge {
display: inline-block;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.85rem;
font-weight: 500;
}
.badge.auto {
background: rgba(139, 148, 158, 0.2);
color: var(--text-secondary);
}
.badge.local {
background: rgba(63, 185, 80, 0.2);
color: var(--success);
}
.mono {
font-family: 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Consolas, monospace;
font-size: 0.85rem;
color: var(--text-secondary);
}
.badge.external {
background: rgba(88, 166, 255, 0.2);
color: var(--accent);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 20px;
}
.badge.manual {
background: rgba(210, 153, 34, 0.2);
color: var(--warning);
}
.pagination button {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.badge.auto {
background: rgba(139, 148, 158, 0.2);
color: var(--text-secondary);
}
.pagination button:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--accent);
}
.mono {
font-family:
"SF Mono", Monaco, "Cascadia Code", "Roboto Mono", Consolas,
monospace;
font-size: 0.85rem;
color: var(--text-secondary);
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 12px;
padding: 20px;
}
.pagination .page-info {
color: var(--text-secondary);
font-size: 0.9rem;
}
.pagination button {
background: var(--bg-tertiary);
border: 1px solid var(--border);
color: var(--text-primary);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
font-size: 0.9rem;
}
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
.pagination button:hover:not(:disabled) {
background: var(--bg-secondary);
border-color: var(--accent);
}
.error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--error);
border-radius: 8px;
padding: 16px;
margin: 20px 0;
color: var(--error);
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.pagination .page-info {
color: var(--text-secondary);
font-size: 0.9rem;
}
.empty-state h3 {
font-size: 1.2rem;
margin-bottom: 8px;
color: var(--text-primary);
}
</style>
<script src="/spotify-mappings.js" defer></script>
</head>
<body>
<div class="container">
<header>
<h1>Spotify Track Mappings</h1>
<a href="/" class="back-link">
← Back to Dashboard
</a>
</header>
.loading {
text-align: center;
padding: 40px;
color: var(--text-secondary);
}
<div class="stats-grid" id="stats">
<div class="stat-card">
<div class="stat-label">Total Mappings</div>
<div class="stat-value" id="stat-total">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Local Tracks</div>
<div class="stat-value" id="stat-local">-</div>
</div>
<div class="stat-card">
<div class="stat-label">External Tracks</div>
<div class="stat-value" id="stat-external">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Manual Mappings</div>
<div class="stat-value" id="stat-manual">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Auto Mappings</div>
<div class="stat-value" id="stat-auto">-</div>
</div>
</div>
.error {
background: rgba(248, 81, 73, 0.1);
border: 1px solid var(--error);
border-radius: 8px;
padding: 16px;
margin: 20px 0;
color: var(--error);
}
<div class="mappings-table">
<div class="table-header">
<h2>All Mappings</h2>
<div class="table-controls">
<div class="filters">
<select class="filter-select" id="filter-type">
<option value="all">All Types</option>
<option value="local">Local Only</option>
<option value="external">External Only</option>
</select>
<select class="filter-select" id="filter-source">
<option value="all">All Sources</option>
<option value="manual">Manual Only</option>
<option value="auto">Auto Only</option>
</select>
</div>
<input type="text" class="search-box" placeholder="Search by title, artist, or Spotify ID..." id="search">
.empty-state {
text-align: center;
padding: 60px 20px;
color: var(--text-secondary);
}
.empty-state h3 {
font-size: 1.2rem;
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>
<body>
<div class="container">
<header>
<h1>Spotify Track Mappings</h1>
<a href="/" class="back-link"> ← Back to Dashboard </a>
</header>
<div class="stats-grid" id="stats">
<div class="stat-card">
<div class="stat-label">Total Mappings</div>
<div class="stat-value" id="stat-total">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Local Tracks</div>
<div class="stat-value" id="stat-local">-</div>
</div>
<div class="stat-card">
<div class="stat-label">External Tracks</div>
<div class="stat-value" id="stat-external">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Manual Mappings</div>
<div class="stat-value" id="stat-manual">-</div>
</div>
<div class="stat-card">
<div class="stat-label">Auto Mappings</div>
<div class="stat-value" id="stat-auto">-</div>
</div>
</div>
<div id="content">
<div class="loading">Loading mappings...</div>
</div>
<div class="mappings-table">
<div class="table-header">
<h2>All Mappings</h2>
<div class="table-controls">
<div class="filters">
<select class="filter-select" id="filter-type">
<option value="all">All Types</option>
<option value="local">Local Only</option>
<option value="external">External Only</option>
</select>
<select class="filter-select" id="filter-source">
<option value="all">All Sources</option>
<option value="manual">Manual Only</option>
<option value="auto">Auto Only</option>
</select>
</div>
<input
type="text"
class="search-box"
placeholder="Search by title, artist, or Spotify ID..."
id="search"
/>
</div>
</div>
<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 id="content">
<div class="loading">Loading mappings...</div>
</div>
<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>
</body>
<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>
+508 -234
View File
@@ -4,107 +4,145 @@
let currentPage = 1;
const pageSize = 50;
let currentFilters = {
targetType: 'all',
source: 'all',
search: '',
sortBy: null,
sortOrder: 'asc'
targetType: "all",
source: "all",
search: "",
sortBy: null,
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
*/
async function loadMappings() {
try {
// Build query string with filters
const params = new URLSearchParams({
page: currentPage,
pageSize: pageSize,
enrichMetadata: true
});
try {
// Build query string with filters
const params = new URLSearchParams({
page: currentPage,
pageSize: pageSize,
enrichMetadata: true,
});
if (currentFilters.targetType !== 'all') {
params.append('targetType', currentFilters.targetType);
}
if (currentFilters.source !== 'all') {
params.append('source', currentFilters.source);
}
if (currentFilters.search) {
params.append('search', currentFilters.search);
}
if (currentFilters.sortBy) {
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');
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();
// Update pagination
updatePagination(data.pagination);
// 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>`;
if (currentFilters.targetType !== "all") {
params.append("targetType", currentFilters.targetType);
}
if (currentFilters.source !== "all") {
params.append("source", currentFilters.source);
}
if (currentFilters.search) {
params.append("search", currentFilters.search);
}
if (currentFilters.sortBy) {
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(
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();
// Update pagination
updatePagination(data.pagination);
// Render table
renderMappings(data.mappings);
} catch (error) {
console.error("Error loading mappings:", error);
document.getElementById("content").innerHTML =
`<div class="error">Failed to load mappings: ${escapeHtml(error.message || "Unknown error")}</div>`;
}
}
/**
* Updates pagination controls
*/
function updatePagination(pagination) {
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("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";
}
/**
* Renders the mappings table
*/
function renderMappings(mappings) {
const content = document.getElementById('content');
const content = document.getElementById("content");
if (mappings.length === 0) {
content.innerHTML = `
if (mappings.length === 0) {
content.innerHTML = `
<div class="empty-state">
<h3>No mappings found</h3>
<p>Try adjusting your filters or search query.</p>
</div>
`;
return;
}
return;
}
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'
? mapping.LocalId
: `${mapping.ExternalProvider}:${mapping.ExternalId}`;
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"
? mapping.LocalId
: `${mapping.ExternalProvider}:${mapping.ExternalId}`;
return `
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 '';
};
const sortIndicator = (column) => {
if (currentFilters.sortBy === column) {
return currentFilters.sortOrder === "asc" ? " ▲" : " ▼";
}
return "";
};
content.innerHTML = `
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>
@@ -175,188 +214,423 @@ function renderMappings(mappings) {
* Sorts the table by the specified column
*/
function sortBy(column) {
if (currentFilters.sortBy === column) {
// Toggle sort order
currentFilters.sortOrder = currentFilters.sortOrder === 'asc' ? 'desc' : 'asc';
} else {
// New column, default to ascending
currentFilters.sortBy = column;
currentFilters.sortOrder = 'asc';
}
if (currentFilters.sortBy === column) {
// Toggle sort order
currentFilters.sortOrder =
currentFilters.sortOrder === "asc" ? "desc" : "asc";
} else {
// New column, default to ascending
currentFilters.sortBy = column;
currentFilters.sortOrder = "asc";
}
currentPage = 1; // Reset to first page
loadMappings();
currentPage = 1; // Reset to first page
loadMappings();
}
/**
* 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();
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');
div.textContent = text;
return div.innerHTML;
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'
});
try {
const response = await fetch(`/api/admin/spotify/mappings/${spotifyId}`, {
method: "DELETE",
});
if (!response.ok) throw new Error('Failed to delete mapping');
alert(`✓ Deleted mapping for "${title}"`);
loadMappings(); // Reload
} catch (error) {
console.error('Error deleting mapping:', error);
alert(`Failed to delete mapping: ${error.message}`);
if (!response.ok) {
throw new Error(
await readErrorMessage(response, "Failed to delete mapping"),
);
}
showToast(`Deleted mapping for "${title}"`, "success");
await loadMappings();
} catch (error) {
console.error("Error deleting mapping:", error);
showToast(error.message || "Failed to delete mapping", "error");
}
}
/**
* Initializes event listeners
*/
function initializeEventListeners() {
// Search with debounce
let searchTimeout;
document.getElementById('search').addEventListener('input', () => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(applyFilters, 300);
// Search with debounce
let searchTimeout;
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);
// Pagination
document.getElementById("prev-btn").addEventListener("click", () => {
if (currentPage > 1) {
currentPage--;
loadMappings();
}
});
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();
}
});
// Filter dropdowns
document.getElementById('filter-type').addEventListener('change', applyFilters);
document.getElementById('filter-source').addEventListener('change', applyFilters);
// 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);
// Pagination
document.getElementById('prev-btn').addEventListener('click', () => {
if (currentPage > 1) {
currentPage--;
loadMappings();
}
// Backdrop close
document
.getElementById("local-map-modal")
.addEventListener("click", (event) => {
if (event.target.id === "local-map-modal") {
closeLocalMapModal();
}
});
document.getElementById('next-btn').addEventListener('click', () => {
currentPage++;
loadMappings();
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', () => {
initializeEventListeners();
loadMappings();
document.addEventListener("DOMContentLoaded", () => {
initializeEventListeners();
loadMappings();
});
+496 -21
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);
}
@@ -273,19 +682,33 @@ input::placeholder {
border-radius: 8px;
background: var(--bg-secondary);
border: 1px solid var(--border);
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1000;
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 {
@@ -337,7 +760,7 @@ input::placeholder {
font-weight: 500;
z-index: 9998;
display: none;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
.restart-banner.active {
@@ -366,7 +789,7 @@ input::placeholder {
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.7);
background: rgba(0, 0, 0, 0.7);
z-index: 1000;
justify-content: center;
align-items: center;
@@ -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
```
+9
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:-}
@@ -149,6 +155,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