mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-23 10:42:37 -04:00
Compare commits
33 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
|
|||
|
0a5b383526
|
@@ -73,7 +73,7 @@ jobs:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=sha,prefix=
|
||||
type=ref,event=tag
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
type=semver,pattern={{major}}
|
||||
|
||||
@@ -65,13 +65,13 @@ Allstarr includes a web UI for easy configuration and playlist management, acces
|
||||
- `37i9dQZF1DXcBWIGoYBM5M` (just the ID)
|
||||
- `spotify:playlist:37i9dQZF1DXcBWIGoYBM5M` (Spotify URI)
|
||||
- `https://open.spotify.com/playlist/37i9dQZF1DXcBWIGoYBM5M` (full URL)
|
||||
4. **Restart** to apply changes (should be a banner)
|
||||
4. **Restart Allstarr** to apply changes (should be a banner)
|
||||
|
||||
Then, proceeed to **Active Playlists**, which shows you which Spotify playlists are currently being monitored and filled with tracks, and lets you do a bunch of useful operations on them.
|
||||
|
||||
### Configuration Persistence
|
||||
|
||||
The web UI updates your `.env` file directly. Changes persist across container restarts, but require a restart to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
|
||||
The web UI updates your `.env` file directly. Allstarr reloads that file on startup, so a normal container restart is enough for UI changes to take effect. In development mode, the `.env` file is in your project root. In Docker, it's at `/app/.env`.
|
||||
|
||||
There's an environment variable to modify this.
|
||||
|
||||
|
||||
@@ -100,4 +100,39 @@ public class AuthHeaderHelperTests
|
||||
Assert.Contains("Version=\"1.0\"", header);
|
||||
Assert.Contains("Token=\"abc\"", header);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractAccessToken_ShouldReadMediaBrowserToken()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] =
|
||||
"MediaBrowser Client=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
|
||||
};
|
||||
|
||||
Assert.Equal("abc", AuthHeaderHelper.ExtractAccessToken(headers));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractAccessToken_ShouldReadBearerToken()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["Authorization"] = "Bearer xyz"
|
||||
};
|
||||
|
||||
Assert.Equal("xyz", AuthHeaderHelper.ExtractAccessToken(headers));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractUserId_ShouldReadMediaBrowserUserId()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] =
|
||||
"MediaBrowser Client=\"Feishin\", UserId=\"user-123\", Token=\"abc\""
|
||||
};
|
||||
|
||||
Assert.Equal("user-123", AuthHeaderHelper.ExtractUserId(headers));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,7 +122,8 @@ public class ConfigControllerAuthorizationTests
|
||||
Enabled = false,
|
||||
ConnectionString = "localhost:6379"
|
||||
}),
|
||||
redisLogger.Object);
|
||||
redisLogger.Object,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
|
||||
var spotifySessionCookieService = new SpotifySessionCookieService(
|
||||
Options.Create(new SpotifyApiSettings()),
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Text;
|
||||
using allstarr.Controllers;
|
||||
using allstarr.Models.Admin;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.SquidWTF;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Moq;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class DiagnosticsControllerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task TestSquidWtfEndpoints_WithoutAdministratorSession_ReturnsForbidden()
|
||||
{
|
||||
var controller = CreateController(
|
||||
CreateHttpContextWithSession(isAdmin: false),
|
||||
_ => new HttpResponseMessage(HttpStatusCode.OK));
|
||||
|
||||
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
|
||||
|
||||
var forbidden = Assert.IsType<ObjectResult>(result);
|
||||
Assert.Equal(StatusCodes.Status403Forbidden, forbidden.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task TestSquidWtfEndpoints_ReturnsIndependentApiAndStreamingResults()
|
||||
{
|
||||
var controller = CreateController(
|
||||
CreateHttpContextWithSession(isAdmin: true),
|
||||
request =>
|
||||
{
|
||||
var uri = request.RequestUri!;
|
||||
|
||||
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/search/")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
{"data":{"items":[{"id":227242909,"title":"Monica Lewinsky"}]}}
|
||||
""",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
if (uri.Host == "node-one.example" && uri.AbsolutePath == "/track/")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(
|
||||
"""
|
||||
{"data":{"manifest":"ZmFrZS1tYW5pZmVzdA=="}}
|
||||
""",
|
||||
Encoding.UTF8,
|
||||
"application/json")
|
||||
};
|
||||
}
|
||||
|
||||
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/search/")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.ServiceUnavailable);
|
||||
}
|
||||
|
||||
if (uri.Host == "node-two.example" && uri.AbsolutePath == "/track/")
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.GatewayTimeout);
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected request URI: {uri}");
|
||||
});
|
||||
|
||||
var result = await controller.TestSquidWtfEndpoints(CancellationToken.None);
|
||||
|
||||
var ok = Assert.IsType<OkObjectResult>(result);
|
||||
var payload = Assert.IsType<SquidWtfEndpointHealthResponse>(ok.Value);
|
||||
Assert.Equal(2, payload.TotalRows);
|
||||
|
||||
var nodeOne = Assert.Single(payload.Endpoints, e => e.Host == "node-one.example");
|
||||
Assert.True(nodeOne.Api.Configured);
|
||||
Assert.True(nodeOne.Api.IsUp);
|
||||
Assert.Equal("up", nodeOne.Api.State);
|
||||
Assert.Equal(200, nodeOne.Api.StatusCode);
|
||||
Assert.True(nodeOne.Streaming.Configured);
|
||||
Assert.True(nodeOne.Streaming.IsUp);
|
||||
Assert.Equal("up", nodeOne.Streaming.State);
|
||||
Assert.Equal(200, nodeOne.Streaming.StatusCode);
|
||||
|
||||
var nodeTwo = Assert.Single(payload.Endpoints, e => e.Host == "node-two.example");
|
||||
Assert.True(nodeTwo.Api.Configured);
|
||||
Assert.False(nodeTwo.Api.IsUp);
|
||||
Assert.Equal("down", nodeTwo.Api.State);
|
||||
Assert.Equal(503, nodeTwo.Api.StatusCode);
|
||||
Assert.True(nodeTwo.Streaming.Configured);
|
||||
Assert.False(nodeTwo.Streaming.IsUp);
|
||||
Assert.Equal("down", nodeTwo.Streaming.State);
|
||||
Assert.Equal(504, nodeTwo.Streaming.StatusCode);
|
||||
}
|
||||
|
||||
private static HttpContext CreateHttpContextWithSession(bool isAdmin)
|
||||
{
|
||||
var context = new DefaultHttpContext();
|
||||
context.Connection.LocalPort = 5275;
|
||||
context.Items[AdminAuthSessionService.HttpContextSessionItemKey] = new AdminAuthSession
|
||||
{
|
||||
SessionId = "session-id",
|
||||
UserId = "user-id",
|
||||
UserName = "user",
|
||||
IsAdministrator = isAdmin,
|
||||
JellyfinAccessToken = "token",
|
||||
JellyfinServerId = "server-id",
|
||||
ExpiresAtUtc = DateTime.UtcNow.AddHours(1),
|
||||
LastSeenUtc = DateTime.UtcNow
|
||||
};
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static DiagnosticsController CreateController(
|
||||
HttpContext httpContext,
|
||||
Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
var logger = new Mock<ILogger<DiagnosticsController>>();
|
||||
var configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(new Dictionary<string, string?>())
|
||||
.Build();
|
||||
|
||||
var webHostEnvironment = new Mock<IWebHostEnvironment>();
|
||||
webHostEnvironment.SetupGet(e => e.EnvironmentName).Returns(Environments.Development);
|
||||
webHostEnvironment.SetupGet(e => e.ContentRootPath).Returns(Directory.GetCurrentDirectory());
|
||||
|
||||
var helperLogger = new Mock<ILogger<AdminHelperService>>();
|
||||
var helperService = new AdminHelperService(
|
||||
helperLogger.Object,
|
||||
Options.Create(new JellyfinSettings()),
|
||||
webHostEnvironment.Object);
|
||||
|
||||
var spotifyCookieLogger = new Mock<ILogger<SpotifySessionCookieService>>();
|
||||
var spotifySessionCookieService = new SpotifySessionCookieService(
|
||||
Options.Create(new SpotifyApiSettings()),
|
||||
helperService,
|
||||
spotifyCookieLogger.Object);
|
||||
|
||||
var redisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var redisCache = new RedisCacheService(
|
||||
Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = false,
|
||||
ConnectionString = "localhost:6379"
|
||||
}),
|
||||
redisLogger.Object,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
|
||||
var httpClientFactory = new Mock<IHttpClientFactory>();
|
||||
httpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>()))
|
||||
.Returns(() => new HttpClient(new StubHttpMessageHandler(responseFactory)));
|
||||
|
||||
var controller = new DiagnosticsController(
|
||||
logger.Object,
|
||||
configuration,
|
||||
Options.Create(new SpotifyApiSettings()),
|
||||
Options.Create(new SpotifyImportSettings()),
|
||||
Options.Create(new JellyfinSettings()),
|
||||
Options.Create(new DeezerSettings()),
|
||||
Options.Create(new QobuzSettings()),
|
||||
Options.Create(new SquidWTFSettings()),
|
||||
spotifySessionCookieService,
|
||||
new SquidWtfEndpointCatalog(
|
||||
new List<string>
|
||||
{
|
||||
"https://node-one.example",
|
||||
"https://node-two.example"
|
||||
},
|
||||
new List<string>
|
||||
{
|
||||
"https://node-one.example",
|
||||
"https://node-two.example"
|
||||
}),
|
||||
redisCache,
|
||||
httpClientFactory.Object)
|
||||
{
|
||||
ControllerContext = new ControllerContext
|
||||
{
|
||||
HttpContext = httpContext
|
||||
}
|
||||
};
|
||||
|
||||
return controller;
|
||||
}
|
||||
|
||||
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||
{
|
||||
private readonly Func<HttpRequestMessage, HttpResponseMessage> _responseFactory;
|
||||
|
||||
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> responseFactory)
|
||||
{
|
||||
_responseFactory = responseFactory;
|
||||
}
|
||||
|
||||
protected override Task<HttpResponseMessage> SendAsync(
|
||||
HttpRequestMessage request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(_responseFactory(request));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class ImageConditionalRequestHelperTests
|
||||
{
|
||||
[Fact]
|
||||
public void ComputeStrongETag_SamePayload_ReturnsStableQuotedHash()
|
||||
{
|
||||
var payload = new byte[] { 1, 2, 3, 4 };
|
||||
|
||||
var first = ImageConditionalRequestHelper.ComputeStrongETag(payload);
|
||||
var second = ImageConditionalRequestHelper.ComputeStrongETag(payload);
|
||||
|
||||
Assert.Equal(first, second);
|
||||
Assert.StartsWith("\"", first);
|
||||
Assert.EndsWith("\"", first);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithExactMatch_ReturnsTrue()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "\"ABC123\""
|
||||
};
|
||||
|
||||
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"ABC123\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithMultipleValues_ReturnsTrueForMatchingEntry()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "\"stale\", \"fresh\""
|
||||
};
|
||||
|
||||
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"fresh\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithWildcard_ReturnsTrue()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "*"
|
||||
};
|
||||
|
||||
Assert.True(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"anything\""));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MatchesIfNoneMatch_WithoutMatch_ReturnsFalse()
|
||||
{
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["If-None-Match"] = "\"ABC123\""
|
||||
};
|
||||
|
||||
Assert.False(ImageConditionalRequestHelper.MatchesIfNoneMatch(headers, "\"XYZ789\""));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
using System.Reflection;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinControllerSearchLimitTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData(null, 20, true, 20, 20, 20)]
|
||||
[InlineData("MusicAlbum", 20, true, 0, 20, 0)]
|
||||
[InlineData("Audio", 20, true, 20, 0, 0)]
|
||||
[InlineData("MusicArtist", 20, true, 0, 0, 20)]
|
||||
[InlineData("Playlist", 20, true, 0, 20, 0)]
|
||||
[InlineData("Playlist", 20, false, 0, 0, 0)]
|
||||
[InlineData("Audio,MusicArtist", 15, true, 15, 0, 15)]
|
||||
[InlineData("BoxSet", 10, true, 0, 0, 0)]
|
||||
public void GetExternalSearchLimits_UsesRequestedItemTypes(
|
||||
string? includeItemTypes,
|
||||
int limit,
|
||||
bool includePlaylistsAsAlbums,
|
||||
int expectedSongLimit,
|
||||
int expectedAlbumLimit,
|
||||
int expectedArtistLimit)
|
||||
{
|
||||
var requestedTypes = string.IsNullOrWhiteSpace(includeItemTypes)
|
||||
? null
|
||||
: includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"GetExternalSearchLimits",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
var result = ((int SongLimit, int AlbumLimit, int ArtistLimit))method!.Invoke(
|
||||
null,
|
||||
new object?[] { requestedTypes, limit, includePlaylistsAsAlbums })!;
|
||||
|
||||
Assert.Equal(expectedSongLimit, result.SongLimit);
|
||||
Assert.Equal(expectedAlbumLimit, result.AlbumLimit);
|
||||
Assert.Equal(expectedArtistLimit, result.ArtistLimit);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinImageTagExtractionTests
|
||||
{
|
||||
[Fact]
|
||||
public void ExtractImageTag_WithMatchingImageTagsObject_ReturnsRequestedTag()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"ImageTags": {
|
||||
"Primary": "playlist-primary-tag",
|
||||
"Backdrop": "playlist-backdrop-tag"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
|
||||
|
||||
Assert.Equal("playlist-primary-tag", imageTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractImageTag_WithPrimaryImageTagFallback_ReturnsFallbackTag()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"PrimaryImageTag": "primary-fallback-tag"
|
||||
}
|
||||
""");
|
||||
|
||||
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
|
||||
|
||||
Assert.Equal("primary-fallback-tag", imageTag);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExtractImageTag_WithoutMatchingTag_ReturnsNull()
|
||||
{
|
||||
using var document = JsonDocument.Parse("""
|
||||
{
|
||||
"ImageTags": {
|
||||
"Backdrop": "playlist-backdrop-tag"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
var imageTag = InvokeExtractImageTag(document.RootElement, "Primary");
|
||||
|
||||
Assert.Null(imageTag);
|
||||
}
|
||||
|
||||
private static string? InvokeExtractImageTag(JsonElement item, string imageType)
|
||||
{
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"ExtractImageTag",
|
||||
BindingFlags.Static | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
return (string?)method!.Invoke(null, new object?[] { item, imageType });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
using System.Reflection;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinPlaylistRouteMatchingTests
|
||||
{
|
||||
[Theory]
|
||||
[InlineData("playlists/abc123/items", "abc123")]
|
||||
[InlineData("Playlists/abc123/Items", "abc123")]
|
||||
[InlineData("/playlists/abc123/items/", "abc123")]
|
||||
public void GetExactPlaylistItemsRequestId_ExactPlaylistItemsRoute_ReturnsPlaylistId(string path, string expectedPlaylistId)
|
||||
{
|
||||
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
|
||||
|
||||
Assert.Equal(expectedPlaylistId, playlistId);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("playlists/abc123/items/extra")]
|
||||
[InlineData("users/user-1/playlists/abc123/items")]
|
||||
[InlineData("items/abc123")]
|
||||
[InlineData("playlists")]
|
||||
public void GetExactPlaylistItemsRequestId_NonExactRoute_ReturnsNull(string path)
|
||||
{
|
||||
var playlistId = InvokePrivateStatic<string?>("GetExactPlaylistItemsRequestId", path);
|
||||
|
||||
Assert.Null(playlistId);
|
||||
}
|
||||
|
||||
private static T InvokePrivateStatic<T>(string methodName, params object?[] args)
|
||||
{
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
methodName,
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.NotNull(method);
|
||||
var result = method!.Invoke(null, args);
|
||||
return (T)result!;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
@@ -20,6 +21,7 @@ public class JellyfinProxyServiceTests
|
||||
private readonly Mock<IHttpClientFactory> _mockHttpClientFactory;
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
|
||||
public JellyfinProxyServiceTests()
|
||||
{
|
||||
@@ -31,7 +33,7 @@ public class JellyfinProxyServiceTests
|
||||
|
||||
var redisSettings = new RedisSettings { Enabled = false };
|
||||
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
|
||||
_cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
_settings = new JellyfinSettings
|
||||
{
|
||||
@@ -45,19 +47,21 @@ public class JellyfinProxyServiceTests
|
||||
};
|
||||
|
||||
var httpContext = new DefaultHttpContext();
|
||||
var httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
_httpContextAccessor = new HttpContextAccessor { HttpContext = httpContext };
|
||||
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
||||
var userResolver = CreateUserContextResolver(_httpContextAccessor);
|
||||
|
||||
// Initialize cache settings for tests
|
||||
var serviceCollection = new Microsoft.Extensions.DependencyInjection.ServiceCollection();
|
||||
serviceCollection.Configure<CacheSettings>(options => { }); // Use defaults
|
||||
var serviceProvider = serviceCollection.BuildServiceProvider();
|
||||
CacheExtensions.InitializeCacheSettings(serviceProvider);
|
||||
allstarr.Services.Common.CacheExtensions.InitializeCacheSettings(serviceProvider);
|
||||
|
||||
_service = new JellyfinProxyService(
|
||||
_mockHttpClientFactory.Object,
|
||||
Options.Create(_settings),
|
||||
httpContextAccessor,
|
||||
_httpContextAccessor,
|
||||
userResolver,
|
||||
mockLogger.Object,
|
||||
_cache);
|
||||
}
|
||||
@@ -93,6 +97,21 @@ public class JellyfinProxyServiceTests
|
||||
Assert.Equal(500, statusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJsonAsync_ServerErrorWithJsonBody_ReturnsParsedErrorDocument()
|
||||
{
|
||||
// Arrange
|
||||
SetupMockResponse(HttpStatusCode.Unauthorized, "{\"Message\":\"Token expired\"}", "application/json");
|
||||
|
||||
// Act
|
||||
var (body, statusCode) = await _service.GetJsonAsync("Items");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(body);
|
||||
Assert.Equal(401, statusCode);
|
||||
Assert.Equal("Token expired", body.RootElement.GetProperty("Message").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJsonAsync_WithoutClientHeaders_SendsNoAuth()
|
||||
{
|
||||
@@ -228,6 +247,44 @@ public class JellyfinProxyServiceTests
|
||||
Assert.Equal("test query", searchTermValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAsync_WithClientToken_ResolvesAndAppendsRequestUserId()
|
||||
{
|
||||
var requestedUris = new List<string>();
|
||||
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync((HttpRequestMessage req, CancellationToken _) =>
|
||||
{
|
||||
requestedUris.Add(req.RequestUri!.ToString());
|
||||
|
||||
if (req.RequestUri!.AbsolutePath.EndsWith("/Users/Me", StringComparison.Ordinal))
|
||||
{
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Id\":\"resolved-user\"}")
|
||||
};
|
||||
}
|
||||
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[],\"TotalRecordCount\":0}")
|
||||
};
|
||||
});
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Token"] = "token-123"
|
||||
};
|
||||
|
||||
await _service.SearchAsync("test query", new[] { "Audio" }, 25, clientHeaders: headers);
|
||||
|
||||
Assert.Contains(requestedUris, uri => uri.EndsWith("/Users/Me"));
|
||||
Assert.Contains(requestedUris, uri => uri.Contains("userId=resolved-user", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetItemAsync_RequestsCorrectEndpoint()
|
||||
{
|
||||
@@ -311,6 +368,169 @@ public class JellyfinProxyServiceTests
|
||||
Assert.Contains("UserId=user-abc", query);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPassthroughResponseAsync_WithRepeatedFields_PreservesAllFieldParameters()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[]}")
|
||||
});
|
||||
|
||||
// Act
|
||||
var response = await _service.GetPassthroughResponseAsync(
|
||||
"Playlists/playlist-123/Items?Fields=Genres&Fields=DateCreated&Fields=MediaSources&UserId=user-abc");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
var query = captured!.RequestUri!.Query;
|
||||
Assert.Contains("Fields=Genres", query);
|
||||
Assert.Contains("Fields=DateCreated", query);
|
||||
Assert.Contains("Fields=MediaSources", query);
|
||||
Assert.Contains("UserId=user-abc", query);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPassthroughResponseAsync_WithClientAuth_ForwardsAuthHeader()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[]}")
|
||||
});
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _service.GetPassthroughResponseAsync(
|
||||
"Playlists/playlist-123/Items?Fields=Genres",
|
||||
headers);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
Assert.True(captured!.Headers.TryGetValues("X-Emby-Authorization", out var values));
|
||||
Assert.Contains("MediaBrowser Token=\"abc\"", values);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_WithNoBody_PreservesEmptyRequestBody()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||
};
|
||||
|
||||
// Act
|
||||
var (_, statusCode) = await _service.SendAsync(
|
||||
HttpMethod.Post,
|
||||
"Sessions/session-123/Playing/Pause?controllingUserId=user-123",
|
||||
null,
|
||||
headers);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(204, statusCode);
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(HttpMethod.Post, captured!.Method);
|
||||
Assert.Null(captured.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SendAsync_WithCustomContentType_PreservesOriginalType()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, _) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = "MediaBrowser Token=\"abc\""
|
||||
};
|
||||
|
||||
// Act
|
||||
await _service.SendAsync(
|
||||
HttpMethod.Put,
|
||||
"Sessions/session-123/Command/DisplayMessage",
|
||||
"{\"Text\":\"hello\"}",
|
||||
headers,
|
||||
"application/json; charset=utf-8");
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
Assert.Equal(HttpMethod.Put, captured!.Method);
|
||||
Assert.NotNull(captured.Content);
|
||||
Assert.Equal("application/json; charset=utf-8", captured.Content!.Headers.ContentType!.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetPassthroughResponseAsync_WithAcceptEncoding_ForwardsCompressionHeaders()
|
||||
{
|
||||
// Arrange
|
||||
HttpRequestMessage? captured = null;
|
||||
_mockHandler.Protected()
|
||||
.Setup<Task<HttpResponseMessage>>("SendAsync",
|
||||
ItExpr.IsAny<HttpRequestMessage>(),
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.Callback<HttpRequestMessage, CancellationToken>((req, ct) => captured = req)
|
||||
.ReturnsAsync(new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent("{\"Items\":[]}")
|
||||
});
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["Accept-Encoding"] = "gzip, br",
|
||||
["User-Agent"] = "Finamp/1.0",
|
||||
["Accept-Language"] = "en-US"
|
||||
};
|
||||
|
||||
// Act
|
||||
var response = await _service.GetPassthroughResponseAsync(
|
||||
"Playlists/playlist-123/Items?Fields=Genres",
|
||||
headers);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(captured);
|
||||
Assert.True(captured!.Headers.TryGetValues("Accept-Encoding", out var encodings));
|
||||
Assert.Contains("gzip", encodings);
|
||||
Assert.Contains("br", encodings);
|
||||
Assert.True(captured.Headers.TryGetValues("User-Agent", out var userAgents));
|
||||
Assert.Contains("Finamp/1.0", userAgents);
|
||||
Assert.True(captured.Headers.TryGetValues("Accept-Language", out var languages));
|
||||
Assert.Contains("en-US", languages);
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetJsonAsync_WithEndpointAndExplicitQuery_MergesWithExplicitPrecedence()
|
||||
{
|
||||
@@ -466,12 +686,14 @@ public class JellyfinProxyServiceTests
|
||||
var mockLogger = new Mock<ILogger<JellyfinProxyService>>();
|
||||
var redisSettings = new RedisSettings { Enabled = false };
|
||||
var mockCacheLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object);
|
||||
var cache = new RedisCacheService(Options.Create(redisSettings), mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
|
||||
var userResolver = CreateUserContextResolver(httpContextAccessor);
|
||||
|
||||
var service = new JellyfinProxyService(
|
||||
_mockHttpClientFactory.Object,
|
||||
Options.Create(_settings),
|
||||
httpContextAccessor,
|
||||
userResolver,
|
||||
mockLogger.Object,
|
||||
cache);
|
||||
|
||||
@@ -497,4 +719,14 @@ public class JellyfinProxyServiceTests
|
||||
ItExpr.IsAny<CancellationToken>())
|
||||
.ReturnsAsync(response);
|
||||
}
|
||||
|
||||
private JellyfinUserContextResolver CreateUserContextResolver(IHttpContextAccessor httpContextAccessor)
|
||||
{
|
||||
return new JellyfinUserContextResolver(
|
||||
httpContextAccessor,
|
||||
_mockHttpClientFactory.Object,
|
||||
Options.Create(_settings),
|
||||
new MemoryCache(new MemoryCacheOptions()),
|
||||
new Mock<ILogger<JellyfinUserContextResolver>>().Object);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,6 +47,8 @@ public class JellyfinResponseBuilderTests
|
||||
Assert.Equal(1, result["ParentIndexNumber"]);
|
||||
Assert.Equal(2023, result["ProductionYear"]);
|
||||
Assert.Equal(245 * TimeSpan.TicksPerSecond, result["RunTimeTicks"]);
|
||||
Assert.NotNull(result["AudioInfo"]);
|
||||
Assert.Equal(false, result["CanDelete"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -192,6 +194,9 @@ public class JellyfinResponseBuilderTests
|
||||
Assert.Equal("Famous Band", result["AlbumArtist"]);
|
||||
Assert.Equal(2020, result["ProductionYear"]);
|
||||
Assert.Equal(12, result["ChildCount"]);
|
||||
Assert.Equal("Greatest Hits", result["SortName"]);
|
||||
Assert.NotNull(result["DateCreated"]);
|
||||
Assert.NotNull(result["BasicSyncInfo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -215,6 +220,9 @@ public class JellyfinResponseBuilderTests
|
||||
Assert.Equal("MusicArtist", result["Type"]);
|
||||
Assert.Equal(true, result["IsFolder"]);
|
||||
Assert.Equal(5, result["AlbumCount"]);
|
||||
Assert.Equal("The Rockers", result["SortName"]);
|
||||
Assert.Equal(1.0, result["PrimaryImageAspectRatio"]);
|
||||
Assert.NotNull(result["BasicSyncInfo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -243,6 +251,9 @@ public class JellyfinResponseBuilderTests
|
||||
Assert.Equal("DJ Cool", result["AlbumArtist"]);
|
||||
Assert.Equal(50, result["ChildCount"]);
|
||||
Assert.Equal(2023, result["ProductionYear"]);
|
||||
Assert.Equal("Summer Vibes [S/P]", result["SortName"]);
|
||||
Assert.NotNull(result["DateCreated"]);
|
||||
Assert.NotNull(result["BasicSyncInfo"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using allstarr.Controllers;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinSearchInterleaveTests
|
||||
{
|
||||
[Fact]
|
||||
public void InterleaveByScore_PrimaryOnly_PreservesOriginalOrder()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("zzz filler"),
|
||||
CreateItem("BTS Anthem")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, [], "bts", 5.0);
|
||||
|
||||
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_SecondaryOnly_PreservesOriginalOrder()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("zzz filler"),
|
||||
CreateItem("BTS Anthem")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, [], secondary, "bts", 5.0);
|
||||
|
||||
Assert.Equal(["zzz filler", "BTS Anthem"], result.Select(GetName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_StrongerHeadMatch_LeadsWithoutReorderingSource()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("luther remastered"),
|
||||
CreateItem("zzz filler")
|
||||
};
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("luther"),
|
||||
CreateItem("yyy filler")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 0.0);
|
||||
|
||||
Assert.Equal(["luther", "luther remastered", "zzz filler", "yyy filler"], result.Select(GetName));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_TiedScores_PreferPrimaryQueueHead()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("bts", "p1"),
|
||||
CreateItem("bts", "p2")
|
||||
};
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("bts", "s1"),
|
||||
CreateItem("bts", "s2")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
|
||||
|
||||
Assert.Equal(["p1", "p2", "s1", "s2"], result.Select(GetId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_StrongerLaterPrimaryHead_DoesNotBypassCurrentQueueHead()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("zzz filler", "p1"),
|
||||
CreateItem("bts local later", "p2")
|
||||
};
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("bts", "s1"),
|
||||
CreateItem("bts live", "s2")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, secondary, "bts", 0.0);
|
||||
|
||||
Assert.Equal(["s1", "s2", "p1", "p2"], result.Select(GetId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void InterleaveByScore_JellyfinBoost_CanWinCloseHeadToHead()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var primary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("luther remastered", "p1")
|
||||
};
|
||||
var secondary = new List<Dictionary<string, object?>>
|
||||
{
|
||||
CreateItem("luther", "s1")
|
||||
};
|
||||
|
||||
var result = InvokeInterleaveByScore(controller, primary, secondary, "luther", 5.0);
|
||||
|
||||
Assert.Equal(["p1", "s1"], result.Select(GetId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateItemRelevanceScore_SongUsesArtistContext()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var withArtist = CreateTypedItem("Audio", "cardigan", "song-with-artist");
|
||||
withArtist["Artists"] = new[] { "Taylor Swift" };
|
||||
|
||||
var withoutArtist = CreateTypedItem("Audio", "cardigan", "song-without-artist");
|
||||
|
||||
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
|
||||
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
|
||||
|
||||
Assert.True(withArtistScore > withoutArtistScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateItemRelevanceScore_AlbumUsesArtistContext()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var withArtist = CreateTypedItem("MusicAlbum", "folklore", "album-with-artist");
|
||||
withArtist["AlbumArtist"] = "Taylor Swift";
|
||||
|
||||
var withoutArtist = CreateTypedItem("MusicAlbum", "folklore", "album-without-artist");
|
||||
|
||||
var withArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withArtist);
|
||||
var withoutArtistScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", withoutArtist);
|
||||
|
||||
Assert.True(withArtistScore > withoutArtistScore);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CalculateItemRelevanceScore_ArtistIgnoresNonNameMetadata()
|
||||
{
|
||||
var controller = CreateController();
|
||||
var plainArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-plain");
|
||||
var noisyArtist = CreateTypedItem("MusicArtist", "Taylor Swift", "artist-noisy");
|
||||
noisyArtist["AlbumArtist"] = "Completely Different";
|
||||
noisyArtist["Artists"] = new[] { "Someone Else" };
|
||||
|
||||
var plainScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", plainArtist);
|
||||
var noisyScore = InvokeCalculateItemRelevanceScore(controller, "taylor swift", noisyArtist);
|
||||
|
||||
Assert.Equal(plainScore, noisyScore);
|
||||
}
|
||||
|
||||
private static JellyfinController CreateController()
|
||||
{
|
||||
return (JellyfinController)RuntimeHelpers.GetUninitializedObject(typeof(JellyfinController));
|
||||
}
|
||||
|
||||
private static List<Dictionary<string, object?>> InvokeInterleaveByScore(
|
||||
JellyfinController controller,
|
||||
List<Dictionary<string, object?>> primary,
|
||||
List<Dictionary<string, object?>> secondary,
|
||||
string query,
|
||||
double primaryBoost)
|
||||
{
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"InterleaveByScore",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
return (List<Dictionary<string, object?>>)method!.Invoke(
|
||||
controller,
|
||||
[primary, secondary, query, primaryBoost])!;
|
||||
}
|
||||
|
||||
private static double InvokeCalculateItemRelevanceScore(
|
||||
JellyfinController controller,
|
||||
string query,
|
||||
Dictionary<string, object?> item)
|
||||
{
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"CalculateItemRelevanceScore",
|
||||
BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
return (double)method!.Invoke(controller, [query, item])!;
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> CreateItem(string name, string? id = null)
|
||||
{
|
||||
return new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = name,
|
||||
["Id"] = id ?? name
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?> CreateTypedItem(string type, string name, string id)
|
||||
{
|
||||
var item = CreateItem(name, id);
|
||||
item["Type"] = type;
|
||||
return item;
|
||||
}
|
||||
|
||||
private static string GetName(Dictionary<string, object?> item)
|
||||
{
|
||||
return item["Name"]?.ToString() ?? string.Empty;
|
||||
}
|
||||
|
||||
private static string GetId(Dictionary<string, object?> item)
|
||||
{
|
||||
return item["Id"]?.ToString() ?? string.Empty;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using allstarr.Controllers;
|
||||
using allstarr.Models.Jellyfin;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class JellyfinSearchResponseSerializationTests
|
||||
{
|
||||
[Fact]
|
||||
public void SerializeSearchResponseJson_PreservesPascalCaseShape()
|
||||
{
|
||||
var payload = new JellyfinItemsResponse
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = "BTS",
|
||||
["Type"] = "MusicAlbum"
|
||||
}
|
||||
],
|
||||
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.Equal(
|
||||
"{\"Items\":[{\"Name\":\"BTS\",\"Type\":\"MusicAlbum\"}],\"TotalRecordCount\":1,\"StartIndex\":0}",
|
||||
json);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SerializeSearchResponseJson_FallsBackForMixedRuntimeShapes()
|
||||
{
|
||||
using var rawDoc = JsonDocument.Parse("""
|
||||
{
|
||||
"ServerId": "c17d351d3af24c678a6d8049c212d522",
|
||||
"RunTimeTicks": 2234068710
|
||||
}
|
||||
""");
|
||||
|
||||
var payload = new JellyfinItemsResponse
|
||||
{
|
||||
Items =
|
||||
[
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["Name"] = "Harleys in Hawaii",
|
||||
["Type"] = "MusicAlbum",
|
||||
["MediaSources"] = new Dictionary<string, object?>[]
|
||||
{
|
||||
new Dictionary<string, object?>
|
||||
{
|
||||
["RunTimeTicks"] = 2234068710L,
|
||||
["MediaAttachments"] = new List<object>(),
|
||||
["Formats"] = new List<string>(),
|
||||
["RequiredHttpHeaders"] = new Dictionary<string, string>()
|
||||
}
|
||||
},
|
||||
["ArtistItems"] = new List<object>
|
||||
{
|
||||
new Dictionary<string, object?> { ["Name"] = "Katy Perry" }
|
||||
},
|
||||
["RawItem"] = rawDoc.RootElement.Clone()
|
||||
}
|
||||
],
|
||||
TotalRecordCount = 1,
|
||||
StartIndex = 0
|
||||
};
|
||||
|
||||
var method = typeof(JellyfinController).GetMethod(
|
||||
"SerializeSearchResponseJson",
|
||||
BindingFlags.NonPublic | BindingFlags.Static);
|
||||
|
||||
Assert.NotNull(method);
|
||||
|
||||
var json = (string)method!.Invoke(null, new object?[] { payload })!;
|
||||
|
||||
Assert.Contains("\"Items\":[{", json);
|
||||
Assert.Contains("\"MediaAttachments\":[]", json);
|
||||
Assert.Contains("\"ArtistItems\":[{\"Name\":\"Katy Perry\"}]", json);
|
||||
Assert.Contains("\"RawItem\":{\"ServerId\":\"c17d351d3af24c678a6d8049c212d522\",\"RunTimeTicks\":2234068710}", json);
|
||||
Assert.Contains("\"TotalRecordCount\":1", json);
|
||||
}
|
||||
}
|
||||
@@ -52,9 +52,17 @@ public class JellyfinSessionManagerTests
|
||||
public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
|
||||
{
|
||||
var requestedPaths = new ConcurrentBag<string>();
|
||||
var requestBodies = new ConcurrentDictionary<string, string>();
|
||||
var handler = new DelegateHttpMessageHandler((request, _) =>
|
||||
{
|
||||
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
|
||||
var path = request.RequestUri?.AbsolutePath ?? string.Empty;
|
||||
requestedPaths.Add(path);
|
||||
|
||||
if (request.Content != null)
|
||||
{
|
||||
requestBodies[path] = request.Content.ReadAsStringAsync().GetAwaiter().GetResult();
|
||||
}
|
||||
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
});
|
||||
|
||||
@@ -89,6 +97,12 @@ public class JellyfinSessionManagerTests
|
||||
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
|
||||
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
|
||||
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
|
||||
Assert.Equal(
|
||||
"{\"PlayableMediaTypes\":[\"Audio\"],\"SupportedCommands\":[\"Play\",\"Playstate\",\"PlayNext\"],\"SupportsMediaControl\":true,\"SupportsPersistentIdentifier\":true,\"SupportsSync\":false}",
|
||||
requestBodies["/Sessions/Capabilities/Full"]);
|
||||
Assert.Equal(
|
||||
"{\"ItemId\":\"item-123\",\"PositionTicks\":42}",
|
||||
requestBodies["/Sessions/Playing/Stopped"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -132,6 +146,46 @@ public class JellyfinSessionManagerTests
|
||||
Assert.Equal(45 * TimeSpan.TicksPerSecond, state.PositionTicks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EnsureSessionAsync_WithProxiedWebSocket_DoesNotPostSyntheticCapabilities()
|
||||
{
|
||||
var requestedPaths = new ConcurrentBag<string>();
|
||||
var handler = new DelegateHttpMessageHandler((request, _) =>
|
||||
{
|
||||
requestedPaths.Add(request.RequestUri?.AbsolutePath ?? string.Empty);
|
||||
return Task.FromResult(new HttpResponseMessage(HttpStatusCode.NoContent));
|
||||
});
|
||||
|
||||
var settings = new JellyfinSettings
|
||||
{
|
||||
Url = "http://127.0.0.1:1",
|
||||
ApiKey = "server-api-key",
|
||||
ClientName = "Allstarr",
|
||||
DeviceName = "Allstarr",
|
||||
DeviceId = "allstarr",
|
||||
ClientVersion = "1.0"
|
||||
};
|
||||
|
||||
var proxyService = CreateProxyService(handler, settings);
|
||||
using var manager = new JellyfinSessionManager(
|
||||
proxyService,
|
||||
Options.Create(settings),
|
||||
NullLogger<JellyfinSessionManager>.Instance);
|
||||
|
||||
var headers = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] =
|
||||
"MediaBrowser Client=\"Finamp\", Device=\"Android Auto\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
|
||||
};
|
||||
|
||||
await manager.RegisterProxiedWebSocketAsync("dev-123");
|
||||
|
||||
var ensured = await manager.EnsureSessionAsync("dev-123", "Finamp", "Android Auto", "1.0", headers);
|
||||
|
||||
Assert.True(ensured);
|
||||
Assert.DoesNotContain("/Sessions/Capabilities/Full", requestedPaths);
|
||||
}
|
||||
|
||||
private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
|
||||
{
|
||||
var httpClientFactory = new TestHttpClientFactory(handler);
|
||||
@@ -142,12 +196,19 @@ public class JellyfinSessionManagerTests
|
||||
|
||||
var cache = new RedisCacheService(
|
||||
Options.Create(new RedisSettings { Enabled = false }),
|
||||
NullLogger<RedisCacheService>.Instance);
|
||||
NullLogger<RedisCacheService>.Instance,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
|
||||
return new JellyfinProxyService(
|
||||
httpClientFactory,
|
||||
Options.Create(settings),
|
||||
httpContextAccessor,
|
||||
new JellyfinUserContextResolver(
|
||||
httpContextAccessor,
|
||||
httpClientFactory,
|
||||
Options.Create(settings),
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()),
|
||||
NullLogger<JellyfinUserContextResolver>.Instance),
|
||||
NullLogger<JellyfinProxyService>.Instance,
|
||||
cache);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using allstarr.Services.Lyrics;
|
||||
using allstarr.Services.Common;
|
||||
@@ -23,7 +24,7 @@ public class LrclibServiceTests
|
||||
// Create mock Redis cache
|
||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object, new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
_httpClient = new HttpClient
|
||||
{
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using System.Text.Json;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Models.Settings;
|
||||
|
||||
@@ -23,11 +27,19 @@ public class RedisCacheServiceTests
|
||||
});
|
||||
}
|
||||
|
||||
private RedisCacheService CreateService(IMemoryCache? memoryCache = null, IOptions<RedisSettings>? settings = null)
|
||||
{
|
||||
return new RedisCacheService(
|
||||
settings ?? _settings,
|
||||
_mockLogger.Object,
|
||||
memoryCache ?? new MemoryCache(new MemoryCacheOptions()));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_InitializesWithSettings()
|
||||
{
|
||||
// Act
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
@@ -45,7 +57,7 @@ public class RedisCacheServiceTests
|
||||
});
|
||||
|
||||
// Act - Constructor will try to connect but should handle failure gracefully
|
||||
var service = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||
var service = CreateService(settings: enabledSettings);
|
||||
|
||||
// Assert - Service should be created even if connection fails
|
||||
Assert.NotNull(service);
|
||||
@@ -55,7 +67,7 @@ public class RedisCacheServiceTests
|
||||
public async Task GetStringAsync_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetStringAsync("test:key");
|
||||
@@ -68,7 +80,7 @@ public class RedisCacheServiceTests
|
||||
public async Task GetAsync_WhenDisabled_ReturnsNull()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync<TestObject>("test:key");
|
||||
@@ -81,7 +93,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetStringAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.SetStringAsync("test:key", "test value");
|
||||
@@ -94,7 +106,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||
|
||||
// Act
|
||||
@@ -108,7 +120,7 @@ public class RedisCacheServiceTests
|
||||
public async Task DeleteAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.DeleteAsync("test:key");
|
||||
@@ -121,7 +133,7 @@ public class RedisCacheServiceTests
|
||||
public async Task ExistsAsync_WhenDisabled_ReturnsFalse()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.ExistsAsync("test:key");
|
||||
@@ -134,7 +146,7 @@ public class RedisCacheServiceTests
|
||||
public async Task DeleteByPatternAsync_WhenDisabled_ReturnsZero()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.DeleteByPatternAsync("test:*");
|
||||
@@ -147,7 +159,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetStringAsync_WithExpiry_AcceptsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
var expiry = TimeSpan.FromHours(1);
|
||||
|
||||
// Act
|
||||
@@ -161,7 +173,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetAsync_WithExpiry_AcceptsTimeSpan()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
var testObj = new TestObject { Id = 1, Name = "Test" };
|
||||
var expiry = TimeSpan.FromDays(30);
|
||||
|
||||
@@ -176,14 +188,14 @@ public class RedisCacheServiceTests
|
||||
public void IsEnabled_ReflectsSettings()
|
||||
{
|
||||
// Arrange
|
||||
var disabledService = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var disabledService = CreateService();
|
||||
|
||||
var enabledSettings = Options.Create(new RedisSettings
|
||||
{
|
||||
Enabled = true,
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
var enabledService = new RedisCacheService(enabledSettings, _mockLogger.Object);
|
||||
var enabledService = CreateService(settings: enabledSettings);
|
||||
|
||||
// Assert
|
||||
Assert.False(disabledService.IsEnabled);
|
||||
@@ -194,7 +206,7 @@ public class RedisCacheServiceTests
|
||||
public async Task GetAsync_DeserializesComplexObjects()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
|
||||
// Act
|
||||
var result = await service.GetAsync<ComplexTestObject>("test:complex");
|
||||
@@ -207,7 +219,7 @@ public class RedisCacheServiceTests
|
||||
public async Task SetAsync_SerializesComplexObjects()
|
||||
{
|
||||
// Arrange
|
||||
var service = new RedisCacheService(_settings, _mockLogger.Object);
|
||||
var service = CreateService();
|
||||
var complexObj = new ComplexTestObject
|
||||
{
|
||||
Id = 1,
|
||||
@@ -238,12 +250,184 @@ public class RedisCacheServiceTests
|
||||
});
|
||||
|
||||
// Act
|
||||
var service = new RedisCacheService(customSettings, _mockLogger.Object);
|
||||
var service = CreateService(settings: customSettings);
|
||||
|
||||
// Assert
|
||||
Assert.NotNull(service);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStringAsync_WhenDisabled_CachesValueInMemory()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
var setResult = await service.SetStringAsync("test:key", "test value");
|
||||
var cachedValue = await service.GetStringAsync("test:key");
|
||||
|
||||
Assert.False(setResult);
|
||||
Assert.Equal("test value", cachedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenDisabled_CachesSerializedObjectInMemory()
|
||||
{
|
||||
var service = CreateService();
|
||||
var expected = new TestObject { Id = 42, Name = "Tiered" };
|
||||
|
||||
var setResult = await service.SetAsync("test:object", expected);
|
||||
var cachedValue = await service.GetAsync<TestObject>("test:object");
|
||||
|
||||
Assert.False(setResult);
|
||||
Assert.NotNull(cachedValue);
|
||||
Assert.Equal(expected.Id, cachedValue.Id);
|
||||
Assert.Equal(expected.Name, cachedValue.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ExistsAsync_WhenValueOnlyExistsInMemory_ReturnsTrue()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await service.SetStringAsync("test:key", "test value");
|
||||
|
||||
var exists = await service.ExistsAsync("test:key");
|
||||
|
||||
Assert.True(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteAsync_WhenValueOnlyExistsInMemory_EvictsEntry()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await service.SetStringAsync("test:key", "test value");
|
||||
|
||||
var deleted = await service.DeleteAsync("test:key");
|
||||
var cachedValue = await service.GetStringAsync("test:key");
|
||||
|
||||
Assert.False(deleted);
|
||||
Assert.Null(cachedValue);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteByPatternAsync_WhenValuesOnlyExistInMemory_RemovesMatchingEntries()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await service.SetStringAsync("search:one", "1");
|
||||
await service.SetStringAsync("search:two", "2");
|
||||
await service.SetStringAsync("other:one", "3");
|
||||
|
||||
var deletedCount = await service.DeleteByPatternAsync("search:*");
|
||||
|
||||
Assert.Equal(2, deletedCount);
|
||||
Assert.Null(await service.GetStringAsync("search:one"));
|
||||
Assert.Null(await service.GetStringAsync("search:two"));
|
||||
Assert.Equal("3", await service.GetStringAsync("other:one"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetStringAsync_ImageKeysDoNotUseMemoryCache()
|
||||
{
|
||||
var service = CreateService();
|
||||
|
||||
await service.SetStringAsync("image:test:key", "binary-ish");
|
||||
|
||||
var cachedValue = await service.GetStringAsync("image:test:key");
|
||||
var exists = await service.ExistsAsync("image:test:key");
|
||||
|
||||
Assert.Null(cachedValue);
|
||||
Assert.False(exists);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenSongContainsRawJellyfinMetadata_CachesSerializedValueInMemory()
|
||||
{
|
||||
var service = CreateService();
|
||||
var songs = new List<Song> { CreateLocalSongWithRawJellyfinMetadata() };
|
||||
|
||||
var setResult = await service.SetAsync("test:songs:raw-jellyfin", songs);
|
||||
var cachedValue = await service.GetAsync<List<Song>>("test:songs:raw-jellyfin");
|
||||
|
||||
Assert.False(setResult);
|
||||
Assert.NotNull(cachedValue);
|
||||
|
||||
var roundTrippedSong = Assert.Single(cachedValue!);
|
||||
Assert.True(JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedSong, out var rawItem));
|
||||
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
|
||||
|
||||
var mediaSources = Assert.IsType<JsonElement>(roundTrippedSong.JellyfinMetadata!["MediaSources"]);
|
||||
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
|
||||
Assert.Equal(2234068710L, mediaSources[0].GetProperty("RunTimeTicks").GetInt64());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SetAsync_WhenMatchedTracksContainRawJellyfinMetadata_CachesSerializedValueInMemory()
|
||||
{
|
||||
var service = CreateService();
|
||||
var matchedTracks = new List<MatchedTrack>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Position = 0,
|
||||
SpotifyId = "spotify-1",
|
||||
SpotifyTitle = "Track",
|
||||
SpotifyArtist = "Artist",
|
||||
MatchType = "fuzzy",
|
||||
MatchedSong = CreateLocalSongWithRawJellyfinMetadata()
|
||||
}
|
||||
};
|
||||
|
||||
var setResult = await service.SetAsync("test:matched:raw-jellyfin", matchedTracks);
|
||||
var cachedValue = await service.GetAsync<List<MatchedTrack>>("test:matched:raw-jellyfin");
|
||||
|
||||
Assert.False(setResult);
|
||||
Assert.NotNull(cachedValue);
|
||||
|
||||
var roundTrippedMatch = Assert.Single(cachedValue!);
|
||||
Assert.True(
|
||||
JellyfinItemSnapshotHelper.TryGetClonedRawItemSnapshot(roundTrippedMatch.MatchedSong, out var rawItem));
|
||||
Assert.Equal("song-1", ((JsonElement)rawItem["Id"]!).GetString());
|
||||
|
||||
var mediaSources =
|
||||
Assert.IsType<JsonElement>(roundTrippedMatch.MatchedSong.JellyfinMetadata!["MediaSources"]);
|
||||
Assert.Equal(JsonValueKind.Array, mediaSources.ValueKind);
|
||||
Assert.Equal("song-1", mediaSources[0].GetProperty("Id").GetString());
|
||||
}
|
||||
|
||||
private static Song CreateLocalSongWithRawJellyfinMetadata()
|
||||
{
|
||||
var song = new Song
|
||||
{
|
||||
Id = "song-1",
|
||||
Title = "Track",
|
||||
Artist = "Artist",
|
||||
Album = "Album",
|
||||
IsLocal = true
|
||||
};
|
||||
|
||||
using var doc = JsonDocument.Parse("""
|
||||
{
|
||||
"Id": "song-1",
|
||||
"ServerId": "c17d351d3af24c678a6d8049c212d522",
|
||||
"RunTimeTicks": 2234068710,
|
||||
"MediaSources": [
|
||||
{
|
||||
"Id": "song-1",
|
||||
"RunTimeTicks": 2234068710
|
||||
}
|
||||
]
|
||||
}
|
||||
""");
|
||||
|
||||
JellyfinItemSnapshotHelper.StoreRawItemSnapshot(song, doc.RootElement);
|
||||
song.JellyfinMetadata ??= new Dictionary<string, object?>();
|
||||
song.JellyfinMetadata["MediaSources"] =
|
||||
JsonSerializer.Deserialize<object>(doc.RootElement.GetProperty("MediaSources").GetRawText());
|
||||
|
||||
return song;
|
||||
}
|
||||
|
||||
private class TestObject
|
||||
{
|
||||
public int Id { get; set; }
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public sealed class RuntimeEnvConfigurationTests : IDisposable
|
||||
{
|
||||
private readonly string _envFilePath = Path.Combine(
|
||||
Path.GetTempPath(),
|
||||
$"allstarr-runtime-{Guid.NewGuid():N}.env");
|
||||
|
||||
[Fact]
|
||||
public void MapEnvVarToConfiguration_MapsFlatKeyToNestedConfigKey()
|
||||
{
|
||||
var mappings = RuntimeEnvConfiguration
|
||||
.MapEnvVarToConfiguration("SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS", "7")
|
||||
.ToList();
|
||||
|
||||
var mapping = Assert.Single(mappings);
|
||||
Assert.Equal("SpotifyImport:MatchingIntervalHours", mapping.Key);
|
||||
Assert.Equal("7", mapping.Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapEnvVarToConfiguration_MapsSharedBackendKeysToBothSections()
|
||||
{
|
||||
var mappings = RuntimeEnvConfiguration
|
||||
.MapEnvVarToConfiguration("MUSIC_SERVICE", "Qobuz")
|
||||
.OrderBy(x => x.Key, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
Assert.Equal(2, mappings.Count);
|
||||
Assert.Equal("Jellyfin:MusicService", mappings[0].Key);
|
||||
Assert.Equal("Qobuz", mappings[0].Value);
|
||||
Assert.Equal("Subsonic:MusicService", mappings[1].Key);
|
||||
Assert.Equal("Qobuz", mappings[1].Value);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapEnvVarToConfiguration_IgnoresComposeOnlyMountKeys()
|
||||
{
|
||||
var mappings = RuntimeEnvConfiguration
|
||||
.MapEnvVarToConfiguration("DOWNLOAD_PATH", "./downloads")
|
||||
.ToList();
|
||||
|
||||
Assert.Empty(mappings);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void LoadDotEnvOverrides_StripsQuotesAndSupportsDoubleUnderscoreKeys()
|
||||
{
|
||||
File.WriteAllText(
|
||||
_envFilePath,
|
||||
"""
|
||||
SPOTIFY_API_SESSION_COOKIE="secret-cookie"
|
||||
Admin__EnableEnvExport=true
|
||||
""");
|
||||
|
||||
var overrides = RuntimeEnvConfiguration.LoadDotEnvOverrides(_envFilePath);
|
||||
|
||||
Assert.Equal("secret-cookie", overrides["SpotifyApi:SessionCookie"]);
|
||||
Assert.Equal("true", overrides["Admin:EnableEnvExport"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void AddDotEnvOverrides_OverridesEarlierConfigurationValues()
|
||||
{
|
||||
File.WriteAllText(_envFilePath, "SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS=7\n");
|
||||
|
||||
var configuration = new ConfigurationManager();
|
||||
configuration.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["SpotifyImport:MatchingIntervalHours"] = "24"
|
||||
});
|
||||
|
||||
RuntimeEnvConfiguration.AddDotEnvOverrides(configuration, _envFilePath);
|
||||
|
||||
Assert.Equal(7, configuration.GetValue<int>("SpotifyImport:MatchingIntervalHours"));
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (File.Exists(_envFilePath))
|
||||
{
|
||||
File.Delete(_envFilePath);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ using Xunit;
|
||||
using Moq;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.Spotify;
|
||||
@@ -30,7 +31,7 @@ public class SpotifyMappingServiceTests
|
||||
ConnectionString = "localhost:6379"
|
||||
});
|
||||
|
||||
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object);
|
||||
_cache = new RedisCacheService(redisSettings, _mockCacheLogger.Object, new MemoryCache(new MemoryCacheOptions()));
|
||||
_service = new SpotifyMappingService(_cache, _mockLogger.Object);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Tests;
|
||||
|
||||
public class SpotifyPlaylistScopeResolverTests
|
||||
{
|
||||
[Fact]
|
||||
public void ResolveConfig_PrefersExactJellyfinIdOverDuplicatePlaylistName()
|
||||
{
|
||||
var settings = new SpotifyImportSettings
|
||||
{
|
||||
Playlists =
|
||||
{
|
||||
new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = "Discover Weekly",
|
||||
Id = "spotify-a",
|
||||
JellyfinId = "jellyfin-a",
|
||||
UserId = "user-a"
|
||||
},
|
||||
new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = "Discover Weekly",
|
||||
Id = "spotify-b",
|
||||
JellyfinId = "jellyfin-b",
|
||||
UserId = "user-b"
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
var resolved = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
settings,
|
||||
"Discover Weekly",
|
||||
userId: "user-a",
|
||||
jellyfinPlaylistId: "jellyfin-b");
|
||||
|
||||
Assert.NotNull(resolved);
|
||||
Assert.Equal("spotify-b", resolved!.Id);
|
||||
Assert.Equal("jellyfin-b", resolved.JellyfinId);
|
||||
Assert.Equal("user-b", resolved.UserId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUserId_PrefersConfiguredValueAndTrimsFallback()
|
||||
{
|
||||
var configured = new SpotifyPlaylistConfig
|
||||
{
|
||||
UserId = " configured-user "
|
||||
};
|
||||
|
||||
Assert.Equal("configured-user", SpotifyPlaylistScopeResolver.GetUserId(configured, "fallback"));
|
||||
Assert.Equal("fallback-user", SpotifyPlaylistScopeResolver.GetUserId(null, " fallback-user "));
|
||||
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(null, " "));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetUserId_DoesNotApplyRequestFallbackToGlobalConfiguredPlaylist()
|
||||
{
|
||||
var globalConfiguredPlaylist = new SpotifyPlaylistConfig
|
||||
{
|
||||
Name = "On Repeat",
|
||||
JellyfinId = "7c2b218bd69b00e24c986363ba71852f"
|
||||
};
|
||||
|
||||
Assert.Null(SpotifyPlaylistScopeResolver.GetUserId(
|
||||
globalConfiguredPlaylist,
|
||||
"1635cd7d23144ba08251ebe22a56119e"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetScopeId_PrefersJellyfinIdThenSpotifyIdThenFallback()
|
||||
{
|
||||
var jellyfinScoped = new SpotifyPlaylistConfig
|
||||
{
|
||||
Id = "spotify-id",
|
||||
JellyfinId = " jellyfin-id "
|
||||
};
|
||||
|
||||
var spotifyScoped = new SpotifyPlaylistConfig
|
||||
{
|
||||
Id = " spotify-id "
|
||||
};
|
||||
|
||||
Assert.Equal("jellyfin-id", SpotifyPlaylistScopeResolver.GetScopeId(jellyfinScoped, "fallback"));
|
||||
Assert.Equal("spotify-id", SpotifyPlaylistScopeResolver.GetScopeId(spotifyScoped, "fallback"));
|
||||
Assert.Equal("fallback-id", SpotifyPlaylistScopeResolver.GetScopeId(null, " fallback-id "));
|
||||
Assert.Null(SpotifyPlaylistScopeResolver.GetScopeId(null, " "));
|
||||
}
|
||||
}
|
||||
@@ -85,7 +85,8 @@ public class SquidWTFDownloadServiceTests : IDisposable
|
||||
|
||||
var cache = new RedisCacheService(
|
||||
Options.Create(new RedisSettings { Enabled = false }),
|
||||
_redisLoggerMock.Object);
|
||||
_redisLoggerMock.Object,
|
||||
new Microsoft.Extensions.Caching.Memory.MemoryCache(new Microsoft.Extensions.Caching.Memory.MemoryCacheOptions()));
|
||||
|
||||
var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Xunit;
|
||||
using Moq;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Services.SquidWTF;
|
||||
@@ -42,7 +43,10 @@ public class SquidWTFMetadataServiceTests
|
||||
// Create mock Redis cache
|
||||
var mockRedisLogger = new Mock<ILogger<RedisCacheService>>();
|
||||
var mockRedisSettings = Options.Create(new RedisSettings { Enabled = false });
|
||||
_mockCache = new Mock<RedisCacheService>(mockRedisSettings, mockRedisLogger.Object);
|
||||
_mockCache = new Mock<RedisCacheService>(
|
||||
mockRedisSettings,
|
||||
mockRedisLogger.Object,
|
||||
new MemoryCache(new MemoryCacheOptions()));
|
||||
|
||||
_apiUrls = new List<string>
|
||||
{
|
||||
@@ -299,6 +303,65 @@ public class SquidWTFMetadataServiceTests
|
||||
Assert.NotNull(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SearchAllAsync_WithZeroLimits_SkipsUnusedBuckets()
|
||||
{
|
||||
var requestKinds = new List<string>();
|
||||
var handler = new StubHttpMessageHandler(request =>
|
||||
{
|
||||
var trackQuery = GetQueryParameter(request.RequestUri!, "s");
|
||||
var albumQuery = GetQueryParameter(request.RequestUri!, "al");
|
||||
var artistQuery = GetQueryParameter(request.RequestUri!, "a");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(trackQuery))
|
||||
{
|
||||
requestKinds.Add("song");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(CreateTrackSearchResponse(CreateTrackPayload(1, "Song", "USRC12345678")))
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(albumQuery))
|
||||
{
|
||||
requestKinds.Add("album");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(CreateAlbumSearchResponse())
|
||||
};
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(artistQuery))
|
||||
{
|
||||
requestKinds.Add("artist");
|
||||
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||
{
|
||||
Content = new StringContent(CreateArtistSearchResponse())
|
||||
};
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"Unexpected request URI: {request.RequestUri}");
|
||||
});
|
||||
|
||||
var httpClient = new HttpClient(handler);
|
||||
_mockHttpClientFactory.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||
|
||||
var service = new SquidWTFMetadataService(
|
||||
_mockHttpClientFactory.Object,
|
||||
_subsonicSettings,
|
||||
_squidwtfSettings,
|
||||
_mockLogger.Object,
|
||||
_mockCache.Object,
|
||||
new List<string> { "https://test1.example.com" });
|
||||
|
||||
var result = await service.SearchAllAsync("OK Computer", 0, 5, 0);
|
||||
|
||||
Assert.Empty(result.Songs);
|
||||
Assert.Single(result.Albums);
|
||||
Assert.Empty(result.Artists);
|
||||
Assert.Equal(new[] { "album" }, requestKinds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ExplicitFilter_RespectsSettings()
|
||||
{
|
||||
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </summary>
|
||||
public const string Version = "1.4.1";
|
||||
public const string Version = "1.4.7";
|
||||
}
|
||||
|
||||
@@ -580,7 +580,7 @@ public class ConfigController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = "Configuration updated. Restart container to apply changes.",
|
||||
message = "Configuration updated. Restart Allstarr to apply changes.",
|
||||
updatedKeys = appliedUpdates,
|
||||
requiresRestart = true,
|
||||
envFilePath = _helperService.GetEnvFilePath()
|
||||
@@ -637,11 +637,11 @@ public class ConfigController : ControllerBase
|
||||
{
|
||||
var keysToDelete = new[]
|
||||
{
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
$"spotify:matched:{playlist.Name}", // Legacy key
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name)
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
|
||||
};
|
||||
|
||||
foreach (var key in keysToDelete)
|
||||
@@ -696,7 +696,7 @@ public class ConfigController : ControllerBase
|
||||
_logger.LogWarning("Docker socket not available at {Path}", socketPath);
|
||||
return StatusCode(503, new {
|
||||
error = "Docker socket not available",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
message = "Please restart manually: docker restart allstarr"
|
||||
});
|
||||
}
|
||||
|
||||
@@ -749,7 +749,7 @@ public class ConfigController : ControllerBase
|
||||
_logger.LogError("Failed to restart container: {StatusCode} - {Body}", response.StatusCode, errorBody);
|
||||
return StatusCode((int)response.StatusCode, new {
|
||||
error = "Failed to restart container",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
message = "Please restart manually: docker restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -758,7 +758,7 @@ public class ConfigController : ControllerBase
|
||||
_logger.LogError(ex, "Error restarting container");
|
||||
return StatusCode(500, new {
|
||||
error = "Failed to restart container",
|
||||
message = "Please restart manually: docker-compose restart allstarr"
|
||||
message = "Please restart manually: docker restart allstarr"
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -890,7 +890,7 @@ public class ConfigController : ControllerBase
|
||||
return Ok(new
|
||||
{
|
||||
success = true,
|
||||
message = ".env file imported successfully. Restart the application for changes to take effect."
|
||||
message = ".env file imported successfully. Restart Allstarr for changes to take effect."
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
|
||||
@@ -9,7 +9,9 @@ using allstarr.Services.Admin;
|
||||
using allstarr.Services.Spotify;
|
||||
using allstarr.Services.Scrobbling;
|
||||
using allstarr.Services.SquidWTF;
|
||||
using System.Diagnostics;
|
||||
using System.Runtime;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
@@ -18,6 +20,9 @@ namespace allstarr.Controllers;
|
||||
[ServiceFilter(typeof(AdminPortFilter))]
|
||||
public class DiagnosticsController : ControllerBase
|
||||
{
|
||||
private const string SquidWtfProbeSearchQuery = "22 Taylor Swift";
|
||||
private const string SquidWtfProbeTrackId = "227242909";
|
||||
private const string SquidWtfProbeQuality = "LOW";
|
||||
private readonly ILogger<DiagnosticsController> _logger;
|
||||
private readonly IConfiguration _configuration;
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
@@ -29,6 +34,8 @@ public class DiagnosticsController : ControllerBase
|
||||
private readonly RedisCacheService _cache;
|
||||
private readonly SpotifySessionCookieService _spotifySessionCookieService;
|
||||
private readonly List<string> _squidWtfApiUrls;
|
||||
private readonly List<string> _squidWtfStreamingUrls;
|
||||
private readonly IHttpClientFactory _httpClientFactory;
|
||||
private static int _urlIndex = 0;
|
||||
private static readonly object _urlIndexLock = new();
|
||||
|
||||
@@ -43,7 +50,8 @@ public class DiagnosticsController : ControllerBase
|
||||
IOptions<SquidWTFSettings> squidWtfSettings,
|
||||
SpotifySessionCookieService spotifySessionCookieService,
|
||||
SquidWtfEndpointCatalog squidWtfEndpointCatalog,
|
||||
RedisCacheService cache)
|
||||
RedisCacheService cache,
|
||||
IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
_logger = logger;
|
||||
_configuration = configuration;
|
||||
@@ -56,6 +64,8 @@ public class DiagnosticsController : ControllerBase
|
||||
_spotifySessionCookieService = spotifySessionCookieService;
|
||||
_cache = cache;
|
||||
_squidWtfApiUrls = squidWtfEndpointCatalog.ApiUrls;
|
||||
_squidWtfStreamingUrls = squidWtfEndpointCatalog.StreamingUrls;
|
||||
_httpClientFactory = httpClientFactory;
|
||||
}
|
||||
|
||||
[HttpGet("status")]
|
||||
@@ -161,6 +171,61 @@ public class DiagnosticsController : ControllerBase
|
||||
return Ok(new { baseUrl });
|
||||
}
|
||||
|
||||
[HttpPost("squidwtf/endpoints/test")]
|
||||
public async Task<IActionResult> TestSquidWtfEndpoints(CancellationToken cancellationToken)
|
||||
{
|
||||
var forbidden = RequireAdministratorForSensitiveOperation("squidwtf endpoint diagnostics");
|
||||
if (forbidden != null)
|
||||
{
|
||||
return forbidden;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var rows = BuildSquidWtfEndpointRows();
|
||||
_logger.LogInformation(
|
||||
"Starting SquidWTF endpoint diagnostics for {RowCount} hosts ({ApiCount} API URLs, {StreamingCount} streaming URLs)",
|
||||
rows.Count,
|
||||
_squidWtfApiUrls.Count,
|
||||
_squidWtfStreamingUrls.Count);
|
||||
|
||||
var probeTasks = rows.Select(row => PopulateProbeResultsAsync(row, cancellationToken));
|
||||
await Task.WhenAll(probeTasks);
|
||||
|
||||
var apiUpCount = rows.Count(row => row.Api.Configured && row.Api.IsUp);
|
||||
var streamingUpCount = rows.Count(row => row.Streaming.Configured && row.Streaming.IsUp);
|
||||
_logger.LogInformation(
|
||||
"Completed SquidWTF endpoint diagnostics: API up {ApiUp}/{ApiConfigured}, streaming up {StreamingUp}/{StreamingConfigured}",
|
||||
apiUpCount,
|
||||
rows.Count(row => row.Api.Configured),
|
||||
streamingUpCount,
|
||||
rows.Count(row => row.Streaming.Configured));
|
||||
|
||||
var response = new SquidWtfEndpointHealthResponse
|
||||
{
|
||||
TestedAtUtc = DateTime.UtcNow,
|
||||
TotalRows = rows.Count,
|
||||
Endpoints = rows
|
||||
.OrderBy(r => r.Host, StringComparer.OrdinalIgnoreCase)
|
||||
.ToList()
|
||||
};
|
||||
|
||||
return Ok(response);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to test SquidWTF endpoints");
|
||||
return StatusCode(StatusCodes.Status500InternalServerError, new
|
||||
{
|
||||
error = "Failed to test SquidWTF endpoints"
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Get current configuration including cache settings
|
||||
/// </summary>
|
||||
@@ -423,6 +488,233 @@ public class DiagnosticsController : ControllerBase
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult? RequireAdministratorForSensitiveOperation(string operationName)
|
||||
{
|
||||
if (HttpContext.Items.TryGetValue(AdminAuthSessionService.HttpContextSessionItemKey, out var sessionObj) &&
|
||||
sessionObj is AdminAuthSession session &&
|
||||
session.IsAdministrator)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
_logger.LogWarning("Blocked sensitive admin operation '{Operation}' due to missing administrator session", operationName);
|
||||
return StatusCode(StatusCodes.Status403Forbidden, new
|
||||
{
|
||||
error = "Administrator permissions required",
|
||||
message = "This operation is restricted to Jellyfin administrators."
|
||||
});
|
||||
}
|
||||
|
||||
private List<SquidWtfEndpointHealthRow> BuildSquidWtfEndpointRows()
|
||||
{
|
||||
var rows = new Dictionary<string, SquidWtfEndpointHealthRow>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var apiUrl in _squidWtfApiUrls)
|
||||
{
|
||||
var key = GetEndpointKey(apiUrl);
|
||||
if (!rows.TryGetValue(key, out var row))
|
||||
{
|
||||
row = new SquidWtfEndpointHealthRow
|
||||
{
|
||||
Host = key
|
||||
};
|
||||
rows[key] = row;
|
||||
}
|
||||
|
||||
row.ApiUrl = apiUrl;
|
||||
}
|
||||
|
||||
foreach (var streamingUrl in _squidWtfStreamingUrls)
|
||||
{
|
||||
var key = GetEndpointKey(streamingUrl);
|
||||
if (!rows.TryGetValue(key, out var row))
|
||||
{
|
||||
row = new SquidWtfEndpointHealthRow
|
||||
{
|
||||
Host = key
|
||||
};
|
||||
rows[key] = row;
|
||||
}
|
||||
|
||||
row.StreamingUrl = streamingUrl;
|
||||
}
|
||||
|
||||
return rows.Values.ToList();
|
||||
}
|
||||
|
||||
private async Task PopulateProbeResultsAsync(SquidWtfEndpointHealthRow row, CancellationToken cancellationToken)
|
||||
{
|
||||
var apiTask = ProbeApiEndpointAsync(row.ApiUrl, cancellationToken);
|
||||
var streamingTask = ProbeStreamingEndpointAsync(row.StreamingUrl, cancellationToken);
|
||||
await Task.WhenAll(apiTask, streamingTask);
|
||||
|
||||
row.Api = await apiTask;
|
||||
row.Streaming = await streamingTask;
|
||||
|
||||
var anyFailure = (row.Api.Configured && !row.Api.IsUp) ||
|
||||
(row.Streaming.Configured && !row.Streaming.IsUp);
|
||||
_logger.Log(
|
||||
anyFailure ? LogLevel.Warning : LogLevel.Information,
|
||||
"SquidWTF probe {Host}: API {ApiState} ({ApiStatusCode}, {ApiLatencyMs}ms{ApiErrorSuffix}) | streaming {StreamingState} ({StreamingStatusCode}, {StreamingLatencyMs}ms{StreamingErrorSuffix})",
|
||||
row.Host,
|
||||
row.Api.State,
|
||||
row.Api.StatusCode?.ToString() ?? "n/a",
|
||||
row.Api.LatencyMs?.ToString() ?? "n/a",
|
||||
string.IsNullOrWhiteSpace(row.Api.Error) ? string.Empty : $", {row.Api.Error}",
|
||||
row.Streaming.State,
|
||||
row.Streaming.StatusCode?.ToString() ?? "n/a",
|
||||
row.Streaming.LatencyMs?.ToString() ?? "n/a",
|
||||
string.IsNullOrWhiteSpace(row.Streaming.Error) ? string.Empty : $", {row.Streaming.Error}");
|
||||
}
|
||||
|
||||
private async Task<SquidWtfEndpointProbeResult> ProbeApiEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = false,
|
||||
State = "missing",
|
||||
Error = "No API URL configured"
|
||||
};
|
||||
}
|
||||
|
||||
var requestUrl = $"{baseUrl}/search/?s={Uri.EscapeDataString(SquidWtfProbeSearchQuery)}&limit=1&offset=0";
|
||||
return await ProbeEndpointAsync(
|
||||
requestUrl,
|
||||
response => ResponseContainsSearchItemsAsync(response, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<SquidWtfEndpointProbeResult> ProbeStreamingEndpointAsync(string? baseUrl, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(baseUrl))
|
||||
{
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = false,
|
||||
State = "missing",
|
||||
Error = "No streaming URL configured"
|
||||
};
|
||||
}
|
||||
|
||||
var requestUrl = $"{baseUrl}/track/?id={Uri.EscapeDataString(SquidWtfProbeTrackId)}&quality={Uri.EscapeDataString(SquidWtfProbeQuality)}";
|
||||
return await ProbeEndpointAsync(
|
||||
requestUrl,
|
||||
response => ResponseContainsTrackManifestAsync(response, cancellationToken),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private async Task<SquidWtfEndpointProbeResult> ProbeEndpointAsync(
|
||||
string requestUrl,
|
||||
Func<HttpResponseMessage, Task<bool>> isHealthyResponse,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
using var client = CreateDiagnosticsHttpClient();
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl);
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
|
||||
try
|
||||
{
|
||||
using var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||
stopwatch.Stop();
|
||||
|
||||
var isHealthy = response.IsSuccessStatusCode && await isHealthyResponse(response);
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = true,
|
||||
IsUp = isHealthy,
|
||||
State = isHealthy ? "up" : "down",
|
||||
StatusCode = (int)response.StatusCode,
|
||||
LatencyMs = stopwatch.ElapsedMilliseconds,
|
||||
RequestUrl = requestUrl,
|
||||
Error = isHealthy ? null : $"Unexpected {(int)response.StatusCode} response"
|
||||
};
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = true,
|
||||
State = "timeout",
|
||||
LatencyMs = stopwatch.ElapsedMilliseconds,
|
||||
RequestUrl = requestUrl,
|
||||
Error = "Timed out"
|
||||
};
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = true,
|
||||
State = "down",
|
||||
StatusCode = ex.StatusCode.HasValue ? (int)ex.StatusCode.Value : null,
|
||||
LatencyMs = stopwatch.ElapsedMilliseconds,
|
||||
RequestUrl = requestUrl,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
stopwatch.Stop();
|
||||
return new SquidWtfEndpointProbeResult
|
||||
{
|
||||
Configured = true,
|
||||
State = "down",
|
||||
LatencyMs = stopwatch.ElapsedMilliseconds,
|
||||
RequestUrl = requestUrl,
|
||||
Error = ex.Message
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private HttpClient CreateDiagnosticsHttpClient()
|
||||
{
|
||||
var client = _httpClientFactory.CreateClient();
|
||||
client.Timeout = TimeSpan.FromSeconds(8);
|
||||
if (!client.DefaultRequestHeaders.UserAgent.Any())
|
||||
{
|
||||
client.DefaultRequestHeaders.UserAgent.ParseAdd("allstarr-admin-diagnostics/1.0");
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
private static async Task<bool> ResponseContainsSearchItemsAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
return document.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("items", out var items) &&
|
||||
items.ValueKind == JsonValueKind.Array;
|
||||
}
|
||||
|
||||
private static async Task<bool> ResponseContainsTrackManifestAsync(
|
||||
HttpResponseMessage response,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken);
|
||||
using var document = await JsonDocument.ParseAsync(stream, cancellationToken: cancellationToken);
|
||||
return document.RootElement.TryGetProperty("data", out var data) &&
|
||||
data.TryGetProperty("manifest", out var manifest) &&
|
||||
manifest.ValueKind == JsonValueKind.String &&
|
||||
!string.IsNullOrWhiteSpace(manifest.GetString());
|
||||
}
|
||||
|
||||
private static string GetEndpointKey(string url)
|
||||
{
|
||||
if (Uri.TryCreate(url, UriKind.Absolute, out var uri))
|
||||
{
|
||||
return uri.IsDefaultPort ? uri.Host : $"{uri.Host}:{uri.Port}";
|
||||
}
|
||||
|
||||
return url.Trim();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using System.Net.Http;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.AspNetCore.Http.Features;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
@@ -11,6 +14,20 @@ public partial class JellyfinController
|
||||
{
|
||||
#region Helpers
|
||||
|
||||
private static readonly HashSet<string> PassthroughResponseHeadersToSkip = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"Connection",
|
||||
"Keep-Alive",
|
||||
"Proxy-Authenticate",
|
||||
"Proxy-Authorization",
|
||||
"TE",
|
||||
"Trailer",
|
||||
"Transfer-Encoding",
|
||||
"Upgrade",
|
||||
"Content-Type",
|
||||
"Content-Length"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Helper to handle proxy responses with proper status code handling.
|
||||
/// </summary>
|
||||
@@ -48,6 +65,60 @@ public partial class JellyfinController
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
private async Task<IActionResult> ProxyJsonPassthroughAsync(string endpoint)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Match the previous proxy semantics for client compatibility.
|
||||
// Some Jellyfin clients/proxies cancel the ASP.NET request token aggressively
|
||||
// even though the upstream request would still complete successfully.
|
||||
var upstreamResponse = await _proxyService.GetPassthroughResponseAsync(
|
||||
endpoint,
|
||||
Request.Headers);
|
||||
|
||||
HttpContext.Response.RegisterForDispose(upstreamResponse);
|
||||
HttpContext.Features.Get<IHttpResponseBodyFeature>()?.DisableBuffering();
|
||||
Response.StatusCode = (int)upstreamResponse.StatusCode;
|
||||
Response.Headers["X-Accel-Buffering"] = "no";
|
||||
|
||||
CopyPassthroughResponseHeaders(upstreamResponse);
|
||||
|
||||
if (upstreamResponse.Content.Headers.ContentLength.HasValue)
|
||||
{
|
||||
Response.ContentLength = upstreamResponse.Content.Headers.ContentLength.Value;
|
||||
}
|
||||
|
||||
var contentType = upstreamResponse.Content.Headers.ContentType?.ToString() ?? "application/json";
|
||||
var stream = await upstreamResponse.Content.ReadAsStreamAsync();
|
||||
|
||||
return new FileStreamResult(stream, contentType);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to transparently proxy Jellyfin request for {Endpoint}", endpoint);
|
||||
return StatusCode(502, new { error = "Failed to connect to Jellyfin server" });
|
||||
}
|
||||
}
|
||||
|
||||
private void CopyPassthroughResponseHeaders(HttpResponseMessage upstreamResponse)
|
||||
{
|
||||
foreach (var header in upstreamResponse.Headers)
|
||||
{
|
||||
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
|
||||
{
|
||||
Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
foreach (var header in upstreamResponse.Content.Headers)
|
||||
{
|
||||
if (!PassthroughResponseHeadersToSkip.Contains(header.Key))
|
||||
{
|
||||
Response.Headers[header.Key] = header.Value.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Updates ChildCount for Spotify playlists in the response to show total tracks (local + matched).
|
||||
/// </summary>
|
||||
@@ -97,7 +168,7 @@ public partial class JellyfinController
|
||||
|
||||
if (!spotifyPlaylistCreatedDates.TryGetValue(playlistName, out var playlistCreatedDate))
|
||||
{
|
||||
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistName);
|
||||
playlistCreatedDate = await ResolveSpotifyPlaylistCreatedDateAsync(playlistConfig);
|
||||
spotifyPlaylistCreatedDates[playlistName] = playlistCreatedDate;
|
||||
}
|
||||
|
||||
@@ -107,7 +178,16 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Get matched external tracks (tracks that were successfully downloaded/matched)
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
|
||||
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
|
||||
? null
|
||||
: playlistConfig.UserId.Trim();
|
||||
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
|
||||
? playlistConfig.JellyfinId
|
||||
: playlistConfig.Id;
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
|
||||
_logger.LogDebug("Cache lookup for {Key}: {Count} matched tracks",
|
||||
@@ -116,7 +196,10 @@ public partial class JellyfinController
|
||||
// Fallback to legacy cache format
|
||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||
{
|
||||
var legacyKey = $"spotify:matched:{playlistName}";
|
||||
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var legacySongs = await _cache.GetAsync<List<Song>>(legacyKey);
|
||||
if (legacySongs != null && legacySongs.Count > 0)
|
||||
{
|
||||
@@ -132,7 +215,10 @@ public partial class JellyfinController
|
||||
// Prefer the currently served playlist items cache when available.
|
||||
// This most closely matches what the injected playlist endpoint will return.
|
||||
var exactServedCount = 0;
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var cachedPlaylistItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(playlistItemsKey);
|
||||
var exactServedRunTimeTicks = 0L;
|
||||
if (cachedPlaylistItems != null &&
|
||||
@@ -161,7 +247,7 @@ public partial class JellyfinController
|
||||
var localRunTimeTicks = 0L;
|
||||
try
|
||||
{
|
||||
var userId = _settings.UserId;
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items";
|
||||
var queryParams = new Dictionary<string, string>
|
||||
{
|
||||
@@ -264,11 +350,22 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(string playlistName)
|
||||
private async Task<DateTime?> ResolveSpotifyPlaylistCreatedDateAsync(SpotifyPlaylistConfig playlistConfig)
|
||||
{
|
||||
var playlistName = playlistConfig.Name;
|
||||
|
||||
try
|
||||
{
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
|
||||
var playlistScopeUserId = string.IsNullOrWhiteSpace(playlistConfig.UserId)
|
||||
? null
|
||||
: playlistConfig.UserId.Trim();
|
||||
var playlistScopeId = !string.IsNullOrWhiteSpace(playlistConfig.JellyfinId)
|
||||
? playlistConfig.JellyfinId
|
||||
: playlistConfig.Id;
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var cachedPlaylist = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
var createdAt = GetCreatedDateFromSpotifyPlaylist(cachedPlaylist);
|
||||
if (createdAt.HasValue)
|
||||
@@ -281,7 +378,10 @@ public partial class JellyfinController
|
||||
return null;
|
||||
}
|
||||
|
||||
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
var tracks = await _spotifyPlaylistFetcher.GetPlaylistTracksAsync(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistConfig.JellyfinId);
|
||||
var earliestTrackAddedAt = tracks
|
||||
.Where(t => t.AddedAt.HasValue)
|
||||
.Select(t => t.AddedAt!.Value.ToUniversalTime())
|
||||
@@ -407,6 +507,47 @@ public partial class JellyfinController
|
||||
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||
}
|
||||
|
||||
private static string? GetExactPlaylistItemsRequestId(string path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length != 3 ||
|
||||
!parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase) ||
|
||||
!parts[2].Equals("items", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return parts[1];
|
||||
}
|
||||
|
||||
private static string? ExtractImageTag(JsonElement item, string imageType)
|
||||
{
|
||||
if (item.TryGetProperty("ImageTags", out var imageTags) &&
|
||||
imageTags.ValueKind == JsonValueKind.Object)
|
||||
{
|
||||
foreach (var imageTag in imageTags.EnumerateObject())
|
||||
{
|
||||
if (string.Equals(imageTag.Name, imageType, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return imageTag.Value.GetString();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (string.Equals(imageType, "Primary", StringComparison.OrdinalIgnoreCase) &&
|
||||
item.TryGetProperty("PrimaryImageTag", out var primaryImageTag))
|
||||
{
|
||||
return primaryImageTag.GetString();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether Spotify playlist count enrichment should run for a response.
|
||||
/// We only run enrichment for playlist-oriented payloads to avoid mutating unrelated item lists
|
||||
|
||||
@@ -39,69 +39,9 @@ public partial class JellyfinController
|
||||
{
|
||||
var responseJson = result.RootElement.GetRawText();
|
||||
|
||||
// On successful auth, extract access token and post session capabilities in background
|
||||
if (statusCode == 200)
|
||||
{
|
||||
_logger.LogInformation("Authentication successful");
|
||||
|
||||
// Extract access token from response for session capabilities
|
||||
string? accessToken = null;
|
||||
if (result.RootElement.TryGetProperty("AccessToken", out var tokenEl))
|
||||
{
|
||||
accessToken = tokenEl.GetString();
|
||||
}
|
||||
|
||||
// Post session capabilities in background if we have a token
|
||||
if (!string.IsNullOrEmpty(accessToken))
|
||||
{
|
||||
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
||||
// Capture token in closure - don't use Request.Headers (will be disposed)
|
||||
var token = accessToken;
|
||||
var authHeader = AuthHeaderHelper.CreateAuthHeader(token, client, device, deviceId, version);
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
{
|
||||
_logger.LogDebug("🔧 Posting session capabilities after authentication");
|
||||
|
||||
// Build auth header with the new token
|
||||
var authHeaders = new HeaderDictionary
|
||||
{
|
||||
["X-Emby-Authorization"] = authHeader,
|
||||
["X-Emby-Token"] = token
|
||||
};
|
||||
|
||||
var capabilities = new
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = Array.Empty<string>(),
|
||||
SupportsMediaControl = false,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false
|
||||
};
|
||||
|
||||
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
|
||||
var (capResult, capStatus) =
|
||||
await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", capabilitiesJson,
|
||||
authHeaders);
|
||||
|
||||
if (capStatus == 204 || capStatus == 200)
|
||||
{
|
||||
_logger.LogDebug("✓ Session capabilities posted after auth ({StatusCode})",
|
||||
capStatus);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("⚠ Session capabilities returned {StatusCode} after auth",
|
||||
capStatus);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to post session capabilities after auth");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
using allstarr.Models.Jellyfin;
|
||||
using allstarr.Models.Scrobbling;
|
||||
using allstarr.Serialization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
@@ -226,7 +228,7 @@ public partial class JellyfinController
|
||||
|
||||
// Build minimal playback start with just the ghost UUID
|
||||
// Don't include the Item object - Jellyfin will just track the session without item details
|
||||
var playbackStart = new
|
||||
var playbackStart = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
@@ -236,7 +238,7 @@ public partial class JellyfinController
|
||||
PlayMethod = "DirectPlay"
|
||||
};
|
||||
|
||||
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
||||
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
|
||||
_logger.LogDebug("📤 Sending ghost playback start for external track: {Json}", playbackJson);
|
||||
|
||||
// Forward to Jellyfin with ghost UUID
|
||||
@@ -357,14 +359,13 @@ public partial class JellyfinController
|
||||
trackName ?? "Unknown", itemId);
|
||||
|
||||
// Build playback start info - Jellyfin will fetch item details itself
|
||||
var playbackStart = new
|
||||
var playbackStart = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
// Let Jellyfin fetch the item details - don't include NowPlayingItem
|
||||
ItemId = itemId ?? string.Empty,
|
||||
PositionTicks = positionTicks ?? 0
|
||||
};
|
||||
|
||||
var playbackJson = JsonSerializer.Serialize(playbackStart);
|
||||
var playbackJson = AllstarrJsonSerializer.Serialize(playbackStart);
|
||||
_logger.LogDebug("📤 Sending playback start: {Json}", playbackJson);
|
||||
|
||||
var (result, statusCode) =
|
||||
@@ -624,7 +625,7 @@ public partial class JellyfinController
|
||||
externalId);
|
||||
|
||||
var inferredStartGhostUuid = GenerateUuidFromString(itemId);
|
||||
var inferredExternalStartPayload = JsonSerializer.Serialize(new
|
||||
var inferredExternalStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = inferredStartGhostUuid,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
@@ -692,7 +693,7 @@ public partial class JellyfinController
|
||||
var ghostUuid = GenerateUuidFromString(itemId);
|
||||
|
||||
// Build progress report with ghost UUID
|
||||
var progressReport = new
|
||||
var progressReport = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = positionTicks ?? 0,
|
||||
@@ -702,7 +703,7 @@ public partial class JellyfinController
|
||||
PlayMethod = "DirectPlay"
|
||||
};
|
||||
|
||||
var progressJson = JsonSerializer.Serialize(progressReport);
|
||||
var progressJson = AllstarrJsonSerializer.Serialize(progressReport);
|
||||
|
||||
// Forward to Jellyfin with ghost UUID
|
||||
var (progressResult, progressStatusCode) =
|
||||
@@ -773,7 +774,7 @@ public partial class JellyfinController
|
||||
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
|
||||
trackName ?? "Unknown", itemId);
|
||||
|
||||
var inferredStartPayload = JsonSerializer.Serialize(new
|
||||
var inferredStartPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = itemId,
|
||||
PositionTicks = positionTicks ?? 0
|
||||
@@ -948,7 +949,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
var ghostUuid = GenerateUuidFromString(previousItemId);
|
||||
var inferredExternalStopPayload = JsonSerializer.Serialize(new
|
||||
var inferredExternalStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = previousPositionTicks ?? 0,
|
||||
@@ -997,7 +998,7 @@ public partial class JellyfinController
|
||||
});
|
||||
}
|
||||
|
||||
var inferredStopPayload = JsonSerializer.Serialize(new
|
||||
var inferredStopPayload = AllstarrJsonSerializer.Serialize(new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = previousItemId,
|
||||
PositionTicks = previousPositionTicks ?? 0,
|
||||
@@ -1062,7 +1063,7 @@ public partial class JellyfinController
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = ResolvePlaybackUserId(progressPayload);
|
||||
var userId = await ResolvePlaybackUserIdAsync(progressPayload);
|
||||
if (string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
_logger.LogDebug("Skipping local played signal for {ItemId} - no user id available", itemId);
|
||||
@@ -1098,7 +1099,7 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
private string? ResolvePlaybackUserId(JsonElement progressPayload)
|
||||
private async Task<string?> ResolvePlaybackUserIdAsync(JsonElement progressPayload)
|
||||
{
|
||||
if (progressPayload.TryGetProperty("UserId", out var userIdElement) &&
|
||||
userIdElement.ValueKind == JsonValueKind.String)
|
||||
@@ -1116,7 +1117,7 @@ public partial class JellyfinController
|
||||
return queryUserId;
|
||||
}
|
||||
|
||||
return _settings.UserId;
|
||||
return await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||
}
|
||||
|
||||
private static int? ToPlaybackPositionSeconds(long? positionTicks)
|
||||
@@ -1294,13 +1295,13 @@ public partial class JellyfinController
|
||||
// Report stop to Jellyfin with ghost UUID
|
||||
var ghostUuid = GenerateUuidFromString(itemId);
|
||||
|
||||
var externalStopInfo = new
|
||||
var externalStopInfo = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = ghostUuid,
|
||||
PositionTicks = positionTicks ?? 0
|
||||
};
|
||||
|
||||
var stopJson = JsonSerializer.Serialize(externalStopInfo);
|
||||
var stopJson = AllstarrJsonSerializer.Serialize(externalStopInfo);
|
||||
_logger.LogDebug("📤 Sending ghost playback stop for external track: {Json}", stopJson);
|
||||
|
||||
var (stopResult, stopStatusCode) =
|
||||
@@ -1469,7 +1470,7 @@ public partial class JellyfinController
|
||||
stopInfo["PositionTicks"] = positionTicks.Value;
|
||||
}
|
||||
|
||||
body = JsonSerializer.Serialize(stopInfo);
|
||||
body = AllstarrJsonSerializer.Serialize(stopInfo);
|
||||
_logger.LogDebug("📤 Sending playback stop body (IsPaused=false, {BodyLength} bytes)", body.Length);
|
||||
|
||||
var (result, statusCode) =
|
||||
@@ -1558,9 +1559,14 @@ public partial class JellyfinController
|
||||
string.Join(", ", Request.Headers.Keys.Where(h =>
|
||||
h.Contains("Auth", StringComparison.OrdinalIgnoreCase))));
|
||||
|
||||
// Read body if present
|
||||
string body = "{}";
|
||||
if ((method == "POST" || method == "PUT") && Request.ContentLength > 0)
|
||||
// Read body if present. Preserve true empty-body requests because Jellyfin
|
||||
// uses several POST session-control endpoints with query params only.
|
||||
string? body = null;
|
||||
var hasRequestBody = !HttpMethods.IsGet(method) &&
|
||||
(Request.ContentLength.GetValueOrDefault() > 0 ||
|
||||
Request.Headers.ContainsKey("Transfer-Encoding"));
|
||||
|
||||
if (hasRequestBody)
|
||||
{
|
||||
Request.EnableBuffering();
|
||||
using (var reader = new StreamReader(Request.Body, System.Text.Encoding.UTF8,
|
||||
@@ -1577,9 +1583,9 @@ public partial class JellyfinController
|
||||
var (result, statusCode) = method switch
|
||||
{
|
||||
"GET" => await _proxyService.GetJsonAsync(endpoint, null, Request.Headers),
|
||||
"POST" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers),
|
||||
"PUT" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for PUT
|
||||
"DELETE" => await _proxyService.PostJsonAsync(endpoint, body, Request.Headers), // Use POST for DELETE
|
||||
"POST" => await _proxyService.SendAsync(HttpMethod.Post, endpoint, body, Request.Headers, Request.ContentType),
|
||||
"PUT" => await _proxyService.SendAsync(HttpMethod.Put, endpoint, body, Request.Headers, Request.ContentType),
|
||||
"DELETE" => await _proxyService.SendAsync(HttpMethod.Delete, endpoint, body, Request.Headers, Request.ContentType),
|
||||
_ => (null, 405)
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
using System.Buffers;
|
||||
using System.Text.Json;
|
||||
using System.Text;
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Jellyfin;
|
||||
using allstarr.Models.Search;
|
||||
using allstarr.Models.Subsonic;
|
||||
using allstarr.Serialization;
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
@@ -24,6 +28,7 @@ public partial class JellyfinController
|
||||
[FromQuery] int startIndex = 0,
|
||||
[FromQuery] string? parentId = null,
|
||||
[FromQuery] string? artistIds = null,
|
||||
[FromQuery] string? contributingArtistIds = null,
|
||||
[FromQuery] string? albumArtistIds = null,
|
||||
[FromQuery] string? albumIds = null,
|
||||
[FromQuery] string? sortBy = null,
|
||||
@@ -32,13 +37,14 @@ public partial class JellyfinController
|
||||
{
|
||||
var boundSearchTerm = searchTerm;
|
||||
searchTerm = GetEffectiveSearchTerm(searchTerm, Request.QueryString.Value);
|
||||
string? searchCacheKey = null;
|
||||
|
||||
// AlbumArtistIds takes precedence over ArtistIds if both are provided
|
||||
var effectiveArtistIds = albumArtistIds ?? artistIds;
|
||||
var favoritesOnlyRequest = IsFavoritesOnlyRequest();
|
||||
|
||||
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
|
||||
searchTerm, includeItemTypes, parentId, artistIds, albumArtistIds, albumIds, userId);
|
||||
_logger.LogDebug("=== SEARCHITEMS V2 CALLED === searchTerm={SearchTerm}, includeItemTypes={ItemTypes}, parentId={ParentId}, artistIds={ArtistIds}, contributingArtistIds={ContributingArtistIds}, albumArtistIds={AlbumArtistIds}, albumIds={AlbumIds}, userId={UserId}",
|
||||
searchTerm, includeItemTypes, parentId, artistIds, contributingArtistIds, albumArtistIds, albumIds, userId);
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: rawQuery='{RawQuery}', boundSearchTerm='{BoundSearchTerm}', effectiveSearchTerm='{EffectiveSearchTerm}', includeItemTypes='{IncludeItemTypes}'",
|
||||
Request.QueryString.Value ?? string.Empty,
|
||||
@@ -49,15 +55,34 @@ public partial class JellyfinController
|
||||
// ============================================================================
|
||||
// REQUEST ROUTING LOGIC (Priority Order)
|
||||
// ============================================================================
|
||||
// 1. ArtistIds present (external) → Handle external artists (even with ParentId)
|
||||
// 2. AlbumIds present (external) → Handle external albums (even with ParentId)
|
||||
// 3. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
|
||||
// 4. ArtistIds present (library) → Proxy to Jellyfin with artist filter
|
||||
// 5. SearchTerm present → Integrated search (Jellyfin + external sources)
|
||||
// 6. Otherwise → Proxy browse request transparently to Jellyfin
|
||||
// 1. ContributingArtistIds present (external) → Handle external "appears on" albums
|
||||
// 2. ArtistIds present (external) → Handle external artists (even with ParentId)
|
||||
// 3. AlbumIds present (external) → Handle external albums (even with ParentId)
|
||||
// 4. ParentId present → GetChildItems (handles external playlists/albums/artists OR proxies library items)
|
||||
// 5. ArtistIds / ContributingArtistIds present (library) → Proxy to Jellyfin with full filter
|
||||
// 6. SearchTerm present → Integrated search (Jellyfin + external sources)
|
||||
// 7. Otherwise → Proxy browse request transparently to Jellyfin
|
||||
// ============================================================================
|
||||
|
||||
// PRIORITY 1: External artist filter - takes precedence over everything (including ParentId)
|
||||
// PRIORITY 1: External contributing artist filter - used by Jellyfin's "Appears on" album requests.
|
||||
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
|
||||
{
|
||||
var contributingArtistId = contributingArtistIds.Split(',')[0];
|
||||
var (isExternal, provider, type, externalId) = _localLibraryService.ParseExternalId(contributingArtistId);
|
||||
|
||||
if (isExternal)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Fetching contributing artist albums for external artist: {Provider}/{ExternalId}, type={Type}",
|
||||
provider,
|
||||
externalId,
|
||||
type);
|
||||
return await GetExternalContributorChildItems(provider!, type!, externalId!, includeItemTypes, HttpContext.RequestAborted);
|
||||
}
|
||||
// If library artist, fall through to proxy
|
||||
}
|
||||
|
||||
// PRIORITY 2: External artist filter - takes precedence over everything (including ParentId)
|
||||
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||
{
|
||||
var artistId = effectiveArtistIds.Split(',')[0]; // Take first artist if multiple
|
||||
@@ -85,7 +110,7 @@ public partial class JellyfinController
|
||||
// If library artist, fall through to handle with ParentId or proxy
|
||||
}
|
||||
|
||||
// PRIORITY 2: External album filter
|
||||
// PRIORITY 3: External album filter
|
||||
if (!string.IsNullOrWhiteSpace(albumIds))
|
||||
{
|
||||
var albumId = albumIds.Split(',')[0]; // Take first album if multiple
|
||||
@@ -105,23 +130,17 @@ public partial class JellyfinController
|
||||
var album = await _metadataService.GetAlbumAsync(provider!, externalId!, HttpContext.RequestAborted);
|
||||
if (album == null)
|
||||
{
|
||||
return new JsonResult(new
|
||||
{ Items = Array.Empty<object>(), TotalRecordCount = 0, StartIndex = startIndex });
|
||||
return CreateItemsResponse([], 0, startIndex);
|
||||
}
|
||||
|
||||
var albumItems = album.Songs.Select(song => _responseBuilder.ConvertSongToJellyfinItem(song)).ToList();
|
||||
|
||||
return new JsonResult(new
|
||||
{
|
||||
Items = albumItems,
|
||||
TotalRecordCount = albumItems.Count,
|
||||
StartIndex = startIndex
|
||||
});
|
||||
return CreateItemsResponse(albumItems, albumItems.Count, startIndex);
|
||||
}
|
||||
// If library album, fall through to handle with ParentId or proxy
|
||||
}
|
||||
|
||||
// PRIORITY 3: ParentId present - check if external first
|
||||
// PRIORITY 4: ParentId present - check if external first
|
||||
if (!string.IsNullOrWhiteSpace(parentId))
|
||||
{
|
||||
// Check if this is an external playlist
|
||||
@@ -163,7 +182,17 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
// PRIORITY 4: Library artist filter (already checked for external above)
|
||||
// PRIORITY 5: Library artist/contributing-artist filters (already checked for external above)
|
||||
if (!string.IsNullOrWhiteSpace(contributingArtistIds))
|
||||
{
|
||||
_logger.LogDebug("Library contributing artist filter requested, proxying to Jellyfin");
|
||||
var endpoint = userId != null
|
||||
? $"Users/{userId}/Items{Request.QueryString}"
|
||||
: $"Items{Request.QueryString}";
|
||||
var (result, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||
{
|
||||
// Library artist - proxy transparently with full query string
|
||||
@@ -175,13 +204,13 @@ public partial class JellyfinController
|
||||
return HandleProxyResponse(result, statusCode);
|
||||
}
|
||||
|
||||
// PRIORITY 5: Search term present - do integrated search (Jellyfin + external)
|
||||
// PRIORITY 6: Search term present - do integrated search (Jellyfin + external)
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm))
|
||||
{
|
||||
// Check cache for search results (only cache pure searches, not filtered searches)
|
||||
if (string.IsNullOrWhiteSpace(effectiveArtistIds) && string.IsNullOrWhiteSpace(albumIds))
|
||||
{
|
||||
var cacheKey = CacheKeyBuilder.BuildSearchKey(
|
||||
searchCacheKey = CacheKeyBuilder.BuildSearchKey(
|
||||
searchTerm,
|
||||
includeItemTypes,
|
||||
limit,
|
||||
@@ -192,18 +221,18 @@ public partial class JellyfinController
|
||||
recursive,
|
||||
userId,
|
||||
Request.Query["IsFavorite"].ToString());
|
||||
var cachedResult = await _cache.GetAsync<object>(cacheKey);
|
||||
var cachedResult = await _cache.GetStringAsync(searchCacheKey);
|
||||
|
||||
if (cachedResult != null)
|
||||
if (!string.IsNullOrWhiteSpace(cachedResult))
|
||||
{
|
||||
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", cacheKey);
|
||||
return new JsonResult(cachedResult);
|
||||
_logger.LogInformation("SEARCH TRACE: cache hit for key '{CacheKey}'", searchCacheKey);
|
||||
return Content(cachedResult, "application/json");
|
||||
}
|
||||
}
|
||||
|
||||
// Fall through to integrated search below
|
||||
}
|
||||
// PRIORITY 6: No filters, no search - proxy browse request transparently
|
||||
// PRIORITY 7: No filters, no search - proxy browse request transparently
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Browse request with no filters, proxying to Jellyfin with full query string");
|
||||
@@ -303,6 +332,7 @@ public partial class JellyfinController
|
||||
|
||||
// Run local and external searches in parallel
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
var externalSearchLimits = GetExternalSearchLimits(itemTypes, limit, includePlaylistsAsAlbums: true);
|
||||
var jellyfinTask = GetLocalSearchResultForCurrentRequest(
|
||||
cleanQuery,
|
||||
includeItemTypes,
|
||||
@@ -311,12 +341,29 @@ public partial class JellyfinController
|
||||
recursive,
|
||||
userId);
|
||||
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: external limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit);
|
||||
|
||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||
var externalTask = favoritesOnlyRequest
|
||||
? Task.FromResult(new SearchResult())
|
||||
: _parallelMetadataService != null
|
||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
|
||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
|
||||
? _parallelMetadataService.SearchAllAsync(
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit,
|
||||
HttpContext.RequestAborted)
|
||||
: _metadataService.SearchAllAsync(
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
|
||||
? Task.FromResult(new List<ExternalPlaylist>())
|
||||
@@ -384,11 +431,11 @@ public partial class JellyfinController
|
||||
var externalAlbumItems = externalResult.Albums.Select(a => _responseBuilder.ConvertAlbumToJellyfinItem(a)).ToList();
|
||||
var externalArtistItems = externalResult.Artists.Select(a => _responseBuilder.ConvertArtistToJellyfinItem(a)).ToList();
|
||||
|
||||
// Score-sort each source, then interleave by highest remaining score.
|
||||
// Keep only a small source preference for already-relevant primary results.
|
||||
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 72);
|
||||
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 78);
|
||||
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 1.5, boostMinScore: 75);
|
||||
// Keep Jellyfin/provider ordering intact.
|
||||
// Scores only decide which source leads each interleaving round.
|
||||
var allSongs = InterleaveByScore(jellyfinSongItems, externalSongItems, cleanQuery, primaryBoost: 5.0);
|
||||
var allAlbums = InterleaveByScore(jellyfinAlbumItems, externalAlbumItems, cleanQuery, primaryBoost: 5.0);
|
||||
var allArtists = InterleaveByScore(jellyfinArtistItems, externalArtistItems, cleanQuery, primaryBoost: 5.0);
|
||||
|
||||
// Log top results for debugging
|
||||
if (_logger.IsEnabled(LogLevel.Debug))
|
||||
@@ -437,13 +484,8 @@ public partial class JellyfinController
|
||||
_logger.LogDebug("No playlists found to merge with albums");
|
||||
}
|
||||
|
||||
// Merge albums and playlists using score-based interleaving (albums keep a light priority over playlists).
|
||||
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 2.0, boostMinScore: 70);
|
||||
mergedAlbumsAndPlaylists = ApplyRequestedAlbumOrderingIfApplicable(
|
||||
mergedAlbumsAndPlaylists,
|
||||
itemTypes,
|
||||
Request.Query["SortBy"].ToString(),
|
||||
Request.Query["SortOrder"].ToString());
|
||||
// Keep album/playlist source ordering intact and only let scores decide who leads each round.
|
||||
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 0.0);
|
||||
|
||||
_logger.LogDebug(
|
||||
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
|
||||
@@ -531,58 +573,20 @@ public partial class JellyfinController
|
||||
|
||||
try
|
||||
{
|
||||
// Return with PascalCase - use ContentResult to bypass JSON serialization issues
|
||||
var response = new
|
||||
var response = new JellyfinItemsResponse
|
||||
{
|
||||
Items = pagedItems,
|
||||
TotalRecordCount = items.Count,
|
||||
StartIndex = startIndex
|
||||
};
|
||||
|
||||
// Cache search results in Redis using the configured search TTL.
|
||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||
{
|
||||
if (externalHasRequestedTypeResults)
|
||||
{
|
||||
var cacheKey = CacheKeyBuilder.BuildSearchKey(
|
||||
return await WriteSearchItemsResponseAsync(
|
||||
response,
|
||||
searchTerm,
|
||||
includeItemTypes,
|
||||
limit,
|
||||
startIndex,
|
||||
parentId,
|
||||
sortBy,
|
||||
Request.Query["SortOrder"].ToString(),
|
||||
recursive,
|
||||
userId,
|
||||
Request.Query["IsFavorite"].ToString());
|
||||
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
|
||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
|
||||
CacheExtensions.SearchResultsTTL.TotalMinutes);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})",
|
||||
effectiveArtistIds,
|
||||
searchCacheKey,
|
||||
externalHasRequestedTypeResults,
|
||||
cleanQuery,
|
||||
includeItemTypes ?? string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
_logger.LogDebug("About to serialize response...");
|
||||
|
||||
var json = System.Text.Json.JsonSerializer.Serialize(response, new System.Text.Json.JsonSerializerOptions
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DictionaryKeyPolicy = null
|
||||
});
|
||||
|
||||
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");
|
||||
includeItemTypes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -591,6 +595,49 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeSearchResponseJson(JellyfinItemsResponse response)
|
||||
{
|
||||
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)
|
||||
{
|
||||
await _cache.SetStringAsync(searchCacheKey!, json, CacheExtensions.SearchResultsTTL);
|
||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
|
||||
CacheExtensions.SearchResultsTTL.TotalMinutes);
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(searchTerm) &&
|
||||
string.IsNullOrWhiteSpace(effectiveArtistIds) &&
|
||||
!string.IsNullOrWhiteSpace(searchCacheKey))
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: skipped cache write for query '{Query}' because requested external result buckets were empty (types={ItemTypes})",
|
||||
cleanQuery,
|
||||
includeItemTypes ?? string.Empty);
|
||||
}
|
||||
|
||||
return new EmptyResult();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets child items of a parent (tracks in album, albums for artist).
|
||||
/// </summary>
|
||||
@@ -681,11 +728,33 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
var cleanQuery = searchTerm.Trim().Trim('"');
|
||||
var requestedTypes = ParseItemTypes(includeItemTypes);
|
||||
var externalSearchLimits = GetExternalSearchLimits(requestedTypes, limit, includePlaylistsAsAlbums: false);
|
||||
var includesSongs = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||
var includesAlbums = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
||||
var includesArtists = requestedTypes == null || requestedTypes.Length == 0 || requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
_logger.LogInformation(
|
||||
"SEARCH TRACE: hint limits for query '{Query}' => songs={SongLimit}, albums={AlbumLimit}, artists={ArtistLimit}",
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit);
|
||||
|
||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||
var externalTask = _parallelMetadataService != null
|
||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
|
||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
|
||||
? _parallelMetadataService.SearchAllAsync(
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit,
|
||||
HttpContext.RequestAborted)
|
||||
: _metadataService.SearchAllAsync(
|
||||
cleanQuery,
|
||||
externalSearchLimits.SongLimit,
|
||||
externalSearchLimits.AlbumLimit,
|
||||
externalSearchLimits.ArtistLimit,
|
||||
HttpContext.RequestAborted);
|
||||
|
||||
// Run searches in parallel (local Jellyfin hints + external providers)
|
||||
var jellyfinTask = GetLocalSearchHintsResultForCurrentRequest(cleanQuery, userId);
|
||||
@@ -698,9 +767,15 @@ public partial class JellyfinController
|
||||
var (localSongs, localAlbums, localArtists) = _modelMapper.ParseSearchHintsResponse(jellyfinResult);
|
||||
|
||||
// NO deduplication - merge all results and take top matches
|
||||
var allSongs = localSongs.Concat(externalResult.Songs).Take(limit).ToList();
|
||||
var allAlbums = localAlbums.Concat(externalResult.Albums).Take(limit).ToList();
|
||||
var allArtists = localArtists.Concat(externalResult.Artists).Take(limit).ToList();
|
||||
var allSongs = includesSongs
|
||||
? localSongs.Concat(externalResult.Songs).Take(limit).ToList()
|
||||
: new List<Song>();
|
||||
var allAlbums = includesAlbums
|
||||
? localAlbums.Concat(externalResult.Albums).Take(limit).ToList()
|
||||
: new List<Album>();
|
||||
var allArtists = includesArtists
|
||||
? localArtists.Concat(externalResult.Artists).Take(limit).ToList()
|
||||
: new List<Artist>();
|
||||
|
||||
return _responseBuilder.CreateSearchHintsResponse(
|
||||
allSongs.Take(limit).ToList(),
|
||||
@@ -751,14 +826,91 @@ public partial class JellyfinController
|
||||
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static (int SongLimit, int AlbumLimit, int ArtistLimit) GetExternalSearchLimits(
|
||||
string[]? requestedTypes,
|
||||
int limit,
|
||||
bool includePlaylistsAsAlbums)
|
||||
{
|
||||
if (limit <= 0)
|
||||
{
|
||||
return (0, 0, 0);
|
||||
}
|
||||
|
||||
if (requestedTypes == null || requestedTypes.Length == 0)
|
||||
{
|
||||
return (limit, limit, limit);
|
||||
}
|
||||
|
||||
var includeSongs = requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||
var includeAlbums = requestedTypes.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) ||
|
||||
(includePlaylistsAsAlbums && requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase));
|
||||
var includeArtists = requestedTypes.Contains("MusicArtist", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
return (
|
||||
includeSongs ? limit : 0,
|
||||
includeAlbums ? limit : 0,
|
||||
includeArtists ? limit : 0);
|
||||
}
|
||||
|
||||
private static IActionResult CreateEmptyItemsResponse(int startIndex)
|
||||
{
|
||||
return new JsonResult(new
|
||||
return CreateItemsResponse([], 0, startIndex);
|
||||
}
|
||||
|
||||
private static ContentResult CreateItemsResponse(
|
||||
List<Dictionary<string, object?>> items,
|
||||
int totalRecordCount,
|
||||
int startIndex)
|
||||
{
|
||||
Items = Array.Empty<object>(),
|
||||
TotalRecordCount = 0,
|
||||
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(
|
||||
@@ -906,82 +1058,45 @@ public partial class JellyfinController
|
||||
|
||||
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Score-sorts each source and then interleaves by highest remaining score.
|
||||
/// This avoids weak head results in one source blocking stronger results later in that same source.
|
||||
/// Merges two source queues without reordering either queue.
|
||||
/// At each step, compare only the current head from each source and dequeue the winner.
|
||||
/// </summary>
|
||||
private List<Dictionary<string, object?>> InterleaveByScore(
|
||||
List<Dictionary<string, object?>> primaryItems,
|
||||
List<Dictionary<string, object?>> secondaryItems,
|
||||
string query,
|
||||
double primaryBoost,
|
||||
double boostMinScore = 70)
|
||||
double primaryBoost)
|
||||
{
|
||||
var primaryScored = primaryItems.Select((item, index) =>
|
||||
var primaryScored = primaryItems.Select(item =>
|
||||
{
|
||||
var baseScore = CalculateItemRelevanceScore(query, item);
|
||||
var finalScore = baseScore >= boostMinScore
|
||||
? Math.Min(100.0, baseScore + primaryBoost)
|
||||
: baseScore;
|
||||
return new
|
||||
{
|
||||
Item = item,
|
||||
BaseScore = baseScore,
|
||||
Score = finalScore,
|
||||
SourceIndex = index
|
||||
Score = Math.Min(100.0, CalculateItemRelevanceScore(query, item) + primaryBoost)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ThenByDescending(x => x.BaseScore)
|
||||
.ThenBy(x => x.SourceIndex)
|
||||
.ToList();
|
||||
|
||||
var secondaryScored = secondaryItems.Select((item, index) =>
|
||||
var secondaryScored = secondaryItems.Select(item =>
|
||||
{
|
||||
var baseScore = CalculateItemRelevanceScore(query, item);
|
||||
return new
|
||||
{
|
||||
Item = item,
|
||||
BaseScore = baseScore,
|
||||
Score = baseScore,
|
||||
SourceIndex = index
|
||||
Score = CalculateItemRelevanceScore(query, item)
|
||||
};
|
||||
})
|
||||
.OrderByDescending(x => x.Score)
|
||||
.ThenByDescending(x => x.BaseScore)
|
||||
.ThenBy(x => x.SourceIndex)
|
||||
.ToList();
|
||||
|
||||
var result = new List<Dictionary<string, object?>>(primaryScored.Count + secondaryScored.Count);
|
||||
int primaryIdx = 0, secondaryIdx = 0;
|
||||
|
||||
while (primaryIdx < primaryScored.Count || secondaryIdx < secondaryScored.Count)
|
||||
while (primaryIdx < primaryScored.Count && secondaryIdx < secondaryScored.Count)
|
||||
{
|
||||
if (primaryIdx >= primaryScored.Count)
|
||||
{
|
||||
result.Add(secondaryScored[secondaryIdx++].Item);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (secondaryIdx >= secondaryScored.Count)
|
||||
{
|
||||
result.Add(primaryScored[primaryIdx++].Item);
|
||||
continue;
|
||||
}
|
||||
|
||||
var primaryCandidate = primaryScored[primaryIdx];
|
||||
var secondaryCandidate = secondaryScored[secondaryIdx];
|
||||
|
||||
if (primaryCandidate.Score > secondaryCandidate.Score)
|
||||
{
|
||||
result.Add(primaryScored[primaryIdx++].Item);
|
||||
}
|
||||
else if (secondaryCandidate.Score > primaryCandidate.Score)
|
||||
{
|
||||
result.Add(secondaryScored[secondaryIdx++].Item);
|
||||
}
|
||||
else if (primaryCandidate.BaseScore >= secondaryCandidate.BaseScore)
|
||||
if (primaryCandidate.Score >= secondaryCandidate.Score)
|
||||
{
|
||||
result.Add(primaryScored[primaryIdx++].Item);
|
||||
}
|
||||
@@ -991,146 +1106,31 @@ public partial class JellyfinController
|
||||
}
|
||||
}
|
||||
|
||||
while (primaryIdx < primaryScored.Count)
|
||||
{
|
||||
result.Add(primaryScored[primaryIdx++].Item);
|
||||
}
|
||||
|
||||
while (secondaryIdx < secondaryScored.Count)
|
||||
{
|
||||
result.Add(secondaryScored[secondaryIdx++].Item);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Calculates query relevance for a search item.
|
||||
/// Title is primary; metadata context is secondary and down-weighted.
|
||||
/// Calculates query relevance using the product's per-type rules.
|
||||
/// </summary>
|
||||
private double CalculateItemRelevanceScore(string query, Dictionary<string, object?> item)
|
||||
{
|
||||
var title = GetItemName(item);
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return GetItemType(item) switch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var titleScore = FuzzyMatcher.CalculateSimilarityAggressive(query, title);
|
||||
var searchText = BuildItemSearchText(item, title);
|
||||
|
||||
if (string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return titleScore;
|
||||
}
|
||||
|
||||
var metadataScore = FuzzyMatcher.CalculateSimilarityAggressive(query, searchText);
|
||||
var weightedMetadataScore = metadataScore * 0.85;
|
||||
|
||||
var baseScore = Math.Max(titleScore, weightedMetadataScore);
|
||||
return ApplyQueryCoverageAdjustment(query, title, searchText, baseScore);
|
||||
}
|
||||
|
||||
private static double ApplyQueryCoverageAdjustment(string query, string title, string searchText, double baseScore)
|
||||
{
|
||||
var queryTokens = TokenizeForCoverage(query);
|
||||
if (queryTokens.Count < 2)
|
||||
{
|
||||
return baseScore;
|
||||
}
|
||||
|
||||
var titleCoverage = CalculateTokenCoverage(queryTokens, title);
|
||||
var searchCoverage = string.Equals(searchText, title, StringComparison.OrdinalIgnoreCase)
|
||||
? titleCoverage
|
||||
: CalculateTokenCoverage(queryTokens, searchText);
|
||||
|
||||
var coverage = Math.Max(titleCoverage, searchCoverage);
|
||||
|
||||
if (coverage >= 0.999)
|
||||
{
|
||||
return Math.Min(100.0, baseScore + 3.0);
|
||||
}
|
||||
|
||||
if (coverage >= 0.8)
|
||||
{
|
||||
return baseScore * 0.9;
|
||||
}
|
||||
|
||||
if (coverage >= 0.6)
|
||||
{
|
||||
return baseScore * 0.72;
|
||||
}
|
||||
|
||||
return baseScore * 0.5;
|
||||
}
|
||||
|
||||
private static double CalculateTokenCoverage(IReadOnlyList<string> queryTokens, string target)
|
||||
{
|
||||
var targetTokens = TokenizeForCoverage(target);
|
||||
if (queryTokens.Count == 0 || targetTokens.Count == 0)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
var matched = 0;
|
||||
foreach (var queryToken in queryTokens)
|
||||
{
|
||||
if (targetTokens.Any(targetToken => IsTokenMatch(queryToken, targetToken)))
|
||||
{
|
||||
matched++;
|
||||
}
|
||||
}
|
||||
|
||||
return (double)matched / queryTokens.Count;
|
||||
}
|
||||
|
||||
private static bool IsTokenMatch(string queryToken, string targetToken)
|
||||
{
|
||||
return queryToken.Equals(targetToken, StringComparison.Ordinal) ||
|
||||
queryToken.StartsWith(targetToken, StringComparison.Ordinal) ||
|
||||
targetToken.StartsWith(queryToken, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> TokenizeForCoverage(string text)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(text))
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var normalized = NormalizeForCoverage(text);
|
||||
var allTokens = normalized
|
||||
.Split(' ', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
if (allTokens.Count == 0)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
|
||||
var significant = allTokens
|
||||
.Where(token => token.Length >= 2 && !SearchStopWords.Contains(token))
|
||||
.ToList();
|
||||
|
||||
return significant.Count > 0
|
||||
? significant
|
||||
: allTokens.Where(token => token.Length >= 2).ToList();
|
||||
}
|
||||
|
||||
private static string NormalizeForCoverage(string text)
|
||||
{
|
||||
var normalized = RemoveDiacritics(text).ToLowerInvariant();
|
||||
normalized = normalized.Replace('&', ' ');
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"[^\w\s]", " ");
|
||||
normalized = System.Text.RegularExpressions.Regex.Replace(normalized, @"\s+", " ").Trim();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string RemoveDiacritics(string text)
|
||||
{
|
||||
var normalized = text.Normalize(NormalizationForm.FormD);
|
||||
var chars = new List<char>(normalized.Length);
|
||||
|
||||
foreach (var c in normalized)
|
||||
{
|
||||
if (System.Globalization.CharUnicodeInfo.GetUnicodeCategory(c) != System.Globalization.UnicodeCategory.NonSpacingMark)
|
||||
{
|
||||
chars.Add(c);
|
||||
}
|
||||
}
|
||||
|
||||
return new string(chars.ToArray()).Normalize(NormalizationForm.FormC);
|
||||
"Audio" => CalculateSongRelevanceScore(query, item),
|
||||
"MusicAlbum" => CalculateAlbumRelevanceScore(query, item),
|
||||
"MusicArtist" => CalculateArtistRelevanceScore(query, item),
|
||||
_ => CalculateArtistRelevanceScore(query, item)
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -1141,52 +1141,90 @@ public partial class JellyfinController
|
||||
return GetItemStringValue(item, "Name");
|
||||
}
|
||||
|
||||
private string BuildItemSearchText(Dictionary<string, object?> item, string title)
|
||||
private double CalculateSongRelevanceScore(string query, Dictionary<string, object?> item)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
|
||||
AddDistinct(parts, title);
|
||||
AddDistinct(parts, GetItemStringValue(item, "SortName"));
|
||||
AddDistinct(parts, GetItemStringValue(item, "AlbumArtist"));
|
||||
AddDistinct(parts, GetItemStringValue(item, "Artist"));
|
||||
AddDistinct(parts, GetItemStringValue(item, "Album"));
|
||||
|
||||
foreach (var artist in GetItemStringList(item, "Artists").Take(3))
|
||||
{
|
||||
AddDistinct(parts, artist);
|
||||
var title = GetItemName(item);
|
||||
var artistText = GetSongArtistText(item);
|
||||
return CalculateBestFuzzyScore(query, title, CombineSearchFields(title, artistText));
|
||||
}
|
||||
|
||||
return string.Join(" ", parts);
|
||||
private double CalculateAlbumRelevanceScore(string query, Dictionary<string, object?> item)
|
||||
{
|
||||
var albumName = GetItemName(item);
|
||||
var artistText = GetAlbumArtistText(item);
|
||||
return CalculateBestFuzzyScore(query, albumName, CombineSearchFields(albumName, artistText));
|
||||
}
|
||||
|
||||
private static readonly HashSet<string> SearchStopWords = new(StringComparer.Ordinal)
|
||||
private double CalculateArtistRelevanceScore(string query, Dictionary<string, object?> item)
|
||||
{
|
||||
"a",
|
||||
"an",
|
||||
"and",
|
||||
"at",
|
||||
"for",
|
||||
"in",
|
||||
"of",
|
||||
"on",
|
||||
"the",
|
||||
"to",
|
||||
"with",
|
||||
"feat",
|
||||
"ft"
|
||||
};
|
||||
|
||||
private static void AddDistinct(List<string> values, string? value)
|
||||
var artistName = GetItemName(item);
|
||||
if (string.IsNullOrWhiteSpace(artistName))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
return 0;
|
||||
}
|
||||
|
||||
if (!values.Contains(value, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
values.Add(value);
|
||||
return FuzzyMatcher.CalculateSimilarityAggressive(query, artistName);
|
||||
}
|
||||
|
||||
private double CalculateBestFuzzyScore(string query, params string?[] candidates)
|
||||
{
|
||||
var best = 0;
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
best = Math.Max(best, FuzzyMatcher.CalculateSimilarityAggressive(query, candidate));
|
||||
}
|
||||
|
||||
return best;
|
||||
}
|
||||
|
||||
private static string CombineSearchFields(params string?[] fields)
|
||||
{
|
||||
return string.Join(" ", fields.Where(field => !string.IsNullOrWhiteSpace(field)));
|
||||
}
|
||||
|
||||
private string GetItemType(Dictionary<string, object?> item)
|
||||
{
|
||||
return GetItemStringValue(item, "Type");
|
||||
}
|
||||
|
||||
private string GetSongArtistText(Dictionary<string, object?> item)
|
||||
{
|
||||
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
|
||||
if (artists.Count > 0)
|
||||
{
|
||||
return string.Join(" ", artists);
|
||||
}
|
||||
|
||||
var albumArtist = GetItemStringValue(item, "AlbumArtist");
|
||||
if (!string.IsNullOrWhiteSpace(albumArtist))
|
||||
{
|
||||
return albumArtist;
|
||||
}
|
||||
|
||||
return GetItemStringValue(item, "Artist");
|
||||
}
|
||||
|
||||
private string GetAlbumArtistText(Dictionary<string, object?> item)
|
||||
{
|
||||
var albumArtist = GetItemStringValue(item, "AlbumArtist");
|
||||
if (!string.IsNullOrWhiteSpace(albumArtist))
|
||||
{
|
||||
return albumArtist;
|
||||
}
|
||||
|
||||
var artists = GetItemStringList(item, "Artists").Take(3).ToList();
|
||||
if (artists.Count > 0)
|
||||
{
|
||||
return string.Join(" ", artists);
|
||||
}
|
||||
|
||||
return GetItemStringValue(item, "Artist");
|
||||
}
|
||||
|
||||
private string GetItemStringValue(Dictionary<string, object?> item, string key)
|
||||
|
||||
@@ -57,8 +57,18 @@ public partial class JellyfinController
|
||||
private async Task<IActionResult?> GetSpotifyPlaylistTracksOrderedAsync(string spotifyPlaylistName,
|
||||
string playlistId)
|
||||
{
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
_spotifySettings,
|
||||
spotifyPlaylistName,
|
||||
userId,
|
||||
playlistId);
|
||||
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
|
||||
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, playlistId);
|
||||
|
||||
// Check if Jellyfin playlist has changed (cheap API call)
|
||||
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{spotifyPlaylistName}";
|
||||
var jellyfinSignatureCacheKey =
|
||||
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(spotifyPlaylistName, playlistScopeUserId, playlistScopeId)}";
|
||||
var currentJellyfinSignature = await GetJellyfinPlaylistSignatureAsync(playlistId);
|
||||
var cachedJellyfinSignature = await _cache.GetAsync<string>(jellyfinSignatureCacheKey);
|
||||
|
||||
@@ -66,7 +76,10 @@ public partial class JellyfinController
|
||||
var requestNeedsGenreMetadata = RequestIncludesField("Genres");
|
||||
|
||||
// Check Redis cache first for fast serving (only if Jellyfin playlist hasn't changed)
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(spotifyPlaylistName);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
spotifyPlaylistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var cachedItems = await _cache.GetAsync<List<Dictionary<string, object?>>>(cacheKey);
|
||||
|
||||
if (cachedItems != null && cachedItems.Count > 0 &&
|
||||
@@ -110,7 +123,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Check file cache as fallback
|
||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName);
|
||||
var fileItems = await LoadPlaylistItemsFromFile(spotifyPlaylistName, playlistScopeUserId, playlistScopeId);
|
||||
if (fileItems != null && fileItems.Count > 0 &&
|
||||
InjectedPlaylistItemHelper.ContainsSyntheticLocalItems(fileItems))
|
||||
{
|
||||
@@ -147,26 +160,74 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Check for ordered matched tracks from SpotifyTrackMatchingService
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(spotifyPlaylistName);
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
spotifyPlaylistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var orderedTracks = await _cache.GetAsync<List<MatchedTrack>>(orderedCacheKey);
|
||||
|
||||
if (orderedTracks == null || orderedTracks.Count == 0)
|
||||
{
|
||||
_logger.LogInformation("No ordered matched tracks in cache for {Playlist}, checking if we can fetch",
|
||||
_logger.LogInformation(
|
||||
"No ordered matched tracks in cache for {Playlist}; attempting exact-scope rebuild before fallback",
|
||||
spotifyPlaylistName);
|
||||
|
||||
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}",
|
||||
orderedTracks.Count, spotifyPlaylistName);
|
||||
|
||||
// Get existing Jellyfin playlist items (RAW - don't convert!)
|
||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||
var userId = _settings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogError(
|
||||
"❌ JELLYFIN_USER_ID is NOT configured! Cannot fetch playlist tracks. Set it in .env or admin UI.");
|
||||
"❌ Could not resolve Jellyfin user from the current request. Cannot fetch playlist tracks.");
|
||||
return null; // Fall back to legacy mode
|
||||
}
|
||||
|
||||
@@ -237,7 +298,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Get the full playlist from Spotify to know the correct order
|
||||
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName);
|
||||
var spotifyTracks = await _spotifyPlaylistFetcher!.GetPlaylistTracksAsync(spotifyPlaylistName, userId, playlistId);
|
||||
if (spotifyTracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("Could not get Spotify playlist tracks for {Playlist}", spotifyPlaylistName);
|
||||
@@ -394,7 +455,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// Save to file cache for persistence across restarts
|
||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems);
|
||||
await SavePlaylistItemsToFile(spotifyPlaylistName, finalItems, playlistScopeUserId, playlistScopeId);
|
||||
|
||||
// Also cache in Redis for fast serving (reuse the same cache key from top of method)
|
||||
await _cache.SetAsync(cacheKey, finalItems, CacheExtensions.SpotifyPlaylistItemsTTL);
|
||||
@@ -916,7 +977,7 @@ public partial class JellyfinController
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = _settings.UserId;
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync(Request.Headers);
|
||||
var playlistItemsUrl = $"Playlists/{playlistId}/Items?Fields=Id";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
@@ -958,14 +1019,19 @@ public partial class JellyfinController
|
||||
/// <summary>
|
||||
/// Saves playlist items (raw Jellyfin JSON) to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SavePlaylistItemsToFile(string playlistName, List<Dictionary<string, object?>> items)
|
||||
private async Task SavePlaylistItemsToFile(
|
||||
string playlistName,
|
||||
List<Dictionary<string, object?>> items,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var safeName = AdminHelperService.SanitizeFileName(
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
@@ -983,11 +1049,15 @@ public partial class JellyfinController
|
||||
/// <summary>
|
||||
/// Loads playlist items (raw Jellyfin JSON) from file cache.
|
||||
/// </summary>
|
||||
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(string playlistName)
|
||||
private async Task<List<Dictionary<string, object?>>?> LoadPlaylistItemsFromFile(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var safeName = AdminHelperService.SanitizeFileName(
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
|
||||
var filePath = Path.Combine("/app/cache/spotify", $"{safeName}_items.json");
|
||||
|
||||
if (!System.IO.File.Exists(filePath))
|
||||
|
||||
@@ -34,15 +34,18 @@ public partial class JellyfinController : ControllerBase
|
||||
private readonly SpotifyApiSettings _spotifyApiSettings;
|
||||
private readonly ScrobblingSettings _scrobblingSettings;
|
||||
private readonly IMusicMetadataService _metadataService;
|
||||
private readonly ExternalArtistAppearancesService _externalArtistAppearancesService;
|
||||
private readonly ParallelMetadataService? _parallelMetadataService;
|
||||
private readonly ILocalLibraryService _localLibraryService;
|
||||
private readonly IDownloadService _downloadService;
|
||||
private readonly JellyfinResponseBuilder _responseBuilder;
|
||||
private readonly JellyfinModelMapper _modelMapper;
|
||||
private readonly JellyfinProxyService _proxyService;
|
||||
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
|
||||
private readonly JellyfinSessionManager _sessionManager;
|
||||
private readonly PlaylistSyncService? _playlistSyncService;
|
||||
private readonly SpotifyPlaylistFetcher? _spotifyPlaylistFetcher;
|
||||
private readonly SpotifyTrackMatchingService? _spotifyTrackMatchingService;
|
||||
private readonly SpotifyLyricsService? _spotifyLyricsService;
|
||||
private readonly LyricsPlusService? _lyricsPlusService;
|
||||
private readonly LrclibService? _lrclibService;
|
||||
@@ -60,11 +63,13 @@ public partial class JellyfinController : ControllerBase
|
||||
IOptions<SpotifyApiSettings> spotifyApiSettings,
|
||||
IOptions<ScrobblingSettings> scrobblingSettings,
|
||||
IMusicMetadataService metadataService,
|
||||
ExternalArtistAppearancesService externalArtistAppearancesService,
|
||||
ILocalLibraryService localLibraryService,
|
||||
IDownloadService downloadService,
|
||||
JellyfinResponseBuilder responseBuilder,
|
||||
JellyfinModelMapper modelMapper,
|
||||
JellyfinProxyService proxyService,
|
||||
JellyfinUserContextResolver jellyfinUserContextResolver,
|
||||
JellyfinSessionManager sessionManager,
|
||||
OdesliService odesliService,
|
||||
RedisCacheService cache,
|
||||
@@ -73,6 +78,7 @@ public partial class JellyfinController : ControllerBase
|
||||
ParallelMetadataService? parallelMetadataService = null,
|
||||
PlaylistSyncService? playlistSyncService = null,
|
||||
SpotifyPlaylistFetcher? spotifyPlaylistFetcher = null,
|
||||
SpotifyTrackMatchingService? spotifyTrackMatchingService = null,
|
||||
SpotifyLyricsService? spotifyLyricsService = null,
|
||||
LyricsPlusService? lyricsPlusService = null,
|
||||
LrclibService? lrclibService = null,
|
||||
@@ -85,15 +91,18 @@ public partial class JellyfinController : ControllerBase
|
||||
_spotifyApiSettings = spotifyApiSettings.Value;
|
||||
_scrobblingSettings = scrobblingSettings.Value;
|
||||
_metadataService = metadataService;
|
||||
_externalArtistAppearancesService = externalArtistAppearancesService;
|
||||
_parallelMetadataService = parallelMetadataService;
|
||||
_localLibraryService = localLibraryService;
|
||||
_downloadService = downloadService;
|
||||
_responseBuilder = responseBuilder;
|
||||
_modelMapper = modelMapper;
|
||||
_proxyService = proxyService;
|
||||
_jellyfinUserContextResolver = jellyfinUserContextResolver;
|
||||
_sessionManager = sessionManager;
|
||||
_playlistSyncService = playlistSyncService;
|
||||
_spotifyPlaylistFetcher = spotifyPlaylistFetcher;
|
||||
_spotifyTrackMatchingService = spotifyTrackMatchingService;
|
||||
_spotifyLyricsService = spotifyLyricsService;
|
||||
_lyricsPlusService = lyricsPlusService;
|
||||
_lrclibService = lrclibService;
|
||||
@@ -290,6 +299,75 @@ public partial class JellyfinController : ControllerBase
|
||||
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets "appears on" albums for an external artist when Jellyfin requests
|
||||
/// ContributingArtistIds for album containers.
|
||||
/// </summary>
|
||||
private async Task<IActionResult> GetExternalContributorChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (IsFavoritesOnlyRequest())
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Suppressing external contributing artist items for favorites-only request: provider={Provider}, type={Type}, externalId={ExternalId}",
|
||||
provider,
|
||||
type,
|
||||
externalId);
|
||||
return CreateEmptyItemsResponse(GetRequestedStartIndex());
|
||||
}
|
||||
|
||||
if (!string.Equals(type, "artist", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Ignoring external contributing item request for non-artist id: provider={Provider}, type={Type}, externalId={ExternalId}",
|
||||
provider,
|
||||
type,
|
||||
externalId);
|
||||
return CreateEmptyItemsResponse(GetRequestedStartIndex());
|
||||
}
|
||||
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0;
|
||||
var wantsAlbums = itemTypesUnspecified || itemTypes!.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!wantsAlbums)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"No external contributing artist handler for requested item types {ItemTypes}",
|
||||
string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||
return CreateEmptyItemsResponse(GetRequestedStartIndex());
|
||||
}
|
||||
|
||||
var albums = await _externalArtistAppearancesService.GetAppearsOnAlbumsAsync(provider, externalId, cancellationToken);
|
||||
var items = albums
|
||||
.Select(_responseBuilder.ConvertAlbumToJellyfinItem)
|
||||
.ToList();
|
||||
|
||||
items = ApplyRequestedAlbumOrderingIfApplicable(
|
||||
items,
|
||||
itemTypes,
|
||||
Request.Query["SortBy"].ToString(),
|
||||
Request.Query["SortOrder"].ToString());
|
||||
|
||||
var totalRecordCount = items.Count;
|
||||
var startIndex = GetRequestedStartIndex();
|
||||
if (startIndex > 0)
|
||||
{
|
||||
items = items.Skip(startIndex).ToList();
|
||||
}
|
||||
|
||||
if (int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit > 0)
|
||||
{
|
||||
items = items.Take(parsedLimit).ToList();
|
||||
}
|
||||
|
||||
return _responseBuilder.CreateJsonResponse(new
|
||||
{
|
||||
Items = items,
|
||||
TotalRecordCount = totalRecordCount,
|
||||
StartIndex = startIndex
|
||||
});
|
||||
}
|
||||
|
||||
private int GetRequestedStartIndex()
|
||||
{
|
||||
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
|
||||
@@ -628,13 +706,20 @@ public partial class JellyfinController : ControllerBase
|
||||
|
||||
if (!isExternal)
|
||||
{
|
||||
var effectiveImageTag = tag;
|
||||
if (string.IsNullOrWhiteSpace(effectiveImageTag) &&
|
||||
_spotifySettings.IsSpotifyPlaylist(itemId))
|
||||
{
|
||||
effectiveImageTag = await ResolveCurrentSpotifyPlaylistImageTagAsync(itemId, imageType);
|
||||
}
|
||||
|
||||
// Proxy image from Jellyfin for local content
|
||||
var (imageBytes, contentType) = await _proxyService.GetImageAsync(
|
||||
itemId,
|
||||
imageType,
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
tag);
|
||||
effectiveImageTag);
|
||||
|
||||
if (imageBytes == null || contentType == null)
|
||||
{
|
||||
@@ -671,7 +756,7 @@ public partial class JellyfinController : ControllerBase
|
||||
|
||||
if (fallbackBytes != null && fallbackContentType != null)
|
||||
{
|
||||
return File(fallbackBytes, fallbackContentType);
|
||||
return CreateConditionalImageResponse(fallbackBytes, fallbackContentType);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -680,7 +765,7 @@ public partial class JellyfinController : ControllerBase
|
||||
return await GetPlaceholderImageAsync();
|
||||
}
|
||||
|
||||
return File(imageBytes, contentType);
|
||||
return CreateConditionalImageResponse(imageBytes, contentType);
|
||||
}
|
||||
|
||||
// Check Redis cache for previously fetched external image
|
||||
@@ -689,7 +774,7 @@ public partial class JellyfinController : ControllerBase
|
||||
if (cachedImageBytes != null)
|
||||
{
|
||||
_logger.LogDebug("Cache hit for external {Type} image: {Provider}/{ExternalId}", type, provider, externalId);
|
||||
return File(cachedImageBytes, "image/jpeg");
|
||||
return CreateConditionalImageResponse(cachedImageBytes, "image/jpeg");
|
||||
}
|
||||
|
||||
// Get external cover art URL
|
||||
@@ -760,7 +845,7 @@ public partial class JellyfinController : ControllerBase
|
||||
|
||||
_logger.LogDebug("Successfully fetched and cached external image from host {Host}, size: {Size} bytes",
|
||||
safeCoverUri.Host, imageBytes.Length);
|
||||
return File(imageBytes, "image/jpeg");
|
||||
return CreateConditionalImageResponse(imageBytes, "image/jpeg");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -782,7 +867,7 @@ public partial class JellyfinController : ControllerBase
|
||||
if (System.IO.File.Exists(placeholderPath))
|
||||
{
|
||||
var imageBytes = await System.IO.File.ReadAllBytesAsync(placeholderPath);
|
||||
return File(imageBytes, "image/png");
|
||||
return CreateConditionalImageResponse(imageBytes, "image/png");
|
||||
}
|
||||
|
||||
// Fallback: Return a 1x1 transparent PNG as minimal placeholder
|
||||
@@ -790,7 +875,54 @@ public partial class JellyfinController : ControllerBase
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
|
||||
);
|
||||
|
||||
return File(transparentPng, "image/png");
|
||||
return CreateConditionalImageResponse(transparentPng, "image/png");
|
||||
}
|
||||
|
||||
private IActionResult CreateConditionalImageResponse(byte[] imageBytes, string contentType)
|
||||
{
|
||||
var etag = ImageConditionalRequestHelper.ComputeStrongETag(imageBytes);
|
||||
Response.Headers["ETag"] = etag;
|
||||
|
||||
if (ImageConditionalRequestHelper.MatchesIfNoneMatch(Request.Headers, etag))
|
||||
{
|
||||
return StatusCode(StatusCodes.Status304NotModified);
|
||||
}
|
||||
|
||||
return File(imageBytes, contentType);
|
||||
}
|
||||
|
||||
private async Task<string?> ResolveCurrentSpotifyPlaylistImageTagAsync(string itemId, string imageType)
|
||||
{
|
||||
try
|
||||
{
|
||||
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}");
|
||||
if (itemResult == null || statusCode != 200)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
using var itemDocument = itemResult;
|
||||
var imageTag = ExtractImageTag(itemDocument.RootElement, imageType);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(imageTag))
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Resolved current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}: {ImageTag}",
|
||||
imageType,
|
||||
itemId,
|
||||
imageTag);
|
||||
}
|
||||
|
||||
return imageTag;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex,
|
||||
"Failed to resolve current Jellyfin {ImageType} image tag for Spotify playlist {PlaylistId}",
|
||||
imageType,
|
||||
itemId);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -1292,33 +1424,37 @@ public partial class JellyfinController : ControllerBase
|
||||
});
|
||||
}
|
||||
|
||||
// Intercept Spotify playlist requests by ID
|
||||
if (_spotifySettings.Enabled &&
|
||||
path.StartsWith("playlists/", StringComparison.OrdinalIgnoreCase) &&
|
||||
path.Contains("/items", StringComparison.OrdinalIgnoreCase))
|
||||
var playlistItemsRequestId = GetExactPlaylistItemsRequestId(path);
|
||||
if (!string.IsNullOrEmpty(playlistItemsRequestId))
|
||||
{
|
||||
// Extract playlist ID from path: playlists/{id}/items
|
||||
var parts = path.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
if (parts.Length >= 2 && parts[0].Equals("playlists", StringComparison.OrdinalIgnoreCase))
|
||||
if (_spotifySettings.Enabled)
|
||||
{
|
||||
var playlistId = parts[1];
|
||||
|
||||
_logger.LogDebug("=== PLAYLIST REQUEST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
|
||||
_logger.LogInformation("Spotify Enabled: {Enabled}", _spotifySettings.Enabled);
|
||||
_logger.LogInformation("Configured Playlists: {Playlists}", string.Join(", ", _spotifySettings.Playlists.Select(p => $"{p.Name}:{p.Id}")));
|
||||
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistId));
|
||||
_logger.LogInformation("Is configured: {IsConfigured}", _spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId));
|
||||
|
||||
// Check if this playlist ID is configured for Spotify injection
|
||||
if (_spotifySettings.IsSpotifyPlaylist(playlistId))
|
||||
if (_spotifySettings.IsSpotifyPlaylist(playlistItemsRequestId))
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
_logger.LogInformation("=== INTERCEPTING SPOTIFY PLAYLIST ===");
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistId);
|
||||
_logger.LogInformation("Playlist ID: {PlaylistId}", playlistItemsRequestId);
|
||||
_logger.LogInformation("========================================");
|
||||
return await GetPlaylistTracks(playlistId);
|
||||
return await GetPlaylistTracks(playlistItemsRequestId);
|
||||
}
|
||||
}
|
||||
|
||||
var playlistItemsPath = path;
|
||||
if (Request.QueryString.HasValue)
|
||||
{
|
||||
playlistItemsPath = $"{playlistItemsPath}{Request.QueryString.Value}";
|
||||
}
|
||||
|
||||
_logger.LogDebug("Using transparent Jellyfin passthrough for non-injected playlist {PlaylistId}",
|
||||
playlistItemsRequestId);
|
||||
return await ProxyJsonPassthroughAsync(playlistItemsPath);
|
||||
}
|
||||
|
||||
// Handle non-JSON responses (images, robots.txt, etc.)
|
||||
@@ -1690,7 +1826,10 @@ public partial class JellyfinController : ControllerBase
|
||||
// Search through each playlist's matched tracks cache
|
||||
foreach (var playlist in playlists)
|
||||
{
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
playlist.Name,
|
||||
playlist.UserId,
|
||||
string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(cacheKey);
|
||||
|
||||
if (matchedTracks == null || matchedTracks.Count == 0)
|
||||
|
||||
@@ -8,6 +8,7 @@ using allstarr.Services.Common;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services;
|
||||
using allstarr.Filters;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
@@ -27,6 +28,7 @@ public class PlaylistController : ControllerBase
|
||||
private readonly HttpClient _jellyfinHttpClient;
|
||||
private readonly AdminHelperService _helperService;
|
||||
private readonly IServiceProvider _serviceProvider;
|
||||
private readonly JellyfinUserContextResolver _jellyfinUserContextResolver;
|
||||
private const string CacheDirectory = "/app/cache/spotify";
|
||||
|
||||
public PlaylistController(
|
||||
@@ -39,6 +41,7 @@ public class PlaylistController : ControllerBase
|
||||
IHttpClientFactory httpClientFactory,
|
||||
AdminHelperService helperService,
|
||||
IServiceProvider serviceProvider,
|
||||
JellyfinUserContextResolver jellyfinUserContextResolver,
|
||||
SpotifyTrackMatchingService? matchingService = null)
|
||||
{
|
||||
_logger = logger;
|
||||
@@ -51,6 +54,23 @@ public class PlaylistController : ControllerBase
|
||||
_jellyfinHttpClient = httpClientFactory.CreateClient();
|
||||
_helperService = helperService;
|
||||
_serviceProvider = serviceProvider;
|
||||
_jellyfinUserContextResolver = jellyfinUserContextResolver;
|
||||
}
|
||||
|
||||
private async Task<SpotifyPlaylistConfig?> ResolvePlaylistConfigForCurrentScopeAsync(string playlistName)
|
||||
{
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
|
||||
return _spotifyImportSettings.GetPlaylistByName(playlistName, userId);
|
||||
}
|
||||
|
||||
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(playlist?.JellyfinId))
|
||||
{
|
||||
return playlist.JellyfinId;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(playlist?.Id) ? null : playlist.Id;
|
||||
}
|
||||
|
||||
[HttpGet("playlists")]
|
||||
@@ -149,7 +169,7 @@ public class PlaylistController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
spotifyTrackCount = spotifyTracks.Count;
|
||||
playlistInfo["trackCount"] = spotifyTrackCount;
|
||||
_logger.LogDebug("Fetched {Count} tracks from Spotify for playlist {Name}", spotifyTrackCount, config.Name);
|
||||
@@ -167,7 +187,10 @@ public class PlaylistController : ControllerBase
|
||||
try
|
||||
{
|
||||
// Try to use the pre-built playlist cache
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
config.Name,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
@@ -239,7 +262,7 @@ public class PlaylistController : ControllerBase
|
||||
else
|
||||
{
|
||||
// No playlist cache - calculate from global mappings as fallback
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
var localCount = 0;
|
||||
var externalCount = 0;
|
||||
var missingCount = 0;
|
||||
@@ -291,7 +314,7 @@ public class PlaylistController : ControllerBase
|
||||
try
|
||||
{
|
||||
// Jellyfin requires UserId parameter to fetch playlist items
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
var userId = config.UserId;
|
||||
|
||||
// If no user configured, try to get the first user
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
@@ -330,10 +353,13 @@ public class PlaylistController : ControllerBase
|
||||
if (jellyfinDoc.RootElement.TryGetProperty("Items", out var items))
|
||||
{
|
||||
// Get Spotify tracks to match against
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name);
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
|
||||
// Try to use the pre-built playlist cache first (includes manual mappings!)
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(config.Name);
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
config.Name,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
@@ -438,7 +464,10 @@ public class PlaylistController : ControllerBase
|
||||
}
|
||||
|
||||
// Get matched external tracks cache once
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(config.Name);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
config.Name,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
var matchedSpotifyIds = new HashSet<string>(
|
||||
matchedTracks?.Select(m => m.SpotifyId) ?? Enumerable.Empty<string>()
|
||||
@@ -455,7 +484,11 @@ public class PlaylistController : ControllerBase
|
||||
var hasExternalMapping = false;
|
||||
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{config.Name}:{track.SpotifyId}";
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
config.Name,
|
||||
track.SpotifyId,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
@@ -466,7 +499,11 @@ public class PlaylistController : ControllerBase
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{config.Name}:{track.SpotifyId}";
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
config.Name,
|
||||
track.SpotifyId,
|
||||
config.UserId,
|
||||
GetPlaylistScopeId(config));
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
@@ -592,16 +629,22 @@ public class PlaylistController : ControllerBase
|
||||
public async Task<IActionResult> GetPlaylistTracks(string name)
|
||||
{
|
||||
var decodedName = Uri.UnescapeDataString(name);
|
||||
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||
var playlistScopeUserId = playlistConfig?.UserId;
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
|
||||
// Get Spotify tracks
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName);
|
||||
var spotifyTracks = await _playlistFetcher.GetPlaylistTracksAsync(decodedName, playlistScopeUserId, playlistConfig?.JellyfinId);
|
||||
|
||||
var tracksWithStatus = new List<object>();
|
||||
var matchedTracksBySpotifyId = new Dictionary<string, MatchedTrack>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
try
|
||||
{
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var matchedTracks = await _cache.GetAsync<List<MatchedTrack>>(matchedTracksKey);
|
||||
|
||||
if (matchedTracks != null)
|
||||
@@ -627,7 +670,10 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
// Use the pre-built playlist cache (same as GetPlaylists endpoint)
|
||||
// This cache includes all matched tracks with proper provider IDs
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
|
||||
List<Dictionary<string, object?>>? cachedPlaylistItems = null;
|
||||
try
|
||||
@@ -948,7 +994,11 @@ public class PlaylistController : ControllerBase
|
||||
string? externalProvider = null;
|
||||
|
||||
// Check for manual Jellyfin mapping
|
||||
var manualMappingKey = $"spotify:manual-map:{decodedName}:{track.SpotifyId}";
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
decodedName,
|
||||
track.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
@@ -958,7 +1008,11 @@ public class PlaylistController : ControllerBase
|
||||
else
|
||||
{
|
||||
// Check for external manual mapping
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{track.SpotifyId}";
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
decodedName,
|
||||
track.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
@@ -1071,10 +1125,16 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||
var playlistScopeUserId = playlistConfig?.UserId;
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
await _playlistFetcher.RefreshPlaylistAsync(decodedName);
|
||||
|
||||
// Clear playlist stats cache first (so it gets recalculated with fresh data)
|
||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(statsCacheKey);
|
||||
|
||||
// Then invalidate playlist summary cache (will rebuild with fresh stats)
|
||||
@@ -1109,18 +1169,28 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||
var playlistScopeUserId = playlistConfig?.UserId;
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
// Clear the Jellyfin playlist signature cache to force re-checking if local tracks changed
|
||||
var jellyfinSignatureCacheKey = $"spotify:playlist:jellyfin-signature:{decodedName}";
|
||||
var jellyfinSignatureCacheKey =
|
||||
$"spotify:playlist:jellyfin-signature:{CacheKeyBuilder.BuildSpotifyPlaylistScope(decodedName, playlistScopeUserId, playlistScopeId)}";
|
||||
await _cache.DeleteAsync(jellyfinSignatureCacheKey);
|
||||
_logger.LogDebug("Cleared Jellyfin signature cache to force change detection");
|
||||
|
||||
// Clear the matched results cache to force re-matching
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(matchedTracksKey);
|
||||
_logger.LogDebug("Cleared matched tracks cache");
|
||||
|
||||
// Clear the playlist items cache
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
||||
var playlistItemsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(playlistItemsCacheKey);
|
||||
_logger.LogDebug("Cleared playlist items cache");
|
||||
|
||||
@@ -1131,7 +1201,10 @@ public class PlaylistController : ControllerBase
|
||||
_helperService.InvalidatePlaylistSummaryCache();
|
||||
|
||||
// Clear playlist stats cache to force recalculation from new mappings
|
||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(statsCacheKey);
|
||||
_logger.LogDebug("Cleared stats cache for {Name}", decodedName);
|
||||
|
||||
@@ -1196,7 +1269,7 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
|
||||
|
||||
// Build URL with UserId if available
|
||||
var url = $"{_jellyfinSettings.Url}/Items?searchTerm={Uri.EscapeDataString(query)}&includeItemTypes=Audio&recursive=true&limit=20";
|
||||
@@ -1328,7 +1401,7 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var userId = _jellyfinSettings.UserId;
|
||||
var userId = await _jellyfinUserContextResolver.ResolveCurrentUserIdAsync();
|
||||
|
||||
var url = $"{_jellyfinSettings.Url}/Items/{id}";
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
@@ -1424,13 +1497,20 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
try
|
||||
{
|
||||
var playlistConfig = await ResolvePlaylistConfigForCurrentScopeAsync(decodedName);
|
||||
var playlistScopeUserId = playlistConfig?.UserId;
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
string? normalizedProvider = null;
|
||||
string? normalizedExternalId = null;
|
||||
|
||||
if (hasJellyfinMapping)
|
||||
{
|
||||
// Store Jellyfin mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var mappingKey = $"spotify:manual-map:{decodedName}:{request.SpotifyId}";
|
||||
var mappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
decodedName,
|
||||
request.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.SetAsync(mappingKey, request.JellyfinId!);
|
||||
|
||||
// Also save to file for persistence across restarts
|
||||
@@ -1442,7 +1522,11 @@ public class PlaylistController : ControllerBase
|
||||
else
|
||||
{
|
||||
// Store external mapping in cache (NO EXPIRATION - manual mappings are permanent)
|
||||
var externalMappingKey = $"spotify:external-map:{decodedName}:{request.SpotifyId}";
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
decodedName,
|
||||
request.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
normalizedProvider = request.ExternalProvider!.ToLowerInvariant(); // Normalize to lowercase
|
||||
normalizedExternalId = NormalizeExternalTrackId(normalizedProvider, request.ExternalId!);
|
||||
var externalMapping = new { provider = normalizedProvider, id = normalizedExternalId };
|
||||
@@ -1482,10 +1566,22 @@ public class PlaylistController : ControllerBase
|
||||
}
|
||||
|
||||
// Clear all related caches to force rebuild
|
||||
var matchedCacheKey = $"spotify:matched:{decodedName}";
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(decodedName);
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(decodedName);
|
||||
var statsCacheKey = $"spotify:playlist:stats:{decodedName}";
|
||||
var matchedCacheKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var orderedCacheKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var playlistItemsKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
|
||||
decodedName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
|
||||
await _cache.DeleteAsync(matchedCacheKey);
|
||||
await _cache.DeleteAsync(orderedCacheKey);
|
||||
|
||||
@@ -357,9 +357,9 @@ public class SpotifyAdminController : ControllerBase
|
||||
{
|
||||
var keys = new[]
|
||||
{
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name)
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId),
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlist.UserId, string.IsNullOrWhiteSpace(playlist.JellyfinId) ? playlist.Id : playlist.JellyfinId)
|
||||
};
|
||||
|
||||
foreach (var key in keys)
|
||||
|
||||
@@ -152,6 +152,11 @@ public class WebSocketProxyMiddleware
|
||||
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
|
||||
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
await _sessionManager.RegisterProxiedWebSocketAsync(deviceId);
|
||||
}
|
||||
|
||||
// Start bidirectional proxying
|
||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
|
||||
@@ -194,6 +199,11 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_sessionManager.UnregisterProxiedWebSocket(deviceId);
|
||||
}
|
||||
|
||||
// Clean up connections
|
||||
if (clientWebSocket?.State == WebSocketState.Open)
|
||||
{
|
||||
|
||||
@@ -79,6 +79,29 @@ public class TrackMetadataRequest
|
||||
public int? DurationMs { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Request model for updating configuration
|
||||
/// </summary>
|
||||
public class SquidWtfEndpointHealthResponse
|
||||
{
|
||||
public DateTime TestedAtUtc { get; set; }
|
||||
public int TotalRows { get; set; }
|
||||
public List<SquidWtfEndpointHealthRow> Endpoints { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SquidWtfEndpointHealthRow
|
||||
{
|
||||
public string Host { get; set; } = string.Empty;
|
||||
public string? ApiUrl { get; set; }
|
||||
public string? StreamingUrl { get; set; }
|
||||
public SquidWtfEndpointProbeResult Api { get; set; } = new();
|
||||
public SquidWtfEndpointProbeResult Streaming { get; set; } = new();
|
||||
}
|
||||
|
||||
public class SquidWtfEndpointProbeResult
|
||||
{
|
||||
public bool Configured { get; set; }
|
||||
public bool IsUp { get; set; }
|
||||
public string State { get; set; } = "unknown";
|
||||
public int? StatusCode { get; set; }
|
||||
public long? LatencyMs { get; set; }
|
||||
public string? RequestUrl { get; set; }
|
||||
public string? Error { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
namespace allstarr.Models.Jellyfin;
|
||||
|
||||
/// <summary>
|
||||
/// Canonical Jellyfin Items response wrapper used by search-related hot paths.
|
||||
/// </summary>
|
||||
public class JellyfinItemsResponse
|
||||
{
|
||||
public List<Dictionary<string, object?>> Items { get; set; } = [];
|
||||
public int TotalRecordCount { get; set; }
|
||||
public int StartIndex { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Playback payload forwarded to Jellyfin for start/progress/stop events.
|
||||
/// Nullable members are omitted to preserve the lean request shapes clients expect.
|
||||
/// </summary>
|
||||
public class JellyfinPlaybackStatePayload
|
||||
{
|
||||
public string ItemId { get; set; } = string.Empty;
|
||||
public long PositionTicks { get; set; }
|
||||
public bool? CanSeek { get; set; }
|
||||
public bool? IsPaused { get; set; }
|
||||
public bool? IsMuted { get; set; }
|
||||
public string? PlayMethod { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Synthetic capabilities payload used when allstarr needs to establish a Jellyfin session.
|
||||
/// </summary>
|
||||
public class JellyfinSessionCapabilitiesPayload
|
||||
{
|
||||
public string[] PlayableMediaTypes { get; set; } = [];
|
||||
public string[] SupportedCommands { get; set; } = [];
|
||||
public bool SupportsMediaControl { get; set; }
|
||||
public bool SupportsPersistentIdentifier { get; set; }
|
||||
public bool SupportsSync { get; set; }
|
||||
}
|
||||
@@ -126,8 +126,33 @@ public class SpotifyImportSettings
|
||||
/// <summary>
|
||||
/// Gets the playlist configuration by name.
|
||||
/// </summary>
|
||||
public SpotifyPlaylistConfig? GetPlaylistByName(string name) =>
|
||||
Playlists.FirstOrDefault(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
|
||||
public SpotifyPlaylistConfig? GetPlaylistByName(string name, string? userId = null, string? jellyfinPlaylistId = null)
|
||||
{
|
||||
var matches = Playlists
|
||||
.Where(p => p.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
.ToList();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(jellyfinPlaylistId))
|
||||
{
|
||||
var byPlaylistId = matches.FirstOrDefault(p =>
|
||||
p.JellyfinId.Equals(jellyfinPlaylistId, StringComparison.OrdinalIgnoreCase));
|
||||
if (byPlaylistId != null)
|
||||
{
|
||||
return byPlaylistId;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
var normalizedUserId = userId.Trim();
|
||||
return matches.FirstOrDefault(p =>
|
||||
!string.IsNullOrWhiteSpace(p.UserId) &&
|
||||
p.UserId.Equals(normalizedUserId, StringComparison.OrdinalIgnoreCase))
|
||||
?? matches.FirstOrDefault(p => string.IsNullOrWhiteSpace(p.UserId));
|
||||
}
|
||||
|
||||
return matches.FirstOrDefault();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if a Jellyfin playlist ID is configured for Spotify import.
|
||||
|
||||
@@ -16,6 +16,7 @@ using Microsoft.Extensions.Http;
|
||||
using System.Net;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
|
||||
|
||||
// Discover SquidWTF API and streaming endpoints from uptime feeds.
|
||||
var squidWtfEndpointCatalog = await SquidWtfEndpointDiscovery.DiscoverAsync();
|
||||
@@ -175,6 +176,25 @@ builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
||||
// but we want to reduce noise in production logs
|
||||
options.SuppressHandlerScope = true;
|
||||
});
|
||||
|
||||
// Register a dedicated named HttpClient for Jellyfin backend with connection pooling.
|
||||
// SocketsHttpHandler reuses TCP connections across the scoped JellyfinProxyService
|
||||
// instances, eliminating per-request TCP/TLS handshake overhead.
|
||||
builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
|
||||
.ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler
|
||||
{
|
||||
// Keep up to 20 idle connections to Jellyfin alive at any time
|
||||
MaxConnectionsPerServer = 20,
|
||||
// Recycle pooled connections every 5 minutes to pick up DNS changes
|
||||
PooledConnectionLifetime = TimeSpan.FromMinutes(5),
|
||||
// Close idle connections after 90 seconds to avoid stale sockets
|
||||
PooledConnectionIdleTimeout = TimeSpan.FromSeconds(90),
|
||||
// Allow HTTP/2 multiplexing when Jellyfin supports it
|
||||
EnableMultipleHttp2Connections = true,
|
||||
// Follow redirects within Jellyfin
|
||||
AllowAutoRedirect = true,
|
||||
MaxAutomaticRedirections = 5
|
||||
});
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
@@ -508,6 +528,7 @@ else
|
||||
|
||||
// Business services - shared across backends
|
||||
builder.Services.AddSingleton(squidWtfEndpointCatalog);
|
||||
builder.Services.AddMemoryCache(); // L1 in-memory tier for RedisCacheService
|
||||
builder.Services.AddSingleton<RedisCacheService>();
|
||||
builder.Services.AddSingleton<FavoritesMigrationService>();
|
||||
builder.Services.AddSingleton<OdesliService>();
|
||||
@@ -520,6 +541,8 @@ if (backendType == BackendType.Jellyfin)
|
||||
// Jellyfin services
|
||||
builder.Services.AddSingleton<JellyfinResponseBuilder>();
|
||||
builder.Services.AddSingleton<JellyfinModelMapper>();
|
||||
builder.Services.AddSingleton<ExternalArtistAppearancesService>();
|
||||
builder.Services.AddScoped<JellyfinUserContextResolver>();
|
||||
builder.Services.AddScoped<JellyfinProxyService>();
|
||||
builder.Services.AddSingleton<JellyfinSessionManager>();
|
||||
builder.Services.AddScoped<JellyfinAuthFilter>();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,7 @@ using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Common;
|
||||
|
||||
namespace allstarr.Services.Admin;
|
||||
|
||||
@@ -20,9 +21,7 @@ public class AdminHelperService
|
||||
{
|
||||
_logger = logger;
|
||||
_jellyfinSettings = jellyfinSettings.Value;
|
||||
_envFilePath = environment.IsDevelopment()
|
||||
? Path.Combine(environment.ContentRootPath, "..", ".env")
|
||||
: "/app/.env";
|
||||
_envFilePath = RuntimeEnvConfiguration.ResolveEnvFilePath(environment);
|
||||
}
|
||||
|
||||
public string GetJellyfinAuthHeader()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
@@ -9,6 +10,10 @@ namespace allstarr.Services.Common;
|
||||
/// </summary>
|
||||
public static class AuthHeaderHelper
|
||||
{
|
||||
private static readonly Regex AuthParameterRegex = new(
|
||||
@"(?<key>[A-Za-z0-9_-]+)\s*=\s*""(?<value>[^""]*)""",
|
||||
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||
|
||||
/// <summary>
|
||||
/// Forwards authentication headers from HTTP request to HttpRequestMessage.
|
||||
/// Handles both X-Emby-Authorization and Authorization headers.
|
||||
@@ -99,17 +104,7 @@ public static class AuthHeaderHelper
|
||||
/// </summary>
|
||||
private static string? ExtractDeviceIdFromAuthString(string authValue)
|
||||
{
|
||||
var deviceIdMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
authValue,
|
||||
@"DeviceId=""([^""]+)""",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
|
||||
if (deviceIdMatch.Success)
|
||||
{
|
||||
return deviceIdMatch.Groups[1].Value;
|
||||
}
|
||||
|
||||
return null;
|
||||
return ExtractAuthParameter(authValue, "DeviceId");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -140,14 +135,93 @@ public static class AuthHeaderHelper
|
||||
/// </summary>
|
||||
private static string? ExtractClientNameFromAuthString(string authValue)
|
||||
{
|
||||
var clientMatch = System.Text.RegularExpressions.Regex.Match(
|
||||
authValue,
|
||||
@"Client=""([^""]+)""",
|
||||
System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
return ExtractAuthParameter(authValue, "Client");
|
||||
}
|
||||
|
||||
if (clientMatch.Success)
|
||||
/// <summary>
|
||||
/// Extracts the authenticated Jellyfin access token from request headers.
|
||||
/// Supports X-Emby-Authorization, X-Emby-Token, Authorization: MediaBrowser ..., and Bearer tokens.
|
||||
/// </summary>
|
||||
public static string? ExtractAccessToken(IHeaderDictionary headers)
|
||||
{
|
||||
return clientMatch.Groups[1].Value;
|
||||
if (headers.TryGetValue("X-Emby-Token", out var tokenHeader))
|
||||
{
|
||||
var token = tokenHeader.ToString().Trim();
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
|
||||
{
|
||||
var token = ExtractAuthParameter(authHeader.ToString(), "Token");
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("Authorization", out var authorizationHeader))
|
||||
{
|
||||
var authValue = authorizationHeader.ToString().Trim();
|
||||
if (authValue.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var bearerToken = authValue["Bearer ".Length..].Trim();
|
||||
return string.IsNullOrWhiteSpace(bearerToken) ? null : bearerToken;
|
||||
}
|
||||
|
||||
var token = ExtractAuthParameter(authValue, "Token");
|
||||
if (!string.IsNullOrWhiteSpace(token))
|
||||
{
|
||||
return token;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts a Jellyfin user id from auth headers when present.
|
||||
/// This is uncommon but some clients may include it in MediaBrowser auth parameters.
|
||||
/// </summary>
|
||||
public static string? ExtractUserId(IHeaderDictionary headers)
|
||||
{
|
||||
if (headers.TryGetValue("X-Emby-Authorization", out var authHeader))
|
||||
{
|
||||
var userId = ExtractAuthParameter(authHeader.ToString(), "UserId");
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
if (headers.TryGetValue("Authorization", out var authorizationHeader))
|
||||
{
|
||||
var userId = ExtractAuthParameter(authorizationHeader.ToString(), "UserId");
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
return userId;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ExtractAuthParameter(string authValue, string parameterName)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authValue))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
foreach (Match match in AuthParameterRegex.Matches(authValue))
|
||||
{
|
||||
if (match.Groups["key"].Value.Equals(parameterName, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var value = match.Groups["value"].Value;
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -67,34 +67,52 @@ public static class CacheKeyBuilder
|
||||
|
||||
#region Spotify Keys
|
||||
|
||||
public static string BuildSpotifyPlaylistKey(string playlistName)
|
||||
public static string BuildSpotifyPlaylistScope(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:playlist:{playlistName}";
|
||||
var normalizedUserId = Normalize(userId);
|
||||
var normalizedScopeId = Normalize(scopeId);
|
||||
var normalizedPlaylistName = Normalize(playlistName);
|
||||
|
||||
if (string.IsNullOrEmpty(normalizedUserId) && string.IsNullOrEmpty(normalizedScopeId))
|
||||
{
|
||||
return playlistName;
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistItemsKey(string playlistName)
|
||||
{
|
||||
return $"spotify:playlist:items:{playlistName}";
|
||||
var effectiveScopeId = string.IsNullOrEmpty(normalizedScopeId)
|
||||
? normalizedPlaylistName
|
||||
: normalizedScopeId;
|
||||
|
||||
return $"{normalizedUserId}:{effectiveScopeId}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistOrderedKey(string playlistName)
|
||||
public static string BuildSpotifyPlaylistKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:playlist:ordered:{playlistName}";
|
||||
return $"spotify:playlist:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyMatchedTracksKey(string playlistName)
|
||||
public static string BuildSpotifyPlaylistItemsKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:matched:ordered:{playlistName}";
|
||||
return $"spotify:playlist:items:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName)
|
||||
public static string BuildSpotifyPlaylistOrderedKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:matched:{playlistName}";
|
||||
return $"spotify:playlist:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistStatsKey(string playlistName)
|
||||
public static string BuildSpotifyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:playlist:stats:{playlistName}";
|
||||
return $"spotify:matched:ordered:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyLegacyMatchedTracksKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:matched:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistStatsKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:playlist:stats:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyPlaylistStatsPattern()
|
||||
@@ -102,19 +120,27 @@ public static class CacheKeyBuilder
|
||||
return "spotify:playlist:stats:*";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyMissingTracksKey(string playlistName)
|
||||
public static string BuildSpotifyMissingTracksKey(string playlistName, string? userId = null, string? scopeId = null)
|
||||
{
|
||||
return $"spotify:missing:{playlistName}";
|
||||
return $"spotify:missing:{BuildSpotifyPlaylistScope(playlistName, userId, scopeId)}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyManualMappingKey(string playlist, string spotifyId)
|
||||
public static string BuildSpotifyManualMappingKey(
|
||||
string playlist,
|
||||
string spotifyId,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
return $"spotify:manual-map:{playlist}:{spotifyId}";
|
||||
return $"spotify:manual-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyExternalMappingKey(string playlist, string spotifyId)
|
||||
public static string BuildSpotifyExternalMappingKey(
|
||||
string playlist,
|
||||
string spotifyId,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
return $"spotify:external-map:{playlist}:{spotifyId}";
|
||||
return $"spotify:external-map:{BuildSpotifyPlaylistScope(playlist, userId, scopeId)}:{spotifyId}";
|
||||
}
|
||||
|
||||
public static string BuildSpotifyGlobalMappingKey(string spotifyId)
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Security.Cryptography;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
public static class ImageConditionalRequestHelper
|
||||
{
|
||||
public static string ComputeStrongETag(byte[] payload)
|
||||
{
|
||||
var hash = SHA256.HashData(payload);
|
||||
return $"\"{Convert.ToHexString(hash)}\"";
|
||||
}
|
||||
|
||||
public static bool MatchesIfNoneMatch(IHeaderDictionary headers, string etag)
|
||||
{
|
||||
if (!headers.TryGetValue("If-None-Match", out var headerValues))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (var headerValue in headerValues)
|
||||
{
|
||||
if (string.IsNullOrEmpty(headerValue))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
foreach (var candidate in headerValue.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
{
|
||||
if (candidate == "*" || string.Equals(candidate, etag, StringComparison.Ordinal))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +1,57 @@
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Serialization;
|
||||
using StackExchange.Redis;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Text.Json.Serialization.Metadata;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Redis caching service for metadata and images.
|
||||
/// Tiered caching service: L1 in-memory (IMemoryCache, ~30s TTL) backed by
|
||||
/// L2 Redis for persistence. The memory tier eliminates Redis network round-trips
|
||||
/// for repeated reads within a short window (playlist scrolling, search-as-you-type).
|
||||
/// </summary>
|
||||
public class RedisCacheService
|
||||
{
|
||||
/// <summary>
|
||||
/// Default L1 memory cache duration. Kept short to avoid serving stale data,
|
||||
/// but long enough to absorb bursts of repeated reads.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan DefaultMemoryTtl = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// Key prefixes that should NOT be cached in memory (e.g., large binary blobs).
|
||||
/// </summary>
|
||||
private static readonly string[] MemoryExcludedPrefixes = ["image:"];
|
||||
private static readonly JsonSerializerOptions ReflectionFallbackJsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = null,
|
||||
DictionaryKeyPolicy = null,
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
WriteIndented = false
|
||||
};
|
||||
|
||||
private readonly RedisSettings _settings;
|
||||
private readonly ILogger<RedisCacheService> _logger;
|
||||
private readonly IMemoryCache _memoryCache;
|
||||
private readonly ConcurrentDictionary<string, byte> _memoryKeys = new(StringComparer.Ordinal);
|
||||
private IConnectionMultiplexer? _redis;
|
||||
private IDatabase? _db;
|
||||
private readonly object _lock = new();
|
||||
|
||||
public RedisCacheService(
|
||||
IOptions<RedisSettings> settings,
|
||||
ILogger<RedisCacheService> logger)
|
||||
ILogger<RedisCacheService> logger,
|
||||
IMemoryCache memoryCache)
|
||||
{
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_memoryCache = memoryCache;
|
||||
|
||||
if (_settings.Enabled)
|
||||
{
|
||||
@@ -48,23 +78,145 @@ public class RedisCacheService
|
||||
public bool IsEnabled => _settings.Enabled && _db != null;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached value as a string.
|
||||
/// Checks whether a key should be cached in the L1 memory tier.
|
||||
/// Large binary data (images) is excluded to avoid memory pressure.
|
||||
/// </summary>
|
||||
public async Task<string?> GetStringAsync(string key)
|
||||
private static bool ShouldUseMemoryCache(string key)
|
||||
{
|
||||
if (!IsEnabled) return null;
|
||||
foreach (var prefix in MemoryExcludedPrefixes)
|
||||
{
|
||||
if (key.StartsWith(prefix, StringComparison.Ordinal))
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes the L1 TTL for a key mirrored from Redis.
|
||||
/// Returns null for already-expired entries, which skips L1 caching entirely.
|
||||
/// </summary>
|
||||
private static TimeSpan? GetMemoryTtl(TimeSpan? redisExpiry)
|
||||
{
|
||||
if (redisExpiry == null)
|
||||
return DefaultMemoryTtl;
|
||||
|
||||
if (redisExpiry.Value <= TimeSpan.Zero)
|
||||
return null;
|
||||
|
||||
return redisExpiry.Value < DefaultMemoryTtl ? redisExpiry.Value : DefaultMemoryTtl;
|
||||
}
|
||||
|
||||
private bool TryGetMemoryValue(string key, out string? value)
|
||||
{
|
||||
if (!ShouldUseMemoryCache(key))
|
||||
{
|
||||
value = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
return _memoryCache.TryGetValue(key, out value);
|
||||
}
|
||||
|
||||
private void SetMemoryValue(string key, string value, TimeSpan? expiry)
|
||||
{
|
||||
if (!ShouldUseMemoryCache(key))
|
||||
return;
|
||||
|
||||
var memoryTtl = GetMemoryTtl(expiry);
|
||||
if (memoryTtl == null)
|
||||
{
|
||||
_memoryCache.Remove(key);
|
||||
_memoryKeys.TryRemove(key, out _);
|
||||
return;
|
||||
}
|
||||
|
||||
var options = new MemoryCacheEntryOptions
|
||||
{
|
||||
AbsoluteExpirationRelativeToNow = memoryTtl
|
||||
};
|
||||
options.RegisterPostEvictionCallback(
|
||||
static (cacheKey, _, _, state) =>
|
||||
{
|
||||
if (cacheKey is string stringKey && state is ConcurrentDictionary<string, byte> memoryKeys)
|
||||
{
|
||||
memoryKeys.TryRemove(stringKey, out _);
|
||||
}
|
||||
},
|
||||
_memoryKeys);
|
||||
|
||||
_memoryCache.Set(key, value, options);
|
||||
_memoryKeys[key] = 0;
|
||||
}
|
||||
|
||||
private int RemoveMemoryKeysByPattern(string pattern)
|
||||
{
|
||||
if (_memoryKeys.IsEmpty)
|
||||
return 0;
|
||||
|
||||
if (!pattern.Contains('*') && !pattern.Contains('?'))
|
||||
{
|
||||
var removed = _memoryKeys.TryRemove(pattern, out _);
|
||||
_memoryCache.Remove(pattern);
|
||||
return removed ? 1 : 0;
|
||||
}
|
||||
|
||||
var regex = new Regex(
|
||||
"^" + Regex.Escape(pattern).Replace("\\*", ".*").Replace("\\?", ".") + "$",
|
||||
RegexOptions.CultureInvariant);
|
||||
var keysToRemove = _memoryKeys.Keys.Where(key => regex.IsMatch(key)).ToArray();
|
||||
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_memoryCache.Remove(key);
|
||||
_memoryKeys.TryRemove(key, out _);
|
||||
}
|
||||
|
||||
return keysToRemove.Length;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached value as a string.
|
||||
/// Checks L1 memory cache first, falls back to L2 Redis.
|
||||
/// </summary>
|
||||
public ValueTask<string?> GetStringAsync(string key)
|
||||
{
|
||||
// L1: Try in-memory cache first (sub-microsecond)
|
||||
if (TryGetMemoryValue(key, out var memoryValue))
|
||||
{
|
||||
_logger.LogDebug("L1 memory cache HIT: {Key}", key);
|
||||
return new ValueTask<string?>(memoryValue);
|
||||
}
|
||||
|
||||
if (!IsEnabled) return new ValueTask<string?>((string?)null);
|
||||
|
||||
return new ValueTask<string?>(GetStringFromRedisAsync(key));
|
||||
}
|
||||
|
||||
private async Task<string?> GetStringFromRedisAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
// L2: Fall back to Redis
|
||||
var value = await _db!.StringGetAsync(key);
|
||||
|
||||
if (value.HasValue)
|
||||
{
|
||||
_logger.LogDebug("Redis cache HIT: {Key}", key);
|
||||
_logger.LogDebug("L2 Redis cache HIT: {Key}", key);
|
||||
|
||||
// Promote to L1 for subsequent reads
|
||||
if (ShouldUseMemoryCache(key))
|
||||
{
|
||||
var stringValue = (string?)value;
|
||||
if (stringValue != null)
|
||||
{
|
||||
var redisExpiry = await _db.KeyTimeToLiveAsync(key);
|
||||
SetMemoryValue(key, stringValue, redisExpiry);
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Redis cache MISS: {Key}", key);
|
||||
_logger.LogDebug("Cache MISS: {Key}", key);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
@@ -77,15 +229,17 @@ public class RedisCacheService
|
||||
|
||||
/// <summary>
|
||||
/// Gets a cached value and deserializes it.
|
||||
/// Uses source-generated serializer for registered types (3-8x faster),
|
||||
/// with automatic fallback to reflection-based serialization.
|
||||
/// </summary>
|
||||
public async Task<T?> GetAsync<T>(string key) where T : class
|
||||
public async ValueTask<T?> GetAsync<T>(string key) where T : class
|
||||
{
|
||||
var json = await GetStringAsync(key);
|
||||
if (string.IsNullOrEmpty(json)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize<T>(json);
|
||||
return DeserializeWithFallback<T>(json, key);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -96,11 +250,20 @@ public class RedisCacheService
|
||||
|
||||
/// <summary>
|
||||
/// Sets a cached value with TTL.
|
||||
/// Writes to both L1 memory cache and L2 Redis.
|
||||
/// </summary>
|
||||
public async Task<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
|
||||
public ValueTask<bool> SetStringAsync(string key, string value, TimeSpan? expiry = null)
|
||||
{
|
||||
if (!IsEnabled) return false;
|
||||
// Always update L1 (even if Redis is down — provides degraded caching)
|
||||
SetMemoryValue(key, value, expiry);
|
||||
|
||||
if (!IsEnabled) return new ValueTask<bool>(false);
|
||||
|
||||
return new ValueTask<bool>(SetStringWithRedisAsync(key, value, expiry));
|
||||
}
|
||||
|
||||
private async Task<bool> SetStringWithRedisAsync(string key, string value, TimeSpan? expiry)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await SetStringInternalAsync(key, value, expiry);
|
||||
@@ -197,12 +360,14 @@ public class RedisCacheService
|
||||
|
||||
/// <summary>
|
||||
/// Sets a cached value by serializing it with TTL.
|
||||
/// Uses source-generated serializer for registered types (3-8x faster),
|
||||
/// with automatic fallback to reflection-based serialization.
|
||||
/// </summary>
|
||||
public async Task<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
|
||||
public async ValueTask<bool> SetAsync<T>(string key, T value, TimeSpan? expiry = null) where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
var json = JsonSerializer.Serialize(value);
|
||||
var json = SerializeWithFallback(value, key);
|
||||
return await SetStringAsync(key, json, expiry);
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -212,13 +377,80 @@ public class RedisCacheService
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a cached value.
|
||||
/// </summary>
|
||||
public async Task<bool> DeleteAsync(string key)
|
||||
private T? DeserializeWithFallback<T>(string json, string key) where T : class
|
||||
{
|
||||
if (!IsEnabled) return false;
|
||||
var typeInfo = TryGetTypeInfo<T>();
|
||||
if (typeInfo != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Deserialize(json, typeInfo);
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Source-generated deserialization unsupported for key: {Key}; falling back to reflection.",
|
||||
key);
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<T>(json, ReflectionFallbackJsonOptions);
|
||||
}
|
||||
|
||||
private string SerializeWithFallback<T>(T value, string key) where T : class
|
||||
{
|
||||
var typeInfo = TryGetTypeInfo<T>();
|
||||
if (typeInfo != null)
|
||||
{
|
||||
try
|
||||
{
|
||||
return JsonSerializer.Serialize(value, typeInfo);
|
||||
}
|
||||
catch (NotSupportedException ex)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
ex,
|
||||
"Source-generated serialization unsupported for key: {Key}; falling back to reflection.",
|
||||
key);
|
||||
}
|
||||
}
|
||||
|
||||
return JsonSerializer.Serialize(value, ReflectionFallbackJsonOptions);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to resolve a JsonTypeInfo from the AllstarrJsonContext source generator.
|
||||
/// Returns null if the type isn't registered, triggering fallback to reflection.
|
||||
/// </summary>
|
||||
private static JsonTypeInfo<T>? TryGetTypeInfo<T>() where T : class
|
||||
{
|
||||
try
|
||||
{
|
||||
return (JsonTypeInfo<T>?)AllstarrJsonContext.Default.GetTypeInfo(typeof(T));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Deletes a cached value from both L1 memory and L2 Redis.
|
||||
/// </summary>
|
||||
public ValueTask<bool> DeleteAsync(string key)
|
||||
{
|
||||
// Always evict from L1
|
||||
_memoryCache.Remove(key);
|
||||
_memoryKeys.TryRemove(key, out _);
|
||||
|
||||
if (!IsEnabled) return new ValueTask<bool>(false);
|
||||
|
||||
return new ValueTask<bool>(DeleteFromRedisAsync(key));
|
||||
}
|
||||
|
||||
private async Task<bool> DeleteFromRedisAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _db!.KeyDeleteAsync(key);
|
||||
@@ -233,10 +465,20 @@ public class RedisCacheService
|
||||
/// <summary>
|
||||
/// Checks if a key exists.
|
||||
/// </summary>
|
||||
public async Task<bool> ExistsAsync(string key)
|
||||
public ValueTask<bool> ExistsAsync(string key)
|
||||
{
|
||||
if (!IsEnabled) return false;
|
||||
if (ShouldUseMemoryCache(key) && _memoryCache.TryGetValue(key, out _))
|
||||
{
|
||||
return new ValueTask<bool>(true);
|
||||
}
|
||||
|
||||
if (!IsEnabled) return new ValueTask<bool>(false);
|
||||
|
||||
return new ValueTask<bool>(ExistsInRedisAsync(key));
|
||||
}
|
||||
|
||||
private async Task<bool> ExistsInRedisAsync(string key)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await _db!.KeyExistsAsync(key);
|
||||
@@ -271,10 +513,16 @@ public class RedisCacheService
|
||||
/// Deletes all keys matching a pattern (e.g., "search:*").
|
||||
/// WARNING: Use with caution as this scans all keys.
|
||||
/// </summary>
|
||||
public async Task<int> DeleteByPatternAsync(string pattern)
|
||||
public ValueTask<int> DeleteByPatternAsync(string pattern)
|
||||
{
|
||||
if (!IsEnabled) return 0;
|
||||
var memoryDeleted = RemoveMemoryKeysByPattern(pattern);
|
||||
if (!IsEnabled) return new ValueTask<int>(memoryDeleted);
|
||||
|
||||
return new ValueTask<int>(DeleteByPatternFromRedisAsync(pattern, memoryDeleted));
|
||||
}
|
||||
|
||||
private async Task<int> DeleteByPatternFromRedisAsync(string pattern, int memoryDeleted)
|
||||
{
|
||||
try
|
||||
{
|
||||
var server = _redis!.GetServer(_redis.GetEndPoints().First());
|
||||
@@ -282,18 +530,22 @@ public class RedisCacheService
|
||||
|
||||
if (keys.Length == 0)
|
||||
{
|
||||
_logger.LogDebug("No keys found matching pattern: {Pattern}", pattern);
|
||||
return 0;
|
||||
_logger.LogDebug("No Redis keys found matching pattern: {Pattern}", pattern);
|
||||
return memoryDeleted;
|
||||
}
|
||||
|
||||
var deleted = await _db!.KeyDeleteAsync(keys);
|
||||
_logger.LogDebug("Deleted {Count} Redis keys matching pattern: {Pattern}", deleted, pattern);
|
||||
_logger.LogDebug(
|
||||
"Deleted {RedisCount} Redis keys and {MemoryCount} memory keys matching pattern: {Pattern}",
|
||||
deleted,
|
||||
memoryDeleted,
|
||||
pattern);
|
||||
return (int)deleted;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Redis DELETE BY PATTERN failed for pattern: {Pattern}", pattern);
|
||||
return 0;
|
||||
return memoryDeleted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,16 +442,18 @@ public class RoundRobinFallbackHelper
|
||||
private void LogEndpointFailure(string baseUrl, Exception ex, bool willRetry)
|
||||
{
|
||||
var message = BuildFailureSummary(ex);
|
||||
var isTimeoutOrCancellation = ex is TaskCanceledException or OperationCanceledException;
|
||||
var verb = isTimeoutOrCancellation ? "request timed out" : "request failed";
|
||||
|
||||
if (willRetry)
|
||||
{
|
||||
_logger.LogWarning("{Service} request failed at {Endpoint}: {Error}. Trying next...",
|
||||
_serviceName, baseUrl, message);
|
||||
_logger.LogWarning("{Service} {Verb} at {Endpoint}: {Error}. Trying next...",
|
||||
_serviceName, verb, baseUrl, message);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError("{Service} request failed at {Endpoint}: {Error}",
|
||||
_serviceName, baseUrl, message);
|
||||
_logger.LogError("{Service} {Verb} at {Endpoint}: {Error}",
|
||||
_serviceName, verb, baseUrl, message);
|
||||
}
|
||||
|
||||
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
|
||||
@@ -466,6 +468,16 @@ public class RoundRobinFallbackHelper
|
||||
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
|
||||
}
|
||||
|
||||
if (ex is TaskCanceledException)
|
||||
{
|
||||
return "Timed out";
|
||||
}
|
||||
|
||||
if (ex is OperationCanceledException)
|
||||
{
|
||||
return "Canceled";
|
||||
}
|
||||
|
||||
return ex.Message;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.Hosting;
|
||||
|
||||
namespace allstarr.Services.Common;
|
||||
|
||||
/// <summary>
|
||||
/// Loads supported flat .env keys into ASP.NET configuration so Docker/admin UI
|
||||
/// updates stored in /app/.env take effect on the next application startup.
|
||||
/// </summary>
|
||||
public static class RuntimeEnvConfiguration
|
||||
{
|
||||
private static readonly IReadOnlyDictionary<string, string[]> ExactKeyMappings =
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["BACKEND_TYPE"] = ["Backend:Type"],
|
||||
["ADMIN_BIND_ANY_IP"] = ["Admin:BindAnyIp"],
|
||||
["ADMIN_TRUSTED_SUBNETS"] = ["Admin:TrustedSubnets"],
|
||||
["ADMIN_ENABLE_ENV_EXPORT"] = ["Admin:EnableEnvExport"],
|
||||
|
||||
["CORS_ALLOWED_ORIGINS"] = ["Cors:AllowedOrigins"],
|
||||
["CORS_ALLOWED_METHODS"] = ["Cors:AllowedMethods"],
|
||||
["CORS_ALLOWED_HEADERS"] = ["Cors:AllowedHeaders"],
|
||||
["CORS_ALLOW_CREDENTIALS"] = ["Cors:AllowCredentials"],
|
||||
|
||||
["SUBSONIC_URL"] = ["Subsonic:Url"],
|
||||
["JELLYFIN_URL"] = ["Jellyfin:Url"],
|
||||
["JELLYFIN_API_KEY"] = ["Jellyfin:ApiKey"],
|
||||
["JELLYFIN_USER_ID"] = ["Jellyfin:UserId"],
|
||||
["JELLYFIN_CLIENT_USERNAME"] = ["Jellyfin:ClientUsername"],
|
||||
["JELLYFIN_LIBRARY_ID"] = ["Jellyfin:LibraryId"],
|
||||
|
||||
["LIBRARY_DOWNLOAD_PATH"] = ["Library:DownloadPath"],
|
||||
["LIBRARY_KEPT_PATH"] = ["Library:KeptPath"],
|
||||
|
||||
["REDIS_ENABLED"] = ["Redis:Enabled"],
|
||||
["REDIS_CONNECTION_STRING"] = ["Redis:ConnectionString"],
|
||||
|
||||
["SPOTIFY_IMPORT_ENABLED"] = ["SpotifyImport:Enabled"],
|
||||
["SPOTIFY_IMPORT_SYNC_START_HOUR"] = ["SpotifyImport:SyncStartHour"],
|
||||
["SPOTIFY_IMPORT_SYNC_START_MINUTE"] = ["SpotifyImport:SyncStartMinute"],
|
||||
["SPOTIFY_IMPORT_SYNC_WINDOW_HOURS"] = ["SpotifyImport:SyncWindowHours"],
|
||||
["SPOTIFY_IMPORT_MATCHING_INTERVAL_HOURS"] = ["SpotifyImport:MatchingIntervalHours"],
|
||||
["SPOTIFY_IMPORT_PLAYLISTS"] = ["SpotifyImport:Playlists"],
|
||||
["SPOTIFY_IMPORT_PLAYLIST_IDS"] = ["SpotifyImport:PlaylistIds"],
|
||||
["SPOTIFY_IMPORT_PLAYLIST_NAMES"] = ["SpotifyImport:PlaylistNames"],
|
||||
["SPOTIFY_IMPORT_PLAYLIST_LOCAL_TRACKS_POSITIONS"] = ["SpotifyImport:PlaylistLocalTracksPositions"],
|
||||
|
||||
["SPOTIFY_API_ENABLED"] = ["SpotifyApi:Enabled"],
|
||||
["SPOTIFY_API_SESSION_COOKIE"] = ["SpotifyApi:SessionCookie"],
|
||||
["SPOTIFY_API_SESSION_COOKIE_SET_DATE"] = ["SpotifyApi:SessionCookieSetDate"],
|
||||
["SPOTIFY_API_CACHE_DURATION_MINUTES"] = ["SpotifyApi:CacheDurationMinutes"],
|
||||
["SPOTIFY_API_RATE_LIMIT_DELAY_MS"] = ["SpotifyApi:RateLimitDelayMs"],
|
||||
["SPOTIFY_API_PREFER_ISRC_MATCHING"] = ["SpotifyApi:PreferIsrcMatching"],
|
||||
["SPOTIFY_LYRICS_API_URL"] = ["SpotifyApi:LyricsApiUrl"],
|
||||
|
||||
["SCROBBLING_ENABLED"] = ["Scrobbling:Enabled"],
|
||||
["SCROBBLING_LOCAL_TRACKS_ENABLED"] = ["Scrobbling:LocalTracksEnabled"],
|
||||
["SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED"] = ["Scrobbling:SyntheticLocalPlayedSignalEnabled"],
|
||||
["SCROBBLING_LASTFM_ENABLED"] = ["Scrobbling:LastFm:Enabled"],
|
||||
["SCROBBLING_LASTFM_API_KEY"] = ["Scrobbling:LastFm:ApiKey"],
|
||||
["SCROBBLING_LASTFM_SHARED_SECRET"] = ["Scrobbling:LastFm:SharedSecret"],
|
||||
["SCROBBLING_LASTFM_SESSION_KEY"] = ["Scrobbling:LastFm:SessionKey"],
|
||||
["SCROBBLING_LASTFM_USERNAME"] = ["Scrobbling:LastFm:Username"],
|
||||
["SCROBBLING_LASTFM_PASSWORD"] = ["Scrobbling:LastFm:Password"],
|
||||
["SCROBBLING_LISTENBRAINZ_ENABLED"] = ["Scrobbling:ListenBrainz:Enabled"],
|
||||
["SCROBBLING_LISTENBRAINZ_USER_TOKEN"] = ["Scrobbling:ListenBrainz:UserToken"],
|
||||
|
||||
["DEBUG_LOG_ALL_REQUESTS"] = ["Debug:LogAllRequests"],
|
||||
["DEBUG_REDACT_SENSITIVE_REQUEST_VALUES"] = ["Debug:RedactSensitiveRequestValues"],
|
||||
|
||||
["DEEZER_ARL"] = ["Deezer:Arl"],
|
||||
["DEEZER_ARL_FALLBACK"] = ["Deezer:ArlFallback"],
|
||||
["DEEZER_QUALITY"] = ["Deezer:Quality"],
|
||||
["DEEZER_MIN_REQUEST_INTERVAL_MS"] = ["Deezer:MinRequestIntervalMs"],
|
||||
|
||||
["QOBUZ_USER_AUTH_TOKEN"] = ["Qobuz:UserAuthToken"],
|
||||
["QOBUZ_USER_ID"] = ["Qobuz:UserId"],
|
||||
["QOBUZ_QUALITY"] = ["Qobuz:Quality"],
|
||||
["QOBUZ_MIN_REQUEST_INTERVAL_MS"] = ["Qobuz:MinRequestIntervalMs"],
|
||||
|
||||
["SQUIDWTF_QUALITY"] = ["SquidWTF:Quality"],
|
||||
["SQUIDWTF_MIN_REQUEST_INTERVAL_MS"] = ["SquidWTF:MinRequestIntervalMs"],
|
||||
|
||||
["MUSICBRAINZ_ENABLED"] = ["MusicBrainz:Enabled"],
|
||||
["MUSICBRAINZ_USERNAME"] = ["MusicBrainz:Username"],
|
||||
["MUSICBRAINZ_PASSWORD"] = ["MusicBrainz:Password"],
|
||||
|
||||
["CACHE_SEARCH_RESULTS_MINUTES"] = ["Cache:SearchResultsMinutes"],
|
||||
["CACHE_PLAYLIST_IMAGES_HOURS"] = ["Cache:PlaylistImagesHours"],
|
||||
["CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS"] = ["Cache:SpotifyPlaylistItemsHours"],
|
||||
["CACHE_SPOTIFY_MATCHED_TRACKS_DAYS"] = ["Cache:SpotifyMatchedTracksDays"],
|
||||
["CACHE_LYRICS_DAYS"] = ["Cache:LyricsDays"],
|
||||
["CACHE_GENRE_DAYS"] = ["Cache:GenreDays"],
|
||||
["CACHE_METADATA_DAYS"] = ["Cache:MetadataDays"],
|
||||
["CACHE_ODESLI_LOOKUP_DAYS"] = ["Cache:OdesliLookupDays"],
|
||||
["CACHE_PROXY_IMAGES_DAYS"] = ["Cache:ProxyImagesDays"],
|
||||
["CACHE_TRANSCODE_MINUTES"] = ["Cache:TranscodeCacheMinutes"]
|
||||
};
|
||||
|
||||
private static readonly IReadOnlyDictionary<string, string[]> SharedBackendKeyMappings =
|
||||
new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["MUSIC_SERVICE"] = ["Subsonic:MusicService", "Jellyfin:MusicService"],
|
||||
["EXPLICIT_FILTER"] = ["Subsonic:ExplicitFilter", "Jellyfin:ExplicitFilter"],
|
||||
["DOWNLOAD_MODE"] = ["Subsonic:DownloadMode", "Jellyfin:DownloadMode"],
|
||||
["STORAGE_MODE"] = ["Subsonic:StorageMode", "Jellyfin:StorageMode"],
|
||||
["CACHE_DURATION_HOURS"] = ["Subsonic:CacheDurationHours", "Jellyfin:CacheDurationHours"],
|
||||
["ENABLE_EXTERNAL_PLAYLISTS"] = ["Subsonic:EnableExternalPlaylists", "Jellyfin:EnableExternalPlaylists"],
|
||||
["PLAYLISTS_DIRECTORY"] = ["Subsonic:PlaylistsDirectory", "Jellyfin:PlaylistsDirectory"]
|
||||
};
|
||||
|
||||
private static readonly HashSet<string> IgnoredComposeOnlyKeys = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"DOWNLOAD_PATH",
|
||||
"KEPT_PATH",
|
||||
"CACHE_PATH",
|
||||
"REDIS_DATA_PATH"
|
||||
};
|
||||
|
||||
public static string ResolveEnvFilePath(IHostEnvironment environment)
|
||||
{
|
||||
return environment.IsDevelopment()
|
||||
? Path.GetFullPath(Path.Combine(environment.ContentRootPath, "..", ".env"))
|
||||
: "/app/.env";
|
||||
}
|
||||
|
||||
public static void AddDotEnvOverrides(
|
||||
ConfigurationManager configuration,
|
||||
IHostEnvironment environment,
|
||||
TextWriter? logWriter = null)
|
||||
{
|
||||
AddDotEnvOverrides(configuration, ResolveEnvFilePath(environment), logWriter);
|
||||
}
|
||||
|
||||
public static void AddDotEnvOverrides(
|
||||
ConfigurationManager configuration,
|
||||
string envFilePath,
|
||||
TextWriter? logWriter = null)
|
||||
{
|
||||
var overrides = LoadDotEnvOverrides(envFilePath);
|
||||
if (overrides.Count == 0)
|
||||
{
|
||||
if (File.Exists(envFilePath))
|
||||
{
|
||||
logWriter?.WriteLine($"No supported runtime overrides found in {envFilePath}");
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
configuration.AddInMemoryCollection(overrides);
|
||||
logWriter?.WriteLine($"Loaded {overrides.Count} runtime override(s) from {envFilePath}");
|
||||
}
|
||||
|
||||
public static Dictionary<string, string?> LoadDotEnvOverrides(string envFilePath)
|
||||
{
|
||||
var overrides = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
if (!File.Exists(envFilePath))
|
||||
{
|
||||
return overrides;
|
||||
}
|
||||
|
||||
foreach (var line in File.ReadLines(envFilePath))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line) || line.TrimStart().StartsWith('#'))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var separatorIndex = line.IndexOf('=');
|
||||
if (separatorIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var envKey = line[..separatorIndex].Trim();
|
||||
var envValue = StripQuotes(line[(separatorIndex + 1)..].Trim());
|
||||
|
||||
foreach (var mapping in MapEnvVarToConfiguration(envKey, envValue))
|
||||
{
|
||||
overrides[mapping.Key] = mapping.Value;
|
||||
}
|
||||
}
|
||||
|
||||
return overrides;
|
||||
}
|
||||
|
||||
public static IEnumerable<KeyValuePair<string, string?>> MapEnvVarToConfiguration(string envKey, string? envValue)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(envKey) || IgnoredComposeOnlyKeys.Contains(envKey))
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (envKey.Contains("__", StringComparison.Ordinal))
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(envKey.Replace("__", ":"), envValue);
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (SharedBackendKeyMappings.TryGetValue(envKey, out var sharedKeys))
|
||||
{
|
||||
foreach (var sharedKey in sharedKeys)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(sharedKey, envValue);
|
||||
}
|
||||
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (ExactKeyMappings.TryGetValue(envKey, out var configKeys))
|
||||
{
|
||||
foreach (var configKey in configKeys)
|
||||
{
|
||||
yield return new KeyValuePair<string, string?>(configKey, envValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string StripQuotes(string? value)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
return value ?? string.Empty;
|
||||
}
|
||||
|
||||
if (value.StartsWith('"') && value.EndsWith('"') && value.Length >= 2)
|
||||
{
|
||||
return value[1..^1];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -135,10 +135,15 @@ public class DeezerMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Execute searches in parallel
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
var songsTask = songLimit > 0
|
||||
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Song>());
|
||||
var albumsTask = albumLimit > 0
|
||||
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Album>());
|
||||
var artistsTask = artistLimit > 0
|
||||
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Artist>());
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -10,12 +10,21 @@ namespace allstarr.Services.Jellyfin;
|
||||
|
||||
/// <summary>
|
||||
/// Handles proxying requests to the Jellyfin server and authentication.
|
||||
/// Uses a named HttpClient ("JellyfinBackend") with SocketsHttpHandler for
|
||||
/// TCP connection pooling across scoped instances.
|
||||
/// </summary>
|
||||
public class JellyfinProxyService
|
||||
{
|
||||
/// <summary>
|
||||
/// The IHttpClientFactory registration name for the Jellyfin backend client.
|
||||
/// Configured with SocketsHttpHandler for connection pooling in Program.cs.
|
||||
/// </summary>
|
||||
public const string HttpClientName = "JellyfinBackend";
|
||||
|
||||
private readonly HttpClient _httpClient;
|
||||
private readonly JellyfinSettings _settings;
|
||||
private readonly IHttpContextAccessor _httpContextAccessor;
|
||||
private readonly JellyfinUserContextResolver _userContextResolver;
|
||||
private readonly ILogger<JellyfinProxyService> _logger;
|
||||
private readonly RedisCacheService _cache;
|
||||
private string? _cachedMusicLibraryId;
|
||||
@@ -28,16 +37,35 @@ public class JellyfinProxyService
|
||||
IHttpClientFactory httpClientFactory,
|
||||
IOptions<JellyfinSettings> settings,
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
JellyfinUserContextResolver userContextResolver,
|
||||
ILogger<JellyfinProxyService> logger,
|
||||
RedisCacheService cache)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient = httpClientFactory.CreateClient(HttpClientName);
|
||||
_settings = settings.Value;
|
||||
_httpContextAccessor = httpContextAccessor;
|
||||
_userContextResolver = userContextResolver;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
}
|
||||
|
||||
private async Task AddResolvedUserIdAsync(
|
||||
Dictionary<string, string> queryParams,
|
||||
IHeaderDictionary? clientHeaders = null,
|
||||
bool allowConfigurationFallback = true)
|
||||
{
|
||||
if (queryParams.ContainsKey("userId") || queryParams.ContainsKey("UserId"))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = await _userContextResolver.ResolveCurrentUserIdAsync(clientHeaders, allowConfigurationFallback);
|
||||
if (!string.IsNullOrWhiteSpace(userId))
|
||||
{
|
||||
queryParams["userId"] = userId;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the music library ID, auto-detecting it if not configured.
|
||||
/// </summary>
|
||||
@@ -153,9 +181,73 @@ public class JellyfinProxyService
|
||||
return await GetJsonAsyncInternal(finalUrl, clientHeaders);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends a proxied GET request to Jellyfin and returns the raw upstream response without buffering the body.
|
||||
/// Intended for transparent passthrough of large JSON payloads that Allstarr does not modify.
|
||||
/// </summary>
|
||||
public async Task<HttpResponseMessage> GetPassthroughResponseAsync(
|
||||
string endpoint,
|
||||
IHeaderDictionary? clientHeaders = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var url = BuildUrl(endpoint);
|
||||
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||
ForwardPassthroughRequestHeaders(clientHeaders, request);
|
||||
|
||||
var response = await _httpClient.SendAsync(
|
||||
request,
|
||||
HttpCompletionOption.ResponseHeadersRead,
|
||||
cancellationToken);
|
||||
|
||||
if (!response.IsSuccessStatusCode && !isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
|
||||
private async Task<(JsonDocument? Body, int StatusCode)> GetJsonAsyncInternal(string url, IHeaderDictionary? clientHeaders)
|
||||
{
|
||||
using var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
using var request = CreateClientGetRequest(url, clientHeaders, out var isBrowserStaticRequest, out var isPublicEndpoint);
|
||||
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
|
||||
}
|
||||
|
||||
// Try to parse error response to pass through to client
|
||||
try
|
||||
{
|
||||
await using var errorStream = await response.Content.ReadAsStreamAsync();
|
||||
var errorDoc = await JsonDocument.ParseAsync(errorStream);
|
||||
return (errorDoc, statusCode);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
// Not valid JSON, return null
|
||||
}
|
||||
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
return (await JsonDocument.ParseAsync(stream), statusCode);
|
||||
}
|
||||
|
||||
private HttpRequestMessage CreateClientGetRequest(
|
||||
string url,
|
||||
IHeaderDictionary? clientHeaders,
|
||||
out bool isBrowserStaticRequest,
|
||||
out bool isPublicEndpoint)
|
||||
{
|
||||
var request = new HttpRequestMessage(HttpMethod.Get, url);
|
||||
|
||||
// Forward client IP address to Jellyfin so it can identify the real client
|
||||
if (_httpContextAccessor.HttpContext != null)
|
||||
@@ -168,10 +260,8 @@ public class JellyfinProxyService
|
||||
}
|
||||
}
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
|
||||
// Check if this is a browser request for static assets (favicon, etc.)
|
||||
bool isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
|
||||
isBrowserStaticRequest = url.Contains("/favicon.ico", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/web/", StringComparison.OrdinalIgnoreCase) ||
|
||||
(clientHeaders?.Any(h => h.Key.Equals("User-Agent", StringComparison.OrdinalIgnoreCase) &&
|
||||
h.Value.ToString().Contains("Mozilla", StringComparison.OrdinalIgnoreCase)) == true &&
|
||||
@@ -180,10 +270,12 @@ public class JellyfinProxyService
|
||||
h.Value.ToString().Contains("document", StringComparison.OrdinalIgnoreCase))) == true);
|
||||
|
||||
// Check if this is a public endpoint that doesn't require authentication
|
||||
bool isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
|
||||
isPublicEndpoint = url.Contains("/System/Info/Public", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/Branding/", StringComparison.OrdinalIgnoreCase) ||
|
||||
url.Contains("/Startup/", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
var authHeaderAdded = false;
|
||||
|
||||
// Forward authentication headers from client if provided
|
||||
if (clientHeaders != null && clientHeaders.Count > 0)
|
||||
{
|
||||
@@ -209,40 +301,35 @@ public class JellyfinProxyService
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
// Always parse the response, even for errors
|
||||
// The caller needs to see 401s so the client can re-authenticate
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||
{
|
||||
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
|
||||
return request;
|
||||
}
|
||||
|
||||
// Try to parse error response to pass through to client
|
||||
if (!string.IsNullOrWhiteSpace(content))
|
||||
private static void ForwardPassthroughRequestHeaders(
|
||||
IHeaderDictionary? clientHeaders,
|
||||
HttpRequestMessage request)
|
||||
{
|
||||
try
|
||||
if (clientHeaders == null || clientHeaders.Count == 0)
|
||||
{
|
||||
var errorDoc = JsonDocument.Parse(content);
|
||||
return (errorDoc, statusCode);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Not valid JSON, return null
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
return (null, statusCode);
|
||||
if (clientHeaders.TryGetValue("Accept-Encoding", out var acceptEncoding) &&
|
||||
acceptEncoding.Count > 0)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Accept-Encoding", acceptEncoding.ToArray());
|
||||
}
|
||||
|
||||
return (JsonDocument.Parse(content), statusCode);
|
||||
if (clientHeaders.TryGetValue("User-Agent", out var userAgent) &&
|
||||
userAgent.Count > 0)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("User-Agent", userAgent.ToArray());
|
||||
}
|
||||
|
||||
if (clientHeaders.TryGetValue("Accept-Language", out var acceptLanguage) &&
|
||||
acceptLanguage.Count > 0)
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("Accept-Language", acceptLanguage.ToArray());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -251,10 +338,31 @@ public class JellyfinProxyService
|
||||
/// Returns the response body and HTTP status code.
|
||||
/// </summary>
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> PostJsonAsync(string endpoint, string body, IHeaderDictionary clientHeaders)
|
||||
{
|
||||
var bodyToSend = body;
|
||||
if (string.IsNullOrWhiteSpace(bodyToSend))
|
||||
{
|
||||
bodyToSend = "{}";
|
||||
_logger.LogWarning("POST body was empty for {Endpoint}, sending empty JSON object", endpoint);
|
||||
}
|
||||
|
||||
return await SendAsync(HttpMethod.Post, endpoint, bodyToSend, clientHeaders, "application/json");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sends an arbitrary HTTP request to Jellyfin while preserving the caller's method and body semantics.
|
||||
/// Intended for transparent proxy scenarios such as session control routes.
|
||||
/// </summary>
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> SendAsync(
|
||||
HttpMethod method,
|
||||
string endpoint,
|
||||
string? body,
|
||||
IHeaderDictionary clientHeaders,
|
||||
string? contentType = null)
|
||||
{
|
||||
var url = BuildUrl(endpoint, null);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Post, url);
|
||||
using var request = new HttpRequestMessage(method, url);
|
||||
|
||||
// Forward client IP address to Jellyfin so it can identify the real client
|
||||
if (_httpContextAccessor.HttpContext != null)
|
||||
@@ -267,58 +375,62 @@ public class JellyfinProxyService
|
||||
}
|
||||
}
|
||||
|
||||
// Handle special case for playback endpoints
|
||||
// NOTE: Jellyfin API expects PlaybackStartInfo/PlaybackProgressInfo/PlaybackStopInfo
|
||||
// DIRECTLY as the body, NOT wrapped in a field. Do NOT wrap the body.
|
||||
var bodyToSend = body;
|
||||
if (string.IsNullOrWhiteSpace(body))
|
||||
if (body != null)
|
||||
{
|
||||
bodyToSend = "{}";
|
||||
_logger.LogWarning("POST body was empty for {Url}, sending empty JSON object", url);
|
||||
var requestContent = new StringContent(body, System.Text.Encoding.UTF8);
|
||||
try
|
||||
{
|
||||
requestContent.Headers.ContentType = !string.IsNullOrWhiteSpace(contentType)
|
||||
? MediaTypeHeaderValue.Parse(contentType)
|
||||
: new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
_logger.LogWarning("Invalid content type '{ContentType}' for {Method} {Endpoint}; falling back to application/json",
|
||||
contentType,
|
||||
method,
|
||||
endpoint);
|
||||
requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/json") { CharSet = "utf-8" };
|
||||
}
|
||||
|
||||
request.Content = new StringContent(bodyToSend, System.Text.Encoding.UTF8, "application/json");
|
||||
request.Content = requestContent;
|
||||
}
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
bool isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
// Forward authentication headers from client
|
||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||
var authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||
var isAuthEndpoint = endpoint.Contains("Authenticate", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
if (authHeaderAdded)
|
||||
{
|
||||
_logger.LogTrace("Forwarded authentication headers");
|
||||
}
|
||||
|
||||
// For authentication endpoints, credentials are in the body, not headers
|
||||
// For other endpoints without auth, let Jellyfin reject the request
|
||||
if (!authHeaderAdded && !isAuthEndpoint)
|
||||
else if (!isAuthEndpoint)
|
||||
{
|
||||
_logger.LogDebug("No client auth provided for POST {Url} - Jellyfin will handle authentication", url);
|
||||
_logger.LogDebug("No client auth provided for {Method} {Url} - Jellyfin will handle authentication", method, url);
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
// DO NOT log the body for auth endpoints - it contains passwords!
|
||||
if (isAuthEndpoint)
|
||||
{
|
||||
_logger.LogDebug("POST to Jellyfin: {Url} (auth request - body not logged)", url);
|
||||
_logger.LogDebug("{Method} to Jellyfin: {Url} (auth request - body not logged)", method, url);
|
||||
}
|
||||
else if (body == null)
|
||||
{
|
||||
_logger.LogTrace("{Method} to Jellyfin: {Url} (no request body)", method, url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogTrace("POST to Jellyfin: {Url}, body length: {Length} bytes", url, bodyToSend.Length);
|
||||
_logger.LogTrace("{Method} to Jellyfin: {Url}, body length: {Length} bytes", method, url, body.Length);
|
||||
}
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
LogUpstreamFailure(HttpMethod.Post, response.StatusCode, url, errorContent);
|
||||
LogUpstreamFailure(method, response.StatusCode, url, errorContent);
|
||||
|
||||
// Try to parse error response as JSON to pass through to client
|
||||
if (!string.IsNullOrWhiteSpace(errorContent))
|
||||
{
|
||||
try
|
||||
@@ -335,21 +447,17 @@ public class JellyfinProxyService
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
// Log successful session-related responses
|
||||
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogTrace("Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
|
||||
_logger.LogTrace("Jellyfin responded {StatusCode} for {Method} {Endpoint}", statusCode, method, endpoint);
|
||||
}
|
||||
|
||||
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
if (response.StatusCode == HttpStatusCode.NoContent)
|
||||
{
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Handle empty responses
|
||||
if (string.IsNullOrWhiteSpace(responseContent))
|
||||
{
|
||||
return (null, statusCode);
|
||||
@@ -411,65 +519,7 @@ public class JellyfinProxyService
|
||||
/// </summary>
|
||||
public async Task<(JsonDocument? Body, int StatusCode)> DeleteAsync(string endpoint, IHeaderDictionary clientHeaders)
|
||||
{
|
||||
var url = BuildUrl(endpoint, null);
|
||||
|
||||
using var request = new HttpRequestMessage(HttpMethod.Delete, url);
|
||||
|
||||
// Forward client IP address to Jellyfin so it can identify the real client
|
||||
if (_httpContextAccessor.HttpContext != null)
|
||||
{
|
||||
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
|
||||
if (!string.IsNullOrEmpty(clientIp))
|
||||
{
|
||||
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
|
||||
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
|
||||
}
|
||||
}
|
||||
|
||||
bool authHeaderAdded = false;
|
||||
|
||||
// Forward authentication headers from client
|
||||
authHeaderAdded = AuthHeaderHelper.ForwardAuthHeaders(clientHeaders, request);
|
||||
|
||||
if (!authHeaderAdded)
|
||||
{
|
||||
_logger.LogDebug("No client auth provided for DELETE {Url} - forwarding without auth", url);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogTrace("Forwarded authentication headers");
|
||||
}
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
_logger.LogDebug("DELETE to Jellyfin: {Url}", url);
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
|
||||
var statusCode = (int)response.StatusCode;
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorContent = await response.Content.ReadAsStringAsync();
|
||||
LogUpstreamFailure(HttpMethod.Delete, response.StatusCode, url, errorContent);
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
// Handle 204 No Content responses
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
|
||||
{
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
var responseContent = await response.Content.ReadAsStringAsync();
|
||||
|
||||
// Handle empty responses
|
||||
if (string.IsNullOrWhiteSpace(responseContent))
|
||||
{
|
||||
return (null, statusCode);
|
||||
}
|
||||
|
||||
return (JsonDocument.Parse(responseContent), statusCode);
|
||||
return await SendAsync(HttpMethod.Delete, endpoint, null, clientHeaders);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -517,10 +567,7 @@ public class JellyfinProxyService
|
||||
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
// Note: We don't force parentId here - let clients specify which library to search
|
||||
// The controller will detect music library searches and add external results
|
||||
@@ -567,10 +614,7 @@ public class JellyfinProxyService
|
||||
["fields"] = "PrimaryImageAspectRatio,MediaSources,Path,Genres,Studios,DateCreated,Overview,ProviderIds,ParentId"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
if (!string.IsNullOrEmpty(parentId))
|
||||
{
|
||||
@@ -612,10 +656,7 @@ public class JellyfinProxyService
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
return await GetJsonAsync($"Items/{itemId}", queryParams, clientHeaders);
|
||||
}
|
||||
@@ -634,10 +675,7 @@ public class JellyfinProxyService
|
||||
["fields"] = "PrimaryImageAspectRatio,Genres,Overview"
|
||||
};
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
if (!string.IsNullOrEmpty(searchTerm))
|
||||
{
|
||||
@@ -664,10 +702,7 @@ public class JellyfinProxyService
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams, clientHeaders);
|
||||
|
||||
// Try to get by ID first
|
||||
if (Guid.TryParse(artistIdOrName, out _))
|
||||
@@ -858,10 +893,7 @@ public class JellyfinProxyService
|
||||
try
|
||||
{
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrEmpty(_settings.UserId))
|
||||
{
|
||||
queryParams["userId"] = _settings.UserId;
|
||||
}
|
||||
await AddResolvedUserIdAsync(queryParams);
|
||||
|
||||
var (result, statusCode) = await GetJsonAsync("Library/MediaFolders", queryParams);
|
||||
if (result == null)
|
||||
@@ -982,12 +1014,12 @@ public class JellyfinProxyService
|
||||
|
||||
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
|
||||
|
||||
var response = await _httpClient.SendAsync(request);
|
||||
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead);
|
||||
var statusCode = (int)response.StatusCode;
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var content = await response.Content.ReadAsStringAsync();
|
||||
_logger.LogWarning("Jellyfin internal request returned {StatusCode} for {Url}: {Content}",
|
||||
statusCode, url, content);
|
||||
return (null, statusCode);
|
||||
@@ -995,12 +1027,13 @@ public class JellyfinProxyService
|
||||
|
||||
try
|
||||
{
|
||||
var jsonDocument = JsonDocument.Parse(content);
|
||||
await using var stream = await response.Content.ReadAsStreamAsync();
|
||||
var jsonDocument = await JsonDocument.ParseAsync(stream);
|
||||
return (jsonDocument, statusCode);
|
||||
}
|
||||
catch (JsonException ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to parse JSON response from {Url}: {Content}", url, content);
|
||||
_logger.LogError(ex, "Failed to parse JSON response from {Url}", url);
|
||||
return (null, statusCode);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -355,6 +355,7 @@ public class JellyfinResponseBuilder
|
||||
["Tags"] = new string[0],
|
||||
["People"] = new object[0],
|
||||
["SortName"] = songTitle,
|
||||
["AudioInfo"] = new Dictionary<string, object?>(),
|
||||
["ParentLogoItemId"] = song.AlbumId,
|
||||
["ParentBackdropItemId"] = song.AlbumId,
|
||||
["ParentBackdropImageTags"] = new string[0],
|
||||
@@ -405,6 +406,7 @@ public class JellyfinResponseBuilder
|
||||
["MediaType"] = "Audio",
|
||||
["NormalizationGain"] = 0.0,
|
||||
["Path"] = $"/music/{song.Artist}/{song.Album}/{song.Title}.flac",
|
||||
["CanDelete"] = false,
|
||||
["CanDownload"] = true,
|
||||
["SupportsSync"] = true
|
||||
};
|
||||
@@ -539,6 +541,7 @@ public class JellyfinResponseBuilder
|
||||
["ServerId"] = "allstarr",
|
||||
["Id"] = album.Id,
|
||||
["PremiereDate"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : null,
|
||||
["DateCreated"] = album.Year.HasValue ? $"{album.Year}-01-01T05:00:00.0000000Z" : "1970-01-01T00:00:00.0000000Z",
|
||||
["ChannelId"] = (object?)null,
|
||||
["Genres"] = !string.IsNullOrEmpty(album.Genre)
|
||||
? new[] { album.Genre }
|
||||
@@ -547,6 +550,8 @@ public class JellyfinResponseBuilder
|
||||
["ProductionYear"] = album.Year,
|
||||
["IsFolder"] = true,
|
||||
["Type"] = "MusicAlbum",
|
||||
["SortName"] = albumName,
|
||||
["BasicSyncInfo"] = new Dictionary<string, object?>(),
|
||||
["GenreItems"] = !string.IsNullOrEmpty(album.Genre)
|
||||
? new[]
|
||||
{
|
||||
@@ -633,6 +638,9 @@ public class JellyfinResponseBuilder
|
||||
["RunTimeTicks"] = 0,
|
||||
["IsFolder"] = true,
|
||||
["Type"] = "MusicArtist",
|
||||
["SortName"] = artistName,
|
||||
["PrimaryImageAspectRatio"] = 1.0,
|
||||
["BasicSyncInfo"] = new Dictionary<string, object?>(),
|
||||
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||
["UserData"] = new Dictionary<string, object>
|
||||
{
|
||||
@@ -755,6 +763,11 @@ public class JellyfinResponseBuilder
|
||||
["RunTimeTicks"] = playlist.Duration * TimeSpan.TicksPerSecond,
|
||||
["IsFolder"] = true,
|
||||
["Type"] = "MusicAlbum",
|
||||
["SortName"] = $"{playlist.Name} [S/P]",
|
||||
["DateCreated"] = playlist.CreatedDate.HasValue
|
||||
? playlist.CreatedDate.Value.ToString("o")
|
||||
: "1970-01-01T00:00:00.0000000Z",
|
||||
["BasicSyncInfo"] = new Dictionary<string, object?>(),
|
||||
["GenreItems"] = new Dictionary<string, object?>[0],
|
||||
["UserData"] = new Dictionary<string, object>
|
||||
{
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.Extensions.Options;
|
||||
using allstarr.Models.Jellyfin;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Serialization;
|
||||
|
||||
namespace allstarr.Services.Jellyfin;
|
||||
|
||||
@@ -20,6 +21,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
private readonly ILogger<JellyfinSessionManager> _logger;
|
||||
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
|
||||
private readonly ConcurrentDictionary<string, SemaphoreSlim> _sessionInitLocks = new();
|
||||
private readonly ConcurrentDictionary<string, byte> _proxiedWebSocketConnections = new();
|
||||
private readonly Timer _keepAliveTimer;
|
||||
|
||||
public JellyfinSessionManager(
|
||||
@@ -53,14 +55,20 @@ public class JellyfinSessionManager : IDisposable
|
||||
await initLock.WaitAsync();
|
||||
try
|
||||
{
|
||||
var hasProxiedWebSocket = HasProxiedWebSocket(deviceId);
|
||||
|
||||
// Check if we already have this session tracked
|
||||
if (_sessions.TryGetValue(deviceId, out var existingSession))
|
||||
{
|
||||
existingSession.LastActivity = DateTime.UtcNow;
|
||||
existingSession.HasProxiedWebSocket = hasProxiedWebSocket;
|
||||
_logger.LogInformation("Session already exists for device {DeviceId}", deviceId);
|
||||
|
||||
// Refresh capabilities to keep session alive
|
||||
// If this returns false (401), the token expired and client needs to re-auth
|
||||
if (!hasProxiedWebSocket)
|
||||
{
|
||||
// Refresh capabilities to keep session alive only for sessions that Allstarr
|
||||
// is synthesizing itself. Native proxied websocket sessions should be left
|
||||
// entirely under Jellyfin's control.
|
||||
var refreshOk = await PostCapabilitiesAsync(headers);
|
||||
if (!refreshOk)
|
||||
{
|
||||
@@ -69,13 +77,18 @@ public class JellyfinSessionManager : IDisposable
|
||||
await RemoveSessionAsync(deviceId);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
_logger.LogDebug("Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
|
||||
|
||||
// Post session capabilities to Jellyfin - this creates the session
|
||||
if (!hasProxiedWebSocket)
|
||||
{
|
||||
// Post session capabilities to Jellyfin only when Allstarr is creating a
|
||||
// synthetic session. If the real client already has a proxied websocket,
|
||||
// re-posting capabilities can overwrite its remote-control state.
|
||||
var createOk = await PostCapabilitiesAsync(headers);
|
||||
if (!createOk)
|
||||
{
|
||||
@@ -85,6 +98,12 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
|
||||
_logger.LogInformation("Session created for {DeviceId}", deviceId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogDebug("Skipping synthetic Jellyfin session bootstrap for proxied websocket device {DeviceId}",
|
||||
deviceId);
|
||||
}
|
||||
|
||||
// Track this session
|
||||
var clientIp = headers["X-Forwarded-For"].FirstOrDefault()?.Split(',')[0].Trim()
|
||||
@@ -99,11 +118,16 @@ public class JellyfinSessionManager : IDisposable
|
||||
Version = version,
|
||||
LastActivity = DateTime.UtcNow,
|
||||
Headers = CloneHeaders(headers),
|
||||
ClientIp = clientIp
|
||||
ClientIp = clientIp,
|
||||
HasProxiedWebSocket = hasProxiedWebSocket
|
||||
};
|
||||
|
||||
// Start a WebSocket connection to Jellyfin on behalf of this client
|
||||
// Start a synthetic WebSocket connection only when the client itself does not
|
||||
// already have a proxied Jellyfin socket through Allstarr.
|
||||
if (!hasProxiedWebSocket)
|
||||
{
|
||||
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
@@ -118,27 +142,65 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
}
|
||||
|
||||
public async Task RegisterProxiedWebSocketAsync(string deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_proxiedWebSocketConnections[deviceId] = 0;
|
||||
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
session.HasProxiedWebSocket = true;
|
||||
session.LastActivity = DateTime.UtcNow;
|
||||
await CloseSyntheticWebSocketAsync(deviceId, session);
|
||||
}
|
||||
}
|
||||
|
||||
public void UnregisterProxiedWebSocket(string deviceId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
|
||||
|
||||
if (_sessions.TryGetValue(deviceId, out var session))
|
||||
{
|
||||
session.HasProxiedWebSocket = false;
|
||||
session.LastActivity = DateTime.UtcNow;
|
||||
}
|
||||
}
|
||||
|
||||
private bool HasProxiedWebSocket(string deviceId)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(deviceId) && _proxiedWebSocketConnections.ContainsKey(deviceId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Posts session capabilities to Jellyfin.
|
||||
/// Returns true if successful, false if token expired (401).
|
||||
/// </summary>
|
||||
private async Task<bool> PostCapabilitiesAsync(IHeaderDictionary headers)
|
||||
{
|
||||
var capabilities = new
|
||||
{
|
||||
PlayableMediaTypes = new[] { "Audio" },
|
||||
SupportedCommands = new[]
|
||||
var capabilities = new JellyfinSessionCapabilitiesPayload
|
||||
{
|
||||
PlayableMediaTypes = ["Audio"],
|
||||
SupportedCommands =
|
||||
[
|
||||
"Play",
|
||||
"Playstate",
|
||||
"PlayNext"
|
||||
},
|
||||
],
|
||||
SupportsMediaControl = true,
|
||||
SupportsPersistentIdentifier = true,
|
||||
SupportsSync = false
|
||||
};
|
||||
|
||||
var json = JsonSerializer.Serialize(capabilities);
|
||||
var json = AllstarrJsonSerializer.Serialize(capabilities);
|
||||
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
|
||||
|
||||
if (statusCode == 204 || statusCode == 200)
|
||||
@@ -345,8 +407,10 @@ public class JellyfinSessionManager : IDisposable
|
||||
ClientIp = s.ClientIp,
|
||||
LastActivity = s.LastActivity,
|
||||
InactiveMinutes = Math.Round((now - s.LastActivity).TotalMinutes, 1),
|
||||
HasWebSocket = s.WebSocket != null,
|
||||
WebSocketState = s.WebSocket?.State.ToString() ?? "None"
|
||||
HasWebSocket = s.HasProxiedWebSocket || s.WebSocket != null,
|
||||
HasProxiedWebSocket = s.HasProxiedWebSocket,
|
||||
HasSyntheticWebSocket = s.WebSocket != null,
|
||||
WebSocketState = s.HasProxiedWebSocket ? "Proxied" : s.WebSocket?.State.ToString() ?? "None"
|
||||
}).ToList();
|
||||
|
||||
return new
|
||||
@@ -363,6 +427,8 @@ public class JellyfinSessionManager : IDisposable
|
||||
/// </summary>
|
||||
public async Task RemoveSessionAsync(string deviceId)
|
||||
{
|
||||
_proxiedWebSocketConnections.TryRemove(deviceId, out _);
|
||||
|
||||
if (_sessions.TryRemove(deviceId, out var session))
|
||||
{
|
||||
_logger.LogDebug("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
|
||||
@@ -390,12 +456,12 @@ public class JellyfinSessionManager : IDisposable
|
||||
// Report playback stopped to Jellyfin if we have a playing item (for scrobbling)
|
||||
if (!string.IsNullOrEmpty(session.LastPlayingItemId))
|
||||
{
|
||||
var stopPayload = new
|
||||
var stopPayload = new JellyfinPlaybackStatePayload
|
||||
{
|
||||
ItemId = session.LastPlayingItemId,
|
||||
PositionTicks = session.LastPlayingPositionTicks ?? 0
|
||||
};
|
||||
var stopJson = JsonSerializer.Serialize(stopPayload);
|
||||
var stopJson = AllstarrJsonSerializer.Serialize(stopPayload);
|
||||
await _proxyService.PostJsonAsync("Sessions/Playing/Stopped", stopJson, session.Headers);
|
||||
_logger.LogInformation("🛑 SESSION: Reported playback stopped for {DeviceId} (ItemId: {ItemId}, Position: {Position})",
|
||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||
@@ -422,6 +488,12 @@ public class JellyfinSessionManager : IDisposable
|
||||
return;
|
||||
}
|
||||
|
||||
if (session.HasProxiedWebSocket || HasProxiedWebSocket(deviceId))
|
||||
{
|
||||
_logger.LogDebug("Skipping synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
|
||||
return;
|
||||
}
|
||||
|
||||
ClientWebSocket? webSocket = null;
|
||||
|
||||
try
|
||||
@@ -525,6 +597,13 @@ public class JellyfinSessionManager : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
if (HasProxiedWebSocket(deviceId))
|
||||
{
|
||||
_logger.LogDebug("Stopping synthetic Jellyfin websocket because proxied client websocket is active for {DeviceId}",
|
||||
deviceId);
|
||||
break;
|
||||
}
|
||||
|
||||
// Use a timeout so we can send keep-alive messages periodically
|
||||
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
|
||||
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
|
||||
@@ -635,6 +714,12 @@ public class JellyfinSessionManager : IDisposable
|
||||
{
|
||||
try
|
||||
{
|
||||
session.HasProxiedWebSocket = HasProxiedWebSocket(session.DeviceId);
|
||||
if (session.HasProxiedWebSocket)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Post capabilities again to keep session alive
|
||||
// If this returns false (401), the token has expired
|
||||
var success = await PostCapabilitiesAsync(session.Headers);
|
||||
@@ -695,6 +780,7 @@ public class JellyfinSessionManager : IDisposable
|
||||
public string? LastLocalPlayedSignalItemId { get; set; }
|
||||
public string? LastExplicitStopItemId { get; set; }
|
||||
public DateTime? LastExplicitStopAtUtc { get; set; }
|
||||
public bool HasProxiedWebSocket { get; set; }
|
||||
}
|
||||
|
||||
public sealed record ActivePlaybackState(
|
||||
@@ -729,4 +815,31 @@ public class JellyfinSessionManager : IDisposable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task CloseSyntheticWebSocketAsync(string deviceId, SessionInfo session)
|
||||
{
|
||||
var syntheticSocket = session.WebSocket;
|
||||
if (syntheticSocket == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
session.WebSocket = null;
|
||||
|
||||
try
|
||||
{
|
||||
if (syntheticSocket.State == WebSocketState.Open || syntheticSocket.State == WebSocketState.CloseReceived)
|
||||
{
|
||||
await syntheticSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Native client websocket active", CancellationToken.None);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogDebug(ex, "Failed to close synthetic Jellyfin websocket for proxied device {DeviceId}", deviceId);
|
||||
}
|
||||
finally
|
||||
{
|
||||
syntheticSocket.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)}";
|
||||
}
|
||||
}
|
||||
@@ -160,9 +160,15 @@ public class QobuzMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
var songsTask = songLimit > 0
|
||||
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Song>());
|
||||
var albumsTask = albumLimit > 0
|
||||
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Album>());
|
||||
var artistsTask = artistLimit > 0
|
||||
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Artist>());
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
private readonly RedisCacheService _cache;
|
||||
|
||||
// Track Spotify playlist IDs after discovery
|
||||
private readonly Dictionary<string, string> _playlistNameToSpotifyId = new();
|
||||
private readonly Dictionary<string, string> _playlistScopeToSpotifyId = new();
|
||||
|
||||
public SpotifyPlaylistFetcher(
|
||||
ILogger<SpotifyPlaylistFetcher> logger,
|
||||
@@ -55,10 +55,20 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
/// </summary>
|
||||
/// <param name="playlistName">Playlist name (e.g., "Release Radar", "Discover Weekly")</param>
|
||||
/// <returns>List of tracks in playlist order, or empty list if not found</returns>
|
||||
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(string playlistName)
|
||||
public async Task<List<SpotifyPlaylistTrack>> GetPlaylistTracksAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
_spotifyImportSettings,
|
||||
playlistName,
|
||||
userId,
|
||||
jellyfinPlaylistId);
|
||||
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
|
||||
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName, playlistScopeUserId, playlistScopeId);
|
||||
var playlistScope = CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, playlistScopeUserId, playlistScopeId);
|
||||
|
||||
// Try Redis cache first
|
||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
@@ -124,14 +134,14 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
try
|
||||
{
|
||||
// Try to use cached or configured Spotify playlist ID
|
||||
if (!_playlistNameToSpotifyId.TryGetValue(playlistName, out var spotifyId))
|
||||
if (!_playlistScopeToSpotifyId.TryGetValue(playlistScope, out var spotifyId))
|
||||
{
|
||||
// Check if we have a configured Spotify ID for this playlist
|
||||
if (playlistConfig != null && !string.IsNullOrEmpty(playlistConfig.Id))
|
||||
{
|
||||
// Use the configured Spotify playlist ID directly
|
||||
spotifyId = playlistConfig.Id;
|
||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
|
||||
_logger.LogInformation("Using configured Spotify playlist ID for '{Name}': {Id}", playlistName, spotifyId);
|
||||
}
|
||||
else
|
||||
@@ -150,7 +160,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
}
|
||||
|
||||
spotifyId = exactMatch.SpotifyId;
|
||||
_playlistNameToSpotifyId[playlistName] = spotifyId;
|
||||
_playlistScopeToSpotifyId[playlistScope] = spotifyId;
|
||||
_logger.LogInformation("Found Spotify playlist '{Name}' with ID: {Id}", playlistName, spotifyId);
|
||||
}
|
||||
}
|
||||
@@ -226,7 +236,8 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
string playlistName,
|
||||
HashSet<string> jellyfinTrackIds)
|
||||
{
|
||||
var allTracks = await GetPlaylistTracksAsync(playlistName);
|
||||
var playlistConfig = _spotifyImportSettings.GetPlaylistByName(playlistName);
|
||||
var allTracks = await GetPlaylistTracksAsync(playlistName, playlistConfig?.UserId, playlistConfig?.JellyfinId);
|
||||
|
||||
// Filter to only tracks not in Jellyfin, preserving order
|
||||
return allTracks
|
||||
@@ -237,16 +248,30 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
/// <summary>
|
||||
/// Manual trigger to refresh a specific playlist.
|
||||
/// </summary>
|
||||
public async Task RefreshPlaylistAsync(string playlistName)
|
||||
public async Task RefreshPlaylistAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
_logger.LogInformation("Manual refresh triggered for playlist '{Name}'", playlistName);
|
||||
|
||||
// Clear cache to force refresh
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(playlistName);
|
||||
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
_spotifyImportSettings,
|
||||
playlistName,
|
||||
userId,
|
||||
jellyfinPlaylistId);
|
||||
var playlistScopeUserId = SpotifyPlaylistScopeResolver.GetUserId(playlistConfig, userId);
|
||||
var playlistScopeId = SpotifyPlaylistScopeResolver.GetScopeId(playlistConfig, jellyfinPlaylistId);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.DeleteAsync(cacheKey);
|
||||
|
||||
// Re-fetch
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
await GetPlaylistTracksAsync(playlistName, playlistScopeUserId, playlistConfig?.JellyfinId ?? jellyfinPlaylistId);
|
||||
await ClearPlaylistImageCacheAsync(playlistName, userId, jellyfinPlaylistId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -258,10 +283,31 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
await RefreshPlaylistAsync(config.Name);
|
||||
await RefreshPlaylistAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearPlaylistImageCacheAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
var playlistConfig = SpotifyPlaylistScopeResolver.ResolveConfig(
|
||||
_spotifyImportSettings,
|
||||
playlistName,
|
||||
userId,
|
||||
jellyfinPlaylistId);
|
||||
if (playlistConfig == null || string.IsNullOrWhiteSpace(playlistConfig.JellyfinId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlistConfig.JellyfinId}:*");
|
||||
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
|
||||
deletedCount,
|
||||
playlistName);
|
||||
}
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
@@ -316,7 +362,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
// Check each playlist to see if it needs refreshing based on cron schedule
|
||||
var now = DateTime.UtcNow;
|
||||
var needsRefresh = new List<string>();
|
||||
var needsRefresh = new List<SpotifyPlaylistConfig>();
|
||||
|
||||
foreach (var config in _spotifyImportSettings.Playlists)
|
||||
{
|
||||
@@ -327,7 +373,10 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
var cron = CronExpression.Parse(schedule);
|
||||
|
||||
// Check if we have cached data
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(config.Name);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistKey(
|
||||
config.Name,
|
||||
config.UserId,
|
||||
config.JellyfinId ?? config.Id);
|
||||
var cached = await _cache.GetAsync<SpotifyPlaylist>(cacheKey);
|
||||
|
||||
if (cached != null)
|
||||
@@ -337,7 +386,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
if (nextRun.HasValue && now >= nextRun.Value)
|
||||
{
|
||||
needsRefresh.Add(config.Name);
|
||||
needsRefresh.Add(config);
|
||||
_logger.LogInformation("Playlist '{Name}' needs refresh - last fetched {Age:F1}h ago, next run was {NextRun}",
|
||||
config.Name, (now - cached.FetchedAt).TotalHours, nextRun.Value);
|
||||
}
|
||||
@@ -345,7 +394,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
else
|
||||
{
|
||||
// No cache, fetch it
|
||||
needsRefresh.Add(config.Name);
|
||||
needsRefresh.Add(config);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -359,24 +408,24 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
{
|
||||
_logger.LogInformation("=== CRON TRIGGER: Fetching {Count} playlists ===", needsRefresh.Count);
|
||||
|
||||
foreach (var playlistName in needsRefresh)
|
||||
foreach (var config in needsRefresh)
|
||||
{
|
||||
if (stoppingToken.IsCancellationRequested) break;
|
||||
|
||||
try
|
||||
{
|
||||
await GetPlaylistTracksAsync(playlistName);
|
||||
await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
|
||||
// Rate limiting between playlists
|
||||
if (playlistName != needsRefresh.Last())
|
||||
if (!ReferenceEquals(config, needsRefresh.Last()))
|
||||
{
|
||||
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", playlistName);
|
||||
_logger.LogWarning("Finished fetching '{Name}' - waiting 3 seconds before next playlist to avoid rate limits...", config.Name);
|
||||
await Task.Delay(TimeSpan.FromSeconds(3), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", playlistName);
|
||||
_logger.LogError(ex, "Error fetching playlist '{Name}'", config.Name);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -404,7 +453,7 @@ public class SpotifyPlaylistFetcher : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
var tracks = await GetPlaylistTracksAsync(config.Name);
|
||||
var tracks = await GetPlaylistTracksAsync(config.Name, config.UserId, config.JellyfinId);
|
||||
_logger.LogDebug(" {Name}: {Count} tracks", config.Name, tracks.Count);
|
||||
|
||||
// Log sample of track order for debugging
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using allstarr.Models.Domain;
|
||||
using allstarr.Models.Settings;
|
||||
using allstarr.Models.Spotify;
|
||||
using allstarr.Services.Admin;
|
||||
using allstarr.Services.Common;
|
||||
using allstarr.Services.Jellyfin;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
@@ -72,6 +73,24 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
return true;
|
||||
}
|
||||
|
||||
private static string? GetPlaylistScopeUserId(SpotifyPlaylistConfig? playlist) =>
|
||||
SpotifyPlaylistScopeResolver.GetUserId(playlist);
|
||||
|
||||
private static string? GetPlaylistScopeId(SpotifyPlaylistConfig? playlist) =>
|
||||
SpotifyPlaylistScopeResolver.GetScopeId(playlist);
|
||||
|
||||
private SpotifyPlaylistConfig? ResolvePlaylistConfig(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null) =>
|
||||
SpotifyPlaylistScopeResolver.ResolveConfig(_spotifySettings, playlistName, userId, jellyfinPlaylistId);
|
||||
|
||||
private static string BuildPlaylistRunKey(SpotifyPlaylistConfig playlist) =>
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(
|
||||
playlist.Name,
|
||||
GetPlaylistScopeUserId(playlist),
|
||||
GetPlaylistScopeId(playlist));
|
||||
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
_logger.LogInformation("========================================");
|
||||
@@ -121,7 +140,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
// Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
|
||||
var now = DateTime.UtcNow;
|
||||
var schedulerReference = now.AddMinutes(-1);
|
||||
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
||||
var nextRuns = new List<(SpotifyPlaylistConfig Playlist, DateTime NextRun, CronExpression Cron)>();
|
||||
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
{
|
||||
@@ -134,7 +153,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
nextRuns.Add((playlist.Name, nextRun.Value, cron));
|
||||
nextRuns.Add((playlist, nextRun.Value, cron));
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -169,7 +188,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var waitTime = nextPlaylist.NextRun - now;
|
||||
|
||||
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||
nextPlaylist.Playlist.Name, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||
|
||||
var maxWait = TimeSpan.FromHours(1);
|
||||
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
||||
@@ -190,10 +209,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
|
||||
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.Playlist.Name);
|
||||
|
||||
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||
due.PlaylistName,
|
||||
due.Playlist,
|
||||
stoppingToken,
|
||||
trigger: "cron");
|
||||
|
||||
@@ -204,7 +223,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC",
|
||||
due.PlaylistName, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||
due.Playlist.Name, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||
}
|
||||
|
||||
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
|
||||
@@ -225,29 +244,24 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
|
||||
/// Used by individual per-playlist rebuild actions.
|
||||
/// </summary>
|
||||
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||
private async Task RebuildSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
|
||||
{
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
|
||||
var playlistScopeId = GetPlaylistScopeId(playlist);
|
||||
var playlistName = playlist.Name;
|
||||
|
||||
_logger.LogInformation("Step 1/3: Clearing cache for {Playlist}", playlistName);
|
||||
|
||||
// Clear cache for this playlist (same as "Rebuild All Remote" button)
|
||||
var keysToDelete = new[]
|
||||
{
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name), // Legacy key
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name)
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistOrderedKey(playlist.Name, playlistScopeUserId, playlistScopeId),
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlist.Name, playlistScopeUserId, playlistScopeId)
|
||||
};
|
||||
|
||||
foreach (var key in keysToDelete)
|
||||
@@ -268,7 +282,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
if (playlistFetcher != null)
|
||||
{
|
||||
// Force refresh from Spotify (clears cache and re-fetches)
|
||||
await playlistFetcher.RefreshPlaylistAsync(playlist.Name);
|
||||
await playlistFetcher.RefreshPlaylistAsync(playlist.Name, playlistScopeUserId, playlist.JellyfinId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -280,13 +294,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||
playlist, playlistFetcher, metadataService, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, cancellationToken);
|
||||
playlist, metadataService, cancellationToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -295,6 +309,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
throw;
|
||||
}
|
||||
|
||||
await ClearPlaylistImageCacheAsync(playlist);
|
||||
_logger.LogInformation("✓ Rebuild complete for {Playlist}", playlistName);
|
||||
}
|
||||
|
||||
@@ -302,16 +317,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Matches tracks for a single playlist WITHOUT clearing cache or refreshing from Spotify.
|
||||
/// Used for lightweight re-matching when only local library has changed.
|
||||
/// </summary>
|
||||
private async Task MatchSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||
private async Task MatchSinglePlaylistAsync(SpotifyPlaylistConfig playlist, CancellationToken cancellationToken)
|
||||
{
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
var playlistName = playlist.Name;
|
||||
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var metadataService = scope.ServiceProvider.GetRequiredService<IMusicMetadataService>();
|
||||
@@ -329,14 +337,16 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
// Use new direct API mode with ISRC support
|
||||
await MatchPlaylistTracksWithIsrcAsync(
|
||||
playlist.Name, playlistFetcher, metadataService, cancellationToken);
|
||||
playlist, playlistFetcher, metadataService, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Fall back to legacy mode
|
||||
await MatchPlaylistTracksLegacyAsync(
|
||||
playlist.Name, metadataService, cancellationToken);
|
||||
playlist, metadataService, cancellationToken);
|
||||
}
|
||||
|
||||
await ClearPlaylistImageCacheAsync(playlist);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -345,6 +355,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ClearPlaylistImageCacheAsync(SpotifyPlaylistConfig playlist)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(playlist.JellyfinId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var deletedCount = await _cache.DeleteByPatternAsync($"image:{playlist.JellyfinId}:*");
|
||||
_logger.LogDebug("Cleared {Count} cached local image entries for playlist {Playlist}",
|
||||
deletedCount,
|
||||
playlist.Name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
|
||||
/// This clears caches, fetches fresh data, and re-matches everything immediately.
|
||||
@@ -359,17 +382,28 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
|
||||
/// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
|
||||
/// </summary>
|
||||
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
|
||||
public async Task TriggerRebuildForPlaylistAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName);
|
||||
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||
playlistName,
|
||||
playlist,
|
||||
CancellationToken.None,
|
||||
trigger: "manual");
|
||||
|
||||
if (!rebuilt)
|
||||
{
|
||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||
var runKey = BuildPlaylistRunKey(playlist);
|
||||
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||
var remaining = _minimumRunInterval - timeSinceLastRun;
|
||||
@@ -383,11 +417,12 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||
string playlistName,
|
||||
SpotifyPlaylistConfig playlist,
|
||||
CancellationToken cancellationToken,
|
||||
string trigger)
|
||||
{
|
||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||
var runKey = BuildPlaylistRunKey(playlist);
|
||||
if (_lastRunTimes.TryGetValue(runKey, out var lastRun))
|
||||
{
|
||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
@@ -395,15 +430,15 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
_logger.LogWarning(
|
||||
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
trigger,
|
||||
playlistName,
|
||||
playlist.Name,
|
||||
(int)timeSinceLastRun.TotalSeconds,
|
||||
(int)_minimumRunInterval.TotalSeconds);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
|
||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
|
||||
_lastRunTimes[runKey] = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -423,14 +458,23 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// This bypasses cron schedules and runs immediately WITHOUT clearing cache or refreshing from Spotify.
|
||||
/// Use this when only the local library has changed, not when Spotify playlist changed.
|
||||
/// </summary>
|
||||
public async Task TriggerMatchingForPlaylistAsync(string playlistName)
|
||||
public async Task TriggerMatchingForPlaylistAsync(
|
||||
string playlistName,
|
||||
string? userId = null,
|
||||
string? jellyfinPlaylistId = null)
|
||||
{
|
||||
_logger.LogInformation("Manual track matching triggered for playlist: {Playlist} (lightweight, no cache clear)", playlistName);
|
||||
var playlist = ResolvePlaylistConfig(playlistName, userId, jellyfinPlaylistId);
|
||||
if (playlist == null)
|
||||
{
|
||||
_logger.LogInformation("Playlist {Playlist} not found in configuration", playlistName);
|
||||
return;
|
||||
}
|
||||
|
||||
// Intentionally no cooldown here: this path should react immediately to
|
||||
// local library changes and manual mapping updates without waiting for
|
||||
// Spotify API cooldown windows.
|
||||
await MatchSinglePlaylistAsync(playlistName, CancellationToken.None);
|
||||
await MatchSinglePlaylistAsync(playlist, CancellationToken.None);
|
||||
}
|
||||
|
||||
private async Task RebuildAllPlaylistsAsync(CancellationToken cancellationToken)
|
||||
@@ -450,7 +494,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
await RebuildSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||
await RebuildSinglePlaylistAsync(playlist, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -478,7 +522,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
try
|
||||
{
|
||||
await MatchSinglePlaylistAsync(playlist.Name, cancellationToken);
|
||||
await MatchSinglePlaylistAsync(playlist, cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -496,15 +540,25 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Uses GREEDY ASSIGNMENT to maximize total matches.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksWithIsrcAsync(
|
||||
string playlistName,
|
||||
SpotifyPlaylistConfig playlistConfig,
|
||||
SpotifyPlaylistFetcher playlistFetcher,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(playlistName);
|
||||
var playlist = playlistConfig ?? throw new ArgumentNullException(nameof(playlistConfig));
|
||||
var playlistName = playlist.Name;
|
||||
var playlistScopeUserId = GetPlaylistScopeUserId(playlist);
|
||||
var playlistScopeId = GetPlaylistScopeId(playlist);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
|
||||
// Get playlist tracks with full metadata including ISRC and position
|
||||
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(playlistName);
|
||||
var spotifyTracks = await playlistFetcher.GetPlaylistTracksAsync(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlist.JellyfinId);
|
||||
if (spotifyTracks.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No tracks found for {Playlist}, skipping matching", playlistName);
|
||||
@@ -512,12 +566,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
// Get the Jellyfin playlist ID to check which tracks already exist
|
||||
var playlistConfig = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
HashSet<string> existingSpotifyIds = new();
|
||||
|
||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
||||
if (!string.IsNullOrEmpty(playlist.JellyfinId))
|
||||
{
|
||||
// Get existing tracks from Jellyfin playlist to avoid re-matching
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
@@ -529,8 +581,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
try
|
||||
{
|
||||
// CRITICAL: Must include UserId parameter or Jellyfin returns empty results
|
||||
var userId = jellyfinSettings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
||||
var userId = playlist.UserId ?? jellyfinSettings.UserId;
|
||||
var jellyfinPlaylistId = playlist.JellyfinId;
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
|
||||
var queryParams = new Dictionary<string, string>();
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
@@ -612,10 +665,18 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
foreach (var track in tracksToMatch)
|
||||
{
|
||||
// Check if this track has a manual mapping but isn't in the cached results
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, track.SpotifyId);
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
playlistName,
|
||||
track.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var manualMapping = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, track.SpotifyId);
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
playlistName,
|
||||
track.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
var hasManualMapping = !string.IsNullOrEmpty(manualMapping) || !string.IsNullOrEmpty(externalMappingJson);
|
||||
@@ -643,7 +704,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
// PHASE 1: Get ALL Jellyfin tracks from the playlist (already injected by plugin)
|
||||
var jellyfinTracks = new List<Song>();
|
||||
if (!string.IsNullOrEmpty(playlistConfig?.JellyfinId))
|
||||
if (!string.IsNullOrEmpty(playlist.JellyfinId))
|
||||
{
|
||||
using var scope = _serviceProvider.CreateScope();
|
||||
var proxyService = scope.ServiceProvider.GetService<JellyfinProxyService>();
|
||||
@@ -654,8 +715,9 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
try
|
||||
{
|
||||
var userId = jellyfinSettings.UserId;
|
||||
var playlistItemsUrl = $"Playlists/{playlistConfig.JellyfinId}/Items";
|
||||
var userId = playlist.UserId ?? jellyfinSettings.UserId;
|
||||
var jellyfinPlaylistId = playlist.JellyfinId;
|
||||
var playlistItemsUrl = $"Playlists/{jellyfinPlaylistId}/Items";
|
||||
var queryParams = new Dictionary<string, string> { ["Fields"] = CachedPlaylistItemFields };
|
||||
if (!string.IsNullOrEmpty(userId))
|
||||
{
|
||||
@@ -926,19 +988,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
["missing"] = statsMissingCount
|
||||
};
|
||||
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(playlistName);
|
||||
var statsCacheKey = CacheKeyBuilder.BuildSpotifyPlaylistStatsKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.SetAsync(statsCacheKey, stats, TimeSpan.FromMinutes(30));
|
||||
|
||||
_logger.LogInformation("📊 Updated stats cache for {Playlist}: {Local} local, {External} external, {Missing} missing",
|
||||
playlistName, statsLocalCount, statsExternalCount, statsMissingCount);
|
||||
|
||||
// Calculate cache expiration: until next cron run (not just cache duration from settings)
|
||||
var playlist = _spotifySettings.Playlists
|
||||
.FirstOrDefault(p => p.Name.Equals(playlistName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
var cacheExpiration = TimeSpan.FromHours(24); // Default 24 hours
|
||||
|
||||
if (playlist != null && !string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||
if (!string.IsNullOrEmpty(playlist.SyncSchedule))
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -965,10 +1027,13 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
await _cache.SetAsync(matchedTracksKey, matchedTracks, cacheExpiration);
|
||||
|
||||
// Save matched tracks to file for persistence across restarts
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks);
|
||||
await SaveMatchedTracksToFileAsync(playlistName, matchedTracks, playlistScopeUserId, playlistScopeId);
|
||||
|
||||
// Also update legacy cache for backward compatibility
|
||||
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
|
||||
var legacyKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var legacySongs = matchedTracks.OrderBy(t => t.Position).Select(t => t.MatchedSong).ToList();
|
||||
await _cache.SetAsync(legacyKey, legacySongs, cacheExpiration);
|
||||
|
||||
@@ -978,7 +1043,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
// Pre-build playlist items cache for instant serving
|
||||
// This is what makes the UI show all matched tracks at once
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlistConfig?.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||
await PreBuildPlaylistItemsCacheAsync(playlistName, playlist.JellyfinId, spotifyTracks, matchedTracks, cacheExpiration, cancellationToken);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -1249,12 +1314,21 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// Legacy matching mode using MissingTrack from Jellyfin plugin.
|
||||
/// </summary>
|
||||
private async Task MatchPlaylistTracksLegacyAsync(
|
||||
string playlistName,
|
||||
SpotifyPlaylistConfig playlistConfig,
|
||||
IMusicMetadataService metadataService,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(playlistName);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(playlistName);
|
||||
var playlistName = playlistConfig.Name;
|
||||
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig);
|
||||
var missingTracksKey = CacheKeyBuilder.BuildSpotifyMissingTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var matchedTracksKey = CacheKeyBuilder.BuildSpotifyLegacyMatchedTracksKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
|
||||
// Check if we already have matched tracks cached
|
||||
var existingMatched = await _cache.GetAsync<List<Song>>(matchedTracksKey);
|
||||
@@ -1361,6 +1435,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
try
|
||||
{
|
||||
var playlistConfig = _spotifySettings.GetPlaylistByName(playlistName, jellyfinPlaylistId: jellyfinPlaylistId);
|
||||
var playlistScopeUserId = GetPlaylistScopeUserId(playlistConfig);
|
||||
var playlistScopeId = GetPlaylistScopeId(playlistConfig) ?? jellyfinPlaylistId;
|
||||
|
||||
_logger.LogDebug("🔨 Pre-building playlist items cache for {Playlist}...", playlistName);
|
||||
|
||||
if (string.IsNullOrEmpty(jellyfinPlaylistId))
|
||||
@@ -1381,7 +1459,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
return;
|
||||
}
|
||||
|
||||
var userId = jellyfinSettings.UserId;
|
||||
var userId = playlistConfig?.UserId ?? jellyfinSettings.UserId;
|
||||
if (string.IsNullOrEmpty(userId))
|
||||
{
|
||||
_logger.LogError("No UserId configured, cannot pre-build playlist cache for {Playlist}", playlistName);
|
||||
@@ -1457,7 +1535,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
string? matchedKey = null;
|
||||
|
||||
// FIRST: Check for manual Jellyfin mapping
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(playlistName, spotifyTrack.SpotifyId);
|
||||
var manualMappingKey = CacheKeyBuilder.BuildSpotifyManualMappingKey(
|
||||
playlistName,
|
||||
spotifyTrack.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var manualJellyfinId = await _cache.GetAsync<string>(manualMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(manualJellyfinId))
|
||||
@@ -1537,7 +1619,11 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
// SECOND: Check for external manual mapping
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(playlistName, spotifyTrack.SpotifyId);
|
||||
var externalMappingKey = CacheKeyBuilder.BuildSpotifyExternalMappingKey(
|
||||
playlistName,
|
||||
spotifyTrack.SpotifyId,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
var externalMappingJson = await _cache.GetStringAsync(externalMappingKey);
|
||||
|
||||
if (!string.IsNullOrEmpty(externalMappingJson))
|
||||
@@ -1825,11 +1911,14 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
}
|
||||
|
||||
// Save to Redis cache with same expiration as matched tracks (until next cron run)
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(playlistName);
|
||||
var cacheKey = CacheKeyBuilder.BuildSpotifyPlaylistItemsKey(
|
||||
playlistName,
|
||||
playlistScopeUserId,
|
||||
playlistScopeId);
|
||||
await _cache.SetAsync(cacheKey, finalItems, cacheExpiration);
|
||||
|
||||
// Save to file cache for persistence
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems);
|
||||
await SavePlaylistItemsToFileAsync(playlistName, finalItems, playlistScopeUserId, playlistScopeId);
|
||||
|
||||
var manualMappingInfo = "";
|
||||
if (manualExternalCount > 0)
|
||||
@@ -1854,14 +1943,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// <summary>
|
||||
/// Saves playlist items to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SavePlaylistItemsToFileAsync(string playlistName, List<Dictionary<string, object?>> items)
|
||||
private async Task SavePlaylistItemsToFileAsync(
|
||||
string playlistName,
|
||||
List<Dictionary<string, object?>> items,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var safeName = AdminHelperService.SanitizeFileName(
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_items.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(items, new JsonSerializerOptions { WriteIndented = true });
|
||||
@@ -1878,14 +1972,19 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
/// <summary>
|
||||
/// Saves matched tracks to file cache for persistence across restarts.
|
||||
/// </summary>
|
||||
private async Task SaveMatchedTracksToFileAsync(string playlistName, List<MatchedTrack> matchedTracks)
|
||||
private async Task SaveMatchedTracksToFileAsync(
|
||||
string playlistName,
|
||||
List<MatchedTrack> matchedTracks,
|
||||
string? userId = null,
|
||||
string? scopeId = null)
|
||||
{
|
||||
try
|
||||
{
|
||||
var cacheDir = "/app/cache/spotify";
|
||||
Directory.CreateDirectory(cacheDir);
|
||||
|
||||
var safeName = string.Join("_", playlistName.Split(Path.GetInvalidFileNameChars()));
|
||||
var safeName = AdminHelperService.SanitizeFileName(
|
||||
CacheKeyBuilder.BuildSpotifyPlaylistScope(playlistName, userId, scopeId));
|
||||
var filePath = Path.Combine(cacheDir, $"{safeName}_matched.json");
|
||||
|
||||
var json = JsonSerializer.Serialize(matchedTracks, new JsonSerializerOptions { WriteIndented = true });
|
||||
|
||||
@@ -498,10 +498,15 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
public async Task<SearchResult> SearchAllAsync(string query, int songLimit = 20, int albumLimit = 20, int artistLimit = 20, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Execute searches in parallel
|
||||
var songsTask = SearchSongsAsync(query, songLimit, cancellationToken);
|
||||
var albumsTask = SearchAlbumsAsync(query, albumLimit, cancellationToken);
|
||||
var artistsTask = SearchArtistsAsync(query, artistLimit, cancellationToken);
|
||||
var songsTask = songLimit > 0
|
||||
? SearchSongsAsync(query, songLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Song>());
|
||||
var albumsTask = albumLimit > 0
|
||||
? SearchAlbumsAsync(query, albumLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Album>());
|
||||
var artistsTask = artistLimit > 0
|
||||
? SearchArtistsAsync(query, artistLimit, cancellationToken)
|
||||
: Task.FromResult(new List<Artist>());
|
||||
|
||||
await Task.WhenAll(songsTask, albumsTask, artistsTask);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<!-- Restart Required Banner -->
|
||||
<div class="restart-banner" id="restart-banner">
|
||||
⚠️ Configuration changed. Restart required to apply changes.
|
||||
<button onclick="restartContainer()">Restart Now</button>
|
||||
<button onclick="restartContainer()">Restart Allstarr</button>
|
||||
<button onclick="dismissRestartBanner()"
|
||||
style="background: transparent; border: 1px solid var(--bg-primary);">Dismiss</button>
|
||||
</div>
|
||||
@@ -32,6 +32,14 @@
|
||||
<div class="auth-error" id="auth-error" role="alert"></div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="support-badge">
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="container" id="main-container" style="display:none;">
|
||||
@@ -858,13 +866,53 @@
|
||||
</p>
|
||||
<div style="display: flex; gap: 12px; flex-wrap: wrap;">
|
||||
<button class="danger" onclick="clearCache()">Clear All Cache</button>
|
||||
<button class="danger" onclick="restartContainer()">Restart Container</button>
|
||||
<button class="danger" onclick="restartContainer()">Restart Allstarr</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Analytics Tab -->
|
||||
<div class="tab-content" id="tab-endpoints">
|
||||
<div class="card">
|
||||
<h2>
|
||||
SquidWTF Endpoint Health
|
||||
<div class="actions">
|
||||
<button class="primary" onclick="fetchSquidWtfEndpointHealth(true)">Test Endpoints</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p style="color: var(--text-secondary); margin-bottom: 16px;">
|
||||
Runs a real SquidWTF API search probe and a real SquidWTF streaming manifest probe against every configured mirror.
|
||||
Green means the API request worked. Blue means the streaming request worked.
|
||||
</p>
|
||||
<div class="endpoint-health-toolbar">
|
||||
<div class="endpoint-health-legend">
|
||||
<span><span class="endpoint-health-dot api up"></span> API up</span>
|
||||
<span><span class="endpoint-health-dot streaming up"></span> Streaming up</span>
|
||||
<span><span class="endpoint-health-dot down"></span> Down</span>
|
||||
<span><span class="endpoint-health-dot unknown"></span> Not tested</span>
|
||||
</div>
|
||||
<div class="endpoint-health-last-tested" id="squidwtf-endpoints-tested-at">Not tested yet</div>
|
||||
</div>
|
||||
<div style="max-height: 520px; overflow-y: auto;">
|
||||
<table class="playlist-table endpoint-health-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Host</th>
|
||||
<th style="width: 72px; text-align: center;">API</th>
|
||||
<th style="width: 96px; text-align: center;">Streaming</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="squidwtf-endpoints-table-body">
|
||||
<tr>
|
||||
<td colspan="3" class="loading">
|
||||
Click <strong>Test Endpoints</strong> to probe SquidWTF mirrors.
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>
|
||||
API Endpoint Usage
|
||||
@@ -954,6 +1002,16 @@
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p>
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<!-- Add Playlist Modal -->
|
||||
|
||||
@@ -274,7 +274,7 @@ export async function restartContainer() {
|
||||
return requestJson(
|
||||
"/api/admin/restart",
|
||||
{ method: "POST" },
|
||||
"Failed to restart container",
|
||||
"Failed to restart Allstarr",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -414,6 +414,14 @@ export async function getSquidWTFBaseUrl() {
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSquidWtfEndpointHealth() {
|
||||
return requestJson(
|
||||
"/api/admin/squidwtf/endpoints/test",
|
||||
{ method: "POST" },
|
||||
"Failed to test SquidWTF endpoints",
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchScrobblingStatus() {
|
||||
return requestJson(
|
||||
"/api/admin/scrobbling/status",
|
||||
|
||||
@@ -300,6 +300,33 @@ async function clearEndpointUsage() {
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSquidWtfEndpointHealth(showFeedback = false) {
|
||||
const tbody = document.getElementById("squidwtf-endpoints-table-body");
|
||||
if (tbody) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" class="loading"><span class="spinner"></span> Testing SquidWTF endpoints...</td></tr>';
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await API.fetchSquidWtfEndpointHealth();
|
||||
UI.updateSquidWtfEndpointHealthUI(data);
|
||||
|
||||
if (showFeedback) {
|
||||
showToast("SquidWTF endpoint test completed", "success");
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to test SquidWTF endpoints:", error);
|
||||
if (tbody) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" style="text-align:center;color:var(--error);padding:40px;">Failed to test SquidWTF endpoints</td></tr>';
|
||||
}
|
||||
|
||||
if (showFeedback) {
|
||||
showToast("Failed to test SquidWTF endpoints", "error");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function startPlaylistAutoRefresh() {
|
||||
if (playlistAutoRefreshInterval) {
|
||||
clearInterval(playlistAutoRefreshInterval);
|
||||
@@ -370,6 +397,11 @@ async function loadDashboardData() {
|
||||
fetchEndpointUsage(),
|
||||
]);
|
||||
|
||||
const endpointsTab = document.getElementById("tab-endpoints");
|
||||
if (endpointsTab && endpointsTab.classList.contains("active")) {
|
||||
await fetchSquidWtfEndpointHealth(false);
|
||||
}
|
||||
|
||||
// Ensure user filter defaults are populated before loading Link Playlists rows.
|
||||
await fetchJellyfinUsers();
|
||||
await fetchJellyfinPlaylists();
|
||||
@@ -529,6 +561,7 @@ export function initDashboardData(options) {
|
||||
window.fetchJellyfinUsers = fetchJellyfinUsers;
|
||||
window.fetchEndpointUsage = fetchEndpointUsage;
|
||||
window.clearEndpointUsage = clearEndpointUsage;
|
||||
window.fetchSquidWtfEndpointHealth = fetchSquidWtfEndpointHealth;
|
||||
|
||||
return {
|
||||
stopDashboardRefresh,
|
||||
@@ -540,5 +573,6 @@ export function initDashboardData(options) {
|
||||
fetchJellyfinPlaylists,
|
||||
fetchConfig,
|
||||
fetchStatus,
|
||||
fetchSquidWtfEndpointHealth,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -179,6 +179,16 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
});
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -270,7 +270,7 @@ async function importEnv(event) {
|
||||
|
||||
const result = await runAction({
|
||||
confirmMessage:
|
||||
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart the container for changes to take effect.",
|
||||
"Import this .env file? This will replace your current configuration.\n\nA backup will be created automatically.\n\nYou will need to restart Allstarr for changes to take effect.",
|
||||
task: () => API.importEnv(file),
|
||||
success: (data) => data.message,
|
||||
error: (err) => err.message || "Failed to import .env file",
|
||||
@@ -283,7 +283,7 @@ async function importEnv(event) {
|
||||
async function restartContainer() {
|
||||
if (
|
||||
!confirm(
|
||||
"Restart the container to apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
|
||||
"Restart Allstarr to reload /app/.env and apply configuration changes?\n\nThe dashboard will be temporarily unavailable.",
|
||||
)
|
||||
) {
|
||||
return;
|
||||
@@ -291,7 +291,7 @@ async function restartContainer() {
|
||||
|
||||
const result = await runAction({
|
||||
task: () => API.restartContainer(),
|
||||
error: "Failed to restart container",
|
||||
error: "Failed to restart Allstarr",
|
||||
});
|
||||
|
||||
if (!result) {
|
||||
@@ -301,7 +301,7 @@ async function restartContainer() {
|
||||
document.getElementById("restart-overlay")?.classList.add("active");
|
||||
const statusEl = document.getElementById("restart-status");
|
||||
if (statusEl) {
|
||||
statusEl.textContent = "Stopping container...";
|
||||
statusEl.textContent = "Restarting Allstarr...";
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
@@ -783,6 +783,110 @@ export function updateEndpointUsageUI(data) {
|
||||
.join("");
|
||||
}
|
||||
|
||||
export function updateSquidWtfEndpointHealthUI(data) {
|
||||
const tbody = document.getElementById("squidwtf-endpoints-table-body");
|
||||
const testedAt = document.getElementById("squidwtf-endpoints-tested-at");
|
||||
const endpoints = data.Endpoints || data.endpoints || [];
|
||||
const testedAtValue = data.TestedAtUtc || data.testedAtUtc;
|
||||
|
||||
if (testedAt) {
|
||||
testedAt.textContent = testedAtValue
|
||||
? `Last tested ${new Date(testedAtValue).toLocaleString()}`
|
||||
: "Not tested yet";
|
||||
}
|
||||
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (endpoints.length === 0) {
|
||||
tbody.innerHTML =
|
||||
'<tr><td colspan="3" style="text-align:center;color:var(--text-secondary);padding:40px;">No SquidWTF endpoints configured.</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = endpoints
|
||||
.map((row) => {
|
||||
const apiResult = normalizeProbeResult(row.Api || row.api);
|
||||
const streamingResult = normalizeProbeResult(row.Streaming || row.streaming);
|
||||
const host = row.Host || row.host || "-";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td>
|
||||
<strong>${escapeHtml(host)}</strong>
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
${renderProbeDot(apiResult, "api")}
|
||||
</td>
|
||||
<td style="text-align:center;">
|
||||
${renderProbeDot(streamingResult, "streaming")}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
function normalizeProbeResult(result) {
|
||||
if (!result) {
|
||||
return {
|
||||
configured: false,
|
||||
isUp: false,
|
||||
state: "unknown",
|
||||
statusCode: null,
|
||||
latencyMs: null,
|
||||
requestUrl: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
configured: result.Configured ?? result.configured ?? false,
|
||||
isUp: result.IsUp ?? result.isUp ?? false,
|
||||
state: result.State ?? result.state ?? "unknown",
|
||||
statusCode: result.StatusCode ?? result.statusCode ?? null,
|
||||
latencyMs: result.LatencyMs ?? result.latencyMs ?? null,
|
||||
requestUrl: result.RequestUrl ?? result.requestUrl ?? null,
|
||||
error: result.Error ?? result.error ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function renderProbeDot(result, type) {
|
||||
const state = result.state || "unknown";
|
||||
const isUp = result.isUp === true;
|
||||
const variant = isUp
|
||||
? type
|
||||
: state === "missing"
|
||||
? "unknown"
|
||||
: "down";
|
||||
const titleParts = [];
|
||||
|
||||
if (type === "api") {
|
||||
titleParts.push(isUp ? "API up" : "API down");
|
||||
} else {
|
||||
titleParts.push(isUp ? "Streaming up" : "Streaming down");
|
||||
}
|
||||
|
||||
if (result.statusCode != null) {
|
||||
titleParts.push(`HTTP ${result.statusCode}`);
|
||||
}
|
||||
|
||||
if (result.latencyMs != null) {
|
||||
titleParts.push(`${result.latencyMs}ms`);
|
||||
}
|
||||
|
||||
if (result.error) {
|
||||
titleParts.push(result.error);
|
||||
}
|
||||
|
||||
if (result.requestUrl) {
|
||||
titleParts.push(result.requestUrl);
|
||||
}
|
||||
|
||||
return `<span class="endpoint-health-dot ${variant}" title="${escapeHtml(titleParts.join(" • "))}"></span>`;
|
||||
}
|
||||
|
||||
export function showErrorState(message) {
|
||||
const statusBadge = document.getElementById("spotify-status");
|
||||
if (statusBadge) {
|
||||
|
||||
@@ -41,6 +41,26 @@
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
max-width: 1400px;
|
||||
margin: 0 auto 24px;
|
||||
padding: 20px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.support-footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.support-footer a:hover {
|
||||
color: var(--accent-hover);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -646,5 +666,15 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer class="support-footer">
|
||||
<p>
|
||||
If Allstarr has helped you, or maybe even replaced a monthly streaming service subscription, consider
|
||||
supporting its development via
|
||||
<a href="https://github.com/sponsors/SoPat712" target="_blank" rel="noopener noreferrer">GitHub Sponsor</a>
|
||||
or
|
||||
<a href="https://ko-fi.com/joshpatra" target="_blank" rel="noopener noreferrer">Ko-Fi</a>.
|
||||
</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -69,12 +69,127 @@ body {
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.support-badge {
|
||||
position: fixed;
|
||||
right: 20px;
|
||||
bottom: 20px;
|
||||
width: min(360px, calc(100vw - 32px));
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: rgba(22, 27, 34, 0.94);
|
||||
box-shadow: 0 12px 30px rgba(0, 0, 0, 0.28);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.82rem;
|
||||
line-height: 1.45;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.support-badge a,
|
||||
.support-footer a {
|
||||
color: var(--accent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.support-badge a:hover,
|
||||
.support-footer a:hover {
|
||||
color: var(--accent-hover);
|
||||
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 {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
margin-top: 8px;
|
||||
padding: 20px 0 8px;
|
||||
border-top: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.92rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
@@ -859,6 +974,21 @@ input::placeholder {
|
||||
border-bottom-color: var(--accent);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.support-badge {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
width: min(340px, calc(100vw - 24px));
|
||||
padding: 10px 12px;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.support-footer {
|
||||
padding-top: 16px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user