mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
ecdd514579
|
|||
|
48b40f89c0
|
+12
-3
@@ -40,8 +40,8 @@ REDIS_DATA_PATH=./redis-data
|
|||||||
# All values are configurable via Web UI (Configuration tab > Cache Settings)
|
# All values are configurable via Web UI (Configuration tab > Cache Settings)
|
||||||
# Changes require container restart to apply
|
# Changes require container restart to apply
|
||||||
|
|
||||||
# Search results cache duration in minutes (default: 120 = 2 hours)
|
# Search results cache duration in minutes (default: 1)
|
||||||
CACHE_SEARCH_RESULTS_MINUTES=120
|
CACHE_SEARCH_RESULTS_MINUTES=1
|
||||||
|
|
||||||
# Playlist cover images cache duration in hours (default: 168 = 1 week)
|
# Playlist cover images cache duration in hours (default: 168 = 1 week)
|
||||||
CACHE_PLAYLIST_IMAGES_HOURS=168
|
CACHE_PLAYLIST_IMAGES_HOURS=168
|
||||||
@@ -263,6 +263,11 @@ SCROBBLING_ENABLED=false
|
|||||||
# This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz)
|
# This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz)
|
||||||
SCROBBLING_LOCAL_TRACKS_ENABLED=false
|
SCROBBLING_LOCAL_TRACKS_ENABLED=false
|
||||||
|
|
||||||
|
# Emit synthetic local "played" events from progress when local scrobbling is disabled (default: false)
|
||||||
|
# Only enable this if you explicitly need UserPlayedItems-based plugin triggering.
|
||||||
|
# Keep false to avoid duplicate local scrobbles with Jellyfin plugins.
|
||||||
|
SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED=false
|
||||||
|
|
||||||
# ===== LAST.FM SCROBBLING =====
|
# ===== LAST.FM SCROBBLING =====
|
||||||
# Enable Last.fm scrobbling (default: false)
|
# Enable Last.fm scrobbling (default: false)
|
||||||
SCROBBLING_LASTFM_ENABLED=false
|
SCROBBLING_LASTFM_ENABLED=false
|
||||||
@@ -301,7 +306,11 @@ SCROBBLING_LISTENBRAINZ_USER_TOKEN=
|
|||||||
# Enable detailed request logging (default: false)
|
# Enable detailed request logging (default: false)
|
||||||
# When enabled, logs every incoming HTTP request with full details:
|
# When enabled, logs every incoming HTTP request with full details:
|
||||||
# - Method, path, query string
|
# - Method, path, query string
|
||||||
# - Headers (auth tokens are masked)
|
# - Headers
|
||||||
# - Response status and timing
|
# - Response status and timing
|
||||||
# Useful for debugging client issues and seeing what API calls are being made
|
# Useful for debugging client issues and seeing what API calls are being made
|
||||||
DEBUG_LOG_ALL_REQUESTS=false
|
DEBUG_LOG_ALL_REQUESTS=false
|
||||||
|
|
||||||
|
# Redact auth/query sensitive values in request logs (default: false).
|
||||||
|
# Set true if you want DEBUG_LOG_ALL_REQUESTS while still masking tokens.
|
||||||
|
DEBUG_REDACT_SENSITIVE_REQUEST_VALUES=false
|
||||||
|
|||||||
@@ -39,6 +39,22 @@ public class AuthHeaderHelperTests
|
|||||||
Assert.True(request.Headers.Contains("X-Emby-Authorization"));
|
Assert.True(request.Headers.Contains("X-Emby-Authorization"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ForwardAuthHeaders_ShouldForwardXEmbyToken()
|
||||||
|
{
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["X-Emby-Token"] = "abc"
|
||||||
|
};
|
||||||
|
|
||||||
|
using var request = new HttpRequestMessage();
|
||||||
|
var forwarded = AuthHeaderHelper.ForwardAuthHeaders(headers, request);
|
||||||
|
|
||||||
|
Assert.True(forwarded);
|
||||||
|
Assert.True(request.Headers.TryGetValues("X-Emby-Token", out var values));
|
||||||
|
Assert.Contains("abc", values);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void ForwardAuthHeaders_ShouldForwardStandardAuthorization()
|
public void ForwardAuthHeaders_ShouldForwardStandardAuthorization()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using System.Net;
|
||||||
|
using allstarr.Middleware;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class BotProbeBlockMiddlewareTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_ScannerPath_Returns404WithoutCallingNext()
|
||||||
|
{
|
||||||
|
var nextInvoked = false;
|
||||||
|
var middleware = new BotProbeBlockMiddleware(
|
||||||
|
_ =>
|
||||||
|
{
|
||||||
|
nextInvoked = true;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
NullLogger<BotProbeBlockMiddleware>.Instance);
|
||||||
|
|
||||||
|
var context = CreateContext("/.env");
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.False(nextInvoked);
|
||||||
|
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task InvokeAsync_NormalPath_CallsNext()
|
||||||
|
{
|
||||||
|
var nextInvoked = false;
|
||||||
|
var middleware = new BotProbeBlockMiddleware(
|
||||||
|
context =>
|
||||||
|
{
|
||||||
|
nextInvoked = true;
|
||||||
|
context.Response.StatusCode = StatusCodes.Status204NoContent;
|
||||||
|
return Task.CompletedTask;
|
||||||
|
},
|
||||||
|
NullLogger<BotProbeBlockMiddleware>.Instance);
|
||||||
|
|
||||||
|
var context = CreateContext("/System/Info/Public");
|
||||||
|
|
||||||
|
await middleware.InvokeAsync(context);
|
||||||
|
|
||||||
|
Assert.True(nextInvoked);
|
||||||
|
Assert.Equal(StatusCodes.Status204NoContent, context.Response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DefaultHttpContext CreateContext(string path)
|
||||||
|
{
|
||||||
|
var context = new DefaultHttpContext();
|
||||||
|
context.Request.Path = path;
|
||||||
|
context.Request.Method = HttpMethods.Get;
|
||||||
|
context.Connection.RemoteIpAddress = IPAddress.Parse("203.0.113.10");
|
||||||
|
context.Response.Body = new MemoryStream();
|
||||||
|
return context;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class BotProbeDetectorTests
|
||||||
|
{
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/.env")]
|
||||||
|
[InlineData("/.git/config")]
|
||||||
|
[InlineData("/wordpress")]
|
||||||
|
[InlineData("/wp")]
|
||||||
|
[InlineData("/wp-admin/install.php")]
|
||||||
|
[InlineData("/vendor/phpunit/phpunit/src/Util/PHP/eval-stdin.php")]
|
||||||
|
[InlineData("/public/vendor/laravel-filemanager/js/script.js")]
|
||||||
|
[InlineData("/_ignition/execute-solution")]
|
||||||
|
[InlineData("/debug/default/index")]
|
||||||
|
[InlineData("https://jellyfin.joshpatra.me/.git/config")]
|
||||||
|
public void IsHighConfidenceProbeUrl_ScannerPaths_ReturnsTrue(string path)
|
||||||
|
{
|
||||||
|
Assert.True(BotProbeDetector.IsHighConfidenceProbeUrl(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("/System/Info/Public")]
|
||||||
|
[InlineData("/web/index.html")]
|
||||||
|
[InlineData("/Items/123")]
|
||||||
|
[InlineData("/Users/AuthenticateByName")]
|
||||||
|
[InlineData("/new")]
|
||||||
|
[InlineData("/blog")]
|
||||||
|
public void IsHighConfidenceProbeUrl_NormalProxyPaths_ReturnsFalse(string path)
|
||||||
|
{
|
||||||
|
Assert.False(BotProbeDetector.IsHighConfidenceProbeUrl(path));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,10 +20,42 @@ public class CacheKeyBuilderTests
|
|||||||
"1635cd7d23144ba08251ebe22a56119e");
|
"1635cd7d23144ba08251ebe22a56119e");
|
||||||
|
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
"search:data:musicalbum:500:0:efa26829c37196b030fa31d127e0715b:datecreated,sortname:descending:true:1635cd7d23144ba08251ebe22a56119e",
|
"search:data:musicalbum:500:0:efa26829c37196b030fa31d127e0715b:datecreated,sortname:descending:true:1635cd7d23144ba08251ebe22a56119e:",
|
||||||
key);
|
key);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void SearchKey_ShouldDifferentiateFavoriteOnlyQueries()
|
||||||
|
{
|
||||||
|
var normalKey = CacheKeyBuilder.BuildSearchKey(
|
||||||
|
"Sunflower",
|
||||||
|
"Audio",
|
||||||
|
100,
|
||||||
|
0,
|
||||||
|
"parent",
|
||||||
|
"SortName",
|
||||||
|
"Ascending",
|
||||||
|
true,
|
||||||
|
"user-1",
|
||||||
|
"false");
|
||||||
|
|
||||||
|
var favoritesOnlyKey = CacheKeyBuilder.BuildSearchKey(
|
||||||
|
"Sunflower",
|
||||||
|
"Audio",
|
||||||
|
100,
|
||||||
|
0,
|
||||||
|
"parent",
|
||||||
|
"SortName",
|
||||||
|
"Ascending",
|
||||||
|
true,
|
||||||
|
"user-1",
|
||||||
|
"true");
|
||||||
|
|
||||||
|
Assert.NotEqual(normalKey, favoritesOnlyKey);
|
||||||
|
Assert.EndsWith(":false", normalKey);
|
||||||
|
Assert.EndsWith(":true", favoritesOnlyKey);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void SearchKey_OldOverload_ShouldRemainCompatible()
|
public void SearchKey_OldOverload_ShouldRemainCompatible()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -117,6 +117,35 @@ public class JellyfinProxyServiceTests
|
|||||||
Assert.False(captured.Headers.Contains("X-Emby-Authorization"));
|
Assert.False(captured.Headers.Contains("X-Emby-Authorization"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetJsonAsync_WithXEmbyToken_ForwardsTokenHeader()
|
||||||
|
{
|
||||||
|
// 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("{}")
|
||||||
|
});
|
||||||
|
|
||||||
|
var headers = new HeaderDictionary
|
||||||
|
{
|
||||||
|
["X-Emby-Token"] = "token-123"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _service.GetJsonAsync("Items", null, headers);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
Assert.True(captured!.Headers.TryGetValues("X-Emby-Token", out var values));
|
||||||
|
Assert.Contains("token-123", values);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetBytesAsync_ReturnsBodyAndContentType()
|
public async Task GetBytesAsync_ReturnsBodyAndContentType()
|
||||||
{
|
{
|
||||||
@@ -338,6 +367,30 @@ public class JellyfinProxyServiceTests
|
|||||||
Assert.Contains("maxHeight=300", url);
|
Assert.Contains("maxHeight=300", url);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetImageAsync_WithTag_IncludesTagInQuery()
|
||||||
|
{
|
||||||
|
// 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 ByteArrayContent(new byte[] { 1, 2, 3 })
|
||||||
|
});
|
||||||
|
|
||||||
|
// Act
|
||||||
|
await _service.GetImageAsync("item-123", "Primary", imageTag: "playlist-art-v2");
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
Assert.NotNull(captured);
|
||||||
|
var query = System.Web.HttpUtility.ParseQueryString(captured!.RequestUri!.Query);
|
||||||
|
Assert.Equal("playlist-art-v2", query.Get("tag"));
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -75,6 +75,44 @@ public class JellyfinResponseBuilderTests
|
|||||||
Assert.Equal("USRC12345678", providerIds["ISRC"]);
|
Assert.Equal("USRC12345678", providerIds["ISRC"]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConvertSongToJellyfinItem_ExternalExplicitSong_AppendsStreamingAndExplicitLabels()
|
||||||
|
{
|
||||||
|
var song = new Song
|
||||||
|
{
|
||||||
|
Id = "ext-squidwtf-song-12345",
|
||||||
|
Title = "Sunflower",
|
||||||
|
Artist = "Artist",
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = "squidwtf",
|
||||||
|
ExternalId = "12345",
|
||||||
|
ExplicitContentLyrics = 1
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = _builder.ConvertSongToJellyfinItem(song);
|
||||||
|
|
||||||
|
Assert.Equal("Sunflower [S] [E]", result["Name"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ConvertSongToJellyfinItem_ExternalCleanSong_AppendsOnlyStreamingLabel()
|
||||||
|
{
|
||||||
|
var song = new Song
|
||||||
|
{
|
||||||
|
Id = "ext-squidwtf-song-12345",
|
||||||
|
Title = "Sunflower",
|
||||||
|
Artist = "Artist",
|
||||||
|
IsLocal = false,
|
||||||
|
ExternalProvider = "squidwtf",
|
||||||
|
ExternalId = "12345",
|
||||||
|
ExplicitContentLyrics = 0
|
||||||
|
};
|
||||||
|
|
||||||
|
var result = _builder.ConvertSongToJellyfinItem(song);
|
||||||
|
|
||||||
|
Assert.Equal("Sunflower [S]", result["Name"]);
|
||||||
|
}
|
||||||
|
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("deezer")]
|
[InlineData("deezer")]
|
||||||
[InlineData("qobuz")]
|
[InlineData("qobuz")]
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
|
using System.Net;
|
||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Services.Jellyfin;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class JellyfinSessionManagerTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task MarkSessionPotentiallyEnded_DoesNotAutoRemoveSession()
|
||||||
|
{
|
||||||
|
var handler = new DelegateHttpMessageHandler((_, _) =>
|
||||||
|
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=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
|
||||||
|
};
|
||||||
|
|
||||||
|
var ensured = await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", headers);
|
||||||
|
Assert.True(ensured);
|
||||||
|
|
||||||
|
manager.MarkSessionPotentiallyEnded("dev-123", TimeSpan.FromMilliseconds(25));
|
||||||
|
await Task.Delay(100);
|
||||||
|
|
||||||
|
Assert.True(manager.HasSession("dev-123"));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task RemoveSessionAsync_ReportsPlaybackStopButDoesNotLogoutUserSession()
|
||||||
|
{
|
||||||
|
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=\"Feishin\", Device=\"Desktop\", DeviceId=\"dev-123\", Version=\"1.0\", Token=\"abc\""
|
||||||
|
};
|
||||||
|
|
||||||
|
var ensured = await manager.EnsureSessionAsync("dev-123", "Feishin", "Desktop", "1.0", headers);
|
||||||
|
Assert.True(ensured);
|
||||||
|
|
||||||
|
manager.UpdatePlayingItem("dev-123", "item-123", 42);
|
||||||
|
await manager.RemoveSessionAsync("dev-123");
|
||||||
|
|
||||||
|
Assert.Contains("/Sessions/Capabilities/Full", requestedPaths);
|
||||||
|
Assert.Contains("/Sessions/Playing/Stopped", requestedPaths);
|
||||||
|
Assert.DoesNotContain("/Sessions/Logout", requestedPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static JellyfinProxyService CreateProxyService(HttpMessageHandler handler, JellyfinSettings settings)
|
||||||
|
{
|
||||||
|
var httpClientFactory = new TestHttpClientFactory(handler);
|
||||||
|
var httpContextAccessor = new HttpContextAccessor
|
||||||
|
{
|
||||||
|
HttpContext = new DefaultHttpContext()
|
||||||
|
};
|
||||||
|
|
||||||
|
var cache = new RedisCacheService(
|
||||||
|
Options.Create(new RedisSettings { Enabled = false }),
|
||||||
|
NullLogger<RedisCacheService>.Instance);
|
||||||
|
|
||||||
|
return new JellyfinProxyService(
|
||||||
|
httpClientFactory,
|
||||||
|
Options.Create(settings),
|
||||||
|
httpContextAccessor,
|
||||||
|
NullLogger<JellyfinProxyService>.Instance,
|
||||||
|
cache);
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class TestHttpClientFactory : IHttpClientFactory
|
||||||
|
{
|
||||||
|
private readonly HttpClient _client;
|
||||||
|
|
||||||
|
public TestHttpClientFactory(HttpMessageHandler handler)
|
||||||
|
{
|
||||||
|
_client = new HttpClient(handler, disposeHandler: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
public HttpClient CreateClient(string name)
|
||||||
|
{
|
||||||
|
return _client;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class DelegateHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> _handler;
|
||||||
|
|
||||||
|
public DelegateHttpMessageHandler(Func<HttpRequestMessage, CancellationToken, Task<HttpResponseMessage>> handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(
|
||||||
|
HttpRequestMessage request,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return _handler(request, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
using allstarr.Models.Scrobbling;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class PlaybackSessionTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void ShouldScrobble_ExternalTrackStartedFromBeginning_ScrobblesWhenThresholdMet()
|
||||||
|
{
|
||||||
|
var session = CreateSession(isExternal: true, startPositionSeconds: 0, durationSeconds: 300, playedSeconds: 240);
|
||||||
|
|
||||||
|
Assert.True(session.ShouldScrobble());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldScrobble_ExternalTrackResumedMidTrack_DoesNotScrobble()
|
||||||
|
{
|
||||||
|
var session = CreateSession(isExternal: true, startPositionSeconds: 90, durationSeconds: 300, playedSeconds: 240);
|
||||||
|
|
||||||
|
Assert.False(session.ShouldScrobble());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldScrobble_ExternalTrackAtToleranceBoundary_Scrobbles()
|
||||||
|
{
|
||||||
|
var session = CreateSession(isExternal: true, startPositionSeconds: 5, durationSeconds: 240, playedSeconds: 120);
|
||||||
|
|
||||||
|
Assert.True(session.ShouldScrobble());
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void ShouldScrobble_LocalTrackIgnoresStartPosition_ScrobblesWhenThresholdMet()
|
||||||
|
{
|
||||||
|
var session = CreateSession(isExternal: false, startPositionSeconds: 120, durationSeconds: 300, playedSeconds: 150);
|
||||||
|
|
||||||
|
Assert.True(session.ShouldScrobble());
|
||||||
|
}
|
||||||
|
|
||||||
|
private static PlaybackSession CreateSession(
|
||||||
|
bool isExternal,
|
||||||
|
int startPositionSeconds,
|
||||||
|
int durationSeconds,
|
||||||
|
int playedSeconds)
|
||||||
|
{
|
||||||
|
return new PlaybackSession
|
||||||
|
{
|
||||||
|
SessionId = "session-1",
|
||||||
|
DeviceId = "device-1",
|
||||||
|
StartTime = DateTime.UtcNow,
|
||||||
|
LastActivity = DateTime.UtcNow,
|
||||||
|
LastPositionSeconds = playedSeconds,
|
||||||
|
Track = new ScrobbleTrack
|
||||||
|
{
|
||||||
|
Title = "Track",
|
||||||
|
Artist = "Artist",
|
||||||
|
DurationSeconds = durationSeconds,
|
||||||
|
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||||
|
IsExternal = isExternal,
|
||||||
|
StartPositionSeconds = startPositionSeconds
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using allstarr.Models.Scrobbling;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Services.Scrobbling;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class ScrobblingOrchestratorTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task OnPlaybackStartAsync_DuplicateStartForSameTrack_SendsNowPlayingOnce()
|
||||||
|
{
|
||||||
|
var service = new Mock<IScrobblingService>();
|
||||||
|
service.SetupGet(s => s.IsEnabled).Returns(true);
|
||||||
|
service.SetupGet(s => s.ServiceName).Returns("MockService");
|
||||||
|
service.Setup(s => s.UpdateNowPlayingAsync(It.IsAny<ScrobbleTrack>(), It.IsAny<CancellationToken>()))
|
||||||
|
.ReturnsAsync(ScrobbleResult.CreateSuccess());
|
||||||
|
|
||||||
|
var orchestrator = CreateOrchestrator(service.Object);
|
||||||
|
var track = CreateTrack();
|
||||||
|
|
||||||
|
await orchestrator.OnPlaybackStartAsync("device-1", track);
|
||||||
|
await orchestrator.OnPlaybackStartAsync("device-1", track);
|
||||||
|
|
||||||
|
service.Verify(
|
||||||
|
s => s.UpdateNowPlayingAsync(It.IsAny<ScrobbleTrack>(), It.IsAny<CancellationToken>()),
|
||||||
|
Times.Once);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScrobblingOrchestrator CreateOrchestrator(IScrobblingService service)
|
||||||
|
{
|
||||||
|
var settings = Options.Create(new ScrobblingSettings
|
||||||
|
{
|
||||||
|
Enabled = true
|
||||||
|
});
|
||||||
|
|
||||||
|
var logger = Mock.Of<ILogger<ScrobblingOrchestrator>>();
|
||||||
|
return new ScrobblingOrchestrator(new[] { service }, settings, logger);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static ScrobbleTrack CreateTrack()
|
||||||
|
{
|
||||||
|
return new ScrobbleTrack
|
||||||
|
{
|
||||||
|
Title = "Sad Girl Summer",
|
||||||
|
Artist = "Maisie Peters",
|
||||||
|
DurationSeconds = 180,
|
||||||
|
IsExternal = true,
|
||||||
|
StartPositionSeconds = 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
using System.Net;
|
||||||
|
using System.Reflection;
|
||||||
|
using System.Text;
|
||||||
|
using allstarr.Models.Settings;
|
||||||
|
using allstarr.Services;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
using allstarr.Services.Local;
|
||||||
|
using allstarr.Services.SquidWTF;
|
||||||
|
using Microsoft.Extensions.Configuration;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
|
using Moq;
|
||||||
|
|
||||||
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
|
public class SquidWTFDownloadServiceTests : IDisposable
|
||||||
|
{
|
||||||
|
private readonly Mock<IHttpClientFactory> _httpClientFactoryMock = new();
|
||||||
|
private readonly Mock<ILocalLibraryService> _localLibraryServiceMock = new();
|
||||||
|
private readonly Mock<IMusicMetadataService> _metadataServiceMock = new();
|
||||||
|
private readonly Mock<IServiceProvider> _serviceProviderMock = new();
|
||||||
|
private readonly Mock<ILogger<SquidWTFDownloadService>> _loggerMock = new();
|
||||||
|
private readonly Mock<ILogger<OdesliService>> _odesliLoggerMock = new();
|
||||||
|
private readonly Mock<ILogger<RedisCacheService>> _redisLoggerMock = new();
|
||||||
|
private readonly string _testDownloadPath;
|
||||||
|
private readonly List<string> _apiUrls =
|
||||||
|
[
|
||||||
|
"http://127.0.0.1:18081",
|
||||||
|
"http://127.0.0.1:18082"
|
||||||
|
];
|
||||||
|
|
||||||
|
public SquidWTFDownloadServiceTests()
|
||||||
|
{
|
||||||
|
_testDownloadPath = Path.Combine(Path.GetTempPath(), "allstarr-squidwtf-download-tests-" + Guid.NewGuid());
|
||||||
|
Directory.CreateDirectory(_testDownloadPath);
|
||||||
|
|
||||||
|
_serviceProviderMock
|
||||||
|
.Setup(sp => sp.GetService(typeof(allstarr.Services.Subsonic.PlaylistSyncService)))
|
||||||
|
.Returns((object?)null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose()
|
||||||
|
{
|
||||||
|
if (Directory.Exists(_testDownloadPath))
|
||||||
|
{
|
||||||
|
Directory.Delete(_testDownloadPath, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void BuildQualityFallbackOrder_MapsConfiguredQualityToDescendingFallbacks()
|
||||||
|
{
|
||||||
|
var order = InvokePrivateStaticMethod<IReadOnlyList<string>>(
|
||||||
|
typeof(SquidWTFDownloadService),
|
||||||
|
"BuildQualityFallbackOrder",
|
||||||
|
"HI_RES");
|
||||||
|
|
||||||
|
Assert.Equal(["HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "LOW"], order);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrackDownloadInfoAsync_FallsBackToLowerQualityWhenPreferredQualityIsUnavailable()
|
||||||
|
{
|
||||||
|
var requests = new List<string>();
|
||||||
|
using var handler = new StubHttpMessageHandler(request =>
|
||||||
|
{
|
||||||
|
var url = request.RequestUri!.ToString();
|
||||||
|
requests.Add(url);
|
||||||
|
|
||||||
|
if (url.Contains("quality=LOSSLESS", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.Contains("quality=HIGH", StringComparison.Ordinal) &&
|
||||||
|
url.StartsWith("http://127.0.0.1:18082/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.Forbidden);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.Contains("quality=HIGH", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent(CreateTrackResponseJson("HIGH", "audio/mp4", "https://cdn.example.com/334284374.m4a"))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.NotFound);
|
||||||
|
});
|
||||||
|
|
||||||
|
var service = CreateService(handler, quality: "FLAC");
|
||||||
|
|
||||||
|
var result = await InvokePrivateAsync(service, "GetTrackDownloadInfoAsync", "334284374", CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal("http://127.0.0.1:18081", GetProperty<string>(result, "Endpoint"));
|
||||||
|
Assert.Equal("https://cdn.example.com/334284374.m4a", GetProperty<string>(result, "DownloadUrl"));
|
||||||
|
Assert.Equal("audio/mp4", GetProperty<string>(result, "MimeType"));
|
||||||
|
Assert.Equal("HIGH", GetProperty<string>(result, "AudioQuality"));
|
||||||
|
|
||||||
|
Assert.Contains(requests, url => url.Contains("quality=LOSSLESS", StringComparison.Ordinal));
|
||||||
|
Assert.Contains(requests, url => url.Contains("quality=HIGH", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
var lastLosslessRequest = requests.FindLastIndex(url => url.Contains("quality=LOSSLESS", StringComparison.Ordinal));
|
||||||
|
var firstHighRequest = requests.FindIndex(url => url.Contains("quality=HIGH", StringComparison.Ordinal));
|
||||||
|
|
||||||
|
Assert.True(lastLosslessRequest >= 0);
|
||||||
|
Assert.True(firstHighRequest > lastLosslessRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SquidWTFDownloadService CreateService(HttpMessageHandler handler, string quality)
|
||||||
|
{
|
||||||
|
var httpClient = new HttpClient(handler);
|
||||||
|
_httpClientFactoryMock.Setup(f => f.CreateClient(It.IsAny<string>())).Returns(httpClient);
|
||||||
|
|
||||||
|
var configuration = new ConfigurationBuilder()
|
||||||
|
.AddInMemoryCollection(new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["Library:DownloadPath"] = _testDownloadPath
|
||||||
|
})
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
var subsonicSettings = Options.Create(new SubsonicSettings
|
||||||
|
{
|
||||||
|
DownloadMode = DownloadMode.Track,
|
||||||
|
StorageMode = StorageMode.Cache
|
||||||
|
});
|
||||||
|
|
||||||
|
var squidwtfSettings = Options.Create(new SquidWTFSettings
|
||||||
|
{
|
||||||
|
Quality = quality
|
||||||
|
});
|
||||||
|
|
||||||
|
var cache = new RedisCacheService(
|
||||||
|
Options.Create(new RedisSettings { Enabled = false }),
|
||||||
|
_redisLoggerMock.Object);
|
||||||
|
|
||||||
|
var odesliService = new OdesliService(_httpClientFactoryMock.Object, _odesliLoggerMock.Object, cache);
|
||||||
|
|
||||||
|
return new SquidWTFDownloadService(
|
||||||
|
_httpClientFactoryMock.Object,
|
||||||
|
configuration,
|
||||||
|
_localLibraryServiceMock.Object,
|
||||||
|
_metadataServiceMock.Object,
|
||||||
|
subsonicSettings,
|
||||||
|
squidwtfSettings,
|
||||||
|
_serviceProviderMock.Object,
|
||||||
|
_loggerMock.Object,
|
||||||
|
odesliService,
|
||||||
|
_apiUrls);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CreateTrackResponseJson(string audioQuality, string mimeType, string downloadUrl)
|
||||||
|
{
|
||||||
|
var manifestJson = $$"""
|
||||||
|
{
|
||||||
|
"mimeType": "{{mimeType}}",
|
||||||
|
"codecs": "aac",
|
||||||
|
"encryptionType": "NONE",
|
||||||
|
"urls": ["{{downloadUrl}}"]
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
|
||||||
|
var manifestBase64 = Convert.ToBase64String(Encoding.UTF8.GetBytes(manifestJson));
|
||||||
|
|
||||||
|
return $$"""
|
||||||
|
{
|
||||||
|
"version": "2.4",
|
||||||
|
"data": {
|
||||||
|
"audioQuality": "{{audioQuality}}",
|
||||||
|
"manifest": "{{manifestBase64}}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""";
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<object> InvokePrivateAsync(object target, string methodName, params object?[] parameters)
|
||||||
|
{
|
||||||
|
var method = target.GetType().GetMethod(methodName, BindingFlags.Instance | BindingFlags.NonPublic);
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
var task = method!.Invoke(target, parameters) as Task;
|
||||||
|
Assert.NotNull(task);
|
||||||
|
|
||||||
|
await task!;
|
||||||
|
|
||||||
|
var resultProperty = task.GetType().GetProperty("Result");
|
||||||
|
Assert.NotNull(resultProperty);
|
||||||
|
|
||||||
|
var result = resultProperty!.GetValue(task);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
return result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T InvokePrivateStaticMethod<T>(Type targetType, string methodName, params object?[] parameters)
|
||||||
|
{
|
||||||
|
var method = targetType.GetMethod(methodName, BindingFlags.Static | BindingFlags.NonPublic);
|
||||||
|
Assert.NotNull(method);
|
||||||
|
|
||||||
|
var result = method!.Invoke(null, parameters);
|
||||||
|
Assert.NotNull(result);
|
||||||
|
return (T)result!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static T GetProperty<T>(object target, string propertyName)
|
||||||
|
{
|
||||||
|
var property = target.GetType().GetProperty(propertyName, BindingFlags.Instance | BindingFlags.Public);
|
||||||
|
Assert.NotNull(property);
|
||||||
|
|
||||||
|
var value = property!.GetValue(target);
|
||||||
|
Assert.NotNull(value);
|
||||||
|
return (T)value!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||||
|
|
||||||
|
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_handler(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,11 @@ using allstarr.Services.Common;
|
|||||||
using allstarr.Models.Domain;
|
using allstarr.Models.Domain;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using System.Collections.Generic;
|
using System.Collections.Generic;
|
||||||
|
using System.Net;
|
||||||
using System.Reflection;
|
using System.Reflection;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
|
||||||
namespace allstarr.Tests;
|
namespace allstarr.Tests;
|
||||||
|
|
||||||
@@ -343,6 +346,168 @@ public class SquidWTFMetadataServiceTests
|
|||||||
Assert.NotNull(service);
|
Assert.NotNull(service);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetTrackRecommendationsAsync_FallsBackWhenFirstEndpointReturnsEmpty()
|
||||||
|
{
|
||||||
|
var handler = new StubHttpMessageHandler(request =>
|
||||||
|
{
|
||||||
|
var port = request.RequestUri?.Port;
|
||||||
|
|
||||||
|
if (port == 5011)
|
||||||
|
{
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""
|
||||||
|
{
|
||||||
|
"version": "2.4",
|
||||||
|
"data": {
|
||||||
|
"limit": 20,
|
||||||
|
"offset": 0,
|
||||||
|
"totalNumberOfItems": 0,
|
||||||
|
"items": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port == 5012)
|
||||||
|
{
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""
|
||||||
|
{
|
||||||
|
"version": "2.4",
|
||||||
|
"data": {
|
||||||
|
"limit": 20,
|
||||||
|
"offset": 0,
|
||||||
|
"totalNumberOfItems": 1,
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"track": {
|
||||||
|
"id": 371921532,
|
||||||
|
"title": "Take It Slow",
|
||||||
|
"duration": 139,
|
||||||
|
"trackNumber": 1,
|
||||||
|
"volumeNumber": 1,
|
||||||
|
"explicit": false,
|
||||||
|
"artist": { "id": 10330497, "name": "Isaac Dunbar" },
|
||||||
|
"artists": [
|
||||||
|
{ "id": 10330497, "name": "Isaac Dunbar" }
|
||||||
|
],
|
||||||
|
"album": {
|
||||||
|
"id": 371921525,
|
||||||
|
"title": "Take It Slow",
|
||||||
|
"cover": "aeb70f15-78ef-4230-929d-2d62c70ac00c"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{
|
||||||
|
"http://127.0.0.1:5011",
|
||||||
|
"http://127.0.0.1:5012"
|
||||||
|
});
|
||||||
|
|
||||||
|
var result = await service.GetTrackRecommendationsAsync("227242909", 20);
|
||||||
|
|
||||||
|
Assert.Single(result);
|
||||||
|
Assert.Equal("371921532", result[0].ExternalId);
|
||||||
|
Assert.Equal("Take It Slow", result[0].Title);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetSongAsync_FallsBackWhenFirstEndpointReturnsErrorPayload()
|
||||||
|
{
|
||||||
|
var handler = new StubHttpMessageHandler(request =>
|
||||||
|
{
|
||||||
|
var port = request.RequestUri?.Port;
|
||||||
|
|
||||||
|
if (port == 5021)
|
||||||
|
{
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""
|
||||||
|
{
|
||||||
|
"detail": "Upstream API error"
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (port == 5022)
|
||||||
|
{
|
||||||
|
return new HttpResponseMessage(HttpStatusCode.OK)
|
||||||
|
{
|
||||||
|
Content = new StringContent("""
|
||||||
|
{
|
||||||
|
"version": "2.4",
|
||||||
|
"data": {
|
||||||
|
"id": 227242909,
|
||||||
|
"title": "Monica Lewinsky",
|
||||||
|
"duration": 132,
|
||||||
|
"trackNumber": 1,
|
||||||
|
"volumeNumber": 1,
|
||||||
|
"explicit": true,
|
||||||
|
"artist": { "id": 8420542, "name": "UPSAHL" },
|
||||||
|
"artists": [
|
||||||
|
{ "id": 8420542, "name": "UPSAHL" }
|
||||||
|
],
|
||||||
|
"album": {
|
||||||
|
"id": 227242908,
|
||||||
|
"title": "Monica Lewinsky",
|
||||||
|
"cover": "32522342-3903-42ab-aaea-a6f4f46ca0cc"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""")
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
{
|
||||||
|
"http://127.0.0.1:5021",
|
||||||
|
"http://127.0.0.1:5022"
|
||||||
|
});
|
||||||
|
|
||||||
|
var song = await service.GetSongAsync("squidwtf", "227242909");
|
||||||
|
|
||||||
|
Assert.NotNull(song);
|
||||||
|
Assert.Equal("227242909", song!.ExternalId);
|
||||||
|
Assert.Equal("Monica Lewinsky", song.Title);
|
||||||
|
Assert.Equal(1, song.ExplicitContentLyrics);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void BuildSearchQueryVariants_WithAmpersand_AddsAndVariant()
|
public void BuildSearchQueryVariants_WithAmpersand_AddsAndVariant()
|
||||||
{
|
{
|
||||||
@@ -561,4 +726,19 @@ public class SquidWTFMetadataServiceTests
|
|||||||
Assert.NotNull(result);
|
Assert.NotNull(result);
|
||||||
return (T)result!;
|
return (T)result!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private sealed class StubHttpMessageHandler : HttpMessageHandler
|
||||||
|
{
|
||||||
|
private readonly Func<HttpRequestMessage, HttpResponseMessage> _handler;
|
||||||
|
|
||||||
|
public StubHttpMessageHandler(Func<HttpRequestMessage, HttpResponseMessage> handler)
|
||||||
|
{
|
||||||
|
_handler = handler;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return Task.FromResult(_handler(request));
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Current application version.
|
/// Current application version.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public const string Version = "1.2.1";
|
public const string Version = "1.3.0";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,7 +144,11 @@ public class ConfigController : ControllerBase
|
|||||||
redisEnabled = GetEnvBool(envVars, "REDIS_ENABLED", _configuration.GetValue<bool>("Redis:Enabled", false)),
|
redisEnabled = GetEnvBool(envVars, "REDIS_ENABLED", _configuration.GetValue<bool>("Redis:Enabled", false)),
|
||||||
debug = new
|
debug = new
|
||||||
{
|
{
|
||||||
logAllRequests = GetEnvBool(envVars, "DEBUG_LOG_ALL_REQUESTS", _configuration.GetValue<bool>("Debug:LogAllRequests", false))
|
logAllRequests = GetEnvBool(envVars, "DEBUG_LOG_ALL_REQUESTS", _configuration.GetValue<bool>("Debug:LogAllRequests", false)),
|
||||||
|
redactSensitiveRequestValues = GetEnvBool(
|
||||||
|
envVars,
|
||||||
|
"DEBUG_REDACT_SENSITIVE_REQUEST_VALUES",
|
||||||
|
_configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false))
|
||||||
},
|
},
|
||||||
admin = new
|
admin = new
|
||||||
{
|
{
|
||||||
@@ -216,7 +220,7 @@ public class ConfigController : ControllerBase
|
|||||||
},
|
},
|
||||||
cache = new
|
cache = new
|
||||||
{
|
{
|
||||||
searchResultsMinutes = GetEnvInt(envVars, "CACHE_SEARCH_RESULTS_MINUTES", _configuration.GetValue<int>("Cache:SearchResultsMinutes", 120)),
|
searchResultsMinutes = GetEnvInt(envVars, "CACHE_SEARCH_RESULTS_MINUTES", _configuration.GetValue<int>("Cache:SearchResultsMinutes", 1)),
|
||||||
playlistImagesHours = GetEnvInt(envVars, "CACHE_PLAYLIST_IMAGES_HOURS", _configuration.GetValue<int>("Cache:PlaylistImagesHours", 168)),
|
playlistImagesHours = GetEnvInt(envVars, "CACHE_PLAYLIST_IMAGES_HOURS", _configuration.GetValue<int>("Cache:PlaylistImagesHours", 168)),
|
||||||
spotifyPlaylistItemsHours = GetEnvInt(envVars, "CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS", _configuration.GetValue<int>("Cache:SpotifyPlaylistItemsHours", 168)),
|
spotifyPlaylistItemsHours = GetEnvInt(envVars, "CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS", _configuration.GetValue<int>("Cache:SpotifyPlaylistItemsHours", 168)),
|
||||||
spotifyMatchedTracksDays = GetEnvInt(envVars, "CACHE_SPOTIFY_MATCHED_TRACKS_DAYS", _configuration.GetValue<int>("Cache:SpotifyMatchedTracksDays", 30)),
|
spotifyMatchedTracksDays = GetEnvInt(envVars, "CACHE_SPOTIFY_MATCHED_TRACKS_DAYS", _configuration.GetValue<int>("Cache:SpotifyMatchedTracksDays", 30)),
|
||||||
@@ -335,6 +339,8 @@ public class ConfigController : ControllerBase
|
|||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
enabled = _scrobblingSettings.Enabled,
|
enabled = _scrobblingSettings.Enabled,
|
||||||
|
localTracksEnabled = _scrobblingSettings.LocalTracksEnabled,
|
||||||
|
syntheticLocalPlayedSignalEnabled = _scrobblingSettings.SyntheticLocalPlayedSignalEnabled,
|
||||||
lastFm = new
|
lastFm = new
|
||||||
{
|
{
|
||||||
enabled = _scrobblingSettings.LastFm.Enabled,
|
enabled = _scrobblingSettings.LastFm.Enabled,
|
||||||
@@ -372,6 +378,12 @@ public class ConfigController : ControllerBase
|
|||||||
enabled = envVars.TryGetValue("SCROBBLING_ENABLED", out var scrobblingEnabled)
|
enabled = envVars.TryGetValue("SCROBBLING_ENABLED", out var scrobblingEnabled)
|
||||||
? scrobblingEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
|
? scrobblingEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
|
||||||
: _scrobblingSettings.Enabled,
|
: _scrobblingSettings.Enabled,
|
||||||
|
localTracksEnabled = envVars.TryGetValue("SCROBBLING_LOCAL_TRACKS_ENABLED", out var localTracksEnabled)
|
||||||
|
? localTracksEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
|
||||||
|
: _scrobblingSettings.LocalTracksEnabled,
|
||||||
|
syntheticLocalPlayedSignalEnabled = envVars.TryGetValue("SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED", out var syntheticPlayedSignalEnabled)
|
||||||
|
? syntheticPlayedSignalEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
|
||||||
|
: _scrobblingSettings.SyntheticLocalPlayedSignalEnabled,
|
||||||
lastFm = new
|
lastFm = new
|
||||||
{
|
{
|
||||||
enabled = envVars.TryGetValue("SCROBBLING_LASTFM_ENABLED", out var lastFmEnabled)
|
enabled = envVars.TryGetValue("SCROBBLING_LASTFM_ENABLED", out var lastFmEnabled)
|
||||||
@@ -411,6 +423,8 @@ public class ConfigController : ControllerBase
|
|||||||
return new
|
return new
|
||||||
{
|
{
|
||||||
enabled = _scrobblingSettings.Enabled,
|
enabled = _scrobblingSettings.Enabled,
|
||||||
|
localTracksEnabled = _scrobblingSettings.LocalTracksEnabled,
|
||||||
|
syntheticLocalPlayedSignalEnabled = _scrobblingSettings.SyntheticLocalPlayedSignalEnabled,
|
||||||
lastFm = new
|
lastFm = new
|
||||||
{
|
{
|
||||||
enabled = _scrobblingSettings.LastFm.Enabled,
|
enabled = _scrobblingSettings.LastFm.Enabled,
|
||||||
|
|||||||
@@ -83,14 +83,14 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId))
|
if (!string.IsNullOrEmpty(playlistId) && _spotifySettings.IsSpotifyPlaylist(playlistId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Found Spotify playlist: {Id}", playlistId);
|
_logger.LogDebug("Found Spotify playlist: {Id}", playlistId);
|
||||||
|
|
||||||
// This is a Spotify playlist - get the actual track count
|
// This is a Spotify playlist - get the actual track count
|
||||||
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
|
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
|
||||||
|
|
||||||
if (playlistConfig != null)
|
if (playlistConfig != null)
|
||||||
{
|
{
|
||||||
_logger.LogInformation(
|
_logger.LogDebug(
|
||||||
"Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
|
"Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
|
||||||
playlistId, playlistConfig.Name, playlistConfig.Id);
|
playlistId, playlistConfig.Name, playlistConfig.Id);
|
||||||
var playlistName = playlistConfig.Name;
|
var playlistName = playlistConfig.Name;
|
||||||
@@ -396,6 +396,48 @@ public partial class JellyfinController
|
|||||||
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
return includeItemTypes.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <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
|
||||||
|
/// (for example, album browse responses requested by clients like Finer).
|
||||||
|
/// </summary>
|
||||||
|
private bool ShouldProcessSpotifyPlaylistCounts(JsonDocument response, string? includeItemTypes)
|
||||||
|
{
|
||||||
|
if (!_spotifySettings.Enabled)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.RootElement.ValueKind != JsonValueKind.Object ||
|
||||||
|
!response.RootElement.TryGetProperty("Items", out var items) ||
|
||||||
|
items.ValueKind != JsonValueKind.Array)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestedTypes = ParseItemTypes(includeItemTypes);
|
||||||
|
if (requestedTypes != null && requestedTypes.Length > 0)
|
||||||
|
{
|
||||||
|
return requestedTypes.Contains("Playlist", StringComparer.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the request did not explicitly constrain types, inspect payload types.
|
||||||
|
foreach (var item in items.EnumerateArray())
|
||||||
|
{
|
||||||
|
if (!item.TryGetProperty("Type", out var typeProp))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(typeProp.GetString(), "Playlist", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Recovers SearchTerm directly from raw query string.
|
/// Recovers SearchTerm directly from raw query string.
|
||||||
/// Handles malformed clients that do not URL-encode '&' inside SearchTerm.
|
/// Handles malformed clients that do not URL-encode '&' inside SearchTerm.
|
||||||
|
|||||||
@@ -184,7 +184,19 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
|
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
|
||||||
|
{
|
||||||
|
_logger.LogError("Failed to stream external song {Provider}:{ExternalId}: {StatusCode}: {ReasonPhrase}",
|
||||||
|
provider,
|
||||||
|
externalId,
|
||||||
|
(int)httpRequestException.StatusCode.Value,
|
||||||
|
httpRequestException.StatusCode.Value);
|
||||||
|
_logger.LogDebug(ex, "Detailed streaming failure for external song {Provider}:{ExternalId}", provider, externalId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
|
||||||
|
}
|
||||||
return StatusCode(500, new { error = "Streaming failed" });
|
return StatusCode(500, new { error = "Streaming failed" });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
namespace allstarr.Controllers;
|
namespace allstarr.Controllers;
|
||||||
|
|
||||||
@@ -53,8 +54,10 @@ public partial class JellyfinController
|
|||||||
// Post session capabilities in background if we have a token
|
// Post session capabilities in background if we have a token
|
||||||
if (!string.IsNullOrEmpty(accessToken))
|
if (!string.IsNullOrEmpty(accessToken))
|
||||||
{
|
{
|
||||||
|
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
||||||
// Capture token in closure - don't use Request.Headers (will be disposed)
|
// Capture token in closure - don't use Request.Headers (will be disposed)
|
||||||
var token = accessToken;
|
var token = accessToken;
|
||||||
|
var authHeader = AuthHeaderHelper.CreateAuthHeader(token, client, device, deviceId, version);
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -64,6 +67,7 @@ public partial class JellyfinController
|
|||||||
// Build auth header with the new token
|
// Build auth header with the new token
|
||||||
var authHeaders = new HeaderDictionary
|
var authHeaders = new HeaderDictionary
|
||||||
{
|
{
|
||||||
|
["X-Emby-Authorization"] = authHeader,
|
||||||
["X-Emby-Token"] = token
|
["X-Emby-Token"] = token
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -145,12 +145,11 @@ public partial class JellyfinController
|
|||||||
return NotFound(new { error = "Song not found" });
|
return NotFound(new { error = "Song not found" });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip [S] suffix from title, artist, and album for lyrics search
|
// Strip external track labels from lyrics search terms.
|
||||||
// The [S] tag is added to external tracks but shouldn't be used in lyrics queries
|
var searchTitle = StripTrackDecorators(song.Title);
|
||||||
var searchTitle = song.Title.Replace(" [S]", "").Trim();
|
var searchArtist = StripTrackDecorators(song.Artist);
|
||||||
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
|
var searchAlbum = StripTrackDecorators(song.Album);
|
||||||
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
|
var searchArtists = song.Artists.Select(StripTrackDecorators).ToList();
|
||||||
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
|
|
||||||
|
|
||||||
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
|
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
|
||||||
{
|
{
|
||||||
@@ -379,11 +378,11 @@ public partial class JellyfinController
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip [S] suffix for lyrics search
|
// Strip external track labels for lyrics search.
|
||||||
var searchTitle = song.Title.Replace(" [S]", "").Trim();
|
var searchTitle = StripTrackDecorators(song.Title);
|
||||||
var searchArtist = song.Artist?.Replace(" [S]", "").Trim() ?? "";
|
var searchArtist = StripTrackDecorators(song.Artist);
|
||||||
var searchAlbum = song.Album?.Replace(" [S]", "").Trim() ?? "";
|
var searchAlbum = StripTrackDecorators(song.Album);
|
||||||
var searchArtists = song.Artists.Select(a => a.Replace(" [S]", "").Trim()).ToList();
|
var searchArtists = song.Artists.Select(StripTrackDecorators).ToList();
|
||||||
|
|
||||||
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
|
if (searchArtists.Count == 0 && !string.IsNullOrEmpty(searchArtist))
|
||||||
{
|
{
|
||||||
@@ -467,5 +466,18 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string StripTrackDecorators(string? value)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
return value
|
||||||
|
.Replace(" [S]", "", StringComparison.Ordinal)
|
||||||
|
.Replace(" [E]", "", StringComparison.Ordinal)
|
||||||
|
.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Collections.Concurrent;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
using allstarr.Models.Scrobbling;
|
using allstarr.Models.Scrobbling;
|
||||||
@@ -7,6 +8,11 @@ namespace allstarr.Controllers;
|
|||||||
|
|
||||||
public partial class JellyfinController
|
public partial class JellyfinController
|
||||||
{
|
{
|
||||||
|
private static readonly TimeSpan InferredStopDedupeWindow = TimeSpan.FromSeconds(15);
|
||||||
|
private static readonly TimeSpan PlaybackSignalDedupeWindow = TimeSpan.FromSeconds(8);
|
||||||
|
private static readonly TimeSpan PlaybackSignalRetentionWindow = TimeSpan.FromMinutes(5);
|
||||||
|
private static readonly ConcurrentDictionary<string, DateTime> RecentPlaybackSignals = new();
|
||||||
|
|
||||||
#region Playback Session Reporting
|
#region Playback Session Reporting
|
||||||
|
|
||||||
#region Session Management
|
#region Session Management
|
||||||
@@ -53,22 +59,28 @@ public partial class JellyfinController
|
|||||||
_logger.LogDebug("Capabilities body length: {BodyLength} bytes", body.Length);
|
_logger.LogDebug("Capabilities body length: {BodyLength} bytes", body.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
var (result, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers);
|
var (_, statusCode) = await _proxyService.PostJsonAsync(endpoint, body, Request.Headers);
|
||||||
|
|
||||||
if (statusCode == 204 || statusCode == 200)
|
if (statusCode == 204 || statusCode == 200)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
|
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||||
}
|
return NoContent();
|
||||||
else if (statusCode == 401)
|
|
||||||
{
|
|
||||||
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return NoContent();
|
if (statusCode == 401)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
||||||
|
return Unauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode == 403)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("⚠ Jellyfin returned 403 for capabilities");
|
||||||
|
return Forbid();
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
|
||||||
|
return StatusCode(statusCode);
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -104,6 +116,7 @@ public partial class JellyfinController
|
|||||||
string? itemId = null;
|
string? itemId = null;
|
||||||
string? itemName = null;
|
string? itemName = null;
|
||||||
long? positionTicks = null;
|
long? positionTicks = null;
|
||||||
|
string? playSessionId = null;
|
||||||
|
|
||||||
itemId = ParsePlaybackItemId(doc.RootElement);
|
itemId = ParsePlaybackItemId(doc.RootElement);
|
||||||
|
|
||||||
@@ -113,6 +126,7 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
|
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
|
||||||
|
playSessionId = ParsePlaybackSessionId(doc.RootElement);
|
||||||
|
|
||||||
// Track the playing item for scrobbling on session cleanup (local tracks only)
|
// Track the playing item for scrobbling on session cleanup (local tracks only)
|
||||||
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
|
||||||
@@ -169,6 +183,23 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping duplicate external playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
|
||||||
|
itemId,
|
||||||
|
deviceId ?? "unknown",
|
||||||
|
playSessionId ?? "none");
|
||||||
|
|
||||||
|
if (sessionReady)
|
||||||
|
{
|
||||||
|
_sessionManager.UpdateActivity(deviceId!);
|
||||||
|
_sessionManager.UpdatePlayingItem(deviceId!, itemId, positionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch metadata early so we can log the correct track name
|
// Fetch metadata early so we can log the correct track name
|
||||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
var trackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown";
|
var trackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown";
|
||||||
@@ -242,7 +273,8 @@ public partial class JellyfinController
|
|||||||
artist: song.Artist,
|
artist: song.Artist,
|
||||||
album: song.Album,
|
album: song.Album,
|
||||||
albumArtist: song.AlbumArtist,
|
albumArtist: song.AlbumArtist,
|
||||||
durationSeconds: song.Duration
|
durationSeconds: song.Duration,
|
||||||
|
startPositionSeconds: ToPlaybackPositionSeconds(positionTicks)
|
||||||
);
|
);
|
||||||
|
|
||||||
if (track != null)
|
if (track != null)
|
||||||
@@ -284,6 +316,24 @@ public partial class JellyfinController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(itemId) &&
|
||||||
|
ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping duplicate local playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
|
||||||
|
itemId,
|
||||||
|
deviceId ?? "unknown",
|
||||||
|
playSessionId ?? "none");
|
||||||
|
|
||||||
|
if (!string.IsNullOrEmpty(deviceId))
|
||||||
|
{
|
||||||
|
_sessionManager.UpdateActivity(deviceId);
|
||||||
|
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
// For local tracks, forward playback start to Jellyfin FIRST
|
// For local tracks, forward playback start to Jellyfin FIRST
|
||||||
_logger.LogDebug("Forwarding playback start to Jellyfin...");
|
_logger.LogDebug("Forwarding playback start to Jellyfin...");
|
||||||
|
|
||||||
@@ -439,9 +489,11 @@ public partial class JellyfinController
|
|||||||
var doc = JsonDocument.Parse(body);
|
var doc = JsonDocument.Parse(body);
|
||||||
string? itemId = null;
|
string? itemId = null;
|
||||||
long? positionTicks = null;
|
long? positionTicks = null;
|
||||||
|
string? playSessionId = null;
|
||||||
|
|
||||||
itemId = ParsePlaybackItemId(doc.RootElement);
|
itemId = ParsePlaybackItemId(doc.RootElement);
|
||||||
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
|
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
|
||||||
|
playSessionId = ParsePlaybackSessionId(doc.RootElement);
|
||||||
|
|
||||||
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
|
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
|
||||||
|
|
||||||
@@ -482,7 +534,8 @@ public partial class JellyfinController
|
|||||||
artist: song.Artist,
|
artist: song.Artist,
|
||||||
album: song.Album,
|
album: song.Album,
|
||||||
albumArtist: song.AlbumArtist,
|
albumArtist: song.AlbumArtist,
|
||||||
durationSeconds: song.Duration
|
durationSeconds: song.Duration,
|
||||||
|
startPositionSeconds: ToPlaybackPositionSeconds(positionTicks)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -541,9 +594,11 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId);
|
var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId);
|
||||||
var inferredStop = !string.IsNullOrWhiteSpace(previousItemId) &&
|
var inferredStop = sessionReady &&
|
||||||
|
!string.IsNullOrWhiteSpace(previousItemId) &&
|
||||||
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
||||||
var inferredStart = !string.IsNullOrWhiteSpace(itemId) &&
|
var inferredStart = sessionReady &&
|
||||||
|
!string.IsNullOrWhiteSpace(itemId) &&
|
||||||
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
||||||
|
|
||||||
if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId))
|
if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId))
|
||||||
@@ -557,7 +612,8 @@ public partial class JellyfinController
|
|||||||
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inferredStart)
|
if (inferredStart &&
|
||||||
|
!ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
|
||||||
{
|
{
|
||||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||||
var externalTrackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown";
|
var externalTrackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown";
|
||||||
@@ -601,7 +657,8 @@ public partial class JellyfinController
|
|||||||
artist: song.Artist,
|
artist: song.Artist,
|
||||||
album: song.Album,
|
album: song.Album,
|
||||||
albumArtist: song.AlbumArtist,
|
albumArtist: song.AlbumArtist,
|
||||||
durationSeconds: song.Duration);
|
durationSeconds: song.Duration,
|
||||||
|
startPositionSeconds: ToPlaybackPositionSeconds(positionTicks));
|
||||||
|
|
||||||
if (track != null)
|
if (track != null)
|
||||||
{
|
{
|
||||||
@@ -615,6 +672,20 @@ public partial class JellyfinController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (inferredStart)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping duplicate inferred external playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
|
||||||
|
itemId,
|
||||||
|
deviceId,
|
||||||
|
playSessionId ?? "none");
|
||||||
|
}
|
||||||
|
else if (!sessionReady)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping inferred external playback start/stop from progress for {DeviceId} because session is unavailable",
|
||||||
|
deviceId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// For external tracks, report progress with ghost UUID to Jellyfin
|
// For external tracks, report progress with ghost UUID to Jellyfin
|
||||||
@@ -677,9 +748,11 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId);
|
var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId);
|
||||||
var inferredStop = !string.IsNullOrWhiteSpace(previousItemId) &&
|
var inferredStop = sessionReady &&
|
||||||
|
!string.IsNullOrWhiteSpace(previousItemId) &&
|
||||||
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
||||||
var inferredStart = !string.IsNullOrWhiteSpace(itemId) &&
|
var inferredStart = sessionReady &&
|
||||||
|
!string.IsNullOrWhiteSpace(itemId) &&
|
||||||
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
||||||
|
|
||||||
if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId))
|
if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId))
|
||||||
@@ -693,7 +766,8 @@ public partial class JellyfinController
|
|||||||
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (inferredStart)
|
if (inferredStart &&
|
||||||
|
!ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
|
||||||
{
|
{
|
||||||
var trackName = await TryGetLocalTrackNameAsync(itemId);
|
var trackName = await TryGetLocalTrackNameAsync(itemId);
|
||||||
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
|
_logger.LogInformation("🎵 Local track playback started (inferred from progress): {Name} (ID: {ItemId})",
|
||||||
@@ -738,6 +812,20 @@ public partial class JellyfinController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else if (inferredStart)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping duplicate inferred local playback start signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
|
||||||
|
itemId,
|
||||||
|
deviceId,
|
||||||
|
playSessionId ?? "none");
|
||||||
|
}
|
||||||
|
else if (!sessionReady)
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping inferred local playback start/stop from progress for {DeviceId} because session is unavailable",
|
||||||
|
deviceId);
|
||||||
|
}
|
||||||
|
|
||||||
// When local scrobbling is disabled, still trigger Jellyfin's user-data path
|
// When local scrobbling is disabled, still trigger Jellyfin's user-data path
|
||||||
// shortly after the normal scrobble threshold so downstream plugins that listen
|
// shortly after the normal scrobble threshold so downstream plugins that listen
|
||||||
@@ -801,6 +889,25 @@ public partial class JellyfinController
|
|||||||
string previousItemId,
|
string previousItemId,
|
||||||
long? previousPositionTicks)
|
long? previousPositionTicks)
|
||||||
{
|
{
|
||||||
|
if (_sessionManager.WasRecentlyExplicitlyStopped(deviceId, previousItemId, InferredStopDedupeWindow))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping inferred stop for {ItemId} on {DeviceId} (explicit stop already recorded within {Window}s)",
|
||||||
|
previousItemId,
|
||||||
|
deviceId,
|
||||||
|
InferredStopDedupeWindow.TotalSeconds);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ShouldSuppressPlaybackSignal("stop", deviceId, previousItemId, playSessionId: null))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping duplicate inferred playback stop signal for {ItemId} on {DeviceId}",
|
||||||
|
previousItemId,
|
||||||
|
deviceId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(previousItemId);
|
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(previousItemId);
|
||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
@@ -918,7 +1025,10 @@ public partial class JellyfinController
|
|||||||
string itemId,
|
string itemId,
|
||||||
long? positionTicks)
|
long? positionTicks)
|
||||||
{
|
{
|
||||||
if (_scrobblingSettings.LocalTracksEnabled || _scrobblingHelper == null)
|
if (!_scrobblingSettings.Enabled ||
|
||||||
|
_scrobblingSettings.LocalTracksEnabled ||
|
||||||
|
!_scrobblingSettings.SyntheticLocalPlayedSignalEnabled ||
|
||||||
|
_scrobblingHelper == null)
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1009,6 +1119,22 @@ public partial class JellyfinController
|
|||||||
return _settings.UserId;
|
return _settings.UserId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static int? ToPlaybackPositionSeconds(long? positionTicks)
|
||||||
|
{
|
||||||
|
if (!positionTicks.HasValue)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var seconds = positionTicks.Value / TimeSpan.TicksPerSecond;
|
||||||
|
if (seconds <= 0)
|
||||||
|
{
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return seconds > int.MaxValue ? int.MaxValue : (int)seconds;
|
||||||
|
}
|
||||||
|
|
||||||
private string? ResolveDeviceId(string? parsedDeviceId, JsonElement? payload = null)
|
private string? ResolveDeviceId(string? parsedDeviceId, JsonElement? payload = null)
|
||||||
{
|
{
|
||||||
if (!string.IsNullOrWhiteSpace(parsedDeviceId))
|
if (!string.IsNullOrWhiteSpace(parsedDeviceId))
|
||||||
@@ -1071,6 +1197,7 @@ public partial class JellyfinController
|
|||||||
string? itemId = null;
|
string? itemId = null;
|
||||||
string? itemName = null;
|
string? itemName = null;
|
||||||
long? positionTicks = null;
|
long? positionTicks = null;
|
||||||
|
string? playSessionId = null;
|
||||||
var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers);
|
var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers);
|
||||||
|
|
||||||
itemId = ParsePlaybackItemId(doc.RootElement);
|
itemId = ParsePlaybackItemId(doc.RootElement);
|
||||||
@@ -1086,6 +1213,7 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
|
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
|
||||||
|
playSessionId = ParsePlaybackSessionId(doc.RootElement);
|
||||||
|
|
||||||
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
|
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
|
||||||
|
|
||||||
@@ -1113,6 +1241,24 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
|
if (ShouldSuppressPlaybackSignal("stop", deviceId, itemId, playSessionId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping duplicate external playback stop signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
|
||||||
|
itemId,
|
||||||
|
deviceId ?? "unknown",
|
||||||
|
playSessionId ?? "none");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
{
|
||||||
|
_sessionManager.MarkExplicitStop(deviceId, itemId);
|
||||||
|
_sessionManager.UpdatePlayingItem(deviceId, null, null);
|
||||||
|
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
var position = positionTicks.HasValue
|
var position = positionTicks.HasValue
|
||||||
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
|
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
|
||||||
: "unknown";
|
: "unknown";
|
||||||
@@ -1207,6 +1353,13 @@ public partial class JellyfinController
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ((stopStatusCode == 200 || stopStatusCode == 204) && !string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
{
|
||||||
|
_sessionManager.MarkExplicitStop(deviceId, itemId);
|
||||||
|
_sessionManager.UpdatePlayingItem(deviceId, null, null);
|
||||||
|
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,6 +1389,24 @@ public partial class JellyfinController
|
|||||||
_logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})",
|
_logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {ItemId})",
|
||||||
trackName ?? "Unknown", itemId);
|
trackName ?? "Unknown", itemId);
|
||||||
|
|
||||||
|
if (ShouldSuppressPlaybackSignal("stop", deviceId, itemId, playSessionId))
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Skipping duplicate local playback stop signal for {ItemId} on {DeviceId} (PlaySessionId: {PlaySessionId})",
|
||||||
|
itemId,
|
||||||
|
deviceId ?? "unknown",
|
||||||
|
playSessionId ?? "none");
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||||
|
{
|
||||||
|
_sessionManager.MarkExplicitStop(deviceId, itemId);
|
||||||
|
_sessionManager.UpdatePlayingItem(deviceId, null, null);
|
||||||
|
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
|
||||||
|
}
|
||||||
|
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
|
||||||
// Scrobble local track playback stop (only if enabled)
|
// Scrobble local track playback stop (only if enabled)
|
||||||
if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null &&
|
if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null &&
|
||||||
_scrobblingHelper != null && !string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId) &&
|
_scrobblingHelper != null && !string.IsNullOrEmpty(deviceId) && !string.IsNullOrEmpty(itemId) &&
|
||||||
@@ -1309,6 +1480,11 @@ public partial class JellyfinController
|
|||||||
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
|
_logger.LogDebug("✓ Playback stop forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||||
if (!string.IsNullOrWhiteSpace(deviceId))
|
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||||
{
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(itemId))
|
||||||
|
{
|
||||||
|
_sessionManager.MarkExplicitStop(deviceId, itemId);
|
||||||
|
}
|
||||||
|
_sessionManager.UpdatePlayingItem(deviceId, null, null);
|
||||||
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
|
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1488,6 +1664,78 @@ public partial class JellyfinController
|
|||||||
return ParseOptionalString(value);
|
return ParseOptionalString(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string? ParsePlaybackSessionId(JsonElement payload)
|
||||||
|
{
|
||||||
|
var direct = TryReadStringProperty(payload, "PlaySessionId");
|
||||||
|
if (!string.IsNullOrWhiteSpace(direct))
|
||||||
|
{
|
||||||
|
return direct;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.TryGetProperty("PlaySession", out var playSession))
|
||||||
|
{
|
||||||
|
var nested = TryReadStringProperty(playSession, "Id");
|
||||||
|
if (!string.IsNullOrWhiteSpace(nested))
|
||||||
|
{
|
||||||
|
return nested;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static bool ShouldSuppressPlaybackSignal(
|
||||||
|
string signalType,
|
||||||
|
string? deviceId,
|
||||||
|
string itemId,
|
||||||
|
string? playSessionId)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(itemId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var normalizedDevice = string.IsNullOrWhiteSpace(deviceId) ? "unknown-device" : deviceId;
|
||||||
|
var baseKey = $"{signalType}:{normalizedDevice}:{itemId}";
|
||||||
|
var sessionKey = string.IsNullOrWhiteSpace(playSessionId)
|
||||||
|
? null
|
||||||
|
: $"{baseKey}:{playSessionId}";
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
if (RecentPlaybackSignals.TryGetValue(baseKey, out var lastSeenAtUtc) &&
|
||||||
|
(now - lastSeenAtUtc) <= PlaybackSignalDedupeWindow)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.IsNullOrWhiteSpace(sessionKey) &&
|
||||||
|
RecentPlaybackSignals.TryGetValue(sessionKey, out var lastSeenForSessionAtUtc) &&
|
||||||
|
(now - lastSeenForSessionAtUtc) <= PlaybackSignalDedupeWindow)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
RecentPlaybackSignals[baseKey] = now;
|
||||||
|
if (!string.IsNullOrWhiteSpace(sessionKey))
|
||||||
|
{
|
||||||
|
RecentPlaybackSignals[sessionKey] = now;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (RecentPlaybackSignals.Count > 4096)
|
||||||
|
{
|
||||||
|
var cutoff = now - PlaybackSignalRetentionWindow;
|
||||||
|
foreach (var pair in RecentPlaybackSignals)
|
||||||
|
{
|
||||||
|
if (pair.Value < cutoff)
|
||||||
|
{
|
||||||
|
RecentPlaybackSignals.TryRemove(pair.Key, out _);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
private static string? ParsePlaybackItemId(JsonElement payload)
|
private static string? ParsePlaybackItemId(JsonElement payload)
|
||||||
{
|
{
|
||||||
var direct = TryReadStringProperty(payload, "ItemId");
|
var direct = TryReadStringProperty(payload, "ItemId");
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text;
|
using System.Text;
|
||||||
|
using allstarr.Models.Search;
|
||||||
using allstarr.Models.Subsonic;
|
using allstarr.Models.Subsonic;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
using Microsoft.AspNetCore.Mvc;
|
using Microsoft.AspNetCore.Mvc;
|
||||||
@@ -34,6 +35,7 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
// AlbumArtistIds takes precedence over ArtistIds if both are provided
|
// AlbumArtistIds takes precedence over ArtistIds if both are provided
|
||||||
var effectiveArtistIds = albumArtistIds ?? artistIds;
|
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}",
|
_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);
|
searchTerm, includeItemTypes, parentId, artistIds, albumArtistIds, albumIds, userId);
|
||||||
@@ -63,6 +65,12 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
|
if (favoritesOnlyRequest)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Suppressing external artist results for favorites-only request: {ArtistId}", artistId);
|
||||||
|
return CreateEmptyItemsResponse(startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
// Check if this is a curator ID (format: ext-{provider}-curator-{name})
|
// Check if this is a curator ID (format: ext-{provider}-curator-{name})
|
||||||
if (artistId.Contains("-curator-", StringComparison.OrdinalIgnoreCase))
|
if (artistId.Contains("-curator-", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
@@ -85,6 +93,12 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
|
if (favoritesOnlyRequest)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Suppressing external album results for favorites-only request: {AlbumId}", albumId);
|
||||||
|
return CreateEmptyItemsResponse(startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
_logger.LogDebug("Fetching songs for external album: {Provider}/{ExternalId}", provider,
|
_logger.LogDebug("Fetching songs for external album: {Provider}/{ExternalId}", provider,
|
||||||
externalId);
|
externalId);
|
||||||
|
|
||||||
@@ -120,6 +134,12 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
if (isExternal)
|
if (isExternal)
|
||||||
{
|
{
|
||||||
|
if (favoritesOnlyRequest)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Suppressing external parent results for favorites-only request: {ParentId}", parentId);
|
||||||
|
return CreateEmptyItemsResponse(startIndex);
|
||||||
|
}
|
||||||
|
|
||||||
// External parent - get external content
|
// External parent - get external content
|
||||||
_logger.LogDebug("Fetching children for external parent: {Provider}/{Type}/{ExternalId}",
|
_logger.LogDebug("Fetching children for external parent: {Provider}/{Type}/{ExternalId}",
|
||||||
provider, type, externalId);
|
provider, type, externalId);
|
||||||
@@ -170,7 +190,8 @@ public partial class JellyfinController
|
|||||||
sortBy,
|
sortBy,
|
||||||
Request.Query["SortOrder"].ToString(),
|
Request.Query["SortOrder"].ToString(),
|
||||||
recursive,
|
recursive,
|
||||||
userId);
|
userId,
|
||||||
|
Request.Query["IsFavorite"].ToString());
|
||||||
var cachedResult = await _cache.GetAsync<object>(cacheKey);
|
var cachedResult = await _cache.GetAsync<object>(cacheKey);
|
||||||
|
|
||||||
if (cachedResult != null)
|
if (cachedResult != null)
|
||||||
@@ -189,10 +210,14 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
|
var endpoint = userId != null ? $"Users/{userId}/Items" : "Items";
|
||||||
|
|
||||||
// Ensure MediaSources is included in Fields parameter for bitrate info
|
// Include MediaSources only for audio-oriented browse requests (bitrate needs).
|
||||||
|
// Album/artist browse requests should stay as close to raw Jellyfin responses as possible.
|
||||||
var queryString = Request.QueryString.Value ?? "";
|
var queryString = Request.QueryString.Value ?? "";
|
||||||
|
var requestedTypes = ParseItemTypes(includeItemTypes);
|
||||||
|
var shouldIncludeMediaSources = requestedTypes != null &&
|
||||||
|
requestedTypes.Contains("Audio", StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(queryString))
|
if (shouldIncludeMediaSources && !string.IsNullOrEmpty(queryString))
|
||||||
{
|
{
|
||||||
// Parse query string to modify Fields parameter
|
// Parse query string to modify Fields parameter
|
||||||
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||||
@@ -231,13 +256,16 @@ public partial class JellyfinController
|
|||||||
queryString = $"{queryString}&Fields=MediaSources";
|
queryString = $"{queryString}&Fields=MediaSources";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else
|
else if (shouldIncludeMediaSources)
|
||||||
{
|
{
|
||||||
// No query string at all
|
// No query string at all
|
||||||
queryString = "?Fields=MediaSources";
|
queryString = "?Fields=MediaSources";
|
||||||
}
|
}
|
||||||
|
|
||||||
endpoint = $"{endpoint}{queryString}";
|
if (!string.IsNullOrEmpty(queryString))
|
||||||
|
{
|
||||||
|
endpoint = $"{endpoint}{queryString}";
|
||||||
|
}
|
||||||
|
|
||||||
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||||
|
|
||||||
@@ -249,7 +277,7 @@ public partial class JellyfinController
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update Spotify playlist counts if enabled and response contains playlists
|
// Update Spotify playlist counts if enabled and response contains playlists
|
||||||
if (_spotifySettings.Enabled && browseResult.RootElement.TryGetProperty("Items", out var _))
|
if (ShouldProcessSpotifyPlaylistCounts(browseResult, includeItemTypes))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts");
|
_logger.LogDebug("Browse result has Items, checking for Spotify playlists to update counts");
|
||||||
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
|
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
|
||||||
@@ -284,13 +312,15 @@ public partial class JellyfinController
|
|||||||
userId);
|
userId);
|
||||||
|
|
||||||
// Use parallel metadata service if available (races providers), otherwise use primary
|
// Use parallel metadata service if available (races providers), otherwise use primary
|
||||||
var externalTask = _parallelMetadataService != null
|
var externalTask = favoritesOnlyRequest
|
||||||
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
|
? Task.FromResult(new SearchResult())
|
||||||
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
|
: _parallelMetadataService != null
|
||||||
|
? _parallelMetadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted)
|
||||||
|
: _metadataService.SearchAllAsync(cleanQuery, limit, limit, limit, HttpContext.RequestAborted);
|
||||||
|
|
||||||
var playlistTask = _settings.EnableExternalPlaylists
|
var playlistTask = favoritesOnlyRequest || !_settings.EnableExternalPlaylists
|
||||||
? _metadataService.SearchPlaylistsAsync(cleanQuery, limit, HttpContext.RequestAborted)
|
? Task.FromResult(new List<ExternalPlaylist>())
|
||||||
: Task.FromResult(new List<ExternalPlaylist>());
|
: _metadataService.SearchPlaylistsAsync(cleanQuery, limit, HttpContext.RequestAborted);
|
||||||
|
|
||||||
_logger.LogDebug("Playlist search enabled: {Enabled}, searching for: '{Query}'",
|
_logger.LogDebug("Playlist search enabled: {Enabled}, searching for: '{Query}'",
|
||||||
_settings.EnableExternalPlaylists, cleanQuery);
|
_settings.EnableExternalPlaylists, cleanQuery);
|
||||||
@@ -409,6 +439,11 @@ public partial class JellyfinController
|
|||||||
|
|
||||||
// Merge albums and playlists using score-based interleaving (albums keep a light priority over playlists).
|
// 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);
|
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 2.0, boostMinScore: 70);
|
||||||
|
mergedAlbumsAndPlaylists = ApplyRequestedAlbumOrderingIfApplicable(
|
||||||
|
mergedAlbumsAndPlaylists,
|
||||||
|
itemTypes,
|
||||||
|
Request.Query["SortBy"].ToString(),
|
||||||
|
Request.Query["SortOrder"].ToString());
|
||||||
|
|
||||||
_logger.LogDebug(
|
_logger.LogDebug(
|
||||||
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
|
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
|
||||||
@@ -504,7 +539,7 @@ public partial class JellyfinController
|
|||||||
StartIndex = startIndex
|
StartIndex = startIndex
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache search results in Redis (15 min TTL, no file persistence)
|
// Cache search results in Redis using the configured search TTL.
|
||||||
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
|
if (!string.IsNullOrWhiteSpace(searchTerm) && string.IsNullOrWhiteSpace(effectiveArtistIds))
|
||||||
{
|
{
|
||||||
if (externalHasRequestedTypeResults)
|
if (externalHasRequestedTypeResults)
|
||||||
@@ -518,7 +553,8 @@ public partial class JellyfinController
|
|||||||
sortBy,
|
sortBy,
|
||||||
Request.Query["SortOrder"].ToString(),
|
Request.Query["SortOrder"].ToString(),
|
||||||
recursive,
|
recursive,
|
||||||
userId);
|
userId,
|
||||||
|
Request.Query["IsFavorite"].ToString());
|
||||||
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
|
await _cache.SetAsync(cacheKey, response, CacheExtensions.SearchResultsTTL);
|
||||||
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
|
_logger.LogDebug("💾 Cached search results for '{SearchTerm}' ({Minutes} min TTL)", searchTerm,
|
||||||
CacheExtensions.SearchResultsTTL.TotalMinutes);
|
CacheExtensions.SearchResultsTTL.TotalMinutes);
|
||||||
@@ -710,6 +746,167 @@ public partial class JellyfinController
|
|||||||
return MaskSensitiveQueryString(query);
|
return MaskSensitiveQueryString(query);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private bool IsFavoritesOnlyRequest()
|
||||||
|
{
|
||||||
|
return string.Equals(Request.Query["IsFavorite"].ToString(), "true", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IActionResult CreateEmptyItemsResponse(int startIndex)
|
||||||
|
{
|
||||||
|
return new JsonResult(new
|
||||||
|
{
|
||||||
|
Items = Array.Empty<object>(),
|
||||||
|
TotalRecordCount = 0,
|
||||||
|
StartIndex = startIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Dictionary<string, object?>> ApplyRequestedAlbumOrderingIfApplicable(
|
||||||
|
List<Dictionary<string, object?>> items,
|
||||||
|
string[]? requestedTypes,
|
||||||
|
string? sortBy,
|
||||||
|
string? sortOrder)
|
||||||
|
{
|
||||||
|
if (items.Count <= 1 || string.IsNullOrWhiteSpace(sortBy))
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (requestedTypes == null || requestedTypes.Length == 0)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isAlbumOnlyRequest = requestedTypes.All(type =>
|
||||||
|
string.Equals(type, "MusicAlbum", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
string.Equals(type, "Playlist", StringComparison.OrdinalIgnoreCase));
|
||||||
|
|
||||||
|
if (!isAlbumOnlyRequest)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortFields = sortBy
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(field => !string.IsNullOrWhiteSpace(field))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (sortFields.Count == 0)
|
||||||
|
{
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
var descending = string.Equals(sortOrder, "Descending", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var sorted = items.ToList();
|
||||||
|
sorted.Sort((left, right) => CompareAlbumItemsByRequestedSort(left, right, sortFields, descending));
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CompareAlbumItemsByRequestedSort(
|
||||||
|
Dictionary<string, object?> left,
|
||||||
|
Dictionary<string, object?> right,
|
||||||
|
IReadOnlyList<string> sortFields,
|
||||||
|
bool descending)
|
||||||
|
{
|
||||||
|
foreach (var field in sortFields)
|
||||||
|
{
|
||||||
|
var comparison = CompareAlbumItemsByField(left, right, field);
|
||||||
|
if (comparison == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return descending ? -comparison : comparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private int CompareAlbumItemsByField(Dictionary<string, object?> left, Dictionary<string, object?> right, string field)
|
||||||
|
{
|
||||||
|
return field.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"sortname" => string.Compare(GetItemStringValue(left, "SortName"), GetItemStringValue(right, "SortName"), StringComparison.OrdinalIgnoreCase),
|
||||||
|
"name" => string.Compare(GetItemStringValue(left, "Name"), GetItemStringValue(right, "Name"), StringComparison.OrdinalIgnoreCase),
|
||||||
|
"datecreated" => DateTime.Compare(GetItemDateValue(left, "DateCreated"), GetItemDateValue(right, "DateCreated")),
|
||||||
|
"premieredate" => DateTime.Compare(GetItemDateValue(left, "PremiereDate"), GetItemDateValue(right, "PremiereDate")),
|
||||||
|
"productionyear" => CompareIntValues(GetItemIntValue(left, "ProductionYear"), GetItemIntValue(right, "ProductionYear")),
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareIntValues(int? left, int? right)
|
||||||
|
{
|
||||||
|
if (left.HasValue && right.HasValue)
|
||||||
|
{
|
||||||
|
return left.Value.CompareTo(right.Value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (left.HasValue)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (right.HasValue)
|
||||||
|
{
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static DateTime GetItemDateValue(Dictionary<string, object?> item, string key)
|
||||||
|
{
|
||||||
|
if (!item.TryGetValue(key, out var value) || value == null)
|
||||||
|
{
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is JsonElement jsonElement)
|
||||||
|
{
|
||||||
|
if (jsonElement.ValueKind == JsonValueKind.String &&
|
||||||
|
DateTime.TryParse(jsonElement.GetString(), out var parsedDate))
|
||||||
|
{
|
||||||
|
return parsedDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (DateTime.TryParse(value.ToString(), out var parsed))
|
||||||
|
{
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
return DateTime.MinValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int? GetItemIntValue(Dictionary<string, object?> item, string key)
|
||||||
|
{
|
||||||
|
if (!item.TryGetValue(key, out var value) || value == null)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value is JsonElement jsonElement)
|
||||||
|
{
|
||||||
|
if (jsonElement.ValueKind == JsonValueKind.Number && jsonElement.TryGetInt32(out var intValue))
|
||||||
|
{
|
||||||
|
return intValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (jsonElement.ValueKind == JsonValueKind.String &&
|
||||||
|
int.TryParse(jsonElement.GetString(), out var parsedInt))
|
||||||
|
{
|
||||||
|
return parsedInt;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return int.TryParse(value.ToString(), out var parsed) ? parsed : null;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Score-sorts each source and then interleaves by highest remaining score.
|
/// 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.
|
/// This avoids weak head results in one source blocking stronger results later in that same source.
|
||||||
|
|||||||
@@ -201,26 +201,48 @@ public partial class JellyfinController : ControllerBase
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<IActionResult> GetExternalChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
|
private async Task<IActionResult> GetExternalChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
|
if (IsFavoritesOnlyRequest())
|
||||||
|
{
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Suppressing external child items for favorites-only request: provider={Provider}, type={Type}, externalId={ExternalId}",
|
||||||
|
provider,
|
||||||
|
type,
|
||||||
|
externalId);
|
||||||
|
return CreateEmptyItemsResponse(GetRequestedStartIndex());
|
||||||
|
}
|
||||||
|
|
||||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||||
|
var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0;
|
||||||
|
|
||||||
_logger.LogDebug("GetExternalChildItems: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
_logger.LogDebug("GetExternalChildItems: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
||||||
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||||
|
|
||||||
// Check if asking for audio (album tracks or artist songs)
|
// Albums are track containers in Jellyfin clients; when ParentId points to an album,
|
||||||
if (itemTypes?.Contains("Audio") == true)
|
// return tracks even if IncludeItemTypes is omitted.
|
||||||
|
if (type == "album" && (itemTypesUnspecified || itemTypes!.Contains("Audio", StringComparer.OrdinalIgnoreCase)))
|
||||||
{
|
{
|
||||||
if (type == "album")
|
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
|
||||||
|
var album = await _metadataService.GetAlbumAsync(provider, externalId, cancellationToken);
|
||||||
|
if (album == null)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Fetching album tracks for {Provider}/{ExternalId}", provider, externalId);
|
return _responseBuilder.CreateError(404, "Album not found");
|
||||||
var album = await _metadataService.GetAlbumAsync(provider, externalId, cancellationToken);
|
|
||||||
if (album == null)
|
|
||||||
{
|
|
||||||
return _responseBuilder.CreateError(404, "Album not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
return _responseBuilder.CreateItemsResponse(album.Songs);
|
|
||||||
}
|
}
|
||||||
else if (type == "artist")
|
|
||||||
|
var sortedAndPagedSongs = ApplySongSortAndPagingForCurrentRequest(album.Songs, out var totalRecordCount, out var startIndex);
|
||||||
|
var items = sortedAndPagedSongs.Select(_responseBuilder.ConvertSongToJellyfinItem).ToList();
|
||||||
|
|
||||||
|
return _responseBuilder.CreateJsonResponse(new
|
||||||
|
{
|
||||||
|
Items = items,
|
||||||
|
TotalRecordCount = totalRecordCount,
|
||||||
|
StartIndex = startIndex
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if asking for audio (artist songs)
|
||||||
|
if (itemTypes?.Contains("Audio", StringComparer.OrdinalIgnoreCase) == true)
|
||||||
|
{
|
||||||
|
if (type == "artist")
|
||||||
{
|
{
|
||||||
// For artist + Audio, fetch top tracks from the artist endpoint
|
// For artist + Audio, fetch top tracks from the artist endpoint
|
||||||
_logger.LogDebug("Fetching artist tracks for {Provider}/{ExternalId}", provider, externalId);
|
_logger.LogDebug("Fetching artist tracks for {Provider}/{ExternalId}", provider, externalId);
|
||||||
@@ -238,7 +260,7 @@ public partial class JellyfinController : ControllerBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Check if asking for albums (artist albums)
|
// Check if asking for albums (artist albums)
|
||||||
if (itemTypes?.Contains("MusicAlbum") == true || itemTypes == null)
|
if (itemTypes?.Contains("MusicAlbum", StringComparer.OrdinalIgnoreCase) == true || itemTypesUnspecified)
|
||||||
{
|
{
|
||||||
if (type == "artist")
|
if (type == "artist")
|
||||||
{
|
{
|
||||||
@@ -267,6 +289,85 @@ public partial class JellyfinController : ControllerBase
|
|||||||
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||||
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private int GetRequestedStartIndex()
|
||||||
|
{
|
||||||
|
return int.TryParse(Request.Query["StartIndex"], out var startIndex) && startIndex > 0
|
||||||
|
? startIndex
|
||||||
|
: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Song> ApplySongSortAndPagingForCurrentRequest(IReadOnlyCollection<Song> songs, out int totalRecordCount, out int startIndex)
|
||||||
|
{
|
||||||
|
var sortBy = Request.Query["SortBy"].ToString();
|
||||||
|
var sortOrder = Request.Query["SortOrder"].ToString();
|
||||||
|
var descending = sortOrder.Equals("Descending", StringComparison.OrdinalIgnoreCase);
|
||||||
|
var sortFields = ParseSortFields(sortBy);
|
||||||
|
|
||||||
|
var sortedSongs = songs.ToList();
|
||||||
|
sortedSongs.Sort((left, right) => CompareSongs(left, right, sortFields, descending));
|
||||||
|
|
||||||
|
totalRecordCount = sortedSongs.Count;
|
||||||
|
startIndex = 0;
|
||||||
|
if (int.TryParse(Request.Query["StartIndex"], out var parsedStartIndex) && parsedStartIndex > 0)
|
||||||
|
{
|
||||||
|
startIndex = parsedStartIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (int.TryParse(Request.Query["Limit"], out var parsedLimit) && parsedLimit > 0)
|
||||||
|
{
|
||||||
|
return sortedSongs.Skip(startIndex).Take(parsedLimit).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortedSongs.Skip(startIndex).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareSongs(Song left, Song right, IReadOnlyList<string> sortFields, bool descending)
|
||||||
|
{
|
||||||
|
var effectiveSortFields = sortFields.Count > 0
|
||||||
|
? sortFields
|
||||||
|
: new[] { "ParentIndexNumber", "IndexNumber", "SortName" };
|
||||||
|
|
||||||
|
foreach (var field in effectiveSortFields)
|
||||||
|
{
|
||||||
|
var comparison = CompareSongsByField(left, right, field);
|
||||||
|
if (comparison == 0)
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
return descending ? -comparison : comparison;
|
||||||
|
}
|
||||||
|
|
||||||
|
return string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static int CompareSongsByField(Song left, Song right, string field)
|
||||||
|
{
|
||||||
|
return field.ToLowerInvariant() switch
|
||||||
|
{
|
||||||
|
"parentindexnumber" => Nullable.Compare(left.DiscNumber, right.DiscNumber),
|
||||||
|
"indexnumber" => Nullable.Compare(left.Track, right.Track),
|
||||||
|
"sortname" => string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase),
|
||||||
|
"name" => string.Compare(left.Title, right.Title, StringComparison.OrdinalIgnoreCase),
|
||||||
|
"datecreated" => Nullable.Compare(left.Year, right.Year),
|
||||||
|
"productionyear" => Nullable.Compare(left.Year, right.Year),
|
||||||
|
_ => 0
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static List<string> ParseSortFields(string sortBy)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(sortBy))
|
||||||
|
{
|
||||||
|
return new List<string>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortBy
|
||||||
|
.Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||||
|
.Where(field => !string.IsNullOrWhiteSpace(field))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
private async Task<IActionResult> GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
|
private async Task<IActionResult> GetCuratorPlaylists(string provider, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||||
@@ -509,7 +610,8 @@ public partial class JellyfinController : ControllerBase
|
|||||||
string imageType,
|
string imageType,
|
||||||
int imageIndex = 0,
|
int imageIndex = 0,
|
||||||
[FromQuery] int? maxWidth = null,
|
[FromQuery] int? maxWidth = null,
|
||||||
[FromQuery] int? maxHeight = null)
|
[FromQuery] int? maxHeight = null,
|
||||||
|
[FromQuery(Name = "tag")] string? tag = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(itemId))
|
if (string.IsNullOrWhiteSpace(itemId))
|
||||||
{
|
{
|
||||||
@@ -531,7 +633,8 @@ public partial class JellyfinController : ControllerBase
|
|||||||
itemId,
|
itemId,
|
||||||
imageType,
|
imageType,
|
||||||
maxWidth,
|
maxWidth,
|
||||||
maxHeight);
|
maxHeight,
|
||||||
|
tag);
|
||||||
|
|
||||||
if (imageBytes == null || contentType == null)
|
if (imageBytes == null || contentType == null)
|
||||||
{
|
{
|
||||||
@@ -1374,9 +1477,7 @@ public partial class JellyfinController : ControllerBase
|
|||||||
|
|
||||||
// Modify response if it contains Spotify playlists to update ChildCount
|
// Modify response if it contains Spotify playlists to update ChildCount
|
||||||
// Only check for Items if the response is an object (not a string or array)
|
// Only check for Items if the response is an object (not a string or array)
|
||||||
if (_spotifySettings.Enabled &&
|
if (ShouldProcessSpotifyPlaylistCounts(result, Request.Query["IncludeItemTypes"].ToString()))
|
||||||
result.RootElement.ValueKind == JsonValueKind.Object &&
|
|
||||||
result.RootElement.TryGetProperty("Items", out var items))
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Response has Items property, checking for Spotify playlists to update counts");
|
_logger.LogDebug("Response has Items property, checking for Spotify playlists to update counts");
|
||||||
result = await UpdateSpotifyPlaylistCounts(result);
|
result = await UpdateSpotifyPlaylistCounts(result);
|
||||||
|
|||||||
@@ -1155,7 +1155,7 @@ public class PlaylistController : ControllerBase
|
|||||||
public async Task<IActionResult> ClearPlaylistCache(string name)
|
public async Task<IActionResult> ClearPlaylistCache(string name)
|
||||||
{
|
{
|
||||||
var decodedName = Uri.UnescapeDataString(name);
|
var decodedName = Uri.UnescapeDataString(name);
|
||||||
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name} (same as cron job)", decodedName);
|
_logger.LogInformation("Rebuild from scratch triggered for playlist: {Name}", decodedName);
|
||||||
|
|
||||||
if (_matchingService == null)
|
if (_matchingService == null)
|
||||||
{
|
{
|
||||||
@@ -1164,7 +1164,7 @@ public class PlaylistController : ControllerBase
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Use the unified rebuild method (same as cron job and "Rebuild All Remote")
|
// Use the unified per-playlist rebuild method (same workflow as per-playlist cron rebuilds)
|
||||||
await _matchingService.TriggerRebuildForPlaylistAsync(decodedName);
|
await _matchingService.TriggerRebuildForPlaylistAsync(decodedName);
|
||||||
|
|
||||||
// Invalidate playlist summary cache
|
// Invalidate playlist summary cache
|
||||||
@@ -1172,7 +1172,7 @@ public class PlaylistController : ControllerBase
|
|||||||
|
|
||||||
return Ok(new
|
return Ok(new
|
||||||
{
|
{
|
||||||
message = $"Rebuilding {decodedName} from scratch (same as cron job)",
|
message = $"Rebuilding {decodedName} from scratch",
|
||||||
timestamp = DateTime.UtcNow
|
timestamp = DateTime.UtcNow
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1768,12 +1768,12 @@ public class PlaylistController : ControllerBase
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rebuild all playlists from scratch (clear cache, fetch fresh data, re-match).
|
/// Rebuild all playlists from scratch (clear cache, fetch fresh data, re-match).
|
||||||
/// This is the same process as the scheduled cron job - used by "Rebuild All Remote" button.
|
/// This is a manual bulk action across all playlists - used by "Rebuild All Remote" button.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpPost("playlists/rebuild-all")]
|
[HttpPost("playlists/rebuild-all")]
|
||||||
public async Task<IActionResult> RebuildAllPlaylists()
|
public async Task<IActionResult> RebuildAllPlaylists()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)");
|
_logger.LogInformation("Manual full rebuild triggered for all playlists");
|
||||||
|
|
||||||
if (_matchingService == null)
|
if (_matchingService == null)
|
||||||
{
|
{
|
||||||
@@ -1783,7 +1783,7 @@ public class PlaylistController : ControllerBase
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
await _matchingService.TriggerRebuildAllAsync();
|
await _matchingService.TriggerRebuildAllAsync();
|
||||||
return Ok(new { message = "Full rebuild triggered for all playlists (same as cron job)", timestamp = DateTime.UtcNow });
|
return Ok(new { message = "Full rebuild triggered for all playlists", timestamp = DateTime.UtcNow });
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ public class ScrobblingAdminController : ControllerBase
|
|||||||
{
|
{
|
||||||
Enabled = _settings.Enabled,
|
Enabled = _settings.Enabled,
|
||||||
LocalTracksEnabled = _settings.LocalTracksEnabled,
|
LocalTracksEnabled = _settings.LocalTracksEnabled,
|
||||||
|
SyntheticLocalPlayedSignalEnabled = _settings.SyntheticLocalPlayedSignalEnabled,
|
||||||
LastFm = new
|
LastFm = new
|
||||||
{
|
{
|
||||||
Enabled = _settings.LastFm.Enabled,
|
Enabled = _settings.LastFm.Enabled,
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
using allstarr.Services.Common;
|
||||||
|
|
||||||
|
namespace allstarr.Middleware;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Short-circuits common internet scanner paths before they reach the Jellyfin proxy.
|
||||||
|
/// </summary>
|
||||||
|
public class BotProbeBlockMiddleware
|
||||||
|
{
|
||||||
|
private readonly RequestDelegate _next;
|
||||||
|
private readonly ILogger<BotProbeBlockMiddleware> _logger;
|
||||||
|
|
||||||
|
public BotProbeBlockMiddleware(
|
||||||
|
RequestDelegate next,
|
||||||
|
ILogger<BotProbeBlockMiddleware> logger)
|
||||||
|
{
|
||||||
|
_next = next;
|
||||||
|
_logger = logger;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task InvokeAsync(HttpContext context)
|
||||||
|
{
|
||||||
|
var requestPath = context.Request.Path.Value;
|
||||||
|
if (!BotProbeDetector.IsHighConfidenceProbePath(requestPath))
|
||||||
|
{
|
||||||
|
await _next(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("Short-circuited likely bot probe from {RemoteIp}: {Method} {Path}",
|
||||||
|
context.Connection.RemoteIpAddress?.ToString() ?? "(null)",
|
||||||
|
context.Request.Method,
|
||||||
|
requestPath);
|
||||||
|
|
||||||
|
context.Response.StatusCode = StatusCodes.Status404NotFound;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,11 +24,14 @@ public class RequestLoggingMiddleware
|
|||||||
|
|
||||||
// Log initialization status
|
// Log initialization status
|
||||||
var initialValue = _configuration.GetValue<bool>("Debug:LogAllRequests");
|
var initialValue = _configuration.GetValue<bool>("Debug:LogAllRequests");
|
||||||
|
var initialRedactionValue = _configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false);
|
||||||
_logger.LogWarning("🔍 RequestLoggingMiddleware initialized - LogAllRequests={LogAllRequests}", initialValue);
|
_logger.LogWarning("🔍 RequestLoggingMiddleware initialized - LogAllRequests={LogAllRequests}", initialValue);
|
||||||
|
|
||||||
if (initialValue)
|
if (initialValue)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("🔍 Request logging ENABLED - all HTTP requests will be logged");
|
_logger.LogWarning(
|
||||||
|
"🔍 Request logging ENABLED - all HTTP requests will be logged (RedactSensitiveRequestValues={Redact})",
|
||||||
|
initialRedactionValue);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -40,6 +43,7 @@ public class RequestLoggingMiddleware
|
|||||||
{
|
{
|
||||||
// Check configuration on every request to allow dynamic toggling
|
// Check configuration on every request to allow dynamic toggling
|
||||||
var logAllRequests = _configuration.GetValue<bool>("Debug:LogAllRequests");
|
var logAllRequests = _configuration.GetValue<bool>("Debug:LogAllRequests");
|
||||||
|
var redactSensitiveValues = _configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false);
|
||||||
|
|
||||||
if (!logAllRequests)
|
if (!logAllRequests)
|
||||||
{
|
{
|
||||||
@@ -49,11 +53,13 @@ public class RequestLoggingMiddleware
|
|||||||
|
|
||||||
var stopwatch = Stopwatch.StartNew();
|
var stopwatch = Stopwatch.StartNew();
|
||||||
var request = context.Request;
|
var request = context.Request;
|
||||||
var maskedQueryString = BuildMaskedQueryString(request.QueryString.Value);
|
var queryStringForLog = redactSensitiveValues
|
||||||
|
? BuildMaskedQueryString(request.QueryString.Value)
|
||||||
|
: request.QueryString.Value ?? string.Empty;
|
||||||
|
|
||||||
// Log request details
|
// Log request details
|
||||||
var requestLog = new StringBuilder();
|
var requestLog = new StringBuilder();
|
||||||
requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{maskedQueryString}");
|
requestLog.AppendLine($"📥 HTTP {request.Method} {request.Path}{queryStringForLog}");
|
||||||
requestLog.AppendLine($" Host: {request.Host}");
|
requestLog.AppendLine($" Host: {request.Host}");
|
||||||
requestLog.AppendLine($" Content-Type: {request.ContentType ?? "(none)"}");
|
requestLog.AppendLine($" Content-Type: {request.ContentType ?? "(none)"}");
|
||||||
requestLog.AppendLine($" Content-Length: {request.ContentLength?.ToString() ?? "(none)"}");
|
requestLog.AppendLine($" Content-Length: {request.ContentLength?.ToString() ?? "(none)"}");
|
||||||
@@ -65,15 +71,18 @@ public class RequestLoggingMiddleware
|
|||||||
}
|
}
|
||||||
if (request.Headers.ContainsKey("X-Emby-Authorization"))
|
if (request.Headers.ContainsKey("X-Emby-Authorization"))
|
||||||
{
|
{
|
||||||
requestLog.AppendLine($" X-Emby-Authorization: {MaskAuthHeader(request.Headers["X-Emby-Authorization"]!)}");
|
var value = request.Headers["X-Emby-Authorization"].ToString();
|
||||||
|
requestLog.AppendLine($" X-Emby-Authorization: {(redactSensitiveValues ? MaskAuthHeader(value) : value)}");
|
||||||
}
|
}
|
||||||
if (request.Headers.ContainsKey("Authorization"))
|
if (request.Headers.ContainsKey("Authorization"))
|
||||||
{
|
{
|
||||||
requestLog.AppendLine($" Authorization: {MaskAuthHeader(request.Headers["Authorization"]!)}");
|
var value = request.Headers["Authorization"].ToString();
|
||||||
|
requestLog.AppendLine($" Authorization: {(redactSensitiveValues ? MaskAuthHeader(value) : value)}");
|
||||||
}
|
}
|
||||||
if (request.Headers.ContainsKey("X-Emby-Token"))
|
if (request.Headers.ContainsKey("X-Emby-Token"))
|
||||||
{
|
{
|
||||||
requestLog.AppendLine($" X-Emby-Token: ***");
|
var value = request.Headers["X-Emby-Token"].ToString();
|
||||||
|
requestLog.AppendLine($" X-Emby-Token: {(redactSensitiveValues ? "***" : value)}");
|
||||||
}
|
}
|
||||||
if (request.Headers.ContainsKey("X-Emby-Device-Id"))
|
if (request.Headers.ContainsKey("X-Emby-Device-Id"))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -94,10 +94,6 @@ public class WebSocketProxyMiddleware
|
|||||||
_logger.LogDebug("🔍 WEBSOCKET: Client WebSocket for device {DeviceId}", deviceId);
|
_logger.LogDebug("🔍 WEBSOCKET: Client WebSocket for device {DeviceId}", deviceId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Accept the WebSocket connection from the client
|
|
||||||
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
|
||||||
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
|
|
||||||
|
|
||||||
// Build Jellyfin WebSocket URL
|
// Build Jellyfin WebSocket URL
|
||||||
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
|
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
|
||||||
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
|
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
|
||||||
@@ -124,6 +120,11 @@ public class WebSocketProxyMiddleware
|
|||||||
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
|
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
|
||||||
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header");
|
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header");
|
||||||
}
|
}
|
||||||
|
else if (context.Request.Headers.TryGetValue("X-Emby-Token", out var tokenHeader))
|
||||||
|
{
|
||||||
|
serverWebSocket.Options.SetRequestHeader("X-Emby-Token", tokenHeader.ToString());
|
||||||
|
_logger.LogDebug("🔑 WEBSOCKET: Forwarded X-Emby-Token header");
|
||||||
|
}
|
||||||
else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader2))
|
else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader2))
|
||||||
{
|
{
|
||||||
var authValue = authHeader2.ToString();
|
var authValue = authHeader2.ToString();
|
||||||
@@ -146,6 +147,11 @@ public class WebSocketProxyMiddleware
|
|||||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||||
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
_logger.LogInformation("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
|
||||||
|
|
||||||
|
// Only accept the client socket after upstream auth/handshake succeeds.
|
||||||
|
// This ensures auth failures surface as HTTP status (401/403) instead of misleading 101 upgrades.
|
||||||
|
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
|
||||||
|
_logger.LogDebug("✓ WEBSOCKET: Client WebSocket accepted");
|
||||||
|
|
||||||
// Start bidirectional proxying
|
// Start bidirectional proxying
|
||||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||||
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
|
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
|
||||||
@@ -157,10 +163,25 @@ public class WebSocketProxyMiddleware
|
|||||||
}
|
}
|
||||||
catch (WebSocketException wsEx)
|
catch (WebSocketException wsEx)
|
||||||
{
|
{
|
||||||
// 403 is expected when tokens expire or session ends - don't spam logs
|
var isAuthFailure =
|
||||||
if (wsEx.Message.Contains("403"))
|
wsEx.Message.Contains("403", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
wsEx.Message.Contains("401", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
wsEx.Message.Contains("Unauthorized", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
wsEx.Message.Contains("Forbidden", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
if (isAuthFailure)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("WEBSOCKET: Connection rejected with 403 (token expired or session ended)");
|
_logger.LogWarning("WEBSOCKET: Connection rejected by Jellyfin auth (token expired or session ended)");
|
||||||
|
if (!context.Response.HasStarted)
|
||||||
|
{
|
||||||
|
context.Response.StatusCode = StatusCodes.Status403Forbidden;
|
||||||
|
await context.Response.WriteAsJsonAsync(new
|
||||||
|
{
|
||||||
|
type = "https://tools.ietf.org/html/rfc9110#section-15.5.4",
|
||||||
|
title = "Forbidden",
|
||||||
|
status = StatusCodes.Status403Forbidden
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -201,8 +222,8 @@ public class WebSocketProxyMiddleware
|
|||||||
clientWebSocket?.Dispose();
|
clientWebSocket?.Dispose();
|
||||||
serverWebSocket?.Dispose();
|
serverWebSocket?.Dispose();
|
||||||
|
|
||||||
// CRITICAL: Notify session manager that client disconnected
|
// CRITICAL: Notify session manager only when a client socket was accepted.
|
||||||
if (!string.IsNullOrEmpty(deviceId))
|
if (clientWebSocket != null && !string.IsNullOrEmpty(deviceId))
|
||||||
{
|
{
|
||||||
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||||
await _sessionManager.RemoveSessionAsync(deviceId);
|
await _sessionManager.RemoveSessionAsync(deviceId);
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace allstarr.Models.Scrobbling;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public class PlaybackSession
|
public class PlaybackSession
|
||||||
{
|
{
|
||||||
|
private const int ExternalStartToleranceSeconds = 5;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Unique identifier for this playback session.
|
/// Unique identifier for this playback session.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -54,13 +56,18 @@ public class PlaybackSession
|
|||||||
{
|
{
|
||||||
if (Scrobbled)
|
if (Scrobbled)
|
||||||
return false; // Already scrobbled
|
return false; // Already scrobbled
|
||||||
|
|
||||||
if (Track.DurationSeconds == null || Track.DurationSeconds <= 30)
|
if (Track.DurationSeconds == null || Track.DurationSeconds <= 30)
|
||||||
return false; // Track too short or duration unknown
|
return false; // Track too short or duration unknown
|
||||||
|
|
||||||
|
// External scrobbles should only count if playback started near the beginning.
|
||||||
|
// This avoids duplicate/resume scrobbles when users jump into a track mid-way.
|
||||||
|
if (Track.IsExternal && (Track.StartPositionSeconds ?? 0) > ExternalStartToleranceSeconds)
|
||||||
|
return false;
|
||||||
|
|
||||||
var halfDuration = Track.DurationSeconds.Value / 2;
|
var halfDuration = Track.DurationSeconds.Value / 2;
|
||||||
var scrobbleThreshold = Math.Min(halfDuration, 240); // 4 minutes = 240 seconds
|
var scrobbleThreshold = Math.Min(halfDuration, 240); // 4 minutes = 240 seconds
|
||||||
|
|
||||||
return LastPositionSeconds >= scrobbleThreshold;
|
return LastPositionSeconds >= scrobbleThreshold;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,4 +52,10 @@ public record ScrobbleTrack
|
|||||||
/// ListenBrainz only scrobbles external tracks.
|
/// ListenBrainz only scrobbles external tracks.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool IsExternal { get; init; } = false;
|
public bool IsExternal { get; init; } = false;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Playback position in seconds when this listen started.
|
||||||
|
/// Used to prevent scrobbling resumed external tracks that did not start near the beginning.
|
||||||
|
/// </summary>
|
||||||
|
public int? StartPositionSeconds { get; init; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ public class CacheSettings
|
|||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Search results cache duration in minutes.
|
/// Search results cache duration in minutes.
|
||||||
/// Default: 120 minutes (2 hours)
|
/// Default: 1 minute (60 seconds)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int SearchResultsMinutes { get; set; } = 120;
|
public int SearchResultsMinutes { get; set; } = 1;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Playlist cover images cache duration in hours.
|
/// Playlist cover images cache duration in hours.
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ public class ScrobblingSettings
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool LocalTracksEnabled { get; set; }
|
public bool LocalTracksEnabled { get; set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Emits a synthetic local "played" signal from progress events when local scrobbling is disabled.
|
||||||
|
/// Default is false to avoid duplicate local scrobbles with Jellyfin plugins.
|
||||||
|
/// </summary>
|
||||||
|
public bool SyntheticLocalPlayedSignalEnabled { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Last.fm settings.
|
/// Last.fm settings.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
|||||||
@@ -731,6 +731,8 @@ builder.Services.Configure<allstarr.Models.Settings.ScrobblingSettings>(options
|
|||||||
|
|
||||||
options.Enabled = builder.Configuration.GetValue<bool>("Scrobbling:Enabled");
|
options.Enabled = builder.Configuration.GetValue<bool>("Scrobbling:Enabled");
|
||||||
options.LocalTracksEnabled = builder.Configuration.GetValue<bool>("Scrobbling:LocalTracksEnabled");
|
options.LocalTracksEnabled = builder.Configuration.GetValue<bool>("Scrobbling:LocalTracksEnabled");
|
||||||
|
options.SyntheticLocalPlayedSignalEnabled =
|
||||||
|
builder.Configuration.GetValue<bool>("Scrobbling:SyntheticLocalPlayedSignalEnabled");
|
||||||
options.LastFm.Enabled = lastFmEnabled;
|
options.LastFm.Enabled = lastFmEnabled;
|
||||||
|
|
||||||
// Only override hardcoded API credentials if explicitly set in config
|
// Only override hardcoded API credentials if explicitly set in config
|
||||||
@@ -755,6 +757,7 @@ builder.Services.Configure<allstarr.Models.Settings.ScrobblingSettings>(options
|
|||||||
Console.WriteLine($"Scrobbling Configuration:");
|
Console.WriteLine($"Scrobbling Configuration:");
|
||||||
Console.WriteLine($" Enabled: {options.Enabled}");
|
Console.WriteLine($" Enabled: {options.Enabled}");
|
||||||
Console.WriteLine($" Local Tracks Enabled: {options.LocalTracksEnabled}");
|
Console.WriteLine($" Local Tracks Enabled: {options.LocalTracksEnabled}");
|
||||||
|
Console.WriteLine($" Synthetic Local Played Signal Enabled: {options.SyntheticLocalPlayedSignalEnabled}");
|
||||||
Console.WriteLine($" Last.fm Enabled: {options.LastFm.Enabled}");
|
Console.WriteLine($" Last.fm Enabled: {options.LastFm.Enabled}");
|
||||||
Console.WriteLine($" Last.fm Username: {options.LastFm.Username ?? "(not set)"}");
|
Console.WriteLine($" Last.fm Username: {options.LastFm.Username ?? "(not set)"}");
|
||||||
Console.WriteLine($" Last.fm Session Key: {(string.IsNullOrEmpty(options.LastFm.SessionKey) ? "(not set)" : "***" + options.LastFm.SessionKey[^8..])}");
|
Console.WriteLine($" Last.fm Session Key: {(string.IsNullOrEmpty(options.LastFm.SessionKey) ? "(not set)" : "***" + options.LastFm.SessionKey[^8..])}");
|
||||||
@@ -908,6 +911,9 @@ catch (Exception ex)
|
|||||||
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
|
// This processes X-Forwarded-For, X-Real-IP, etc. from nginx
|
||||||
app.UseForwardedHeaders();
|
app.UseForwardedHeaders();
|
||||||
|
|
||||||
|
// Drop high-confidence scanner paths before they hit the proxy or request logging.
|
||||||
|
app.UseMiddleware<BotProbeBlockMiddleware>();
|
||||||
|
|
||||||
// Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true)
|
// Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true)
|
||||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,18 @@ public static class AuthHeaderHelper
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Some Jellyfin clients send the raw token separately instead of a MediaBrowser auth header.
|
||||||
|
foreach (var header in sourceHeaders)
|
||||||
|
{
|
||||||
|
if (header.Key.Equals("X-Emby-Token", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
var headerValue = header.Value.ToString();
|
||||||
|
targetRequest.Headers.TryAddWithoutValidation("X-Emby-Token", headerValue);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
|
// If no X-Emby-Authorization, check if Authorization header contains MediaBrowser format
|
||||||
foreach (var header in sourceHeaders)
|
foreach (var header in sourceHeaders)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -467,7 +467,18 @@ public abstract class BaseDownloadService : IDownloadService
|
|||||||
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
|
Logger.LogDebug("Cleaned up failed download tracking for {SongId}", songId);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
Logger.LogError(ex, "Download failed for {SongId}", songId);
|
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
|
||||||
|
{
|
||||||
|
Logger.LogError("Download failed for {SongId}: {StatusCode}: {ReasonPhrase}",
|
||||||
|
songId,
|
||||||
|
(int)httpRequestException.StatusCode.Value,
|
||||||
|
httpRequestException.StatusCode.Value);
|
||||||
|
Logger.LogDebug(ex, "Detailed download failure for {SongId}", songId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
Logger.LogError(ex, "Download failed for {SongId}", songId);
|
||||||
|
}
|
||||||
throw;
|
throw;
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
|
||||||
|
namespace allstarr.Services.Common;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Identifies high-confidence internet scanner paths that should never hit Jellyfin.
|
||||||
|
/// </summary>
|
||||||
|
public static class BotProbeDetector
|
||||||
|
{
|
||||||
|
private static readonly string[] PrefixMatches =
|
||||||
|
{
|
||||||
|
".env",
|
||||||
|
".git",
|
||||||
|
".hg",
|
||||||
|
".svn",
|
||||||
|
"_ignition/",
|
||||||
|
"debug/default",
|
||||||
|
"vendor/",
|
||||||
|
"public/vendor/"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] FragmentMatches =
|
||||||
|
{
|
||||||
|
"/.env",
|
||||||
|
"/.git/",
|
||||||
|
"/vendor/",
|
||||||
|
"phpunit",
|
||||||
|
"laravel-filemanager",
|
||||||
|
"eval-stdin.php"
|
||||||
|
};
|
||||||
|
|
||||||
|
private static readonly string[] SuffixMatches =
|
||||||
|
{
|
||||||
|
".php"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsHighConfidenceProbePath(string? rawPath)
|
||||||
|
{
|
||||||
|
var path = NormalizePath(rawPath);
|
||||||
|
if (string.IsNullOrEmpty(path))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.Equals("wp", StringComparison.Ordinal) ||
|
||||||
|
path.StartsWith("wp-", StringComparison.Ordinal) ||
|
||||||
|
path.StartsWith("wp/", StringComparison.Ordinal) ||
|
||||||
|
path.Equals("wordpress", StringComparison.Ordinal) ||
|
||||||
|
path.StartsWith("wordpress/", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PrefixMatches.Any(prefix => path.StartsWith(prefix, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (FragmentMatches.Any(fragment => path.Contains(fragment, StringComparison.Ordinal)))
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return SuffixMatches.Any(suffix => path.EndsWith(suffix, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsHighConfidenceProbeUrl(string? rawUrlOrPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawUrlOrPath))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Uri.TryCreate(rawUrlOrPath, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
return IsHighConfidenceProbePath(uri.AbsolutePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
return IsHighConfidenceProbePath(rawUrlOrPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizePath(string? rawPath)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(rawPath))
|
||||||
|
{
|
||||||
|
return string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = rawPath.Trim();
|
||||||
|
|
||||||
|
if (Uri.TryCreate(path, UriKind.Absolute, out var uri))
|
||||||
|
{
|
||||||
|
path = uri.AbsolutePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
path = Uri.UnescapeDataString(path)
|
||||||
|
.Replace('\\', '/')
|
||||||
|
.TrimStart('/');
|
||||||
|
|
||||||
|
while (path.Contains("//", StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
path = path.Replace("//", "/", StringComparison.Ordinal);
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.ToLower(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ public static class CacheKeyBuilder
|
|||||||
string? sortBy,
|
string? sortBy,
|
||||||
string? sortOrder,
|
string? sortOrder,
|
||||||
bool? recursive,
|
bool? recursive,
|
||||||
string? userId)
|
string? userId,
|
||||||
|
string? isFavorite = null)
|
||||||
{
|
{
|
||||||
var normalizedTerm = Normalize(searchTerm);
|
var normalizedTerm = Normalize(searchTerm);
|
||||||
var normalizedItemTypes = Normalize(itemTypes);
|
var normalizedItemTypes = Normalize(itemTypes);
|
||||||
@@ -30,9 +31,10 @@ public static class CacheKeyBuilder
|
|||||||
var normalizedSortBy = Normalize(sortBy);
|
var normalizedSortBy = Normalize(sortBy);
|
||||||
var normalizedSortOrder = Normalize(sortOrder);
|
var normalizedSortOrder = Normalize(sortOrder);
|
||||||
var normalizedUserId = Normalize(userId);
|
var normalizedUserId = Normalize(userId);
|
||||||
|
var normalizedIsFavorite = Normalize(isFavorite);
|
||||||
var normalizedRecursive = recursive.HasValue ? (recursive.Value ? "true" : "false") : string.Empty;
|
var normalizedRecursive = recursive.HasValue ? (recursive.Value ? "true" : "false") : string.Empty;
|
||||||
|
|
||||||
return $"search:{normalizedTerm}:{normalizedItemTypes}:{limit}:{startIndex}:{normalizedParentId}:{normalizedSortBy}:{normalizedSortOrder}:{normalizedRecursive}:{normalizedUserId}";
|
return $"search:{normalizedTerm}:{normalizedItemTypes}:{limit}:{startIndex}:{normalizedParentId}:{normalizedSortBy}:{normalizedSortOrder}:{normalizedRecursive}:{normalizedUserId}:{normalizedIsFavorite}";
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Normalize(string? value)
|
private static string Normalize(string? value)
|
||||||
|
|||||||
@@ -235,8 +235,7 @@ public class RoundRobinFallbackHelper
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
LogEndpointFailure(baseUrl, ex, willRetry: attempt < orderedEndpoints.Count - 1);
|
||||||
_serviceName, baseUrl);
|
|
||||||
|
|
||||||
// Mark as unhealthy in cache
|
// Mark as unhealthy in cache
|
||||||
lock (_healthCacheLock)
|
lock (_healthCacheLock)
|
||||||
@@ -351,8 +350,7 @@ public class RoundRobinFallbackHelper
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
_logger.LogError(ex, "{Service} request failed with endpoint {Endpoint}, trying next...",
|
LogEndpointFailure(baseUrl, ex, willRetry: attempt < orderedEndpoints.Count - 1);
|
||||||
_serviceName, baseUrl);
|
|
||||||
|
|
||||||
// Mark as unhealthy in cache
|
// Mark as unhealthy in cache
|
||||||
lock (_healthCacheLock)
|
lock (_healthCacheLock)
|
||||||
@@ -371,10 +369,110 @@ public class RoundRobinFallbackHelper
|
|||||||
return defaultValue;
|
return defaultValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Tries endpoints until one both succeeds and returns an acceptable result.
|
||||||
|
/// Unacceptable results continue to the next endpoint without poisoning health state.
|
||||||
|
/// </summary>
|
||||||
|
public async Task<T> TryWithFallbackAsync<T>(
|
||||||
|
Func<string, Task<T>> action,
|
||||||
|
Func<T, bool> isAcceptableResult,
|
||||||
|
T defaultValue)
|
||||||
|
{
|
||||||
|
if (isAcceptableResult == null)
|
||||||
|
{
|
||||||
|
throw new ArgumentNullException(nameof(isAcceptableResult));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get healthy endpoints first (with caching to avoid excessive checks)
|
||||||
|
var healthyEndpoints = await GetHealthyEndpointsAsync();
|
||||||
|
|
||||||
|
// Try healthy endpoints first, then fall back to all if needed
|
||||||
|
var endpointsToTry = healthyEndpoints.Count < _apiUrls.Count
|
||||||
|
? healthyEndpoints.Concat(_apiUrls.Except(healthyEndpoints)).ToList()
|
||||||
|
: healthyEndpoints;
|
||||||
|
|
||||||
|
var orderedEndpoints = BuildTryOrder(endpointsToTry);
|
||||||
|
|
||||||
|
for (int attempt = 0; attempt < orderedEndpoints.Count; attempt++)
|
||||||
|
{
|
||||||
|
var baseUrl = orderedEndpoints[attempt];
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Trying {Service} endpoint {Endpoint} (attempt {Attempt}/{Total})",
|
||||||
|
_serviceName, baseUrl, attempt + 1, orderedEndpoints.Count);
|
||||||
|
|
||||||
|
var result = await action(baseUrl);
|
||||||
|
if (isAcceptableResult(result))
|
||||||
|
{
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug("{Service} endpoint {Endpoint} returned an unacceptable result, trying next...",
|
||||||
|
_serviceName, baseUrl);
|
||||||
|
|
||||||
|
if (attempt == orderedEndpoints.Count - 1)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("All {Count} {Service} endpoints returned unacceptable results, returning default value",
|
||||||
|
orderedEndpoints.Count, _serviceName);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
LogEndpointFailure(baseUrl, ex, willRetry: attempt < orderedEndpoints.Count - 1);
|
||||||
|
|
||||||
|
lock (_healthCacheLock)
|
||||||
|
{
|
||||||
|
_healthCache[baseUrl] = (false, DateTime.UtcNow);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attempt == orderedEndpoints.Count - 1)
|
||||||
|
{
|
||||||
|
_logger.LogError("All {Count} {Service} endpoints failed, returning default value",
|
||||||
|
orderedEndpoints.Count, _serviceName);
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LogEndpointFailure(string baseUrl, Exception ex, bool willRetry)
|
||||||
|
{
|
||||||
|
var message = BuildFailureSummary(ex);
|
||||||
|
|
||||||
|
if (willRetry)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("{Service} request failed at {Endpoint}: {Error}. Trying next...",
|
||||||
|
_serviceName, baseUrl, message);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("{Service} request failed at {Endpoint}: {Error}",
|
||||||
|
_serviceName, baseUrl, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
_logger.LogDebug(ex, "{Service} detailed failure for endpoint {Endpoint}",
|
||||||
|
_serviceName, baseUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string BuildFailureSummary(Exception ex)
|
||||||
|
{
|
||||||
|
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
|
||||||
|
{
|
||||||
|
var statusCode = (int)httpRequestException.StatusCode.Value;
|
||||||
|
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Processes multiple items in parallel across all available endpoints.
|
/// Processes multiple items in parallel across all available endpoints.
|
||||||
/// Each endpoint processes items sequentially. Failed endpoints are blacklisted.
|
/// Each endpoint processes items sequentially. Failed endpoints are blacklisted.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task<List<TResult>> ProcessInParallelAsync<TItem, TResult>(
|
public async Task<List<TResult>> ProcessInParallelAsync<TItem, TResult>(
|
||||||
List<TItem> items,
|
List<TItem> items,
|
||||||
Func<string, TItem, CancellationToken, Task<TResult>> action,
|
Func<string, TItem, CancellationToken, Task<TResult>> action,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using allstarr.Models.Settings;
|
using allstarr.Models.Settings;
|
||||||
using allstarr.Services.Common;
|
using allstarr.Services.Common;
|
||||||
|
using System.Net;
|
||||||
using System.Net.Http.Headers;
|
using System.Net.Http.Headers;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
|
|
||||||
@@ -209,14 +210,9 @@ public class JellyfinProxyService
|
|||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
if (!isBrowserStaticRequest && !isPublicEndpoint)
|
||||||
{
|
{
|
||||||
// 401 means token expired or invalid - client needs to re-authenticate
|
LogUpstreamFailure(HttpMethod.Get, response.StatusCode, url);
|
||||||
_logger.LogDebug("Jellyfin returned 401 Unauthorized for {Url} - client should re-authenticate", url);
|
|
||||||
}
|
|
||||||
else if (!isBrowserStaticRequest && !isPublicEndpoint)
|
|
||||||
{
|
|
||||||
_logger.LogError("Jellyfin request failed: {StatusCode} for {Url}", response.StatusCode, url);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to parse error response to pass through to client
|
// Try to parse error response to pass through to client
|
||||||
@@ -310,17 +306,7 @@ public class JellyfinProxyService
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorContent = await response.Content.ReadAsStringAsync();
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
|
LogUpstreamFailure(HttpMethod.Post, response.StatusCode, url, errorContent);
|
||||||
// 401 is expected when tokens expire - don't spam logs
|
|
||||||
if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Jellyfin POST returned 401 for {Url} - client should re-authenticate", url);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogError("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
|
|
||||||
response.StatusCode, url, errorContent.Length > 200 ? errorContent[..200] + "..." : errorContent);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Try to parse error response as JSON to pass through to client
|
// Try to parse error response as JSON to pass through to client
|
||||||
if (!string.IsNullOrWhiteSpace(errorContent))
|
if (!string.IsNullOrWhiteSpace(errorContent))
|
||||||
@@ -455,8 +441,7 @@ public class JellyfinProxyService
|
|||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
var errorContent = await response.Content.ReadAsStringAsync();
|
var errorContent = await response.Content.ReadAsStringAsync();
|
||||||
_logger.LogError("Jellyfin DELETE request failed: {StatusCode} for {Url}. Response: {Response}",
|
LogUpstreamFailure(HttpMethod.Delete, response.StatusCode, url, errorContent);
|
||||||
response.StatusCode, url, errorContent);
|
|
||||||
return (null, statusCode);
|
return (null, statusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,10 +762,11 @@ public class JellyfinProxyService
|
|||||||
string itemId,
|
string itemId,
|
||||||
string imageType = "Primary",
|
string imageType = "Primary",
|
||||||
int? maxWidth = null,
|
int? maxWidth = null,
|
||||||
int? maxHeight = null)
|
int? maxHeight = null,
|
||||||
|
string? imageTag = null)
|
||||||
{
|
{
|
||||||
// Build cache key
|
// Build cache key
|
||||||
var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}";
|
var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}:{imageTag}";
|
||||||
|
|
||||||
// Try cache first
|
// Try cache first
|
||||||
var cached = await _cache.GetStringAsync(cacheKey);
|
var cached = await _cache.GetStringAsync(cacheKey);
|
||||||
@@ -807,6 +793,12 @@ public class JellyfinProxyService
|
|||||||
queryParams["maxHeight"] = maxHeight.Value.ToString();
|
queryParams["maxHeight"] = maxHeight.Value.ToString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Jellyfin uses `tag` for image cache busting when artwork changes.
|
||||||
|
if (!string.IsNullOrWhiteSpace(imageTag))
|
||||||
|
{
|
||||||
|
queryParams["tag"] = imageTag;
|
||||||
|
}
|
||||||
|
|
||||||
var result = await GetBytesSafeAsync($"Items/{itemId}/Images/{imageType}", queryParams);
|
var result = await GetBytesSafeAsync($"Items/{itemId}/Images/{imageType}", queryParams);
|
||||||
|
|
||||||
// Cache for 7 days if successful
|
// Cache for 7 days if successful
|
||||||
@@ -908,6 +900,62 @@ public class JellyfinProxyService
|
|||||||
return url;
|
return url;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void LogUpstreamFailure(HttpMethod method, HttpStatusCode statusCode, string url, string? responseBody = null)
|
||||||
|
{
|
||||||
|
if (statusCode == HttpStatusCode.Unauthorized)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Jellyfin {Method} returned 401 for {Url} - client should re-authenticate",
|
||||||
|
method.Method, url);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLikelyBotProbe = BotProbeDetector.IsHighConfidenceProbeUrl(url);
|
||||||
|
|
||||||
|
if (statusCode == HttpStatusCode.NotFound)
|
||||||
|
{
|
||||||
|
if (isLikelyBotProbe)
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Likely bot probe returned 404 for {Url}", url);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogDebug("Jellyfin {Method} returned 404 for {Url}", method.Method, url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responsePreview = string.IsNullOrWhiteSpace(responseBody)
|
||||||
|
? null
|
||||||
|
: responseBody.Length > 200 ? responseBody[..200] + "..." : responseBody;
|
||||||
|
|
||||||
|
if (isLikelyBotProbe)
|
||||||
|
{
|
||||||
|
if (responsePreview == null)
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Likely bot probe returned {StatusCode} for {Url}", statusCode, url);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogWarning("Likely bot probe returned {StatusCode} for {Url}. Response: {Response}",
|
||||||
|
statusCode, url, responsePreview);
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (responsePreview == null)
|
||||||
|
{
|
||||||
|
_logger.LogError("Jellyfin {Method} request failed: {StatusCode} for {Url}",
|
||||||
|
method.Method, statusCode, url);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_logger.LogError("Jellyfin {Method} request failed: {StatusCode} for {Url}. Response: {Response}",
|
||||||
|
method.Method, statusCode, url, responsePreview);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sends a GET request to the Jellyfin server using the server's API key for internal operations.
|
/// Sends a GET request to the Jellyfin server using the server's API key for internal operations.
|
||||||
/// This should only be used for server-side operations, not for proxying client requests.
|
/// This should only be used for server-side operations, not for proxying client requests.
|
||||||
|
|||||||
@@ -294,7 +294,7 @@ public class JellyfinResponseBuilder
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Dictionary<string, object?> ConvertSongToJellyfinItem(Song song)
|
public Dictionary<string, object?> ConvertSongToJellyfinItem(Song song)
|
||||||
{
|
{
|
||||||
// Add " [S]" suffix to external song titles (S = streaming source)
|
// Add external/explicit labels to song titles for external tracks.
|
||||||
var songTitle = song.Title;
|
var songTitle = song.Title;
|
||||||
var artistName = song.Artist;
|
var artistName = song.Artist;
|
||||||
var albumName = song.Album;
|
var albumName = song.Album;
|
||||||
@@ -302,7 +302,7 @@ public class JellyfinResponseBuilder
|
|||||||
|
|
||||||
if (!song.IsLocal)
|
if (!song.IsLocal)
|
||||||
{
|
{
|
||||||
songTitle = $"{song.Title} [S]";
|
songTitle = BuildExternalSongTitle(song);
|
||||||
|
|
||||||
// Also add [S] to artist and album names for consistency
|
// Also add [S] to artist and album names for consistency
|
||||||
if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]"))
|
if (!string.IsNullOrEmpty(artistName) && !artistName.EndsWith(" [S]"))
|
||||||
@@ -502,6 +502,18 @@ public class JellyfinResponseBuilder
|
|||||||
return item;
|
return item;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static string BuildExternalSongTitle(Song song)
|
||||||
|
{
|
||||||
|
var title = $"{song.Title} [S]";
|
||||||
|
|
||||||
|
if (song.ExplicitContentLyrics == 1)
|
||||||
|
{
|
||||||
|
title = $"{title} [E]";
|
||||||
|
}
|
||||||
|
|
||||||
|
return title;
|
||||||
|
}
|
||||||
|
|
||||||
private static bool ShouldDisableTranscoding(string provider)
|
private static bool ShouldDisableTranscoding(string provider)
|
||||||
{
|
{
|
||||||
return provider.Equals("deezer", StringComparison.OrdinalIgnoreCase) ||
|
return provider.Equals("deezer", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
|||||||
@@ -190,6 +190,48 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Marks that an explicit playback stop was received for this device+item.
|
||||||
|
/// Used to suppress duplicate inferred stop forwarding from progress transitions.
|
||||||
|
/// </summary>
|
||||||
|
public void MarkExplicitStop(string deviceId, string itemId)
|
||||||
|
{
|
||||||
|
if (_sessions.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
|
session.LastExplicitStopItemId = itemId;
|
||||||
|
session.LastExplicitStopAtUtc = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Returns true when an explicit stop for this device+item was recorded within the given time window.
|
||||||
|
/// </summary>
|
||||||
|
public bool WasRecentlyExplicitlyStopped(string deviceId, string itemId, TimeSpan within)
|
||||||
|
{
|
||||||
|
if (_sessions.TryGetValue(deviceId, out var session))
|
||||||
|
{
|
||||||
|
lock (session.SyncRoot)
|
||||||
|
{
|
||||||
|
if (!string.Equals(session.LastExplicitStopItemId, itemId, StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!session.LastExplicitStopAtUtc.HasValue)
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (DateTime.UtcNow - session.LastExplicitStopAtUtc.Value) <= within;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns true if a local played-signal was already sent for this device+item.
|
/// Returns true if a local played-signal was already sent for this device+item.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -256,32 +298,16 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Marks a session as potentially ended (e.g., after playback stops).
|
/// Marks a session as potentially ended (e.g., after playback stops).
|
||||||
/// The session will be cleaned up if no new activity occurs within the timeout.
|
/// Jellyfin should decide when the upstream playback session expires.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
|
public void MarkSessionPotentiallyEnded(string deviceId, TimeSpan timeout)
|
||||||
{
|
{
|
||||||
if (_sessions.TryGetValue(deviceId, out var session))
|
if (_sessions.TryGetValue(deviceId, out _))
|
||||||
{
|
{
|
||||||
_logger.LogDebug("⏰ SESSION: Marking session {DeviceId} as potentially ended, will cleanup in {Seconds}s if no activity",
|
_logger.LogDebug(
|
||||||
deviceId, timeout.TotalSeconds);
|
"⏰ SESSION: Playback stopped for {DeviceId}; leaving upstream session lifetime to Jellyfin (timeout hint {Seconds}s ignored)",
|
||||||
|
deviceId,
|
||||||
_ = Task.Run(async () =>
|
timeout.TotalSeconds);
|
||||||
{
|
|
||||||
var markedTime = DateTime.UtcNow;
|
|
||||||
await Task.Delay(timeout);
|
|
||||||
|
|
||||||
// Check if there's been activity since we marked it
|
|
||||||
if (_sessions.TryGetValue(deviceId, out var currentSession) &&
|
|
||||||
currentSession.LastActivity <= markedTime)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("🧹 SESSION: Auto-removing inactive session {DeviceId} after playback stop", deviceId);
|
|
||||||
await RemoveSessionAsync(deviceId);
|
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
_logger.LogDebug("✓ SESSION: Session {DeviceId} had activity, keeping alive", deviceId);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -356,8 +382,7 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
deviceId, session.LastPlayingItemId, session.LastPlayingPositionTicks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Notify Jellyfin that the session is ending
|
// Let Jellyfin retire the session naturally; internal cleanup must not revoke the user's token.
|
||||||
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -410,6 +435,12 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}", deviceId);
|
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}", deviceId);
|
||||||
authFound = true;
|
authFound = true;
|
||||||
}
|
}
|
||||||
|
else if (sessionHeaders.TryGetValue("X-Emby-Token", out var token))
|
||||||
|
{
|
||||||
|
webSocket.Options.SetRequestHeader("X-Emby-Token", token.ToString());
|
||||||
|
_logger.LogDebug("🔑 WEBSOCKET: Using X-Emby-Token for {DeviceId}", deviceId);
|
||||||
|
authFound = true;
|
||||||
|
}
|
||||||
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
|
else if (sessionHeaders.TryGetValue("Authorization", out var auth))
|
||||||
{
|
{
|
||||||
var authValue = auth.ToString();
|
var authValue = auth.ToString();
|
||||||
@@ -643,6 +674,8 @@ public class JellyfinSessionManager : IDisposable
|
|||||||
public long? LastPlayingPositionTicks { get; set; }
|
public long? LastPlayingPositionTicks { get; set; }
|
||||||
public string? ClientIp { get; set; }
|
public string? ClientIp { get; set; }
|
||||||
public string? LastLocalPlayedSignalItemId { get; set; }
|
public string? LastLocalPlayedSignalItemId { get; set; }
|
||||||
|
public string? LastExplicitStopItemId { get; set; }
|
||||||
|
public DateTime? LastExplicitStopAtUtc { get; set; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
|
|||||||
@@ -101,7 +101,8 @@ public class ScrobblingHelper
|
|||||||
DurationSeconds = durationSeconds,
|
DurationSeconds = durationSeconds,
|
||||||
MusicBrainzId = musicBrainzId,
|
MusicBrainzId = musicBrainzId,
|
||||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||||
IsExternal = isExternal
|
IsExternal = isExternal,
|
||||||
|
StartPositionSeconds = 0
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -119,7 +120,8 @@ public class ScrobblingHelper
|
|||||||
string artist,
|
string artist,
|
||||||
string? album = null,
|
string? album = null,
|
||||||
string? albumArtist = null,
|
string? albumArtist = null,
|
||||||
int? durationSeconds = null)
|
int? durationSeconds = null,
|
||||||
|
int? startPositionSeconds = null)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist))
|
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist))
|
||||||
{
|
{
|
||||||
@@ -134,7 +136,8 @@ public class ScrobblingHelper
|
|||||||
AlbumArtist = albumArtist,
|
AlbumArtist = albumArtist,
|
||||||
DurationSeconds = durationSeconds,
|
DurationSeconds = durationSeconds,
|
||||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||||
IsExternal = true // Explicitly mark as external
|
IsExternal = true, // Explicitly mark as external
|
||||||
|
StartPositionSeconds = startPositionSeconds
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,6 +48,19 @@ public class ScrobblingOrchestrator
|
|||||||
{
|
{
|
||||||
if (!_settings.Enabled)
|
if (!_settings.Enabled)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
|
var existingSession = FindSession(deviceId, track.Artist, track.Title);
|
||||||
|
if (existingSession != null)
|
||||||
|
{
|
||||||
|
existingSession.LastActivity = DateTime.UtcNow;
|
||||||
|
_logger.LogDebug(
|
||||||
|
"Ignoring duplicate playback start for active session: {Artist} - {Track} (device: {DeviceId}, session: {SessionId})",
|
||||||
|
track.Artist,
|
||||||
|
track.Title,
|
||||||
|
deviceId,
|
||||||
|
existingSession.SessionId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var sessionId = $"{deviceId}:{track.Artist}:{track.Title}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
var sessionId = $"{deviceId}:{track.Artist}:{track.Title}:{DateTimeOffset.UtcNow.ToUnixTimeSeconds()}";
|
||||||
|
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ namespace allstarr.Services.Spotify;
|
|||||||
///
|
///
|
||||||
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
/// When ISRC is available, exact matching is preferred. Falls back to fuzzy matching.
|
||||||
///
|
///
|
||||||
/// CRON SCHEDULING: Each playlist has its own cron schedule. Matching only runs when the schedule triggers.
|
/// CRON SCHEDULING: Each playlist has its own cron schedule.
|
||||||
|
/// When a playlist schedule is due, we run the same per-playlist rebuild workflow
|
||||||
|
/// used by the manual per-playlist "Rebuild" button.
|
||||||
/// Manual refresh is always allowed. Cache persists until next cron run.
|
/// Manual refresh is always allowed. Cache persists until next cron run.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class SpotifyTrackMatchingService : BackgroundService
|
public class SpotifyTrackMatchingService : BackgroundService
|
||||||
@@ -82,7 +84,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||||
? "ISRC-preferred" : "fuzzy";
|
? "ISRC-preferred" : "fuzzy";
|
||||||
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
_logger.LogInformation("Matching mode: {Mode}", matchMode);
|
||||||
_logger.LogInformation("Cron-based scheduling: Each playlist has independent schedule");
|
_logger.LogInformation("Cron-based scheduling: each playlist runs independently");
|
||||||
|
|
||||||
// Log all playlist schedules
|
// Log all playlist schedules
|
||||||
foreach (var playlist in _spotifySettings.Playlists)
|
foreach (var playlist in _spotifySettings.Playlists)
|
||||||
@@ -112,8 +114,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
{
|
{
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Calculate next run time for each playlist
|
// Calculate next run time for each playlist.
|
||||||
|
// Use a small grace window so we don't miss exact-minute cron runs when waking slightly late.
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
|
var schedulerReference = now.AddMinutes(-1);
|
||||||
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
||||||
|
|
||||||
foreach (var playlist in _spotifySettings.Playlists)
|
foreach (var playlist in _spotifySettings.Playlists)
|
||||||
@@ -123,7 +127,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
var cron = CronExpression.Parse(schedule);
|
var cron = CronExpression.Parse(schedule);
|
||||||
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
|
var nextRun = cron.GetNextOccurrence(schedulerReference, TimeZoneInfo.Utc);
|
||||||
|
|
||||||
if (nextRun.HasValue)
|
if (nextRun.HasValue)
|
||||||
{
|
{
|
||||||
@@ -149,44 +153,62 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Find the next playlist that needs to run
|
// Run all playlists that are currently due.
|
||||||
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
|
var duePlaylists = nextRuns
|
||||||
var waitTime = nextPlaylist.NextRun - now;
|
.Where(x => x.NextRun <= now)
|
||||||
|
.OrderBy(x => x.NextRun)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
if (waitTime.TotalSeconds > 0)
|
if (duePlaylists.Count == 0)
|
||||||
{
|
{
|
||||||
|
// No playlist due yet: wait until the next scheduled run (or max 1 hour to re-check schedules)
|
||||||
|
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
|
||||||
|
var waitTime = nextPlaylist.NextRun - now;
|
||||||
|
|
||||||
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
_logger.LogInformation("Next scheduled run: {Playlist} at {Time} UTC (in {Minutes:F1} minutes)",
|
||||||
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||||
|
|
||||||
// Wait until next run (or max 1 hour to re-check schedules)
|
|
||||||
var maxWait = TimeSpan.FromHours(1);
|
var maxWait = TimeSpan.FromHours(1);
|
||||||
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
||||||
await Task.Delay(actualWait, stoppingToken);
|
await Task.Delay(actualWait, stoppingToken);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Time to run this playlist
|
_logger.LogInformation(
|
||||||
_logger.LogInformation("=== CRON TRIGGER: Running scheduled sync for {Playlist} ===", nextPlaylist.PlaylistName);
|
"=== CRON TRIGGER: Running scheduled rebuild for {Count} due playlists ===",
|
||||||
|
duePlaylists.Count);
|
||||||
|
|
||||||
// Check cooldown to prevent duplicate runs
|
var anySkippedForCooldown = false;
|
||||||
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
|
|
||||||
|
foreach (var due in duePlaylists)
|
||||||
{
|
{
|
||||||
var timeSinceLastRun = now - lastRun;
|
if (stoppingToken.IsCancellationRequested)
|
||||||
if (timeSinceLastRun < _minimumRunInterval)
|
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
break;
|
||||||
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
}
|
||||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
|
||||||
|
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
|
||||||
|
|
||||||
|
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||||
|
due.PlaylistName,
|
||||||
|
stoppingToken,
|
||||||
|
trigger: "cron");
|
||||||
|
|
||||||
|
if (!rebuilt)
|
||||||
|
{
|
||||||
|
anySkippedForCooldown = true;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_logger.LogInformation("✓ Finished scheduled rebuild for {Playlist} - Next run at {NextRun} UTC",
|
||||||
|
due.PlaylistName, due.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run full rebuild for this playlist (same as "Rebuild All Remote" button)
|
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
|
||||||
await RebuildSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
|
if (anySkippedForCooldown)
|
||||||
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
|
{
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||||
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
|
}
|
||||||
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
@@ -198,7 +220,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
|
/// Rebuilds a single playlist from scratch (clears cache, fetches fresh data, re-matches).
|
||||||
/// This is the unified method used by both cron scheduler and "Rebuild All Remote" button.
|
/// Used by individual per-playlist rebuild actions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -322,36 +344,64 @@ public class SpotifyTrackMatchingService : BackgroundService
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
|
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
|
||||||
/// This clears caches, fetches fresh data, and re-matches everything - same as cron job.
|
/// This clears caches, fetches fresh data, and re-matches everything immediately.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task TriggerRebuildAllAsync()
|
public async Task TriggerRebuildAllAsync()
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Manual full rebuild triggered for all playlists (same as cron job)");
|
_logger.LogInformation("Manual full rebuild triggered for all playlists");
|
||||||
await RebuildAllPlaylistsAsync(CancellationToken.None);
|
await RebuildAllPlaylistsAsync(CancellationToken.None);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
|
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
|
||||||
/// This clears cache, fetches fresh data, and re-matches - same as cron job.
|
/// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
|
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist} (same as cron job)", playlistName);
|
_logger.LogInformation("Manual full rebuild triggered for playlist: {Playlist}", playlistName);
|
||||||
|
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||||
|
playlistName,
|
||||||
|
CancellationToken.None,
|
||||||
|
trigger: "manual");
|
||||||
|
|
||||||
// Check cooldown to prevent abuse
|
if (!rebuilt)
|
||||||
|
{
|
||||||
|
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||||
|
{
|
||||||
|
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||||
|
var remaining = _minimumRunInterval - timeSinceLastRun;
|
||||||
|
var remainingSeconds = Math.Max(1, (int)Math.Ceiling(remaining.TotalSeconds));
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"Please wait {remainingSeconds} more seconds before rebuilding again");
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new InvalidOperationException("Playlist rebuild skipped due to cooldown");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<bool> TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||||
|
string playlistName,
|
||||||
|
CancellationToken cancellationToken,
|
||||||
|
string trigger)
|
||||||
|
{
|
||||||
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
if (_lastRunTimes.TryGetValue(playlistName, out var lastRun))
|
||||||
{
|
{
|
||||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||||
if (timeSinceLastRun < _minimumRunInterval)
|
if (timeSinceLastRun < _minimumRunInterval)
|
||||||
{
|
{
|
||||||
_logger.LogWarning("Skipping manual rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
_logger.LogWarning(
|
||||||
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||||
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before rebuilding again");
|
trigger,
|
||||||
|
playlistName,
|
||||||
|
(int)timeSinceLastRun.TotalSeconds,
|
||||||
|
(int)_minimumRunInterval.TotalSeconds);
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
await RebuildSinglePlaylistAsync(playlistName, CancellationToken.None);
|
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
|
||||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -100,8 +100,12 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
{
|
{
|
||||||
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
|
var downloadInfo = await GetTrackDownloadInfoAsync(trackId, cancellationToken);
|
||||||
|
|
||||||
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadInfo.DownloadUrl);
|
Logger.LogInformation(
|
||||||
Logger.LogInformation("Using format: {Format} (Quality: {Quality})", downloadInfo.MimeType, downloadInfo.AudioQuality);
|
"Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})",
|
||||||
|
downloadInfo.Endpoint,
|
||||||
|
downloadInfo.MimeType,
|
||||||
|
downloadInfo.AudioQuality);
|
||||||
|
Logger.LogDebug("Resolved SquidWTF CDN download URL: {Url}", downloadInfo.DownloadUrl);
|
||||||
|
|
||||||
// Determine extension from MIME type
|
// Determine extension from MIME type
|
||||||
var extension = downloadInfo.MimeType?.ToLower() switch
|
var extension = downloadInfo.MimeType?.ToLower() switch
|
||||||
@@ -127,65 +131,11 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
// Resolve unique path if file already exists
|
// Resolve unique path if file already exists
|
||||||
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
outputPath = PathHelper.ResolveUniquePath(outputPath);
|
||||||
|
|
||||||
// Use round-robin with fallback for downloads to reduce CPU usage
|
using var request = new HttpRequestMessage(HttpMethod.Get, downloadInfo.DownloadUrl);
|
||||||
Logger.LogDebug("Using round-robin endpoint selection for download");
|
request.Headers.Add("User-Agent", "Mozilla/5.0");
|
||||||
|
request.Headers.Add("Accept", "*/*");
|
||||||
var response = await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
|
||||||
{
|
using var response = await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
||||||
// Map quality settings to Tidal's quality levels per hifi-api spec
|
|
||||||
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
|
||||||
{
|
|
||||||
"FLAC" => "LOSSLESS",
|
|
||||||
"HI_RES" => "HI_RES_LOSSLESS",
|
|
||||||
"LOSSLESS" => "LOSSLESS",
|
|
||||||
"HIGH" => "HIGH",
|
|
||||||
"LOW" => "LOW",
|
|
||||||
_ => "LOSSLESS"
|
|
||||||
};
|
|
||||||
|
|
||||||
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
|
||||||
|
|
||||||
Logger.LogDebug("Requesting track download info: {Url}", url);
|
|
||||||
|
|
||||||
// Get download info from this endpoint
|
|
||||||
var infoResponse = await _httpClient.GetAsync(url, cancellationToken);
|
|
||||||
|
|
||||||
if (!infoResponse.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
Logger.LogWarning("Track download request failed: {StatusCode} {Url}", infoResponse.StatusCode, url);
|
|
||||||
infoResponse.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = await infoResponse.Content.ReadAsStringAsync(cancellationToken);
|
|
||||||
var doc = JsonDocument.Parse(json);
|
|
||||||
|
|
||||||
if (!doc.RootElement.TryGetProperty("data", out var data))
|
|
||||||
{
|
|
||||||
throw new Exception("Invalid response from API");
|
|
||||||
}
|
|
||||||
|
|
||||||
var manifestBase64 = data.GetProperty("manifest").GetString()
|
|
||||||
?? throw new Exception("No manifest in response");
|
|
||||||
|
|
||||||
// Decode base64 manifest to get actual CDN URL
|
|
||||||
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
|
|
||||||
var manifest = JsonDocument.Parse(manifestJson);
|
|
||||||
|
|
||||||
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
|
|
||||||
{
|
|
||||||
throw new Exception("No download URLs in manifest");
|
|
||||||
}
|
|
||||||
|
|
||||||
var downloadUrl = urls[0].GetString()
|
|
||||||
?? throw new Exception("Download URL is null");
|
|
||||||
|
|
||||||
// Start the actual download from Tidal CDN (no encryption - squid.wtf handles everything)
|
|
||||||
using var request = new HttpRequestMessage(HttpMethod.Get, downloadUrl);
|
|
||||||
request.Headers.Add("User-Agent", "Mozilla/5.0");
|
|
||||||
request.Headers.Add("Accept", "*/*");
|
|
||||||
|
|
||||||
return await _httpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken);
|
|
||||||
});
|
|
||||||
|
|
||||||
response.EnsureSuccessStatusCode();
|
response.EnsureSuccessStatusCode();
|
||||||
|
|
||||||
@@ -235,81 +185,139 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
/// The manifest is base64-encoded JSON containing: { mimeType, codecs, encryptionType, urls: [downloadUrl] }
|
/// The manifest is base64-encoded JSON containing: { mimeType, codecs, encryptionType, urls: [downloadUrl] }
|
||||||
/// Quality options: HI_RES_LOSSLESS (24-bit/192kHz FLAC), LOSSLESS (16-bit/44.1kHz FLAC), HIGH (320kbps AAC), LOW (96kbps AAC)
|
/// Quality options: HI_RES_LOSSLESS (24-bit/192kHz FLAC), LOSSLESS (16-bit/44.1kHz FLAC), HIGH (320kbps AAC), LOW (96kbps AAC)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
|
private async Task<DownloadResult> GetTrackDownloadInfoAsync(string trackId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return await QueueRequestAsync(async () =>
|
return await QueueRequestAsync(async () =>
|
||||||
{
|
{
|
||||||
// Use round-robin with fallback instead of racing to reduce CPU usage
|
Exception? lastException = null;
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
var qualityOrder = BuildQualityFallbackOrder(_squidwtfSettings.Quality);
|
||||||
|
|
||||||
|
foreach (var quality in qualityOrder)
|
||||||
{
|
{
|
||||||
// Map quality settings to Tidal's quality levels per hifi-api spec
|
try
|
||||||
var quality = _squidwtfSettings.Quality?.ToUpperInvariant() switch
|
|
||||||
{
|
{
|
||||||
"FLAC" => "LOSSLESS",
|
return await _fallbackHelper.TryWithFallbackAsync(baseUrl =>
|
||||||
"HI_RES" => "HI_RES_LOSSLESS",
|
FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken));
|
||||||
"LOSSLESS" => "LOSSLESS",
|
}
|
||||||
"HIGH" => "HIGH",
|
catch (Exception ex)
|
||||||
"LOW" => "LOW",
|
{
|
||||||
_ => "LOSSLESS" // Default to lossless
|
lastException = ex;
|
||||||
};
|
|
||||||
|
|
||||||
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
|
||||||
|
|
||||||
Logger.LogDebug("Fetching track download info from: {Url}", url);
|
if (!string.Equals(quality, qualityOrder[^1], StringComparison.Ordinal))
|
||||||
|
{
|
||||||
|
Logger.LogWarning(
|
||||||
|
"Track {TrackId} unavailable at SquidWTF quality {Quality}: {Error}. Trying lower quality",
|
||||||
|
trackId,
|
||||||
|
quality,
|
||||||
|
DescribeException(ex));
|
||||||
|
Logger.LogDebug(ex,
|
||||||
|
"Detailed SquidWTF quality failure for track {TrackId} at quality {Quality}",
|
||||||
|
trackId,
|
||||||
|
quality);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
throw lastException ?? new Exception($"Unable to fetch SquidWTF download info for track {trackId}");
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
|
||||||
Logger.LogWarning("Track download info request failed: {StatusCode} {Url}", response.StatusCode, url);
|
|
||||||
response.EnsureSuccessStatusCode();
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
||||||
var doc = JsonDocument.Parse(json);
|
|
||||||
|
|
||||||
if (!doc.RootElement.TryGetProperty("data", out var data))
|
|
||||||
{
|
|
||||||
throw new Exception("Invalid response from API");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the manifest (base64 encoded JSON containing the actual CDN URL)
|
|
||||||
var manifestBase64 = data.GetProperty("manifest").GetString()
|
|
||||||
?? throw new Exception("No manifest in response");
|
|
||||||
|
|
||||||
// Decode the manifest
|
|
||||||
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
|
|
||||||
var manifest = JsonDocument.Parse(manifestJson);
|
|
||||||
|
|
||||||
// Extract the download URL from the manifest
|
|
||||||
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
|
|
||||||
{
|
|
||||||
throw new Exception("No download URLs in manifest");
|
|
||||||
}
|
|
||||||
|
|
||||||
var downloadUrl = urls[0].GetString()
|
|
||||||
?? throw new Exception("Download URL is null");
|
|
||||||
|
|
||||||
var mimeType = manifest.RootElement.TryGetProperty("mimeType", out var mimeTypeEl)
|
|
||||||
? mimeTypeEl.GetString()
|
|
||||||
: "audio/flac";
|
|
||||||
|
|
||||||
var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl)
|
|
||||||
? audioQualityEl.GetString()
|
|
||||||
: "LOSSLESS";
|
|
||||||
|
|
||||||
Logger.LogInformation("Track download URL obtained from hifi-api: {Url}", downloadUrl);
|
|
||||||
|
|
||||||
return new DownloadResult
|
|
||||||
{
|
|
||||||
DownloadUrl = downloadUrl,
|
|
||||||
MimeType = mimeType ?? "audio/flac",
|
|
||||||
AudioQuality = audioQuality ?? "LOSSLESS"
|
|
||||||
};
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<DownloadResult> FetchTrackDownloadInfoAsync(
|
||||||
|
string baseUrl,
|
||||||
|
string trackId,
|
||||||
|
string quality,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
||||||
|
|
||||||
|
Logger.LogDebug("Fetching track download info from: {Url}", url);
|
||||||
|
|
||||||
|
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
response.EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
|
using var doc = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
if (!doc.RootElement.TryGetProperty("data", out var data))
|
||||||
|
{
|
||||||
|
throw new Exception("Invalid response from API");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the manifest (base64 encoded JSON containing the actual CDN URL)
|
||||||
|
var manifestBase64 = data.GetProperty("manifest").GetString()
|
||||||
|
?? throw new Exception("No manifest in response");
|
||||||
|
|
||||||
|
// Decode the manifest
|
||||||
|
var manifestJson = Encoding.UTF8.GetString(Convert.FromBase64String(manifestBase64));
|
||||||
|
using var manifest = JsonDocument.Parse(manifestJson);
|
||||||
|
|
||||||
|
// Extract the download URL from the manifest
|
||||||
|
if (!manifest.RootElement.TryGetProperty("urls", out var urls) || urls.GetArrayLength() == 0)
|
||||||
|
{
|
||||||
|
throw new Exception("No download URLs in manifest");
|
||||||
|
}
|
||||||
|
|
||||||
|
var downloadUrl = urls[0].GetString()
|
||||||
|
?? throw new Exception("Download URL is null");
|
||||||
|
|
||||||
|
var mimeType = manifest.RootElement.TryGetProperty("mimeType", out var mimeTypeEl)
|
||||||
|
? mimeTypeEl.GetString()
|
||||||
|
: "audio/flac";
|
||||||
|
|
||||||
|
var audioQuality = data.TryGetProperty("audioQuality", out var audioQualityEl)
|
||||||
|
? audioQualityEl.GetString()
|
||||||
|
: quality;
|
||||||
|
|
||||||
|
return new DownloadResult
|
||||||
|
{
|
||||||
|
Endpoint = baseUrl,
|
||||||
|
DownloadUrl = downloadUrl,
|
||||||
|
MimeType = mimeType ?? "audio/flac",
|
||||||
|
AudioQuality = audioQuality ?? quality
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<string> BuildQualityFallbackOrder(string? configuredQuality)
|
||||||
|
{
|
||||||
|
return NormalizeQuality(configuredQuality) switch
|
||||||
|
{
|
||||||
|
"HI_RES_LOSSLESS" => ["HI_RES_LOSSLESS", "LOSSLESS", "HIGH", "LOW"],
|
||||||
|
"LOSSLESS" => ["LOSSLESS", "HIGH", "LOW"],
|
||||||
|
"HIGH" => ["HIGH", "LOW"],
|
||||||
|
"LOW" => ["LOW"],
|
||||||
|
_ => ["LOSSLESS", "HIGH", "LOW"]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NormalizeQuality(string? configuredQuality)
|
||||||
|
{
|
||||||
|
return configuredQuality?.ToUpperInvariant() switch
|
||||||
|
{
|
||||||
|
"FLAC" => "LOSSLESS",
|
||||||
|
"HI_RES" => "HI_RES_LOSSLESS",
|
||||||
|
"HI_RES_LOSSLESS" => "HI_RES_LOSSLESS",
|
||||||
|
"LOSSLESS" => "LOSSLESS",
|
||||||
|
"HIGH" => "HIGH",
|
||||||
|
"LOW" => "LOW",
|
||||||
|
_ => "LOSSLESS"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string DescribeException(Exception ex)
|
||||||
|
{
|
||||||
|
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
|
||||||
|
{
|
||||||
|
var statusCode = (int)httpRequestException.StatusCode.Value;
|
||||||
|
return $"{statusCode}: {httpRequestException.StatusCode.Value}";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ex.Message;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -367,8 +375,9 @@ public class SquidWTFDownloadService : BaseDownloadService
|
|||||||
|
|
||||||
private class DownloadResult
|
private class DownloadResult
|
||||||
{
|
{
|
||||||
|
public string Endpoint { get; set; } = string.Empty;
|
||||||
public string DownloadUrl { get; set; } = string.Empty;
|
public string DownloadUrl { get; set; } = string.Empty;
|
||||||
public string MimeType { get; set; } = string.Empty;
|
public string MimeType { get; set; } = string.Empty;
|
||||||
public string AudioQuality { get; set; } = string.Empty;
|
public string AudioQuality { get; set; } = string.Empty;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -226,6 +226,10 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("SquidWTF song search response did not contain data.items");
|
||||||
|
}
|
||||||
return songs;
|
return songs;
|
||||||
}, new List<Song>());
|
}, new List<Song>());
|
||||||
}
|
}
|
||||||
@@ -263,6 +267,10 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("SquidWTF album search response did not contain data.albums.items");
|
||||||
|
}
|
||||||
|
|
||||||
return albums;
|
return albums;
|
||||||
}, new List<Album>());
|
}, new List<Album>());
|
||||||
@@ -288,6 +296,12 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
if (result.RootElement.TryGetProperty("detail", out _) ||
|
||||||
|
result.RootElement.TryGetProperty("error", out _))
|
||||||
|
{
|
||||||
|
throw new HttpRequestException("API returned error response");
|
||||||
|
}
|
||||||
|
|
||||||
var artists = new List<Artist>();
|
var artists = new List<Artist>();
|
||||||
// Per hifi-api spec: artist search returns data.artists.items array
|
// Per hifi-api spec: artist search returns data.artists.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
@@ -305,6 +319,10 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
count++;
|
count++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("SquidWTF artist search response did not contain data.artists.items");
|
||||||
|
}
|
||||||
|
|
||||||
return artists;
|
return artists;
|
||||||
}, new List<Artist>());
|
}, new List<Artist>());
|
||||||
@@ -345,11 +363,20 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
// Per hifi-api spec: use 'p' parameter for playlist search
|
// Per hifi-api spec: use 'p' parameter for playlist search
|
||||||
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
|
var url = $"{baseUrl}/search/?p={Uri.EscapeDataString(query)}";
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode) return new List<ExternalPlaylist>();
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
if (result.RootElement.TryGetProperty("detail", out _) ||
|
||||||
|
result.RootElement.TryGetProperty("error", out _))
|
||||||
|
{
|
||||||
|
throw new HttpRequestException("API returned error response");
|
||||||
|
}
|
||||||
|
|
||||||
var playlists = new List<ExternalPlaylist>();
|
var playlists = new List<ExternalPlaylist>();
|
||||||
// Per hifi-api spec: playlist search returns data.playlists.items array
|
// Per hifi-api spec: playlist search returns data.playlists.items array
|
||||||
if (result.RootElement.TryGetProperty("data", out var data) &&
|
if (result.RootElement.TryGetProperty("data", out var data) &&
|
||||||
@@ -370,9 +397,13 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
{
|
{
|
||||||
_logger.LogWarning(ex, "Failed to parse playlist, skipping");
|
_logger.LogWarning(ex, "Failed to parse playlist, skipping");
|
||||||
// Skip this playlist and continue with others
|
// Skip this playlist and continue with others
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("SquidWTF playlist search response did not contain data.playlists.items");
|
||||||
|
}
|
||||||
return playlists;
|
return playlists;
|
||||||
}, new List<ExternalPlaylist>());
|
}, new List<ExternalPlaylist>());
|
||||||
}
|
}
|
||||||
@@ -406,14 +437,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
var url = $"{baseUrl}/info/?id={externalId}";
|
var url = $"{baseUrl}/info/?id={externalId}";
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
// Per hifi-api spec: response is { "version": "2.0", "data": { track object } }
|
// Per hifi-api spec: response is { "version": "2.0", "data": { track object } }
|
||||||
if (!result.RootElement.TryGetProperty("data", out var track))
|
if (!result.RootElement.TryGetProperty("data", out var track))
|
||||||
return null;
|
{
|
||||||
|
throw new InvalidOperationException($"SquidWTF /info response for track {externalId} did not contain data");
|
||||||
|
}
|
||||||
|
|
||||||
var song = ParseTidalTrackFull(track);
|
var song = ParseTidalTrackFull(track);
|
||||||
|
|
||||||
@@ -445,84 +481,96 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(externalId)) return new List<Song>();
|
if (string.IsNullOrWhiteSpace(externalId)) return new List<Song>();
|
||||||
|
|
||||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
return await _fallbackHelper.TryWithFallbackAsync(
|
||||||
{
|
async (baseUrl) =>
|
||||||
var url = $"{baseUrl}/recommendations/?id={Uri.EscapeDataString(externalId)}";
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
|
||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug("SquidWTF recommendations request failed for track {TrackId} with status {StatusCode}",
|
var url = $"{baseUrl}/recommendations/?id={Uri.EscapeDataString(externalId)}";
|
||||||
externalId, response.StatusCode);
|
if (limit > 0)
|
||||||
return new List<Song>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
|
||||||
var result = JsonDocument.Parse(json);
|
|
||||||
|
|
||||||
if (!result.RootElement.TryGetProperty("data", out var data) ||
|
|
||||||
!data.TryGetProperty("items", out var items) ||
|
|
||||||
items.ValueKind != JsonValueKind.Array)
|
|
||||||
{
|
|
||||||
return new List<Song>();
|
|
||||||
}
|
|
||||||
|
|
||||||
var songs = new List<Song>();
|
|
||||||
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
foreach (var recommendation in items.EnumerateArray())
|
|
||||||
{
|
|
||||||
JsonElement track;
|
|
||||||
if (recommendation.TryGetProperty("track", out var wrappedTrack))
|
|
||||||
{
|
{
|
||||||
track = wrappedTrack;
|
url += $"&limit={limit}";
|
||||||
}
|
|
||||||
else
|
|
||||||
{
|
|
||||||
track = recommendation;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!track.TryGetProperty("id", out _))
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
|
|
||||||
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
continue;
|
throw new HttpRequestException(
|
||||||
|
$"SquidWTF recommendations request failed for track {externalId} with status {response.StatusCode}");
|
||||||
}
|
}
|
||||||
|
|
||||||
Song song;
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
try
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
|
if (!result.RootElement.TryGetProperty("data", out var data) ||
|
||||||
|
!data.TryGetProperty("items", out var items) ||
|
||||||
|
items.ValueKind != JsonValueKind.Array)
|
||||||
{
|
{
|
||||||
song = ParseTidalTrack(track);
|
throw new InvalidOperationException(
|
||||||
}
|
$"SquidWTF recommendations response for track {externalId} did not contain data.items");
|
||||||
catch
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(song.ExternalId, externalId, StringComparison.OrdinalIgnoreCase))
|
var songs = new List<Song>();
|
||||||
|
var seenIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
foreach (var recommendation in items.EnumerateArray())
|
||||||
{
|
{
|
||||||
continue;
|
JsonElement track;
|
||||||
|
if (recommendation.TryGetProperty("track", out var wrappedTrack))
|
||||||
|
{
|
||||||
|
track = wrappedTrack;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
track = recommendation;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!track.TryGetProperty("id", out _))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Song song;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
song = ParseTidalTrack(track);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.Equals(song.ExternalId, externalId, StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
var songKey = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
|
||||||
|
if (string.IsNullOrWhiteSpace(songKey) || !seenIds.Add(songKey))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ShouldIncludeSong(song))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
songs.Add(song);
|
||||||
|
if (songs.Count >= limit)
|
||||||
|
{
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var songKey = !string.IsNullOrWhiteSpace(song.ExternalId) ? song.ExternalId : song.Id;
|
_logger.LogDebug(
|
||||||
if (string.IsNullOrWhiteSpace(songKey) || !seenIds.Add(songKey))
|
"SQUIDWTF: Recommendations returned {Count} songs for track {TrackId} from {BaseUrl}",
|
||||||
{
|
songs.Count,
|
||||||
continue;
|
externalId,
|
||||||
}
|
baseUrl);
|
||||||
|
return songs;
|
||||||
if (!ShouldIncludeSong(song))
|
},
|
||||||
{
|
songs => songs.Count > 0,
|
||||||
continue;
|
new List<Song>());
|
||||||
}
|
|
||||||
|
|
||||||
songs.Add(song);
|
|
||||||
if (songs.Count >= limit)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
_logger.LogDebug("SQUIDWTF: Recommendations returned {Count} songs for track {TrackId}", songs.Count, externalId);
|
|
||||||
return songs;
|
|
||||||
}, new List<Song>());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
public async Task<Album?> GetAlbumAsync(string externalProvider, string externalId, CancellationToken cancellationToken = default)
|
||||||
@@ -540,14 +588,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
var url = $"{baseUrl}/album/?id={externalId}";
|
var url = $"{baseUrl}/album/?id={externalId}";
|
||||||
|
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
var result = JsonDocument.Parse(json);
|
var result = JsonDocument.Parse(json);
|
||||||
|
|
||||||
// Response structure: { "data": { album object with "items" array of tracks } }
|
// Response structure: { "data": { album object with "items" array of tracks } }
|
||||||
if (!result.RootElement.TryGetProperty("data", out var albumElement))
|
if (!result.RootElement.TryGetProperty("data", out var albumElement))
|
||||||
return null;
|
{
|
||||||
|
throw new InvalidOperationException($"SquidWTF /album response for album {externalId} did not contain data");
|
||||||
|
}
|
||||||
|
|
||||||
var album = ParseTidalAlbum(albumElement);
|
var album = ParseTidalAlbum(albumElement);
|
||||||
|
|
||||||
@@ -599,8 +652,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogError("SquidWTF artist request failed with status {StatusCode}", response.StatusCode);
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
@@ -637,9 +689,9 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
if (artistSource == null)
|
if (artistSource == null)
|
||||||
{
|
{
|
||||||
_logger.LogDebug("Could not find artist data in response. Response keys: {Keys}",
|
var keys = string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name));
|
||||||
string.Join(", ", result.RootElement.EnumerateObject().Select(p => p.Name)));
|
throw new InvalidOperationException(
|
||||||
return null;
|
$"SquidWTF artist response for {externalId} did not contain artist data. Keys: {keys}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var artistElement = artistSource.Value;
|
var artistElement = artistSource.Value;
|
||||||
@@ -687,8 +739,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogError("SquidWTF artist albums request failed with status {StatusCode}", response.StatusCode);
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
return new List<Album>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
@@ -712,7 +763,8 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No albums found in response for artist {ExternalId}", externalId);
|
throw new InvalidOperationException(
|
||||||
|
$"SquidWTF artist albums response for {externalId} did not contain albums.items");
|
||||||
}
|
}
|
||||||
|
|
||||||
return albums;
|
return albums;
|
||||||
@@ -734,8 +786,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
|
|
||||||
if (!response.IsSuccessStatusCode)
|
if (!response.IsSuccessStatusCode)
|
||||||
{
|
{
|
||||||
_logger.LogError("SquidWTF artist tracks request failed with status {StatusCode}", response.StatusCode);
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
return new List<Song>();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
@@ -756,7 +807,8 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("No tracks found in response for artist {ExternalId}", externalId);
|
throw new InvalidOperationException(
|
||||||
|
$"SquidWTF artist tracks response for {externalId} did not contain tracks");
|
||||||
}
|
}
|
||||||
|
|
||||||
return tracks;
|
return tracks;
|
||||||
@@ -772,18 +824,26 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
||||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
var url = $"{baseUrl}/playlist/?id={externalId}";
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode) return null;
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
var rootElement = JsonDocument.Parse(json).RootElement;
|
var rootElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
// Check for error response
|
// Check for error response
|
||||||
if (rootElement.TryGetProperty("error", out _)) return null;
|
if (rootElement.TryGetProperty("error", out _))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"SquidWTF playlist response for {externalId} contained an error payload");
|
||||||
|
}
|
||||||
|
|
||||||
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
|
// Response structure: { "playlist": { playlist object }, "items": [ track wrappers ] }
|
||||||
// Extract the playlist object from the response
|
// Extract the playlist object from the response
|
||||||
if (!rootElement.TryGetProperty("playlist", out var playlistElement))
|
if (!rootElement.TryGetProperty("playlist", out var playlistElement))
|
||||||
return null;
|
{
|
||||||
|
throw new InvalidOperationException($"SquidWTF playlist response for {externalId} did not contain playlist");
|
||||||
|
}
|
||||||
|
|
||||||
return ParseTidalPlaylist(playlistElement);
|
return ParseTidalPlaylist(playlistElement);
|
||||||
}, (ExternalPlaylist?)null);
|
}, (ExternalPlaylist?)null);
|
||||||
@@ -798,13 +858,19 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
// Note: hifi-api doesn't document playlist endpoint, but /playlist/?id={playlistId} is commonly used
|
||||||
var url = $"{baseUrl}/playlist/?id={externalId}";
|
var url = $"{baseUrl}/playlist/?id={externalId}";
|
||||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||||
if (!response.IsSuccessStatusCode) return new List<Song>();
|
if (!response.IsSuccessStatusCode)
|
||||||
|
{
|
||||||
|
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||||
|
}
|
||||||
|
|
||||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||||
var playlistElement = JsonDocument.Parse(json).RootElement;
|
var playlistElement = JsonDocument.Parse(json).RootElement;
|
||||||
|
|
||||||
// Check for error response
|
// Check for error response
|
||||||
if (playlistElement.TryGetProperty("error", out _)) return new List<Song>();
|
if (playlistElement.TryGetProperty("error", out _))
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException($"SquidWTF playlist tracks response for {externalId} contained an error payload");
|
||||||
|
}
|
||||||
|
|
||||||
JsonElement? playlist = null;
|
JsonElement? playlist = null;
|
||||||
JsonElement? tracks = null;
|
JsonElement? tracks = null;
|
||||||
@@ -820,6 +886,12 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
|||||||
tracks = tracksEl;
|
tracks = tracksEl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!tracks.HasValue)
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException(
|
||||||
|
$"SquidWTF playlist tracks response for {externalId} did not contain items");
|
||||||
|
}
|
||||||
|
|
||||||
var songs = new List<Song>();
|
var songs = new List<Song>();
|
||||||
|
|
||||||
// Get playlist name for album field
|
// Get playlist name for album field
|
||||||
|
|||||||
@@ -8,7 +8,8 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"Debug": {
|
"Debug": {
|
||||||
"LogAllRequests": false
|
"LogAllRequests": false,
|
||||||
|
"RedactSensitiveRequestValues": false
|
||||||
},
|
},
|
||||||
"Backend": {
|
"Backend": {
|
||||||
"Type": "Subsonic"
|
"Type": "Subsonic"
|
||||||
@@ -65,7 +66,7 @@
|
|||||||
"ConnectionString": "localhost:6379"
|
"ConnectionString": "localhost:6379"
|
||||||
},
|
},
|
||||||
"Cache": {
|
"Cache": {
|
||||||
"SearchResultsMinutes": 120,
|
"SearchResultsMinutes": 1,
|
||||||
"PlaylistImagesHours": 168,
|
"PlaylistImagesHours": 168,
|
||||||
"SpotifyPlaylistItemsHours": 168,
|
"SpotifyPlaylistItemsHours": 168,
|
||||||
"SpotifyMatchedTracksDays": 30,
|
"SpotifyMatchedTracksDays": 30,
|
||||||
|
|||||||
@@ -393,6 +393,11 @@
|
|||||||
<span class="value" id="local-tracks-enabled-value">-</span>
|
<span class="value" id="local-tracks-enabled-value">-</span>
|
||||||
<button onclick="toggleLocalTracksEnabled()">Toggle</button>
|
<button onclick="toggleLocalTracksEnabled()">Toggle</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="config-item">
|
||||||
|
<span class="label">Synthetic Local Played Signal</span>
|
||||||
|
<span class="value" id="synthetic-local-played-signal-enabled-value">-</span>
|
||||||
|
<button onclick="toggleSyntheticLocalPlayedSignalEnabled()">Toggle</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style="background: rgba(255, 193, 7, 0.15); border: 1px solid #ffc107; border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
|
<div style="background: rgba(255, 193, 7, 0.15); border: 1px solid #ffc107; border-radius: 6px; padding: 12px; margin-bottom: 16px; color: var(--text-primary);">
|
||||||
@@ -400,6 +405,7 @@
|
|||||||
<br>• <a href="https://github.com/danielfariati/jellyfin-plugin-lastfm" target="_blank" style="color: var(--accent);">Last.fm Plugin</a>
|
<br>• <a href="https://github.com/danielfariati/jellyfin-plugin-lastfm" target="_blank" style="color: var(--accent);">Last.fm Plugin</a>
|
||||||
<br>• <a href="https://github.com/lyarenei/jellyfin-plugin-listenbrainz" target="_blank" style="color: var(--accent);">ListenBrainz Plugin</a>
|
<br>• <a href="https://github.com/lyarenei/jellyfin-plugin-listenbrainz" target="_blank" style="color: var(--accent);">ListenBrainz Plugin</a>
|
||||||
<br>This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz).
|
<br>This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz).
|
||||||
|
<br><strong>Default:</strong> keep Synthetic Local Played Signal disabled to avoid duplicate plugin scrobbles.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ async function refreshPlaylist(name) {
|
|||||||
|
|
||||||
async function clearPlaylistCache(name) {
|
async function clearPlaylistCache(name) {
|
||||||
const result = await runAction({
|
const result = await runAction({
|
||||||
confirmMessage: `Rebuild "${name}" from scratch?\n\nThis will:\n• Clear all caches\n• Fetch fresh Spotify playlist data\n• Re-match all tracks\n\nThis is the SAME process as the scheduled cron job.\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`,
|
confirmMessage: `Rebuild "${name}" from scratch?\n\nThis will:\n• Clear all caches\n• Fetch fresh Spotify playlist data\n• Re-match all tracks\n\nThis uses the same workflow as that playlist's scheduled cron rebuild.\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`,
|
||||||
before: async () => {
|
before: async () => {
|
||||||
setMatchingBannerVisible(true);
|
setMatchingBannerVisible(true);
|
||||||
showToast(`Rebuilding ${name} from scratch...`, "info");
|
showToast(`Rebuilding ${name} from scratch...`, "info");
|
||||||
@@ -208,10 +208,10 @@ async function matchAllPlaylists() {
|
|||||||
async function refreshAndMatchAll() {
|
async function refreshAndMatchAll() {
|
||||||
const result = await runAction({
|
const result = await runAction({
|
||||||
confirmMessage:
|
confirmMessage:
|
||||||
"Rebuild all playlists from scratch?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Re-match all tracks against local library and external providers\n\nThis is the SAME process as the scheduled cron job.\n\nThis may take several minutes.",
|
"Rebuild all playlists from scratch?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Re-match all tracks against local library and external providers\n\nThis is a manual bulk rebuild across all playlists.\n\nThis may take several minutes.",
|
||||||
before: async () => {
|
before: async () => {
|
||||||
setMatchingBannerVisible(true);
|
setMatchingBannerVisible(true);
|
||||||
showToast("Starting full rebuild (same as cron job)...", "info", 3000);
|
showToast("Starting full rebuild for all playlists...", "info", 3000);
|
||||||
},
|
},
|
||||||
task: () => API.rebuildAllPlaylists(),
|
task: () => API.rebuildAllPlaylists(),
|
||||||
success: "✓ Full rebuild complete!",
|
success: "✓ Full rebuild complete!",
|
||||||
|
|||||||
@@ -70,6 +70,12 @@ async function loadScrobblingConfig() {
|
|||||||
? "Enabled"
|
? "Enabled"
|
||||||
: "Disabled";
|
: "Disabled";
|
||||||
|
|
||||||
|
document.getElementById(
|
||||||
|
"synthetic-local-played-signal-enabled-value",
|
||||||
|
).textContent = data.scrobbling.syntheticLocalPlayedSignalEnabled
|
||||||
|
? "Enabled"
|
||||||
|
: "Disabled";
|
||||||
|
|
||||||
document.getElementById("lastfm-enabled-value").textContent = data
|
document.getElementById("lastfm-enabled-value").textContent = data
|
||||||
.scrobbling.lastFm.enabled
|
.scrobbling.lastFm.enabled
|
||||||
? "Enabled"
|
? "Enabled"
|
||||||
@@ -206,6 +212,14 @@ async function toggleLocalTracksEnabled() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleSyntheticLocalPlayedSignalEnabled() {
|
||||||
|
await toggleScrobblingSetting(
|
||||||
|
"SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED",
|
||||||
|
"Synthetic local played signal",
|
||||||
|
(config) => config?.scrobbling?.syntheticLocalPlayedSignalEnabled,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleLastFmEnabled() {
|
async function toggleLastFmEnabled() {
|
||||||
await toggleScrobblingSetting(
|
await toggleScrobblingSetting(
|
||||||
"SCROBBLING_LASTFM_ENABLED",
|
"SCROBBLING_LASTFM_ENABLED",
|
||||||
@@ -332,6 +346,8 @@ export function initScrobblingAdmin(options) {
|
|||||||
window.loadScrobblingConfig = loadScrobblingConfig;
|
window.loadScrobblingConfig = loadScrobblingConfig;
|
||||||
window.toggleScrobblingEnabled = toggleScrobblingEnabled;
|
window.toggleScrobblingEnabled = toggleScrobblingEnabled;
|
||||||
window.toggleLocalTracksEnabled = toggleLocalTracksEnabled;
|
window.toggleLocalTracksEnabled = toggleLocalTracksEnabled;
|
||||||
|
window.toggleSyntheticLocalPlayedSignalEnabled =
|
||||||
|
toggleSyntheticLocalPlayedSignalEnabled;
|
||||||
window.toggleLastFmEnabled = toggleLastFmEnabled;
|
window.toggleLastFmEnabled = toggleLastFmEnabled;
|
||||||
window.toggleListenBrainzEnabled = toggleListenBrainzEnabled;
|
window.toggleListenBrainzEnabled = toggleListenBrainzEnabled;
|
||||||
window.editLastFmUsername = editLastFmUsername;
|
window.editLastFmUsername = editLastFmUsername;
|
||||||
|
|||||||
+4
-1
@@ -77,7 +77,7 @@ services:
|
|||||||
- Redis__Enabled=${REDIS_ENABLED:-true}
|
- Redis__Enabled=${REDIS_ENABLED:-true}
|
||||||
|
|
||||||
# ===== CACHE TTL SETTINGS =====
|
# ===== CACHE TTL SETTINGS =====
|
||||||
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-120}
|
- Cache__SearchResultsMinutes=${CACHE_SEARCH_RESULTS_MINUTES:-1}
|
||||||
- Cache__PlaylistImagesHours=${CACHE_PLAYLIST_IMAGES_HOURS:-168}
|
- Cache__PlaylistImagesHours=${CACHE_PLAYLIST_IMAGES_HOURS:-168}
|
||||||
- Cache__SpotifyPlaylistItemsHours=${CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS:-168}
|
- Cache__SpotifyPlaylistItemsHours=${CACHE_SPOTIFY_PLAYLIST_ITEMS_HOURS:-168}
|
||||||
- Cache__SpotifyMatchedTracksDays=${CACHE_SPOTIFY_MATCHED_TRACKS_DAYS:-30}
|
- Cache__SpotifyMatchedTracksDays=${CACHE_SPOTIFY_MATCHED_TRACKS_DAYS:-30}
|
||||||
@@ -134,6 +134,8 @@ services:
|
|||||||
|
|
||||||
# ===== SCROBBLING (LAST.FM, LISTENBRAINZ) =====
|
# ===== SCROBBLING (LAST.FM, LISTENBRAINZ) =====
|
||||||
- Scrobbling__Enabled=${SCROBBLING_ENABLED:-false}
|
- Scrobbling__Enabled=${SCROBBLING_ENABLED:-false}
|
||||||
|
- Scrobbling__LocalTracksEnabled=${SCROBBLING_LOCAL_TRACKS_ENABLED:-false}
|
||||||
|
- Scrobbling__SyntheticLocalPlayedSignalEnabled=${SCROBBLING_SYNTHETIC_LOCAL_PLAYED_SIGNAL_ENABLED:-false}
|
||||||
- Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false}
|
- Scrobbling__LastFm__Enabled=${SCROBBLING_LASTFM_ENABLED:-false}
|
||||||
- Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
|
- Scrobbling__LastFm__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
|
||||||
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
|
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
|
||||||
@@ -145,6 +147,7 @@ services:
|
|||||||
|
|
||||||
# ===== DEBUG SETTINGS =====
|
# ===== DEBUG SETTINGS =====
|
||||||
- Debug__LogAllRequests=${DEBUG_LOG_ALL_REQUESTS:-false}
|
- Debug__LogAllRequests=${DEBUG_LOG_ALL_REQUESTS:-false}
|
||||||
|
- Debug__RedactSensitiveRequestValues=${DEBUG_REDACT_SENSITIVE_REQUEST_VALUES:-false}
|
||||||
|
|
||||||
# ===== SHARED =====
|
# ===== SHARED =====
|
||||||
- Library__DownloadPath=/app/downloads
|
- Library__DownloadPath=/app/downloads
|
||||||
|
|||||||
Reference in New Issue
Block a user