mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
Compare commits
32 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
d96f722fa1
|
|||
|
e9099c45d5
|
|||
|
e3adaae924
|
|||
|
67db8b185f
|
|||
|
579c1e04d8
|
|||
|
885c86358d
|
|||
|
7550d01667
|
|||
|
af54a3eec1
|
|||
|
7beac7484d
|
|||
|
997f60b0a8
|
|||
|
6965bdc46d
|
|||
|
ad6f521795
|
|||
|
81bae5621a
|
|||
|
dc225945f8
|
|||
|
8be544bdfc
|
|||
|
e34c4bd125
|
|||
|
b1808bd60c
|
|||
|
8239316019
|
|||
|
e8e7f69e13
|
|||
|
815a75fd56
|
|||
|
9d58cdd1bd
|
|||
|
806511d727
|
|||
|
02967c8c67
|
|||
|
bf6fa4e647
|
|||
|
04e0c357aa
|
|||
|
ee98464475
|
|||
|
66f64d6de7
|
|||
|
8d3fde8fb9
|
|||
|
51d3d784b5
|
|||
|
dbc7bd6ea1
|
|||
|
b54d41f560
|
|||
|
877d2ffddf
|
@@ -100,4 +100,39 @@ public class AuthHeaderHelperTests
|
|||||||
Assert.Contains("Version=\"1.0\"", header);
|
Assert.Contains("Version=\"1.0\"", header);
|
||||||
Assert.Contains("Token=\"abc\"", 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,
|
Enabled = false,
|
||||||
ConnectionString = "localhost:6379"
|
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 spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
|
||||||
var spotifySessionCookieService = new SpotifySessionCookieService(
|
var spotifySessionCookieService = new SpotifySessionCookieService(
|
||||||
Options.Create(new SpotifyApiSettings()),
|
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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.Http;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -20,6 +21,7 @@ public class JellyfinProxyServiceTests
|
|||||||
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly JellyfinSettings _settings;
|
private readonly JellyfinSettings _settings;
|
||||||
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
|
||||||
public JellyfinProxyServiceTests()
|
public JellyfinProxyServiceTests()
|
||||||
{
|
{
|
||||||
@@ -31,7 +33,7 @@ public class JellyfinProxyServiceTests
|
|||||||
|
|
||||||
var redisSettings = new RedisSettings { Enabled = false };
|
var redisSettings = new RedisSettings { Enabled = false };
|
||||||
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
|
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
|
_settings = new JellyfinSettings
|
||||||
{
|
{
|
||||||
@@ -45,19 +47,21 @@ public class JellyfinProxyServiceTests
|
|||||||
};
|
};
|
||||||
|
|
||||||
var httpContext = new DefaultHttpContext();
|
var httpContext = new DefaultHttpContext();
|
||||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
_httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||||
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
||||||
|
var userResolver = CreateUserContextResolver(_httpContextAccessor);
|
||||||
|
|
||||||
// Initialize cache settings for tests
|
// Initialize cache settings for tests
|
||||||
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
|
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
|
||||||
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
|
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
|
||||||
var serviceProvider = serviceCollection.BuildServiceProvider();
|
var serviceProvider = serviceCollection.BuildServiceProvider();
|
||||||
CacheExtensions.InitializeCacheSettings(serviceProvider);
|
allstarr.Services.Common.CacheExtensions.InitializeCacheSettings(serviceProvider);
|
||||||
|
|
||||||
_service = new JellyfinProxyService(
|
_service = new JellyfinProxyService(
|
||||||
_mockHttpClientFactory.Object,
|
_mockHttpClientFactory.Object,
|
||||||
Options.Create(_settings),
|
Options.Create(_settings),
|
||||||
httpContextAccessor,
|
_httpContextAccessor,
|
||||||
|
userResolver,
|
||||||
mockLogger.Object,
|
mockLogger.Object,
|
||||||
_cache);
|
_cache);
|
||||||
}
|
}
|
||||||
@@ -93,6 +97,21 @@ public class JellyfinProxyServiceTests
|
|||||||
Assert.Equal(500, statusCode);
|
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]
|
[Fact]
|
||||||
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
|
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
|
||||||
{
|
{
|
||||||
@@ -228,6 +247,44 @@ public class JellyfinProxyServiceTests
|
|||||||
Assert.Equal("test query", searchTermValue);
|
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]
|
[Fact]
|
||||||
public async Task GetItemAsync_RequestsCorrectEndpoint()
|
public async Task GetItemAsync_RequestsCorrectEndpoint()
|
||||||
{
|
{
|
||||||
@@ -629,12 +686,14 @@ public class JellyfinProxyServiceTests
|
|||||||
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
||||||
var redisSettings = new RedisSettings { Enabled = false };
|
var redisSettings = new RedisSettings { Enabled = false };
|
||||||
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
|
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(
|
var service = new JellyfinProxyService(
|
||||||
_mockHttpClientFactory.Object,
|
_mockHttpClientFactory.Object,
|
||||||
Options.Create(_settings),
|
Options.Create(_settings),
|
||||||
httpContextAccessor,
|
httpContextAccessor,
|
||||||
|
userResolver,
|
||||||
mockLogger.Object,
|
mockLogger.Object,
|
||||||
cache);
|
cache);
|
||||||
|
|
||||||
@@ -660,4 +719,14 @@ public class JellyfinProxyServiceTests
|
|||||||
ItExpr.IsAny<CancellationToken>())
|
ItExpr.IsAny<CancellationToken>())
|
||||||
.ReturnsAsync(response);
|
.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.Reflection;
|
||||||
|
using System.Text.Json;
|
||||||
using allstarr.Controllers;
|
using allstarr.Controllers;
|
||||||
|
using allstarr.Models.Jellyfin;
|
||||||
|
|
||||||
namespace allstarr.Tests;
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
@@ -8,16 +10,16 @@ public class JellyfinSearchResponseSerializationTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
|
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
|
||||||
{
|
{
|
||||||
var payload = new
|
var payload = new JellyfinItemsResponse
|
||||||
{
|
{
|
||||||
Items = new[]
|
Items =
|
||||||
{
|
[
|
||||||
new Dictionary<string, object?>
|
new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
["Name"] = "BTS",
|
["Name"] = "BTS",
|
||||||
["Type"] = "MusicAlbum"
|
["Type"] = "MusicAlbum"
|
||||||
}
|
}
|
||||||
},
|
],
|
||||||
TotalRecordCount = 1,
|
TotalRecordCount = 1,
|
||||||
StartIndex = 0
|
StartIndex = 0
|
||||||
};
|
};
|
||||||
@@ -28,11 +30,64 @@ public class JellyfinSearchResponseSerializationTests
|
|||||||
|
|
||||||
Assert.NotNull(method);
|
Assert.NotNull(method);
|
||||||
|
|
||||||
var closedMethod = method!.MakeGenericMethod(payload.GetType());
|
var json = (string)method!.Invoke(null, new object?[] { payload })!;
|
||||||
var json = (string)closedMethod.Invoke(null, new object?[] { payload })!;
|
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
|
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
|
||||||
json);
|
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()
|
public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
|
||||||
{
|
{
|
||||||
var requestedPaths = new ConcurrentBag<string>();
|
var requestedPaths = new ConcurrentBag<string>();
|
||||||
|
var requestBodies = new ConcurrentDictionary<string, string>();
|
||||||
var handler = new DelegateHttpMessageHandler((request, _) =>
|
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));
|
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -89,6 +97,12 @@ public class JellyfinSessionManagerTests
|
|||||||
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
|
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
|
||||||
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
|
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
|
||||||
Assert.DoesNotContain("/Sessions/Logout", 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]
|
[Fact]
|
||||||
@@ -182,12 +196,19 @@ public class JellyfinSessionManagerTests
|
|||||||
|
|
||||||
var cache = new RedisCacheService(
|
var cache = new RedisCacheService(
|
||||||
Options.Create(new RedisSettings { Enabled = false }),
|
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(
|
return new JellyfinProxyService(
|
||||||
httpClientFactory,
|
httpClientFactory,
|
||||||
Options.Create(settings),
|
Options.Create(settings),
|
||||||
httpContextAccessor,
|
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,
|
NullLogger<JellyfinProxyService>.Instance,
|
||||||
cache);
|
cache);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using allstarr.Services.Lyrics;
|
using allstarr.Services.Lyrics;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
@@ -23,7 +24,7 @@ public class LrclibServiceTests
|
|||||||
// Create mock Redis cache
|
// Create mock Redis cache
|
||||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
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
|
_httpClient = new HttpClient
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using Moq;
|
using Moq;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using System.Text.Json;
|
||||||
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using allstarr.Models.Settings;
|
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]
|
[Fact]
|
||||||
public void Constructor_InitializesWithSettings()
|
public void Constructor_InitializesWithSettings()
|
||||||
{
|
{
|
||||||
// Act
|
// Act
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(service);
|
Assert.NotNull(service);
|
||||||
@@ -45,7 +57,7 @@ public class RedisCacheServiceTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Act - Constructor will try to connect but should handle failure gracefully
|
// 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 - Service should be created even if connection fails
|
||||||
Assert.NotNull(service);
|
Assert.NotNull(service);
|
||||||
@@ -55,7 +67,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
|
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetStringAsync("test:key");
|
var result = await service.GetStringAsync("test:key");
|
||||||
@@ -68,7 +80,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task GetAsync_WhenDisabled_ReturnsNull()
|
public async Task GetAsync_WhenDisabled_ReturnsNull()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAsync<TestObject>("test:key");
|
var result = await service.GetAsync<TestObject>("test:key");
|
||||||
@@ -81,7 +93,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
|
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.SetStringAsync("test:key", "test value");
|
var result = await service.SetStringAsync("test:key", "test value");
|
||||||
@@ -94,7 +106,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task SetAsync_WhenDisabled_ReturnsFalse()
|
public async Task SetAsync_WhenDisabled_ReturnsFalse()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -108,7 +120,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
|
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteAsync("test:key");
|
var result = await service.DeleteAsync("test:key");
|
||||||
@@ -121,7 +133,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
|
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.ExistsAsync("test:key");
|
var result = await service.ExistsAsync("test:key");
|
||||||
@@ -134,7 +146,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
|
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.DeleteByPatternAsync("test:*");
|
var result = await service.DeleteByPatternAsync("test:*");
|
||||||
@@ -147,7 +159,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
|
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
var expiry = TimeSpan.FromHours(1);
|
var expiry = TimeSpan.FromHours(1);
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
@@ -161,7 +173,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
|
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||||
var expiry = TimeSpan.FromDays(30);
|
var expiry = TimeSpan.FromDays(30);
|
||||||
|
|
||||||
@@ -176,14 +188,14 @@ public class RedisCacheServiceTests
|
|||||||
public void IsEnabled_ReflectsSettings()
|
public void IsEnabled_ReflectsSettings()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
|
var disabledService = CreateService();
|
||||||
|
|
||||||
var enabledSettings = Options.Create(new RedisSettings
|
var enabledSettings = Options.Create(new RedisSettings
|
||||||
{
|
{
|
||||||
Enabled = true,
|
Enabled = true,
|
||||||
ConnectionString = "localhost:6379"
|
ConnectionString = "localhost:6379"
|
||||||
});
|
});
|
||||||
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
var enabledService = CreateService(settings: enabledSettings);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.False(disabledService.IsEnabled);
|
Assert.False(disabledService.IsEnabled);
|
||||||
@@ -194,7 +206,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task GetAsync_DeserializesComplexObjects()
|
public async Task GetAsync_DeserializesComplexObjects()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = await service.GetAsync<ComplexTestObject>("test:complex");
|
var result = await service.GetAsync<ComplexTestObject>("test:complex");
|
||||||
@@ -207,7 +219,7 @@ public class RedisCacheServiceTests
|
|||||||
public async Task SetAsync_SerializesComplexObjects()
|
public async Task SetAsync_SerializesComplexObjects()
|
||||||
{
|
{
|
||||||
// Arrange
|
// Arrange
|
||||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
var service = CreateService();
|
||||||
var complexObj = new ComplexTestObject
|
var complexObj = new ComplexTestObject
|
||||||
{
|
{
|
||||||
Id = 1,
|
Id = 1,
|
||||||
@@ -238,12 +250,184 @@ public class RedisCacheServiceTests
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var service = new RedisCacheService(customSettings, _mockLogger.Object);
|
var service = CreateService(settings: customSettings);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
Assert.NotNull(service);
|
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
|
private class TestObject
|
||||||
{
|
{
|
||||||
public int Id { get; set; }
|
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);
|
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)
|
private static T InvokePrivateMethod<T>(object instance, string methodName, params object?[] args)
|
||||||
{
|
{
|
||||||
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
|
var method = instance.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Xunit;
|
|||||||
using Moq;
|
using Moq;
|
||||||
using System;
|
using System;
|
||||||
using System.Threading.Tasks;
|
using System.Threading.Tasks;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Services.Spotify;
|
using allstarr.Services.Spotify;
|
||||||
@@ -30,7 +31,7 @@ public class SpotifyMappingServiceTests
|
|||||||
ConnectionString = "localhost:6379"
|
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);
|
_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(
|
var cache = new RedisCacheService(
|
||||||
Options.Create(new RedisSettings { Enabled = false }),
|
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);
|
var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Xunit;
|
using Xunit;
|
||||||
using Moq;
|
using Moq;
|
||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Services.SquidWTF;
|
using allstarr.Services.SquidWTF;
|
||||||
@@ -42,7 +43,10 @@ public class SquidWTFMetadataServiceTests
|
|||||||
// Create mock Redis cache
|
// Create mock Redis cache
|
||||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
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>
|
_apiUrls = new List<string>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Current application version.
|
/// Current application version.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Version = "1.5.2";
|
public const string Version = "1.4.7";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -637,11 +637,11 @@ public class ConfigController : ControllerBase
|
|||||||
{
|
{
|
||||||
var keysToDelete = new[]
|
var keysToDelete = new[]
|
||||||
{
|
{
|
||||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||||
$"spotify:matched:{playlist.Name}", // Legacy key
|
$"spotify:matched:{playlist.Name}", // Legacy key
|
||||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name)
|
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var key in keysToDelete)
|
foreach (var key in keysToDelete)
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ using allstarr.Services.Admin;
|
|||||||
using allstarr.Services.Spotify;
|
using allstarr.Services.Spotify;
|
||||||
using allstarr.Services.Scrobbling;
|
using allstarr.Services.Scrobbling;
|
||||||
using allstarr.Services.SquidWTF;
|
using allstarr.Services.SquidWTF;
|
||||||
|
using System.Diagnostics;
|
||||||
using System.Runtime;
|
using System.Runtime;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
|
|
||||||
@@ -18,6 +20,9 @@ namespace allstarr.Controllers;
|
|||||||
[ServiceFilter(typeof(AdminPortFilter))]
|
[ServiceFilter(typeof(AdminPortFilter))]
|
||||||
public class DiagnosticsController : ControllerBase
|
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 ILogger<DiagnosticsController> _logger;
|
||||||
private readonly IConfiguration _configuration;
|
private readonly IConfiguration _configuration;
|
||||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
@@ -29,6 +34,8 @@ public class DiagnosticsController : ControllerBase
|
|||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private readonly SpotifySessionCookieService _spotifySessionCookieService;
|
private readonly SpotifySessionCookieService _spotifySessionCookieService;
|
||||||
private readonly List<string> _squidWtfApiUrls;
|
private readonly List<string> _squidWtfApiUrls;
|
||||||
|
private readonly List<string> _squidWtfStreamingUrls;
|
||||||
|
private readonly IHttpClientFactory _httpClientFactory;
|
||||||
private static int _urlIndex = 0;
|
private static int _urlIndex = 0;
|
||||||
private static readonly object _urlIndexLock = new();
|
private static readonly object _urlIndexLock = new();
|
||||||
|
|
||||||
@@ -43,7 +50,8 @@ public class DiagnosticsController : ControllerBase
|
|||||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||||
SpotifySessionCookieService spotifySessionCookieService,
|
SpotifySessionCookieService spotifySessionCookieService,
|
||||||
SquidWtfEndpointCatalog squidWtfEndpointCatalog,
|
SquidWtfEndpointCatalog squidWtfEndpointCatalog,
|
||||||
RedisCacheService cache)
|
RedisCacheService cache,
|
||||||
|
IHttpClientFactory httpClientFactory)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_configuration = configuration;
|
_configuration = configuration;
|
||||||
@@ -56,6 +64,8 @@ public class DiagnosticsController : ControllerBase
|
|||||||
_spotifySessionCookieService = spotifySessionCookieService;
|
_spotifySessionCookieService = spotifySessionCookieService;
|
||||||
_cache = cache;
|
_cache = cache;
|
||||||
_squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
|
_squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
|
||||||
|
_squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls;
|
||||||
|
_httpClientFactory = httpClientFactory;
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpGet("status")]
|
[HttpGet("status")]
|
||||||
@@ -161,6 +171,61 @@ public class DiagnosticsController : ControllerBase
|
|||||||
return Ok(new { baseUrl });
|
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>
|
/// <summary>
|
||||||
/// Get current configuration including cache settings
|
/// Get current configuration including cache settings
|
||||||
/// </summary>
|
/// </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>
|
/// <summary>
|
||||||
|
|||||||
@@ -139,56 +139,6 @@ 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 audioExtensions = new[] { ".flac", ".mp3", ".m4a", ".opus" };
|
|
||||||
var allFiles = Directory.GetFiles(keptPath, "*.*", SearchOption.AllDirectories)
|
|
||||||
.Where(f => audioExtensions.Contains(Path.GetExtension(f).ToLowerInvariant()))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
foreach (var filePath in allFiles)
|
|
||||||
{
|
|
||||||
System.IO.File.Delete(filePath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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>
|
/// <summary>
|
||||||
/// GET /api/admin/downloads/file
|
/// GET /api/admin/downloads/file
|
||||||
/// Downloads a specific file from the kept folder
|
/// Downloads a specific file from the kept folder
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using System.Text.Json;
|
|||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Net.Http;
|
using System.Net.Http;
|
||||||
using allstarr.Models.Domain;
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Models.Spotify;
|
using allstarr.Models.Spotify;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -167,7 +168,7 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate))
|
if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate))
|
||||||
{
|
{
|
||||||
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistName);
|
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistConfig);
|
||||||
spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate;
|
spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,7 +178,16 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
// 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);
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
|
|
||||||
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
|
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
|
||||||
@@ -186,7 +196,10 @@ public partial class JellyfinController
|
|||||||
// Fallback to legacy cache format
|
// Fallback to legacy cache format
|
||||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
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);
|
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
|
||||||
if (legacySongs != null && legacySongs.Count > 0)
|
if (legacySongs != null && legacySongs.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -202,7 +215,10 @@ public partial class JellyfinController
|
|||||||
// Prefer the currently served playlist items cache when available.
|
// Prefer the currently served playlist items cache when available.
|
||||||
// This most closely matches what the injected playlist endpoint will return.
|
// This most closely matches what the injected playlist endpoint will return.
|
||||||
var exactServedCount = 0;
|
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 cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
|
||||||
var exactServedRunTimeTicks = 0L;
|
var exactServedRunTimeTicks = 0L;
|
||||||
if (cachedPlaylistItems != null &&
|
if (cachedPlaylistItems != null &&
|
||||||
@@ -231,7 +247,7 @@ public partial class JellyfinController
|
|||||||
var localRunTimeTicks = 0L;
|
var localRunTimeTicks = 0L;
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userId = _settings.UserId;
|
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
||||||
var queryParams = new Dictionary<string, string>
|
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
|
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 cachedPlaylist = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||||
var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist);
|
var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist);
|
||||||
if (createdAt.HasValue)
|
if (createdAt.HasValue)
|
||||||
@@ -351,7 +378,10 @@ public partial class JellyfinController
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(playlistName);
|
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(
|
||||||
|
playlistName,
|
||||||
|
playlistScopeUserId,
|
||||||
|
playlistConfig.JellyfinId);
|
||||||
var earliestTrackAddedAt = tracks
|
var earliestTrackAddedAt = tracks
|
||||||
.Where(t => t.AddedAt.HasValue)
|
.Where(t => t.AddedAt.HasValue)
|
||||||
.Select(t => t.AddedAt!.Value.ToUniversalTime())
|
.Select(t => t.AddedAt!.Value.ToUniversalTime())
|
||||||
|
|||||||
@@ -245,9 +245,7 @@ public class JellyfinAdminController : ControllerBase
|
|||||||
/// Get all playlists from the user's Spotify account
|
/// Get all playlists from the user's Spotify account
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet("jellyfin/playlists")]
|
[HttpGet("jellyfin/playlists")]
|
||||||
public async Task<IActionResult> GetJellyfinPlaylists(
|
public async Task<IActionResult> GetJellyfinPlaylists([FromQuery] string? userId = null)
|
||||||
[FromQuery] string? userId = null,
|
|
||||||
[FromQuery] bool includeStats = true)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
if (string.IsNullOrEmpty(_jellyfinSettings.Url) || string.IsNullOrEmpty(_jellyfinSettings.ApiKey))
|
||||||
{
|
{
|
||||||
@@ -332,13 +330,13 @@ public class JellyfinAdminController : ControllerBase
|
|||||||
|
|
||||||
var statsUserId = requestedUserId;
|
var statsUserId = requestedUserId;
|
||||||
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
var trackStats = (LocalTracks: 0, ExternalTracks: 0, ExternalAvailable: 0);
|
||||||
if (isConfigured && includeStats)
|
if (isConfigured)
|
||||||
{
|
{
|
||||||
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
|
trackStats = await GetPlaylistTrackStats(id!, session, statsUserId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var actualTrackCount = isConfigured
|
var actualTrackCount = isConfigured
|
||||||
? (includeStats ? trackStats.LocalTracks + trackStats.ExternalTracks : childCount)
|
? trackStats.LocalTracks + trackStats.ExternalTracks
|
||||||
: childCount;
|
: childCount;
|
||||||
|
|
||||||
playlists.Add(new
|
playlists.Add(new
|
||||||
@@ -351,7 +349,6 @@ public class JellyfinAdminController : ControllerBase
|
|||||||
isLinkedByAnotherUser,
|
isLinkedByAnotherUser,
|
||||||
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
|
linkedOwnerUserId = scopedLinkedPlaylist?.UserId ??
|
||||||
allLinkedForPlaylist.FirstOrDefault()?.UserId,
|
allLinkedForPlaylist.FirstOrDefault()?.UserId,
|
||||||
statsPending = isConfigured && !includeStats,
|
|
||||||
localTracks = trackStats.LocalTracks,
|
localTracks = trackStats.LocalTracks,
|
||||||
externalTracks = trackStats.ExternalTracks,
|
externalTracks = trackStats.ExternalTracks,
|
||||||
externalAvailable = trackStats.ExternalAvailable
|
externalAvailable = trackStats.ExternalAvailable
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using allstarr.Models.Jellyfin;
|
||||||
using allstarr.Models.Scrobbling;
|
using allstarr.Models.Scrobbling;
|
||||||
|
using allstarr.Serialization;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
@@ -226,7 +228,7 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
// Build minimal playback start with just the ghost UUID
|
// Build minimal playback start with just the ghost UUID
|
||||||
// Don't include the Item object - Jellyfin will just track the session without item details
|
// Don't include the Item object - Jellyfin will just track the session without item details
|
||||||
var playbackStart = new
|
var playbackStart = new JellyfinPlaybackStatePayload
|
||||||
{
|
{
|
||||||
ItemId = ghostUuid,
|
ItemId = ghostUuid,
|
||||||
PositionTicks = positionTicks ?? 0,
|
PositionTicks = positionTicks ?? 0,
|
||||||
@@ -236,7 +238,7 @@ public partial class JellyfinController
|
|||||||
PlayMethod = "DirectPlay"
|
PlayMethod = "DirectPlay"
|
||||||
};
|
};
|
||||||
|
|
||||||
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
|
||||||
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
|
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
|
||||||
|
|
||||||
// Forward to Jellyfin with ghost UUID
|
// Forward to Jellyfin with ghost UUID
|
||||||
@@ -357,14 +359,13 @@ public partial class JellyfinController
|
|||||||
trackName ?? "Unknown", itemId);
|
trackName ?? "Unknown", itemId);
|
||||||
|
|
||||||
// Build playback start info - Jellyfin will fetch item details itself
|
// Build playback start info - Jellyfin will fetch item details itself
|
||||||
var playbackStart = new
|
var playbackStart = new JellyfinPlaybackStatePayload
|
||||||
{
|
{
|
||||||
ItemId = itemId,
|
ItemId = itemId ?? string.Empty,
|
||||||
PositionTicks = positionTicks ?? 0,
|
PositionTicks = positionTicks ?? 0
|
||||||
// Let Jellyfin fetch the item details - don't include NowPlayingItem
|
|
||||||
};
|
};
|
||||||
|
|
||||||
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
|
||||||
_logger.LogDebug("📤 Sending playback start: {Json}", playbackJson);
|
_logger.LogDebug("📤 Sending playback start: {Json}", playbackJson);
|
||||||
|
|
||||||
var (result, statusCode) =
|
var (result, statusCode) =
|
||||||
@@ -624,7 +625,7 @@ public partial class JellyfinController
|
|||||||
externalId);
|
externalId);
|
||||||
|
|
||||||
var inferredStartGhostUuid = GenerateUuidFromString(itemId);
|
var inferredStartGhostUuid = GenerateUuidFromString(itemId);
|
||||||
var inferredExternalStartPayload = JsonSerializer.Serialize(new
|
var inferredExternalStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||||
{
|
{
|
||||||
ItemId = inferredStartGhostUuid,
|
ItemId = inferredStartGhostUuid,
|
||||||
PositionTicks = positionTicks ?? 0,
|
PositionTicks = positionTicks ?? 0,
|
||||||
@@ -692,7 +693,7 @@ public partial class JellyfinController
|
|||||||
var ghostUuid = GenerateUuidFromString(itemId);
|
var ghostUuid = GenerateUuidFromString(itemId);
|
||||||
|
|
||||||
// Build progress report with ghost UUID
|
// Build progress report with ghost UUID
|
||||||
var progressReport = new
|
var progressReport = new JellyfinPlaybackStatePayload
|
||||||
{
|
{
|
||||||
ItemId = ghostUuid,
|
ItemId = ghostUuid,
|
||||||
PositionTicks = positionTicks ?? 0,
|
PositionTicks = positionTicks ?? 0,
|
||||||
@@ -702,7 +703,7 @@ public partial class JellyfinController
|
|||||||
PlayMethod = "DirectPlay"
|
PlayMethod = "DirectPlay"
|
||||||
};
|
};
|
||||||
|
|
||||||
var progressJson = JsonSerializer.Serialize(progressReport);
|
var progressJson = AllstarrJsonSerializer.Serialize(progressReport);
|
||||||
|
|
||||||
// Forward to Jellyfin with ghost UUID
|
// Forward to Jellyfin with ghost UUID
|
||||||
var (progressResult, progressStatusCode) =
|
var (progressResult, progressStatusCode) =
|
||||||
@@ -773,7 +774,7 @@ public partial class JellyfinController
|
|||||||
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
|
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
|
||||||
trackName ?? "Unknown", itemId);
|
trackName ?? "Unknown", itemId);
|
||||||
|
|
||||||
var inferredStartPayload = JsonSerializer.Serialize(new
|
var inferredStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||||
{
|
{
|
||||||
ItemId = itemId,
|
ItemId = itemId,
|
||||||
PositionTicks = positionTicks ?? 0
|
PositionTicks = positionTicks ?? 0
|
||||||
@@ -948,7 +949,7 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
var ghostUuid = GenerateUuidFromString(previousItemId);
|
var ghostUuid = GenerateUuidFromString(previousItemId);
|
||||||
var inferredExternalStopPayload = JsonSerializer.Serialize(new
|
var inferredExternalStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||||
{
|
{
|
||||||
ItemId = ghostUuid,
|
ItemId = ghostUuid,
|
||||||
PositionTicks = previousPositionTicks ?? 0,
|
PositionTicks = previousPositionTicks ?? 0,
|
||||||
@@ -997,7 +998,7 @@ public partial class JellyfinController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var inferredStopPayload = JsonSerializer.Serialize(new
|
var inferredStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||||
{
|
{
|
||||||
ItemId = previousItemId,
|
ItemId = previousItemId,
|
||||||
PositionTicks = previousPositionTicks ?? 0,
|
PositionTicks = previousPositionTicks ?? 0,
|
||||||
@@ -1062,7 +1063,7 @@ public partial class JellyfinController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = ResolvePlaybackUserId(progressPayload);
|
var userId = await ResolvePlaybackUserIdAsync(progressPayload);
|
||||||
if (string.IsNullOrWhiteSpace(userId))
|
if (string.IsNullOrWhiteSpace(userId))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Skipping local played signal for {ItemId} - no user id available", itemId);
|
_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) &&
|
if (progressPayload.TryGetProperty("UserId", out var userIdElement) &&
|
||||||
userIdElement.ValueKind == JsonValueKind.String)
|
userIdElement.ValueKind == JsonValueKind.String)
|
||||||
@@ -1116,7 +1117,7 @@ public partial class JellyfinController
|
|||||||
return queryUserId;
|
return queryUserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
return _settings.UserId;
|
return await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static int? ToPlaybackPositionSeconds(long? positionTicks)
|
private static int? ToPlaybackPositionSeconds(long? positionTicks)
|
||||||
@@ -1294,13 +1295,13 @@ public partial class JellyfinController
|
|||||||
// Report stop to Jellyfin with ghost UUID
|
// Report stop to Jellyfin with ghost UUID
|
||||||
var ghostUuid = GenerateUuidFromString(itemId);
|
var ghostUuid = GenerateUuidFromString(itemId);
|
||||||
|
|
||||||
var externalStopInfo = new
|
var externalStopInfo = new JellyfinPlaybackStatePayload
|
||||||
{
|
{
|
||||||
ItemId = ghostUuid,
|
ItemId = ghostUuid,
|
||||||
PositionTicks = positionTicks ?? 0
|
PositionTicks = positionTicks ?? 0
|
||||||
};
|
};
|
||||||
|
|
||||||
var stopJson = JsonSerializer.Serialize(externalStopInfo);
|
var stopJson = AllstarrJsonSerializer.Serialize(externalStopInfo);
|
||||||
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
|
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
|
||||||
|
|
||||||
var (stopResult, stopStatusCode) =
|
var (stopResult, stopStatusCode) =
|
||||||
@@ -1469,7 +1470,7 @@ public partial class JellyfinController
|
|||||||
stopInfo["PositionTicks"] = positionTicks.Value;
|
stopInfo["PositionTicks"] = positionTicks.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
body = JsonSerializer.Serialize(stopInfo);
|
body = AllstarrJsonSerializer.Serialize(stopInfo);
|
||||||
_logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length);
|
_logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length);
|
||||||
|
|
||||||
var (result, statusCode) =
|
var (result, statusCode) =
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
|
using System.Buffers;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using allstarr.Models.Domain;
|
using allstarr.Models.Domain;
|
||||||
|
using allstarr.Models.Jellyfin;
|
||||||
using allstarr.Models.Search;
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
|
using allstarr.Serialization;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
@@ -25,6 +28,7 @@ public partial class JellyfinController
|
|||||||
[FromQuery] int startIndex = 0,
|
[FromQuery] int startIndex = 0,
|
||||||
[FromQuery] string? parentId = null,
|
[FromQuery] string? parentId = null,
|
||||||
[FromQuery] string? artistIds = null,
|
[FromQuery] string? artistIds = null,
|
||||||
|
[FromQuery] string? contributingArtistIds = null,
|
||||||
[FromQuery] string? albumArtistIds = null,
|
[FromQuery] string? albumArtistIds = null,
|
||||||
[FromQuery] string? albumIds = null,
|
[FromQuery] string? albumIds = null,
|
||||||
[FromQuery] string? sortBy = null,
|
[FromQuery] string? sortBy = null,
|
||||||
@@ -39,8 +43,8 @@ public partial class JellyfinController
|
|||||||
var effectiveArtistIds = albumArtistIds ?? artistIds;
|
var effectiveArtistIds = albumArtistIds ?? artistIds;
|
||||||
var favoritesOnlyRequest = IsFavoritesOnlyRequest();
|
var favoritesOnlyRequest = IsFavoritesOnlyRequest();
|
||||||
|
|
||||||
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
|
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, contributingArtistIds={ContributingArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
|
||||||
searchTerm, includeItemTypes, parentId, artistIds, albumArtistIds, albumIds, userId);
|
searchTerm, includeItemTypes, parentId, artistIds, contributingArtistIds, albumArtistIds, albumIds, userId);
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'",
|
"SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'",
|
||||||
Request.QueryString.Value ?? string.Empty,
|
Request.QueryString.Value ?? string.Empty,
|
||||||
@@ -51,15 +55,34 @@ public partial class JellyfinController
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
// REQUEST ROUTING LOGIC (Priority Order)
|
// REQUEST ROUTING LOGIC (Priority Order)
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 1. ArtistIds present (external) → Handle external artists (even with ParentId)
|
// 1. ContributingArtistIds present (external) → Handle external "appears on" albums
|
||||||
// 2. AlbumIds present (external) → Handle external albums (even with ParentId)
|
// 2. ArtistIds present (external) → Handle external artists (even with ParentId)
|
||||||
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
|
// 3. AlbumIds present (external) → Handle external albums (even with ParentId)
|
||||||
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter
|
// 4. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
|
||||||
// 5. SearchTerm present → Integrated search (Jellyfin + external sources)
|
// 5. ArtistIds / ContributingArtistIds present (library) → Proxy to Jellyfin with full filter
|
||||||
// 6. Otherwise → Proxy browse request transparently to Jellyfin
|
// 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))
|
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||||
{
|
{
|
||||||
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
|
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
|
// 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))
|
if (!string.IsNullOrWhiteSpace(albumIds))
|
||||||
{
|
{
|
||||||
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
|
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);
|
var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted);
|
||||||
if (album == null)
|
if (album == null)
|
||||||
{
|
{
|
||||||
return new JsonResult(new
|
return CreateItemsResponse([], 0, startIndex);
|
||||||
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
|
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
|
||||||
|
|
||||||
return new JsonResult(new
|
return CreateItemsResponse(albumItems, albumItems.Count, startIndex);
|
||||||
{
|
|
||||||
Items = albumItems,
|
|
||||||
TotalRecordCount = albumItems.Count,
|
|
||||||
StartIndex = startIndex
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
// If library album, fall through to handle with ParentId or proxy
|
// 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))
|
if (!string.IsNullOrWhiteSpace(parentId))
|
||||||
{
|
{
|
||||||
// Check if this is an external playlist
|
// 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))
|
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||||
{
|
{
|
||||||
// Library artist - proxy transparently with full query string
|
// Library artist - proxy transparently with full query string
|
||||||
@@ -177,7 +204,7 @@ public partial class JellyfinController
|
|||||||
return HandleProxyResponse(result, statusCode);
|
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))
|
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||||
{
|
{
|
||||||
// Check cache for search results (only cache pure searches, not filtered searches)
|
// 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
|
// 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
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
|
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
|
||||||
@@ -546,44 +573,20 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
|
var response = new JellyfinItemsResponse
|
||||||
var response = new
|
|
||||||
{
|
{
|
||||||
Items = pagedItems,
|
Items = pagedItems,
|
||||||
TotalRecordCount = items.Count,
|
TotalRecordCount = items.Count,
|
||||||
StartIndex = startIndex
|
StartIndex = startIndex
|
||||||
};
|
};
|
||||||
var json = SerializeSearchResponseJson(response);
|
return await WriteSearchItemsResponseAsync(
|
||||||
|
response,
|
||||||
// Cache search results in Redis using the configured search TTL.
|
searchTerm,
|
||||||
if (!string.IsNullOrWhiteSpace(searchTerm) &&
|
effectiveArtistIds,
|
||||||
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
|
searchCacheKey,
|
||||||
!string.IsNullOrWhiteSpace(searchCacheKey))
|
externalHasRequestedTypeResults,
|
||||||
{
|
cleanQuery,
|
||||||
if (externalHasRequestedTypeResults)
|
includeItemTypes);
|
||||||
{
|
|
||||||
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");
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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,
|
await _cache.SetStringAsync(searchCacheKey!, json, CacheExtensions.SearchResultsTTL);
|
||||||
DictionaryKeyPolicy = null
|
_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>
|
/// <summary>
|
||||||
@@ -693,12 +730,9 @@ public partial class JellyfinController
|
|||||||
var cleanQuery = searchTerm.Trim().Trim('"');
|
var cleanQuery = searchTerm.Trim().Trim('"');
|
||||||
var requestedTypes = ParseItemTypes(includeItemTypes);
|
var requestedTypes = ParseItemTypes(includeItemTypes);
|
||||||
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
|
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
|
||||||
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 ||
|
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||||
requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
||||||
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 ||
|
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||||
requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
|
||||||
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 ||
|
|
||||||
requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
_logger.LogInformation(
|
||||||
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
|
"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 includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||||
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
|
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
|
||||||
(includePlaylistsAsAlbums &&
|
(includePlaylistsAsAlbums && requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
|
||||||
requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
|
|
||||||
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -821,14 +854,210 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
private static IActionResult CreateEmptyItemsResponse(int startIndex)
|
private static IActionResult CreateEmptyItemsResponse(int startIndex)
|
||||||
{
|
{
|
||||||
return new JsonResult(new
|
return CreateItemsResponse([], 0, startIndex);
|
||||||
{
|
|
||||||
Items = Array.Empty<object>(),
|
|
||||||
TotalRecordCount = 0,
|
|
||||||
StartIndex = 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>
|
/// <summary>
|
||||||
/// Merges two source queues without reordering either queue.
|
/// Merges two source queues without reordering either queue.
|
||||||
/// At each step, compare only the current head from each source and dequeue the winner.
|
/// At each step, compare only the current head from each source and dequeue the winner.
|
||||||
|
|||||||
@@ -57,8 +57,18 @@ public partial class JellyfinController
|
|||||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
|
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
|
||||||
string playlistId)
|
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)
|
// 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 currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
|
||||||
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
||||||
|
|
||||||
@@ -66,7 +76,10 @@ public partial class JellyfinController
|
|||||||
var requestNeedsGenreMetadata = RequestIncludesField("Genres");
|
var requestNeedsGenreMetadata = RequestIncludesField("Genres");
|
||||||
|
|
||||||
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
|
// 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);
|
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
||||||
|
|
||||||
if (cachedItems != null && cachedItems.Count > 0 &&
|
if (cachedItems != null && cachedItems.Count > 0 &&
|
||||||
@@ -110,7 +123,7 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check file cache as fallback
|
// Check file cache as fallback
|
||||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName, playlistScopeUserId, playlistScopeId);
|
||||||
if (fileItems != null && fileItems.Count > 0 &&
|
if (fileItems != null && fileItems.Count > 0 &&
|
||||||
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems))
|
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems))
|
||||||
{
|
{
|
||||||
@@ -147,14 +160,63 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
// 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);
|
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||||
|
|
||||||
if (orderedTracks == null || orderedTracks.Count == 0)
|
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);
|
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}",
|
_logger.LogDebug("Using {Count} ordered matched tracks for {Playlist}",
|
||||||
@@ -162,11 +224,10 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
||||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||||
var userId = _settings.UserId;
|
|
||||||
if (string.IsNullOrEmpty(userId))
|
if (string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
_logger.LogError(
|
_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
|
return null; // Fall back to legacy mode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,7 +298,7 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get the full playlist from Spotify to know the correct order
|
// 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)
|
if (spotifyTracks.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
|
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
|
||||||
@@ -394,7 +455,7 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Save to file cache for persistence across restarts
|
// 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)
|
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
||||||
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||||
@@ -916,7 +977,7 @@ public partial class JellyfinController
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userId = _settings.UserId;
|
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
|
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
@@ -958,14 +1019,19 @@ public partial class JellyfinController
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||||
/// </summary>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
var cacheDir = "/app/cache/spotify";
|
var cacheDir = "/app/cache/spotify";
|
||||||
Directory.CreateDirectory(cacheDir);
|
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 filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||||
@@ -983,11 +1049,15 @@ public partial class JellyfinController
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Loads playlist items (raw Jellyfin JSON) from file cache.
|
/// Loads playlist items (raw Jellyfin JSON) from file cache.
|
||||||
/// </summary>
|
/// </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
|
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");
|
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
|
||||||
|
|
||||||
if (!System.IO.File.Exists(filePath))
|
if (!System.IO.File.Exists(filePath))
|
||||||
|
|||||||
@@ -34,15 +34,18 @@ public partial class JellyfinController : ControllerBase
|
|||||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||||
private readonly ScrobblingSettings _scrobblingSettings;
|
private readonly ScrobblingSettings _scrobblingSettings;
|
||||||
private readonly IMusicMetadataService _metadataService;
|
private readonly IMusicMetadataService _metadataService;
|
||||||
|
private readonly ExternalArtistAppearancesService _externalArtistAppearancesService;
|
||||||
private readonly ParallelMetadataService? _parallelMetadataService;
|
private readonly ParallelMetadataService? _parallelMetadataService;
|
||||||
private readonly ILocalLibraryService _localLibraryService;
|
private readonly ILocalLibraryService _localLibraryService;
|
||||||
private readonly IDownloadService _downloadService;
|
private readonly IDownloadService _downloadService;
|
||||||
private readonly JellyfinResponseBuilder _responseBuilder;
|
private readonly JellyfinResponseBuilder _responseBuilder;
|
||||||
private readonly JellyfinModelMapper _modelMapper;
|
private readonly JellyfinModelMapper _modelMapper;
|
||||||
private readonly JellyfinProxyService _proxyService;
|
private readonly JellyfinProxyService _proxyService;
|
||||||
|
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
|
||||||
private readonly JellyfinSessionManager _sessionManager;
|
private readonly JellyfinSessionManager _sessionManager;
|
||||||
private readonly PlaylistSyncService? _playlistSyncService;
|
private readonly PlaylistSyncService? _playlistSyncService;
|
||||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||||
|
private readonly SpotifyTrackMatchingService? _spotifyTrackMatchingService;
|
||||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||||
private readonly LyricsPlusService? _lyricsPlusService;
|
private readonly LyricsPlusService? _lyricsPlusService;
|
||||||
private readonly LrclibService? _lrclibService;
|
private readonly LrclibService? _lrclibService;
|
||||||
@@ -60,11 +63,13 @@ public partial class JellyfinController : ControllerBase
|
|||||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||||
IOptions<ScrobblingSettings> scrobblingSettings,
|
IOptions<ScrobblingSettings> scrobblingSettings,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
|
ExternalArtistAppearancesService externalArtistAppearancesService,
|
||||||
ILocalLibraryService localLibraryService,
|
ILocalLibraryService localLibraryService,
|
||||||
IDownloadService downloadService,
|
IDownloadService downloadService,
|
||||||
JellyfinResponseBuilder responseBuilder,
|
JellyfinResponseBuilder responseBuilder,
|
||||||
JellyfinModelMapper modelMapper,
|
JellyfinModelMapper modelMapper,
|
||||||
JellyfinProxyService proxyService,
|
JellyfinProxyService proxyService,
|
||||||
|
JellyfinUserContextResolver jellyfinUserContextResolver,
|
||||||
JellyfinSessionManager sessionManager,
|
JellyfinSessionManager sessionManager,
|
||||||
OdesliService odesliService,
|
OdesliService odesliService,
|
||||||
RedisCacheService cache,
|
RedisCacheService cache,
|
||||||
@@ -73,6 +78,7 @@ public partial class JellyfinController : ControllerBase
|
|||||||
ParallelMetadataService? parallelMetadataService = null,
|
ParallelMetadataService? parallelMetadataService = null,
|
||||||
PlaylistSyncService? playlistSyncService = null,
|
PlaylistSyncService? playlistSyncService = null,
|
||||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||||
|
SpotifyTrackMatchingService? spotifyTrackMatchingService = null,
|
||||||
SpotifyLyricsService? spotifyLyricsService = null,
|
SpotifyLyricsService? spotifyLyricsService = null,
|
||||||
LyricsPlusService? lyricsPlusService = null,
|
LyricsPlusService? lyricsPlusService = null,
|
||||||
LrclibService? lrclibService = null,
|
LrclibService? lrclibService = null,
|
||||||
@@ -85,15 +91,18 @@ public partial class JellyfinController : ControllerBase
|
|||||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||||
_scrobblingSettings = scrobblingSettings.Value;
|
_scrobblingSettings = scrobblingSettings.Value;
|
||||||
_metadataService = metadataService;
|
_metadataService = metadataService;
|
||||||
|
_externalArtistAppearancesService = externalArtistAppearancesService;
|
||||||
_parallelMetadataService = parallelMetadataService;
|
_parallelMetadataService = parallelMetadataService;
|
||||||
_localLibraryService = localLibraryService;
|
_localLibraryService = localLibraryService;
|
||||||
_downloadService = downloadService;
|
_downloadService = downloadService;
|
||||||
_responseBuilder = responseBuilder;
|
_responseBuilder = responseBuilder;
|
||||||
_modelMapper = modelMapper;
|
_modelMapper = modelMapper;
|
||||||
_proxyService = proxyService;
|
_proxyService = proxyService;
|
||||||
|
_jellyfinUserContextResolver = jellyfinUserContextResolver;
|
||||||
_sessionManager = sessionManager;
|
_sessionManager = sessionManager;
|
||||||
_playlistSyncService = playlistSyncService;
|
_playlistSyncService = playlistSyncService;
|
||||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||||
|
_spotifyTrackMatchingService = spotifyTrackMatchingService;
|
||||||
_spotifyLyricsService = spotifyLyricsService;
|
_spotifyLyricsService = spotifyLyricsService;
|
||||||
_lyricsPlusService = lyricsPlusService;
|
_lyricsPlusService = lyricsPlusService;
|
||||||
_lrclibService = lrclibService;
|
_lrclibService = lrclibService;
|
||||||
@@ -290,6 +299,75 @@ public partial class JellyfinController : ControllerBase
|
|||||||
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
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()
|
private int GetRequestedStartIndex()
|
||||||
{
|
{
|
||||||
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
|
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
|
||||||
@@ -1748,7 +1826,10 @@ public partial class JellyfinController : ControllerBase
|
|||||||
// Search through each playlist's matched tracks cache
|
// Search through each playlist's matched tracks cache
|
||||||
foreach (var playlist in playlists)
|
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);
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
|
||||||
|
|
||||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ using allstarr.Services.Common;
|
|||||||
using allstarr.Services.Admin;
|
using allstarr.Services.Admin;
|
||||||
using allstarr.Services;
|
using allstarr.Services;
|
||||||
using allstarr.Filters;
|
using allstarr.Filters;
|
||||||
|
using allstarr.Services.Jellyfin;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
@@ -27,6 +28,7 @@ public class PlaylistController : ControllerBase
|
|||||||
private readonly HttpClient _jellyfinHttpClient;
|
private readonly HttpClient _jellyfinHttpClient;
|
||||||
private readonly AdminHelperService _helperService;
|
private readonly AdminHelperService _helperService;
|
||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
|
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
|
||||||
private const string CacheDirectory = "/app/cache/spotify";
|
private const string CacheDirectory = "/app/cache/spotify";
|
||||||
|
|
||||||
public PlaylistController(
|
public PlaylistController(
|
||||||
@@ -39,6 +41,7 @@ public class PlaylistController : ControllerBase
|
|||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
AdminHelperService helperService,
|
AdminHelperService helperService,
|
||||||
IServiceProvider serviceProvider,
|
IServiceProvider serviceProvider,
|
||||||
|
JellyfinUserContextResolver jellyfinUserContextResolver,
|
||||||
SpotifyTrackMatchingService? matchingService = null)
|
SpotifyTrackMatchingService? matchingService = null)
|
||||||
{
|
{
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
@@ -51,6 +54,23 @@ public class PlaylistController : ControllerBase
|
|||||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||||
_helperService = helperService;
|
_helperService = helperService;
|
||||||
_serviceProvider = serviceProvider;
|
_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")]
|
[HttpGet("playlists")]
|
||||||
@@ -149,7 +169,7 @@ public class PlaylistController : ControllerBase
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||||
spotifyTrackCount = spotifyTracks.Count;
|
spotifyTrackCount = spotifyTracks.Count;
|
||||||
playlistInfo["trackCount"] = spotifyTrackCount;
|
playlistInfo["trackCount"] = spotifyTrackCount;
|
||||||
_logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name);
|
_logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name);
|
||||||
@@ -167,7 +187,10 @@ public class PlaylistController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Try to use the pre-built playlist cache
|
// 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;
|
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||||
try
|
try
|
||||||
@@ -239,7 +262,7 @@ public class PlaylistController : ControllerBase
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No playlist cache - calculate from global mappings as fallback
|
// 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 localCount = 0;
|
||||||
var externalCount = 0;
|
var externalCount = 0;
|
||||||
var missingCount = 0;
|
var missingCount = 0;
|
||||||
@@ -291,7 +314,7 @@ public class PlaylistController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Jellyfin requires UserId parameter to fetch playlist items
|
// 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 no user configured, try to get the first user
|
||||||
if (string.IsNullOrEmpty(userId))
|
if (string.IsNullOrEmpty(userId))
|
||||||
@@ -330,10 +353,13 @@ public class PlaylistController : ControllerBase
|
|||||||
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
||||||
{
|
{
|
||||||
// Get Spotify tracks to match against
|
// 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!)
|
// 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;
|
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||||
try
|
try
|
||||||
@@ -438,7 +464,10 @@ public class PlaylistController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Get matched external tracks cache once
|
// 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 matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
var matchedSpotifyIds = new HashSet<string>(
|
var matchedSpotifyIds = new HashSet<string>(
|
||||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||||
@@ -455,7 +484,11 @@ public class PlaylistController : ControllerBase
|
|||||||
var hasExternalMapping = false;
|
var hasExternalMapping = false;
|
||||||
|
|
||||||
// FIRST: Check for manual Jellyfin mapping
|
// 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);
|
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
@@ -466,7 +499,11 @@ public class PlaylistController : ControllerBase
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Check for external manual mapping
|
// 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);
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
@@ -592,16 +629,22 @@ public class PlaylistController : ControllerBase
|
|||||||
public async Task<IActionResult> GetPlaylistTracks(string name)
|
public async Task<IActionResult> GetPlaylistTracks(string name)
|
||||||
{
|
{
|
||||||
var decodedName = Uri.UnescapeDataString(name);
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
|
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||||
|
var playlistScopeUserId = playlistConfig?.UserId;
|
||||||
|
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||||
|
|
||||||
// Get Spotify tracks
|
// Get Spotify tracks
|
||||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName, playlistScopeUserId, playlistConfig?.JellyfinId);
|
||||||
|
|
||||||
var tracksWithStatus = new List<object>();
|
var tracksWithStatus = new List<object>();
|
||||||
var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase);
|
var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||||
|
decodedName,
|
||||||
|
playlistScopeUserId,
|
||||||
|
playlistScopeId);
|
||||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||||
|
|
||||||
if (matchedTracks != null)
|
if (matchedTracks != null)
|
||||||
@@ -627,7 +670,10 @@ public class PlaylistController : ControllerBase
|
|||||||
|
|
||||||
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
|
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
|
||||||
// This cache includes all matched tracks with proper provider IDs
|
// 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;
|
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||||
try
|
try
|
||||||
@@ -948,7 +994,11 @@ public class PlaylistController : ControllerBase
|
|||||||
string? externalProvider = null;
|
string? externalProvider = null;
|
||||||
|
|
||||||
// Check for manual Jellyfin mapping
|
// 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);
|
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
@@ -958,7 +1008,11 @@ public class PlaylistController : ControllerBase
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Check for external manual mapping
|
// 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);
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||||
@@ -1071,10 +1125,16 @@ public class PlaylistController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||||
|
var playlistScopeUserId = playlistConfig?.UserId;
|
||||||
|
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||||
await _playlistFetcher.RefreshPlaylistAsync(decodedName);
|
await _playlistFetcher.RefreshPlaylistAsync(decodedName);
|
||||||
|
|
||||||
// Clear playlist stats cache first (so it gets recalculated with fresh data)
|
// 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);
|
await _cache.DeleteAsync(statsCacheKey);
|
||||||
|
|
||||||
// Then invalidate playlist summary cache (will rebuild with fresh stats)
|
// Then invalidate playlist summary cache (will rebuild with fresh stats)
|
||||||
@@ -1109,18 +1169,28 @@ public class PlaylistController : ControllerBase
|
|||||||
|
|
||||||
try
|
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
|
// 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);
|
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
|
||||||
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
|
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
|
||||||
|
|
||||||
// Clear the matched results cache to force re-matching
|
// 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);
|
await _cache.DeleteAsync(matchedTracksKey);
|
||||||
_logger.LogDebug("Cleared matched tracks cache");
|
_logger.LogDebug("Cleared matched tracks cache");
|
||||||
|
|
||||||
// Clear the playlist items cache
|
// Clear the playlist items cache
|
||||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||||
|
decodedName,
|
||||||
|
playlistScopeUserId,
|
||||||
|
playlistScopeId);
|
||||||
await _cache.DeleteAsync(playlistItemsCacheKey);
|
await _cache.DeleteAsync(playlistItemsCacheKey);
|
||||||
_logger.LogDebug("Cleared playlist items cache");
|
_logger.LogDebug("Cleared playlist items cache");
|
||||||
|
|
||||||
@@ -1131,7 +1201,10 @@ public class PlaylistController : ControllerBase
|
|||||||
_helperService.InvalidatePlaylistSummaryCache();
|
_helperService.InvalidatePlaylistSummaryCache();
|
||||||
|
|
||||||
// Clear playlist stats cache to force recalculation from new mappings
|
// 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);
|
await _cache.DeleteAsync(statsCacheKey);
|
||||||
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
|
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
|
||||||
|
|
||||||
@@ -1196,7 +1269,7 @@ public class PlaylistController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userId = _jellyfinSettings.UserId;
|
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
|
||||||
|
|
||||||
// Build URL with UserId if available
|
// Build URL with UserId if available
|
||||||
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
||||||
@@ -1328,7 +1401,7 @@ public class PlaylistController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userId = _jellyfinSettings.UserId;
|
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
|
||||||
|
|
||||||
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
@@ -1424,13 +1497,20 @@ public class PlaylistController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||||
|
var playlistScopeUserId = playlistConfig?.UserId;
|
||||||
|
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||||
string? normalizedProvider = null;
|
string? normalizedProvider = null;
|
||||||
string? normalizedExternalId = null;
|
string? normalizedExternalId = null;
|
||||||
|
|
||||||
if (hasJellyfinMapping)
|
if (hasJellyfinMapping)
|
||||||
{
|
{
|
||||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
// 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!);
|
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||||
|
|
||||||
// Also save to file for persistence across restarts
|
// Also save to file for persistence across restarts
|
||||||
@@ -1442,7 +1522,11 @@ public class PlaylistController : ControllerBase
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
// 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
|
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||||
normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!);
|
normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!);
|
||||||
var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId };
|
var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId };
|
||||||
@@ -1482,10 +1566,22 @@ public class PlaylistController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Clear all related caches to force rebuild
|
// Clear all related caches to force rebuild
|
||||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
var matchedCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
decodedName,
|
||||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
playlistScopeUserId,
|
||||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
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(matchedCacheKey);
|
||||||
await _cache.DeleteAsync(orderedCacheKey);
|
await _cache.DeleteAsync(orderedCacheKey);
|
||||||
|
|||||||
@@ -357,9 +357,9 @@ public class SpotifyAdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
var keys = new[]
|
var keys = new[]
|
||||||
{
|
{
|
||||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name)
|
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var key in keys)
|
foreach (var key in keys)
|
||||||
|
|||||||
@@ -79,6 +79,29 @@ public class TrackMetadataRequest
|
|||||||
public int? DurationMs { get; set; }
|
public int? DurationMs { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
public class SquidWtfEndpointHealthResponse
|
||||||
/// Request model for updating configuration
|
{
|
||||||
/// </summary>
|
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>
|
/// <summary>
|
||||||
/// Gets the playlist configuration by name.
|
/// Gets the playlist configuration by name.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
|
public SpotifyPlaylistConfig? GetPlaylistByName(string name, string? userId = null, string? jellyfinPlaylistId = null)
|
||||||
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
{
|
||||||
|
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>
|
/// <summary>
|
||||||
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
|
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
|
||||||
|
|||||||
+4
-5
@@ -528,6 +528,7 @@ else
|
|||||||
|
|
||||||
// Business services - shared across backends
|
// Business services - shared across backends
|
||||||
builder.Services.AddSingleton(squidWtfEndpointCatalog);
|
builder.Services.AddSingleton(squidWtfEndpointCatalog);
|
||||||
|
builder.Services.AddMemoryCache(); // L1 in-memory tier for RedisCacheService
|
||||||
builder.Services.AddSingleton<RedisCacheService>();
|
builder.Services.AddSingleton<RedisCacheService>();
|
||||||
builder.Services.AddSingleton<FavoritesMigrationService>();
|
builder.Services.AddSingleton<FavoritesMigrationService>();
|
||||||
builder.Services.AddSingleton<OdesliService>();
|
builder.Services.AddSingleton<OdesliService>();
|
||||||
@@ -540,6 +541,8 @@ if (backendType == BackendType.Jellyfin)
|
|||||||
// Jellyfin services
|
// Jellyfin services
|
||||||
builder.Services.AddSingleton<JellyfinResponseBuilder>();
|
builder.Services.AddSingleton<JellyfinResponseBuilder>();
|
||||||
builder.Services.AddSingleton<JellyfinModelMapper>();
|
builder.Services.AddSingleton<JellyfinModelMapper>();
|
||||||
|
builder.Services.AddSingleton<ExternalArtistAppearancesService>();
|
||||||
|
builder.Services.AddScoped<JellyfinUserContextResolver>();
|
||||||
builder.Services.AddScoped<JellyfinProxyService>();
|
builder.Services.AddScoped<JellyfinProxyService>();
|
||||||
builder.Services.AddSingleton<JellyfinSessionManager>();
|
builder.Services.AddSingleton<JellyfinSessionManager>();
|
||||||
builder.Services.AddScoped<JellyfinAuthFilter>();
|
builder.Services.AddScoped<JellyfinAuthFilter>();
|
||||||
@@ -965,11 +968,7 @@ if (app.Environment.IsDevelopment())
|
|||||||
app.UseSwaggerUI();
|
app.UseSwaggerUI();
|
||||||
}
|
}
|
||||||
|
|
||||||
// The admin UI is documented and intended to be reachable directly over HTTP on port 5275.
|
app.UseHttpsRedirection();
|
||||||
// Keep HTTPS redirection for non-admin traffic only.
|
|
||||||
app.UseWhen(
|
|
||||||
context => context.Connection.LocalPort != 5275,
|
|
||||||
branch => branch.UseHttpsRedirection());
|
|
||||||
|
|
||||||
// Serve static files only on admin port (5275)
|
// Serve static files only on admin port (5275)
|
||||||
app.UseMiddleware<allstarr.Middleware.AdminNetworkAllowlistMiddleware>();
|
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.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Primitives;
|
using Microsoft.Extensions.Primitives;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace allstarr.Services.Common;
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
@@ -9,6 +10,10 @@ namespace allstarr.Services.Common;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public static class AuthHeaderHelper
|
public static class AuthHeaderHelper
|
||||||
{
|
{
|
||||||
|
private static readonly Regex AuthParameterRegex = new(
|
||||||
|
@"(?<key>[A-Za-z0-9_-]+)\s*=\s*""(?<value>[^""]*)""",
|
||||||
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Forwards authentication headers from HTTP request to HttpRequestMessage.
|
/// Forwards authentication headers from HTTP request to HttpRequestMessage.
|
||||||
/// Handles both X-Emby-Authorization and Authorization headers.
|
/// Handles both X-Emby-Authorization and Authorization headers.
|
||||||
@@ -99,17 +104,7 @@ public static class AuthHeaderHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static string? ExtractDeviceIdFromAuthString(string authValue)
|
private static string? ExtractDeviceIdFromAuthString(string authValue)
|
||||||
{
|
{
|
||||||
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(
|
return ExtractAuthParameter(authValue, "DeviceId");
|
||||||
authValue,
|
|
||||||
@"DeviceId=""([^""]+)""",
|
|
||||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
|
||||||
|
|
||||||
if (deviceIdMatch.Success)
|
|
||||||
{
|
|
||||||
return deviceIdMatch.Groups[1].Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -140,16 +135,95 @@ public static class AuthHeaderHelper
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private static string? ExtractClientNameFromAuthString(string authValue)
|
private static string? ExtractClientNameFromAuthString(string authValue)
|
||||||
{
|
{
|
||||||
var clientMatch = System.Text.RegularExpressions.Regex.Match(
|
return ExtractAuthParameter(authValue, "Client");
|
||||||
authValue,
|
}
|
||||||
@"Client=""([^""]+)""",
|
|
||||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
/// <summary>
|
||||||
|
/// Extracts the authenticated Jellyfin access token from request headers.
|
||||||
if (clientMatch.Success)
|
/// 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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -67,34 +67,52 @@ public static class CacheKeyBuilder
|
|||||||
|
|
||||||
#region Spotify Keys
|
#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()
|
public static string BuildSpotifyPlaylistStatsPattern()
|
||||||
@@ -102,19 +120,27 @@ public static class CacheKeyBuilder
|
|||||||
return "spotify:playlist:stats:*";
|
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)
|
public static string BuildSpotifyGlobalMappingKey(string spotifyId)
|
||||||
|
|||||||
@@ -1,27 +1,57 @@
|
|||||||
|
using Microsoft.Extensions.Caching.Memory;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Serialization;
|
||||||
using StackExchange.Redis;
|
using StackExchange.Redis;
|
||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Text.Json.Serialization;
|
||||||
|
using System.Text.Json.Serialization.Metadata;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace allstarr.Services.Common;
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public class RedisCacheService
|
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 RedisSettings _settings;
|
||||||
private readonly ILogger<RedisCacheService> _logger;
|
private readonly ILogger<RedisCacheService> _logger;
|
||||||
|
private readonly IMemoryCache _memoryCache;
|
||||||
|
private readonly ConcurrentDictionary<string, byte> _memoryKeys = new(StringComparer.Ordinal);
|
||||||
private IConnectionMultiplexer? _redis;
|
private IConnectionMultiplexer? _redis;
|
||||||
private IDatabase? _db;
|
private IDatabase? _db;
|
||||||
private readonly object _lock = new();
|
private readonly object _lock = new();
|
||||||
|
|
||||||
public RedisCacheService(
|
public RedisCacheService(
|
||||||
IOptions<RedisSettings> settings,
|
IOptions<RedisSettings> settings,
|
||||||
ILogger<RedisCacheService> logger)
|
ILogger<RedisCacheService> logger,
|
||||||
|
IMemoryCache memoryCache)
|
||||||
{
|
{
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
|
_memoryCache = memoryCache;
|
||||||
|
|
||||||
if (_settings.Enabled)
|
if (_settings.Enabled)
|
||||||
{
|
{
|
||||||
@@ -48,23 +78,145 @@ public class RedisCacheService
|
|||||||
public bool IsEnabled => _settings.Enabled && _db != null;
|
public bool IsEnabled => _settings.Enabled && _db != null;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
|
// L2: Fall back to Redis
|
||||||
var value = await _db!.StringGetAsync(key);
|
var value = await _db!.StringGetAsync(key);
|
||||||
|
|
||||||
if (value.HasValue)
|
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
|
else
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Redis cache MISS: {Key}", key);
|
_logger.LogDebug("Cache MISS: {Key}", key);
|
||||||
}
|
}
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
@@ -77,15 +229,17 @@ public class RedisCacheService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets a cached value and deserializes it.
|
/// 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>
|
/// </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);
|
var json = await GetStringAsync(key);
|
||||||
if (string.IsNullOrEmpty(json)) return null;
|
if (string.IsNullOrEmpty(json)) return null;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
return JsonSerializer.Deserialize<T>(json);
|
return DeserializeWithFallback<T>(json, key);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -96,11 +250,20 @@ public class RedisCacheService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets a cached value with TTL.
|
/// Sets a cached value with TTL.
|
||||||
|
/// Writes to both L1 memory cache and L2 Redis.
|
||||||
/// </summary>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
return await SetStringInternalAsync(key, value, expiry);
|
return await SetStringInternalAsync(key, value, expiry);
|
||||||
@@ -197,12 +360,14 @@ public class RedisCacheService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets a cached value by serializing it with TTL.
|
/// 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>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
var json = JsonSerializer.Serialize(value);
|
var json = SerializeWithFallback(value, key);
|
||||||
return await SetStringAsync(key, json, expiry);
|
return await SetStringAsync(key, json, expiry);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -212,13 +377,80 @@ public class RedisCacheService
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private T? DeserializeWithFallback<T>(string json, string key) where T : class
|
||||||
/// Deletes a cached value.
|
|
||||||
/// </summary>
|
|
||||||
public async Task<bool> DeleteAsync(string key)
|
|
||||||
{
|
{
|
||||||
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
|
try
|
||||||
{
|
{
|
||||||
return await _db!.KeyDeleteAsync(key);
|
return await _db!.KeyDeleteAsync(key);
|
||||||
@@ -233,10 +465,20 @@ public class RedisCacheService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Checks if a key exists.
|
/// Checks if a key exists.
|
||||||
/// </summary>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
return await _db!.KeyExistsAsync(key);
|
return await _db!.KeyExistsAsync(key);
|
||||||
@@ -271,10 +513,16 @@ public class RedisCacheService
|
|||||||
/// Deletes all keys matching a pattern (e.g., "search:*").
|
/// Deletes all keys matching a pattern (e.g., "search:*").
|
||||||
/// WARNING: Use with caution as this scans all keys.
|
/// WARNING: Use with caution as this scans all keys.
|
||||||
/// </summary>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
var server = _redis!.GetServer(_redis.GetEndPoints().First());
|
var server = _redis!.GetServer(_redis.GetEndPoints().First());
|
||||||
@@ -282,18 +530,22 @@ public class RedisCacheService
|
|||||||
|
|
||||||
if (keys.Length == 0)
|
if (keys.Length == 0)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
|
_logger.LogDebug("No Redis keys found matching pattern: {Pattern}", pattern);
|
||||||
return 0;
|
return memoryDeleted;
|
||||||
}
|
}
|
||||||
|
|
||||||
var deleted = await _db!.KeyDeleteAsync(keys);
|
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;
|
return (int)deleted;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
_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)
|
private void LogEndpointFailure(string baseUrl, Exception ex, bool willRetry)
|
||||||
{
|
{
|
||||||
var message = BuildFailureSummary(ex);
|
var message = BuildFailureSummary(ex);
|
||||||
|
var isTimeoutOrCancellation = ex is TaskCanceledException or OperationCanceledException;
|
||||||
|
var verb = isTimeoutOrCancellation ? "request timed out" : "request failed";
|
||||||
|
|
||||||
if (willRetry)
|
if (willRetry)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("{Service} request failed at {Endpoint}: {Error}. Trying next...",
|
_logger.LogWarning("{Service} {Verb} at {Endpoint}: {Error}. Trying next...",
|
||||||
_serviceName, baseUrl, message);
|
_serviceName, verb, baseUrl, message);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogError("{Service} request failed at {Endpoint}: {Error}",
|
_logger.LogError("{Service} {Verb} at {Endpoint}: {Error}",
|
||||||
_serviceName, baseUrl, message);
|
_serviceName, verb, baseUrl, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
|
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
|
||||||
@@ -466,6 +468,16 @@ public class RoundRobinFallbackHelper
|
|||||||
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
|
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ex is TaskCanceledException)
|
||||||
|
{
|
||||||
|
return "Timed out";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ex is OperationCanceledException)
|
||||||
|
{
|
||||||
|
return "Canceled";
|
||||||
|
}
|
||||||
|
|
||||||
return ex.Message;
|
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 SpotifyTrackMatchingService _matchingService;
|
||||||
private readonly SpotifyImportSettings _spotifyImportSettings;
|
private readonly SpotifyImportSettings _spotifyImportSettings;
|
||||||
private readonly ILogger<VersionUpgradeRebuildService> _logger;
|
private readonly ILogger<VersionUpgradeRebuildService> _logger;
|
||||||
private CancellationTokenSource? _backgroundRebuildCts;
|
|
||||||
private Task? _backgroundRebuildTask;
|
|
||||||
|
|
||||||
public VersionUpgradeRebuildService(
|
public VersionUpgradeRebuildService(
|
||||||
SpotifyTrackMatchingService matchingService,
|
SpotifyTrackMatchingService matchingService,
|
||||||
@@ -55,12 +53,15 @@ public class VersionUpgradeRebuildService : IHostedService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogInformation("Triggering full rebuild for all playlists after version upgrade");
|
||||||
"Scheduling full rebuild for all playlists in background after version upgrade");
|
try
|
||||||
|
{
|
||||||
_backgroundRebuildCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
await _matchingService.TriggerRebuildAllAsync();
|
||||||
_backgroundRebuildTask = RunBackgroundRebuildAsync(currentVersion, _backgroundRebuildCts.Token);
|
}
|
||||||
return;
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to trigger auto rebuild after version upgrade");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -75,51 +76,7 @@ public class VersionUpgradeRebuildService : IHostedService
|
|||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken)
|
public Task StopAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return StopBackgroundRebuildAsync(cancellationToken);
|
return Task.CompletedTask;
|
||||||
}
|
|
||||||
|
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<string?> ReadPreviousVersionAsync(CancellationToken cancellationToken)
|
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 HttpClient _httpClient;
|
||||||
private readonly JellyfinSettings _settings;
|
private readonly JellyfinSettings _settings;
|
||||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||||
|
private readonly JellyfinUserContextResolver _userContextResolver;
|
||||||
private readonly ILogger<JellyfinProxyService> _logger;
|
private readonly ILogger<JellyfinProxyService> _logger;
|
||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
private string? _cachedMusicLibraryId;
|
private string? _cachedMusicLibraryId;
|
||||||
@@ -36,16 +37,35 @@ public class JellyfinProxyService
|
|||||||
IHttpClientFactory httpClientFactory,
|
IHttpClientFactory httpClientFactory,
|
||||||
IOptions<JellyfinSettings> settings,
|
IOptions<JellyfinSettings> settings,
|
||||||
IHttpContextAccessor httpContextAccessor,
|
IHttpContextAccessor httpContextAccessor,
|
||||||
|
JellyfinUserContextResolver userContextResolver,
|
||||||
ILogger<JellyfinProxyService> logger,
|
ILogger<JellyfinProxyService> logger,
|
||||||
RedisCacheService cache)
|
RedisCacheService cache)
|
||||||
{
|
{
|
||||||
_httpClient = httpClientFactory.CreateClient(HttpClientName);
|
_httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||||
_settings = settings.Value;
|
_settings = settings.Value;
|
||||||
_httpContextAccessor = httpContextAccessor;
|
_httpContextAccessor = httpContextAccessor;
|
||||||
|
_userContextResolver = userContextResolver;
|
||||||
_logger = logger;
|
_logger = logger;
|
||||||
_cache = cache;
|
_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>
|
/// <summary>
|
||||||
/// Gets the music library ID, auto-detecting it if not configured.
|
/// Gets the music library ID, auto-detecting it if not configured.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -191,14 +211,10 @@ public class JellyfinProxyService
|
|||||||
{
|
{
|
||||||
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
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;
|
var statusCode = (int)response.StatusCode;
|
||||||
|
|
||||||
// Always parse the response, even for errors
|
|
||||||
// The caller needs to see 401s so the client can re-authenticate
|
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
if (!isBrowserStaticRequest && !isPublicEndpoint)
|
if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||||
@@ -207,23 +223,22 @@ public class JellyfinProxyService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse error response to pass through to client
|
// Try to parse error response to pass through to client
|
||||||
if (!string.IsNullOrWhiteSpace(content))
|
try
|
||||||
{
|
{
|
||||||
try
|
await using var errorStream = await response.Content.ReadAsStreamAsync();
|
||||||
{
|
var errorDoc = await JsonDocument.ParseAsync(errorStream);
|
||||||
var errorDoc = JsonDocument.Parse(content);
|
return (errorDoc, statusCode);
|
||||||
return (errorDoc, statusCode);
|
}
|
||||||
}
|
catch (JsonException)
|
||||||
catch
|
{
|
||||||
{
|
// Not valid JSON, return null
|
||||||
// Not valid JSON, return null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return (null, statusCode);
|
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(
|
private HttpRequestMessage CreateClientGetRequest(
|
||||||
@@ -552,10 +567,7 @@ public class JellyfinProxyService
|
|||||||
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds"
|
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||||
{
|
|
||||||
queryParams["userId"] = _settings.UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Note: We don't force parentId here - let clients specify which library to search
|
// 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
|
// 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"
|
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds,ParentId"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||||
{
|
|
||||||
queryParams["userId"] = _settings.UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(parentId))
|
if (!string.IsNullOrEmpty(parentId))
|
||||||
{
|
{
|
||||||
@@ -647,10 +656,7 @@ public class JellyfinProxyService
|
|||||||
{
|
{
|
||||||
var queryParams = new Dictionary<string, string>();
|
var queryParams = new Dictionary<string, string>();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||||
{
|
|
||||||
queryParams["userId"] = _settings.UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders);
|
return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders);
|
||||||
}
|
}
|
||||||
@@ -669,10 +675,7 @@ public class JellyfinProxyService
|
|||||||
["fields"] = "PrimaryImageAspectRatio,Genres,Overview"
|
["fields"] = "PrimaryImageAspectRatio,Genres,Overview"
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||||
{
|
|
||||||
queryParams["userId"] = _settings.UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(searchTerm))
|
if (!string.IsNullOrEmpty(searchTerm))
|
||||||
{
|
{
|
||||||
@@ -699,10 +702,7 @@ public class JellyfinProxyService
|
|||||||
{
|
{
|
||||||
var queryParams = new Dictionary<string, string>();
|
var queryParams = new Dictionary<string, string>();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||||
{
|
|
||||||
queryParams["userId"] = _settings.UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to get by ID first
|
// Try to get by ID first
|
||||||
if (Guid.TryParse(artistIdOrName, out _))
|
if (Guid.TryParse(artistIdOrName, out _))
|
||||||
@@ -893,10 +893,7 @@ public class JellyfinProxyService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var queryParams = new Dictionary<string, string>();
|
var queryParams = new Dictionary<string, string>();
|
||||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
await AddResolvedUserIdAsync(queryParams);
|
||||||
{
|
|
||||||
queryParams["userId"] = _settings.UserId;
|
|
||||||
}
|
|
||||||
|
|
||||||
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
|
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
|
||||||
if (result == null)
|
if (result == null)
|
||||||
@@ -1017,12 +1014,12 @@ public class JellyfinProxyService
|
|||||||
|
|
||||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
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 statusCode = (int)response.StatusCode;
|
||||||
var content = await response.Content.ReadAsStringAsync();
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
|
var content = await response.Content.ReadAsStringAsync();
|
||||||
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
|
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
|
||||||
statusCode, url, content);
|
statusCode, url, content);
|
||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
@@ -1030,12 +1027,13 @@ public class JellyfinProxyService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var jsonDocument = JsonDocument.Parse(content);
|
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||||
|
var jsonDocument = await JsonDocument.ParseAsync(stream);
|
||||||
return (jsonDocument, statusCode);
|
return (jsonDocument, statusCode);
|
||||||
}
|
}
|
||||||
catch (JsonException ex)
|
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);
|
return (null, statusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Net.WebSockets;
|
using System.Net.WebSockets;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
using System.Text.Json;
|
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Models.Jellyfin;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Serialization;
|
||||||
|
|
||||||
namespace allstarr.Services.Jellyfin;
|
namespace allstarr.Services.Jellyfin;
|
||||||
|
|
||||||
@@ -185,21 +186,21 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
|
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||||
{
|
{
|
||||||
var capabilities = new
|
var capabilities = new JellyfinSessionCapabilitiesPayload
|
||||||
{
|
{
|
||||||
PlayableMediaTypes = new[] { "Audio" },
|
PlayableMediaTypes = ["Audio"],
|
||||||
SupportedCommands = new[]
|
SupportedCommands =
|
||||||
{
|
[
|
||||||
"Play",
|
"Play",
|
||||||
"Playstate",
|
"Playstate",
|
||||||
"PlayNext"
|
"PlayNext"
|
||||||
},
|
],
|
||||||
SupportsMediaControl = true,
|
SupportsMediaControl = true,
|
||||||
SupportsPersistentIdentifier = true,
|
SupportsPersistentIdentifier = true,
|
||||||
SupportsSync = false
|
SupportsSync = false
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(capabilities);
|
var json = AllstarrJsonSerializer.Serialize(capabilities);
|
||||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
|
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
|
||||||
|
|
||||||
if (statusCode == 204 || statusCode == 200)
|
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)
|
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
|
||||||
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
|
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
|
||||||
{
|
{
|
||||||
var stopPayload = new
|
var stopPayload = new JellyfinPlaybackStatePayload
|
||||||
{
|
{
|
||||||
ItemId = session.LastPlayingItemId,
|
ItemId = session.LastPlayingItemId,
|
||||||
PositionTicks = session.LastPlayingPositionTicks ?? 0
|
PositionTicks = session.LastPlayingPositionTicks ?? 0
|
||||||
};
|
};
|
||||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
var stopJson = AllstarrJsonSerializer.Serialize(stopPayload);
|
||||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||||
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
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)}";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1026,7 +1026,26 @@ public class SpotifyApiClient : IDisposable
|
|||||||
continue;
|
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
|
// Log if we couldn't find track count for debugging
|
||||||
if (trackCount == 0)
|
if (trackCount == 0)
|
||||||
@@ -1038,9 +1057,7 @@ public class SpotifyApiClient : IDisposable
|
|||||||
// Get owner name
|
// Get owner name
|
||||||
string? ownerName = null;
|
string? ownerName = null;
|
||||||
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
if (playlist.TryGetProperty("ownerV2", out var ownerV2) &&
|
||||||
ownerV2.ValueKind == JsonValueKind.Object &&
|
|
||||||
ownerV2.TryGetProperty("data", out var ownerData) &&
|
ownerV2.TryGetProperty("data", out var ownerData) &&
|
||||||
ownerData.ValueKind == JsonValueKind.Object &&
|
|
||||||
ownerData.TryGetProperty("username", out var ownerNameProp))
|
ownerData.TryGetProperty("username", out var ownerNameProp))
|
||||||
{
|
{
|
||||||
ownerName = ownerNameProp.GetString();
|
ownerName = ownerNameProp.GetString();
|
||||||
@@ -1049,14 +1066,11 @@ public class SpotifyApiClient : IDisposable
|
|||||||
// Get image URL
|
// Get image URL
|
||||||
string? imageUrl = null;
|
string? imageUrl = null;
|
||||||
if (playlist.TryGetProperty("images", out var images) &&
|
if (playlist.TryGetProperty("images", out var images) &&
|
||||||
images.ValueKind == JsonValueKind.Object &&
|
|
||||||
images.TryGetProperty("items", out var imageItems) &&
|
images.TryGetProperty("items", out var imageItems) &&
|
||||||
imageItems.ValueKind == JsonValueKind.Array &&
|
|
||||||
imageItems.GetArrayLength() > 0)
|
imageItems.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
var firstImage = imageItems[0];
|
var firstImage = imageItems[0];
|
||||||
if (firstImage.TryGetProperty("sources", out var sources) &&
|
if (firstImage.TryGetProperty("sources", out var sources) &&
|
||||||
sources.ValueKind == JsonValueKind.Array &&
|
|
||||||
sources.GetArrayLength() > 0)
|
sources.GetArrayLength() > 0)
|
||||||
{
|
{
|
||||||
var firstSource = sources[0];
|
var firstSource = sources[0];
|
||||||
@@ -1151,68 +1165,6 @@ public class SpotifyApiClient : IDisposable
|
|||||||
return null;
|
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)
|
private static DateTime? ParseSpotifyDateElement(JsonElement value)
|
||||||
{
|
{
|
||||||
switch (value.ValueKind)
|
switch (value.ValueKind)
|
||||||
@@ -1286,40 +1238,6 @@ public class SpotifyApiClient : IDisposable
|
|||||||
return null;
|
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)
|
private static DateTime? ParseSpotifyUnixTimestamp(long value)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
private readonly RedisCacheService _cache;
|
private readonly RedisCacheService _cache;
|
||||||
|
|
||||||
// Track Spotify playlist IDs after discovery
|
// Track Spotify playlist IDs after discovery
|
||||||
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
|
private readonly Dictionary<string, string> _playlistScopeToSpotifyId = new();
|
||||||
|
|
||||||
public SpotifyPlaylistFetcher(
|
public SpotifyPlaylistFetcher(
|
||||||
ILogger<SpotifyPlaylistFetcher> logger,
|
ILogger<SpotifyPlaylistFetcher> logger,
|
||||||
@@ -55,10 +55,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
/// <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>
|
/// <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 = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
_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
|
// Try Redis cache first
|
||||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||||
@@ -124,14 +134,14 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Try to use cached or configured Spotify playlist ID
|
// 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
|
// Check if we have a configured Spotify ID for this playlist
|
||||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
||||||
{
|
{
|
||||||
// Use the configured Spotify playlist ID directly
|
// Use the configured Spotify playlist ID directly
|
||||||
spotifyId = playlistConfig.Id;
|
spotifyId = playlistConfig.Id;
|
||||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
|
||||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -150,7 +160,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
spotifyId = exactMatch.SpotifyId;
|
spotifyId = exactMatch.SpotifyId;
|
||||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
|
||||||
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
|
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,7 +236,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
string playlistName,
|
string playlistName,
|
||||||
HashSet<string> jellyfinTrackIds)
|
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
|
// Filter to only tracks not in Jellyfin, preserving order
|
||||||
return allTracks
|
return allTracks
|
||||||
@@ -237,17 +248,30 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Manual trigger to refresh a specific playlist.
|
/// Manual trigger to refresh a specific playlist.
|
||||||
/// </summary>
|
/// </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);
|
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
|
||||||
|
|
||||||
// Clear cache to force refresh
|
// 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);
|
await _cache.DeleteAsync(cacheKey);
|
||||||
|
|
||||||
// Re-fetch
|
// Re-fetch
|
||||||
await GetPlaylistTracksAsync(playlistName);
|
await GetPlaylistTracksAsync(playlistName, playlistScopeUserId, playlistConfig?.JellyfinId ?? jellyfinPlaylistId);
|
||||||
await ClearPlaylistImageCacheAsync(playlistName);
|
await ClearPlaylistImageCacheAsync(playlistName, userId, jellyfinPlaylistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -259,13 +283,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
foreach (var config in _spotifyImportSettings.Playlists)
|
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))
|
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
@@ -331,7 +362,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
{
|
{
|
||||||
// Check each playlist to see if it needs refreshing based on cron schedule
|
// Check each playlist to see if it needs refreshing based on cron schedule
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var needsRefresh = new List<string>();
|
var needsRefresh = new List<SpotifyPlaylistConfig>();
|
||||||
|
|
||||||
foreach (var config in _spotifyImportSettings.Playlists)
|
foreach (var config in _spotifyImportSettings.Playlists)
|
||||||
{
|
{
|
||||||
@@ -342,7 +373,10 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
var cron = CronExpression.Parse(schedule);
|
var cron = CronExpression.Parse(schedule);
|
||||||
|
|
||||||
// Check if we have cached data
|
// 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);
|
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||||
|
|
||||||
if (cached != null)
|
if (cached != null)
|
||||||
@@ -352,7 +386,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
|
|
||||||
if (nextRun.HasValue && now >= nextRun.Value)
|
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}",
|
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
|
||||||
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
|
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
|
||||||
}
|
}
|
||||||
@@ -360,7 +394,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
else
|
else
|
||||||
{
|
{
|
||||||
// No cache, fetch it
|
// No cache, fetch it
|
||||||
needsRefresh.Add(config.Name);
|
needsRefresh.Add(config);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -374,24 +408,24 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
|||||||
{
|
{
|
||||||
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
|
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
|
||||||
|
|
||||||
foreach (var playlistName in needsRefresh)
|
foreach (var config in needsRefresh)
|
||||||
{
|
{
|
||||||
if (stoppingToken.IsCancellationRequested) break;
|
if (stoppingToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await GetPlaylistTracksAsync(playlistName);
|
await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||||
|
|
||||||
// Rate limiting between playlists
|
// 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);
|
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
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
|
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);
|
_logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
||||||
|
|
||||||
// Log sample of track order for debugging
|
// Log sample of track order for debugging
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using allstarr.Models.Domain;
|
using allstarr.Models.Domain;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Models.Spotify;
|
using allstarr.Models.Spotify;
|
||||||
|
using allstarr.Services.Admin;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using allstarr.Services.Jellyfin;
|
using allstarr.Services.Jellyfin;
|
||||||
using Microsoft.AspNetCore.Http;
|
using Microsoft.AspNetCore.Http;
|
||||||
@@ -38,7 +39,6 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
private readonly IServiceProvider _serviceProvider;
|
private readonly IServiceProvider _serviceProvider;
|
||||||
private const int DelayBetweenSearchesMs = 150; // 150ms = ~6.6 searches/second to avoid rate limiting
|
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 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
|
// Track last run time per playlist to prevent duplicate runs
|
||||||
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
private readonly Dictionary<string, DateTime> _lastRunTimes = new();
|
||||||
@@ -73,6 +73,24 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
return true;
|
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)
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("========================================");
|
_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.
|
// Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var schedulerReference = now.AddMinutes(-1);
|
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)
|
foreach (var playlist in _spotifySettings.Playlists)
|
||||||
{
|
{
|
||||||
@@ -135,7 +153,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
if (nextRun.HasValue)
|
if (nextRun.HasValue)
|
||||||
{
|
{
|
||||||
nextRuns.Add((playlist.Name, nextRun.Value, cron));
|
nextRuns.Add((playlist, nextRun.Value, cron));
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -170,7 +188,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var waitTime = nextPlaylist.NextRun - now;
|
var waitTime = nextPlaylist.NextRun - now;
|
||||||
|
|
||||||
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
_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 maxWait = TimeSpan.FromHours(1);
|
||||||
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
||||||
@@ -191,10 +209,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
|
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.Playlist.Name);
|
||||||
|
|
||||||
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||||
due.PlaylistName,
|
due.Playlist,
|
||||||
stoppingToken,
|
stoppingToken,
|
||||||
trigger: "cron");
|
trigger: "cron");
|
||||||
|
|
||||||
@@ -205,7 +223,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC",
|
_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.
|
// 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).
|
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
|
||||||
/// Used by individual per-playlist rebuild actions.
|
/// Used by individual per-playlist rebuild actions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
private async Task RebuildSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playlist = _spotifySettings.Playlists
|
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
|
||||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
var playlistScopeId = GetPlaylistScopeId(playlist);
|
||||||
|
var playlistName = playlist.Name;
|
||||||
if (playlist == null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
|
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
|
||||||
|
|
||||||
// Clear cache for this playlist (same as "Rebuild All Remote" button)
|
// Clear cache for this playlist (same as "Rebuild All Remote" button)
|
||||||
var keysToDelete = new[]
|
var keysToDelete = new[]
|
||||||
{
|
{
|
||||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||||
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name), // Legacy key
|
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||||
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name),
|
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||||
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name)
|
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name, playlistScopeUserId, playlistScopeId)
|
||||||
};
|
};
|
||||||
|
|
||||||
foreach (var key in keysToDelete)
|
foreach (var key in keysToDelete)
|
||||||
@@ -269,7 +282,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
if (playlistFetcher != null)
|
if (playlistFetcher != null)
|
||||||
{
|
{
|
||||||
// Force refresh from Spotify (clears cache and re-fetches)
|
// 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
|
// Use new direct API mode with ISRC support
|
||||||
await MatchPlaylistTracksWithIsrcAsync(
|
await MatchPlaylistTracksWithIsrcAsync(
|
||||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
playlist, playlistFetcher, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fall back to legacy mode
|
// Fall back to legacy mode
|
||||||
await MatchPlaylistTracksLegacyAsync(
|
await MatchPlaylistTracksLegacyAsync(
|
||||||
playlist.Name, metadataService, cancellationToken);
|
playlist, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -304,16 +317,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
|
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
|
||||||
/// Used for lightweight re-matching when only local library has changed.
|
/// Used for lightweight re-matching when only local library has changed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
private async Task MatchSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playlist = _spotifySettings.Playlists
|
var playlistName = playlist.Name;
|
||||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (playlist == null)
|
|
||||||
{
|
|
||||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||||
@@ -331,13 +337,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
// Use new direct API mode with ISRC support
|
// Use new direct API mode with ISRC support
|
||||||
await MatchPlaylistTracksWithIsrcAsync(
|
await MatchPlaylistTracksWithIsrcAsync(
|
||||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
playlist, playlistFetcher, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
// Fall back to legacy mode
|
// Fall back to legacy mode
|
||||||
await MatchPlaylistTracksLegacyAsync(
|
await MatchPlaylistTracksLegacyAsync(
|
||||||
playlist.Name, metadataService, cancellationToken);
|
playlist, metadataService, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ClearPlaylistImageCacheAsync(playlist);
|
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).
|
/// 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.
|
/// This clears caches, fetches fresh data, and re-matches everything immediately.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task TriggerRebuildAllAsync(CancellationToken cancellationToken = default)
|
public async Task TriggerRebuildAllAsync()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Full rebuild triggered for all playlists");
|
_logger.LogInformation("Manual full rebuild triggered for all playlists");
|
||||||
await RebuildAllPlaylistsAsync(cancellationToken);
|
await RebuildAllPlaylistsAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
|
/// 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.
|
/// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
|
||||||
/// </summary>
|
/// </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);
|
_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(
|
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||||
playlistName,
|
playlist,
|
||||||
CancellationToken.None,
|
CancellationToken.None,
|
||||||
trigger: "manual");
|
trigger: "manual");
|
||||||
|
|
||||||
if (!rebuilt)
|
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 timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||||
var remaining = _minimumRunInterval - timeSinceLastRun;
|
var remaining = _minimumRunInterval - timeSinceLastRun;
|
||||||
@@ -400,11 +417,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
|
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||||
string playlistName,
|
SpotifyPlaylistConfig playlist,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string trigger)
|
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;
|
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||||
if (timeSinceLastRun < _minimumRunInterval)
|
if (timeSinceLastRun < _minimumRunInterval)
|
||||||
@@ -412,15 +430,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
_logger.LogWarning(
|
_logger.LogWarning(
|
||||||
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||||
trigger,
|
trigger,
|
||||||
playlistName,
|
playlist.Name,
|
||||||
(int)timeSinceLastRun.TotalSeconds,
|
(int)timeSinceLastRun.TotalSeconds,
|
||||||
(int)_minimumRunInterval.TotalSeconds);
|
(int)_minimumRunInterval.TotalSeconds);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
|
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
|
||||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
_lastRunTimes[runKey] = DateTime.UtcNow;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -440,14 +458,23 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
|
/// 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.
|
/// Use this when only the local library has changed, not when Spotify playlist changed.
|
||||||
/// </summary>
|
/// </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);
|
_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
|
// Intentionally no cooldown here: this path should react immediately to
|
||||||
// local library changes and manual mapping updates without waiting for
|
// local library changes and manual mapping updates without waiting for
|
||||||
// Spotify API cooldown windows.
|
// Spotify API cooldown windows.
|
||||||
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
await MatchSinglePlaylistAsync(playlist, CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
|
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||||
@@ -467,7 +494,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken);
|
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -495,7 +522,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
await MatchSinglePlaylistAsync(playlist, cancellationToken);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -513,15 +540,25 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// Uses GREEDY ASSIGNMENT to maximize total matches.
|
/// Uses GREEDY ASSIGNMENT to maximize total matches.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task MatchPlaylistTracksWithIsrcAsync(
|
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||||
string playlistName,
|
SpotifyPlaylistConfig playlistConfig,
|
||||||
SpotifyPlaylistFetcher playlistFetcher,
|
SpotifyPlaylistFetcher playlistFetcher,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
CancellationToken cancellationToken)
|
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
|
// 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)
|
if (spotifyTracks.Count == 0)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
|
_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
|
// 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();
|
HashSet<string> existingSpotifyIds = new();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
if (!string.IsNullOrEmpty(playlist.JellyfinId))
|
||||||
{
|
{
|
||||||
// Get existing tracks from Jellyfin playlist to avoid re-matching
|
// Get existing tracks from Jellyfin playlist to avoid re-matching
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
@@ -546,8 +581,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||||
var userId = jellyfinSettings.UserId;
|
var userId = playlist.UserId ?? jellyfinSettings.UserId;
|
||||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
var jellyfinPlaylistId = playlist.JellyfinId;
|
||||||
|
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
|
||||||
var queryParams = new Dictionary<string, string>();
|
var queryParams = new Dictionary<string, string>();
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
@@ -629,10 +665,18 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
foreach (var track in tracksToMatch)
|
foreach (var track in tracksToMatch)
|
||||||
{
|
{
|
||||||
// Check if this track has a manual mapping but isn't in the cached results
|
// 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 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 externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
|
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)
|
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
|
||||||
var jellyfinTracks = new List<Song>();
|
var jellyfinTracks = new List<Song>();
|
||||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
if (!string.IsNullOrEmpty(playlist.JellyfinId))
|
||||||
{
|
{
|
||||||
using var scope = _serviceProvider.CreateScope();
|
using var scope = _serviceProvider.CreateScope();
|
||||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||||
@@ -671,8 +715,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
var userId = jellyfinSettings.UserId;
|
var userId = playlist.UserId ?? jellyfinSettings.UserId;
|
||||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
var jellyfinPlaylistId = playlist.JellyfinId;
|
||||||
|
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
|
||||||
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
|
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
|
||||||
if (!string.IsNullOrEmpty(userId))
|
if (!string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
@@ -774,28 +819,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
if (cancellationToken.IsCancellationRequested) break;
|
if (cancellationToken.IsCancellationRequested) break;
|
||||||
|
|
||||||
var batch = unmatchedSpotifyTracks.Skip(i).Take(BatchSize).ToList();
|
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 batchTasks = batch.Select(async spotifyTrack =>
|
||||||
{
|
{
|
||||||
var primaryArtist = spotifyTrack.PrimaryArtist;
|
|
||||||
var trackStopwatch = System.Diagnostics.Stopwatch.StartNew();
|
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
||||||
timeoutCts.CancelAfter(ExternalProviderSearchTimeout);
|
|
||||||
var trackCancellationToken = timeoutCts.Token;
|
|
||||||
|
|
||||||
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
var candidates = new List<(Song Song, double Score, string MatchType)>();
|
||||||
|
|
||||||
// Check global external mapping first
|
// Check global external mapping first
|
||||||
@@ -807,23 +835,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
|
if (!string.IsNullOrEmpty(globalMapping.ExternalProvider) &&
|
||||||
!string.IsNullOrEmpty(globalMapping.ExternalId))
|
!string.IsNullOrEmpty(globalMapping.ExternalId))
|
||||||
{
|
{
|
||||||
mappedSong = await metadataService.GetSongAsync(
|
mappedSong = await metadataService.GetSongAsync(globalMapping.ExternalProvider, globalMapping.ExternalId);
|
||||||
globalMapping.ExternalProvider,
|
|
||||||
globalMapping.ExternalId,
|
|
||||||
trackCancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mappedSong != null)
|
if (mappedSong != null)
|
||||||
{
|
{
|
||||||
candidates.Add((mappedSong, 100.0, "global-mapping-external"));
|
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);
|
return (spotifyTrack, candidates);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -831,31 +848,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
// Try ISRC match
|
// Try ISRC match
|
||||||
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
if (_spotifyApiSettings.PreferIsrcMatching && !string.IsNullOrEmpty(spotifyTrack.Isrc))
|
||||||
{
|
{
|
||||||
try
|
var isrcSong = await TryMatchByIsrcAsync(spotifyTrack.Isrc, metadataService);
|
||||||
|
if (isrcSong != null)
|
||||||
{
|
{
|
||||||
var isrcSong = await TryMatchByIsrcAsync(
|
candidates.Add((isrcSong, 100.0, "isrc"));
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -863,8 +859,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
var fuzzySongs = await TryMatchByFuzzyMultipleAsync(
|
||||||
spotifyTrack.Title,
|
spotifyTrack.Title,
|
||||||
spotifyTrack.Artists,
|
spotifyTrack.Artists,
|
||||||
metadataService,
|
metadataService);
|
||||||
trackCancellationToken);
|
|
||||||
|
|
||||||
foreach (var (song, score) in fuzzySongs)
|
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);
|
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)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(
|
_logger.LogError(ex, "Failed to match track: {Title}", spotifyTrack.Title);
|
||||||
ex,
|
|
||||||
"Failed to match track for {Playlist} track #{Position}: {Title} by {Artist}",
|
|
||||||
playlistName,
|
|
||||||
spotifyTrack.Position,
|
|
||||||
spotifyTrack.Title,
|
|
||||||
primaryArtist);
|
|
||||||
return (spotifyTrack, new List<(Song, double, string)>());
|
return (spotifyTrack, new List<(Song, double, string)>());
|
||||||
}
|
}
|
||||||
}).ToList();
|
}).ToList();
|
||||||
|
|
||||||
var batchResults = await Task.WhenAll(batchTasks);
|
var batchResults = await Task.WhenAll(batchTasks);
|
||||||
batchStopwatch.Stop();
|
|
||||||
|
|
||||||
foreach (var result in batchResults)
|
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)
|
if (i + BatchSize < unmatchedSpotifyTracks.Count)
|
||||||
{
|
{
|
||||||
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
await Task.Delay(DelayBetweenSearchesMs, cancellationToken);
|
||||||
@@ -1035,19 +988,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
["missing"] = statsMissingCount
|
["missing"] = statsMissingCount
|
||||||
};
|
};
|
||||||
|
|
||||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlistName);
|
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
|
||||||
|
playlistName,
|
||||||
|
playlistScopeUserId,
|
||||||
|
playlistScopeId);
|
||||||
await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30));
|
await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30));
|
||||||
|
|
||||||
_logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing",
|
_logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing",
|
||||||
playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
|
playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
|
||||||
|
|
||||||
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
// 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
|
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
|
||||||
|
|
||||||
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
|
if (!string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -1074,10 +1027,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
|
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
|
||||||
|
|
||||||
// Save matched tracks to file for persistence across restarts
|
// 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
|
// 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();
|
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||||
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
||||||
|
|
||||||
@@ -1087,7 +1043,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
// Pre-build playlist items cache for instant serving
|
// Pre-build playlist items cache for instant serving
|
||||||
// This is what makes the UI show all matched tracks at once
|
// 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
|
else
|
||||||
{
|
{
|
||||||
@@ -1107,136 +1063,140 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
private async Task<List<(Song Song, double Score)>> TryMatchByFuzzyMultipleAsync(
|
||||||
string title,
|
string title,
|
||||||
List<string> artists,
|
List<string> artists,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService)
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var primaryArtist = artists.FirstOrDefault() ?? "";
|
try
|
||||||
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
|
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
|
try
|
||||||
var searchParams = new Dictionary<string, string>
|
|
||||||
{
|
{
|
||||||
["searchTerm"] = query,
|
// Search Jellyfin for local tracks
|
||||||
["includeItemTypes"] = "Audio",
|
var searchParams = new Dictionary<string, string>
|
||||||
["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())
|
|
||||||
{
|
{
|
||||||
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
|
["searchTerm"] = query,
|
||||||
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
["includeItemTypes"] = "Audio",
|
||||||
var artist = "";
|
["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() ?? "";
|
var id = item.TryGetProperty("Id", out var idEl) ? idEl.GetString() ?? "" : "";
|
||||||
}
|
var songTitle = item.TryGetProperty("Name", out var nameEl) ? nameEl.GetString() ?? "" : "";
|
||||||
else if (item.TryGetProperty("AlbumArtist", out var albumArtistEl))
|
var artist = "";
|
||||||
{
|
|
||||||
artist = albumArtistEl.GetString() ?? "";
|
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,
|
// Score local results
|
||||||
Title = songTitle,
|
var scoredLocal = localResults
|
||||||
Artist = artist,
|
.Select(song => new
|
||||||
IsLocal = true
|
{
|
||||||
});
|
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)
|
allCandidates.AddRange(scoredLocal);
|
||||||
{
|
|
||||||
// Score local results
|
// If we found good local matches, return them (don't search external)
|
||||||
var scoredLocal = localResults
|
if (scoredLocal.Any(x => x.TotalScore >= 70))
|
||||||
.Select(song => new
|
|
||||||
{
|
{
|
||||||
Song = song,
|
_logger.LogDebug("Found {Count} local matches for '{Title}', skipping external search",
|
||||||
TitleScore = FuzzyMatcher.CalculateSimilarityAggressive(title, song.Title),
|
scoredLocal.Count, title);
|
||||||
ArtistScore = FuzzyMatcher.CalculateArtistMatchScore(artists, song.Artist, song.Contributors)
|
return allCandidates;
|
||||||
})
|
}
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
catch
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
var scoredExternal = externalResults
|
return new List<(Song, double)>();
|
||||||
.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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private double CalculateMatchScore(string jellyfinTitle, string jellyfinArtist, string spotifyTitle, string spotifyArtist)
|
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.
|
/// Attempts to match a track by ISRC.
|
||||||
/// SEARCHES LOCAL FIRST, then external if no local match found.
|
/// SEARCHES LOCAL FIRST, then external if no local match found.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<Song?> TryMatchByIsrcAsync(
|
private async Task<Song?> TryMatchByIsrcAsync(string isrc, IMusicMetadataService metadataService)
|
||||||
string isrc,
|
|
||||||
IMusicMetadataService metadataService,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
// STEP 1: Search LOCAL Jellyfin library FIRST by ISRC
|
try
|
||||||
// Note: Jellyfin doesn't have ISRC search, so we skip local ISRC search
|
{
|
||||||
// Local tracks will be found via fuzzy matching instead
|
// 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);
|
||||||
// STEP 2: Search EXTERNAL by ISRC
|
}
|
||||||
return await metadataService.FindSongByIsrcAsync(isrc, cancellationToken);
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -1352,12 +1314,21 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
|
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task MatchPlaylistTracksLegacyAsync(
|
private async Task MatchPlaylistTracksLegacyAsync(
|
||||||
string playlistName,
|
SpotifyPlaylistConfig playlistConfig,
|
||||||
IMusicMetadataService metadataService,
|
IMusicMetadataService metadataService,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
|
var playlistName = playlistConfig.Name;
|
||||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
|
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
|
// Check if we already have matched tracks cached
|
||||||
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
||||||
@@ -1464,6 +1435,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
try
|
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);
|
_logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
||||||
@@ -1484,7 +1459,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var userId = jellyfinSettings.UserId;
|
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
|
||||||
if (string.IsNullOrEmpty(userId))
|
if (string.IsNullOrEmpty(userId))
|
||||||
{
|
{
|
||||||
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||||
@@ -1560,7 +1535,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
string? matchedKey = null;
|
string? matchedKey = null;
|
||||||
|
|
||||||
// FIRST: Check for manual Jellyfin mapping
|
// 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);
|
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||||
@@ -1640,7 +1619,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SECOND: Check for external manual mapping
|
// 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);
|
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
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)
|
// 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);
|
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||||
|
|
||||||
// Save to file cache for persistence
|
// Save to file cache for persistence
|
||||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
await SavePlaylistItemsToFileAsync(playlistName, finalItems, playlistScopeUserId, playlistScopeId);
|
||||||
|
|
||||||
var manualMappingInfo = "";
|
var manualMappingInfo = "";
|
||||||
if (manualExternalCount > 0)
|
if (manualExternalCount > 0)
|
||||||
@@ -1957,14 +1943,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves playlist items to file cache for persistence across restarts.
|
/// Saves playlist items to file cache for persistence across restarts.
|
||||||
/// </summary>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
var cacheDir = "/app/cache/spotify";
|
var cacheDir = "/app/cache/spotify";
|
||||||
Directory.CreateDirectory(cacheDir);
|
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 filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||||
@@ -1981,14 +1972,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Saves matched tracks to file cache for persistence across restarts.
|
/// Saves matched tracks to file cache for persistence across restarts.
|
||||||
/// </summary>
|
/// </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
|
try
|
||||||
{
|
{
|
||||||
var cacheDir = "/app/cache/spotify";
|
var cacheDir = "/app/cache/spotify";
|
||||||
Directory.CreateDirectory(cacheDir);
|
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 filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||||
|
|
||||||
var temp = new SearchResult
|
var temp = new SearchResult
|
||||||
{
|
{
|
||||||
Songs = await songsTask,
|
Songs = await songsTask,
|
||||||
Albums = await albumsTask,
|
Albums = await albumsTask,
|
||||||
|
|||||||
+102
-74
@@ -12,8 +12,8 @@
|
|||||||
<!-- Restart Required Banner -->
|
<!-- Restart Required Banner -->
|
||||||
<div class="restart-banner" id="restart-banner">
|
<div class="restart-banner" id="restart-banner">
|
||||||
⚠️ Configuration changed. Restart required to apply changes.
|
⚠️ Configuration changed. Restart required to apply changes.
|
||||||
<button data-action="restartContainer">Restart Allstarr</button>
|
<button onclick="restartContainer()">Restart Allstarr</button>
|
||||||
<button data-action="dismissRestartBanner"
|
<button onclick="dismissRestartBanner()"
|
||||||
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -42,57 +42,44 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="container hidden" id="main-container">
|
<div class="container" id="main-container" style="display:none;">
|
||||||
<div class="app-shell">
|
<header>
|
||||||
<aside class="sidebar" aria-label="Admin navigation">
|
<h1>
|
||||||
<div class="sidebar-brand">
|
Allstarr <span class="version" id="version">Loading...</span>
|
||||||
<div class="sidebar-title">Allstarr</div>
|
</h1>
|
||||||
<div class="sidebar-subtitle" id="sidebar-version">Loading...</div>
|
<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>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<button id="auth-logout-btn" onclick="logoutAdminSession()" style="display:none;">Logout</button>
|
||||||
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
|
<div id="status-indicator">
|
||||||
<button class="sidebar-link" type="button" data-tab="jellyfin-playlists">Link Playlists</button>
|
<span class="status-badge" id="spotify-status">
|
||||||
<button class="sidebar-link" type="button" data-tab="playlists">Injected Playlists</button>
|
<span class="status-dot"></span>
|
||||||
<button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button>
|
<span>Loading...</span>
|
||||||
<button class="sidebar-link" type="button" data-tab="scrobbling">Scrobbling</button>
|
</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
<main class="app-main">
|
<div class="tabs">
|
||||||
<header class="app-header">
|
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||||
<h1>
|
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||||
Allstarr <span class="version" id="version">Loading...</span>
|
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||||
</h1>
|
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||||
<div class="header-actions">
|
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
||||||
<div id="status-indicator">
|
<div class="tab" data-tab="config">Configuration</div>
|
||||||
<span class="status-badge" id="spotify-status">
|
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||||
<span class="status-dot"></span>
|
</div>
|
||||||
<span>Loading...</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<!-- Dashboard Tab -->
|
<!-- Dashboard Tab -->
|
||||||
<div class="tab-content active" id="tab-dashboard">
|
<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="grid">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Spotify API</h2>
|
<h2>Spotify API</h2>
|
||||||
@@ -141,9 +128,9 @@
|
|||||||
</h2>
|
</h2>
|
||||||
<div id="dashboard-guidance" class="guidance-stack"></div>
|
<div id="dashboard-guidance" class="guidance-stack"></div>
|
||||||
<div class="card-actions-row">
|
<div class="card-actions-row">
|
||||||
<button class="primary" data-action="refreshPlaylists">Refresh All Playlists</button>
|
<button class="primary" onclick="refreshPlaylists()">Refresh All Playlists</button>
|
||||||
<button data-action="clearCache">Clear Cache</button>
|
<button onclick="clearCache()">Clear Cache</button>
|
||||||
<button data-action="openAddPlaylist">Add Playlist</button>
|
<button onclick="openAddPlaylist()">Add Playlist</button>
|
||||||
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
|
<button onclick="window.location.href='/spotify-mappings.html'">View Spotify Mappings</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -158,7 +145,7 @@
|
|||||||
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
<button onclick="fetchJellyfinPlaylists()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</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
|
Connect your Jellyfin playlists to Spotify playlists. Allstarr will automatically fill in missing
|
||||||
tracks from Spotify using your preferred music service (SquidWTF/Deezer/Qobuz).
|
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
|
<br><strong>Tip:</strong> Use the sp_dc cookie method for best results - it's simpler and more
|
||||||
@@ -166,9 +153,10 @@
|
|||||||
</p>
|
</p>
|
||||||
<div id="jellyfin-guidance" class="guidance-stack"></div>
|
<div id="jellyfin-guidance" class="guidance-stack"></div>
|
||||||
|
|
||||||
<div id="jellyfin-user-filter" class="flex-row-wrap mb-16">
|
<div id="jellyfin-user-filter" style="display: flex; gap: 16px; margin-bottom: 16px; flex-wrap: wrap;">
|
||||||
<div class="form-group jellyfin-user-form-group">
|
<div class="form-group" style="margin: 0; flex: 1; min-width: 200px;">
|
||||||
<label class="text-secondary">User</label>
|
<label
|
||||||
|
style="display: block; margin-bottom: 4px; color: var(--text-secondary); font-size: 0.85rem;">User</label>
|
||||||
<select id="jellyfin-user-select" onchange="fetchJellyfinPlaylists()"
|
<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);">
|
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>
|
<option value="">All Users</option>
|
||||||
@@ -244,7 +232,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</details>
|
</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
|
These are the Spotify playlists currently being injected into Jellyfin with tracks from your music
|
||||||
service.
|
service.
|
||||||
</p>
|
</p>
|
||||||
@@ -280,14 +268,15 @@
|
|||||||
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
|
Manual mappings override automatic matching for external providers (SquidWTF, Deezer, Qobuz). For
|
||||||
local Jellyfin tracks, use the Spotify Import plugin instead.
|
local Jellyfin tracks, use the Spotify Import plugin instead.
|
||||||
</p>
|
</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>
|
<div>
|
||||||
<span class="summary-label">Total:</span>
|
<span style="color: var(--text-secondary);">Total:</span>
|
||||||
<span class="summary-value" id="mappings-total">0</span>
|
<span style="font-weight: 600; margin-left: 8px;" id="mappings-total">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="summary-label">External:</span>
|
<span style="color: var(--text-secondary);">External:</span>
|
||||||
<span class="summary-value success"
|
<span style="font-weight: 600; margin-left: 8px; color: var(--success);"
|
||||||
id="mappings-external">0</span>
|
id="mappings-external">0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,14 +309,15 @@
|
|||||||
<button onclick="fetchMissingTracks()">Refresh</button>
|
<button onclick="fetchMissingTracks()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</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
|
Tracks that couldn't be matched locally or externally. Map them manually to add them to your
|
||||||
playlists.
|
playlists.
|
||||||
</p>
|
</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>
|
<div>
|
||||||
<span class="summary-label">Total Missing:</span>
|
<span style="color: var(--text-secondary);">Total Missing:</span>
|
||||||
<span class="summary-value warning"
|
<span style="font-weight: 600; margin-left: 8px; color: var(--warning);"
|
||||||
id="missing-total">0</span>
|
id="missing-total">0</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -358,23 +348,23 @@
|
|||||||
<h2>
|
<h2>
|
||||||
Kept Downloads
|
Kept Downloads
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
<button onclick="downloadAllKept()" class="primary">Download All</button>
|
<button onclick="downloadAllKept()" style="background:var(--accent);border-color:var(--accent);">Download All</button>
|
||||||
<button onclick="deleteAllKept()" class="danger">Delete All</button>
|
|
||||||
<button onclick="fetchDownloads()">Refresh</button>
|
<button onclick="fetchDownloads()">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
</h2>
|
</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.
|
Downloaded files stored permanently. Download individual tracks or download all as a zip archive.
|
||||||
</p>
|
</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>
|
<div>
|
||||||
<span class="summary-label">Total Files:</span>
|
<span style="color: var(--text-secondary);">Total Files:</span>
|
||||||
<span class="summary-value accent"
|
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);"
|
||||||
id="downloads-count">0</span>
|
id="downloads-count">0</span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<span class="summary-label">Total Size:</span>
|
<span style="color: var(--text-secondary);">Total Size:</span>
|
||||||
<span class="summary-value accent" id="downloads-size">0
|
<span style="font-weight: 600; margin-left: 8px; color: var(--accent);" id="downloads-size">0
|
||||||
B</span>
|
B</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -883,6 +873,46 @@
|
|||||||
|
|
||||||
<!-- API Analytics Tab -->
|
<!-- API Analytics Tab -->
|
||||||
<div class="tab-content" id="tab-endpoints">
|
<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">
|
<div class="card">
|
||||||
<h2>
|
<h2>
|
||||||
API Endpoint Usage
|
API Endpoint Usage
|
||||||
@@ -982,8 +1012,6 @@
|
|||||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Add Playlist Modal -->
|
<!-- 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() {
|
export async function fetchConfig() {
|
||||||
return requestJson(
|
return requestJson(
|
||||||
"/api/admin/config",
|
"/api/admin/config",
|
||||||
@@ -152,15 +144,10 @@ export async function fetchJellyfinUsers() {
|
|||||||
return requestOptionalJson("/api/admin/jellyfin/users");
|
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";
|
let url = "/api/admin/jellyfin/playlists";
|
||||||
const params = [];
|
|
||||||
if (userId) {
|
if (userId) {
|
||||||
params.push("userId=" + encodeURIComponent(userId));
|
url += "?userId=" + encodeURIComponent(userId);
|
||||||
}
|
|
||||||
params.push("includeStats=" + String(Boolean(includeStats)));
|
|
||||||
if (params.length > 0) {
|
|
||||||
url += "?" + params.join("&");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return requestJson(url, {}, "Failed to fetch Jellyfin playlists");
|
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() {
|
export async function fetchScrobblingStatus() {
|
||||||
return requestJson(
|
return requestJson(
|
||||||
"/api/admin/scrobbling/status",
|
"/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 API from "./api.js";
|
||||||
import * as UI from "./ui.js";
|
import * as UI from "./ui.js";
|
||||||
import { renderCookieAge } from "./settings-editor.js";
|
import { renderCookieAge } from "./settings-editor.js";
|
||||||
@@ -15,7 +15,6 @@ let onCookieNeedsInit = async () => {};
|
|||||||
let setCurrentConfigState = () => {};
|
let setCurrentConfigState = () => {};
|
||||||
let syncConfigUiExtras = () => {};
|
let syncConfigUiExtras = () => {};
|
||||||
let loadScrobblingConfig = () => {};
|
let loadScrobblingConfig = () => {};
|
||||||
let jellyfinPlaylistRequestToken = 0;
|
|
||||||
|
|
||||||
async function fetchStatus() {
|
async function fetchStatus() {
|
||||||
try {
|
try {
|
||||||
@@ -130,7 +129,6 @@ async function fetchMissingTracks() {
|
|||||||
missing.forEach((t) => {
|
missing.forEach((t) => {
|
||||||
missingTracks.push({
|
missingTracks.push({
|
||||||
playlist: playlist.name,
|
playlist: playlist.name,
|
||||||
provider: t.externalProvider || t.provider || "squidwtf",
|
|
||||||
...t,
|
...t,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -153,7 +151,6 @@ async function fetchMissingTracks() {
|
|||||||
const artist =
|
const artist =
|
||||||
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
|
t.artists && t.artists.length > 0 ? t.artists.join(", ") : "";
|
||||||
const searchQuery = `${t.title} ${artist}`;
|
const searchQuery = `${t.title} ${artist}`;
|
||||||
const provider = t.provider || "squidwtf";
|
|
||||||
const trackPosition = Number.isFinite(t.position)
|
const trackPosition = Number.isFinite(t.position)
|
||||||
? Number(t.position)
|
? Number(t.position)
|
||||||
: 0;
|
: 0;
|
||||||
@@ -166,7 +163,7 @@ async function fetchMissingTracks() {
|
|||||||
<td class="mapping-actions-cell">
|
<td class="mapping-actions-cell">
|
||||||
<button class="map-action-btn map-action-search missing-track-search-btn"
|
<button class="map-action-btn map-action-search missing-track-search-btn"
|
||||||
data-query="${escapeHtml(searchQuery)}"
|
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"
|
<button class="map-action-btn map-action-local missing-track-local-btn"
|
||||||
data-playlist="${escapeHtml(t.playlist)}"
|
data-playlist="${escapeHtml(t.playlist)}"
|
||||||
data-position="${trackPosition}"
|
data-position="${trackPosition}"
|
||||||
@@ -216,9 +213,9 @@ async function fetchDownloads() {
|
|||||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||||
<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>
|
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>
|
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -248,28 +245,11 @@ async function fetchJellyfinPlaylists() {
|
|||||||
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
'<tr><td colspan="4" class="loading"><span class="spinner"></span> Loading Jellyfin playlists...</td></tr>';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requestToken = ++jellyfinPlaylistRequestToken;
|
|
||||||
const userId = isAdminSession()
|
const userId = isAdminSession()
|
||||||
? document.getElementById("jellyfin-user-select")?.value
|
? document.getElementById("jellyfin-user-select")?.value
|
||||||
: null;
|
: null;
|
||||||
const baseData = await API.fetchJellyfinPlaylists(userId, false);
|
const data = await API.fetchJellyfinPlaylists(userId);
|
||||||
if (requestToken !== jellyfinPlaylistRequestToken) {
|
UI.updateJellyfinPlaylistsUI(data);
|
||||||
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);
|
|
||||||
});
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch Jellyfin playlists:", error);
|
console.error("Failed to fetch Jellyfin playlists:", error);
|
||||||
tbody.innerHTML =
|
tbody.innerHTML =
|
||||||
@@ -320,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() {
|
function startPlaylistAutoRefresh() {
|
||||||
if (playlistAutoRefreshInterval) {
|
if (playlistAutoRefreshInterval) {
|
||||||
clearInterval(playlistAutoRefreshInterval);
|
clearInterval(playlistAutoRefreshInterval);
|
||||||
@@ -366,10 +373,7 @@ function startDashboardRefresh() {
|
|||||||
fetchPlaylists();
|
fetchPlaylists();
|
||||||
fetchTrackMappings();
|
fetchTrackMappings();
|
||||||
fetchMissingTracks();
|
fetchMissingTracks();
|
||||||
const keptTab = document.getElementById("tab-kept");
|
fetchDownloads();
|
||||||
if (keptTab && keptTab.classList.contains("active")) {
|
|
||||||
fetchDownloads();
|
|
||||||
}
|
|
||||||
|
|
||||||
const endpointsTab = document.getElementById("tab-endpoints");
|
const endpointsTab = document.getElementById("tab-endpoints");
|
||||||
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
||||||
@@ -393,6 +397,11 @@ async function loadDashboardData() {
|
|||||||
fetchEndpointUsage(),
|
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.
|
// Ensure user filter defaults are populated before loading Link Playlists rows.
|
||||||
await fetchJellyfinUsers();
|
await fetchJellyfinUsers();
|
||||||
await fetchJellyfinPlaylists();
|
await fetchJellyfinPlaylists();
|
||||||
@@ -403,6 +412,7 @@ async function loadDashboardData() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
startDashboardRefresh();
|
startDashboardRefresh();
|
||||||
|
startDownloadActivityStream();
|
||||||
}
|
}
|
||||||
|
|
||||||
function startDownloadActivityStream() {
|
function startDownloadActivityStream() {
|
||||||
@@ -551,6 +561,7 @@ export function initDashboardData(options) {
|
|||||||
window.fetchJellyfinUsers = fetchJellyfinUsers;
|
window.fetchJellyfinUsers = fetchJellyfinUsers;
|
||||||
window.fetchEndpointUsage = fetchEndpointUsage;
|
window.fetchEndpointUsage = fetchEndpointUsage;
|
||||||
window.clearEndpointUsage = clearEndpointUsage;
|
window.clearEndpointUsage = clearEndpointUsage;
|
||||||
|
window.fetchSquidWtfEndpointHealth = fetchSquidWtfEndpointHealth;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
stopDashboardRefresh,
|
stopDashboardRefresh,
|
||||||
@@ -562,5 +573,6 @@ export function initDashboardData(options) {
|
|||||||
fetchJellyfinPlaylists,
|
fetchJellyfinPlaylists,
|
||||||
fetchConfig,
|
fetchConfig,
|
||||||
fetchStatus,
|
fetchStatus,
|
||||||
|
fetchSquidWtfEndpointHealth,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,14 +100,14 @@ export async function viewTracks(name) {
|
|||||||
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
const durationSeconds = Math.floor((t.durationMs || 0) / 1000);
|
||||||
const externalSearchLink =
|
const externalSearchLink =
|
||||||
t.isLocal === false && t.searchQuery && t.externalProvider
|
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 =
|
const missingSearchLink =
|
||||||
t.isLocal === null && t.searchQuery
|
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 `
|
return `
|
||||||
<div class="track-item" data-position="${t.position}">
|
<div class="track-item" data-position="${t.position}">
|
||||||
@@ -246,7 +246,7 @@ export async function searchJellyfinTracks() {
|
|||||||
const artist = track.artist || "";
|
const artist = track.artist || "";
|
||||||
const album = track.album || "";
|
const album = track.album || "";
|
||||||
return `
|
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>
|
<div>
|
||||||
<strong>${escapeHtml(title)}</strong>
|
<strong>${escapeHtml(title)}</strong>
|
||||||
<br>
|
<br>
|
||||||
@@ -344,15 +344,7 @@ export async function searchExternalTracks() {
|
|||||||
const externalUrl = track.url || "";
|
const externalUrl = track.url || "";
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="external-result" data-result-index="${index}" data-external-id="${escapeHtml(id)}"
|
<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)}')">
|
||||||
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>
|
<div>
|
||||||
<strong>${escapeHtml(title)}</strong>
|
<strong>${escapeHtml(title)}</strong>
|
||||||
<br>
|
<br>
|
||||||
@@ -670,26 +662,13 @@ export async function saveLyricsMapping() {
|
|||||||
// Search provider (open in new tab)
|
// Search provider (open in new tab)
|
||||||
export async function searchProvider(query, provider) {
|
export async function searchProvider(query, provider) {
|
||||||
try {
|
try {
|
||||||
const normalizedProvider = (provider || "squidwtf").toLowerCase();
|
const data = await API.getSquidWTFBaseUrl();
|
||||||
let searchUrl = "";
|
const baseUrl = data.baseUrl; // Use the actual property name from API
|
||||||
|
const searchUrl = `${baseUrl}/music/search?q=${encodeURIComponent(query)}`;
|
||||||
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)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
window.open(searchUrl, "_blank");
|
window.open(searchUrl, "_blank");
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to open provider search:", error);
|
console.error("Failed to get SquidWTF base URL:", error);
|
||||||
showToast("Failed to open provider search link", "warning");
|
// 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";
|
} from "./playlist-admin.js";
|
||||||
import { initScrobblingAdmin } from "./scrobbling-admin.js";
|
import { initScrobblingAdmin } from "./scrobbling-admin.js";
|
||||||
import { initAuthSession } from "./auth-session.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 cookieDateInitialized = false;
|
||||||
let restartRequired = 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 () {
|
window.showRestartBanner = function () {
|
||||||
restartRequired = true;
|
restartRequired = true;
|
||||||
document.getElementById("restart-banner")?.classList.add("active");
|
document.getElementById("restart-banner")?.classList.add("active");
|
||||||
@@ -54,30 +58,17 @@ window.switchTab = function (tabName) {
|
|||||||
document
|
document
|
||||||
.querySelectorAll(".tab")
|
.querySelectorAll(".tab")
|
||||||
.forEach((tab) => tab.classList.remove("active"));
|
.forEach((tab) => tab.classList.remove("active"));
|
||||||
document
|
|
||||||
.querySelectorAll(".sidebar-link")
|
|
||||||
.forEach((link) => link.classList.remove("active"));
|
|
||||||
document
|
document
|
||||||
.querySelectorAll(".tab-content")
|
.querySelectorAll(".tab-content")
|
||||||
.forEach((content) => content.classList.remove("active"));
|
.forEach((content) => content.classList.remove("active"));
|
||||||
|
|
||||||
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
const tab = document.querySelector(`.tab[data-tab="${tabName}"]`);
|
||||||
const sidebarLink = document.querySelector(
|
|
||||||
`.sidebar-link[data-tab="${tabName}"]`,
|
|
||||||
);
|
|
||||||
const content = document.getElementById(`tab-${tabName}`);
|
const content = document.getElementById(`tab-${tabName}`);
|
||||||
|
|
||||||
if (tab && content) {
|
if (tab && content) {
|
||||||
tab.classList.add("active");
|
tab.classList.add("active");
|
||||||
if (sidebarLink) {
|
|
||||||
sidebarLink.classList.add("active");
|
|
||||||
}
|
|
||||||
content.classList.add("active");
|
content.classList.add("active");
|
||||||
window.location.hash = tabName;
|
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.openManualMap = openManualMap;
|
||||||
window.openExternalMap = openExternalMap;
|
window.openExternalMap = openExternalMap;
|
||||||
window.openMapToLocal = openManualMap;
|
window.openMapToLocal = openManualMap;
|
||||||
window.openMapToExternal = openExternalMap;
|
window.openMapToExternal = openExternalMap;
|
||||||
window.openModal = openModal;
|
|
||||||
window.closeModal = closeModal;
|
|
||||||
window.searchJellyfinTracks = searchJellyfinTracks;
|
window.searchJellyfinTracks = searchJellyfinTracks;
|
||||||
|
window.selectJellyfinTrack = selectJellyfinTrack;
|
||||||
window.saveLocalMapping = saveLocalMapping;
|
window.saveLocalMapping = saveLocalMapping;
|
||||||
window.saveManualMapping = saveManualMapping;
|
window.saveManualMapping = saveManualMapping;
|
||||||
window.searchExternalTracks = searchExternalTracks;
|
window.searchExternalTracks = searchExternalTracks;
|
||||||
window.searchProvider = searchProvider;
|
window.selectExternalTrack = selectExternalTrack;
|
||||||
window.validateExternalMapping = validateExternalMapping;
|
window.validateExternalMapping = validateExternalMapping;
|
||||||
|
window.openLyricsMap = openLyricsMap;
|
||||||
window.saveLyricsMapping = saveLyricsMapping;
|
window.saveLyricsMapping = saveLyricsMapping;
|
||||||
// Note: viewTracks/selectExternalTrack/selectJellyfinTrack/openLyricsMap/searchProvider
|
window.searchProvider = searchProvider;
|
||||||
// are now wired via the ActionDispatcher and no longer require window exports.
|
|
||||||
|
|
||||||
document.addEventListener("DOMContentLoaded", () => {
|
document.addEventListener("DOMContentLoaded", () => {
|
||||||
console.log("🚀 Allstarr Admin UI (Modular) loaded");
|
console.log("🚀 Allstarr Admin UI (Modular) loaded");
|
||||||
|
|
||||||
const dispatcher = initActionDispatcher({ root: document });
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
// Register a few core actions first; more will be migrated as inline
|
tab.addEventListener("click", () => {
|
||||||
// onclick handlers are removed from HTML and generated markup.
|
window.switchTab(tab.dataset.tab);
|
||||||
dispatcher.register("switchTab", ({ args }) => {
|
});
|
||||||
const tab = args?.tab || args?.tabName;
|
|
||||||
if (tab) {
|
|
||||||
window.switchTab(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();
|
setupModalBackdropClose();
|
||||||
|
|
||||||
initScrobblingView({
|
const scrobblingTab = document.querySelector('.tab[data-tab="scrobbling"]');
|
||||||
isAuthenticated: () => authSession.isAuthenticated(),
|
if (scrobblingTab) {
|
||||||
loadScrobblingConfig: () => window.loadScrobblingConfig?.(),
|
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();
|
authSession.bootstrapAuth();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,100 +1,17 @@
|
|||||||
// Modal management
|
// 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) {
|
export function openModal(id) {
|
||||||
const modal = getModal(id);
|
document.getElementById(id).classList.add('active');
|
||||||
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();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeModal(id) {
|
export function closeModal(id) {
|
||||||
const modal = getModal(id);
|
document.getElementById(id).classList.remove('active');
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setupModalBackdropClose() {
|
export function setupModalBackdropClose() {
|
||||||
document.querySelectorAll(".modal").forEach((modal) => {
|
document.querySelectorAll('.modal').forEach(modal => {
|
||||||
modal.setAttribute("aria-hidden", "true");
|
modal.addEventListener('click', e => {
|
||||||
modal.addEventListener("click", (e) => {
|
if (e.target === modal) closeModal(modal.id);
|
||||||
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) {
|
async function deleteDownload(path) {
|
||||||
const result = await runAction({
|
const result = await runAction({
|
||||||
confirmMessage: `Delete this file?\n\n${path}\n\nThis action cannot be undone.`,
|
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.deleteTrackMapping = deleteTrackMapping;
|
||||||
window.downloadFile = downloadFile;
|
window.downloadFile = downloadFile;
|
||||||
window.downloadAllKept = downloadAllKept;
|
window.downloadAllKept = downloadAllKept;
|
||||||
window.deleteAllKept = deleteAllKept;
|
|
||||||
window.deleteDownload = deleteDownload;
|
window.deleteDownload = deleteDownload;
|
||||||
window.refreshPlaylists = refreshPlaylists;
|
window.refreshPlaylists = refreshPlaylists;
|
||||||
window.refreshPlaylist = refreshPlaylist;
|
window.refreshPlaylist = refreshPlaylist;
|
||||||
|
|||||||
@@ -70,12 +70,7 @@ async function openLinkPlaylist(jellyfinId, name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
spotifyUserPlaylists = await API.fetchSpotifyUserPlaylists(selectedUserId);
|
||||||
spotifyUserPlaylists = Array.isArray(response?.playlists)
|
|
||||||
? response.playlists
|
|
||||||
: Array.isArray(response)
|
|
||||||
? response
|
|
||||||
: [];
|
|
||||||
spotifyUserPlaylistsScopeUserId = selectedUserId;
|
spotifyUserPlaylistsScopeUserId = selectedUserId;
|
||||||
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
|
const availablePlaylists = spotifyUserPlaylists.filter((p) => !p.isLinked);
|
||||||
|
|
||||||
|
|||||||
+134
-86
@@ -3,8 +3,6 @@
|
|||||||
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
|
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
|
||||||
|
|
||||||
let rowMenuHandlersBound = false;
|
let rowMenuHandlersBound = false;
|
||||||
let tableRowHandlersBound = false;
|
|
||||||
const expandedInjectedPlaylistDetails = new Set();
|
|
||||||
|
|
||||||
function bindRowMenuHandlers() {
|
function bindRowMenuHandlers() {
|
||||||
if (rowMenuHandlersBound) {
|
if (rowMenuHandlersBound) {
|
||||||
@@ -18,41 +16,6 @@ function bindRowMenuHandlers() {
|
|||||||
rowMenuHandlersBound = true;
|
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) {
|
function closeAllRowMenus(exceptId = null) {
|
||||||
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
|
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
|
||||||
if (!exceptId || menu.id !== exceptId) {
|
if (!exceptId || menu.id !== exceptId) {
|
||||||
@@ -119,18 +82,6 @@ function toggleDetailsRow(event, detailsRowId) {
|
|||||||
);
|
);
|
||||||
if (parentRow) {
|
if (parentRow) {
|
||||||
parentRow.classList.toggle("expanded", isExpanded);
|
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,15 +183,11 @@ if (typeof window !== "undefined") {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bindRowMenuHandlers();
|
bindRowMenuHandlers();
|
||||||
bindTableRowHandlers();
|
|
||||||
|
|
||||||
export function updateStatusUI(data) {
|
export function updateStatusUI(data) {
|
||||||
const versionEl = document.getElementById("version");
|
const versionEl = document.getElementById("version");
|
||||||
if (versionEl) versionEl.textContent = "v" + data.version;
|
if (versionEl) versionEl.textContent = "v" + data.version;
|
||||||
|
|
||||||
const sidebarVersionEl = document.getElementById("sidebar-version");
|
|
||||||
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
|
|
||||||
|
|
||||||
const backendTypeEl = document.getElementById("backend-type");
|
const backendTypeEl = document.getElementById("backend-type");
|
||||||
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
|
if (backendTypeEl) backendTypeEl.textContent = data.backendType;
|
||||||
|
|
||||||
@@ -324,7 +271,6 @@ export function updatePlaylistsUI(data) {
|
|||||||
const playlists = data.playlists || [];
|
const playlists = data.playlists || [];
|
||||||
|
|
||||||
if (playlists.length === 0) {
|
if (playlists.length === 0) {
|
||||||
expandedInjectedPlaylistDetails.clear();
|
|
||||||
tbody.innerHTML =
|
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>';
|
'<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", [
|
renderGuidance("playlists-guidance", [
|
||||||
@@ -383,12 +329,9 @@ export function updatePlaylistsUI(data) {
|
|||||||
const summary = getPlaylistStatusSummary(playlist);
|
const summary = getPlaylistStatusSummary(playlist);
|
||||||
const detailsRowId = `playlist-details-${index}`;
|
const detailsRowId = `playlist-details-${index}`;
|
||||||
const menuId = `playlist-menu-${index}`;
|
const menuId = `playlist-menu-${index}`;
|
||||||
const detailsKey = `${playlist.id || playlist.name || index}`;
|
|
||||||
const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
|
|
||||||
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
const syncSchedule = playlist.syncSchedule || "0 8 * * *";
|
||||||
const escapedPlaylistName = escapeHtml(playlist.name);
|
const escapedPlaylistName = escapeJs(playlist.name);
|
||||||
const escapedSyncSchedule = escapeHtml(syncSchedule);
|
const escapedSyncSchedule = escapeJs(syncSchedule);
|
||||||
const escapedDetailsKey = escapeHtml(detailsKey);
|
|
||||||
|
|
||||||
const breakdownBadges = [
|
const breakdownBadges = [
|
||||||
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
`<span class="status-pill neutral">${summary.localCount} Local</span>`,
|
||||||
@@ -402,7 +345,7 @@ export function updatePlaylistsUI(data) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="compact-row ${isExpanded ? "expanded" : ""}" data-details-row="${detailsRowId}" data-details-key="${escapedDetailsKey}">
|
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
|
||||||
<td>
|
<td>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<strong>${escapeHtml(playlist.name)}</strong>
|
<strong>${escapeHtml(playlist.name)}</strong>
|
||||||
@@ -415,23 +358,24 @@ export function updatePlaylistsUI(data) {
|
|||||||
</td>
|
</td>
|
||||||
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
<td><span class="status-pill ${summary.statusClass}">${summary.statusLabel}</span></td>
|
||||||
<td class="row-controls">
|
<td class="row-controls">
|
||||||
<button class="icon-btn details-trigger" data-details-target="${detailsRowId}" aria-expanded="${isExpanded ? "true" : "false"}">${isExpanded ? "Hide" : "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">
|
<div class="row-actions-wrap">
|
||||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
<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">
|
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||||
<button data-action="viewTracks" data-arg-playlist-name="${escapedPlaylistName}">View Tracks</button>
|
<button onclick="closeRowMenu(event, '${menuId}'); viewTracks('${escapedPlaylistName}')">View Tracks</button>
|
||||||
<button data-action="refreshPlaylist" data-arg-playlist-name="${escapedPlaylistName}">Refresh</button>
|
<button onclick="closeRowMenu(event, '${menuId}'); refreshPlaylist('${escapedPlaylistName}')">Refresh</button>
|
||||||
<button data-action="matchPlaylistTracks" data-arg-playlist-name="${escapedPlaylistName}">Rematch</button>
|
<button onclick="closeRowMenu(event, '${menuId}'); matchPlaylistTracks('${escapedPlaylistName}')">Rematch</button>
|
||||||
<button data-action="clearPlaylistCache" data-arg-playlist-name="${escapedPlaylistName}">Rebuild</button>
|
<button onclick="closeRowMenu(event, '${menuId}'); clearPlaylistCache('${escapedPlaylistName}')">Rebuild</button>
|
||||||
<button data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit Schedule</button>
|
<button onclick="closeRowMenu(event, '${menuId}'); editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit Schedule</button>
|
||||||
<hr>
|
<hr>
|
||||||
<button class="danger-item" data-action="removePlaylist" data-arg-playlist-name="${escapedPlaylistName}">Remove Playlist</button>
|
<button class="danger-item" onclick="closeRowMenu(event, '${menuId}'); removePlaylist('${escapedPlaylistName}')">Remove Playlist</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr id="${detailsRowId}" class="details-row" ${isExpanded ? "" : "hidden"}>
|
<tr id="${detailsRowId}" class="details-row" hidden>
|
||||||
<td colspan="4">
|
<td colspan="4">
|
||||||
<div class="details-panel">
|
<div class="details-panel">
|
||||||
<div class="details-grid">
|
<div class="details-grid">
|
||||||
@@ -439,7 +383,7 @@ export function updatePlaylistsUI(data) {
|
|||||||
<span class="detail-label">Sync Schedule</span>
|
<span class="detail-label">Sync Schedule</span>
|
||||||
<span class="detail-value mono">
|
<span class="detail-value mono">
|
||||||
${escapeHtml(syncSchedule)}
|
${escapeHtml(syncSchedule)}
|
||||||
<button class="inline-action-link" data-action="editPlaylistSchedule" data-arg-playlist-name="${escapedPlaylistName}" data-arg-sync-schedule="${escapedSyncSchedule}">Edit</button>
|
<button class="inline-action-link" onclick="editPlaylistSchedule('${escapedPlaylistName}', '${escapedSyncSchedule}')">Edit</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
@@ -534,9 +478,9 @@ export function updateDownloadsUI(data) {
|
|||||||
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
<td style="font-family:monospace;font-size:0.85rem;">${escapeHtml(f.fileName)}</td>
|
||||||
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
<td style="color:var(--text-secondary);">${f.sizeFormatted}</td>
|
||||||
<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>
|
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>
|
class="danger" style="font-size:0.75rem;padding:4px 8px;">Delete</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -690,27 +634,26 @@ export function updateJellyfinPlaylistsUI(data) {
|
|||||||
.map((playlist, index) => {
|
.map((playlist, index) => {
|
||||||
const detailsRowId = `jellyfin-details-${index}`;
|
const detailsRowId = `jellyfin-details-${index}`;
|
||||||
const menuId = `jellyfin-menu-${index}`;
|
const menuId = `jellyfin-menu-${index}`;
|
||||||
const statsPending = Boolean(playlist.statsPending);
|
|
||||||
const localCount = playlist.localTracks || 0;
|
const localCount = playlist.localTracks || 0;
|
||||||
const externalCount = playlist.externalTracks || 0;
|
const externalCount = playlist.externalTracks || 0;
|
||||||
const externalAvailable = playlist.externalAvailable || 0;
|
const externalAvailable = playlist.externalAvailable || 0;
|
||||||
const escapedId = escapeHtml(playlist.id);
|
const escapedId = escapeJs(playlist.id);
|
||||||
const escapedName = escapeHtml(playlist.name);
|
const escapedName = escapeJs(playlist.name);
|
||||||
const statusClass = playlist.isConfigured ? "success" : "info";
|
const statusClass = playlist.isConfigured ? "success" : "info";
|
||||||
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
|
const statusLabel = playlist.isConfigured ? "Linked" : "Not Linked";
|
||||||
|
|
||||||
const actionButtons = playlist.isConfigured
|
const actionButtons = playlist.isConfigured
|
||||||
? `
|
? `
|
||||||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
<button onclick="closeRowMenu(event, '${menuId}'); 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 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 onclick="closeRowMenu(event, '${menuId}'); openLinkPlaylist('${escapedId}', '${escapedName}')">Link to Spotify</button>
|
||||||
<button data-action="fetchJellyfinPlaylists">Refresh Row Data</button>
|
<button onclick="closeRowMenu(event, '${menuId}'); fetchJellyfinPlaylists()">Refresh Row Data</button>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="compact-row" data-details-row="${detailsRowId}">
|
<tr class="compact-row" data-details-row="${detailsRowId}" onclick="onCompactRowClick(event, '${detailsRowId}')">
|
||||||
<td>
|
<td>
|
||||||
<div class="name-cell">
|
<div class="name-cell">
|
||||||
<strong>${escapeHtml(playlist.name)}</strong>
|
<strong>${escapeHtml(playlist.name)}</strong>
|
||||||
@@ -718,15 +661,16 @@ export function updateJellyfinPlaylistsUI(data) {
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<span class="track-count">${statsPending ? "..." : localCount + externalAvailable}</span>
|
<span class="track-count">${localCount + externalAvailable}</span>
|
||||||
<div class="meta-text">${statsPending ? "Loading track stats..." : `L ${localCount} • E ${externalAvailable}/${externalCount}`}</div>
|
<div class="meta-text">L ${localCount} • E ${externalAvailable}/${externalCount}</div>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
|
<td><span class="status-pill ${statusClass}">${statusLabel}</span></td>
|
||||||
<td class="row-controls">
|
<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">
|
<div class="row-actions-wrap">
|
||||||
<button class="icon-btn menu-trigger" aria-haspopup="true" aria-expanded="false"
|
<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">
|
<div class="row-actions-menu" id="${menuId}" role="menu">
|
||||||
${actionButtons}
|
${actionButtons}
|
||||||
</div>
|
</div>
|
||||||
@@ -739,11 +683,11 @@ export function updateJellyfinPlaylistsUI(data) {
|
|||||||
<div class="details-grid">
|
<div class="details-grid">
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Local Tracks</span>
|
<span class="detail-label">Local Tracks</span>
|
||||||
<span class="detail-value">${statsPending ? "..." : localCount}</span>
|
<span class="detail-value">${localCount}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">External Tracks</span>
|
<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>
|
||||||
<div class="detail-item">
|
<div class="detail-item">
|
||||||
<span class="detail-label">Linked Spotify ID</span>
|
<span class="detail-label">Linked Spotify ID</span>
|
||||||
@@ -839,6 +783,110 @@ export function updateEndpointUsageUI(data) {
|
|||||||
.join("");
|
.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) {
|
export function showErrorState(message) {
|
||||||
const statusBadge = document.getElementById("spotify-status");
|
const statusBadge = document.getElementById("spotify-status");
|
||||||
if (statusBadge) {
|
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 charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Spotify Track Mappings - Allstarr</title>
|
<title>Spotify Track Mappings - Allstarr</title>
|
||||||
<link rel="stylesheet" href="styles.css" />
|
|
||||||
<style>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg-primary: #0d1117;
|
--bg-primary: #0d1117;
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ let localMapContext = null;
|
|||||||
let localMapResults = [];
|
let localMapResults = [];
|
||||||
let localMapSelectedIndex = -1;
|
let localMapSelectedIndex = -1;
|
||||||
let externalMapContext = null;
|
let externalMapContext = null;
|
||||||
const modalFocusState = new Map();
|
|
||||||
|
|
||||||
function showToast(message, type = "success", duration = 3000) {
|
function showToast(message, type = "success", duration = 3000) {
|
||||||
const toast = document.createElement("div");
|
const toast = document.createElement("div");
|
||||||
@@ -248,26 +247,9 @@ function toggleModal(modalId, shouldOpen) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (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");
|
modal.classList.add("active");
|
||||||
const firstFocusable = modal.querySelector(
|
|
||||||
'button, input, select, textarea, a[href], [tabindex]:not([tabindex="-1"])',
|
|
||||||
);
|
|
||||||
if (firstFocusable) {
|
|
||||||
firstFocusable.focus();
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
modal.classList.remove("active");
|
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();
|
closeLocalMapModal();
|
||||||
closeExternalMapModal();
|
closeExternalMapModal();
|
||||||
});
|
});
|
||||||
|
|
||||||
document.querySelectorAll(".modal-overlay").forEach((modal) => {
|
|
||||||
modal.setAttribute("aria-hidden", "true");
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize on page load
|
// Initialize on page load
|
||||||
|
|||||||
+78
-239
@@ -97,107 +97,90 @@ body {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.endpoint-health-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-health-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-health-legend span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.endpoint-health-last-tested {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 {
|
.container {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.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-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;
|
|
||||||
}
|
|
||||||
|
|
||||||
.app-header {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 12px 0 16px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.top-tabs,
|
|
||||||
.tabs.top-tabs {
|
|
||||||
display: none !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-footer {
|
.support-footer {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
padding: 20px 0 8px;
|
padding: 20px 0 8px;
|
||||||
@@ -992,16 +975,6 @@ input::placeholder {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.app-shell {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar {
|
|
||||||
position: static;
|
|
||||||
max-height: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.support-badge {
|
.support-badge {
|
||||||
right: 12px;
|
right: 12px;
|
||||||
bottom: 12px;
|
bottom: 12px;
|
||||||
@@ -1024,140 +997,6 @@ input::placeholder {
|
|||||||
display: block;
|
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 {
|
.tracks-list {
|
||||||
max-height: 400px;
|
max-height: 400px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|||||||
Reference in New Issue
Block a user