mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 10:42:37 -04:00
Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d96f722fa1
|
|||
|
e9099c45d5
|
|||
|
e3adaae924
|
|||
|
67db8b185f
|
|||
|
579c1e04d8
|
|||
|
885c86358d
|
|||
|
7550d01667
|
|||
|
af54a3eec1
|
|||
|
7beac7484d
|
|||
|
997f60b0a8
|
|||
|
6965bdc46d
|
|||
|
ad6f521795
|
|||
|
81bae5621a
|
|||
|
dc225945f8
|
|||
|
8be544bdfc
|
|||
|
e34c4bd125
|
|||
|
b1808bd60c
|
+1
-1
@@ -10,6 +10,6 @@ liberapay: # Replace with a single Liberapay username
|
||||
issuehunt: # Replace with a single IssueHunt username
|
||||
lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry
|
||||
polar: # Replace with a single Polar username
|
||||
buy_me_a_coffee: treeman183
|
||||
buy_me_a_coffee: # Replace with a single Buy Me a Coffee username
|
||||
thanks_dev: # Replace with a single thanks.dev username
|
||||
custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2']
|
||||
|
||||
@@ -100,4 +100,39 @@ public class AuthHeaderHelperTests
|
||||
Assert.Contains("Version=\"1.0\"", header);
|
||||
Assert.Contains("Token=\"abc\"", header);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractAccessToken_ShouldReadMediaBrowserToken()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] =
|
||||
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
|
||||
};
|
||||
|
||||
Assert.Equal("abc", AuthHeaderHelper.ExtractAccessToken(headers));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractAccessToken_ShouldReadBearerToken()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["Authorization"] = "Bearer xyz"
|
||||
};
|
||||
|
||||
Assert.Equal("xyz", AuthHeaderHelper.ExtractAccessToken(headers));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractUserId_ShouldReadMediaBrowserUserId()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] =
|
||||
"MediaBrowser Client=\"Feishin\", UserId=\"user-123\", Token=\"abc\""
|
||||
};
|
||||
|
||||
Assert.Equal("user-123", AuthHeaderHelper.ExtractUserId(headers));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,8 @@ public class ConfigControllerAuthorizationTests
|
||||
Enabled = false,
|
||||
ConnectionString = "localhost:6379"
|
||||
}),
|
||||
redisLogger.Object);
|
||||
redisLogger.Object,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
|
||||
var spotifySessionCookieService = new SpotifySessionCookieService(
|
||||
Options.Create(new SpotifyApiSettings()),
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using allstarr.Controllers;
|
||||
using allstarr.Models.Admin;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.SquidWTF;
|
||||
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;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class DiagnosticsControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TestSquidWtfEndpoints_WithoutAdministratorSession_ReturnsForbidden()
|
||||
{
|
||||
var controller = CreateController(
|
||||
CreateHttpContextWithSession(isAdmin: false),
|
||||
_ => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
|
||||
|
||||
var forbidden = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSquidWtfEndpoints_ReturnsIndependentApiAndStreamingResults()
|
||||
{
|
||||
var controller = CreateController(
|
||||
CreateHttpContextWithSession(isAdmin: true),
|
||||
request =>
|
||||
{
|
||||
var uri = request.RequestUri!;
|
||||
|
||||
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/search/")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
{"data":{"items":[{"id":227242909,"title":"Monica Lewinsky"}]}}
|
||||
""",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/track/")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
{"data":{"manifest":"ZmFrZS1tYW5pZmVzdA=="}}
|
||||
""",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/search/")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/track/")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.GatewayTimeout);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected request URI: {uri}");
|
||||
});
|
||||
|
||||
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsType<SquidWtfEndpointHealthResponse>(ok.Value);
|
||||
Assert.Equal(2, payload.TotalRows);
|
||||
|
||||
var nodeOne = Assert.Single(payload.Endpoints, e => e.Host == "node-one.example");
|
||||
Assert.True(nodeOne.Api.Configured);
|
||||
Assert.True(nodeOne.Api.IsUp);
|
||||
Assert.Equal("up", nodeOne.Api.State);
|
||||
Assert.Equal(200, nodeOne.Api.StatusCode);
|
||||
Assert.True(nodeOne.Streaming.Configured);
|
||||
Assert.True(nodeOne.Streaming.IsUp);
|
||||
Assert.Equal("up", nodeOne.Streaming.State);
|
||||
Assert.Equal(200, nodeOne.Streaming.StatusCode);
|
||||
|
||||
var nodeTwo = Assert.Single(payload.Endpoints, e => e.Host == "node-two.example");
|
||||
Assert.True(nodeTwo.Api.Configured);
|
||||
Assert.False(nodeTwo.Api.IsUp);
|
||||
Assert.Equal("down", nodeTwo.Api.State);
|
||||
Assert.Equal(503, nodeTwo.Api.StatusCode);
|
||||
Assert.True(nodeTwo.Streaming.Configured);
|
||||
Assert.False(nodeTwo.Streaming.IsUp);
|
||||
Assert.Equal("down", nodeTwo.Streaming.State);
|
||||
Assert.Equal(504, nodeTwo.Streaming.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 DiagnosticsController CreateController(
|
||||
HttpContext httpContext,
|
||||
Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
var logger = new Mock<ILogger<DiagnosticsController>>();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(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 spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
|
||||
var spotifySessionCookieService = new SpotifySessionCookieService(
|
||||
Options.Create(new SpotifyApiSettings()),
|
||||
helperService,
|
||||
spotifyCookieLogger.Object);
|
||||
|
||||
var redisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var redisCache = new RedisCacheService(
|
||||
Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = false,
|
||||
ConnectionString = "localhost:6379"
|
||||
}),
|
||||
redisLogger.Object,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(new StubHttpMessageHandler(responseFactory)));
|
||||
|
||||
var controller = new DiagnosticsController(
|
||||
logger.Object,
|
||||
configuration,
|
||||
Options.Create(new SpotifyApiSettings()),
|
||||
Options.Create(new SpotifyImportSettings()),
|
||||
Options.Create(new JellyfinSettings()),
|
||||
Options.Create(new DeezerSettings()),
|
||||
Options.Create(new QobuzSettings()),
|
||||
Options.Create(new SquidWTFSettings()),
|
||||
spotifySessionCookieService,
|
||||
new SquidWtfEndpointCatalog(
|
||||
new List<string>
|
||||
{
|
||||
"https://node-one.example",
|
||||
"https://node-two.example"
|
||||
},
|
||||
new List<string>
|
||||
{
|
||||
"https://node-one.example",
|
||||
"https://node-two.example"
|
||||
}),
|
||||
redisCache,
|
||||
httpClientFactory.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = httpContext
|
||||
}
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,180 +0,0 @@
|
||||
using System.IO.Compression;
|
||||
using allstarr.Controllers;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Services.Lyrics;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class DownloadsControllerLyricsArchiveTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DownloadFile_WithLyricsSidecar_ReturnsZipContainingAudioAndLrc()
|
||||
{
|
||||
var testRoot = CreateTestRoot();
|
||||
var downloadsRoot = Path.Combine(testRoot, "downloads");
|
||||
var artistDir = Path.Combine(downloadsRoot, "kept", "Artist");
|
||||
var audioPath = Path.Combine(artistDir, "track.mp3");
|
||||
|
||||
Directory.CreateDirectory(artistDir);
|
||||
await File.WriteAllTextAsync(audioPath, "audio-data");
|
||||
|
||||
try
|
||||
{
|
||||
var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true));
|
||||
|
||||
var result = await controller.DownloadFile("Artist/track.mp3");
|
||||
|
||||
var fileResult = Assert.IsType<FileStreamResult>(result);
|
||||
Assert.Equal("application/zip", fileResult.ContentType);
|
||||
Assert.Equal("track.zip", fileResult.FileDownloadName);
|
||||
|
||||
var entries = ReadArchiveEntries(fileResult.FileStream);
|
||||
Assert.Contains("track.mp3", entries);
|
||||
Assert.Contains("track.lrc", entries);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTestRoot(testRoot);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadAllFiles_BackfillsLyricsSidecarsIntoArchive()
|
||||
{
|
||||
var testRoot = CreateTestRoot();
|
||||
var downloadsRoot = Path.Combine(testRoot, "downloads");
|
||||
var artistDir = Path.Combine(downloadsRoot, "kept", "Artist", "Album");
|
||||
var audioPath = Path.Combine(artistDir, "01 - track.mp3");
|
||||
|
||||
Directory.CreateDirectory(artistDir);
|
||||
await File.WriteAllTextAsync(audioPath, "audio-data");
|
||||
|
||||
try
|
||||
{
|
||||
var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: true));
|
||||
|
||||
var result = await controller.DownloadAllFiles();
|
||||
|
||||
var fileResult = Assert.IsType<FileStreamResult>(result);
|
||||
Assert.Equal("application/zip", fileResult.ContentType);
|
||||
|
||||
var entries = ReadArchiveEntries(fileResult.FileStream);
|
||||
Assert.Contains(Path.Combine("Artist", "Album", "01 - track.mp3").Replace('\\', '/'), entries);
|
||||
Assert.Contains(Path.Combine("Artist", "Album", "01 - track.lrc").Replace('\\', '/'), entries);
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTestRoot(testRoot);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DeleteDownload_RemovesAdjacentLyricsSidecar()
|
||||
{
|
||||
var testRoot = CreateTestRoot();
|
||||
var downloadsRoot = Path.Combine(testRoot, "downloads");
|
||||
var artistDir = Path.Combine(downloadsRoot, "kept", "Artist");
|
||||
var audioPath = Path.Combine(artistDir, "track.mp3");
|
||||
var sidecarPath = Path.Combine(artistDir, "track.lrc");
|
||||
|
||||
Directory.CreateDirectory(artistDir);
|
||||
File.WriteAllText(audioPath, "audio-data");
|
||||
File.WriteAllText(sidecarPath, "[00:00.00]lyrics");
|
||||
|
||||
try
|
||||
{
|
||||
var controller = CreateController(downloadsRoot, new FakeKeptLyricsSidecarService(createSidecar: false));
|
||||
|
||||
var result = controller.DeleteDownload("Artist/track.mp3");
|
||||
|
||||
Assert.IsType<OkObjectResult>(result);
|
||||
Assert.False(File.Exists(audioPath));
|
||||
Assert.False(File.Exists(sidecarPath));
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteTestRoot(testRoot);
|
||||
}
|
||||
}
|
||||
|
||||
private static DownloadsController CreateController(string downloadsRoot, IKeptLyricsSidecarService? keptLyricsSidecarService = null)
|
||||
{
|
||||
var config = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Library:DownloadPath"] = downloadsRoot
|
||||
})
|
||||
.Build();
|
||||
|
||||
return new DownloadsController(
|
||||
NullLogger<DownloadsController>.Instance,
|
||||
config,
|
||||
keptLyricsSidecarService)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static HashSet<string> ReadArchiveEntries(Stream archiveStream)
|
||||
{
|
||||
archiveStream.Position = 0;
|
||||
using var zip = new ZipArchive(archiveStream, ZipArchiveMode.Read, leaveOpen: true);
|
||||
return zip.Entries
|
||||
.Select(entry => entry.FullName.Replace('\\', '/'))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeKeptLyricsSidecarService : IKeptLyricsSidecarService
|
||||
{
|
||||
private readonly bool _createSidecar;
|
||||
|
||||
public FakeKeptLyricsSidecarService(bool createSidecar)
|
||||
{
|
||||
_createSidecar = createSidecar;
|
||||
}
|
||||
|
||||
public string GetSidecarPath(string audioFilePath)
|
||||
{
|
||||
return Path.ChangeExtension(audioFilePath, ".lrc");
|
||||
}
|
||||
|
||||
public Task<string?> EnsureSidecarAsync(
|
||||
string audioFilePath,
|
||||
Song? song = null,
|
||||
string? externalProvider = null,
|
||||
string? externalId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var sidecarPath = GetSidecarPath(audioFilePath);
|
||||
if (_createSidecar)
|
||||
{
|
||||
File.WriteAllText(sidecarPath, "[00:00.00]lyrics");
|
||||
return Task.FromResult<string?>(sidecarPath);
|
||||
}
|
||||
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ namespace allstarr.Tests;
|
||||
public class DownloadsControllerPathSecurityTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected()
|
||||
public void DownloadFile_PathTraversalIntoPrefixedSibling_IsRejected()
|
||||
{
|
||||
var testRoot = CreateTestRoot();
|
||||
var downloadsRoot = Path.Combine(testRoot, "downloads");
|
||||
@@ -23,7 +23,7 @@ public class DownloadsControllerPathSecurityTests
|
||||
try
|
||||
{
|
||||
var controller = CreateController(downloadsRoot);
|
||||
var result = await controller.DownloadFile("../kept-malicious/attack.mp3");
|
||||
var result = controller.DownloadFile("../kept-malicious/attack.mp3");
|
||||
|
||||
var badRequest = Assert.IsType<BadRequestObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status400BadRequest, badRequest.StatusCode);
|
||||
@@ -63,7 +63,7 @@ public class DownloadsControllerPathSecurityTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DownloadFile_ValidPathInsideKeptFolder_AllowsDownload()
|
||||
public void DownloadFile_ValidPathInsideKeptFolder_AllowsDownload()
|
||||
{
|
||||
var testRoot = CreateTestRoot();
|
||||
var downloadsRoot = Path.Combine(testRoot, "downloads");
|
||||
@@ -76,7 +76,7 @@ public class DownloadsControllerPathSecurityTests
|
||||
try
|
||||
{
|
||||
var controller = CreateController(downloadsRoot);
|
||||
var result = await controller.DownloadFile("Artist/track.mp3");
|
||||
var result = controller.DownloadFile("Artist/track.mp3");
|
||||
|
||||
Assert.IsType<FileStreamResult>(result);
|
||||
}
|
||||
@@ -97,13 +97,7 @@ public class DownloadsControllerPathSecurityTests
|
||||
|
||||
return new DownloadsController(
|
||||
NullLogger<DownloadsController>.Instance,
|
||||
config)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = new DefaultHttpContext()
|
||||
}
|
||||
};
|
||||
config);
|
||||
}
|
||||
|
||||
private static string CreateTestRoot()
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Services;
|
||||
using allstarr.Services.Jellyfin;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class ExternalArtistAppearancesServiceTests
|
||||
{
|
||||
private readonly Mock<IMusicMetadataService> _metadataService = new();
|
||||
private readonly ExternalArtistAppearancesService _service;
|
||||
|
||||
public ExternalArtistAppearancesServiceTests()
|
||||
{
|
||||
_service = new ExternalArtistAppearancesService(
|
||||
_metadataService.Object,
|
||||
Mock.Of<ILogger<ExternalArtistAppearancesService>>());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAppearsOnAlbumsAsync_FiltersPrimaryAlbumsAndDeduplicatesTrackDerivedAlbums()
|
||||
{
|
||||
var artist = new Artist
|
||||
{
|
||||
Id = "ext-squidwtf-artist-artist-a",
|
||||
Name = "Artist A"
|
||||
};
|
||||
|
||||
_metadataService
|
||||
.Setup(service => service.GetArtistAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(artist);
|
||||
|
||||
_metadataService
|
||||
.Setup(service => service.GetArtistAlbumsAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Album>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "ext-squidwtf-album-own",
|
||||
Title = "Own Album",
|
||||
Artist = "Artist A",
|
||||
ArtistId = artist.Id,
|
||||
Year = 2024,
|
||||
IsLocal = false,
|
||||
ExternalProvider = "squidwtf",
|
||||
ExternalId = "own"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "ext-squidwtf-album-feature",
|
||||
Title = "Feature Album",
|
||||
Artist = "Artist B",
|
||||
ArtistId = "ext-squidwtf-artist-artist-b",
|
||||
Year = 2023,
|
||||
IsLocal = false,
|
||||
ExternalProvider = "squidwtf",
|
||||
ExternalId = "feature"
|
||||
}
|
||||
});
|
||||
|
||||
_metadataService
|
||||
.Setup(service => service.GetArtistTracksAsync("squidwtf", "artist-a", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Song>
|
||||
{
|
||||
new()
|
||||
{
|
||||
AlbumId = "ext-squidwtf-album-own",
|
||||
Album = "Own Album",
|
||||
AlbumArtist = "Artist A",
|
||||
Title = "Own Song",
|
||||
Year = 2024
|
||||
},
|
||||
new()
|
||||
{
|
||||
AlbumId = "ext-squidwtf-album-feature",
|
||||
Album = "Feature Album",
|
||||
AlbumArtist = "Artist B",
|
||||
Title = "Feature Song 1",
|
||||
Year = 2023
|
||||
},
|
||||
new()
|
||||
{
|
||||
AlbumId = "ext-squidwtf-album-feature",
|
||||
Album = "Feature Album",
|
||||
AlbumArtist = "Artist B",
|
||||
Title = "Feature Song 2",
|
||||
Year = 2023
|
||||
},
|
||||
new()
|
||||
{
|
||||
AlbumId = "ext-squidwtf-album-comp",
|
||||
Album = "Compilation",
|
||||
AlbumArtist = "Various Artists",
|
||||
Title = "Compilation Song",
|
||||
Year = 2022,
|
||||
CoverArtUrl = "https://example.com/cover.jpg",
|
||||
TotalTracks = 10
|
||||
}
|
||||
});
|
||||
|
||||
var result = await _service.GetAppearsOnAlbumsAsync("squidwtf", "artist-a");
|
||||
|
||||
Assert.Collection(
|
||||
result,
|
||||
album =>
|
||||
{
|
||||
Assert.Equal("Feature Album", album.Title);
|
||||
Assert.Equal("Artist B", album.Artist);
|
||||
Assert.Equal("feature", album.ExternalId);
|
||||
},
|
||||
album =>
|
||||
{
|
||||
Assert.Equal("Compilation", album.Title);
|
||||
Assert.Equal("Various Artists", album.Artist);
|
||||
Assert.Equal("comp", album.ExternalId);
|
||||
Assert.Equal(10, album.SongCount);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAppearsOnAlbumsAsync_WhenTrackDataIsUnavailable_FallsBackToNonPrimaryAlbumsFromAlbumList()
|
||||
{
|
||||
var artist = new Artist
|
||||
{
|
||||
Id = "ext-qobuz-artist-artist-a",
|
||||
Name = "Artist A"
|
||||
};
|
||||
|
||||
_metadataService
|
||||
.Setup(service => service.GetArtistAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(artist);
|
||||
|
||||
_metadataService
|
||||
.Setup(service => service.GetArtistAlbumsAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Album>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = "ext-qobuz-album-own",
|
||||
Title = "Own Album",
|
||||
Artist = "Artist A",
|
||||
ArtistId = artist.Id,
|
||||
Year = 2024,
|
||||
IsLocal = false,
|
||||
ExternalProvider = "qobuz",
|
||||
ExternalId = "own"
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = "ext-qobuz-album-feature",
|
||||
Title = "Feature Album",
|
||||
Artist = "Artist C",
|
||||
ArtistId = "ext-qobuz-artist-artist-c",
|
||||
Year = 2021,
|
||||
IsLocal = false,
|
||||
ExternalProvider = "qobuz",
|
||||
ExternalId = "feature"
|
||||
}
|
||||
});
|
||||
|
||||
_metadataService
|
||||
.Setup(service => service.GetArtistTracksAsync("qobuz", "artist-a", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Song>());
|
||||
|
||||
var result = await _service.GetAppearsOnAlbumsAsync("qobuz", "artist-a");
|
||||
|
||||
var album = Assert.Single(result);
|
||||
Assert.Equal("Feature Album", album.Title);
|
||||
Assert.Equal("Artist C", album.Artist);
|
||||
Assert.Equal("feature", album.ExternalId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAppearsOnAlbumsAsync_WhenArtistLookupFails_ReturnsEmpty()
|
||||
{
|
||||
_metadataService
|
||||
.Setup(service => service.GetArtistAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync((Artist?)null);
|
||||
|
||||
_metadataService
|
||||
.Setup(service => service.GetArtistAlbumsAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Album>());
|
||||
|
||||
_metadataService
|
||||
.Setup(service => service.GetArtistTracksAsync("squidwtf", "missing", It.IsAny<CancellationToken>()))
|
||||
.ReturnsAsync(new List<Song>());
|
||||
|
||||
var result = await _service.GetAppearsOnAlbumsAsync("squidwtf", "missing");
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -20,6 +21,7 @@ public class JellyfinProxyServiceTests
|
||||
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public JellyfinProxyServiceTests()
|
||||
{
|
||||
@@ -31,7 +33,7 @@ public class JellyfinProxyServiceTests
|
||||
|
||||
var redisSettings = new RedisSettings { Enabled = false };
|
||||
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
|
||||
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
_settings = new JellyfinSettings
|
||||
{
|
||||
@@ -45,19 +47,21 @@ public class JellyfinProxyServiceTests
|
||||
};
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
_httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
||||
var userResolver = CreateUserContextResolver(_httpContextAccessor);
|
||||
|
||||
// Initialize cache settings for tests
|
||||
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
|
||||
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
|
||||
var serviceProvider = serviceCollection.BuildServiceProvider();
|
||||
CacheExtensions.InitializeCacheSettings(serviceProvider);
|
||||
allstarr.Services.Common.CacheExtensions.InitializeCacheSettings(serviceProvider);
|
||||
|
||||
_service = new JellyfinProxyService(
|
||||
_mockHttpClientFactory.Object,
|
||||
Options.Create(_settings),
|
||||
httpContextAccessor,
|
||||
_httpContextAccessor,
|
||||
userResolver,
|
||||
mockLogger.Object,
|
||||
_cache);
|
||||
}
|
||||
@@ -93,6 +97,21 @@ public class JellyfinProxyServiceTests
|
||||
Assert.Equal(500, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJsonAsync_ServerErrorWithJsonBody_ReturnsParsedErrorDocument()
|
||||
{
|
||||
// Arrange
|
||||
SetupMockResponse(HttpStatusCode.Unauthorized, "{\"Message\":\"Token expired\"}", "application/json");
|
||||
|
||||
// Act
|
||||
var (body, statusCode) = await _service.GetJsonAsync("Items");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(401, statusCode);
|
||||
Assert.Equal("Token expired", body.RootElement.GetProperty("Message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
|
||||
{
|
||||
@@ -228,6 +247,44 @@ public class JellyfinProxyServiceTests
|
||||
Assert.Equal("test query", searchTermValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_WithClientToken_ResolvesAndAppendsRequestUserId()
|
||||
{
|
||||
var requestedUris = new List<string>();
|
||||
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
|
||||
{
|
||||
requestedUris.Add(req.RequestUri!.ToString());
|
||||
|
||||
if (req.RequestUri!.AbsolutePath.EndsWith("/Users/Me", StringComparison.Ordinal))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Id\":\"resolved-user\"}")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[],\"TotalRecordCount\":0}")
|
||||
};
|
||||
});
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Token"] = "token-123"
|
||||
};
|
||||
|
||||
await _service.SearchAsync("test query", new[] { "Audio" }, 25, clientHeaders: headers);
|
||||
|
||||
Assert.Contains(requestedUris, uri => uri.EndsWith("/Users/Me"));
|
||||
Assert.Contains(requestedUris, uri => uri.Contains("userId=resolved-user", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetItemAsync_RequestsCorrectEndpoint()
|
||||
{
|
||||
@@ -629,12 +686,14 @@ public class JellyfinProxyServiceTests
|
||||
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
||||
var redisSettings = new RedisSettings { Enabled = false };
|
||||
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
|
||||
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
|
||||
var userResolver = CreateUserContextResolver(httpContextAccessor);
|
||||
|
||||
var service = new JellyfinProxyService(
|
||||
_mockHttpClientFactory.Object,
|
||||
Options.Create(_settings),
|
||||
httpContextAccessor,
|
||||
userResolver,
|
||||
mockLogger.Object,
|
||||
cache);
|
||||
|
||||
@@ -660,4 +719,14 @@ public class JellyfinProxyServiceTests
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
}
|
||||
|
||||
private JellyfinUserContextResolver CreateUserContextResolver(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
return new JellyfinUserContextResolver(
|
||||
httpContextAccessor,
|
||||
_mockHttpClientFactory.Object,
|
||||
Options.Create(_settings),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new Mock<ILogger<JellyfinUserContextResolver>>().Object);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using allstarr.Controllers;
|
||||
using allstarr.Models.Jellyfin;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
@@ -8,16 +10,16 @@ public class JellyfinSearchResponseSerializationTests
|
||||
[Fact]
|
||||
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
|
||||
{
|
||||
var payload = new
|
||||
var payload = new JellyfinItemsResponse
|
||||
{
|
||||
Items = new[]
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = "BTS",
|
||||
["Type"] = "MusicAlbum"
|
||||
}
|
||||
},
|
||||
],
|
||||
TotalRecordCount = 1,
|
||||
StartIndex = 0
|
||||
};
|
||||
@@ -28,11 +30,64 @@ public class JellyfinSearchResponseSerializationTests
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
var closedMethod = method!.MakeGenericMethod(payload.GetType());
|
||||
var json = (string)closedMethod.Invoke(null, new object?[] { payload })!;
|
||||
var json = (string)method!.Invoke(null, new object?[] { payload })!;
|
||||
|
||||
Assert.Equal(
|
||||
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
|
||||
json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeSearchResponseJson_FallsBackForMixedRuntimeShapes()
|
||||
{
|
||||
using var rawDoc = JsonDocument.Parse("""
|
||||
{
|
||||
"ServerId": "c17d351d3af24c678a6d8049c212d522",
|
||||
"RunTimeTicks": 2234068710
|
||||
}
|
||||
""");
|
||||
|
||||
var payload = new JellyfinItemsResponse
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = "Harleys in Hawaii",
|
||||
["Type"] = "MusicAlbum",
|
||||
["MediaSources"] = new Dictionary<string, object?>[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["RunTimeTicks"] = 2234068710L,
|
||||
["MediaAttachments"] = new List<object>(),
|
||||
["Formats"] = new List<string>(),
|
||||
["RequiredHttpHeaders"] = new Dictionary<string, string>()
|
||||
}
|
||||
},
|
||||
["ArtistItems"] = new List<object>
|
||||
{
|
||||
new Dictionary<string, object?> { ["Name"] = "Katy Perry" }
|
||||
},
|
||||
["RawItem"] = rawDoc.RootElement.Clone()
|
||||
}
|
||||
],
|
||||
TotalRecordCount = 1,
|
||||
StartIndex = 0
|
||||
};
|
||||
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"SerializeSearchResponseJson",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
var json = (string)method!.Invoke(null, new object?[] { payload })!;
|
||||
|
||||
Assert.Contains("\"Items\":[{", json);
|
||||
Assert.Contains("\"MediaAttachments\":[]", json);
|
||||
Assert.Contains("\"ArtistItems\":[{\"Name\":\"Katy Perry\"}]", json);
|
||||
Assert.Contains("\"RawItem\":{\"ServerId\":\"c17d351d3af24c678a6d8049c212d522\",\"RunTimeTicks\":2234068710}", json);
|
||||
Assert.Contains("\"TotalRecordCount\":1", json);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,9 +52,17 @@ public class JellyfinSessionManagerTests
|
||||
public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
|
||||
{
|
||||
var requestedPaths = new ConcurrentBag<string>();
|
||||
var requestBodies = new ConcurrentDictionary<string, string>();
|
||||
var handler = new DelegateHttpMessageHandler((request, _) =>
|
||||
{
|
||||
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
requestedPaths.Add(path);
|
||||
|
||||
if (request.Content != null)
|
||||
{
|
||||
requestBodies[path] = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
});
|
||||
|
||||
@@ -89,6 +97,12 @@ public class JellyfinSessionManagerTests
|
||||
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
|
||||
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
|
||||
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
|
||||
Assert.Equal(
|
||||
"{\"PlayableMediaTypes\":[\"Audio\"],\"SupportedCommands\":[\"Play\",\"Playstate\",\"PlayNext\"],\"SupportsMediaControl\":true,\"SupportsPersistentIdentifier\":true,\"SupportsSync\":false}",
|
||||
requestBodies["/Sessions/Capabilities/Full"]);
|
||||
Assert.Equal(
|
||||
"{\"ItemId\":\"item-123\",\"PositionTicks\":42}",
|
||||
requestBodies["/Sessions/Playing/Stopped"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -182,12 +196,19 @@ public class JellyfinSessionManagerTests
|
||||
|
||||
var cache = new RedisCacheService(
|
||||
Options.Create(new RedisSettings { Enabled = false }),
|
||||
NullLogger<RedisCacheService>.Instance);
|
||||
NullLogger<RedisCacheService>.Instance,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
|
||||
return new JellyfinProxyService(
|
||||
httpClientFactory,
|
||||
Options.Create(settings),
|
||||
httpContextAccessor,
|
||||
new JellyfinUserContextResolver(
|
||||
httpContextAccessor,
|
||||
httpClientFactory,
|
||||
Options.Create(settings),
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
|
||||
NullLogger<JellyfinUserContextResolver>.Instance),
|
||||
NullLogger<JellyfinProxyService>.Instance,
|
||||
cache);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using allstarr.Services.Lyrics;
|
||||
using allstarr.Services.Common;
|
||||
@@ -23,7 +24,7 @@ public class LrclibServiceTests
|
||||
// Create mock Redis cache
|
||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
@@ -23,11 +27,19 @@ public class RedisCacheServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
private RedisCacheService CreateService(IMemoryCache? memoryCache = null, IOptions<RedisSettings>? settings = null)
|
||||
{
|
||||
return new RedisCacheService(
|
||||
settings ?? _settings,
|
||||
_mockLogger.Object,
|
||||
memoryCache ?? new MemoryCache(new MemoryCacheOptions()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithSettings()
|
||||
{
|
||||
// Act
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
@@ -45,7 +57,7 @@ public class RedisCacheServiceTests
|
||||
});
|
||||
|
||||
// Act - Constructor will try to connect but should handle failure gracefully
|
||||
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||
var service = CreateService(settings: enabledSettings);
|
||||
|
||||
// Assert - Service should be created even if connection fails
|
||||
Assert.NotNull(service);
|
||||
@@ -55,7 +67,7 @@ public class RedisCacheServiceTests
|
||||
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetStringAsync("test:key");
|
||||
@@ -68,7 +80,7 @@ public class RedisCacheServiceTests
|
||||
public async Task GetAsync_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync<TestObject>("test:key");
|
||||
@@ -81,7 +93,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.SetStringAsync("test:key", "test value");
|
||||
@@ -94,7 +106,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||
|
||||
// Act
|
||||
@@ -108,7 +120,7 @@ public class RedisCacheServiceTests
|
||||
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.DeleteAsync("test:key");
|
||||
@@ -121,7 +133,7 @@ public class RedisCacheServiceTests
|
||||
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.ExistsAsync("test:key");
|
||||
@@ -134,7 +146,7 @@ public class RedisCacheServiceTests
|
||||
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.DeleteByPatternAsync("test:*");
|
||||
@@ -147,7 +159,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
var expiry = TimeSpan.FromHours(1);
|
||||
|
||||
// Act
|
||||
@@ -161,7 +173,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||
var expiry = TimeSpan.FromDays(30);
|
||||
|
||||
@@ -176,14 +188,14 @@ public class RedisCacheServiceTests
|
||||
public void IsEnabled_ReflectsSettings()
|
||||
{
|
||||
// Arrange
|
||||
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var disabledService = CreateService();
|
||||
|
||||
var enabledSettings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||
var enabledService = CreateService(settings: enabledSettings);
|
||||
|
||||
// Assert
|
||||
Assert.False(disabledService.IsEnabled);
|
||||
@@ -194,7 +206,7 @@ public class RedisCacheServiceTests
|
||||
public async Task GetAsync_DeserializesComplexObjects()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync<ComplexTestObject>("test:complex");
|
||||
@@ -207,7 +219,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetAsync_SerializesComplexObjects()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
var complexObj = new ComplexTestObject
|
||||
{
|
||||
Id = 1,
|
||||
@@ -238,12 +250,184 @@ public class RedisCacheServiceTests
|
||||
});
|
||||
|
||||
// Act
|
||||
var service = new RedisCacheService(customSettings, _mockLogger.Object);
|
||||
var service = CreateService(settings: customSettings);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStringAsync_WhenDisabled_CachesValueInMemory()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var setResult = await service.SetStringAsync("test:key", "test value");
|
||||
var cachedValue = await service.GetStringAsync("test:key");
|
||||
|
||||
Assert.False(setResult);
|
||||
Assert.Equal("test value", cachedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenDisabled_CachesSerializedObjectInMemory()
|
||||
{
|
||||
var service = CreateService();
|
||||
var expected = new TestObject { Id = 42, Name = "Tiered" };
|
||||
|
||||
var setResult = await service.SetAsync("test:object", expected);
|
||||
var cachedValue = await service.GetAsync<TestObject>("test:object");
|
||||
|
||||
Assert.False(setResult);
|
||||
Assert.NotNull(cachedValue);
|
||||
Assert.Equal(expected.Id, cachedValue.Id);
|
||||
Assert.Equal(expected.Name, cachedValue.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_WhenValueOnlyExistsInMemory_ReturnsTrue()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await service.SetStringAsync("test:key", "test value");
|
||||
|
||||
var exists = await service.ExistsAsync("test:key");
|
||||
|
||||
Assert.True(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WhenValueOnlyExistsInMemory_EvictsEntry()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await service.SetStringAsync("test:key", "test value");
|
||||
|
||||
var deleted = await service.DeleteAsync("test:key");
|
||||
var cachedValue = await service.GetStringAsync("test:key");
|
||||
|
||||
Assert.False(deleted);
|
||||
Assert.Null(cachedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByPatternAsync_WhenValuesOnlyExistInMemory_RemovesMatchingEntries()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await service.SetStringAsync("search:one", "1");
|
||||
await service.SetStringAsync("search:two", "2");
|
||||
await service.SetStringAsync("other:one", "3");
|
||||
|
||||
var deletedCount = await service.DeleteByPatternAsync("search:*");
|
||||
|
||||
Assert.Equal(2, deletedCount);
|
||||
Assert.Null(await service.GetStringAsync("search:one"));
|
||||
Assert.Null(await service.GetStringAsync("search:two"));
|
||||
Assert.Equal("3", await service.GetStringAsync("other:one"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStringAsync_ImageKeysDoNotUseMemoryCache()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await service.SetStringAsync("image:test:key", "binary-ish");
|
||||
|
||||
var cachedValue = await service.GetStringAsync("image:test:key");
|
||||
var exists = await service.ExistsAsync("image:test:key");
|
||||
|
||||
Assert.Null(cachedValue);
|
||||
Assert.False(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenSongContainsRawJellyfinMetadata_CachesSerializedValueInMemory()
|
||||
{
|
||||
var service = CreateService();
|
||||
var songs = new List<Song> { CreateLocalSongWithRawJellyfinMetadata() };
|
||||
|
||||
var setResult = await service.SetAsync("test:songs:raw-jellyfin", songs);
|
||||
var cachedValue = await service.GetAsync<List<Song>>("test:songs:raw-jellyfin");
|
||||
|
||||
Assert.False(setResult);
|
||||
Assert.NotNull(cachedValue);
|
||||
|
||||
var roundTrippedSong = Assert.Single(cachedValue!);
|
||||
Assert.True(JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedSong, out var rawItem));
|
||||
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
|
||||
|
||||
var mediaSources = Assert.IsType<JsonElement>(roundTrippedSong.JellyfinMetadata!["MediaSources"]);
|
||||
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
|
||||
Assert.Equal(2234068710L, mediaSources[0].GetProperty("RunTimeTicks").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenMatchedTracksContainRawJellyfinMetadata_CachesSerializedValueInMemory()
|
||||
{
|
||||
var service = CreateService();
|
||||
var matchedTracks = new List<MatchedTrack>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Position = 0,
|
||||
SpotifyId = "spotify-1",
|
||||
SpotifyTitle = "Track",
|
||||
SpotifyArtist = "Artist",
|
||||
MatchType = "fuzzy",
|
||||
MatchedSong = CreateLocalSongWithRawJellyfinMetadata()
|
||||
}
|
||||
};
|
||||
|
||||
var setResult = await service.SetAsync("test:matched:raw-jellyfin", matchedTracks);
|
||||
var cachedValue = await service.GetAsync<List<MatchedTrack>>("test:matched:raw-jellyfin");
|
||||
|
||||
Assert.False(setResult);
|
||||
Assert.NotNull(cachedValue);
|
||||
|
||||
var roundTrippedMatch = Assert.Single(cachedValue!);
|
||||
Assert.True(
|
||||
JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedMatch.MatchedSong, out var rawItem));
|
||||
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
|
||||
|
||||
var mediaSources =
|
||||
Assert.IsType<JsonElement>(roundTrippedMatch.MatchedSong.JellyfinMetadata!["MediaSources"]);
|
||||
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
|
||||
Assert.Equal("song-1", mediaSources[0].GetProperty("Id").GetString());
|
||||
}
|
||||
|
||||
private static Song CreateLocalSongWithRawJellyfinMetadata()
|
||||
{
|
||||
var song = new Song
|
||||
{
|
||||
Id = "song-1",
|
||||
Title = "Track",
|
||||
Artist = "Artist",
|
||||
Album = "Album",
|
||||
IsLocal = true
|
||||
};
|
||||
|
||||
using var doc = JsonDocument.Parse("""
|
||||
{
|
||||
"Id": "song-1",
|
||||
"ServerId": "c17d351d3af24c678a6d8049c212d522",
|
||||
"RunTimeTicks": 2234068710,
|
||||
"MediaSources": [
|
||||
{
|
||||
"Id": "song-1",
|
||||
"RunTimeTicks": 2234068710
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
JellyfinItemSnapshotHelper.StoreRawItemSnapshot(song, doc.RootElement);
|
||||
song.JellyfinMetadata ??= new Dictionary<string, object?>();
|
||||
song.JellyfinMetadata["MediaSources"] =
|
||||
JsonSerializer.Deserialize<object>(doc.RootElement.GetProperty("MediaSources").GetRawText());
|
||||
|
||||
return song;
|
||||
}
|
||||
|
||||
private class TestObject
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
@@ -157,31 +157,6 @@ public class SpotifyApiClientTests
|
||||
Assert.Equal(new DateTime(2026, 2, 16, 5, 0, 0, DateTimeKind.Utc), track.AddedAt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetSpotifyPlaylistItemCount_ParsesAttributesArrayEntries()
|
||||
{
|
||||
// Arrange
|
||||
using var doc = JsonDocument.Parse("""
|
||||
{
|
||||
"attributes": [
|
||||
{ "key": "core:item_count", "value": "42" }
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
var method = typeof(SpotifyApiClient).GetMethod(
|
||||
"TryGetSpotifyPlaylistItemCount",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
// Act
|
||||
var result = (int)method!.Invoke(null, new object?[] { doc.RootElement })!;
|
||||
|
||||
// Assert
|
||||
Assert.Equal(42, result);
|
||||
}
|
||||
|
||||
private static T InvokePrivateMethod<T>(object instance, string methodName, params object?[] args)
|
||||
{
|
||||
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
@@ -2,6 +2,7 @@ using Xunit;
|
||||
using Moq;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.Spotify;
|
||||
@@ -30,7 +31,7 @@ public class SpotifyMappingServiceTests
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
|
||||
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object);
|
||||
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
|
||||
_service = new SpotifyMappingService(_cache, _mockLogger.Object);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class SpotifyPlaylistScopeResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveConfig_PrefersExactJellyfinIdOverDuplicatePlaylistName()
|
||||
{
|
||||
var settings = new SpotifyImportSettings
|
||||
{
|
||||
Playlists =
|
||||
{
|
||||
new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = "Discover Weekly",
|
||||
Id = "spotify-a",
|
||||
JellyfinId = "jellyfin-a",
|
||||
UserId = "user-a"
|
||||
},
|
||||
new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = "Discover Weekly",
|
||||
Id = "spotify-b",
|
||||
JellyfinId = "jellyfin-b",
|
||||
UserId = "user-b"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var resolved = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
settings,
|
||||
"Discover Weekly",
|
||||
userId: "user-a",
|
||||
jellyfinPlaylistId: "jellyfin-b");
|
||||
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal("spotify-b", resolved!.Id);
|
||||
Assert.Equal("jellyfin-b", resolved.JellyfinId);
|
||||
Assert.Equal("user-b", resolved.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUserId_PrefersConfiguredValueAndTrimsFallback()
|
||||
{
|
||||
var configured = new SpotifyPlaylistConfig
|
||||
{
|
||||
UserId = " configured-user "
|
||||
};
|
||||
|
||||
Assert.Equal("configured-user", SpotifyPlaylistScopeResolver.GetUserId(configured, "fallback"));
|
||||
Assert.Equal("fallback-user", SpotifyPlaylistScopeResolver.GetUserId(null, " fallback-user "));
|
||||
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(null, " "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUserId_DoesNotApplyRequestFallbackToGlobalConfiguredPlaylist()
|
||||
{
|
||||
var globalConfiguredPlaylist = new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = "On Repeat",
|
||||
JellyfinId = "7c2b218bd69b00e24c986363ba71852f"
|
||||
};
|
||||
|
||||
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(
|
||||
globalConfiguredPlaylist,
|
||||
"1635cd7d23144ba08251ebe22a56119e"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScopeId_PrefersJellyfinIdThenSpotifyIdThenFallback()
|
||||
{
|
||||
var jellyfinScoped = new SpotifyPlaylistConfig
|
||||
{
|
||||
Id = "spotify-id",
|
||||
JellyfinId = " jellyfin-id "
|
||||
};
|
||||
|
||||
var spotifyScoped = new SpotifyPlaylistConfig
|
||||
{
|
||||
Id = " spotify-id "
|
||||
};
|
||||
|
||||
Assert.Equal("jellyfin-id", SpotifyPlaylistScopeResolver.GetScopeId(jellyfinScoped, "fallback"));
|
||||
Assert.Equal("spotify-id", SpotifyPlaylistScopeResolver.GetScopeId(spotifyScoped, "fallback"));
|
||||
Assert.Equal("fallback-id", SpotifyPlaylistScopeResolver.GetScopeId(null, " fallback-id "));
|
||||
Assert.Null(SpotifyPlaylistScopeResolver.GetScopeId(null, " "));
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,8 @@ public class SquidWTFDownloadServiceTests : IDisposable
|
||||
|
||||
var cache = new RedisCacheService(
|
||||
Options.Create(new RedisSettings { Enabled = false }),
|
||||
_redisLoggerMock.Object);
|
||||
_redisLoggerMock.Object,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
|
||||
var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.SquidWTF;
|
||||
@@ -42,7 +43,10 @@ public class SquidWTFMetadataServiceTests
|
||||
// Create mock Redis cache
|
||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||
_mockCache = new Mock<RedisCacheService>(
|
||||
mockRedisSettings,
|
||||
mockRedisLogger.Object,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
_apiUrls = new List<string>
|
||||
{
|
||||
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </summary>
|
||||
public const string Version = "1.5.3";
|
||||
public const string Version = "1.4.7";
|
||||
}
|
||||
|
||||
@@ -637,11 +637,11 @@ public class ConfigController : ControllerBase
|
||||
{
|
||||
var keysToDelete = new[]
|
||||
{
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
$"spotify:matched:{playlist.Name}", // Legacy key
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name)
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
|
||||
};
|
||||
|
||||
foreach (var key in keysToDelete)
|
||||
|
||||
@@ -9,7 +9,9 @@ using allstarr.Services.Admin;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Scrobbling;
|
||||
using allstarr.Services.SquidWTF;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
@@ -18,6 +20,9 @@ namespace allstarr.Controllers;
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class DiagnosticsController : ControllerBase
|
||||
{
|
||||
private const string SquidWtfProbeSearchQuery = "22 Taylor Swift";
|
||||
private const string SquidWtfProbeTrackId = "227242909";
|
||||
private const string SquidWtfProbeQuality = "LOW";
|
||||
private readonly ILogger<DiagnosticsController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
@@ -29,6 +34,8 @@ public class DiagnosticsController : ControllerBase
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly SpotifySessionCookieService _spotifySessionCookieService;
|
||||
private readonly List<string> _squidWtfApiUrls;
|
||||
private readonly List<string> _squidWtfStreamingUrls;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private static int _urlIndex = 0;
|
||||
private static readonly object _urlIndexLock = new();
|
||||
|
||||
@@ -43,7 +50,8 @@ public class DiagnosticsController : ControllerBase
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
SpotifySessionCookieService spotifySessionCookieService,
|
||||
SquidWtfEndpointCatalog squidWtfEndpointCatalog,
|
||||
RedisCacheService cache)
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
@@ -56,6 +64,8 @@ public class DiagnosticsController : ControllerBase
|
||||
_spotifySessionCookieService = spotifySessionCookieService;
|
||||
_cache = cache;
|
||||
_squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
|
||||
_squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
@@ -161,6 +171,61 @@ public class DiagnosticsController : ControllerBase
|
||||
return Ok(new { baseUrl });
|
||||
}
|
||||
|
||||
[HttpPost("squidwtf/endpoints/test")]
|
||||
public async Task<IActionResult> TestSquidWtfEndpoints(CancellationToken cancellationToken)
|
||||
{
|
||||
var forbidden = RequireAdministratorForSensitiveOperation("squidwtf endpoint diagnostics");
|
||||
if (forbidden != null)
|
||||
{
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rows = BuildSquidWtfEndpointRows();
|
||||
_logger.LogInformation(
|
||||
"Starting SquidWTF endpoint diagnostics for {RowCount} hosts ({ApiCount} API URLs, {StreamingCount} streaming URLs)",
|
||||
rows.Count,
|
||||
_squidWtfApiUrls.Count,
|
||||
_squidWtfStreamingUrls.Count);
|
||||
|
||||
var probeTasks = rows.Select(row => PopulateProbeResultsAsync(row, cancellationToken));
|
||||
await Task.WhenAll(probeTasks);
|
||||
|
||||
var apiUpCount = rows.Count(row => row.Api.Configured && row.Api.IsUp);
|
||||
var streamingUpCount = rows.Count(row => row.Streaming.Configured && row.Streaming.IsUp);
|
||||
_logger.LogInformation(
|
||||
"Completed SquidWTF endpoint diagnostics: API up {ApiUp}/{ApiConfigured}, streaming up {StreamingUp}/{StreamingConfigured}",
|
||||
apiUpCount,
|
||||
rows.Count(row => row.Api.Configured),
|
||||
streamingUpCount,
|
||||
rows.Count(row => row.Streaming.Configured));
|
||||
|
||||
var response = new SquidWtfEndpointHealthResponse
|
||||
{
|
||||
TestedAtUtc = DateTime.UtcNow,
|
||||
TotalRows = rows.Count,
|
||||
Endpoints = rows
|
||||
.OrderBy(r => r.Host, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test SquidWTF endpoints");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new
|
||||
{
|
||||
error = "Failed to test SquidWTF endpoints"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current configuration including cache settings
|
||||
/// </summary>
|
||||
@@ -423,6 +488,233 @@ public class DiagnosticsController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
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 List<SquidWtfEndpointHealthRow> BuildSquidWtfEndpointRows()
|
||||
{
|
||||
var rows = new Dictionary<string, SquidWtfEndpointHealthRow>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var apiUrl in _squidWtfApiUrls)
|
||||
{
|
||||
var key = GetEndpointKey(apiUrl);
|
||||
if (!rows.TryGetValue(key, out var row))
|
||||
{
|
||||
row = new SquidWtfEndpointHealthRow
|
||||
{
|
||||
Host = key
|
||||
};
|
||||
rows[key] = row;
|
||||
}
|
||||
|
||||
row.ApiUrl = apiUrl;
|
||||
}
|
||||
|
||||
foreach (var streamingUrl in _squidWtfStreamingUrls)
|
||||
{
|
||||
var key = GetEndpointKey(streamingUrl);
|
||||
if (!rows.TryGetValue(key, out var row))
|
||||
{
|
||||
row = new SquidWtfEndpointHealthRow
|
||||
{
|
||||
Host = key
|
||||
};
|
||||
rows[key] = row;
|
||||
}
|
||||
|
||||
row.StreamingUrl = streamingUrl;
|
||||
}
|
||||
|
||||
return rows.Values.ToList();
|
||||
}
|
||||
|
||||
private async Task PopulateProbeResultsAsync(SquidWtfEndpointHealthRow row, CancellationToken cancellationToken)
|
||||
{
|
||||
var apiTask = ProbeApiEndpointAsync(row.ApiUrl, cancellationToken);
|
||||
var streamingTask = ProbeStreamingEndpointAsync(row.StreamingUrl, cancellationToken);
|
||||
await Task.WhenAll(apiTask, streamingTask);
|
||||
|
||||
row.Api = await apiTask;
|
||||
row.Streaming = await streamingTask;
|
||||
|
||||
var anyFailure = (row.Api.Configured && !row.Api.IsUp) ||
|
||||
(row.Streaming.Configured && !row.Streaming.IsUp);
|
||||
_logger.Log(
|
||||
anyFailure ? LogLevel.Warning : LogLevel.Information,
|
||||
"SquidWTF probe {Host}: API {ApiState} ({ApiStatusCode}, {ApiLatencyMs}ms{ApiErrorSuffix}) | streaming {StreamingState} ({StreamingStatusCode}, {StreamingLatencyMs}ms{StreamingErrorSuffix})",
|
||||
row.Host,
|
||||
row.Api.State,
|
||||
row.Api.StatusCode?.ToString() ?? "n/a",
|
||||
row.Api.LatencyMs?.ToString() ?? "n/a",
|
||||
string.IsNullOrWhiteSpace(row.Api.Error) ? string.Empty : $", {row.Api.Error}",
|
||||
row.Streaming.State,
|
||||
row.Streaming.StatusCode?.ToString() ?? "n/a",
|
||||
row.Streaming.LatencyMs?.ToString() ?? "n/a",
|
||||
string.IsNullOrWhiteSpace(row.Streaming.Error) ? string.Empty : $", {row.Streaming.Error}");
|
||||
}
|
||||
|
||||
private async Task<SquidWtfEndpointProbeResult> ProbeApiEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = false,
|
||||
State = "missing",
|
||||
Error = "No API URL configured"
|
||||
};
|
||||
}
|
||||
|
||||
var requestUrl = $"{baseUrl}/search/?s={Uri.EscapeDataString(SquidWtfProbeSearchQuery)}&limit=1&offset=0";
|
||||
return await ProbeEndpointAsync(
|
||||
requestUrl,
|
||||
response => ResponseContainsSearchItemsAsync(response, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<SquidWtfEndpointProbeResult> ProbeStreamingEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = false,
|
||||
State = "missing",
|
||||
Error = "No streaming URL configured"
|
||||
};
|
||||
}
|
||||
|
||||
var requestUrl = $"{baseUrl}/track/?id={Uri.EscapeDataString(SquidWtfProbeTrackId)}&quality={Uri.EscapeDataString(SquidWtfProbeQuality)}";
|
||||
return await ProbeEndpointAsync(
|
||||
requestUrl,
|
||||
response => ResponseContainsTrackManifestAsync(response, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<SquidWtfEndpointProbeResult> ProbeEndpointAsync(
|
||||
string requestUrl,
|
||||
Func<HttpResponseMessage, Task<bool>> isHealthyResponse,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = CreateDiagnosticsHttpClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
stopwatch.Stop();
|
||||
|
||||
var isHealthy = response.IsSuccessStatusCode && await isHealthyResponse(response);
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = true,
|
||||
IsUp = isHealthy,
|
||||
State = isHealthy ? "up" : "down",
|
||||
StatusCode = (int)response.StatusCode,
|
||||
LatencyMs = stopwatch.ElapsedMilliseconds,
|
||||
RequestUrl = requestUrl,
|
||||
Error = isHealthy ? null : $"Unexpected {(int)response.StatusCode} response"
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = true,
|
||||
State = "timeout",
|
||||
LatencyMs = stopwatch.ElapsedMilliseconds,
|
||||
RequestUrl = requestUrl,
|
||||
Error = "Timed out"
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = true,
|
||||
State = "down",
|
||||
StatusCode = ex.StatusCode.HasValue ? (int)ex.StatusCode.Value : null,
|
||||
LatencyMs = stopwatch.ElapsedMilliseconds,
|
||||
RequestUrl = requestUrl,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = true,
|
||||
State = "down",
|
||||
LatencyMs = stopwatch.ElapsedMilliseconds,
|
||||
RequestUrl = requestUrl,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient CreateDiagnosticsHttpClient()
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
if (!client.DefaultRequestHeaders.UserAgent.Any())
|
||||
{
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("allstarr-admin-diagnostics/1.0");
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<bool> ResponseContainsSearchItemsAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
return document.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("items", out var items) &&
|
||||
items.ValueKind == JsonValueKind.Array;
|
||||
}
|
||||
|
||||
private static async Task<bool> ResponseContainsTrackManifestAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
return document.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("manifest", out var manifest) &&
|
||||
manifest.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(manifest.GetString());
|
||||
}
|
||||
|
||||
private static string GetEndpointKey(string url)
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
|
||||
}
|
||||
|
||||
return url.Trim();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Lyrics;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
@@ -10,20 +9,15 @@ namespace allstarr.Controllers;
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class DownloadsController : ControllerBase
|
||||
{
|
||||
private static readonly string[] AudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
|
||||
|
||||
private readonly ILogger<DownloadsController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
|
||||
|
||||
public DownloadsController(
|
||||
ILogger<DownloadsController> logger,
|
||||
IConfiguration configuration,
|
||||
IKeptLyricsSidecarService? keptLyricsSidecarService = null)
|
||||
IConfiguration configuration)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
_keptLyricsSidecarService = keptLyricsSidecarService;
|
||||
}
|
||||
|
||||
[HttpGet("downloads")]
|
||||
@@ -42,8 +36,10 @@ public class DownloadsController : ControllerBase
|
||||
long totalSize = 0;
|
||||
|
||||
// Recursively get all audio files from kept folder
|
||||
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
||||
|
||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(IsSupportedAudioFile)
|
||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||
.ToList();
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
@@ -116,11 +112,6 @@ public class DownloadsController : ControllerBase
|
||||
}
|
||||
|
||||
System.IO.File.Delete(fullPath);
|
||||
var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(fullPath) ?? Path.ChangeExtension(fullPath, ".lrc");
|
||||
if (System.IO.File.Exists(sidecarPath))
|
||||
{
|
||||
System.IO.File.Delete(sidecarPath);
|
||||
}
|
||||
|
||||
// Clean up empty directories (Album folder, then Artist folder if empty)
|
||||
var directory = Path.GetDirectoryName(fullPath);
|
||||
@@ -148,67 +139,12 @@ public class DownloadsController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DELETE /api/admin/downloads/all
|
||||
/// Deletes all kept audio files and removes empty folders
|
||||
/// </summary>
|
||||
[HttpDelete("downloads/all")]
|
||||
public IActionResult DeleteAllDownloads()
|
||||
{
|
||||
try
|
||||
{
|
||||
var keptPath = Path.GetFullPath(Path.Combine(_configuration["Library:DownloadPath"] ?? "./downloads", "kept"));
|
||||
if (!Directory.Exists(keptPath))
|
||||
{
|
||||
return Ok(new { success = true, deletedCount = 0, message = "No kept downloads found" });
|
||||
}
|
||||
|
||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(IsSupportedAudioFile)
|
||||
.ToList();
|
||||
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
System.IO.File.Delete(filePath);
|
||||
}
|
||||
|
||||
var sidecarFiles = Directory.GetFiles(keptPath, "*.lrc", SearchOption.AllDirectories);
|
||||
foreach (var sidecarFile in sidecarFiles)
|
||||
{
|
||||
System.IO.File.Delete(sidecarFile);
|
||||
}
|
||||
|
||||
// Clean up empty directories under kept root (deepest first)
|
||||
var allDirectories = Directory.GetDirectories(keptPath, "*", SearchOption.AllDirectories)
|
||||
.OrderByDescending(d => d.Length);
|
||||
foreach (var directory in allDirectories)
|
||||
{
|
||||
if (!Directory.EnumerateFileSystemEntries(directory).Any())
|
||||
{
|
||||
Directory.Delete(directory);
|
||||
}
|
||||
}
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
deletedCount = allFiles.Count,
|
||||
message = $"Deleted {allFiles.Count} kept download(s)"
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to delete all kept downloads");
|
||||
return StatusCode(500, new { error = "Failed to delete all kept downloads" });
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// GET /api/admin/downloads/file
|
||||
/// Downloads a specific file from the kept folder
|
||||
/// </summary>
|
||||
[HttpGet("downloads/file")]
|
||||
public async Task<IActionResult> DownloadFile([FromQuery] string path)
|
||||
public IActionResult DownloadFile([FromQuery] string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -230,16 +166,8 @@ public class DownloadsController : ControllerBase
|
||||
}
|
||||
|
||||
var fileName = Path.GetFileName(fullPath);
|
||||
if (IsSupportedAudioFile(fullPath))
|
||||
{
|
||||
var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(fullPath, HttpContext.RequestAborted);
|
||||
if (System.IO.File.Exists(sidecarPath))
|
||||
{
|
||||
return await CreateSingleTrackArchiveAsync(fullPath, sidecarPath, fileName);
|
||||
}
|
||||
}
|
||||
|
||||
var fileStream = System.IO.File.OpenRead(fullPath);
|
||||
|
||||
return File(fileStream, "application/octet-stream", fileName);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -254,7 +182,7 @@ public class DownloadsController : ControllerBase
|
||||
/// Downloads all kept files as a zip archive
|
||||
/// </summary>
|
||||
[HttpGet("downloads/all")]
|
||||
public async Task<IActionResult> DownloadAllFiles()
|
||||
public IActionResult DownloadAllFiles()
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -265,8 +193,9 @@ public class DownloadsController : ControllerBase
|
||||
return NotFound(new { error = "No kept files found" });
|
||||
}
|
||||
|
||||
var audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
||||
.Where(IsSupportedAudioFile)
|
||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
||||
.ToList();
|
||||
|
||||
if (allFiles.Count == 0)
|
||||
@@ -280,18 +209,14 @@ public class DownloadsController : ControllerBase
|
||||
var memoryStream = new MemoryStream();
|
||||
using (var archive = new System.IO.Compression.ZipArchive(memoryStream, System.IO.Compression.ZipArchiveMode.Create, true))
|
||||
{
|
||||
var addedEntries = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var filePath in allFiles)
|
||||
{
|
||||
var relativePath = Path.GetRelativePath(keptPath, filePath);
|
||||
await AddFileToArchiveAsync(archive, filePath, relativePath, addedEntries);
|
||||
var entry = archive.CreateEntry(relativePath, System.IO.Compression.CompressionLevel.NoCompression);
|
||||
|
||||
var sidecarPath = await EnsureLyricsSidecarIfPossibleAsync(filePath, HttpContext.RequestAborted);
|
||||
if (System.IO.File.Exists(sidecarPath))
|
||||
{
|
||||
var sidecarRelativePath = Path.GetRelativePath(keptPath, sidecarPath);
|
||||
await AddFileToArchiveAsync(archive, sidecarPath, sidecarRelativePath, addedEntries);
|
||||
}
|
||||
using var entryStream = entry.Open();
|
||||
using var fileStream = System.IO.File.OpenRead(filePath);
|
||||
fileStream.CopyTo(entryStream);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,54 +280,6 @@ public class DownloadsController : ControllerBase
|
||||
: StringComparison.Ordinal;
|
||||
}
|
||||
|
||||
private async Task<string> EnsureLyricsSidecarIfPossibleAsync(string audioFilePath, CancellationToken cancellationToken)
|
||||
{
|
||||
var sidecarPath = _keptLyricsSidecarService?.GetSidecarPath(audioFilePath) ?? Path.ChangeExtension(audioFilePath, ".lrc");
|
||||
if (System.IO.File.Exists(sidecarPath) || _keptLyricsSidecarService == null)
|
||||
{
|
||||
return sidecarPath;
|
||||
}
|
||||
|
||||
var generatedSidecar = await _keptLyricsSidecarService.EnsureSidecarAsync(audioFilePath, cancellationToken: cancellationToken);
|
||||
return generatedSidecar ?? sidecarPath;
|
||||
}
|
||||
|
||||
private async Task<IActionResult> CreateSingleTrackArchiveAsync(string audioFilePath, string sidecarPath, string fileName)
|
||||
{
|
||||
var archiveStream = new MemoryStream();
|
||||
using (var archive = new System.IO.Compression.ZipArchive(archiveStream, System.IO.Compression.ZipArchiveMode.Create, true))
|
||||
{
|
||||
await AddFileToArchiveAsync(archive, audioFilePath, Path.GetFileName(audioFilePath), null);
|
||||
await AddFileToArchiveAsync(archive, sidecarPath, Path.GetFileName(sidecarPath), null);
|
||||
}
|
||||
|
||||
archiveStream.Position = 0;
|
||||
var downloadName = $"{Path.GetFileNameWithoutExtension(fileName)}.zip";
|
||||
return File(archiveStream, "application/zip", downloadName);
|
||||
}
|
||||
|
||||
private static async Task AddFileToArchiveAsync(
|
||||
System.IO.Compression.ZipArchive archive,
|
||||
string filePath,
|
||||
string entryPath,
|
||||
HashSet<string>? addedEntries)
|
||||
{
|
||||
if (addedEntries != null && !addedEntries.Add(entryPath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var entry = archive.CreateEntry(entryPath, System.IO.Compression.CompressionLevel.NoCompression);
|
||||
await using var entryStream = entry.Open();
|
||||
await using var fileStream = System.IO.File.OpenRead(filePath);
|
||||
await fileStream.CopyToAsync(entryStream);
|
||||
}
|
||||
|
||||
private static bool IsSupportedAudioFile(string path)
|
||||
{
|
||||
return AudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets all Spotify track mappings (paginated)
|
||||
/// </summary>
|
||||
|
||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Net.Http;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
@@ -167,7 +168,7 @@ public partial class JellyfinController
|
||||
|
||||
if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate))
|
||||
{
|
||||
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistName);
|
||||
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistConfig);
|
||||
spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate;
|
||||
}
|
||||
|
||||
@@ -177,7 +178,16 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
|
||||
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
|
||||
? null
|
||||
: playlistConfig.UserId.Trim();
|
||||
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
|
||||
? playlistConfig.JellyfinId
|
||||
: playlistConfig.Id;
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
|
||||
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
|
||||
@@ -186,7 +196,10 @@ public partial class JellyfinController
|
||||
// Fallback to legacy cache format
|
||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||
{
|
||||
var legacyKey = $"spotify:matched:{playlistName}";
|
||||
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
|
||||
if (legacySongs != null && legacySongs.Count > 0)
|
||||
{
|
||||
@@ -202,7 +215,10 @@ public partial class JellyfinController
|
||||
// Prefer the currently served playlist items cache when available.
|
||||
// This most closely matches what the injected playlist endpoint will return.
|
||||
var exactServedCount = 0;
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
|
||||
var exactServedRunTimeTicks = 0L;
|
||||
if (cachedPlaylistItems != null &&
|
||||
@@ -231,7 +247,7 @@ public partial class JellyfinController
|
||||
var localRunTimeTicks = 0L;
|
||||
try
|
||||
{
|
||||
var userId = _settings.UserId;
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
@@ -334,11 +350,22 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(string playlistName)
|
||||
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(SpotifyPlaylistConfig playlistConfig)
|
||||
{
|
||||
var playlistName = playlistConfig.Name;
|
||||
|
||||
try
|
||||
{
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
|
||||
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
|
||||
? null
|
||||
: playlistConfig.UserId.Trim();
|
||||
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
|
||||
? playlistConfig.JellyfinId
|
||||
: playlistConfig.Id;
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var cachedPlaylist = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist);
|
||||
if (createdAt.HasValue)
|
||||
@@ -351,7 +378,10 @@ public partial class JellyfinController
|
||||
return null;
|
||||
}
|
||||
|
||||
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistConfig.JellyfinId);
|
||||
var earliestTrackAddedAt = tracks
|
||||
.Where(t => t.AddedAt.HasValue)
|
||||
.Select(t => t.AddedAt!.Value.ToUniversalTime())
|
||||
|
||||
@@ -245,9 +245,7 @@ public class JellyfinAdminController : ControllerBase
|
||||
/// Get all playlists from the user's Spotify account
|
||||
/// </summary>
|
||||
[HttpGet("jellyfin/playlists")]
|
||||
public async Task<IActionResult> GetJellyfinPlaylists(
|
||||
[FromQuery] string? userId = null,
|
||||
[FromQuery] bool includeStats = true)
|
||||
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||
{
|
||||
@@ -332,13 +330,13 @@ public class JellyfinAdminController : ControllerBase
|
||||
|
||||
var statsUserId = requestedUserId;
|
||||
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||
if (isConfigured && includeStats)
|
||||
if (isConfigured)
|
||||
{
|
||||
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
|
||||
}
|
||||
|
||||
var actualTrackCount = isConfigured
|
||||
? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount)
|
||||
? trackStats.LocalTracks + trackStats.ExternalTracks
|
||||
: childCount;
|
||||
|
||||
playlists.Add(new
|
||||
@@ -351,7 +349,6 @@ public class JellyfinAdminController : ControllerBase
|
||||
isLinkedByAnotherUser,
|
||||
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
|
||||
allLinkedForPlaylist.FirstOrDefault()?.UserId,
|
||||
statsPending = isConfigured && !includeStats,
|
||||
localTracks = trackStats.LocalTracks,
|
||||
externalTracks = trackStats.ExternalTracks,
|
||||
externalAvailable = trackStats.ExternalAvailable
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
using allstarr.Models.Jellyfin;
|
||||
using allstarr.Models.Scrobbling;
|
||||
using allstarr.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
@@ -226,7 +228,7 @@ public partial class JellyfinController
|
||||
|
||||
// Build minimal playback start with just the ghost UUID
|
||||
// Don't include the Item object - Jellyfin will just track the session without item details
|
||||
var playbackStart = new
|
||||
var playbackStart = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
@@ -236,7 +238,7 @@ public partial class JellyfinController
|
||||
PlayMethod = "DirectPlay"
|
||||
};
|
||||
|
||||
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
||||
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
|
||||
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
|
||||
|
||||
// Forward to Jellyfin with ghost UUID
|
||||
@@ -357,14 +359,13 @@ public partial class JellyfinController
|
||||
trackName ?? "Unknown", itemId);
|
||||
|
||||
// Build playback start info - Jellyfin will fetch item details itself
|
||||
var playbackStart = new
|
||||
var playbackStart = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
// Let Jellyfin fetch the item details - don't include NowPlayingItem
|
||||
ItemId = itemId ?? string.Empty,
|
||||
PositionTicks = positionTicks ?? 0
|
||||
};
|
||||
|
||||
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
||||
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
|
||||
_logger.LogDebug("📤 Sending playback start: {Json}", playbackJson);
|
||||
|
||||
var (result, statusCode) =
|
||||
@@ -624,7 +625,7 @@ public partial class JellyfinController
|
||||
externalId);
|
||||
|
||||
var inferredStartGhostUuid = GenerateUuidFromString(itemId);
|
||||
var inferredExternalStartPayload = JsonSerializer.Serialize(new
|
||||
var inferredExternalStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = inferredStartGhostUuid,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
@@ -692,7 +693,7 @@ public partial class JellyfinController
|
||||
var ghostUuid = GenerateUuidFromString(itemId);
|
||||
|
||||
// Build progress report with ghost UUID
|
||||
var progressReport = new
|
||||
var progressReport = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
@@ -702,7 +703,7 @@ public partial class JellyfinController
|
||||
PlayMethod = "DirectPlay"
|
||||
};
|
||||
|
||||
var progressJson = JsonSerializer.Serialize(progressReport);
|
||||
var progressJson = AllstarrJsonSerializer.Serialize(progressReport);
|
||||
|
||||
// Forward to Jellyfin with ghost UUID
|
||||
var (progressResult, progressStatusCode) =
|
||||
@@ -773,7 +774,7 @@ public partial class JellyfinController
|
||||
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
|
||||
trackName ?? "Unknown", itemId);
|
||||
|
||||
var inferredStartPayload = JsonSerializer.Serialize(new
|
||||
var inferredStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = positionTicks ?? 0
|
||||
@@ -948,7 +949,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
var ghostUuid = GenerateUuidFromString(previousItemId);
|
||||
var inferredExternalStopPayload = JsonSerializer.Serialize(new
|
||||
var inferredExternalStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = previousPositionTicks ?? 0,
|
||||
@@ -997,7 +998,7 @@ public partial class JellyfinController
|
||||
});
|
||||
}
|
||||
|
||||
var inferredStopPayload = JsonSerializer.Serialize(new
|
||||
var inferredStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = previousItemId,
|
||||
PositionTicks = previousPositionTicks ?? 0,
|
||||
@@ -1062,7 +1063,7 @@ public partial class JellyfinController
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = ResolvePlaybackUserId(progressPayload);
|
||||
var userId = await ResolvePlaybackUserIdAsync(progressPayload);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
_logger.LogDebug("Skipping local played signal for {ItemId} - no user id available", itemId);
|
||||
@@ -1098,7 +1099,7 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolvePlaybackUserId(JsonElement progressPayload)
|
||||
private async Task<string?> ResolvePlaybackUserIdAsync(JsonElement progressPayload)
|
||||
{
|
||||
if (progressPayload.TryGetProperty("UserId", out var userIdElement) &&
|
||||
userIdElement.ValueKind == JsonValueKind.String)
|
||||
@@ -1116,7 +1117,7 @@ public partial class JellyfinController
|
||||
return queryUserId;
|
||||
}
|
||||
|
||||
return _settings.UserId;
|
||||
return await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||
}
|
||||
|
||||
private static int? ToPlaybackPositionSeconds(long? positionTicks)
|
||||
@@ -1294,13 +1295,13 @@ public partial class JellyfinController
|
||||
// Report stop to Jellyfin with ghost UUID
|
||||
var ghostUuid = GenerateUuidFromString(itemId);
|
||||
|
||||
var externalStopInfo = new
|
||||
var externalStopInfo = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = positionTicks ?? 0
|
||||
};
|
||||
|
||||
var stopJson = JsonSerializer.Serialize(externalStopInfo);
|
||||
var stopJson = AllstarrJsonSerializer.Serialize(externalStopInfo);
|
||||
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
|
||||
|
||||
var (stopResult, stopStatusCode) =
|
||||
@@ -1469,7 +1470,7 @@ public partial class JellyfinController
|
||||
stopInfo["PositionTicks"] = positionTicks.Value;
|
||||
}
|
||||
|
||||
body = JsonSerializer.Serialize(stopInfo);
|
||||
body = AllstarrJsonSerializer.Serialize(stopInfo);
|
||||
_logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length);
|
||||
|
||||
var (result, statusCode) =
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using System.Buffers;
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Jellyfin;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Serialization;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -25,6 +28,7 @@ public partial class JellyfinController
|
||||
[FromQuery] int startIndex = 0,
|
||||
[FromQuery] string? parentId = null,
|
||||
[FromQuery] string? artistIds = null,
|
||||
[FromQuery] string? contributingArtistIds = null,
|
||||
[FromQuery] string? albumArtistIds = null,
|
||||
[FromQuery] string? albumIds = null,
|
||||
[FromQuery] string? sortBy = null,
|
||||
@@ -39,8 +43,8 @@ public partial class JellyfinController
|
||||
var effectiveArtistIds = albumArtistIds ?? artistIds;
|
||||
var favoritesOnlyRequest = IsFavoritesOnlyRequest();
|
||||
|
||||
_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.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, contributingArtistIds={ContributingArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
|
||||
searchTerm, includeItemTypes, parentId, artistIds, contributingArtistIds, albumArtistIds, albumIds, userId);
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'",
|
||||
Request.QueryString.Value ?? string.Empty,
|
||||
@@ -51,15 +55,34 @@ public partial class JellyfinController
|
||||
// ============================================================================
|
||||
// REQUEST ROUTING LOGIC (Priority Order)
|
||||
// ============================================================================
|
||||
// 1. ArtistIds present (external) → Handle external artists (even with ParentId)
|
||||
// 2. AlbumIds present (external) → Handle external albums (even with ParentId)
|
||||
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
|
||||
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter
|
||||
// 5. SearchTerm present → Integrated search (Jellyfin + external sources)
|
||||
// 6. Otherwise → Proxy browse request transparently to Jellyfin
|
||||
// 1. ContributingArtistIds present (external) → Handle external "appears on" albums
|
||||
// 2. ArtistIds present (external) → Handle external artists (even with ParentId)
|
||||
// 3. AlbumIds present (external) → Handle external albums (even with ParentId)
|
||||
// 4. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
|
||||
// 5. ArtistIds / ContributingArtistIds present (library) → Proxy to Jellyfin with full filter
|
||||
// 6. SearchTerm present → Integrated search (Jellyfin + external sources)
|
||||
// 7. Otherwise → Proxy browse request transparently to Jellyfin
|
||||
// ============================================================================
|
||||
|
||||
// PRIORITY 1: External artist filter - takes precedence over everything (including ParentId)
|
||||
// PRIORITY 1: External contributing artist filter - used by Jellyfin's "Appears on" album requests.
|
||||
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
|
||||
{
|
||||
var contributingArtistId = contributingArtistIds.Split(',')[0];
|
||||
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(contributingArtistId);
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Fetching contributing artist albums for external artist: {Provider}/{ExternalId}, type={Type}",
|
||||
provider,
|
||||
externalId,
|
||||
type);
|
||||
return await GetExternalContributorChildItems(provider!, type!, externalId!, includeItemTypes, HttpContext.RequestAborted);
|
||||
}
|
||||
// If library artist, fall through to proxy
|
||||
}
|
||||
|
||||
// PRIORITY 2: External artist filter - takes precedence over everything (including ParentId)
|
||||
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||
{
|
||||
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
|
||||
@@ -87,7 +110,7 @@ public partial class JellyfinController
|
||||
// If library artist, fall through to handle with ParentId or proxy
|
||||
}
|
||||
|
||||
// PRIORITY 2: External album filter
|
||||
// PRIORITY 3: External album filter
|
||||
if (!string.IsNullOrWhiteSpace(albumIds))
|
||||
{
|
||||
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
|
||||
@@ -107,23 +130,17 @@ public partial class JellyfinController
|
||||
var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted);
|
||||
if (album == null)
|
||||
{
|
||||
return new JsonResult(new
|
||||
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
|
||||
return CreateItemsResponse([], 0, startIndex);
|
||||
}
|
||||
|
||||
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = albumItems,
|
||||
TotalRecordCount = albumItems.Count,
|
||||
StartIndex = startIndex
|
||||
});
|
||||
return CreateItemsResponse(albumItems, albumItems.Count, startIndex);
|
||||
}
|
||||
// If library album, fall through to handle with ParentId or proxy
|
||||
}
|
||||
|
||||
// PRIORITY 3: ParentId present - check if external first
|
||||
// PRIORITY 4: ParentId present - check if external first
|
||||
if (!string.IsNullOrWhiteSpace(parentId))
|
||||
{
|
||||
// Check if this is an external playlist
|
||||
@@ -165,7 +182,17 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 4: Library artist filter (already checked for external above)
|
||||
// PRIORITY 5: Library artist/contributing-artist filters (already checked for external above)
|
||||
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
|
||||
{
|
||||
_logger.LogDebug("Library contributing artist filter requested, proxying to Jellyfin");
|
||||
var endpoint = userId != null
|
||||
? $"Users/{userId}/Items{Request.QueryString}"
|
||||
: $"Items{Request.QueryString}";
|
||||
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||
{
|
||||
// Library artist - proxy transparently with full query string
|
||||
@@ -177,7 +204,7 @@ public partial class JellyfinController
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
}
|
||||
|
||||
// PRIORITY 5: Search term present - do integrated search (Jellyfin + external)
|
||||
// PRIORITY 6: Search term present - do integrated search (Jellyfin + external)
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
// Check cache for search results (only cache pure searches, not filtered searches)
|
||||
@@ -205,7 +232,7 @@ public partial class JellyfinController
|
||||
|
||||
// Fall through to integrated search below
|
||||
}
|
||||
// PRIORITY 6: No filters, no search - proxy browse request transparently
|
||||
// PRIORITY 7: No filters, no search - proxy browse request transparently
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
|
||||
@@ -546,44 +573,20 @@ public partial class JellyfinController
|
||||
|
||||
try
|
||||
{
|
||||
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
|
||||
var response = new
|
||||
var response = new JellyfinItemsResponse
|
||||
{
|
||||
Items = pagedItems,
|
||||
TotalRecordCount = items.Count,
|
||||
StartIndex = startIndex
|
||||
};
|
||||
var json = SerializeSearchResponseJson(response);
|
||||
|
||||
// Cache search results in Redis using the configured search TTL.
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) &&
|
||||
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
|
||||
!string.IsNullOrWhiteSpace(searchCacheKey))
|
||||
{
|
||||
if (externalHasRequestedTypeResults)
|
||||
{
|
||||
await _cache.SetStringAsync(searchCacheKey, json, 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...");
|
||||
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
{
|
||||
var preview = json.Length > 200 ? json[..200] : json;
|
||||
_logger.LogDebug("JSON response preview: {Json}", preview);
|
||||
}
|
||||
|
||||
return Content(json, "application/json");
|
||||
return await WriteSearchItemsResponseAsync(
|
||||
response,
|
||||
searchTerm,
|
||||
effectiveArtistIds,
|
||||
searchCacheKey,
|
||||
externalHasRequestedTypeResults,
|
||||
cleanQuery,
|
||||
includeItemTypes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -592,13 +595,47 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeSearchResponseJson<T>(T response) where T : class
|
||||
private static string SerializeSearchResponseJson(JellyfinItemsResponse response)
|
||||
{
|
||||
return JsonSerializer.Serialize(response, new JsonSerializerOptions
|
||||
return AllstarrJsonSerializer.Serialize(response);
|
||||
}
|
||||
|
||||
private async Task<IActionResult> WriteSearchItemsResponseAsync(
|
||||
JellyfinItemsResponse response,
|
||||
string? searchTerm,
|
||||
string? effectiveArtistIds,
|
||||
string? searchCacheKey,
|
||||
bool externalHasRequestedTypeResults,
|
||||
string cleanQuery,
|
||||
string? includeItemTypes)
|
||||
{
|
||||
var shouldCache = !string.IsNullOrWhiteSpace(searchTerm) &&
|
||||
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
|
||||
!string.IsNullOrWhiteSpace(searchCacheKey) &&
|
||||
externalHasRequestedTypeResults;
|
||||
|
||||
Response.StatusCode = StatusCodes.Status200OK;
|
||||
Response.ContentType = "application/json";
|
||||
var json = SerializeSearchResponseJson(response);
|
||||
await Response.WriteAsync(json, Encoding.UTF8);
|
||||
|
||||
if (shouldCache)
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DictionaryKeyPolicy = null
|
||||
});
|
||||
await _cache.SetStringAsync(searchCacheKey!, json, CacheExtensions.SearchResultsTTL);
|
||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
|
||||
CacheExtensions.SearchResultsTTL.TotalMinutes);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm) &&
|
||||
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
|
||||
!string.IsNullOrWhiteSpace(searchCacheKey))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})",
|
||||
cleanQuery,
|
||||
includeItemTypes ?? string.Empty);
|
||||
}
|
||||
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -693,12 +730,9 @@ public partial class JellyfinController
|
||||
var cleanQuery = searchTerm.Trim().Trim('"');
|
||||
var requestedTypes = ParseItemTypes(includeItemTypes);
|
||||
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
|
||||
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 ||
|
||||
requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 ||
|
||||
requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
||||
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 ||
|
||||
requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
||||
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
|
||||
@@ -809,8 +843,7 @@ public partial class JellyfinController
|
||||
|
||||
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
|
||||
(includePlaylistsAsAlbums &&
|
||||
requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
|
||||
(includePlaylistsAsAlbums && requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
|
||||
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return (
|
||||
@@ -821,14 +854,210 @@ public partial class JellyfinController
|
||||
|
||||
private static IActionResult CreateEmptyItemsResponse(int startIndex)
|
||||
{
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = Array.Empty<object>(),
|
||||
TotalRecordCount = 0,
|
||||
StartIndex = startIndex
|
||||
});
|
||||
return CreateItemsResponse([], 0, startIndex);
|
||||
}
|
||||
|
||||
private static ContentResult CreateItemsResponse(
|
||||
List<Dictionary<string, object?>> items,
|
||||
int totalRecordCount,
|
||||
int startIndex)
|
||||
{
|
||||
var response = new JellyfinItemsResponse
|
||||
{
|
||||
Items = items,
|
||||
TotalRecordCount = totalRecordCount,
|
||||
StartIndex = startIndex
|
||||
};
|
||||
|
||||
return new ContentResult
|
||||
{
|
||||
Content = SerializeSearchResponseJson(response),
|
||||
ContentType = "application/json"
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TeeBufferWriter : IBufferWriter<byte>
|
||||
{
|
||||
private readonly IBufferWriter<byte> _primary;
|
||||
private readonly ArrayBufferWriter<byte>? _secondary;
|
||||
private Memory<byte> _currentBuffer;
|
||||
|
||||
public TeeBufferWriter(IBufferWriter<byte> primary, ArrayBufferWriter<byte>? secondary)
|
||||
{
|
||||
_primary = primary;
|
||||
_secondary = secondary;
|
||||
}
|
||||
|
||||
public void Advance(int count)
|
||||
{
|
||||
if (count > 0 && _secondary != null)
|
||||
{
|
||||
var destination = _secondary.GetSpan(count);
|
||||
_currentBuffer.Span[..count].CopyTo(destination);
|
||||
_secondary.Advance(count);
|
||||
}
|
||||
|
||||
_primary.Advance(count);
|
||||
_currentBuffer = Memory<byte>.Empty;
|
||||
}
|
||||
|
||||
public Memory<byte> GetMemory(int sizeHint = 0)
|
||||
{
|
||||
_currentBuffer = _primary.GetMemory(sizeHint);
|
||||
return _currentBuffer;
|
||||
}
|
||||
|
||||
public Span<byte> GetSpan(int sizeHint = 0)
|
||||
{
|
||||
return GetMemory(sizeHint).Span;
|
||||
}
|
||||
}
|
||||
|
||||
private List<Dictionary<string, object?>> ApplyRequestedAlbumOrderingIfApplicable(
|
||||
List<Dictionary<string, object?>> items,
|
||||
string[]? requestedTypes,
|
||||
string? sortBy,
|
||||
string? sortOrder)
|
||||
{
|
||||
if (items.Count <= 1 || string.IsNullOrWhiteSpace(sortBy))
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
if (requestedTypes == null || requestedTypes.Length == 0)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var isAlbumOnlyRequest = requestedTypes.All(type =>
|
||||
string.Equals(type, "MusicAlbum", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(type, "Playlist", StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (!isAlbumOnlyRequest)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var sortFields = sortBy
|
||||
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(field => !string.IsNullOrWhiteSpace(field))
|
||||
.ToList();
|
||||
|
||||
if (sortFields.Count == 0)
|
||||
{
|
||||
return items;
|
||||
}
|
||||
|
||||
var descending = string.Equals(sortOrder, "Descending", StringComparison.OrdinalIgnoreCase);
|
||||
var sorted = items.ToList();
|
||||
sorted.Sort((left, right) => CompareAlbumItemsByRequestedSort(left, right, sortFields, descending));
|
||||
return sorted;
|
||||
}
|
||||
|
||||
private int CompareAlbumItemsByRequestedSort(
|
||||
Dictionary<string, object?> left,
|
||||
Dictionary<string, object?> right,
|
||||
IReadOnlyList<string> sortFields,
|
||||
bool descending)
|
||||
{
|
||||
foreach (var field in sortFields)
|
||||
{
|
||||
var comparison = CompareAlbumItemsByField(left, right, field);
|
||||
if (comparison == 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
return descending ? -comparison : comparison;
|
||||
}
|
||||
|
||||
return string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private int CompareAlbumItemsByField(Dictionary<string, object?> left, Dictionary<string, object?> right, string field)
|
||||
{
|
||||
return field.ToLowerInvariant() switch
|
||||
{
|
||||
"sortname" => string.Compare(GetItemStringValue(left, "SortName"), GetItemStringValue(right, "SortName"), StringComparison.OrdinalIgnoreCase),
|
||||
"name" => string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase),
|
||||
"datecreated" => DateTime.Compare(GetItemDateValue(left, "DateCreated"), GetItemDateValue(right, "DateCreated")),
|
||||
"premieredate" => DateTime.Compare(GetItemDateValue(left, "PremiereDate"), GetItemDateValue(right, "PremiereDate")),
|
||||
"productionyear" => CompareIntValues(GetItemIntValue(left, "ProductionYear"), GetItemIntValue(right, "ProductionYear")),
|
||||
_ => 0
|
||||
};
|
||||
}
|
||||
|
||||
private static int CompareIntValues(int? left, int? right)
|
||||
{
|
||||
if (left.HasValue && right.HasValue)
|
||||
{
|
||||
return left.Value.CompareTo(right.Value);
|
||||
}
|
||||
|
||||
if (left.HasValue)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (right.HasValue)
|
||||
{
|
||||
return -1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static DateTime GetItemDateValue(Dictionary<string, object?> item, string key)
|
||||
{
|
||||
if (!item.TryGetValue(key, out var value) || value == null)
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.String &&
|
||||
DateTime.TryParse(jsonElement.GetString(), out var parsedDate))
|
||||
{
|
||||
return parsedDate;
|
||||
}
|
||||
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
if (DateTime.TryParse(value.ToString(), out var parsed))
|
||||
{
|
||||
return parsed;
|
||||
}
|
||||
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
|
||||
private static int? GetItemIntValue(Dictionary<string, object?> item, string key)
|
||||
{
|
||||
if (!item.TryGetValue(key, out var value) || value == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (value is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue))
|
||||
{
|
||||
return intValue;
|
||||
}
|
||||
|
||||
if (jsonElement.ValueKind == JsonValueKind.String &&
|
||||
int.TryParse(jsonElement.GetString(), out var parsedInt))
|
||||
{
|
||||
return parsedInt;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
||||
}
|
||||
/// <summary>
|
||||
/// Merges two source queues without reordering either queue.
|
||||
/// At each step, compare only the current head from each source and dequeue the winner.
|
||||
|
||||
@@ -9,8 +9,6 @@ namespace allstarr.Controllers;
|
||||
|
||||
public partial class JellyfinController
|
||||
{
|
||||
private static readonly string[] KeptAudioExtensions = [".flac", ".mp3", ".m4a", ".opus"];
|
||||
|
||||
#region Spotify Playlist Injection
|
||||
|
||||
/// <summary>
|
||||
@@ -59,8 +57,18 @@ public partial class JellyfinController
|
||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
|
||||
string playlistId)
|
||||
{
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
_spotifySettings,
|
||||
spotifyPlaylistName,
|
||||
userId,
|
||||
playlistId);
|
||||
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
|
||||
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, playlistId);
|
||||
|
||||
// Check if Jellyfin playlist has changed (cheap API call)
|
||||
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
|
||||
var jellyfinSignatureCacheKey =
|
||||
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(spotifyPlaylistName, playlistScopeUserId, playlistScopeId)}";
|
||||
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
|
||||
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
||||
|
||||
@@ -68,7 +76,10 @@ public partial class JellyfinController
|
||||
var requestNeedsGenreMetadata = RequestIncludesField("Genres");
|
||||
|
||||
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
spotifyPlaylistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
||||
|
||||
if (cachedItems != null && cachedItems.Count > 0 &&
|
||||
@@ -112,7 +123,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Check file cache as fallback
|
||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName, playlistScopeUserId, playlistScopeId);
|
||||
if (fileItems != null && fileItems.Count > 0 &&
|
||||
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems))
|
||||
{
|
||||
@@ -149,14 +160,63 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
spotifyPlaylistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||
|
||||
if (orderedTracks == null || orderedTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
||||
_logger.LogInformation(
|
||||
"No ordered matched tracks in cache for {Playlist}; attempting exact-scope rebuild before fallback",
|
||||
spotifyPlaylistName);
|
||||
return null; // Fall back to legacy mode
|
||||
|
||||
if (_spotifyTrackMatchingService != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _spotifyTrackMatchingService.TriggerRebuildForPlaylistAsync(
|
||||
spotifyPlaylistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"On-demand rebuild failed for {Playlist}; falling back to cached compatibility paths",
|
||||
spotifyPlaylistName);
|
||||
}
|
||||
}
|
||||
|
||||
if (orderedTracks == null || orderedTracks.Count == 0)
|
||||
{
|
||||
var legacyCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||
spotifyPlaylistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var legacySongs = await _cache.GetAsync<List<Song>>(legacyCacheKey);
|
||||
if (legacySongs != null && legacySongs.Count > 0)
|
||||
{
|
||||
orderedTracks = legacySongs.Select((song, index) => new MatchedTrack
|
||||
{
|
||||
Position = index,
|
||||
MatchedSong = song
|
||||
}).ToList();
|
||||
_logger.LogInformation(
|
||||
"Loaded {Count} legacy matched tracks for {Playlist} after ordered cache miss",
|
||||
orderedTracks.Count,
|
||||
spotifyPlaylistName);
|
||||
}
|
||||
}
|
||||
|
||||
if (orderedTracks == null || orderedTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("Ordered matched tracks are still unavailable for {Playlist}", spotifyPlaylistName);
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
||||
@@ -164,11 +224,10 @@ public partial class JellyfinController
|
||||
|
||||
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||
var userId = _settings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogError(
|
||||
"❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
|
||||
"❌ Could not resolve Jellyfin user from the current request. Cannot fetch playlist tracks.");
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
@@ -239,7 +298,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Get the full playlist from Spotify to know the correct order
|
||||
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
|
||||
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName, userId, playlistId);
|
||||
if (spotifyTracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
|
||||
@@ -396,7 +455,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Save to file cache for persistence across restarts
|
||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems, playlistScopeUserId, playlistScopeId);
|
||||
|
||||
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
||||
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||
@@ -482,13 +541,10 @@ public partial class JellyfinController
|
||||
if (Directory.Exists(keptAlbumPath))
|
||||
{
|
||||
var sanitizedTitle = AdminHelperService.SanitizeFileName(song.Title);
|
||||
var existingAudioFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*")
|
||||
.Where(IsKeptAudioFile)
|
||||
.ToArray();
|
||||
if (existingAudioFiles.Length > 0)
|
||||
var existingFiles = Directory.GetFiles(keptAlbumPath, $"*{sanitizedTitle}*");
|
||||
if (existingFiles.Length > 0)
|
||||
{
|
||||
_logger.LogInformation("Track already exists in kept folder: {Path}", existingAudioFiles[0]);
|
||||
await EnsureLyricsSidecarForKeptTrackAsync(existingAudioFiles[0], song, provider, externalId);
|
||||
_logger.LogInformation("Track already exists in kept folder: {Path}", existingFiles[0]);
|
||||
// Mark as favorited even if we didn't download it
|
||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||
return;
|
||||
@@ -577,7 +633,6 @@ public partial class JellyfinController
|
||||
{
|
||||
// Race condition - file was created by another request
|
||||
_logger.LogInformation("Track already exists in kept folder (race condition): {Path}", keptFilePath);
|
||||
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
|
||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||
return;
|
||||
}
|
||||
@@ -595,7 +650,6 @@ public partial class JellyfinController
|
||||
{
|
||||
// Race condition on copy fallback
|
||||
_logger.LogInformation("Track already exists in kept folder (race condition on copy): {Path}", keptFilePath);
|
||||
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
|
||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||
return;
|
||||
}
|
||||
@@ -657,8 +711,6 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
await EnsureLyricsSidecarForKeptTrackAsync(keptFilePath, song, provider, externalId);
|
||||
|
||||
// Mark as favorited in persistent storage
|
||||
await MarkTrackAsFavoritedAsync(itemId, song);
|
||||
}
|
||||
@@ -912,33 +964,6 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
private async Task EnsureLyricsSidecarForKeptTrackAsync(string keptFilePath, Song song, string provider, string externalId)
|
||||
{
|
||||
if (_keptLyricsSidecarService == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _keptLyricsSidecarService.EnsureSidecarAsync(
|
||||
keptFilePath,
|
||||
song,
|
||||
provider,
|
||||
externalId,
|
||||
CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to create kept lyrics sidecar for {Path}", keptFilePath);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsKeptAudioFile(string path)
|
||||
{
|
||||
return KeptAudioExtensions.Contains(Path.GetExtension(path).ToLowerInvariant());
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
/// <summary>
|
||||
@@ -952,7 +977,7 @@ public partial class JellyfinController
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = _settings.UserId;
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
@@ -994,14 +1019,19 @@ public partial class JellyfinController
|
||||
/// <summary>
|
||||
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
|
||||
private async Task SavePlaylistItemsToFile(
|
||||
string playlistName,
|
||||
List<Dictionary<string, object?>> items,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var safeName = AdminHelperService.SanitizeFileName(
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
@@ -1019,11 +1049,15 @@ public partial class JellyfinController
|
||||
/// <summary>
|
||||
/// Loads playlist items (raw Jellyfin JSON) from file cache.
|
||||
/// </summary>
|
||||
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
|
||||
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var safeName = AdminHelperService.SanitizeFileName(
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
|
||||
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
|
||||
@@ -34,20 +34,22 @@ public partial class JellyfinController : ControllerBase
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly ScrobblingSettings _scrobblingSettings;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ExternalArtistAppearancesService _externalArtistAppearancesService;
|
||||
private readonly ParallelMetadataService? _parallelMetadataService;
|
||||
private readonly ILocalLibraryService _localLibraryService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly JellyfinResponseBuilder _responseBuilder;
|
||||
private readonly JellyfinModelMapper _modelMapper;
|
||||
private readonly JellyfinProxyService _proxyService;
|
||||
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
|
||||
private readonly JellyfinSessionManager _sessionManager;
|
||||
private readonly PlaylistSyncService? _playlistSyncService;
|
||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||
private readonly SpotifyTrackMatchingService? _spotifyTrackMatchingService;
|
||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||
private readonly LyricsPlusService? _lyricsPlusService;
|
||||
private readonly LrclibService? _lrclibService;
|
||||
private readonly LyricsOrchestrator? _lyricsOrchestrator;
|
||||
private readonly IKeptLyricsSidecarService? _keptLyricsSidecarService;
|
||||
private readonly ScrobblingOrchestrator? _scrobblingOrchestrator;
|
||||
private readonly ScrobblingHelper? _scrobblingHelper;
|
||||
private readonly OdesliService _odesliService;
|
||||
@@ -61,11 +63,13 @@ public partial class JellyfinController : ControllerBase
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<ScrobblingSettings> scrobblingSettings,
|
||||
IMusicMetadataService metadataService,
|
||||
ExternalArtistAppearancesService externalArtistAppearancesService,
|
||||
ILocalLibraryService localLibraryService,
|
||||
IDownloadService downloadService,
|
||||
JellyfinResponseBuilder responseBuilder,
|
||||
JellyfinModelMapper modelMapper,
|
||||
JellyfinProxyService proxyService,
|
||||
JellyfinUserContextResolver jellyfinUserContextResolver,
|
||||
JellyfinSessionManager sessionManager,
|
||||
OdesliService odesliService,
|
||||
RedisCacheService cache,
|
||||
@@ -74,11 +78,11 @@ public partial class JellyfinController : ControllerBase
|
||||
ParallelMetadataService? parallelMetadataService = null,
|
||||
PlaylistSyncService? playlistSyncService = null,
|
||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||
SpotifyTrackMatchingService? spotifyTrackMatchingService = null,
|
||||
SpotifyLyricsService? spotifyLyricsService = null,
|
||||
LyricsPlusService? lyricsPlusService = null,
|
||||
LrclibService? lrclibService = null,
|
||||
LyricsOrchestrator? lyricsOrchestrator = null,
|
||||
IKeptLyricsSidecarService? keptLyricsSidecarService = null,
|
||||
ScrobblingOrchestrator? scrobblingOrchestrator = null,
|
||||
ScrobblingHelper? scrobblingHelper = null)
|
||||
{
|
||||
@@ -87,20 +91,22 @@ public partial class JellyfinController : ControllerBase
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_scrobblingSettings = scrobblingSettings.Value;
|
||||
_metadataService = metadataService;
|
||||
_externalArtistAppearancesService = externalArtistAppearancesService;
|
||||
_parallelMetadataService = parallelMetadataService;
|
||||
_localLibraryService = localLibraryService;
|
||||
_downloadService = downloadService;
|
||||
_responseBuilder = responseBuilder;
|
||||
_modelMapper = modelMapper;
|
||||
_proxyService = proxyService;
|
||||
_jellyfinUserContextResolver = jellyfinUserContextResolver;
|
||||
_sessionManager = sessionManager;
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||
_spotifyTrackMatchingService = spotifyTrackMatchingService;
|
||||
_spotifyLyricsService = spotifyLyricsService;
|
||||
_lyricsPlusService = lyricsPlusService;
|
||||
_lrclibService = lrclibService;
|
||||
_lyricsOrchestrator = lyricsOrchestrator;
|
||||
_keptLyricsSidecarService = keptLyricsSidecarService;
|
||||
_scrobblingOrchestrator = scrobblingOrchestrator;
|
||||
_scrobblingHelper = scrobblingHelper;
|
||||
_odesliService = odesliService;
|
||||
@@ -293,6 +299,75 @@ public partial class JellyfinController : ControllerBase
|
||||
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets "appears on" albums for an external artist when Jellyfin requests
|
||||
/// ContributingArtistIds for album containers.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetExternalContributorChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsFavoritesOnlyRequest())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Suppressing external contributing artist items for favorites-only request: provider={Provider}, type={Type}, externalId={ExternalId}",
|
||||
provider,
|
||||
type,
|
||||
externalId);
|
||||
return CreateEmptyItemsResponse(GetRequestedStartIndex());
|
||||
}
|
||||
|
||||
if (!string.Equals(type, "artist", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Ignoring external contributing item request for non-artist id: provider={Provider}, type={Type}, externalId={ExternalId}",
|
||||
provider,
|
||||
type,
|
||||
externalId);
|
||||
return CreateEmptyItemsResponse(GetRequestedStartIndex());
|
||||
}
|
||||
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0;
|
||||
var wantsAlbums = itemTypesUnspecified || itemTypes!.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!wantsAlbums)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No external contributing artist handler for requested item types {ItemTypes}",
|
||||
string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||
return CreateEmptyItemsResponse(GetRequestedStartIndex());
|
||||
}
|
||||
|
||||
var albums = await _externalArtistAppearancesService.GetAppearsOnAlbumsAsync(provider, externalId, cancellationToken);
|
||||
var items = albums
|
||||
.Select(_responseBuilder.ConvertAlbumToJellyfinItem)
|
||||
.ToList();
|
||||
|
||||
items = ApplyRequestedAlbumOrderingIfApplicable(
|
||||
items,
|
||||
itemTypes,
|
||||
Request.Query["SortBy"].ToString(),
|
||||
Request.Query["SortOrder"].ToString());
|
||||
|
||||
var totalRecordCount = items.Count;
|
||||
var startIndex = GetRequestedStartIndex();
|
||||
if (startIndex > 0)
|
||||
{
|
||||
items = items.Skip(startIndex).ToList();
|
||||
}
|
||||
|
||||
if (int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit > 0)
|
||||
{
|
||||
items = items.Take(parsedLimit).ToList();
|
||||
}
|
||||
|
||||
return _responseBuilder.CreateJsonResponse(new
|
||||
{
|
||||
Items = items,
|
||||
TotalRecordCount = totalRecordCount,
|
||||
StartIndex = startIndex
|
||||
});
|
||||
}
|
||||
|
||||
private int GetRequestedStartIndex()
|
||||
{
|
||||
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
|
||||
@@ -1751,7 +1826,10 @@ public partial class JellyfinController : ControllerBase
|
||||
// Search through each playlist's matched tracks cache
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
playlist.Name,
|
||||
playlist.UserId,
|
||||
string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
|
||||
|
||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||
|
||||
@@ -8,6 +8,7 @@ using allstarr.Services.Common;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
@@ -27,6 +28,7 @@ public class PlaylistController : ControllerBase
|
||||
private readonly HttpClient _jellyfinHttpClient;
|
||||
private readonly AdminHelperService _helperService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
|
||||
public PlaylistController(
|
||||
@@ -39,6 +41,7 @@ public class PlaylistController : ControllerBase
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AdminHelperService helperService,
|
||||
IServiceProvider serviceProvider,
|
||||
JellyfinUserContextResolver jellyfinUserContextResolver,
|
||||
SpotifyTrackMatchingService? matchingService = null)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -51,6 +54,23 @@ public class PlaylistController : ControllerBase
|
||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||
_helperService = helperService;
|
||||
_serviceProvider = serviceProvider;
|
||||
_jellyfinUserContextResolver = jellyfinUserContextResolver;
|
||||
}
|
||||
|
||||
private async Task<SpotifyPlaylistConfig?> ResolvePlaylistConfigForCurrentScopeAsync(string playlistName)
|
||||
{
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
|
||||
return _spotifyImportSettings.GetPlaylistByName(playlistName, userId);
|
||||
}
|
||||
|
||||
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
|
||||
{
|
||||
return playlist.JellyfinId;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(playlist?.Id) ? null : playlist.Id;
|
||||
}
|
||||
|
||||
[HttpGet("playlists")]
|
||||
@@ -149,7 +169,7 @@ public class PlaylistController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
spotifyTrackCount = spotifyTracks.Count;
|
||||
playlistInfo["trackCount"] = spotifyTrackCount;
|
||||
_logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name);
|
||||
@@ -167,7 +187,10 @@ public class PlaylistController : ControllerBase
|
||||
try
|
||||
{
|
||||
// Try to use the pre-built playlist cache
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
config.Name,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
@@ -239,7 +262,7 @@ public class PlaylistController : ControllerBase
|
||||
else
|
||||
{
|
||||
// No playlist cache - calculate from global mappings as fallback
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
var localCount = 0;
|
||||
var externalCount = 0;
|
||||
var missingCount = 0;
|
||||
@@ -291,7 +314,7 @@ public class PlaylistController : ControllerBase
|
||||
try
|
||||
{
|
||||
// Jellyfin requires UserId parameter to fetch playlist items
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
var userId = config.UserId;
|
||||
|
||||
// If no user configured, try to get the first user
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
@@ -330,10 +353,13 @@ public class PlaylistController : ControllerBase
|
||||
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
// Get Spotify tracks to match against
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
|
||||
// Try to use the pre-built playlist cache first (includes manual mappings!)
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
config.Name,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
@@ -438,7 +464,10 @@ public class PlaylistController : ControllerBase
|
||||
}
|
||||
|
||||
// Get matched external tracks cache once
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(config.Name);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
config.Name,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
var matchedSpotifyIds = new HashSet<string>(
|
||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
@@ -455,7 +484,11 @@ public class PlaylistController : ControllerBase
|
||||
var hasExternalMapping = false;
|
||||
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
config.Name,
|
||||
track.SpotifyId,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
@@ -466,7 +499,11 @@ public class PlaylistController : ControllerBase
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
config.Name,
|
||||
track.SpotifyId,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
@@ -592,16 +629,22 @@ public class PlaylistController : ControllerBase
|
||||
public async Task<IActionResult> GetPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||
var playlistScopeUserId = playlistConfig?.UserId;
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
|
||||
// Get Spotify tracks
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName, playlistScopeUserId, playlistConfig?.JellyfinId);
|
||||
|
||||
var tracksWithStatus = new List<object>();
|
||||
var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
try
|
||||
{
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
|
||||
if (matchedTracks != null)
|
||||
@@ -627,7 +670,10 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
|
||||
// This cache includes all matched tracks with proper provider IDs
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
@@ -948,7 +994,11 @@ public class PlaylistController : ControllerBase
|
||||
string? externalProvider = null;
|
||||
|
||||
// Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
decodedName,
|
||||
track.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
@@ -958,7 +1008,11 @@ public class PlaylistController : ControllerBase
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
decodedName,
|
||||
track.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
@@ -1071,10 +1125,16 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||
var playlistScopeUserId = playlistConfig?.UserId;
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
await _playlistFetcher.RefreshPlaylistAsync(decodedName);
|
||||
|
||||
// Clear playlist stats cache first (so it gets recalculated with fresh data)
|
||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(statsCacheKey);
|
||||
|
||||
// Then invalidate playlist summary cache (will rebuild with fresh stats)
|
||||
@@ -1109,18 +1169,28 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||
var playlistScopeUserId = playlistConfig?.UserId;
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
// Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed
|
||||
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}";
|
||||
var jellyfinSignatureCacheKey =
|
||||
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(decodedName, playlistScopeUserId, playlistScopeId)}";
|
||||
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
|
||||
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
|
||||
|
||||
// Clear the matched results cache to force re-matching
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(matchedTracksKey);
|
||||
_logger.LogDebug("Cleared matched tracks cache");
|
||||
|
||||
// Clear the playlist items cache
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(playlistItemsCacheKey);
|
||||
_logger.LogDebug("Cleared playlist items cache");
|
||||
|
||||
@@ -1131,7 +1201,10 @@ public class PlaylistController : ControllerBase
|
||||
_helperService.InvalidatePlaylistSummaryCache();
|
||||
|
||||
// Clear playlist stats cache to force recalculation from new mappings
|
||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(statsCacheKey);
|
||||
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
|
||||
|
||||
@@ -1196,7 +1269,7 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
|
||||
|
||||
// Build URL with UserId if available
|
||||
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
||||
@@ -1328,7 +1401,7 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
|
||||
|
||||
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
@@ -1424,13 +1497,20 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||
var playlistScopeUserId = playlistConfig?.UserId;
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
string? normalizedProvider = null;
|
||||
string? normalizedExternalId = null;
|
||||
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||
var mappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
decodedName,
|
||||
request.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
@@ -1442,7 +1522,11 @@ public class PlaylistController : ControllerBase
|
||||
else
|
||||
{
|
||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
decodedName,
|
||||
request.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||
normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!);
|
||||
var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId };
|
||||
@@ -1482,10 +1566,22 @@ public class PlaylistController : ControllerBase
|
||||
}
|
||||
|
||||
// Clear all related caches to force rebuild
|
||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
||||
var matchedCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
|
||||
await _cache.DeleteAsync(matchedCacheKey);
|
||||
await _cache.DeleteAsync(orderedCacheKey);
|
||||
|
||||
@@ -357,9 +357,9 @@ public class SpotifyAdminController : ControllerBase
|
||||
{
|
||||
var keys = new[]
|
||||
{
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name)
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
|
||||
};
|
||||
|
||||
foreach (var key in keys)
|
||||
|
||||
@@ -79,6 +79,29 @@ public class TrackMetadataRequest
|
||||
public int? DurationMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for updating configuration
|
||||
/// </summary>
|
||||
public class SquidWtfEndpointHealthResponse
|
||||
{
|
||||
public DateTime TestedAtUtc { get; set; }
|
||||
public int TotalRows { get; set; }
|
||||
public List<SquidWtfEndpointHealthRow> Endpoints { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SquidWtfEndpointHealthRow
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public string? ApiUrl { get; set; }
|
||||
public string? StreamingUrl { get; set; }
|
||||
public SquidWtfEndpointProbeResult Api { get; set; } = new();
|
||||
public SquidWtfEndpointProbeResult Streaming { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SquidWtfEndpointProbeResult
|
||||
{
|
||||
public bool Configured { get; set; }
|
||||
public bool IsUp { get; set; }
|
||||
public string State { get; set; } = "unknown";
|
||||
public int? StatusCode { get; set; }
|
||||
public long? LatencyMs { get; set; }
|
||||
public string? RequestUrl { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace allstarr.Models.Jellyfin;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical Jellyfin Items response wrapper used by search-related hot paths.
|
||||
/// </summary>
|
||||
public class JellyfinItemsResponse
|
||||
{
|
||||
public List<Dictionary<string, object?>> Items { get; set; } = [];
|
||||
public int TotalRecordCount { get; set; }
|
||||
public int StartIndex { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Playback payload forwarded to Jellyfin for start/progress/stop events.
|
||||
/// Nullable members are omitted to preserve the lean request shapes clients expect.
|
||||
/// </summary>
|
||||
public class JellyfinPlaybackStatePayload
|
||||
{
|
||||
public string ItemId { get; set; } = string.Empty;
|
||||
public long PositionTicks { get; set; }
|
||||
public bool? CanSeek { get; set; }
|
||||
public bool? IsPaused { get; set; }
|
||||
public bool? IsMuted { get; set; }
|
||||
public string? PlayMethod { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthetic capabilities payload used when allstarr needs to establish a Jellyfin session.
|
||||
/// </summary>
|
||||
public class JellyfinSessionCapabilitiesPayload
|
||||
{
|
||||
public string[] PlayableMediaTypes { get; set; } = [];
|
||||
public string[] SupportedCommands { get; set; } = [];
|
||||
public bool SupportsMediaControl { get; set; }
|
||||
public bool SupportsPersistentIdentifier { get; set; }
|
||||
public bool SupportsSync { get; set; }
|
||||
}
|
||||
@@ -126,8 +126,33 @@ public class SpotifyImportSettings
|
||||
/// <summary>
|
||||
/// Gets the playlist configuration by name.
|
||||
/// </summary>
|
||||
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
|
||||
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
public SpotifyPlaylistConfig? GetPlaylistByName(string name, string? userId = null, string? jellyfinPlaylistId = null)
|
||||
{
|
||||
var matches = Playlists
|
||||
.Where(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
|
||||
{
|
||||
var byPlaylistId = matches.FirstOrDefault(p =>
|
||||
p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||
if (byPlaylistId != null)
|
||||
{
|
||||
return byPlaylistId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
var normalizedUserId = userId.Trim();
|
||||
return matches.FirstOrDefault(p =>
|
||||
!string.IsNullOrWhiteSpace(p.UserId) &&
|
||||
p.UserId.Equals(normalizedUserId, StringComparison.OrdinalIgnoreCase))
|
||||
?? matches.FirstOrDefault(p => string.IsNullOrWhiteSpace(p.UserId));
|
||||
}
|
||||
|
||||
return matches.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
|
||||
|
||||
+4
-6
@@ -528,6 +528,7 @@ else
|
||||
|
||||
// Business services - shared across backends
|
||||
builder.Services.AddSingleton(squidWtfEndpointCatalog);
|
||||
builder.Services.AddMemoryCache(); // L1 in-memory tier for RedisCacheService
|
||||
builder.Services.AddSingleton<RedisCacheService>();
|
||||
builder.Services.AddSingleton<FavoritesMigrationService>();
|
||||
builder.Services.AddSingleton<OdesliService>();
|
||||
@@ -540,6 +541,8 @@ if (backendType == BackendType.Jellyfin)
|
||||
// Jellyfin services
|
||||
builder.Services.AddSingleton<JellyfinResponseBuilder>();
|
||||
builder.Services.AddSingleton<JellyfinModelMapper>();
|
||||
builder.Services.AddSingleton<ExternalArtistAppearancesService>();
|
||||
builder.Services.AddScoped<JellyfinUserContextResolver>();
|
||||
builder.Services.AddScoped<JellyfinProxyService>();
|
||||
builder.Services.AddSingleton<JellyfinSessionManager>();
|
||||
builder.Services.AddScoped<JellyfinAuthFilter>();
|
||||
@@ -712,7 +715,6 @@ builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsPlusService>();
|
||||
|
||||
// Register Lyrics Orchestrator (manages priority-based lyrics fetching)
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.LyricsOrchestrator>();
|
||||
builder.Services.AddSingleton<allstarr.Services.Lyrics.IKeptLyricsSidecarService, allstarr.Services.Lyrics.KeptLyricsSidecarService>();
|
||||
|
||||
// Register Spotify mapping service (global Spotify ID → Local/External mappings)
|
||||
builder.Services.AddSingleton<allstarr.Services.Spotify.SpotifyMappingService>();
|
||||
@@ -966,11 +968,7 @@ if (app.Environment.IsDevelopment())
|
||||
app.UseSwaggerUI();
|
||||
}
|
||||
|
||||
// The admin UI is documented and intended to be reachable directly over HTTP on port 5275.
|
||||
// Keep HTTPS redirection for non-admin traffic only.
|
||||
app.UseWhen(
|
||||
context => context.Connection.LocalPort != 5275,
|
||||
branch => branch.UseHttpsRedirection());
|
||||
app.UseHttpsRedirection();
|
||||
|
||||
// Serve static files only on admin port (5275)
|
||||
app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Jellyfin;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Spotify;
|
||||
|
||||
namespace allstarr.Serialization;
|
||||
|
||||
/// <summary>
|
||||
/// System.Text.Json source-generated serializer context for hot-path types.
|
||||
/// Eliminates runtime reflection for serialize/deserialize operations, providing
|
||||
/// 3-8x faster throughput and significantly reduced GC allocations.
|
||||
///
|
||||
/// Used by RedisCacheService (all cached types), search response serialization,
|
||||
/// and playback session payload construction.
|
||||
/// </summary>
|
||||
[JsonSourceGenerationOptions(
|
||||
PropertyNamingPolicy = JsonKnownNamingPolicy.Unspecified,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
)]
|
||||
// Domain models (hot: cached in Redis, serialized in search responses)
|
||||
[JsonSerializable(typeof(Song))]
|
||||
[JsonSerializable(typeof(Album))]
|
||||
[JsonSerializable(typeof(Artist))]
|
||||
[JsonSerializable(typeof(SearchResult))]
|
||||
[JsonSerializable(typeof(JellyfinItemsResponse))]
|
||||
[JsonSerializable(typeof(JellyfinPlaybackStatePayload))]
|
||||
[JsonSerializable(typeof(JellyfinSessionCapabilitiesPayload))]
|
||||
[JsonSerializable(typeof(List<Song>))]
|
||||
[JsonSerializable(typeof(List<Album>))]
|
||||
[JsonSerializable(typeof(List<Artist>))]
|
||||
// Spotify models (hot: playlist loading, track matching)
|
||||
[JsonSerializable(typeof(SpotifyPlaylistTrack))]
|
||||
[JsonSerializable(typeof(SpotifyPlaylist))]
|
||||
[JsonSerializable(typeof(MatchedTrack))]
|
||||
[JsonSerializable(typeof(MissingTrack))]
|
||||
[JsonSerializable(typeof(SpotifyTrackMapping))]
|
||||
[JsonSerializable(typeof(TrackMetadata))]
|
||||
[JsonSerializable(typeof(List<SpotifyPlaylistTrack>))]
|
||||
[JsonSerializable(typeof(List<MatchedTrack>))]
|
||||
[JsonSerializable(typeof(List<MissingTrack>))]
|
||||
// Lyrics models (moderate: cached in Redis)
|
||||
[JsonSerializable(typeof(LyricsInfo))]
|
||||
// Collection types used in cache and playlist items
|
||||
[JsonSerializable(typeof(List<Dictionary<string, object?>>))]
|
||||
[JsonSerializable(typeof(Dictionary<string, object?>))]
|
||||
[JsonSerializable(typeof(string[]))]
|
||||
[JsonSerializable(typeof(List<string>))]
|
||||
[JsonSerializable(typeof(string))]
|
||||
[JsonSerializable(typeof(byte[]))]
|
||||
internal partial class AllstarrJsonContext : JsonSerializerContext
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared default instance. Use this for all hot-path serialization
|
||||
/// where PropertyNamingPolicy = null (PascalCase / preserve casing).
|
||||
/// </summary>
|
||||
public static AllstarrJsonContext Shared { get; } = new(new JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DictionaryKeyPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
|
||||
namespace allstarr.Serialization;
|
||||
|
||||
internal static class AllstarrJsonSerializer
|
||||
{
|
||||
private static readonly JsonSerializerOptions ReflectionFallbackOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DictionaryKeyPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
public static string Serialize<T>(T value)
|
||||
{
|
||||
var typeInfo = GetTypeInfo<T>();
|
||||
if (typeInfo != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(value, typeInfo);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// Mixed Jellyfin payloads often carry runtime-only shapes such as JsonElement,
|
||||
// List<object>, or dictionary arrays. Fall back to reflection for those cases.
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(value, ReflectionFallbackOptions);
|
||||
}
|
||||
|
||||
private static JsonTypeInfo<T>? GetTypeInfo<T>()
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JsonTypeInfo<T>?)AllstarrJsonContext.Shared.GetTypeInfo(typeof(T));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
@@ -9,6 +10,10 @@ namespace allstarr.Services.Common;
|
||||
/// </summary>
|
||||
public static class AuthHeaderHelper
|
||||
{
|
||||
private static readonly Regex AuthParameterRegex = new(
|
||||
@"(?<key>[A-Za-z0-9_-]+)\s*=\s*""(?<value>[^""]*)""",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Forwards authentication headers from HTTP request to HttpRequestMessage.
|
||||
/// Handles both X-Emby-Authorization and Authorization headers.
|
||||
@@ -99,17 +104,7 @@ public static class AuthHeaderHelper
|
||||
/// </summary>
|
||||
private static string? ExtractDeviceIdFromAuthString(string authValue)
|
||||
{
|
||||
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
authValue,
|
||||
@"DeviceId=""([^""]+)""",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (deviceIdMatch.Success)
|
||||
{
|
||||
return deviceIdMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
return ExtractAuthParameter(authValue, "DeviceId");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -140,16 +135,95 @@ public static class AuthHeaderHelper
|
||||
/// </summary>
|
||||
private static string? ExtractClientNameFromAuthString(string authValue)
|
||||
{
|
||||
var clientMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
authValue,
|
||||
@"Client=""([^""]+)""",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (clientMatch.Success)
|
||||
return ExtractAuthParameter(authValue, "Client");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the authenticated Jellyfin access token from request headers.
|
||||
/// Supports X-Emby-Authorization, X-Emby-Token, Authorization: MediaBrowser ..., and Bearer tokens.
|
||||
/// </summary>
|
||||
public static string? ExtractAccessToken(IHeaderDictionary headers)
|
||||
{
|
||||
if (headers.TryGetValue("X-Emby-Token", out var tokenHeader))
|
||||
{
|
||||
return clientMatch.Groups[1].Value;
|
||||
var token = tokenHeader.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
|
||||
{
|
||||
var token = ExtractAuthParameter(authHeader.ToString(), "Token");
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("Authorization", out var authorizationHeader))
|
||||
{
|
||||
var authValue = authorizationHeader.ToString().Trim();
|
||||
if (authValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var bearerToken = authValue["Bearer ".Length..].Trim();
|
||||
return string.IsNullOrWhiteSpace(bearerToken) ? null : bearerToken;
|
||||
}
|
||||
|
||||
var token = ExtractAuthParameter(authValue, "Token");
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a Jellyfin user id from auth headers when present.
|
||||
/// This is uncommon but some clients may include it in MediaBrowser auth parameters.
|
||||
/// </summary>
|
||||
public static string? ExtractUserId(IHeaderDictionary headers)
|
||||
{
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
|
||||
{
|
||||
var userId = ExtractAuthParameter(authHeader.ToString(), "UserId");
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("Authorization", out var authorizationHeader))
|
||||
{
|
||||
var userId = ExtractAuthParameter(authorizationHeader.ToString(), "UserId");
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractAuthParameter(string authValue, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (Match match in AuthParameterRegex.Matches(authValue))
|
||||
{
|
||||
if (match.Groups["key"].Value.Equals(parameterName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = match.Groups["value"].Value;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -67,34 +67,52 @@ public static class CacheKeyBuilder
|
||||
|
||||
#region Spotify Keys
|
||||
|
||||
public static string BuildSpotifyPlaylistKey(string playlistName)
|
||||
public static string BuildSpotifyPlaylistScope(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:playlist:{playlistName}";
|
||||
var normalizedUserId = Normalize(userId);
|
||||
var normalizedScopeId = Normalize(scopeId);
|
||||
var normalizedPlaylistName = Normalize(playlistName);
|
||||
|
||||
if (string.IsNullOrEmpty(normalizedUserId) && string.IsNullOrEmpty(normalizedScopeId))
|
||||
{
|
||||
return playlistName;
|
||||
}
|
||||
|
||||
var effectiveScopeId = string.IsNullOrEmpty(normalizedScopeId)
|
||||
? normalizedPlaylistName
|
||||
: normalizedScopeId;
|
||||
|
||||
return $"{normalizedUserId}:{effectiveScopeId}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistItemsKey(string playlistName)
|
||||
public static string BuildSpotifyPlaylistKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:playlist:items:{playlistName}";
|
||||
return $"spotify:playlist:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistOrderedKey(string playlistName)
|
||||
public static string BuildSpotifyPlaylistItemsKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:playlist:ordered:{playlistName}";
|
||||
return $"spotify:playlist:items:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyMatchedTracksKey(string playlistName)
|
||||
public static string BuildSpotifyPlaylistOrderedKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:matched:ordered:{playlistName}";
|
||||
return $"spotify:playlist:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName)
|
||||
public static string BuildSpotifyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:matched:{playlistName}";
|
||||
return $"spotify:matched:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistStatsKey(string playlistName)
|
||||
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:playlist:stats:{playlistName}";
|
||||
return $"spotify:matched:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistStatsKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:playlist:stats:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistStatsPattern()
|
||||
@@ -102,19 +120,27 @@ public static class CacheKeyBuilder
|
||||
return "spotify:playlist:stats:*";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyMissingTracksKey(string playlistName)
|
||||
public static string BuildSpotifyMissingTracksKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:missing:{playlistName}";
|
||||
return $"spotify:missing:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyManualMappingKey(string playlist, string spotifyId)
|
||||
public static string BuildSpotifyManualMappingKey(
|
||||
string playlist,
|
||||
string spotifyId,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
return $"spotify:manual-map:{playlist}:{spotifyId}";
|
||||
return $"spotify:manual-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyExternalMappingKey(string playlist, string spotifyId)
|
||||
public static string BuildSpotifyExternalMappingKey(
|
||||
string playlist,
|
||||
string spotifyId,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
return $"spotify:external-map:{playlist}:{spotifyId}";
|
||||
return $"spotify:external-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyGlobalMappingKey(string spotifyId)
|
||||
|
||||
@@ -1,27 +1,57 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Serialization;
|
||||
using StackExchange.Redis;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Redis caching service for metadata and images.
|
||||
/// Tiered caching service: L1 in-memory (IMemoryCache, ~30s TTL) backed by
|
||||
/// L2 Redis for persistence. The memory tier eliminates Redis network round-trips
|
||||
/// for repeated reads within a short window (playlist scrolling, search-as-you-type).
|
||||
/// </summary>
|
||||
public class RedisCacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Default L1 memory cache duration. Kept short to avoid serving stale data,
|
||||
/// but long enough to absorb bursts of repeated reads.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DefaultMemoryTtl = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Key prefixes that should NOT be cached in memory (e.g., large binary blobs).
|
||||
/// </summary>
|
||||
private static readonly string[] MemoryExcludedPrefixes = ["image:"];
|
||||
private static readonly JsonSerializerOptions ReflectionFallbackJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DictionaryKeyPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly RedisSettings _settings;
|
||||
private readonly ILogger<RedisCacheService> _logger;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly ConcurrentDictionary<string, byte> _memoryKeys = new(StringComparer.Ordinal);
|
||||
private IConnectionMultiplexer? _redis;
|
||||
private IDatabase? _db;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public RedisCacheService(
|
||||
IOptions<RedisSettings> settings,
|
||||
ILogger<RedisCacheService> logger)
|
||||
ILogger<RedisCacheService> logger,
|
||||
IMemoryCache memoryCache)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_memoryCache = memoryCache;
|
||||
|
||||
if (_settings.Enabled)
|
||||
{
|
||||
@@ -48,23 +78,145 @@ public class RedisCacheService
|
||||
public bool IsEnabled => _settings.Enabled && _db != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached value as a string.
|
||||
/// Checks whether a key should be cached in the L1 memory tier.
|
||||
/// Large binary data (images) is excluded to avoid memory pressure.
|
||||
/// </summary>
|
||||
public async Task<string?> GetStringAsync(string key)
|
||||
private static bool ShouldUseMemoryCache(string key)
|
||||
{
|
||||
if (!IsEnabled) return null;
|
||||
foreach (var prefix in MemoryExcludedPrefixes)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the L1 TTL for a key mirrored from Redis.
|
||||
/// Returns null for already-expired entries, which skips L1 caching entirely.
|
||||
/// </summary>
|
||||
private static TimeSpan? GetMemoryTtl(TimeSpan? redisExpiry)
|
||||
{
|
||||
if (redisExpiry == null)
|
||||
return DefaultMemoryTtl;
|
||||
|
||||
if (redisExpiry.Value <= TimeSpan.Zero)
|
||||
return null;
|
||||
|
||||
return redisExpiry.Value < DefaultMemoryTtl ? redisExpiry.Value : DefaultMemoryTtl;
|
||||
}
|
||||
|
||||
private bool TryGetMemoryValue(string key, out string? value)
|
||||
{
|
||||
if (!ShouldUseMemoryCache(key))
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return _memoryCache.TryGetValue(key, out value);
|
||||
}
|
||||
|
||||
private void SetMemoryValue(string key, string value, TimeSpan? expiry)
|
||||
{
|
||||
if (!ShouldUseMemoryCache(key))
|
||||
return;
|
||||
|
||||
var memoryTtl = GetMemoryTtl(expiry);
|
||||
if (memoryTtl == null)
|
||||
{
|
||||
_memoryCache.Remove(key);
|
||||
_memoryKeys.TryRemove(key, out _);
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = memoryTtl
|
||||
};
|
||||
options.RegisterPostEvictionCallback(
|
||||
static (cacheKey, _, _, state) =>
|
||||
{
|
||||
if (cacheKey is string stringKey && state is ConcurrentDictionary<string, byte> memoryKeys)
|
||||
{
|
||||
memoryKeys.TryRemove(stringKey, out _);
|
||||
}
|
||||
},
|
||||
_memoryKeys);
|
||||
|
||||
_memoryCache.Set(key, value, options);
|
||||
_memoryKeys[key] = 0;
|
||||
}
|
||||
|
||||
private int RemoveMemoryKeysByPattern(string pattern)
|
||||
{
|
||||
if (_memoryKeys.IsEmpty)
|
||||
return 0;
|
||||
|
||||
if (!pattern.Contains('*') && !pattern.Contains('?'))
|
||||
{
|
||||
var removed = _memoryKeys.TryRemove(pattern, out _);
|
||||
_memoryCache.Remove(pattern);
|
||||
return removed ? 1 : 0;
|
||||
}
|
||||
|
||||
var regex = new Regex(
|
||||
"^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$",
|
||||
RegexOptions.CultureInvariant);
|
||||
var keysToRemove = _memoryKeys.Keys.Where(key => regex.IsMatch(key)).ToArray();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_memoryCache.Remove(key);
|
||||
_memoryKeys.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
return keysToRemove.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached value as a string.
|
||||
/// Checks L1 memory cache first, falls back to L2 Redis.
|
||||
/// </summary>
|
||||
public ValueTask<string?> GetStringAsync(string key)
|
||||
{
|
||||
// L1: Try in-memory cache first (sub-microsecond)
|
||||
if (TryGetMemoryValue(key, out var memoryValue))
|
||||
{
|
||||
_logger.LogDebug("L1 memory cache HIT: {Key}", key);
|
||||
return new ValueTask<string?>(memoryValue);
|
||||
}
|
||||
|
||||
if (!IsEnabled) return new ValueTask<string?>((string?)null);
|
||||
|
||||
return new ValueTask<string?>(GetStringFromRedisAsync(key));
|
||||
}
|
||||
|
||||
private async Task<string?> GetStringFromRedisAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
// L2: Fall back to Redis
|
||||
var value = await _db!.StringGetAsync(key);
|
||||
|
||||
if (value.HasValue)
|
||||
{
|
||||
_logger.LogDebug("Redis cache HIT: {Key}", key);
|
||||
_logger.LogDebug("L2 Redis cache HIT: {Key}", key);
|
||||
|
||||
// Promote to L1 for subsequent reads
|
||||
if (ShouldUseMemoryCache(key))
|
||||
{
|
||||
var stringValue = (string?)value;
|
||||
if (stringValue != null)
|
||||
{
|
||||
var redisExpiry = await _db.KeyTimeToLiveAsync(key);
|
||||
SetMemoryValue(key, stringValue, redisExpiry);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Redis cache MISS: {Key}", key);
|
||||
_logger.LogDebug("Cache MISS: {Key}", key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -77,15 +229,17 @@ public class RedisCacheService
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached value and deserializes it.
|
||||
/// Uses source-generated serializer for registered types (3-8x faster),
|
||||
/// with automatic fallback to reflection-based serialization.
|
||||
/// </summary>
|
||||
public async Task<T?> GetAsync<T>(string key) where T : class
|
||||
public async ValueTask<T?> GetAsync<T>(string key) where T : class
|
||||
{
|
||||
var json = await GetStringAsync(key);
|
||||
if (string.IsNullOrEmpty(json)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
return DeserializeWithFallback<T>(json, key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -96,11 +250,20 @@ public class RedisCacheService
|
||||
|
||||
/// <summary>
|
||||
/// Sets a cached value with TTL.
|
||||
/// Writes to both L1 memory cache and L2 Redis.
|
||||
/// </summary>
|
||||
public async Task<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
|
||||
public ValueTask<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
|
||||
{
|
||||
if (!IsEnabled) return false;
|
||||
// Always update L1 (even if Redis is down — provides degraded caching)
|
||||
SetMemoryValue(key, value, expiry);
|
||||
|
||||
if (!IsEnabled) return new ValueTask<bool>(false);
|
||||
|
||||
return new ValueTask<bool>(SetStringWithRedisAsync(key, value, expiry));
|
||||
}
|
||||
|
||||
private async Task<bool> SetStringWithRedisAsync(string key, string value, TimeSpan? expiry)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await SetStringInternalAsync(key, value, expiry);
|
||||
@@ -197,12 +360,14 @@ public class RedisCacheService
|
||||
|
||||
/// <summary>
|
||||
/// Sets a cached value by serializing it with TTL.
|
||||
/// Uses source-generated serializer for registered types (3-8x faster),
|
||||
/// with automatic fallback to reflection-based serialization.
|
||||
/// </summary>
|
||||
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
|
||||
public async ValueTask<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
var json = SerializeWithFallback(value, key);
|
||||
return await SetStringAsync(key, json, expiry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -212,13 +377,80 @@ public class RedisCacheService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a cached value.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteAsync(string key)
|
||||
private T? DeserializeWithFallback<T>(string json, string key) where T : class
|
||||
{
|
||||
if (!IsEnabled) return false;
|
||||
var typeInfo = TryGetTypeInfo<T>();
|
||||
if (typeInfo != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(json, typeInfo);
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Source-generated deserialization unsupported for key: {Key}; falling back to reflection.",
|
||||
key);
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(json, ReflectionFallbackJsonOptions);
|
||||
}
|
||||
|
||||
private string SerializeWithFallback<T>(T value, string key) where T : class
|
||||
{
|
||||
var typeInfo = TryGetTypeInfo<T>();
|
||||
if (typeInfo != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(value, typeInfo);
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Source-generated serialization unsupported for key: {Key}; falling back to reflection.",
|
||||
key);
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(value, ReflectionFallbackJsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to resolve a JsonTypeInfo from the AllstarrJsonContext source generator.
|
||||
/// Returns null if the type isn't registered, triggering fallback to reflection.
|
||||
/// </summary>
|
||||
private static JsonTypeInfo<T>? TryGetTypeInfo<T>() where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JsonTypeInfo<T>?)AllstarrJsonContext.Default.GetTypeInfo(typeof(T));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a cached value from both L1 memory and L2 Redis.
|
||||
/// </summary>
|
||||
public ValueTask<bool> DeleteAsync(string key)
|
||||
{
|
||||
// Always evict from L1
|
||||
_memoryCache.Remove(key);
|
||||
_memoryKeys.TryRemove(key, out _);
|
||||
|
||||
if (!IsEnabled) return new ValueTask<bool>(false);
|
||||
|
||||
return new ValueTask<bool>(DeleteFromRedisAsync(key));
|
||||
}
|
||||
|
||||
private async Task<bool> DeleteFromRedisAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _db!.KeyDeleteAsync(key);
|
||||
@@ -233,10 +465,20 @@ public class RedisCacheService
|
||||
/// <summary>
|
||||
/// Checks if a key exists.
|
||||
/// </summary>
|
||||
public async Task<bool> ExistsAsync(string key)
|
||||
public ValueTask<bool> ExistsAsync(string key)
|
||||
{
|
||||
if (!IsEnabled) return false;
|
||||
if (ShouldUseMemoryCache(key) && _memoryCache.TryGetValue(key, out _))
|
||||
{
|
||||
return new ValueTask<bool>(true);
|
||||
}
|
||||
|
||||
if (!IsEnabled) return new ValueTask<bool>(false);
|
||||
|
||||
return new ValueTask<bool>(ExistsInRedisAsync(key));
|
||||
}
|
||||
|
||||
private async Task<bool> ExistsInRedisAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _db!.KeyExistsAsync(key);
|
||||
@@ -271,10 +513,16 @@ public class RedisCacheService
|
||||
/// Deletes all keys matching a pattern (e.g., "search:*").
|
||||
/// WARNING: Use with caution as this scans all keys.
|
||||
/// </summary>
|
||||
public async Task<int> DeleteByPatternAsync(string pattern)
|
||||
public ValueTask<int> DeleteByPatternAsync(string pattern)
|
||||
{
|
||||
if (!IsEnabled) return 0;
|
||||
var memoryDeleted = RemoveMemoryKeysByPattern(pattern);
|
||||
if (!IsEnabled) return new ValueTask<int>(memoryDeleted);
|
||||
|
||||
return new ValueTask<int>(DeleteByPatternFromRedisAsync(pattern, memoryDeleted));
|
||||
}
|
||||
|
||||
private async Task<int> DeleteByPatternFromRedisAsync(string pattern, int memoryDeleted)
|
||||
{
|
||||
try
|
||||
{
|
||||
var server = _redis!.GetServer(_redis.GetEndPoints().First());
|
||||
@@ -282,18 +530,22 @@ public class RedisCacheService
|
||||
|
||||
if (keys.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
|
||||
return 0;
|
||||
_logger.LogDebug("No Redis keys found matching pattern: {Pattern}", pattern);
|
||||
return memoryDeleted;
|
||||
}
|
||||
|
||||
var deleted = await _db!.KeyDeleteAsync(keys);
|
||||
_logger.LogDebug("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
|
||||
_logger.LogDebug(
|
||||
"Deleted {RedisCount} Redis keys and {MemoryCount} memory keys matching pattern: {Pattern}",
|
||||
deleted,
|
||||
memoryDeleted,
|
||||
pattern);
|
||||
return (int)deleted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
||||
return 0;
|
||||
return memoryDeleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,16 +442,18 @@ public class RoundRobinFallbackHelper
|
||||
private void LogEndpointFailure(string baseUrl, Exception ex, bool willRetry)
|
||||
{
|
||||
var message = BuildFailureSummary(ex);
|
||||
var isTimeoutOrCancellation = ex is TaskCanceledException or OperationCanceledException;
|
||||
var verb = isTimeoutOrCancellation ? "request timed out" : "request failed";
|
||||
|
||||
if (willRetry)
|
||||
{
|
||||
_logger.LogWarning("{Service} request failed at {Endpoint}: {Error}. Trying next...",
|
||||
_serviceName, baseUrl, message);
|
||||
_logger.LogWarning("{Service} {Verb} at {Endpoint}: {Error}. Trying next...",
|
||||
_serviceName, verb, baseUrl, message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("{Service} request failed at {Endpoint}: {Error}",
|
||||
_serviceName, baseUrl, message);
|
||||
_logger.LogError("{Service} {Verb} at {Endpoint}: {Error}",
|
||||
_serviceName, verb, baseUrl, message);
|
||||
}
|
||||
|
||||
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
|
||||
@@ -466,6 +468,16 @@ public class RoundRobinFallbackHelper
|
||||
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
|
||||
}
|
||||
|
||||
if (ex is TaskCanceledException)
|
||||
{
|
||||
return "Timed out";
|
||||
}
|
||||
|
||||
if (ex is OperationCanceledException)
|
||||
{
|
||||
return "Canceled";
|
||||
}
|
||||
|
||||
return ex.Message;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
public static class SpotifyPlaylistScopeResolver
|
||||
{
|
||||
public static SpotifyPlaylistConfig? ResolveConfig(
|
||||
SpotifyImportSettings settings,
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
|
||||
{
|
||||
var byJellyfinId = settings.GetPlaylistByJellyfinId(jellyfinPlaylistId.Trim());
|
||||
if (byJellyfinId != null)
|
||||
{
|
||||
return byJellyfinId;
|
||||
}
|
||||
}
|
||||
|
||||
return settings.GetPlaylistByName(playlistName, userId, jellyfinPlaylistId);
|
||||
}
|
||||
|
||||
public static string? GetUserId(SpotifyPlaylistConfig? playlist, string? fallbackUserId = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playlist?.UserId))
|
||||
{
|
||||
return playlist.UserId.Trim();
|
||||
}
|
||||
|
||||
// A configured playlist with no explicit owner is global. Do not
|
||||
// accidentally scope its caches to whichever Jellyfin user made
|
||||
// the current request.
|
||||
if (playlist != null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(fallbackUserId) ? null : fallbackUserId.Trim();
|
||||
}
|
||||
|
||||
public static string? GetScopeId(SpotifyPlaylistConfig? playlist, string? fallbackScopeId = null)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
|
||||
{
|
||||
return playlist.JellyfinId.Trim();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(playlist?.Id))
|
||||
{
|
||||
return playlist.Id.Trim();
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(fallbackScopeId) ? null : fallbackScopeId.Trim();
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ public class VersionUpgradeRebuildService : IHostedService
|
||||
private readonly SpotifyTrackMatchingService _matchingService;
|
||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||
private readonly ILogger<VersionUpgradeRebuildService> _logger;
|
||||
private CancellationTokenSource? _backgroundRebuildCts;
|
||||
private Task? _backgroundRebuildTask;
|
||||
|
||||
public VersionUpgradeRebuildService(
|
||||
SpotifyTrackMatchingService matchingService,
|
||||
@@ -55,12 +53,15 @@ public class VersionUpgradeRebuildService : IHostedService
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Scheduling full rebuild for all playlists in background after version upgrade");
|
||||
|
||||
_backgroundRebuildCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
_backgroundRebuildTask = RunBackgroundRebuildAsync(currentVersion, _backgroundRebuildCts.Token);
|
||||
return;
|
||||
_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
|
||||
@@ -75,51 +76,7 @@ public class VersionUpgradeRebuildService : IHostedService
|
||||
|
||||
public Task StopAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return StopBackgroundRebuildAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RunBackgroundRebuildAsync(string currentVersion, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogInformation("Starting background full rebuild for all playlists after version upgrade");
|
||||
await _matchingService.TriggerRebuildAllAsync(cancellationToken);
|
||||
_logger.LogInformation("Background full rebuild after version upgrade completed");
|
||||
await WriteCurrentVersionAsync(currentVersion, cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Background full rebuild after version upgrade was cancelled before completion");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
|
||||
await WriteCurrentVersionAsync(currentVersion, CancellationToken.None);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task StopBackgroundRebuildAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_backgroundRebuildTask == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_backgroundRebuildCts?.Cancel();
|
||||
await _backgroundRebuildTask.WaitAsync(cancellationToken);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
// Host shutdown is in progress or the background task observed cancellation.
|
||||
}
|
||||
finally
|
||||
{
|
||||
_backgroundRebuildCts?.Dispose();
|
||||
_backgroundRebuildCts = null;
|
||||
_backgroundRebuildTask = null;
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private async Task<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken)
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
using allstarr.Models.Domain;
|
||||
|
||||
namespace allstarr.Services.Jellyfin;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves "Appears on" albums for external artists.
|
||||
/// Prefers provider-supplied album lists when they expose non-primary releases and
|
||||
/// falls back to deriving albums from artist track payloads when needed.
|
||||
/// </summary>
|
||||
public class ExternalArtistAppearancesService
|
||||
{
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ILogger<ExternalArtistAppearancesService> _logger;
|
||||
|
||||
public ExternalArtistAppearancesService(
|
||||
IMusicMetadataService metadataService,
|
||||
ILogger<ExternalArtistAppearancesService> logger)
|
||||
{
|
||||
_metadataService = metadataService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<List<Album>> GetAppearsOnAlbumsAsync(
|
||||
string provider,
|
||||
string externalId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var artistTask = _metadataService.GetArtistAsync(provider, externalId, cancellationToken);
|
||||
var albumsTask = _metadataService.GetArtistAlbumsAsync(provider, externalId, cancellationToken);
|
||||
var tracksTask = _metadataService.GetArtistTracksAsync(provider, externalId, cancellationToken);
|
||||
|
||||
await Task.WhenAll(artistTask, albumsTask, tracksTask);
|
||||
|
||||
var artist = await artistTask;
|
||||
if (artist == null || string.IsNullOrWhiteSpace(artist.Name))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No external artist metadata available for appears-on lookup: provider={Provider}, externalId={ExternalId}",
|
||||
provider,
|
||||
externalId);
|
||||
return new List<Album>();
|
||||
}
|
||||
|
||||
var allArtistAlbums = await albumsTask;
|
||||
var artistTracks = await tracksTask;
|
||||
var appearsOnAlbums = new Dictionary<string, Album>(StringComparer.OrdinalIgnoreCase);
|
||||
var albumsById = allArtistAlbums
|
||||
.Where(album => !string.IsNullOrWhiteSpace(album.Id))
|
||||
.GroupBy(album => album.Id, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(group => group.Key, group => group.First(), StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var album in allArtistAlbums)
|
||||
{
|
||||
if (!IsKnownNonPrimaryAlbum(album, artist))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddAlbumIfMissing(appearsOnAlbums, album);
|
||||
}
|
||||
|
||||
foreach (var track in artistTracks)
|
||||
{
|
||||
var album = TryCreateAlbumFromTrack(provider, track, artist, albumsById);
|
||||
if (album == null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
AddAlbumIfMissing(appearsOnAlbums, album);
|
||||
}
|
||||
|
||||
var resolvedAlbums = appearsOnAlbums.Values
|
||||
.OrderByDescending(album => album.Year ?? int.MinValue)
|
||||
.ThenBy(album => album.Title, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
|
||||
_logger.LogDebug(
|
||||
"Resolved {Count} external appears-on albums for artist {ArtistId}",
|
||||
resolvedAlbums.Count,
|
||||
artist.Id);
|
||||
|
||||
return resolvedAlbums;
|
||||
}
|
||||
|
||||
private static Album? TryCreateAlbumFromTrack(
|
||||
string provider,
|
||||
Song track,
|
||||
Artist artist,
|
||||
IReadOnlyDictionary<string, Album> albumsById)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(track.AlbumId) || string.IsNullOrWhiteSpace(track.Album))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (albumsById.TryGetValue(track.AlbumId, out var knownAlbum))
|
||||
{
|
||||
return IsKnownNonPrimaryAlbum(knownAlbum, artist) ? knownAlbum : null;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(track.AlbumArtist) || NamesEqual(track.AlbumArtist, artist.Name))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Album
|
||||
{
|
||||
Id = track.AlbumId,
|
||||
Title = track.Album,
|
||||
Artist = track.AlbumArtist,
|
||||
Year = track.Year,
|
||||
SongCount = track.TotalTracks,
|
||||
CoverArtUrl = track.CoverArtUrl,
|
||||
IsLocal = false,
|
||||
ExternalProvider = provider,
|
||||
ExternalId = ExtractExternalAlbumId(track.AlbumId, provider)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsKnownNonPrimaryAlbum(Album album, Artist artist)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(album.ArtistId) && !string.IsNullOrWhiteSpace(artist.Id))
|
||||
{
|
||||
return !string.Equals(album.ArtistId, artist.Id, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
return !string.IsNullOrWhiteSpace(album.Artist) && !NamesEqual(album.Artist, artist.Name);
|
||||
}
|
||||
|
||||
private static void AddAlbumIfMissing(IDictionary<string, Album> albums, Album album)
|
||||
{
|
||||
var key = BuildAlbumKey(album);
|
||||
if (!albums.ContainsKey(key))
|
||||
{
|
||||
albums[key] = album;
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildAlbumKey(Album album)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(album.Id))
|
||||
{
|
||||
return album.Id;
|
||||
}
|
||||
|
||||
return $"{album.Title}\u001f{album.Artist}";
|
||||
}
|
||||
|
||||
private static string? ExtractExternalAlbumId(string albumId, string provider)
|
||||
{
|
||||
var prefix = $"ext-{provider}-album-";
|
||||
return albumId.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
|
||||
? albumId[prefix.Length..]
|
||||
: null;
|
||||
}
|
||||
|
||||
private static bool NamesEqual(string? left, string? right)
|
||||
{
|
||||
return string.Equals(
|
||||
left?.Trim(),
|
||||
right?.Trim(),
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ public class JellyfinProxyService
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly JellyfinUserContextResolver _userContextResolver;
|
||||
private readonly ILogger<JellyfinProxyService> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private string? _cachedMusicLibraryId;
|
||||
@@ -36,16 +37,35 @@ public class JellyfinProxyService
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<JellyfinSettings> settings,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
JellyfinUserContextResolver userContextResolver,
|
||||
ILogger<JellyfinProxyService> logger,
|
||||
RedisCacheService cache)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
_settings = settings.Value;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_userContextResolver = userContextResolver;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
private async Task AddResolvedUserIdAsync(
|
||||
Dictionary<string, string> queryParams,
|
||||
IHeaderDictionary? clientHeaders = null,
|
||||
bool allowConfigurationFallback = true)
|
||||
{
|
||||
if (queryParams.ContainsKey("userId") || queryParams.ContainsKey("UserId"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await _userContextResolver.ResolveCurrentUserIdAsync(clientHeaders, allowConfigurationFallback);
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
queryParams["userId"] = userId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the music library ID, auto-detecting it if not configured.
|
||||
/// </summary>
|
||||
@@ -191,14 +211,10 @@ public class JellyfinProxyService
|
||||
{
|
||||
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
// Always parse the response, even for errors
|
||||
// The caller needs to see 401s so the client can re-authenticate
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||
@@ -207,23 +223,22 @@ public class JellyfinProxyService
|
||||
}
|
||||
|
||||
// Try to parse error response to pass through to client
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
var errorDoc = JsonDocument.Parse(content);
|
||||
return (errorDoc, statusCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not valid JSON, return null
|
||||
}
|
||||
await using var errorStream = await response.Content.ReadAsStreamAsync();
|
||||
var errorDoc = await JsonDocument.ParseAsync(errorStream);
|
||||
return (errorDoc, statusCode);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON, return null
|
||||
}
|
||||
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
return (JsonDocument.Parse(content), statusCode);
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
return (await JsonDocument.ParseAsync(stream), statusCode);
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateClientGetRequest(
|
||||
@@ -552,10 +567,7 @@ public class JellyfinProxyService
|
||||
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
// Note: We don't force parentId here - let clients specify which library to search
|
||||
// The controller will detect music library searches and add external results
|
||||
@@ -602,10 +614,7 @@ public class JellyfinProxyService
|
||||
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds,ParentId"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
if (!string.IsNullOrEmpty(parentId))
|
||||
{
|
||||
@@ -647,10 +656,7 @@ public class JellyfinProxyService
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders);
|
||||
}
|
||||
@@ -669,10 +675,7 @@ public class JellyfinProxyService
|
||||
["fields"] = "PrimaryImageAspectRatio,Genres,Overview"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
if (!string.IsNullOrEmpty(searchTerm))
|
||||
{
|
||||
@@ -699,10 +702,7 @@ public class JellyfinProxyService
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
// Try to get by ID first
|
||||
if (Guid.TryParse(artistIdOrName, out _))
|
||||
@@ -893,10 +893,7 @@ public class JellyfinProxyService
|
||||
try
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams);
|
||||
|
||||
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
|
||||
if (result == null)
|
||||
@@ -1017,12 +1014,12 @@ public class JellyfinProxyService
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
|
||||
statusCode, url, content);
|
||||
return (null, statusCode);
|
||||
@@ -1030,12 +1027,13 @@ public class JellyfinProxyService
|
||||
|
||||
try
|
||||
{
|
||||
var jsonDocument = JsonDocument.Parse(content);
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
var jsonDocument = await JsonDocument.ParseAsync(stream);
|
||||
return (jsonDocument, statusCode);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
|
||||
_logger.LogError(ex, "Failed to parse JSON response from {Url}", url);
|
||||
return (null, statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Jellyfin;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Serialization;
|
||||
|
||||
namespace allstarr.Services.Jellyfin;
|
||||
|
||||
@@ -185,21 +186,21 @@ public class JellyfinSessionManager : IDisposable
|
||||
/// </summary>
|
||||
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||
{
|
||||
var capabilities = new
|
||||
var capabilities = new JellyfinSessionCapabilitiesPayload
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = new[]
|
||||
{
|
||||
PlayableMediaTypes = ["Audio"],
|
||||
SupportedCommands =
|
||||
[
|
||||
"Play",
|
||||
"Playstate",
|
||||
"PlayNext"
|
||||
},
|
||||
],
|
||||
SupportsMediaControl = true,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(capabilities);
|
||||
var json = AllstarrJsonSerializer.Serialize(capabilities);
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
@@ -455,12 +456,12 @@ public class JellyfinSessionManager : IDisposable
|
||||
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
|
||||
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
|
||||
{
|
||||
var stopPayload = new
|
||||
var stopPayload = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = session.LastPlayingItemId,
|
||||
PositionTicks = session.LastPlayingPositionTicks ?? 0
|
||||
};
|
||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||
var stopJson = AllstarrJsonSerializer.Serialize(stopPayload);
|
||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Jellyfin;
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the effective Jellyfin user for the current request.
|
||||
/// Prefers explicit request/session context and falls back to the legacy configured user id.
|
||||
/// </summary>
|
||||
public class JellyfinUserContextResolver
|
||||
{
|
||||
private static readonly TimeSpan TokenLookupCacheTtl = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly ILogger<JellyfinUserContextResolver> _logger;
|
||||
|
||||
public JellyfinUserContextResolver(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<JellyfinSettings> settings,
|
||||
IMemoryCache memoryCache,
|
||||
ILogger<JellyfinUserContextResolver> logger)
|
||||
{
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
_settings = settings.Value;
|
||||
_memoryCache = memoryCache;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public async Task<string?> ResolveCurrentUserIdAsync(
|
||||
IHeaderDictionary? headers = null,
|
||||
bool allowConfigurationFallback = true,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var httpContext = _httpContextAccessor.HttpContext;
|
||||
var request = httpContext?.Request;
|
||||
headers ??= request?.Headers;
|
||||
|
||||
var explicitUserId = request?.RouteValues["userId"]?.ToString();
|
||||
if (string.IsNullOrWhiteSpace(explicitUserId))
|
||||
{
|
||||
explicitUserId = request?.Query["userId"].ToString();
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(explicitUserId))
|
||||
{
|
||||
return explicitUserId.Trim();
|
||||
}
|
||||
|
||||
if (httpContext?.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) == true &&
|
||||
sessionObj is AdminAuthSession session &&
|
||||
!string.IsNullOrWhiteSpace(session.UserId))
|
||||
{
|
||||
return session.UserId.Trim();
|
||||
}
|
||||
|
||||
if (headers != null)
|
||||
{
|
||||
var headerUserId = AuthHeaderHelper.ExtractUserId(headers);
|
||||
if (!string.IsNullOrWhiteSpace(headerUserId))
|
||||
{
|
||||
return headerUserId.Trim();
|
||||
}
|
||||
|
||||
var token = AuthHeaderHelper.ExtractAccessToken(headers);
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
var cacheKey = BuildTokenCacheKey(token);
|
||||
if (_memoryCache.TryGetValue(cacheKey, out string? cachedUserId) &&
|
||||
!string.IsNullOrWhiteSpace(cachedUserId))
|
||||
{
|
||||
return cachedUserId;
|
||||
}
|
||||
|
||||
var resolvedUserId = await ResolveUserIdFromJellyfinAsync(headers, cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(resolvedUserId))
|
||||
{
|
||||
_memoryCache.Set(cacheKey, resolvedUserId.Trim(), TokenLookupCacheTtl);
|
||||
return resolvedUserId.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (allowConfigurationFallback && !string.IsNullOrWhiteSpace(_settings.UserId))
|
||||
{
|
||||
_logger.LogDebug("Falling back to configured Jellyfin user id for current request scope");
|
||||
return _settings.UserId.Trim();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveUserIdFromJellyfinAsync(
|
||||
IHeaderDictionary headers,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_settings.Url))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using var request = new HttpRequestMessage(
|
||||
HttpMethod.Get,
|
||||
$"{_settings.Url.TrimEnd('/')}/Users/Me");
|
||||
|
||||
if (!AuthHeaderHelper.ForwardAuthHeaders(headers, request))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
request.Headers.Accept.ParseAdd("application/json");
|
||||
|
||||
var client = _httpClientFactory.CreateClient(JellyfinProxyService.HttpClientName);
|
||||
using var response = await client.SendAsync(request, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
_logger.LogDebug("Failed to resolve Jellyfin user from token via /Users/Me: {StatusCode}",
|
||||
response.StatusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var doc = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
if (doc.RootElement.TryGetProperty("Id", out var idProp))
|
||||
{
|
||||
var userId = idProp.GetString();
|
||||
return string.IsNullOrWhiteSpace(userId) ? null : userId.Trim();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Error resolving Jellyfin user from auth token");
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string BuildTokenCacheKey(string token)
|
||||
{
|
||||
var hash = SHA256.HashData(Encoding.UTF8.GetBytes(token));
|
||||
return $"jellyfin:user-from-token:{Convert.ToHexString(hash)}";
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
using allstarr.Models.Domain;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
public interface IKeptLyricsSidecarService
|
||||
{
|
||||
string GetSidecarPath(string audioFilePath);
|
||||
|
||||
Task<string?> EnsureSidecarAsync(
|
||||
string audioFilePath,
|
||||
Song? song = null,
|
||||
string? externalProvider = null,
|
||||
string? externalId = null,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
@@ -1,319 +0,0 @@
|
||||
using System.Text.RegularExpressions;
|
||||
using TagLib;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Lyrics;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace allstarr.Services.Lyrics;
|
||||
|
||||
public class KeptLyricsSidecarService : IKeptLyricsSidecarService
|
||||
{
|
||||
private static readonly Regex ProviderSuffixRegex = new(
|
||||
@"\[(?<provider>[A-Za-z0-9_-]+)-(?<externalId>[^\]]+)\]$",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
private readonly LyricsOrchestrator _lyricsOrchestrator;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly SpotifyImportSettings _spotifySettings;
|
||||
private readonly OdesliService _odesliService;
|
||||
private readonly ILogger<KeptLyricsSidecarService> _logger;
|
||||
|
||||
public KeptLyricsSidecarService(
|
||||
LyricsOrchestrator lyricsOrchestrator,
|
||||
RedisCacheService cache,
|
||||
IOptions<SpotifyImportSettings> spotifySettings,
|
||||
OdesliService odesliService,
|
||||
ILogger<KeptLyricsSidecarService> logger)
|
||||
{
|
||||
_lyricsOrchestrator = lyricsOrchestrator;
|
||||
_cache = cache;
|
||||
_spotifySettings = spotifySettings.Value;
|
||||
_odesliService = odesliService;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public string GetSidecarPath(string audioFilePath)
|
||||
{
|
||||
return Path.ChangeExtension(audioFilePath, ".lrc");
|
||||
}
|
||||
|
||||
public async Task<string?> EnsureSidecarAsync(
|
||||
string audioFilePath,
|
||||
Song? song = null,
|
||||
string? externalProvider = null,
|
||||
string? externalId = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(audioFilePath) || !System.IO.File.Exists(audioFilePath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var sidecarPath = GetSidecarPath(audioFilePath);
|
||||
if (System.IO.File.Exists(sidecarPath))
|
||||
{
|
||||
return sidecarPath;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var inferredExternalRef = ParseExternalReferenceFromPath(audioFilePath);
|
||||
externalProvider ??= inferredExternalRef.Provider;
|
||||
externalId ??= inferredExternalRef.ExternalId;
|
||||
|
||||
var metadata = ReadAudioMetadata(audioFilePath);
|
||||
var artistNames = ResolveArtists(song, metadata);
|
||||
var title = FirstNonEmpty(
|
||||
StripTrackDecorators(song?.Title),
|
||||
StripTrackDecorators(metadata.Title),
|
||||
GetFallbackTitleFromPath(audioFilePath));
|
||||
var album = FirstNonEmpty(
|
||||
StripTrackDecorators(song?.Album),
|
||||
StripTrackDecorators(metadata.Album));
|
||||
var durationSeconds = song?.Duration ?? metadata.DurationSeconds;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(title) || artistNames.Count == 0)
|
||||
{
|
||||
_logger.LogDebug("Skipping lyrics sidecar generation for {Path}: missing title or artist metadata", audioFilePath);
|
||||
return null;
|
||||
}
|
||||
|
||||
var spotifyTrackId = FirstNonEmpty(song?.SpotifyId);
|
||||
if (string.IsNullOrWhiteSpace(spotifyTrackId) &&
|
||||
!string.IsNullOrWhiteSpace(externalProvider) &&
|
||||
!string.IsNullOrWhiteSpace(externalId))
|
||||
{
|
||||
spotifyTrackId = await ResolveSpotifyTrackIdAsync(externalProvider, externalId, cancellationToken);
|
||||
}
|
||||
|
||||
var lyrics = await _lyricsOrchestrator.GetLyricsAsync(
|
||||
trackName: title,
|
||||
artistNames: artistNames.ToArray(),
|
||||
albumName: album,
|
||||
durationSeconds: durationSeconds,
|
||||
spotifyTrackId: spotifyTrackId);
|
||||
|
||||
if (lyrics == null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var lrcContent = BuildLrcContent(
|
||||
lyrics,
|
||||
title,
|
||||
artistNames,
|
||||
album,
|
||||
durationSeconds);
|
||||
|
||||
if (string.IsNullOrWhiteSpace(lrcContent))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await System.IO.File.WriteAllTextAsync(sidecarPath, lrcContent, cancellationToken);
|
||||
_logger.LogInformation("Saved lyrics sidecar: {SidecarPath}", sidecarPath);
|
||||
return sidecarPath;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to create lyrics sidecar for {Path}", audioFilePath);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveSpotifyTrackIdAsync(
|
||||
string externalProvider,
|
||||
string externalId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var spotifyId = await FindSpotifyIdFromMatchedTracksAsync(externalProvider, externalId);
|
||||
if (!string.IsNullOrWhiteSpace(spotifyId))
|
||||
{
|
||||
return spotifyId;
|
||||
}
|
||||
|
||||
return externalProvider.ToLowerInvariant() switch
|
||||
{
|
||||
"squidwtf" => await _odesliService.ConvertTidalToSpotifyIdAsync(externalId, cancellationToken),
|
||||
"deezer" => await _odesliService.ConvertUrlToSpotifyIdAsync($"https://www.deezer.com/track/{externalId}", cancellationToken),
|
||||
"qobuz" => await _odesliService.ConvertUrlToSpotifyIdAsync($"https://www.qobuz.com/us-en/album/-/-/{externalId}", cancellationToken),
|
||||
_ => null
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<string?> FindSpotifyIdFromMatchedTracksAsync(string externalProvider, string externalId)
|
||||
{
|
||||
if (_spotifySettings.Playlists == null || _spotifySettings.Playlists.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
|
||||
|
||||
var match = matchedTracks?.FirstOrDefault(track =>
|
||||
track.MatchedSong != null &&
|
||||
string.Equals(track.MatchedSong.ExternalProvider, externalProvider, StringComparison.OrdinalIgnoreCase) &&
|
||||
string.Equals(track.MatchedSong.ExternalId, externalId, StringComparison.Ordinal));
|
||||
|
||||
if (match != null && !string.IsNullOrWhiteSpace(match.SpotifyId))
|
||||
{
|
||||
return match.SpotifyId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static (string? Provider, string? ExternalId) ParseExternalReferenceFromPath(string audioFilePath)
|
||||
{
|
||||
var baseName = Path.GetFileNameWithoutExtension(audioFilePath);
|
||||
var match = ProviderSuffixRegex.Match(baseName);
|
||||
if (!match.Success)
|
||||
{
|
||||
return (null, null);
|
||||
}
|
||||
|
||||
return (
|
||||
match.Groups["provider"].Value,
|
||||
match.Groups["externalId"].Value);
|
||||
}
|
||||
|
||||
private static AudioMetadata ReadAudioMetadata(string audioFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var tagFile = TagLib.File.Create(audioFilePath);
|
||||
return new AudioMetadata
|
||||
{
|
||||
Title = tagFile.Tag.Title,
|
||||
Album = tagFile.Tag.Album,
|
||||
Artists = tagFile.Tag.Performers?.Where(value => !string.IsNullOrWhiteSpace(value)).ToList() ?? new List<string>(),
|
||||
DurationSeconds = (int)Math.Round(tagFile.Properties.Duration.TotalSeconds)
|
||||
};
|
||||
}
|
||||
catch
|
||||
{
|
||||
return new AudioMetadata();
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> ResolveArtists(Song? song, AudioMetadata metadata)
|
||||
{
|
||||
var artists = new List<string>();
|
||||
|
||||
if (song?.Artists != null && song.Artists.Count > 0)
|
||||
{
|
||||
artists.AddRange(song.Artists.Where(value => !string.IsNullOrWhiteSpace(value)));
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(song?.Artist))
|
||||
{
|
||||
artists.Add(song.Artist);
|
||||
}
|
||||
|
||||
if (artists.Count == 0 && metadata.Artists.Count > 0)
|
||||
{
|
||||
artists.AddRange(metadata.Artists);
|
||||
}
|
||||
|
||||
return artists
|
||||
.Select(StripTrackDecorators)
|
||||
.Where(value => !string.IsNullOrWhiteSpace(value))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static string BuildLrcContent(
|
||||
LyricsInfo lyrics,
|
||||
string fallbackTitle,
|
||||
IReadOnlyList<string> fallbackArtists,
|
||||
string? fallbackAlbum,
|
||||
int fallbackDurationSeconds)
|
||||
{
|
||||
var title = FirstNonEmpty(lyrics.TrackName, fallbackTitle);
|
||||
var artist = FirstNonEmpty(lyrics.ArtistName, string.Join(", ", fallbackArtists));
|
||||
var album = FirstNonEmpty(lyrics.AlbumName, fallbackAlbum);
|
||||
var durationSeconds = lyrics.Duration > 0 ? lyrics.Duration : fallbackDurationSeconds;
|
||||
|
||||
var body = FirstNonEmpty(
|
||||
NormalizeLineEndings(lyrics.SyncedLyrics),
|
||||
NormalizeLineEndings(lyrics.PlainLyrics));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
var headerLines = new List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(artist))
|
||||
{
|
||||
headerLines.Add($"[ar:{artist}]");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(album))
|
||||
{
|
||||
headerLines.Add($"[al:{album}]");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(title))
|
||||
{
|
||||
headerLines.Add($"[ti:{title}]");
|
||||
}
|
||||
|
||||
if (durationSeconds > 0)
|
||||
{
|
||||
var duration = TimeSpan.FromSeconds(durationSeconds);
|
||||
headerLines.Add($"[length:{(int)duration.TotalMinutes}:{duration.Seconds:D2}]");
|
||||
}
|
||||
|
||||
return headerLines.Count == 0
|
||||
? body
|
||||
: $"{string.Join('\n', headerLines)}\n\n{body}";
|
||||
}
|
||||
|
||||
private static string? GetFallbackTitleFromPath(string audioFilePath)
|
||||
{
|
||||
var baseName = Path.GetFileNameWithoutExtension(audioFilePath);
|
||||
baseName = ProviderSuffixRegex.Replace(baseName, string.Empty).Trim();
|
||||
baseName = Regex.Replace(baseName, @"^\d+\s*-\s*", string.Empty);
|
||||
return baseName.Trim();
|
||||
}
|
||||
|
||||
private static string FirstNonEmpty(params string?[] values)
|
||||
{
|
||||
return values.FirstOrDefault(value => !string.IsNullOrWhiteSpace(value)) ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string NormalizeLineEndings(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value)
|
||||
? string.Empty
|
||||
: value.Replace("\r\n", "\n").Replace('\r', '\n').Trim();
|
||||
}
|
||||
|
||||
private static string StripTrackDecorators(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
return value
|
||||
.Replace(" [S]", "", StringComparison.Ordinal)
|
||||
.Replace(" [E]", "", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
|
||||
private sealed class AudioMetadata
|
||||
{
|
||||
public string? Title { get; init; }
|
||||
public string? Album { get; init; }
|
||||
public List<string> Artists { get; init; } = new();
|
||||
public int DurationSeconds { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -1026,7 +1026,26 @@ public class SpotifyApiClient : IDisposable
|
||||
continue;
|
||||
}
|
||||
|
||||
var trackCount = TryGetSpotifyPlaylistItemCount(playlist);
|
||||
// Get track count if available - try multiple possible paths
|
||||
var trackCount = 0;
|
||||
if (playlist.TryGetProperty("content", out var content))
|
||||
{
|
||||
if (content.TryGetProperty("totalCount", out var totalTrackCount))
|
||||
{
|
||||
trackCount = totalTrackCount.GetInt32();
|
||||
}
|
||||
}
|
||||
// Fallback: try attributes.itemCount
|
||||
else if (playlist.TryGetProperty("attributes", out var attributes) &&
|
||||
attributes.TryGetProperty("itemCount", out var itemCountProp))
|
||||
{
|
||||
trackCount = itemCountProp.GetInt32();
|
||||
}
|
||||
// Fallback: try totalCount directly
|
||||
else if (playlist.TryGetProperty("totalCount", out var directTotalCount))
|
||||
{
|
||||
trackCount = directTotalCount.GetInt32();
|
||||
}
|
||||
|
||||
// Log if we couldn't find track count for debugging
|
||||
if (trackCount == 0)
|
||||
@@ -1038,9 +1057,7 @@ public class SpotifyApiClient : IDisposable
|
||||
// Get owner name
|
||||
string? ownerName = null;
|
||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||
ownerV2.ValueKind == JsonValueKind.Object &&
|
||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||
ownerData.ValueKind == JsonValueKind.Object &&
|
||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||
{
|
||||
ownerName = ownerNameProp.GetString();
|
||||
@@ -1049,14 +1066,11 @@ public class SpotifyApiClient : IDisposable
|
||||
// Get image URL
|
||||
string? imageUrl = null;
|
||||
if (playlist.TryGetProperty("images", out var images) &&
|
||||
images.ValueKind == JsonValueKind.Object &&
|
||||
images.TryGetProperty("items", out var imageItems) &&
|
||||
imageItems.ValueKind == JsonValueKind.Array &&
|
||||
imageItems.GetArrayLength() > 0)
|
||||
{
|
||||
var firstImage = imageItems[0];
|
||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||
sources.ValueKind == JsonValueKind.Array &&
|
||||
sources.GetArrayLength() > 0)
|
||||
{
|
||||
var firstSource = sources[0];
|
||||
@@ -1151,68 +1165,6 @@ public class SpotifyApiClient : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static int TryGetSpotifyPlaylistItemCount(JsonElement playlistElement)
|
||||
{
|
||||
if (playlistElement.TryGetProperty("content", out var content) &&
|
||||
content.ValueKind == JsonValueKind.Object &&
|
||||
content.TryGetProperty("totalCount", out var totalTrackCount) &&
|
||||
TryParseSpotifyIntegerElement(totalTrackCount, out var contentCount))
|
||||
{
|
||||
return contentCount;
|
||||
}
|
||||
|
||||
if (playlistElement.TryGetProperty("attributes", out var attributes))
|
||||
{
|
||||
if (attributes.ValueKind == JsonValueKind.Object &&
|
||||
attributes.TryGetProperty("itemCount", out var itemCountProp) &&
|
||||
TryParseSpotifyIntegerElement(itemCountProp, out var directAttributeCount))
|
||||
{
|
||||
return directAttributeCount;
|
||||
}
|
||||
|
||||
if (attributes.ValueKind == JsonValueKind.Array)
|
||||
{
|
||||
foreach (var attribute in attributes.EnumerateArray())
|
||||
{
|
||||
if (attribute.ValueKind != JsonValueKind.Object ||
|
||||
!attribute.TryGetProperty("key", out var keyProp) ||
|
||||
keyProp.ValueKind != JsonValueKind.String ||
|
||||
!attribute.TryGetProperty("value", out var valueProp))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = keyProp.GetString();
|
||||
if (string.IsNullOrWhiteSpace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var normalizedKey = key.Replace("_", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(":", "", StringComparison.OrdinalIgnoreCase);
|
||||
if (!normalizedKey.Contains("itemcount", StringComparison.OrdinalIgnoreCase) &&
|
||||
!normalizedKey.Contains("trackcount", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (TryParseSpotifyIntegerElement(valueProp, out var attributeCount))
|
||||
{
|
||||
return attributeCount;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (playlistElement.TryGetProperty("totalCount", out var directTotalCount) &&
|
||||
TryParseSpotifyIntegerElement(directTotalCount, out var totalCount))
|
||||
{
|
||||
return totalCount;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private static DateTime? ParseSpotifyDateElement(JsonElement value)
|
||||
{
|
||||
switch (value.ValueKind)
|
||||
@@ -1286,40 +1238,6 @@ public class SpotifyApiClient : IDisposable
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool TryParseSpotifyIntegerElement(JsonElement value, out int parsed)
|
||||
{
|
||||
switch (value.ValueKind)
|
||||
{
|
||||
case JsonValueKind.Number:
|
||||
return value.TryGetInt32(out parsed);
|
||||
case JsonValueKind.String:
|
||||
return int.TryParse(value.GetString(), NumberStyles.Integer, CultureInfo.InvariantCulture, out parsed);
|
||||
case JsonValueKind.Object:
|
||||
if (value.TryGetProperty("value", out var nestedValue) &&
|
||||
TryParseSpotifyIntegerElement(nestedValue, out parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.TryGetProperty("itemCount", out var itemCount) &&
|
||||
TryParseSpotifyIntegerElement(itemCount, out parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
if (value.TryGetProperty("totalCount", out var totalCount) &&
|
||||
TryParseSpotifyIntegerElement(totalCount, out parsed))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
parsed = 0;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static DateTime? ParseSpotifyUnixTimestamp(long value)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -29,7 +29,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
private readonly RedisCacheService _cache;
|
||||
|
||||
// Track Spotify playlist IDs after discovery
|
||||
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
|
||||
private readonly Dictionary<string, string> _playlistScopeToSpotifyId = new();
|
||||
|
||||
public SpotifyPlaylistFetcher(
|
||||
ILogger<SpotifyPlaylistFetcher> logger,
|
||||
@@ -55,10 +55,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
/// </summary>
|
||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(string playlistName)
|
||||
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
_spotifyImportSettings,
|
||||
playlistName,
|
||||
userId,
|
||||
jellyfinPlaylistId);
|
||||
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
|
||||
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName, playlistScopeUserId, playlistScopeId);
|
||||
var playlistScope = CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, playlistScopeUserId, playlistScopeId);
|
||||
|
||||
// Try Redis cache first
|
||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
@@ -124,14 +134,14 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
try
|
||||
{
|
||||
// Try to use cached or configured Spotify playlist ID
|
||||
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||
if (!_playlistScopeToSpotifyId.TryGetValue(playlistScope, 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;
|
||||
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
|
||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||
}
|
||||
else
|
||||
@@ -150,7 +160,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
}
|
||||
|
||||
spotifyId = exactMatch.SpotifyId;
|
||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
|
||||
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
|
||||
}
|
||||
}
|
||||
@@ -226,7 +236,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
string playlistName,
|
||||
HashSet<string> jellyfinTrackIds)
|
||||
{
|
||||
var allTracks = await GetPlaylistTracksAsync(playlistName);
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var allTracks = await GetPlaylistTracksAsync(playlistName, playlistConfig?.UserId, playlistConfig?.JellyfinId);
|
||||
|
||||
// Filter to only tracks not in Jellyfin, preserving order
|
||||
return allTracks
|
||||
@@ -237,17 +248,30 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
/// <summary>
|
||||
/// Manual trigger to refresh a specific playlist.
|
||||
/// </summary>
|
||||
public async Task RefreshPlaylistAsync(string playlistName)
|
||||
public async Task RefreshPlaylistAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
|
||||
|
||||
// Clear cache to force refresh
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
|
||||
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
_spotifyImportSettings,
|
||||
playlistName,
|
||||
userId,
|
||||
jellyfinPlaylistId);
|
||||
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
|
||||
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
// Re-fetch
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
await ClearPlaylistImageCacheAsync(playlistName);
|
||||
await GetPlaylistTracksAsync(playlistName, playlistScopeUserId, playlistConfig?.JellyfinId ?? jellyfinPlaylistId);
|
||||
await ClearPlaylistImageCacheAsync(playlistName, userId, jellyfinPlaylistId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -259,13 +283,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
await RefreshPlaylistAsync(config.Name);
|
||||
await RefreshPlaylistAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearPlaylistImageCacheAsync(string playlistName)
|
||||
private async Task ClearPlaylistImageCacheAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
_spotifyImportSettings,
|
||||
playlistName,
|
||||
userId,
|
||||
jellyfinPlaylistId);
|
||||
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
|
||||
{
|
||||
return;
|
||||
@@ -331,7 +362,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
// Check each playlist to see if it needs refreshing based on cron schedule
|
||||
var now = DateTime.UtcNow;
|
||||
var needsRefresh = new List<string>();
|
||||
var needsRefresh = new List<SpotifyPlaylistConfig>();
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
@@ -342,7 +373,10 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
var cron = CronExpression.Parse(schedule);
|
||||
|
||||
// Check if we have cached data
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(config.Name);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
|
||||
config.Name,
|
||||
config.UserId,
|
||||
config.JellyfinId ?? config.Id);
|
||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
|
||||
if (cached != null)
|
||||
@@ -352,7 +386,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
if (nextRun.HasValue && now >= nextRun.Value)
|
||||
{
|
||||
needsRefresh.Add(config.Name);
|
||||
needsRefresh.Add(config);
|
||||
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
|
||||
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
|
||||
}
|
||||
@@ -360,7 +394,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
else
|
||||
{
|
||||
// No cache, fetch it
|
||||
needsRefresh.Add(config.Name);
|
||||
needsRefresh.Add(config);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -374,24 +408,24 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
|
||||
|
||||
foreach (var playlistName in needsRefresh)
|
||||
foreach (var config in needsRefresh)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
|
||||
// Rate limiting between playlists
|
||||
if (playlistName != needsRefresh.Last())
|
||||
if (!ReferenceEquals(config, needsRefresh.Last()))
|
||||
{
|
||||
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
|
||||
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
|
||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -419,7 +453,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
var tracks = await GetPlaylistTracksAsync(config.Name);
|
||||
var tracks = await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
_logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
||||
|
||||
// Log sample of track order for debugging
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -38,7 +39,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
||||
private const int BatchSize = 11; // Number of parallel searches (matches SquidWTF provider count)
|
||||
private static readonly TimeSpan ExternalProviderSearchTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
// Track last run time per playlist to prevent duplicate runs
|
||||
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
||||
@@ -73,6 +73,24 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? GetPlaylistScopeUserId(SpotifyPlaylistConfig? playlist) =>
|
||||
SpotifyPlaylistScopeResolver.GetUserId(playlist);
|
||||
|
||||
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist) =>
|
||||
SpotifyPlaylistScopeResolver.GetScopeId(playlist);
|
||||
|
||||
private SpotifyPlaylistConfig? ResolvePlaylistConfig(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null) =>
|
||||
SpotifyPlaylistScopeResolver.ResolveConfig(_spotifySettings, playlistName, userId, jellyfinPlaylistId);
|
||||
|
||||
private static string BuildPlaylistRunKey(SpotifyPlaylistConfig playlist) =>
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(
|
||||
playlist.Name,
|
||||
GetPlaylistScopeUserId(playlist),
|
||||
GetPlaylistScopeId(playlist));
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
@@ -122,7 +140,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
|
||||
var now = DateTime.UtcNow;
|
||||
var schedulerReference = now.AddMinutes(-1);
|
||||
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
||||
var nextRuns = new List<(SpotifyPlaylistConfig Playlist, DateTime NextRun, CronExpression Cron)>();
|
||||
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
@@ -135,7 +153,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
nextRuns.Add((playlist.Name, nextRun.Value, cron));
|
||||
nextRuns.Add((playlist, nextRun.Value, cron));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -170,7 +188,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var waitTime = nextPlaylist.NextRun - now;
|
||||
|
||||
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||
nextPlaylist.Playlist.Name, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||
|
||||
var maxWait = TimeSpan.FromHours(1);
|
||||
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
||||
@@ -191,10 +209,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
|
||||
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.Playlist.Name);
|
||||
|
||||
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||
due.PlaylistName,
|
||||
due.Playlist,
|
||||
stoppingToken,
|
||||
trigger: "cron");
|
||||
|
||||
@@ -205,7 +223,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC",
|
||||
due.PlaylistName, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||
due.Playlist.Name, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||
}
|
||||
|
||||
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
|
||||
@@ -226,29 +244,24 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
|
||||
/// Used by individual per-playlist rebuild actions.
|
||||
/// </summary>
|
||||
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||
private async Task RebuildSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
|
||||
{
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
|
||||
var playlistScopeId = GetPlaylistScopeId(playlist);
|
||||
var playlistName = playlist.Name;
|
||||
|
||||
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
|
||||
|
||||
// Clear cache for this playlist (same as "Rebuild All Remote" button)
|
||||
var keysToDelete = new[]
|
||||
{
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name), // Legacy key
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name)
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name, playlistScopeUserId, playlistScopeId)
|
||||
};
|
||||
|
||||
foreach (var key in keysToDelete)
|
||||
@@ -269,7 +282,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
if (playlistFetcher != null)
|
||||
{
|
||||
// Force refresh from Spotify (clears cache and re-fetches)
|
||||
await playlistFetcher.RefreshPlaylistAsync(playlist.Name);
|
||||
await playlistFetcher.RefreshPlaylistAsync(playlist.Name, playlistScopeUserId, playlist.JellyfinId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -281,13 +294,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||
playlist, playlistFetcher, metadataService, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, cancellationToken);
|
||||
playlist, metadataService, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -304,16 +317,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
|
||||
/// Used for lightweight re-matching when only local library has changed.
|
||||
/// </summary>
|
||||
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||
private async Task MatchSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
|
||||
{
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
var playlistName = playlist.Name;
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||
@@ -331,13 +337,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||
playlist, playlistFetcher, metadataService, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, cancellationToken);
|
||||
playlist, metadataService, cancellationToken);
|
||||
}
|
||||
|
||||
await ClearPlaylistImageCacheAsync(playlist);
|
||||
@@ -366,27 +372,38 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
|
||||
/// This clears caches, fetches fresh data, and re-matches everything immediately.
|
||||
/// </summary>
|
||||
public async Task TriggerRebuildAllAsync(CancellationToken cancellationToken = default)
|
||||
public async Task TriggerRebuildAllAsync()
|
||||
{
|
||||
_logger.LogInformation("Full rebuild triggered for all playlists");
|
||||
await RebuildAllPlaylistsAsync(cancellationToken);
|
||||
_logger.LogInformation("Manual full rebuild triggered for all playlists");
|
||||
await RebuildAllPlaylistsAsync(CancellationToken.None);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
|
||||
/// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
|
||||
/// </summary>
|
||||
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
|
||||
public async Task TriggerRebuildForPlaylistAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName);
|
||||
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||
playlistName,
|
||||
playlist,
|
||||
CancellationToken.None,
|
||||
trigger: "manual");
|
||||
|
||||
if (!rebuilt)
|
||||
{
|
||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||
var runKey = BuildPlaylistRunKey(playlist);
|
||||
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||
var remaining = _minimumRunInterval - timeSinceLastRun;
|
||||
@@ -400,11 +417,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||
string playlistName,
|
||||
SpotifyPlaylistConfig playlist,
|
||||
CancellationToken cancellationToken,
|
||||
string trigger)
|
||||
{
|
||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||
var runKey = BuildPlaylistRunKey(playlist);
|
||||
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
@@ -412,15 +430,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_logger.LogWarning(
|
||||
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
trigger,
|
||||
playlistName,
|
||||
playlist.Name,
|
||||
(int)timeSinceLastRun.TotalSeconds,
|
||||
(int)_minimumRunInterval.TotalSeconds);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
|
||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
|
||||
_lastRunTimes[runKey] = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -440,14 +458,23 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
|
||||
/// Use this when only the local library has changed, not when Spotify playlist changed.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
public async Task TriggerMatchingForPlaylistAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (lightweight, no cache clear)", playlistName);
|
||||
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
await MatchSinglePlaylistAsync(playlist, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
@@ -467,7 +494,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -495,7 +522,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||
await MatchSinglePlaylistAsync(playlist, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -513,15 +540,25 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Uses GREEDY ASSIGNMENT to maximize total matches.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||
string playlistName,
|
||||
SpotifyPlaylistConfig playlistConfig,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
|
||||
var playlist = playlistConfig ?? throw new ArgumentNullException(nameof(playlistConfig));
|
||||
var playlistName = playlist.Name;
|
||||
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
|
||||
var playlistScopeId = GetPlaylistScopeId(playlist);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
|
||||
// Get playlist tracks with full metadata including ISRC and position
|
||||
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlist.JellyfinId);
|
||||
if (spotifyTracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
|
||||
@@ -529,12 +566,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
// Get the Jellyfin playlist ID to check which tracks already exist
|
||||
var playlistConfig = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
HashSet<string> existingSpotifyIds = new();
|
||||
|
||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
||||
if (!string.IsNullOrEmpty(playlist.JellyfinId))
|
||||
{
|
||||
// Get existing tracks from Jellyfin playlist to avoid re-matching
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
@@ -546,8 +581,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
try
|
||||
{
|
||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||
var userId = jellyfinSettings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
||||
var userId = playlist.UserId ?? jellyfinSettings.UserId;
|
||||
var jellyfinPlaylistId = playlist.JellyfinId;
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
@@ -629,10 +665,18 @@ 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 = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, track.SpotifyId);
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
playlistName,
|
||||
track.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, track.SpotifyId);
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
playlistName,
|
||||
track.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
|
||||
@@ -660,7 +704,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
|
||||
var jellyfinTracks = new List<Song>();
|
||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
||||
if (!string.IsNullOrEmpty(playlist.JellyfinId))
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
@@ -671,8 +715,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = jellyfinSettings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
||||
var userId = playlist.UserId ?? jellyfinSettings.UserId;
|
||||
var jellyfinPlaylistId = playlist.JellyfinId;
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
|
||||
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
@@ -774,28 +819,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
if (cancellationToken.IsCancellationRequested) break;
|
||||
|
||||
var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList();
|
||||
var batchStart = i + 1;
|
||||
var batchEnd = i + batch.Count;
|
||||
var batchStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
_logger.LogInformation(
|
||||
"Starting external matching batch for {Playlist}: tracks {Start}-{End}/{Total}",
|
||||
playlistName,
|
||||
batchStart,
|
||||
batchEnd,
|
||||
unmatchedSpotifyTracks.Count);
|
||||
|
||||
var batchTasks = batch.Select(async spotifyTrack =>
|
||||
{
|
||||
var primaryArtist = spotifyTrack.PrimaryArtist;
|
||||
var trackStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(ExternalProviderSearchTimeout);
|
||||
var trackCancellationToken = timeoutCts.Token;
|
||||
|
||||
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||
|
||||
// Check global external mapping first
|
||||
@@ -807,23 +835,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
|
||||
!string.IsNullOrEmpty(globalMapping.ExternalId))
|
||||
{
|
||||
mappedSong = await metadataService.GetSongAsync(
|
||||
globalMapping.ExternalProvider,
|
||||
globalMapping.ExternalId,
|
||||
trackCancellationToken);
|
||||
mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId);
|
||||
}
|
||||
|
||||
if (mappedSong != null)
|
||||
{
|
||||
candidates.Add((mappedSong, 100.0, "global-mapping-external"));
|
||||
trackStopwatch.Stop();
|
||||
_logger.LogDebug(
|
||||
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms using global mapping",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist,
|
||||
trackStopwatch.ElapsedMilliseconds);
|
||||
return (spotifyTrack, candidates);
|
||||
}
|
||||
}
|
||||
@@ -831,31 +848,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Try ISRC match
|
||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||
{
|
||||
try
|
||||
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||
if (isrcSong != null)
|
||||
{
|
||||
var isrcSong = await TryMatchByIsrcAsync(
|
||||
spotifyTrack.Isrc,
|
||||
metadataService,
|
||||
trackCancellationToken);
|
||||
|
||||
if (isrcSong != null)
|
||||
{
|
||||
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"ISRC lookup failed for {Playlist} track #{Position}: {Title} by {Artist}",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist);
|
||||
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -863,8 +859,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||
spotifyTrack.Title,
|
||||
spotifyTrack.Artists,
|
||||
metadataService,
|
||||
trackCancellationToken);
|
||||
metadataService);
|
||||
|
||||
foreach (var (song, score) in fuzzySongs)
|
||||
{
|
||||
@@ -874,48 +869,16 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
trackStopwatch.Stop();
|
||||
_logger.LogDebug(
|
||||
"External candidate search finished for {Playlist} track #{Position}: {Title} by {Artist} in {ElapsedMs}ms with {CandidateCount} candidates",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist,
|
||||
trackStopwatch.ElapsedMilliseconds,
|
||||
candidates.Count);
|
||||
|
||||
return (spotifyTrack, candidates);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"External candidate search timed out for {Playlist} track #{Position}: {Title} by {Artist} after {TimeoutSeconds}s",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist,
|
||||
ExternalProviderSearchTimeout.TotalSeconds);
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(
|
||||
ex,
|
||||
"Failed to match track for {Playlist} track #{Position}: {Title} by {Artist}",
|
||||
playlistName,
|
||||
spotifyTrack.Position,
|
||||
spotifyTrack.Title,
|
||||
primaryArtist);
|
||||
_logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title);
|
||||
return (spotifyTrack, new List<(Song, double, string)>());
|
||||
}
|
||||
}).ToList();
|
||||
|
||||
var batchResults = await Task.WhenAll(batchTasks);
|
||||
batchStopwatch.Stop();
|
||||
|
||||
foreach (var result in batchResults)
|
||||
{
|
||||
@@ -925,16 +888,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
var batchCandidateCount = batchResults.Sum(result => result.Item2.Count);
|
||||
_logger.LogInformation(
|
||||
"Finished external matching batch for {Playlist}: tracks {Start}-{End}/{Total} in {ElapsedMs}ms ({CandidateCount} candidates)",
|
||||
playlistName,
|
||||
batchStart,
|
||||
batchEnd,
|
||||
unmatchedSpotifyTracks.Count,
|
||||
batchStopwatch.ElapsedMilliseconds,
|
||||
batchCandidateCount);
|
||||
|
||||
if (i + BatchSize < unmatchedSpotifyTracks.Count)
|
||||
{
|
||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||
@@ -1035,19 +988,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
["missing"] = statsMissingCount
|
||||
};
|
||||
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlistName);
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30));
|
||||
|
||||
_logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing",
|
||||
playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
|
||||
|
||||
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
|
||||
|
||||
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||
if (!string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -1074,10 +1027,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
|
||||
|
||||
// Save matched tracks to file for persistence across restarts
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks, playlistScopeUserId, playlistScopeId);
|
||||
|
||||
// Also update legacy cache for backward compatibility
|
||||
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
|
||||
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
||||
|
||||
@@ -1087,7 +1043,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
// This is what makes the UI show all matched tracks at once
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlist.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1107,136 +1063,140 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||
string title,
|
||||
List<string> artists,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
IMusicMetadataService metadataService)
|
||||
{
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||
var query = $"{titleStripped} {primaryArtist}";
|
||||
|
||||
var allCandidates = new List<(Song Song, double Score)>();
|
||||
|
||||
// STEP 1: Search LOCAL Jellyfin library FIRST
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
if (proxyService != null)
|
||||
try
|
||||
{
|
||||
try
|
||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
||||
var titleStripped = FuzzyMatcher.StripDecorators(title);
|
||||
var query = $"{titleStripped} {primaryArtist}";
|
||||
|
||||
var allCandidates = new List<(Song Song, double Score)>();
|
||||
|
||||
// STEP 1: Search LOCAL Jellyfin library FIRST
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
if (proxyService != null)
|
||||
{
|
||||
// Search Jellyfin for local tracks
|
||||
var searchParams = new Dictionary<string, string>
|
||||
try
|
||||
{
|
||||
["searchTerm"] = query,
|
||||
["includeItemTypes"] = "Audio",
|
||||
["recursive"] = "true",
|
||||
["limit"] = "10"
|
||||
};
|
||||
|
||||
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||
|
||||
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
var localResults = new List<Song>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
// Search Jellyfin for local tracks
|
||||
var searchParams = new Dictionary<string, string>
|
||||
{
|
||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
|
||||
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
["searchTerm"] = query,
|
||||
["includeItemTypes"] = "Audio",
|
||||
["recursive"] = "true",
|
||||
["limit"] = "10"
|
||||
};
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
var (searchResponse, _) = await proxyService.GetJsonAsyncInternal("Items", searchParams);
|
||||
|
||||
if (searchResponse != null && searchResponse.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
var localResults = new List<Song>();
|
||||
foreach (var item in items.EnumerateArray())
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
|
||||
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||
var artist = "";
|
||||
|
||||
if (item.TryGetProperty("Artists", out var artistsEl) && artistsEl.GetArrayLength() > 0)
|
||||
{
|
||||
artist = artistsEl[0].GetString() ?? "";
|
||||
}
|
||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
||||
{
|
||||
artist = albumArtistEl.GetString() ?? "";
|
||||
}
|
||||
|
||||
localResults.Add(new Song
|
||||
{
|
||||
Id = id,
|
||||
Title = songTitle,
|
||||
Artist = artist,
|
||||
IsLocal = true
|
||||
});
|
||||
}
|
||||
|
||||
localResults.Add(new Song
|
||||
if (localResults.Count > 0)
|
||||
{
|
||||
Id = id,
|
||||
Title = songTitle,
|
||||
Artist = artist,
|
||||
IsLocal = true
|
||||
});
|
||||
}
|
||||
// Score local results
|
||||
var scoredLocal = localResults
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
if (localResults.Count > 0)
|
||||
{
|
||||
// Score local results
|
||||
var scoredLocal = localResults
|
||||
.Select(song => new
|
||||
allCandidates.AddRange(scoredLocal);
|
||||
|
||||
// If we found good local matches, return them (don't search external)
|
||||
if (scoredLocal.Any(x => x.TotalScore >= 70))
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
allCandidates.AddRange(scoredLocal);
|
||||
|
||||
// If we found good local matches, return them (don't search external)
|
||||
if (scoredLocal.Any(x => x.TotalScore >= 70))
|
||||
{
|
||||
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
|
||||
scoredLocal.Count, title);
|
||||
return allCandidates;
|
||||
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
|
||||
scoredLocal.Count, title);
|
||||
return allCandidates;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
// STEP 2: Only search EXTERNAL if no good local match found
|
||||
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10);
|
||||
|
||||
if (externalResults.Count > 0)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to search local library for '{Title}'", title);
|
||||
var scoredExternal = externalResults
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
allCandidates.AddRange(scoredExternal);
|
||||
}
|
||||
|
||||
return allCandidates;
|
||||
}
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// STEP 2: Only search EXTERNAL if no good local match found
|
||||
var externalResults = await metadataService.SearchSongsAsync(query, limit: 10, cancellationToken);
|
||||
|
||||
if (externalResults.Count > 0)
|
||||
catch
|
||||
{
|
||||
var scoredExternal = externalResults
|
||||
.Select(song => new
|
||||
{
|
||||
Song = song,
|
||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
||||
})
|
||||
.Select(x => new
|
||||
{
|
||||
x.Song,
|
||||
x.TitleScore,
|
||||
x.ArtistScore,
|
||||
TotalScore = (x.TitleScore * 0.7) + (x.ArtistScore * 0.3)
|
||||
})
|
||||
.Where(x =>
|
||||
x.TotalScore >= 40 ||
|
||||
(x.ArtistScore >= 70 && x.TitleScore >= 30) ||
|
||||
x.TitleScore >= 85)
|
||||
.OrderByDescending(x => x.TotalScore)
|
||||
.Select(x => (x.Song, x.TotalScore))
|
||||
.ToList();
|
||||
|
||||
allCandidates.AddRange(scoredExternal);
|
||||
return new List<(Song, double)>();
|
||||
}
|
||||
|
||||
return allCandidates;
|
||||
}
|
||||
|
||||
private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist)
|
||||
@@ -1250,19 +1210,21 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Attempts to match a track by ISRC.
|
||||
/// SEARCHES LOCAL FIRST, then external if no local match found.
|
||||
/// </summary>
|
||||
private async Task<Song?> TryMatchByIsrcAsync(
|
||||
string isrc,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
|
||||
{
|
||||
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
|
||||
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
|
||||
// Local tracks will be found via fuzzy matching instead
|
||||
try
|
||||
{
|
||||
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
|
||||
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
|
||||
// Local tracks will be found via fuzzy matching instead
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
// STEP 2: Search EXTERNAL by ISRC
|
||||
return await metadataService.FindSongByIsrcAsync(isrc, cancellationToken);
|
||||
// STEP 2: Search EXTERNAL by ISRC
|
||||
return await metadataService.FindSongByIsrcAsync(isrc);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1352,12 +1314,21 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksLegacyAsync(
|
||||
string playlistName,
|
||||
SpotifyPlaylistConfig playlistConfig,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
|
||||
var playlistName = playlistConfig.Name;
|
||||
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
|
||||
// Check if we already have matched tracks cached
|
||||
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
||||
@@ -1464,6 +1435,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
try
|
||||
{
|
||||
var playlistConfig = _spotifySettings.GetPlaylistByName(playlistName, jellyfinPlaylistId: jellyfinPlaylistId);
|
||||
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig) ?? jellyfinPlaylistId;
|
||||
|
||||
_logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
||||
@@ -1484,7 +1459,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = jellyfinSettings.UserId;
|
||||
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||
@@ -1560,7 +1535,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
string? matchedKey = null;
|
||||
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, spotifyTrack.SpotifyId);
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
playlistName,
|
||||
spotifyTrack.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
@@ -1640,7 +1619,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
// SECOND: Check for external manual mapping
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, spotifyTrack.SpotifyId);
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
playlistName,
|
||||
spotifyTrack.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
@@ -1928,11 +1911,14 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||
|
||||
// Save to file cache for persistence
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems, playlistScopeUserId, playlistScopeId);
|
||||
|
||||
var manualMappingInfo = "";
|
||||
if (manualExternalCount > 0)
|
||||
@@ -1957,14 +1943,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// <summary>
|
||||
/// Saves playlist items to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
|
||||
private async Task SavePlaylistItemsToFileAsync(
|
||||
string playlistName,
|
||||
List<Dictionary<string, object?>> items,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var safeName = AdminHelperService.SanitizeFileName(
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
@@ -1981,14 +1972,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// <summary>
|
||||
/// Saves matched tracks to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
|
||||
private async Task SaveMatchedTracksToFileAsync(
|
||||
string playlistName,
|
||||
List<MatchedTrack> matchedTracks,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var safeName = AdminHelperService.SanitizeFileName(
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
@@ -510,7 +510,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
var temp = new SearchResult
|
||||
var temp = new SearchResult
|
||||
{
|
||||
Songs = await songsTask,
|
||||
Albums = await albumsTask,
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FFDD00" d="M20.216 6.415l-.132-.666c-.119-.598-.388-1.163-1.001-1.379-.197-.069-.42-.098-.57-.241-.152-.143-.196-.366-.231-.572-.065-.378-.125-.756-.192-1.133-.057-.325-.102-.69-.25-.987-.195-.4-.597-.634-.996-.788a5.723 5.723 0 00-.626-.194c-1-.263-2.05-.36-3.077-.416a25.834 25.834 0 00-3.7.062c-.915.083-1.88.184-2.75.5-.318.116-.646.256-.888.501-.297.302-.393.77-.177 1.146.154.267.415.456.692.58.36.162.737.284 1.123.366 1.075.238 2.189.331 3.287.37 1.218.05 2.437.01 3.65-.118.299-.033.598-.073.896-.119.352-.054.578-.513.474-.834-.124-.383-.457-.531-.834-.473-.466.074-.96.108-1.382.146-1.177.08-2.358.082-3.536.006a22.228 22.228 0 01-1.157-.107c-.086-.01-.18-.025-.258-.036-.243-.036-.484-.08-.724-.13-.111-.027-.111-.185 0-.212h.005c.277-.06.557-.108.838-.147h.002c.131-.009.263-.032.394-.048a25.076 25.076 0 013.426-.12c.674.019 1.347.067 2.017.144l.228.031c.267.04.533.088.798.145.392.085.895.113 1.07.542.055.137.08.288.111.431l.319 1.484a.237.237 0 01-.199.284h-.003c-.037.006-.075.01-.112.015a36.704 36.704 0 01-4.743.295 37.059 37.059 0 01-4.699-.304c-.14-.017-.293-.042-.417-.06-.326-.048-.649-.108-.973-.161-.393-.065-.768-.032-1.123.161-.29.16-.527.404-.675.701-.154.316-.199.66-.267 1-.069.34-.176.707-.135 1.056.087.753.613 1.365 1.37 1.502a39.69 39.69 0 0011.343.376.483.483 0 01.535.53l-.071.697-1.018 9.907c-.041.41-.047.832-.125 1.237-.122.637-.553 1.028-1.182 1.171-.577.131-1.165.2-1.756.205-.656.004-1.31-.025-1.966-.022-.699.004-1.556-.06-2.095-.58-.475-.458-.54-1.174-.605-1.793l-.731-7.013-.322-3.094c-.037-.351-.286-.695-.678-.678-.336.015-.718.3-.678.679l.228 2.185.949 9.112c.147 1.344 1.174 2.068 2.446 2.272.742.12 1.503.144 2.257.156.966.016 1.942.053 2.892-.122 1.408-.258 2.465-1.198 2.616-2.657.34-3.332.683-6.663 1.024-9.995l.215-2.087a.484.484 0 01.39-.426c.402-.078.787-.212 1.074-.518.455-.488.546-1.124.385-1.766zm-1.478.772c-.145.137-.363.201-.578.233-2.416.359-4.866.54-7.308.46-1.748-.06-3.477-.254-5.207-.498-.17-.024-.353-.055-.47-.18-.22-.236-.111-.71-.054-.995.052-.26.152-.609.463-.646.484-.057 1.046.148 1.526.22.577.088 1.156.159 1.737.212 2.48.226 5.002.19 7.472-.14.45-.06.899-.13 1.345-.21.399-.072.84-.206 1.08.206.166.281.188.657.162.974a.544.544 0 01-.169.364zm-6.159 3.9c-.862.37-1.84.788-3.109.788a5.884 5.884 0 01-1.569-.217l.877 9.004c.065.78.717 1.38 1.5 1.38 0 0 1.243.065 1.658.065.447 0 1.786-.065 1.786-.065.783 0 1.434-.6 1.499-1.38l.94-9.95a3.996 3.996 0 00-1.322-.238c-.826 0-1.491.284-2.26.613z"/></svg>
|
||||
|
Before Width: | Height: | Size: 2.5 KiB |
@@ -1 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 98 96" aria-hidden="true"><path fill="#f0f6fc" fill-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" clip-rule="evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 980 B |
@@ -1,13 +0,0 @@
|
||||
<svg width="241" height="194" viewBox="0 0 241 194" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_1_219" style="mask-type:luminance" maskUnits="userSpaceOnUse" x="-1" y="0" width="242" height="194">
|
||||
<path d="M240.469 0.958984H-0.00585938V193.918H240.469V0.958984Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1_219)">
|
||||
<path d="M96.1344 193.911C61.1312 193.911 32.6597 178.256 15.9721 149.829C1.19788 124.912 -0.00585938 97.9229 -0.00585938 67.7662C-0.00585938 49.8876 5.37293 34.3215 15.5413 22.7466C24.8861 12.1157 38.1271 5.22907 52.8317 3.35378C70.2858 1.14271 91.9848 0.958984 114.545 0.958984C151.259 0.958984 161.63 1.4088 176.075 2.85328C195.29 4.76026 211.458 11.932 222.824 23.5955C234.368 35.4428 240.469 51.2624 240.469 69.3627V72.9994C240.469 103.885 219.821 129.733 191.046 136.759C188.898 141.827 186.237 146.871 183.089 151.837L183.006 151.964C172.869 167.632 149.042 193.918 103.401 193.918H96.1281L96.1344 193.911Z" fill="white"/>
|
||||
<path d="M174.568 17.9772C160.927 16.6151 151.38 16.1589 114.552 16.1589C90.908 16.1589 70.9008 16.387 54.7644 18.4334C33.3949 21.164 15.2058 37.5285 15.2058 67.7674C15.2058 98.0066 16.796 121.422 29.0741 142.107C42.9425 165.751 66.1302 178.707 96.1412 178.707H103.414C140.242 178.707 160.25 159.156 170.253 143.698C174.574 136.874 177.754 130.058 179.801 123.234C205.947 120.96 225.27 99.3624 225.27 72.9941V69.3577C225.27 40.9432 206.631 21.164 174.574 17.9772H174.568Z" fill="white"/>
|
||||
<path d="M15.1975 67.7674C15.1975 37.5285 33.3866 21.164 54.7559 18.4334C70.8987 16.387 90.906 16.1589 114.544 16.1589C151.372 16.1589 160.919 16.6151 174.559 17.9772C206.617 21.1576 225.255 40.937 225.255 69.3577V72.9941C225.255 99.3687 205.932 120.966 179.786 123.234C177.74 130.058 174.559 136.874 170.238 143.698C160.235 159.156 140.228 178.707 103.4 178.707H96.1264C66.1155 178.707 42.9277 165.751 29.0595 142.107C16.7814 121.422 15.1912 98.4563 15.1912 67.7674" fill="#202020"/>
|
||||
<path d="M32.2469 67.9899C32.2469 97.3168 34.0654 116.184 43.6127 133.689C54.5225 153.924 74.3018 161.653 96.8117 161.653H103.857C133.411 161.653 147.736 147.329 155.693 134.829C159.558 128.462 162.966 121.417 164.784 112.547L166.147 106.864H174.332C192.521 106.864 208.208 92.09 208.208 73.2166V69.8082C208.208 48.6669 195.024 37.5228 172.058 34.7987C159.102 33.6646 151.372 33.2084 114.538 33.2084C89.7602 33.2084 72.0272 33.4364 58.6152 35.4828C39.7483 38.2134 32.2407 48.8951 32.2407 67.9899" fill="white"/>
|
||||
<path d="M166.158 83.6801C166.158 86.4107 168.204 88.4572 171.841 88.4572C183.435 88.4572 189.802 81.8619 189.802 70.9523C189.802 60.0427 183.435 53.2195 171.841 53.2195C168.204 53.2195 166.158 55.2657 166.158 57.9963V83.6866V83.6801Z" fill="#202020"/>
|
||||
<path d="M54.5321 82.3198C54.5321 95.732 62.0332 107.326 71.5807 116.424C77.9478 122.562 87.9515 128.93 94.7685 133.022C96.8147 134.157 98.8611 134.841 101.136 134.841C103.866 134.841 106.134 134.157 107.959 133.022C114.782 128.93 124.779 122.562 130.919 116.424C140.694 107.332 148.195 95.7383 148.195 82.3198C148.195 67.7673 137.286 54.8115 121.599 54.8115C112.28 54.8115 105.912 59.5882 101.136 66.1772C96.8147 59.582 90.2259 54.8115 80.9001 54.8115C64.9855 54.8115 54.5256 67.7673 54.5256 82.3198" fill="#FF5A16"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 3.2 KiB |
+112
-115
@@ -12,8 +12,8 @@
|
||||
<!-- Restart Required Banner -->
|
||||
<div class="restart-banner" id="restart-banner">
|
||||
⚠️ Configuration changed. Restart required to apply changes.
|
||||
<button data-action="restartContainer">Restart Allstarr</button>
|
||||
<button data-action="dismissRestartBanner"
|
||||
<button onclick="restartContainer()">Restart Allstarr</button>
|
||||
<button onclick="dismissRestartBanner()"
|
||||
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||
</div>
|
||||
|
||||
@@ -34,79 +34,52 @@
|
||||
</div>
|
||||
|
||||
<div class="support-badge">
|
||||
<p class="support-text">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container hidden" id="main-container">
|
||||
<div class="app-shell">
|
||||
<aside class="sidebar" aria-label="Admin navigation">
|
||||
<div class="sidebar-brand">
|
||||
<div class="sidebar-title">
|
||||
<a class="title-link" href="https://github.com/SoPat712/allstarr" target="_blank"
|
||||
rel="noopener noreferrer">Allstarr</a>
|
||||
</div>
|
||||
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div>
|
||||
<div class="sidebar-status" id="status-indicator">
|
||||
<span class="status-badge" id="spotify-status">
|
||||
<span class="status-dot"></span>
|
||||
<span>Loading...</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="container" id="main-container" style="display:none;">
|
||||
<header>
|
||||
<h1>
|
||||
Allstarr <span class="version" id="version">Loading...</span>
|
||||
</h1>
|
||||
<div class="header-actions">
|
||||
<div class="auth-user" id="auth-user-display" style="display:none;">
|
||||
Signed in as <strong id="auth-user-name">-</strong>
|
||||
</div>
|
||||
<nav class="sidebar-nav">
|
||||
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
|
||||
<button class="sidebar-link" type="button" data-tab="jellyfin-playlists">Link Playlists</button>
|
||||
<button class="sidebar-link" type="button" data-tab="playlists">Injected Playlists</button>
|
||||
<button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button>
|
||||
<button class="sidebar-link" type="button" data-tab="scrobbling">Scrobbling</button>
|
||||
<button class="sidebar-link" type="button" data-tab="config">Configuration</button>
|
||||
<button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
<div class="auth-user hidden" id="auth-user-display">
|
||||
Signed in as <strong id="auth-user-name">-</strong>
|
||||
</div>
|
||||
<button id="auth-logout-btn" data-action="logoutAdminSession" class="hidden">Logout</button>
|
||||
<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>
|
||||
</aside>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="app-main">
|
||||
<div class="tabs top-tabs" aria-hidden="true">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
<div class="tabs">
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
|
||||
<!-- Dashboard Tab -->
|
||||
<div class="tab-content active" id="tab-dashboard">
|
||||
<div class="card" id="download-activity-card">
|
||||
<h2>Live Download Queue</h2>
|
||||
<div id="download-activity-list" class="download-queue-list">
|
||||
<div class="empty-state">No active downloads</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<div class="card">
|
||||
<h2>Spotify API</h2>
|
||||
@@ -155,9 +128,9 @@
|
||||
</h2>
|
||||
<div id="dashboard-guidance" class="guidance-stack"></div>
|
||||
<div class="card-actions-row">
|
||||
<button class="primary" data-action="refreshPlaylists">Refresh All Playlists</button>
|
||||
<button data-action="clearCache">Clear Cache</button>
|
||||
<button data-action="openAddPlaylist">Add Playlist</button>
|
||||
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
||||
<button onclick="clearCache()">Clear Cache</button>
|
||||
<button onclick="openAddPlaylist()">Add Playlist</button>
|
||||
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -172,7 +145,7 @@
|
||||
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p class="text-secondary mb-16">
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing
|
||||
tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
|
||||
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
|
||||
@@ -180,9 +153,10 @@
|
||||
</p>
|
||||
<div id="jellyfin-guidance" class="guidance-stack"></div>
|
||||
|
||||
<div id="jellyfin-user-filter" class="flex-row-wrap mb-16">
|
||||
<div class="form-group jellyfin-user-form-group">
|
||||
<label class="text-secondary">User</label>
|
||||
<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>
|
||||
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()"
|
||||
style="width: 100%; padding: 8px; background: var(--bg-secondary); border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);">
|
||||
<option value="">All Users</option>
|
||||
@@ -258,7 +232,7 @@
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<p class="text-secondary mb-12">
|
||||
<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>
|
||||
@@ -294,14 +268,15 @@
|
||||
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
|
||||
local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||
</p>
|
||||
<div id="mappings-summary" class="summary-box">
|
||||
<div id="mappings-summary"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span class="summary-label">Total:</span>
|
||||
<span class="summary-value" id="mappings-total">0</span>
|
||||
<span style="color: var(--text-secondary);">Total:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="summary-label">External:</span>
|
||||
<span class="summary-value success"
|
||||
<span style="color: var(--text-secondary);">External:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--success);"
|
||||
id="mappings-external">0</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -334,14 +309,15 @@
|
||||
<button onclick="fetchMissingTracks()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p class="text-secondary mb-12">
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Tracks that couldn't be matched locally or externally. Map them manually to add them to your
|
||||
playlists.
|
||||
</p>
|
||||
<div id="missing-summary" class="summary-box">
|
||||
<div id="missing-summary"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span class="summary-label">Total Missing:</span>
|
||||
<span class="summary-value warning"
|
||||
<span style="color: var(--text-secondary);">Total Missing:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);"
|
||||
id="missing-total">0</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -372,23 +348,23 @@
|
||||
<h2>
|
||||
Kept Downloads
|
||||
<div class="actions">
|
||||
<button onclick="downloadAllKept()" class="primary">Download All</button>
|
||||
<button onclick="deleteAllKept()" class="danger">Delete All</button>
|
||||
<button onclick="downloadAllKept()" style="background:var(--accent);border-color:var(--accent);">Download All</button>
|
||||
<button onclick="fetchDownloads()">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p class="text-secondary mb-12">
|
||||
<p style="color: var(--text-secondary); margin-bottom: 12px;">
|
||||
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
|
||||
</p>
|
||||
<div id="downloads-summary" class="summary-box">
|
||||
<div id="downloads-summary"
|
||||
style="display: flex; gap: 20px; margin-bottom: 16px; padding: 12px; background: var(--bg-tertiary); border-radius: 6px;">
|
||||
<div>
|
||||
<span class="summary-label">Total Files:</span>
|
||||
<span class="summary-value accent"
|
||||
<span style="color: var(--text-secondary);">Total Files:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);"
|
||||
id="downloads-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="summary-label">Total Size:</span>
|
||||
<span class="summary-value accent" id="downloads-size">0
|
||||
<span style="color: var(--text-secondary);">Total Size:</span>
|
||||
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0
|
||||
B</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -897,6 +873,46 @@
|
||||
|
||||
<!-- API Analytics Tab -->
|
||||
<div class="tab-content" id="tab-endpoints">
|
||||
<div class="card">
|
||||
<h2>
|
||||
SquidWTF Endpoint Health
|
||||
<div class="actions">
|
||||
<button class="primary" onclick="fetchSquidWtfEndpointHealth(true)">Test Endpoints</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Runs a real SquidWTF API search probe and a real SquidWTF streaming manifest probe against every configured mirror.
|
||||
Green means the API request worked. Blue means the streaming request worked.
|
||||
</p>
|
||||
<div class="endpoint-health-toolbar">
|
||||
<div class="endpoint-health-legend">
|
||||
<span><span class="endpoint-health-dot api up"></span> API up</span>
|
||||
<span><span class="endpoint-health-dot streaming up"></span> Streaming up</span>
|
||||
<span><span class="endpoint-health-dot down"></span> Down</span>
|
||||
<span><span class="endpoint-health-dot unknown"></span> Not tested</span>
|
||||
</div>
|
||||
<div class="endpoint-health-last-tested" id="squidwtf-endpoints-tested-at">Not tested yet</div>
|
||||
</div>
|
||||
<div style="max-height: 520px; overflow-y: auto;">
|
||||
<table class="playlist-table endpoint-health-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th style="width: 72px; text-align: center;">API</th>
|
||||
<th style="width: 96px; text-align: center;">Streaming</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="squidwtf-endpoints-table-body">
|
||||
<tr>
|
||||
<td colspan="3" class="loading">
|
||||
Click <strong>Test Endpoints</strong> to probe SquidWTF mirrors.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
API Endpoint Usage
|
||||
@@ -988,33 +1004,14 @@
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p class="support-text">
|
||||
<p>
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add Playlist Modal -->
|
||||
|
||||
@@ -1,84 +0,0 @@
|
||||
function toBoolean(value) {
|
||||
if (value === true || value === false) {
|
||||
return value;
|
||||
}
|
||||
const normalized = String(value ?? "")
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return normalized === "true" || normalized === "1" || normalized === "yes";
|
||||
}
|
||||
|
||||
function toNumber(value) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function getActionArgs(el) {
|
||||
if (!el || !el.dataset) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Convention:
|
||||
// - data-action="foo"
|
||||
// - data-arg-bar="baz" => { bar: "baz" }
|
||||
const args = {};
|
||||
for (const [key, value] of Object.entries(el.dataset)) {
|
||||
if (!key.startsWith("arg")) continue;
|
||||
const argName = key.slice(3);
|
||||
if (!argName) continue;
|
||||
const normalized =
|
||||
argName.charAt(0).toLowerCase() + argName.slice(1);
|
||||
args[normalized] = value;
|
||||
}
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
export function initActionDispatcher({ root = document } = {}) {
|
||||
const handlers = new Map();
|
||||
|
||||
function register(actionName, handler) {
|
||||
if (!actionName || typeof handler !== "function") {
|
||||
return;
|
||||
}
|
||||
handlers.set(actionName, handler);
|
||||
}
|
||||
|
||||
async function dispatch(actionName, el, event = null) {
|
||||
const handler = handlers.get(actionName);
|
||||
const args = getActionArgs(el);
|
||||
|
||||
if (handler) {
|
||||
return await handler({ el, event, args, toBoolean, toNumber });
|
||||
}
|
||||
|
||||
// Transitional fallback: if a legacy window function exists, call it.
|
||||
// This allows incremental conversion away from inline onclick.
|
||||
const legacy = typeof window !== "undefined" ? window[actionName] : null;
|
||||
if (typeof legacy === "function") {
|
||||
const legacyArgs = args && Object.keys(args).length > 0 ? [args] : [];
|
||||
return legacy(...legacyArgs);
|
||||
}
|
||||
|
||||
console.warn(`No handler registered for action "${actionName}"`);
|
||||
return null;
|
||||
}
|
||||
|
||||
function bind() {
|
||||
root.addEventListener("click", (event) => {
|
||||
const trigger = event.target?.closest?.("[data-action]");
|
||||
if (!trigger) return;
|
||||
|
||||
const actionName = trigger.getAttribute("data-action") || "";
|
||||
if (!actionName) return;
|
||||
|
||||
event.preventDefault();
|
||||
dispatch(actionName, trigger, event);
|
||||
});
|
||||
}
|
||||
|
||||
bind();
|
||||
|
||||
return { register, dispatch };
|
||||
}
|
||||
|
||||
+10
-15
@@ -124,14 +124,6 @@ export async function deleteDownload(path) {
|
||||
);
|
||||
}
|
||||
|
||||
export async function deleteAllDownloads() {
|
||||
return requestJson(
|
||||
"/api/admin/downloads/all",
|
||||
{ method: "DELETE" },
|
||||
"Failed to delete all downloads",
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchConfig() {
|
||||
return requestJson(
|
||||
"/api/admin/config",
|
||||
@@ -152,15 +144,10 @@ export async function fetchJellyfinUsers() {
|
||||
return requestOptionalJson("/api/admin/jellyfin/users");
|
||||
}
|
||||
|
||||
export async function fetchJellyfinPlaylists(userId = null, includeStats = true) {
|
||||
export async function fetchJellyfinPlaylists(userId = null) {
|
||||
let url = "/api/admin/jellyfin/playlists";
|
||||
const params = [];
|
||||
if (userId) {
|
||||
params.push("userId=" + encodeURIComponent(userId));
|
||||
}
|
||||
params.push("includeStats=" + String(Boolean(includeStats)));
|
||||
if (params.length > 0) {
|
||||
url += "?" + params.join("&");
|
||||
url += "?userId=" + encodeURIComponent(userId);
|
||||
}
|
||||
|
||||
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
|
||||
@@ -427,6 +414,14 @@ export async function getSquidWTFBaseUrl() {
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSquidWtfEndpointHealth() {
|
||||
return requestJson(
|
||||
"/api/admin/squidwtf/endpoints/test",
|
||||
{ method: "POST" },
|
||||
"Failed to test SquidWTF endpoints",
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchScrobblingStatus() {
|
||||
return requestJson(
|
||||
"/api/admin/scrobbling/status",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { escapeHtml, escapeJs, showToast, formatCookieAge } from "./utils.js";
|
||||
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";
|
||||
@@ -15,8 +15,6 @@ let onCookieNeedsInit = async () => {};
|
||||
let setCurrentConfigState = () => {};
|
||||
let syncConfigUiExtras = () => {};
|
||||
let loadScrobblingConfig = () => {};
|
||||
let injectedPlaylistRequestToken = 0;
|
||||
let jellyfinPlaylistRequestToken = 0;
|
||||
|
||||
async function fetchStatus() {
|
||||
try {
|
||||
@@ -40,20 +38,10 @@ async function fetchStatus() {
|
||||
}
|
||||
|
||||
async function fetchPlaylists(silent = false) {
|
||||
const requestToken = ++injectedPlaylistRequestToken;
|
||||
|
||||
try {
|
||||
const data = await API.fetchPlaylists();
|
||||
if (requestToken !== injectedPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
UI.updatePlaylistsUI(data);
|
||||
} catch (error) {
|
||||
if (requestToken !== injectedPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!silent) {
|
||||
console.error("Failed to fetch playlists:", error);
|
||||
showToast("Failed to fetch playlists", "error");
|
||||
@@ -141,7 +129,6 @@ async function fetchMissingTracks() {
|
||||
missing.forEach((t) => {
|
||||
missingTracks.push({
|
||||
playlist: playlist.name,
|
||||
provider: t.externalProvider || t.provider || "squidwtf",
|
||||
...t,
|
||||
});
|
||||
});
|
||||
@@ -164,7 +151,6 @@ async function fetchMissingTracks() {
|
||||
const artist =
|
||||
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
|
||||
const searchQuery = `${t.title} ${artist}`;
|
||||
const provider = t.provider || "squidwtf";
|
||||
const trackPosition = Number.isFinite(t.position)
|
||||
? Number(t.position)
|
||||
: 0;
|
||||
@@ -177,7 +163,7 @@ async function fetchMissingTracks() {
|
||||
<td class="mapping-actions-cell">
|
||||
<button class="map-action-btn map-action-search missing-track-search-btn"
|
||||
data-query="${escapeHtml(searchQuery)}"
|
||||
data-provider="${escapeHtml(provider)}">🔍 Search</button>
|
||||
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}"
|
||||
@@ -227,9 +213,9 @@ async function fetchDownloads() {
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||
<td>
|
||||
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
<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 data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -259,28 +245,11 @@ async function fetchJellyfinPlaylists() {
|
||||
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||
|
||||
try {
|
||||
const requestToken = ++jellyfinPlaylistRequestToken;
|
||||
const userId = isAdminSession()
|
||||
? document.getElementById("jellyfin-user-select")?.value
|
||||
: null;
|
||||
const baseData = await API.fetchJellyfinPlaylists(userId, false);
|
||||
if (requestToken !== jellyfinPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
UI.updateJellyfinPlaylistsUI(baseData);
|
||||
|
||||
// Enrich counts after initial render so big accounts don't appear empty.
|
||||
API.fetchJellyfinPlaylists(userId, true)
|
||||
.then((statsData) => {
|
||||
if (requestToken !== jellyfinPlaylistRequestToken) {
|
||||
return;
|
||||
}
|
||||
UI.updateJellyfinPlaylistsUI(statsData);
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Failed to fetch Jellyfin playlist track stats:", err);
|
||||
});
|
||||
const data = await API.fetchJellyfinPlaylists(userId);
|
||||
UI.updateJellyfinPlaylistsUI(data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Jellyfin playlists:", error);
|
||||
tbody.innerHTML =
|
||||
@@ -331,6 +300,33 @@ async function clearEndpointUsage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSquidWtfEndpointHealth(showFeedback = false) {
|
||||
const tbody = document.getElementById("squidwtf-endpoints-table-body");
|
||||
if (tbody) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" class="loading"><span class="spinner"></span> Testing SquidWTF endpoints...</td></tr>';
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await API.fetchSquidWtfEndpointHealth();
|
||||
UI.updateSquidWtfEndpointHealthUI(data);
|
||||
|
||||
if (showFeedback) {
|
||||
showToast("SquidWTF endpoint test completed", "success");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to test SquidWTF endpoints:", error);
|
||||
if (tbody) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" style="text-align:center;color:var(--error);padding:40px;">Failed to test SquidWTF endpoints</td></tr>';
|
||||
}
|
||||
|
||||
if (showFeedback) {
|
||||
showToast("Failed to test SquidWTF endpoints", "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPlaylistAutoRefresh() {
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
@@ -377,10 +373,7 @@ function startDashboardRefresh() {
|
||||
fetchPlaylists();
|
||||
fetchTrackMappings();
|
||||
fetchMissingTracks();
|
||||
const keptTab = document.getElementById("tab-kept");
|
||||
if (keptTab && keptTab.classList.contains("active")) {
|
||||
fetchDownloads();
|
||||
}
|
||||
fetchDownloads();
|
||||
|
||||
const endpointsTab = document.getElementById("tab-endpoints");
|
||||
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
||||
@@ -404,6 +397,11 @@ async function loadDashboardData() {
|
||||
fetchEndpointUsage(),
|
||||
]);
|
||||
|
||||
const endpointsTab = document.getElementById("tab-endpoints");
|
||||
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
||||
await fetchSquidWtfEndpointHealth(false);
|
||||
}
|
||||
|
||||
// Ensure user filter defaults are populated before loading Link Playlists rows.
|
||||
await fetchJellyfinUsers();
|
||||
await fetchJellyfinPlaylists();
|
||||
@@ -414,6 +412,7 @@ async function loadDashboardData() {
|
||||
}
|
||||
|
||||
startDashboardRefresh();
|
||||
startDownloadActivityStream();
|
||||
}
|
||||
|
||||
function startDownloadActivityStream() {
|
||||
@@ -562,6 +561,7 @@ export function initDashboardData(options) {
|
||||
window.fetchJellyfinUsers = fetchJellyfinUsers;
|
||||
window.fetchEndpointUsage = fetchEndpointUsage;
|
||||
window.clearEndpointUsage = clearEndpointUsage;
|
||||
window.fetchSquidWtfEndpointHealth = fetchSquidWtfEndpointHealth;
|
||||
|
||||
return {
|
||||
stopDashboardRefresh,
|
||||
@@ -573,5 +573,6 @@ export function initDashboardData(options) {
|
||||
fetchJellyfinPlaylists,
|
||||
fetchConfig,
|
||||
fetchStatus,
|
||||
fetchSquidWtfEndpointHealth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -100,14 +100,14 @@ export async function viewTracks(name) {
|
||||
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="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="${escapeHtml(escapeJs(t.externalProvider))}" style="color:var(--accent);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
? `<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="#" data-action="searchProvider" data-arg-query="${escapeHtml(escapeJs(t.searchQuery))}" data-arg-provider="squidwtf" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
? `<br><small style="color:var(--text-secondary)"><a href="#" onclick="searchProvider('${escapeJs(t.searchQuery)}', 'squidwtf'); return false;" style="color:var(--text-secondary);text-decoration:underline;">🔍 Search: ${escapeHtml(searchLinkText)}</a></small>`
|
||||
: "";
|
||||
|
||||
const lyricsMapButton = `<button class="small" data-action="openLyricsMap" data-arg-artist="${escapeHtml(escapeJs(firstArtist))}" data-arg-title="${escapeHtml(escapeJs(t.title))}" data-arg-album="${escapeHtml(escapeJs(t.album || ""))}" data-arg-duration-seconds="${durationSeconds}" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||
const lyricsMapButton = `<button class="small" onclick="openLyricsMap('${escapeJs(firstArtist)}', '${escapeJs(t.title)}', '${escapeJs(t.album || "")}', ${durationSeconds})" style="margin-left:4px;font-size:0.75rem;padding:4px 8px;background:#3b82f6;border-color:#3b82f6;color:white;">Map Lyrics ID</button>`;
|
||||
|
||||
return `
|
||||
<div class="track-item" data-position="${t.position}">
|
||||
@@ -246,7 +246,7 @@ export async function searchJellyfinTracks() {
|
||||
const artist = track.artist || "";
|
||||
const album = track.album || "";
|
||||
return `
|
||||
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" data-action="selectJellyfinTrack" data-arg-jellyfin-id="${escapeHtml(escapeJs(id))}">
|
||||
<div class="jellyfin-result" data-jellyfin-id="${escapeHtml(id)}" onclick="selectJellyfinTrack('${escapeJs(id)}')">
|
||||
<div>
|
||||
<strong>${escapeHtml(title)}</strong>
|
||||
<br>
|
||||
@@ -344,15 +344,7 @@ export async function searchExternalTracks() {
|
||||
const externalUrl = track.url || "";
|
||||
|
||||
return `
|
||||
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}"
|
||||
data-action="selectExternalTrack"
|
||||
data-arg-result-index="${index}"
|
||||
data-arg-external-id="${escapeHtml(escapeJs(id))}"
|
||||
data-arg-title="${escapeHtml(escapeJs(title))}"
|
||||
data-arg-artist="${escapeHtml(escapeJs(artist))}"
|
||||
data-arg-provider="${escapeHtml(escapeJs(providerName))}"
|
||||
data-arg-external-url="${escapeHtml(escapeJs(externalUrl))}"
|
||||
>
|
||||
<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>
|
||||
@@ -670,26 +662,13 @@ export async function saveLyricsMapping() {
|
||||
// Search provider (open in new tab)
|
||||
export async function searchProvider(query, provider) {
|
||||
try {
|
||||
const normalizedProvider = (provider || "squidwtf").toLowerCase();
|
||||
let searchUrl = "";
|
||||
|
||||
if (normalizedProvider === "squidwtf" || normalizedProvider === "tidal") {
|
||||
const data = await API.getSquidWTFBaseUrl();
|
||||
const baseUrl = data.baseUrl;
|
||||
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
} else if (normalizedProvider === "deezer") {
|
||||
searchUrl = `https://www.deezer.com/search/${encodeURIComponent(query)}`;
|
||||
} else if (normalizedProvider === "qobuz") {
|
||||
searchUrl = `https://www.qobuz.com/search?query=${encodeURIComponent(query)}`;
|
||||
} else {
|
||||
const data = await API.getSquidWTFBaseUrl();
|
||||
const baseUrl = data.baseUrl;
|
||||
searchUrl = `${baseUrl}/search/?s=${encodeURIComponent(query)}`;
|
||||
}
|
||||
|
||||
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 open provider search:", error);
|
||||
showToast("Failed to open provider search link", "warning");
|
||||
console.error("Failed to get SquidWTF base URL:", error);
|
||||
// Fallback to first encoded URL (triton)
|
||||
showToast("Failed to get SquidWTF URL, using fallback", "warning");
|
||||
}
|
||||
}
|
||||
|
||||
+38
-103
@@ -34,13 +34,17 @@ import {
|
||||
} from "./playlist-admin.js";
|
||||
import { initScrobblingAdmin } from "./scrobbling-admin.js";
|
||||
import { initAuthSession } from "./auth-session.js";
|
||||
import { initActionDispatcher } from "./action-dispatcher.js";
|
||||
import { initNavigationView } from "./views/navigation-view.js";
|
||||
import { initScrobblingView } from "./views/scrobbling-view.js";
|
||||
|
||||
let cookieDateInitialized = false;
|
||||
let restartRequired = false;
|
||||
|
||||
window.showToast = showToast;
|
||||
window.escapeHtml = escapeHtml;
|
||||
window.escapeJs = escapeJs;
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
window.capitalizeProvider = capitalizeProvider;
|
||||
|
||||
window.showRestartBanner = function () {
|
||||
restartRequired = true;
|
||||
document.getElementById("restart-banner")?.classList.add("active");
|
||||
@@ -54,30 +58,17 @@ window.switchTab = function (tabName) {
|
||||
document
|
||||
.querySelectorAll(".tab")
|
||||
.forEach((tab) => tab.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".sidebar-link")
|
||||
.forEach((link) => link.classList.remove("active"));
|
||||
document
|
||||
.querySelectorAll(".tab-content")
|
||||
.forEach((content) => content.classList.remove("active"));
|
||||
|
||||
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||
const sidebarLink = document.querySelector(
|
||||
`.sidebar-link[data-tab="${tabName}"]`,
|
||||
);
|
||||
const content = document.getElementById(`tab-${tabName}`);
|
||||
|
||||
if (tab && content) {
|
||||
tab.classList.add("active");
|
||||
if (sidebarLink) {
|
||||
sidebarLink.classList.add("active");
|
||||
}
|
||||
content.classList.add("active");
|
||||
window.location.hash = tabName;
|
||||
|
||||
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
|
||||
window.fetchDownloads();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,112 +138,56 @@ const authSession = initAuthSession({
|
||||
},
|
||||
});
|
||||
|
||||
window.viewTracks = viewTracks;
|
||||
window.openManualMap = openManualMap;
|
||||
window.openExternalMap = openExternalMap;
|
||||
window.openMapToLocal = openManualMap;
|
||||
window.openMapToExternal = openExternalMap;
|
||||
window.openModal = openModal;
|
||||
window.closeModal = closeModal;
|
||||
window.searchJellyfinTracks = searchJellyfinTracks;
|
||||
window.selectJellyfinTrack = selectJellyfinTrack;
|
||||
window.saveLocalMapping = saveLocalMapping;
|
||||
window.saveManualMapping = saveManualMapping;
|
||||
window.searchExternalTracks = searchExternalTracks;
|
||||
window.searchProvider = searchProvider;
|
||||
window.selectExternalTrack = selectExternalTrack;
|
||||
window.validateExternalMapping = validateExternalMapping;
|
||||
window.openLyricsMap = openLyricsMap;
|
||||
window.saveLyricsMapping = saveLyricsMapping;
|
||||
// Note: viewTracks/selectExternalTrack/selectJellyfinTrack/openLyricsMap/searchProvider
|
||||
// are now wired via the ActionDispatcher and no longer require window exports.
|
||||
window.searchProvider = searchProvider;
|
||||
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
console.log("🚀 Allstarr Admin UI (Modular) loaded");
|
||||
|
||||
const dispatcher = initActionDispatcher({ root: document });
|
||||
// Register a few core actions first; more will be migrated as inline
|
||||
// onclick handlers are removed from HTML and generated markup.
|
||||
dispatcher.register("switchTab", ({ args }) => {
|
||||
const tab = args?.tab || args?.tabName;
|
||||
if (tab) {
|
||||
window.switchTab(tab);
|
||||
}
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
window.switchTab(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
dispatcher.register("logoutAdminSession", () => window.logoutAdminSession?.());
|
||||
dispatcher.register("dismissRestartBanner", () =>
|
||||
window.dismissRestartBanner?.(),
|
||||
);
|
||||
dispatcher.register("restartContainer", () => window.restartContainer?.());
|
||||
dispatcher.register("refreshPlaylists", () => window.refreshPlaylists?.());
|
||||
dispatcher.register("clearCache", () => window.clearCache?.());
|
||||
dispatcher.register("openAddPlaylist", () => window.openAddPlaylist?.());
|
||||
dispatcher.register("toggleRowMenu", ({ event, args }) =>
|
||||
window.toggleRowMenu?.(event, args?.menuId),
|
||||
);
|
||||
dispatcher.register("toggleDetailsRow", ({ event, args }) =>
|
||||
window.toggleDetailsRow?.(event, args?.detailsRowId),
|
||||
);
|
||||
dispatcher.register("viewTracks", ({ args }) => viewTracks(args?.playlistName));
|
||||
dispatcher.register("refreshPlaylist", ({ args }) =>
|
||||
window.refreshPlaylist?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("matchPlaylistTracks", ({ args }) =>
|
||||
window.matchPlaylistTracks?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("clearPlaylistCache", ({ args }) =>
|
||||
window.clearPlaylistCache?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("editPlaylistSchedule", ({ args }) =>
|
||||
window.editPlaylistSchedule?.(args?.playlistName, args?.syncSchedule),
|
||||
);
|
||||
dispatcher.register("removePlaylist", ({ args }) =>
|
||||
window.removePlaylist?.(args?.playlistName),
|
||||
);
|
||||
dispatcher.register("openLinkPlaylist", ({ args }) =>
|
||||
window.openLinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
|
||||
);
|
||||
dispatcher.register("unlinkPlaylist", ({ args }) =>
|
||||
window.unlinkPlaylist?.(args?.jellyfinId, args?.jellyfinName),
|
||||
);
|
||||
dispatcher.register("fetchJellyfinPlaylists", () =>
|
||||
window.fetchJellyfinPlaylists?.(),
|
||||
);
|
||||
dispatcher.register("searchProvider", ({ args }) =>
|
||||
searchProvider(args?.query, args?.provider),
|
||||
);
|
||||
dispatcher.register("openLyricsMap", ({ args, toNumber }) =>
|
||||
openLyricsMap(
|
||||
args?.artist,
|
||||
args?.title,
|
||||
args?.album,
|
||||
toNumber(args?.durationSeconds) ?? 0,
|
||||
),
|
||||
);
|
||||
dispatcher.register("selectJellyfinTrack", ({ args }) =>
|
||||
selectJellyfinTrack(args?.jellyfinId),
|
||||
);
|
||||
dispatcher.register("selectExternalTrack", ({ args, toNumber }) =>
|
||||
selectExternalTrack(
|
||||
toNumber(args?.resultIndex),
|
||||
args?.externalId,
|
||||
args?.title,
|
||||
args?.artist,
|
||||
args?.provider,
|
||||
args?.externalUrl,
|
||||
),
|
||||
);
|
||||
dispatcher.register("downloadFile", ({ args }) =>
|
||||
window.downloadFile?.(args?.path),
|
||||
);
|
||||
dispatcher.register("deleteDownload", ({ args }) =>
|
||||
window.deleteDownload?.(args?.path),
|
||||
);
|
||||
|
||||
initNavigationView({ switchTab: window.switchTab });
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
window.switchTab(hash);
|
||||
}
|
||||
|
||||
setupModalBackdropClose();
|
||||
|
||||
initScrobblingView({
|
||||
isAuthenticated: () => authSession.isAuthenticated(),
|
||||
loadScrobblingConfig: () => window.loadScrobblingConfig?.(),
|
||||
});
|
||||
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
||||
if (scrobblingTab) {
|
||||
scrobblingTab.addEventListener("click", () => {
|
||||
if (authSession.isAuthenticated()) {
|
||||
window.loadScrobblingConfig();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const endpointsTab = document.querySelector('.tab[data-tab="endpoints"]');
|
||||
if (endpointsTab) {
|
||||
endpointsTab.addEventListener("click", () => {
|
||||
if (authSession.isAuthenticated() && authSession.isAdminSession()) {
|
||||
window.fetchEndpointUsage?.();
|
||||
window.fetchSquidWtfEndpointHealth?.(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
authSession.bootstrapAuth();
|
||||
});
|
||||
|
||||
@@ -1,100 +1,17 @@
|
||||
// Modal management
|
||||
const modalState = new Map();
|
||||
const FOCUSABLE_SELECTOR =
|
||||
'a[href], button:not([disabled]), textarea, input, select, [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function getModal(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function getFocusableElements(modal) {
|
||||
return Array.from(modal.querySelectorAll(FOCUSABLE_SELECTOR)).filter(
|
||||
(el) => !el.hasAttribute("disabled") && !el.getAttribute("aria-hidden"),
|
||||
);
|
||||
}
|
||||
|
||||
function onModalKeyDown(event, modal) {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
closeModal(modal.id);
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.key !== "Tab") {
|
||||
return;
|
||||
}
|
||||
|
||||
const focusable = getFocusableElements(modal);
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const isShift = event.shiftKey;
|
||||
|
||||
if (isShift && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!isShift && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function openModal(id) {
|
||||
const modal = getModal(id);
|
||||
if (!modal) return;
|
||||
|
||||
const modalContent = modal.querySelector(".modal-content");
|
||||
if (!modalContent) return;
|
||||
|
||||
const previousActive = document.activeElement;
|
||||
modalState.set(id, { previousActive });
|
||||
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.removeAttribute("aria-hidden");
|
||||
modal.classList.add("active");
|
||||
|
||||
const keydownHandler = (event) => onModalKeyDown(event, modal);
|
||||
modalState.set(id, { previousActive, keydownHandler });
|
||||
modal.addEventListener("keydown", keydownHandler);
|
||||
|
||||
const focusable = getFocusableElements(modalContent);
|
||||
if (focusable.length > 0) {
|
||||
focusable[0].focus();
|
||||
} else {
|
||||
modalContent.setAttribute("tabindex", "-1");
|
||||
modalContent.focus();
|
||||
}
|
||||
document.getElementById(id).classList.add('active');
|
||||
}
|
||||
|
||||
export function closeModal(id) {
|
||||
const modal = getModal(id);
|
||||
if (!modal) return;
|
||||
|
||||
modal.classList.remove("active");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
|
||||
const state = modalState.get(id);
|
||||
if (state?.keydownHandler) {
|
||||
modal.removeEventListener("keydown", state.keydownHandler);
|
||||
}
|
||||
|
||||
if (state?.previousActive && typeof state.previousActive.focus === "function") {
|
||||
state.previousActive.focus();
|
||||
}
|
||||
|
||||
modalState.delete(id);
|
||||
document.getElementById(id).classList.remove('active');
|
||||
}
|
||||
|
||||
export function setupModalBackdropClose() {
|
||||
document.querySelectorAll(".modal").forEach((modal) => {
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
modal.addEventListener("click", (e) => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
document.querySelectorAll('.modal').forEach(modal => {
|
||||
modal.addEventListener('click', e => {
|
||||
if (e.target === modal) closeModal(modal.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -77,20 +77,6 @@ function downloadAllKept() {
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteAllKept() {
|
||||
const result = await runAction({
|
||||
confirmMessage:
|
||||
"Delete ALL kept downloads?\n\nThis will permanently remove all kept audio files.",
|
||||
task: () => API.deleteAllDownloads(),
|
||||
success: (data) => data.message || "All kept downloads deleted",
|
||||
error: (err) => err.message || "Failed to delete all kept downloads",
|
||||
});
|
||||
|
||||
if (result) {
|
||||
await fetchDownloads();
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteDownload(path) {
|
||||
const result = await runAction({
|
||||
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
|
||||
@@ -378,7 +364,6 @@ export function initOperations(options) {
|
||||
window.deleteTrackMapping = deleteTrackMapping;
|
||||
window.downloadFile = downloadFile;
|
||||
window.downloadAllKept = downloadAllKept;
|
||||
window.deleteAllKept = deleteAllKept;
|
||||
window.deleteDownload = deleteDownload;
|
||||
window.refreshPlaylists = refreshPlaylists;
|
||||
window.refreshPlaylist = refreshPlaylist;
|
||||
|
||||
@@ -70,12 +70,7 @@ async function openLinkPlaylist(jellyfinId, name) {
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
||||
spotifyUserPlaylists = Array.isArray(response?.playlists)
|
||||
? response.playlists
|
||||
: Array.isArray(response)
|
||||
? response
|
||||
: [];
|
||||
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
||||
spotifyUserPlaylistsScopeUserId = selectedUserId;
|
||||
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
|
||||
|
||||
|
||||
+202
-426
@@ -3,9 +3,6 @@
|
||||
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
|
||||
|
||||
let rowMenuHandlersBound = false;
|
||||
let tableRowHandlersBound = false;
|
||||
const expandedInjectedPlaylistDetails = new Set();
|
||||
let openInjectedPlaylistMenuKey = null;
|
||||
|
||||
function bindRowMenuHandlers() {
|
||||
if (rowMenuHandlersBound) {
|
||||
@@ -19,55 +16,12 @@ function bindRowMenuHandlers() {
|
||||
rowMenuHandlersBound = true;
|
||||
}
|
||||
|
||||
function bindTableRowHandlers() {
|
||||
if (tableRowHandlersBound) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const detailsTrigger = event.target.closest?.(
|
||||
"button.details-trigger[data-details-target]",
|
||||
);
|
||||
if (detailsTrigger) {
|
||||
const target = detailsTrigger.getAttribute("data-details-target");
|
||||
if (target) {
|
||||
toggleDetailsRow(event, target);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const row = event.target.closest?.("tr.compact-row[data-details-row]");
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.target.closest("button, a, .row-actions-menu")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const detailsRowId = row.getAttribute("data-details-row");
|
||||
if (detailsRowId) {
|
||||
toggleDetailsRow(null, detailsRowId);
|
||||
}
|
||||
});
|
||||
|
||||
tableRowHandlersBound = true;
|
||||
}
|
||||
|
||||
function closeAllRowMenus(exceptId = null) {
|
||||
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
|
||||
if (!exceptId || menu.id !== exceptId) {
|
||||
menu.classList.remove("open");
|
||||
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
|
||||
if (trigger) {
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (!exceptId) {
|
||||
openInjectedPlaylistMenuKey = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closeRowMenu(event, menuId) {
|
||||
@@ -78,13 +32,6 @@ function closeRowMenu(event, menuId) {
|
||||
const menu = document.getElementById(menuId);
|
||||
if (menu) {
|
||||
menu.classList.remove("open");
|
||||
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
|
||||
if (trigger) {
|
||||
trigger.setAttribute("aria-expanded", "false");
|
||||
}
|
||||
if (menu.dataset.menuKey) {
|
||||
openInjectedPlaylistMenuKey = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,14 +48,6 @@ function toggleRowMenu(event, menuId) {
|
||||
const isOpen = menu.classList.contains("open");
|
||||
closeAllRowMenus(menuId);
|
||||
menu.classList.toggle("open", !isOpen);
|
||||
const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
|
||||
if (trigger) {
|
||||
trigger.setAttribute("aria-expanded", String(!isOpen));
|
||||
}
|
||||
|
||||
if (menu.dataset.menuKey) {
|
||||
openInjectedPlaylistMenuKey = isOpen ? null : menu.dataset.menuKey;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDetailsRow(event, detailsRowId) {
|
||||
@@ -143,18 +82,6 @@ function toggleDetailsRow(event, detailsRowId) {
|
||||
);
|
||||
if (parentRow) {
|
||||
parentRow.classList.toggle("expanded", isExpanded);
|
||||
|
||||
// Persist Injected Playlists details expansion across auto-refreshes.
|
||||
if (parentRow.closest("#playlist-table-body")) {
|
||||
const detailsKey = parentRow.getAttribute("data-details-key");
|
||||
if (detailsKey) {
|
||||
if (isExpanded) {
|
||||
expandedInjectedPlaylistDetails.add(detailsKey);
|
||||
} else {
|
||||
expandedInjectedPlaylistDetails.delete(detailsKey);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -248,275 +175,6 @@ function getPlaylistStatusSummary(playlist) {
|
||||
};
|
||||
}
|
||||
|
||||
function syncElementAttributes(target, source) {
|
||||
if (!target || !source) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceAttributes = new Map(
|
||||
Array.from(source.attributes || []).map((attribute) => [
|
||||
attribute.name,
|
||||
attribute.value,
|
||||
]),
|
||||
);
|
||||
|
||||
Array.from(target.attributes || []).forEach((attribute) => {
|
||||
if (!sourceAttributes.has(attribute.name)) {
|
||||
target.removeAttribute(attribute.name);
|
||||
}
|
||||
});
|
||||
|
||||
sourceAttributes.forEach((value, name) => {
|
||||
target.setAttribute(name, value);
|
||||
});
|
||||
}
|
||||
|
||||
function syncPlaylistRowActionsWrap(existingWrap, nextWrap) {
|
||||
if (!existingWrap || !nextWrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingWrap, nextWrap);
|
||||
|
||||
const activeElement = document.activeElement;
|
||||
let focusTarget = null;
|
||||
|
||||
if (activeElement && existingWrap.contains(activeElement)) {
|
||||
if (activeElement.classList.contains("menu-trigger")) {
|
||||
focusTarget = { type: "trigger" };
|
||||
} else if (activeElement.tagName === "BUTTON") {
|
||||
focusTarget = {
|
||||
type: "menu-item",
|
||||
action: activeElement.getAttribute("data-action") || "",
|
||||
text: activeElement.textContent || "",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const existingTrigger = existingWrap.querySelector(".menu-trigger");
|
||||
const nextTrigger = nextWrap.querySelector(".menu-trigger");
|
||||
if (existingTrigger && nextTrigger) {
|
||||
syncElementAttributes(existingTrigger, nextTrigger);
|
||||
existingTrigger.textContent = nextTrigger.textContent;
|
||||
} else if (nextTrigger && !existingTrigger) {
|
||||
existingWrap.prepend(nextTrigger.cloneNode(true));
|
||||
} else if (existingTrigger && !nextTrigger) {
|
||||
existingTrigger.remove();
|
||||
}
|
||||
|
||||
const existingMenu = existingWrap.querySelector(".row-actions-menu");
|
||||
const nextMenu = nextWrap.querySelector(".row-actions-menu");
|
||||
if (existingMenu && nextMenu) {
|
||||
syncElementAttributes(existingMenu, nextMenu);
|
||||
existingMenu.replaceChildren(
|
||||
...Array.from(nextMenu.children).map((child) => child.cloneNode(true)),
|
||||
);
|
||||
} else if (nextMenu && !existingMenu) {
|
||||
existingWrap.append(nextMenu.cloneNode(true));
|
||||
} else if (existingMenu && !nextMenu) {
|
||||
existingMenu.remove();
|
||||
}
|
||||
|
||||
if (!focusTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (focusTarget.type === "trigger") {
|
||||
existingWrap.querySelector(".menu-trigger")?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const matchingButton =
|
||||
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
|
||||
(button) =>
|
||||
(button.getAttribute("data-action") || "") === focusTarget.action &&
|
||||
button.textContent === focusTarget.text,
|
||||
) ||
|
||||
Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
|
||||
(button) =>
|
||||
(button.getAttribute("data-action") || "") === focusTarget.action,
|
||||
);
|
||||
|
||||
matchingButton?.focus();
|
||||
}
|
||||
|
||||
function syncPlaylistControlsCell(
|
||||
existingControlsCell,
|
||||
nextControlsCell,
|
||||
preserveOpenMenu = false,
|
||||
) {
|
||||
if (!existingControlsCell || !nextControlsCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingControlsCell, nextControlsCell);
|
||||
|
||||
if (!preserveOpenMenu) {
|
||||
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
const existingDetailsTrigger =
|
||||
existingControlsCell.querySelector(".details-trigger");
|
||||
const nextDetailsTrigger = nextControlsCell.querySelector(".details-trigger");
|
||||
const existingWrap = existingControlsCell.querySelector(".row-actions-wrap");
|
||||
const nextWrap = nextControlsCell.querySelector(".row-actions-wrap");
|
||||
|
||||
if (
|
||||
!existingDetailsTrigger ||
|
||||
!nextDetailsTrigger ||
|
||||
!existingWrap ||
|
||||
!nextWrap
|
||||
) {
|
||||
existingControlsCell.innerHTML = nextControlsCell.innerHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingDetailsTrigger, nextDetailsTrigger);
|
||||
existingDetailsTrigger.textContent = nextDetailsTrigger.textContent;
|
||||
syncPlaylistRowActionsWrap(existingWrap, nextWrap);
|
||||
}
|
||||
|
||||
function syncPlaylistMainRow(
|
||||
existingMainRow,
|
||||
nextMainRow,
|
||||
preserveOpenMenu = false,
|
||||
) {
|
||||
if (!existingMainRow || !nextMainRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingMainRow, nextMainRow);
|
||||
|
||||
const nextCells = Array.from(nextMainRow.children);
|
||||
const existingCells = Array.from(existingMainRow.children);
|
||||
|
||||
if (!preserveOpenMenu || nextCells.length !== existingCells.length) {
|
||||
existingMainRow.innerHTML = nextMainRow.innerHTML;
|
||||
return;
|
||||
}
|
||||
|
||||
nextCells.forEach((nextCell, index) => {
|
||||
const existingCell = existingCells[index];
|
||||
if (!existingCell) {
|
||||
existingMainRow.append(nextCell.cloneNode(true));
|
||||
return;
|
||||
}
|
||||
|
||||
if (index === nextCells.length - 1) {
|
||||
syncPlaylistControlsCell(existingCell, nextCell, preserveOpenMenu);
|
||||
return;
|
||||
}
|
||||
|
||||
existingCell.replaceWith(nextCell.cloneNode(true));
|
||||
});
|
||||
|
||||
while (existingMainRow.children.length > nextCells.length) {
|
||||
existingMainRow.lastElementChild?.remove();
|
||||
}
|
||||
}
|
||||
|
||||
function syncPlaylistDetailsRow(existingDetailsRow, nextDetailsRow) {
|
||||
if (!existingDetailsRow || !nextDetailsRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
syncElementAttributes(existingDetailsRow, nextDetailsRow);
|
||||
existingDetailsRow.innerHTML = nextDetailsRow.innerHTML;
|
||||
}
|
||||
|
||||
function renderPlaylistRowPairMarkup(playlist, index) {
|
||||
const summary = getPlaylistStatusSummary(playlist);
|
||||
const detailsRowId = `playlist-details-${index}`;
|
||||
const menuId = `playlist-menu-${index}`;
|
||||
const detailsKey = `${playlist.id || playlist.name || index}`;
|
||||
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
|
||||
const isMenuOpen = openInjectedPlaylistMenuKey === detailsKey;
|
||||
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
||||
const escapedPlaylistName = escapeHtml(playlist.name);
|
||||
const escapedSyncSchedule = escapeHtml(syncSchedule);
|
||||
const escapedDetailsKey = escapeHtml(detailsKey);
|
||||
|
||||
const breakdownBadges = [
|
||||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
||||
`<span class="status-pill info">${summary.externalMatched} External</span>`,
|
||||
];
|
||||
|
||||
if (summary.externalMissing > 0) {
|
||||
breakdownBadges.push(
|
||||
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
return `
|
||||
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<strong>${escapeHtml(playlist.name)}</strong>
|
||||
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="track-count">${summary.totalPlayable}/${summary.spotifyTotal}</span>
|
||||
<div class="meta-text">${summary.completionPct}% playable</div>
|
||||
</td>
|
||||
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
||||
<td class="row-controls">
|
||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "Details"}</button>
|
||||
<div class="row-actions-wrap">
|
||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="${isMenuOpen ? "true" : "false"}"
|
||||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||
<div class="row-actions-menu ${isMenuOpen ? "open" : ""}" id="${menuId}" data-menu-key="${escapedDetailsKey}" role="menu">
|
||||
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
|
||||
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
|
||||
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
|
||||
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
|
||||
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
|
||||
<hr>
|
||||
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "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" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${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>
|
||||
`;
|
||||
}
|
||||
|
||||
function createPlaylistRowPair(playlist, index) {
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = renderPlaylistRowPairMarkup(playlist, index).trim();
|
||||
const [mainRow, detailsRow] = template.content.querySelectorAll("tr");
|
||||
return { mainRow, detailsRow };
|
||||
}
|
||||
|
||||
if (typeof window !== "undefined") {
|
||||
window.toggleRowMenu = toggleRowMenu;
|
||||
window.closeRowMenu = closeRowMenu;
|
||||
@@ -525,11 +183,10 @@ if (typeof window !== "undefined") {
|
||||
}
|
||||
|
||||
bindRowMenuHandlers();
|
||||
bindTableRowHandlers();
|
||||
|
||||
export function updateStatusUI(data) {
|
||||
const sidebarVersionEl = document.getElementById("sidebar-version");
|
||||
if (sidebarVersionEl) sidebarVersionEl.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;
|
||||
@@ -611,15 +268,9 @@ export function updateStatusUI(data) {
|
||||
|
||||
export function updatePlaylistsUI(data) {
|
||||
const tbody = document.getElementById("playlist-table-body");
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
const playlists = data.playlists || [];
|
||||
|
||||
if (playlists.length === 0) {
|
||||
expandedInjectedPlaylistDetails.clear();
|
||||
openInjectedPlaylistMenuKey = null;
|
||||
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", [
|
||||
@@ -673,68 +324,89 @@ export function updatePlaylistsUI(data) {
|
||||
});
|
||||
renderGuidance("playlists-guidance", guidance);
|
||||
|
||||
const existingPairs = new Map();
|
||||
Array.from(
|
||||
tbody.querySelectorAll("tr.compact-row[data-details-key]"),
|
||||
).forEach((mainRow) => {
|
||||
const detailsKey = mainRow.getAttribute("data-details-key");
|
||||
if (!detailsKey || existingPairs.has(detailsKey)) {
|
||||
return;
|
||||
}
|
||||
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 detailsRowId = mainRow.getAttribute("data-details-row");
|
||||
const detailsRow =
|
||||
(detailsRowId && document.getElementById(detailsRowId)) ||
|
||||
mainRow.nextElementSibling;
|
||||
if (!detailsRow) {
|
||||
return;
|
||||
}
|
||||
const breakdownBadges = [
|
||||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
||||
`<span class="status-pill info">${summary.externalMatched} External</span>`,
|
||||
];
|
||||
|
||||
existingPairs.set(detailsKey, { mainRow, detailsRow });
|
||||
});
|
||||
if (summary.externalMissing > 0) {
|
||||
breakdownBadges.push(
|
||||
`<span class="status-pill warning">${summary.externalMissing} Missing</span>`,
|
||||
);
|
||||
}
|
||||
|
||||
const orderedRows = [];
|
||||
playlists.forEach((playlist, index) => {
|
||||
const detailsKey = `${playlist.id || playlist.name || index}`;
|
||||
const { mainRow: nextMainRow, detailsRow: nextDetailsRow } =
|
||||
createPlaylistRowPair(playlist, index);
|
||||
const existingPair = existingPairs.get(detailsKey);
|
||||
|
||||
if (!existingPair) {
|
||||
orderedRows.push(nextMainRow, nextDetailsRow);
|
||||
return;
|
||||
}
|
||||
|
||||
syncPlaylistMainRow(
|
||||
existingPair.mainRow,
|
||||
nextMainRow,
|
||||
detailsKey === openInjectedPlaylistMenuKey,
|
||||
);
|
||||
syncPlaylistDetailsRow(existingPair.detailsRow, nextDetailsRow);
|
||||
|
||||
orderedRows.push(existingPair.mainRow, existingPair.detailsRow);
|
||||
existingPairs.delete(detailsKey);
|
||||
});
|
||||
|
||||
const activeRows = new Set(orderedRows);
|
||||
orderedRows.forEach((row) => {
|
||||
tbody.append(row);
|
||||
});
|
||||
Array.from(tbody.children).forEach((row) => {
|
||||
if (!activeRows.has(row)) {
|
||||
row.remove();
|
||||
}
|
||||
});
|
||||
|
||||
if (
|
||||
openInjectedPlaylistMenuKey &&
|
||||
!playlists.some(
|
||||
(playlist, index) =>
|
||||
`${playlist.id || playlist.name || index}` === openInjectedPlaylistMenuKey,
|
||||
)
|
||||
) {
|
||||
openInjectedPlaylistMenuKey = null;
|
||||
}
|
||||
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">${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) {
|
||||
@@ -806,9 +478,9 @@ export function updateDownloadsUI(data) {
|
||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||
<td>
|
||||
<button data-action="downloadFile" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
<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 data-action="deleteDownload" data-arg-path="${escapeHtml(escapeJs(f.path))}"
|
||||
<button onclick="deleteDownload('${escapeJs(f.path)}')"
|
||||
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -962,27 +634,26 @@ export function updateJellyfinPlaylistsUI(data) {
|
||||
.map((playlist, index) => {
|
||||
const detailsRowId = `jellyfin-details-${index}`;
|
||||
const menuId = `jellyfin-menu-${index}`;
|
||||
const statsPending = Boolean(playlist.statsPending);
|
||||
const localCount = playlist.localTracks || 0;
|
||||
const externalCount = playlist.externalTracks || 0;
|
||||
const externalAvailable = playlist.externalAvailable || 0;
|
||||
const escapedId = escapeHtml(playlist.id);
|
||||
const escapedName = escapeHtml(playlist.name);
|
||||
const escapedId = escapeJs(playlist.id);
|
||||
const escapedName = escapeJs(playlist.name);
|
||||
const statusClass = playlist.isConfigured ? "success" : "info";
|
||||
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
|
||||
|
||||
const actionButtons = playlist.isConfigured
|
||||
? `
|
||||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
||||
<button class="danger-item" data-action="unlinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Unlink from Spotify</button>
|
||||
<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 data-action="openLinkPlaylist" data-arg-jellyfin-id="${escapedId}" data-arg-jellyfin-name="${escapedName}">Link to Spotify</button>
|
||||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</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 class="compact-row" data-details-row="${detailsRowId}">
|
||||
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<strong>${escapeHtml(playlist.name)}</strong>
|
||||
@@ -990,15 +661,16 @@ export function updateJellyfinPlaylistsUI(data) {
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
|
||||
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
|
||||
<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">Details</button>
|
||||
<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"
|
||||
data-action="toggleRowMenu" data-arg-menu-id="${menuId}">...</button>
|
||||
onclick="toggleRowMenu(event, '${menuId}')">...</button>
|
||||
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||
${actionButtons}
|
||||
</div>
|
||||
@@ -1011,11 +683,11 @@ export function updateJellyfinPlaylistsUI(data) {
|
||||
<div class="details-grid">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Local Tracks</span>
|
||||
<span class="detail-value">${statsPending ? "..." : localCount}</span>
|
||||
<span class="detail-value">${localCount}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">External Tracks</span>
|
||||
<span class="detail-value">${statsPending ? "Loading..." : `${externalAvailable}/${externalCount}`}</span>
|
||||
<span class="detail-value">${externalAvailable}/${externalCount}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">Linked Spotify ID</span>
|
||||
@@ -1111,6 +783,110 @@ export function updateEndpointUsageUI(data) {
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function updateSquidWtfEndpointHealthUI(data) {
|
||||
const tbody = document.getElementById("squidwtf-endpoints-table-body");
|
||||
const testedAt = document.getElementById("squidwtf-endpoints-tested-at");
|
||||
const endpoints = data.Endpoints || data.endpoints || [];
|
||||
const testedAtValue = data.TestedAtUtc || data.testedAtUtc;
|
||||
|
||||
if (testedAt) {
|
||||
testedAt.textContent = testedAtValue
|
||||
? `Last tested ${new Date(testedAtValue).toLocaleString()}`
|
||||
: "Not tested yet";
|
||||
}
|
||||
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:40px;">No SquidWTF endpoints configured.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = endpoints
|
||||
.map((row) => {
|
||||
const apiResult = normalizeProbeResult(row.Api || row.api);
|
||||
const streamingResult = normalizeProbeResult(row.Streaming || row.streaming);
|
||||
const host = row.Host || row.host || "-";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<strong>${escapeHtml(host)}</strong>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
${renderProbeDot(apiResult, "api")}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
${renderProbeDot(streamingResult, "streaming")}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function normalizeProbeResult(result) {
|
||||
if (!result) {
|
||||
return {
|
||||
configured: false,
|
||||
isUp: false,
|
||||
state: "unknown",
|
||||
statusCode: null,
|
||||
latencyMs: null,
|
||||
requestUrl: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
configured: result.Configured ?? result.configured ?? false,
|
||||
isUp: result.IsUp ?? result.isUp ?? false,
|
||||
state: result.State ?? result.state ?? "unknown",
|
||||
statusCode: result.StatusCode ?? result.statusCode ?? null,
|
||||
latencyMs: result.LatencyMs ?? result.latencyMs ?? null,
|
||||
requestUrl: result.RequestUrl ?? result.requestUrl ?? null,
|
||||
error: result.Error ?? result.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderProbeDot(result, type) {
|
||||
const state = result.state || "unknown";
|
||||
const isUp = result.isUp === true;
|
||||
const variant = isUp
|
||||
? type
|
||||
: state === "missing"
|
||||
? "unknown"
|
||||
: "down";
|
||||
const titleParts = [];
|
||||
|
||||
if (type === "api") {
|
||||
titleParts.push(isUp ? "API up" : "API down");
|
||||
} else {
|
||||
titleParts.push(isUp ? "Streaming up" : "Streaming down");
|
||||
}
|
||||
|
||||
if (result.statusCode != null) {
|
||||
titleParts.push(`HTTP ${result.statusCode}`);
|
||||
}
|
||||
|
||||
if (result.latencyMs != null) {
|
||||
titleParts.push(`${result.latencyMs}ms`);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
titleParts.push(result.error);
|
||||
}
|
||||
|
||||
if (result.requestUrl) {
|
||||
titleParts.push(result.requestUrl);
|
||||
}
|
||||
|
||||
return `<span class="endpoint-health-dot ${variant}" title="${escapeHtml(titleParts.join(" • "))}"></span>`;
|
||||
}
|
||||
|
||||
export function showErrorState(message) {
|
||||
const statusBadge = document.getElementById("spotify-status");
|
||||
if (statusBadge) {
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
This folder contains small “view” modules for the admin UI.
|
||||
|
||||
Goal: keep `js/main.js` as orchestration only, while view modules encapsulate DOM wiring for each section.
|
||||
|
||||
@@ -1,22 +0,0 @@
|
||||
export function initNavigationView({ switchTab } = {}) {
|
||||
const doSwitch =
|
||||
typeof switchTab === "function" ? switchTab : (tab) => window.switchTab?.(tab);
|
||||
|
||||
document.querySelectorAll(".tab").forEach((tab) => {
|
||||
tab.addEventListener("click", () => {
|
||||
doSwitch(tab.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelectorAll(".sidebar-link").forEach((link) => {
|
||||
link.addEventListener("click", () => {
|
||||
doSwitch(link.dataset.tab);
|
||||
});
|
||||
});
|
||||
|
||||
const hash = window.location.hash.substring(1);
|
||||
if (hash) {
|
||||
doSwitch(hash);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
export function initScrobblingView({
|
||||
isAuthenticated,
|
||||
loadScrobblingConfig,
|
||||
} = {}) {
|
||||
const canLoad =
|
||||
typeof isAuthenticated === "function" ? isAuthenticated : () => false;
|
||||
const load =
|
||||
typeof loadScrobblingConfig === "function"
|
||||
? loadScrobblingConfig
|
||||
: () => window.loadScrobblingConfig?.();
|
||||
|
||||
function onActivateScrobbling() {
|
||||
if (canLoad()) {
|
||||
load();
|
||||
}
|
||||
}
|
||||
|
||||
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
||||
if (scrobblingTab) {
|
||||
scrobblingTab.addEventListener("click", onActivateScrobbling);
|
||||
}
|
||||
|
||||
const scrobblingSidebar = document.querySelector(
|
||||
'.sidebar-link[data-tab="scrobbling"]',
|
||||
);
|
||||
if (scrobblingSidebar) {
|
||||
scrobblingSidebar.addEventListener("click", onActivateScrobbling);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Spotify Track Mappings - Allstarr</title>
|
||||
<link rel="stylesheet" href="styles.css" />
|
||||
<style>
|
||||
:root {
|
||||
--bg-primary: #0d1117;
|
||||
@@ -669,30 +668,13 @@
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p class="support-text">
|
||||
<p>
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
</p>
|
||||
<ul class="support-funding-icons">
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://ko-fi.com/joshpatra" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Support on Ko-fi">
|
||||
<img src="images/kofi_symbol.svg" alt="" width="37" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://github.com/sponsors/SoPat712" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="GitHub Sponsors">
|
||||
<img src="images/github-mark.svg" alt="" width="30" height="29" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="support-funding-link" href="https://buymeacoffee.com/treeman183" target="_blank"
|
||||
rel="noopener noreferrer" aria-label="Buy Me a Coffee">
|
||||
<img src="images/buymeacoffee-symbol.svg" alt="" width="30" height="30" decoding="async" />
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,7 +15,6 @@ let localMapContext = null;
|
||||
let localMapResults = [];
|
||||
let localMapSelectedIndex = -1;
|
||||
let externalMapContext = null;
|
||||
const modalFocusState = new Map();
|
||||
|
||||
function showToast(message, type = "success", duration = 3000) {
|
||||
const toast = document.createElement("div");
|
||||
@@ -248,26 +247,9 @@ function toggleModal(modalId, shouldOpen) {
|
||||
}
|
||||
|
||||
if (shouldOpen) {
|
||||
const previousActive = document.activeElement;
|
||||
modalFocusState.set(modalId, previousActive);
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.removeAttribute("aria-hidden");
|
||||
modal.classList.add("active");
|
||||
const firstFocusable = modal.querySelector(
|
||||
'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
if (firstFocusable) {
|
||||
firstFocusable.focus();
|
||||
}
|
||||
} else {
|
||||
modal.classList.remove("active");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
const previousActive = modalFocusState.get(modalId);
|
||||
if (previousActive && typeof previousActive.focus === "function") {
|
||||
previousActive.focus();
|
||||
}
|
||||
modalFocusState.delete(modalId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -645,10 +627,6 @@ function initializeEventListeners() {
|
||||
closeLocalMapModal();
|
||||
closeExternalMapModal();
|
||||
});
|
||||
|
||||
document.querySelectorAll(".modal-overlay").forEach((modal) => {
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on page load
|
||||
|
||||
+87
-291
@@ -97,165 +97,88 @@ body {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.support-text {
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
.support-funding-icons {
|
||||
.endpoint-health-toolbar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.support-funding-icons li {
|
||||
.endpoint-health-legend {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
flex-wrap: wrap;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.support-badge .support-funding-icons {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.support-footer .support-funding-icons {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.support-funding-link {
|
||||
.endpoint-health-legend span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0.9;
|
||||
line-height: 0;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.support-badge .support-funding-link:hover,
|
||||
.support-footer .support-funding-link:hover {
|
||||
opacity: 1;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
.endpoint-health-last-tested {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.support-funding-link img {
|
||||
display: block;
|
||||
height: 30px;
|
||||
width: auto;
|
||||
.endpoint-health-dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
display: inline-block;
|
||||
border-radius: 999px;
|
||||
background: var(--text-secondary);
|
||||
box-shadow: inset 0 0 0 1px rgba(13, 17, 23, 0.25);
|
||||
}
|
||||
|
||||
.endpoint-health-dot.api,
|
||||
.endpoint-health-dot.up.api {
|
||||
background: var(--success);
|
||||
}
|
||||
|
||||
.endpoint-health-dot.streaming,
|
||||
.endpoint-health-dot.up.streaming {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.endpoint-health-dot.down {
|
||||
background: var(--error);
|
||||
}
|
||||
|
||||
.endpoint-health-dot.unknown {
|
||||
background: var(--text-secondary);
|
||||
}
|
||||
|
||||
.endpoint-health-table td,
|
||||
.endpoint-health-table th {
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.endpoint-link-cell {
|
||||
max-width: 260px;
|
||||
}
|
||||
|
||||
.endpoint-url {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: monospace;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.endpoint-url.muted {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.container {
|
||||
max-width: 1280px;
|
||||
margin: 0 auto 0 0;
|
||||
padding: 20px 20px 20px 8px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: 260px 1fr;
|
||||
gap: 18px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: sticky;
|
||||
top: 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(22, 27, 34, 0.8);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 14px;
|
||||
max-height: calc(100vh - 32px);
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.sidebar-brand {
|
||||
padding-bottom: 12px;
|
||||
margin-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.sidebar-title {
|
||||
font-weight: 700;
|
||||
font-size: 1.05rem;
|
||||
letter-spacing: 0.2px;
|
||||
}
|
||||
|
||||
.title-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.title-link:hover {
|
||||
color: var(--accent-hover);
|
||||
}
|
||||
|
||||
.sidebar-subtitle {
|
||||
margin-top: 2px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
|
||||
monospace;
|
||||
}
|
||||
|
||||
.sidebar-status {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.sidebar-status .status-badge {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.sidebar-nav {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sidebar-link {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 9px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.sidebar-link:hover {
|
||||
background: rgba(33, 38, 45, 0.7);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sidebar-link.active {
|
||||
background: rgba(88, 166, 255, 0.12);
|
||||
border-color: rgba(88, 166, 255, 0.35);
|
||||
color: #9ecbff;
|
||||
}
|
||||
|
||||
.sidebar-footer {
|
||||
margin-top: 14px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.sidebar-footer button {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.app-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-tabs,
|
||||
.tabs.top-tabs {
|
||||
display: none !important;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
@@ -267,6 +190,21 @@ body {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 20px 0;
|
||||
border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.auth-user {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
@@ -280,6 +218,12 @@ h1 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
h1 .version {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1031,20 +975,6 @@ input::placeholder {
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.container {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: static;
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.support-badge {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
@@ -1067,140 +997,6 @@ input::placeholder {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* Utility classes to reduce inline styles in index.html */
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.text-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.text-error {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.mb-12 {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.mb-16 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.mt-12 {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.flex-row-wrap {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.flex-row-wrap-8 {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.summary-box {
|
||||
display: flex;
|
||||
gap: 20px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-weight: 600;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.summary-value.success {
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.summary-value.warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
.summary-value.accent {
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
.callout {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.callout.warning {
|
||||
background: rgba(245, 158, 11, 0.12);
|
||||
border-color: var(--warning);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.callout.warning-strong {
|
||||
background: rgba(255, 193, 7, 0.15);
|
||||
border-color: #ffc107;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.callout.danger {
|
||||
background: rgba(248, 81, 73, 0.15);
|
||||
border-color: var(--error);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.pill-card {
|
||||
background: var(--bg-tertiary);
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.stats-grid-auto {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.max-h-600 {
|
||||
max-height: 600px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.jellyfin-user-form-group {
|
||||
margin: 0;
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
}
|
||||
|
||||
.jellyfin-user-form-group label {
|
||||
display: block;
|
||||
margin-bottom: 4px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.tracks-list {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
|
||||
Reference in New Issue
Block a user