mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 12:02:51 -04:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
c9344a6832
|
|||
|
1ba6135115
|
|||
| dfac3c4d60 | |||
|
ec994773dd
|
|||
|
f3091624ec
|
|||
|
375b7c6909
|
|||
|
40338ce25f
|
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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&ersand", "pass&ersand")]
|
||||
[InlineData("user*asterisk", "pass*asterisk")]
|
||||
[InlineData("user$dollar", "pass$dollar")]
|
||||
[InlineData("user(paren)", "pass)paren")]
|
||||
[InlineData("user[bracket]", "pass{bracket}")]
|
||||
public void DebugAuth_SpecialCharacters_HandlesCorrectly(string username, string password)
|
||||
private static ScrobblingAdminController CreateController(
|
||||
ScrobblingSettings settings,
|
||||
HttpResponseMessage httpResponse,
|
||||
AdminHelperService? adminHelper = null)
|
||||
{
|
||||
// Arrange
|
||||
var request = new ScrobblingAdminController.AuthenticateRequest
|
||||
{
|
||||
Username = username,
|
||||
Password = password
|
||||
};
|
||||
var mockSettings = new Mock<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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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!;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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>();
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -5,9 +5,15 @@
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<RootNamespace>allstarr</RootNamespace>
|
||||
<Version>1.1.1</Version>
|
||||
<AssemblyVersion>1.1.1.0</AssemblyVersion>
|
||||
<FileVersion>1.1.1.0</FileVersion>
|
||||
|
||||
<!-- Keep build/package version in sync with AppVersion.cs -->
|
||||
<AppVersionFile>$([System.IO.Path]::Combine('$(MSBuildProjectDirectory)', 'AppVersion.cs'))</AppVersionFile>
|
||||
<AppVersionText>$([System.IO.File]::ReadAllText('$(AppVersionFile)'))</AppVersionText>
|
||||
<AppVersion>$([System.Text.RegularExpressions.Regex]::Match('$(AppVersionText)', 'Version\s*=\s*\"([0-9]+\.[0-9]+\.[0-9]+)\"').Groups[1].Value)</AppVersion>
|
||||
|
||||
<Version>$(AppVersion)</Version>
|
||||
<AssemblyVersion>$(AppVersion).0</AssemblyVersion>
|
||||
<FileVersion>$(AppVersion).0</FileVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -13,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
@@ -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
@@ -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",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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");
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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,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, "&")
|
||||
.replace(/'/g, "\\'")
|
||||
.replace(/"/g, """)
|
||||
.replace(/\r/g, "\\r")
|
||||
.replace(/\n/g, "\\n");
|
||||
}
|
||||
|
||||
export function showToast(message, type = 'success', duration = 3000) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user