mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
v1.3.0-beta.1: Fixed double scrobbling, inferring stops much better, fixed playlist cron rebuilding, stale injected playlist artwork, and search cache TTL
This commit is contained in:
+10
-1
@@ -263,6 +263,11 @@ SCROBBLING_ENABLED=false
|
||||
# This ensures Allstarr only scrobbles external tracks (Spotify, Deezer, Qobuz)
|
||||
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 =====
|
||||
# Enable Last.fm scrobbling (default: false)
|
||||
SCROBBLING_LASTFM_ENABLED=false
|
||||
@@ -301,7 +306,11 @@ SCROBBLING_LISTENBRAINZ_USER_TOKEN=
|
||||
# Enable detailed request logging (default: false)
|
||||
# When enabled, logs every incoming HTTP request with full details:
|
||||
# - Method, path, query string
|
||||
# - Headers (auth tokens are masked)
|
||||
# - Headers
|
||||
# - Response status and timing
|
||||
# Useful for debugging client issues and seeing what API calls are being made
|
||||
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
|
||||
|
||||
@@ -338,6 +338,30 @@ public class JellyfinProxyServiceTests
|
||||
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]
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </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)),
|
||||
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
|
||||
{
|
||||
@@ -216,7 +220,7 @@ public class ConfigController : ControllerBase
|
||||
},
|
||||
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)),
|
||||
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)),
|
||||
@@ -335,6 +339,8 @@ public class ConfigController : ControllerBase
|
||||
return new
|
||||
{
|
||||
enabled = _scrobblingSettings.Enabled,
|
||||
localTracksEnabled = _scrobblingSettings.LocalTracksEnabled,
|
||||
syntheticLocalPlayedSignalEnabled = _scrobblingSettings.SyntheticLocalPlayedSignalEnabled,
|
||||
lastFm = new
|
||||
{
|
||||
enabled = _scrobblingSettings.LastFm.Enabled,
|
||||
@@ -372,6 +378,12 @@ public class ConfigController : ControllerBase
|
||||
enabled = envVars.TryGetValue("SCROBBLING_ENABLED", out var scrobblingEnabled)
|
||||
? scrobblingEnabled.Equals("true", StringComparison.OrdinalIgnoreCase)
|
||||
: _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
|
||||
{
|
||||
enabled = envVars.TryGetValue("SCROBBLING_LASTFM_ENABLED", out var lastFmEnabled)
|
||||
@@ -411,6 +423,8 @@ public class ConfigController : ControllerBase
|
||||
return new
|
||||
{
|
||||
enabled = _scrobblingSettings.Enabled,
|
||||
localTracksEnabled = _scrobblingSettings.LocalTracksEnabled,
|
||||
syntheticLocalPlayedSignalEnabled = _scrobblingSettings.SyntheticLocalPlayedSignalEnabled,
|
||||
lastFm = new
|
||||
{
|
||||
enabled = _scrobblingSettings.LastFm.Enabled,
|
||||
|
||||
@@ -83,14 +83,14 @@ public partial class JellyfinController
|
||||
|
||||
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
|
||||
var playlistConfig = _spotifySettings.GetPlaylistByJellyfinId(playlistId);
|
||||
|
||||
if (playlistConfig != null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
_logger.LogDebug(
|
||||
"Found playlist config for Jellyfin ID {JellyfinId}: {Name} (Spotify ID: {SpotifyId})",
|
||||
playlistId, playlistConfig.Name, playlistConfig.Id);
|
||||
var playlistName = playlistConfig.Name;
|
||||
@@ -396,6 +396,48 @@ public partial class JellyfinController
|
||||
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>
|
||||
/// Recovers SearchTerm directly from raw query string.
|
||||
/// Handles malformed clients that do not URL-encode '&' inside SearchTerm.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text.Json;
|
||||
using System.Globalization;
|
||||
using allstarr.Models.Scrobbling;
|
||||
@@ -7,6 +8,11 @@ namespace allstarr.Controllers;
|
||||
|
||||
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 Session Management
|
||||
@@ -53,22 +59,28 @@ public partial class JellyfinController
|
||||
_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)
|
||||
{
|
||||
_logger.LogDebug("✓ Session capabilities forwarded to Jellyfin ({StatusCode})", statusCode);
|
||||
}
|
||||
else if (statusCode == 401)
|
||||
{
|
||||
_logger.LogWarning("⚠ Jellyfin returned 401 for capabilities (token expired)");
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogWarning("⚠ Jellyfin returned {StatusCode} for capabilities", statusCode);
|
||||
return NoContent();
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
@@ -104,6 +116,7 @@ public partial class JellyfinController
|
||||
string? itemId = null;
|
||||
string? itemName = null;
|
||||
long? positionTicks = null;
|
||||
string? playSessionId = null;
|
||||
|
||||
itemId = ParsePlaybackItemId(doc.RootElement);
|
||||
|
||||
@@ -113,6 +126,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
|
||||
playSessionId = ParsePlaybackSessionId(doc.RootElement);
|
||||
|
||||
// Track the playing item for scrobbling on session cleanup (local tracks only)
|
||||
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
|
||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||
var trackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown";
|
||||
@@ -242,7 +273,8 @@ public partial class JellyfinController
|
||||
artist: song.Artist,
|
||||
album: song.Album,
|
||||
albumArtist: song.AlbumArtist,
|
||||
durationSeconds: song.Duration
|
||||
durationSeconds: song.Duration,
|
||||
startPositionSeconds: ToPlaybackPositionSeconds(positionTicks)
|
||||
);
|
||||
|
||||
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
|
||||
_logger.LogDebug("Forwarding playback start to Jellyfin...");
|
||||
|
||||
@@ -439,9 +489,11 @@ public partial class JellyfinController
|
||||
var doc = JsonDocument.Parse(body);
|
||||
string? itemId = null;
|
||||
long? positionTicks = null;
|
||||
string? playSessionId = null;
|
||||
|
||||
itemId = ParsePlaybackItemId(doc.RootElement);
|
||||
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
|
||||
playSessionId = ParsePlaybackSessionId(doc.RootElement);
|
||||
|
||||
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
|
||||
|
||||
@@ -482,7 +534,8 @@ public partial class JellyfinController
|
||||
artist: song.Artist,
|
||||
album: song.Album,
|
||||
albumArtist: song.AlbumArtist,
|
||||
durationSeconds: song.Duration
|
||||
durationSeconds: song.Duration,
|
||||
startPositionSeconds: ToPlaybackPositionSeconds(positionTicks)
|
||||
);
|
||||
}
|
||||
else
|
||||
@@ -541,9 +594,11 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId);
|
||||
var inferredStop = !string.IsNullOrWhiteSpace(previousItemId) &&
|
||||
var inferredStop = sessionReady &&
|
||||
!string.IsNullOrWhiteSpace(previousItemId) &&
|
||||
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
||||
var inferredStart = !string.IsNullOrWhiteSpace(itemId) &&
|
||||
var inferredStart = sessionReady &&
|
||||
!string.IsNullOrWhiteSpace(itemId) &&
|
||||
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
||||
|
||||
if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId))
|
||||
@@ -557,7 +612,8 @@ public partial class JellyfinController
|
||||
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||
}
|
||||
|
||||
if (inferredStart)
|
||||
if (inferredStart &&
|
||||
!ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
|
||||
{
|
||||
var song = await _metadataService.GetSongAsync(provider!, externalId!);
|
||||
var externalTrackName = song != null ? $"{song.Artist} - {song.Title}" : "Unknown";
|
||||
@@ -601,7 +657,8 @@ public partial class JellyfinController
|
||||
artist: song.Artist,
|
||||
album: song.Album,
|
||||
albumArtist: song.AlbumArtist,
|
||||
durationSeconds: song.Duration);
|
||||
durationSeconds: song.Duration,
|
||||
startPositionSeconds: ToPlaybackPositionSeconds(positionTicks));
|
||||
|
||||
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
|
||||
@@ -677,9 +748,11 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
var (previousItemId, previousPositionTicks) = _sessionManager.GetLastPlayingState(deviceId);
|
||||
var inferredStop = !string.IsNullOrWhiteSpace(previousItemId) &&
|
||||
var inferredStop = sessionReady &&
|
||||
!string.IsNullOrWhiteSpace(previousItemId) &&
|
||||
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
||||
var inferredStart = !string.IsNullOrWhiteSpace(itemId) &&
|
||||
var inferredStart = sessionReady &&
|
||||
!string.IsNullOrWhiteSpace(itemId) &&
|
||||
!string.Equals(previousItemId, itemId, StringComparison.Ordinal);
|
||||
|
||||
if (sessionReady && inferredStop && !string.IsNullOrWhiteSpace(previousItemId))
|
||||
@@ -693,7 +766,8 @@ public partial class JellyfinController
|
||||
_sessionManager.UpdatePlayingItem(deviceId, itemId, positionTicks);
|
||||
}
|
||||
|
||||
if (inferredStart)
|
||||
if (inferredStart &&
|
||||
!ShouldSuppressPlaybackSignal("start", deviceId, itemId, playSessionId))
|
||||
{
|
||||
var trackName = await TryGetLocalTrackNameAsync(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
|
||||
// shortly after the normal scrobble threshold so downstream plugins that listen
|
||||
@@ -801,6 +889,25 @@ public partial class JellyfinController
|
||||
string previousItemId,
|
||||
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);
|
||||
|
||||
if (isExternal)
|
||||
@@ -918,7 +1025,10 @@ public partial class JellyfinController
|
||||
string itemId,
|
||||
long? positionTicks)
|
||||
{
|
||||
if (_scrobblingSettings.LocalTracksEnabled || _scrobblingHelper == null)
|
||||
if (!_scrobblingSettings.Enabled ||
|
||||
_scrobblingSettings.LocalTracksEnabled ||
|
||||
!_scrobblingSettings.SyntheticLocalPlayedSignalEnabled ||
|
||||
_scrobblingHelper == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
@@ -1009,6 +1119,22 @@ public partial class JellyfinController
|
||||
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)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(parsedDeviceId))
|
||||
@@ -1071,6 +1197,7 @@ public partial class JellyfinController
|
||||
string? itemId = null;
|
||||
string? itemName = null;
|
||||
long? positionTicks = null;
|
||||
string? playSessionId = null;
|
||||
var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers);
|
||||
|
||||
itemId = ParsePlaybackItemId(doc.RootElement);
|
||||
@@ -1086,6 +1213,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
positionTicks = ParsePlaybackPositionTicks(doc.RootElement);
|
||||
playSessionId = ParsePlaybackSessionId(doc.RootElement);
|
||||
|
||||
deviceId = ResolveDeviceId(deviceId, doc.RootElement);
|
||||
|
||||
@@ -1113,6 +1241,24 @@ public partial class JellyfinController
|
||||
|
||||
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
|
||||
? TimeSpan.FromTicks(positionTicks.Value).ToString(@"mm\:ss")
|
||||
: "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();
|
||||
}
|
||||
|
||||
@@ -1236,6 +1389,24 @@ public partial class JellyfinController
|
||||
_logger.LogInformation("🎵 Local track playback stopped: {Name} (ID: {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)
|
||||
if (_scrobblingSettings.LocalTracksEnabled && _scrobblingOrchestrator != null &&
|
||||
_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);
|
||||
if (!string.IsNullOrWhiteSpace(deviceId))
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
_sessionManager.MarkExplicitStop(deviceId, itemId);
|
||||
}
|
||||
_sessionManager.UpdatePlayingItem(deviceId, null, null);
|
||||
_sessionManager.MarkSessionPotentiallyEnded(deviceId, TimeSpan.FromSeconds(30));
|
||||
}
|
||||
}
|
||||
@@ -1488,6 +1664,78 @@ public partial class JellyfinController
|
||||
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)
|
||||
{
|
||||
var direct = TryReadStringProperty(payload, "ItemId");
|
||||
|
||||
@@ -189,10 +189,14 @@ public partial class JellyfinController
|
||||
|
||||
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 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
|
||||
var queryParams = Microsoft.AspNetCore.WebUtilities.QueryHelpers.ParseQuery(queryString);
|
||||
@@ -231,13 +235,16 @@ public partial class JellyfinController
|
||||
queryString = $"{queryString}&Fields=MediaSources";
|
||||
}
|
||||
}
|
||||
else
|
||||
else if (shouldIncludeMediaSources)
|
||||
{
|
||||
// No query string at all
|
||||
queryString = "?Fields=MediaSources";
|
||||
}
|
||||
|
||||
endpoint = $"{endpoint}{queryString}";
|
||||
if (!string.IsNullOrEmpty(queryString))
|
||||
{
|
||||
endpoint = $"{endpoint}{queryString}";
|
||||
}
|
||||
|
||||
var (browseResult, statusCode) = await _proxyService.GetJsonAsync(endpoint, null, Request.Headers);
|
||||
|
||||
@@ -249,7 +256,7 @@ public partial class JellyfinController
|
||||
}
|
||||
|
||||
// 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");
|
||||
browseResult = await UpdateSpotifyPlaylistCounts(browseResult);
|
||||
@@ -409,6 +416,11 @@ public partial class JellyfinController
|
||||
|
||||
// Merge albums and playlists using score-based interleaving (albums keep a light priority over playlists).
|
||||
var mergedAlbumsAndPlaylists = InterleaveByScore(allAlbums, mergedPlaylistItems, cleanQuery, primaryBoost: 2.0, boostMinScore: 70);
|
||||
mergedAlbumsAndPlaylists = ApplyRequestedAlbumOrderingIfApplicable(
|
||||
mergedAlbumsAndPlaylists,
|
||||
itemTypes,
|
||||
Request.Query["SortBy"].ToString(),
|
||||
Request.Query["SortOrder"].ToString());
|
||||
|
||||
_logger.LogDebug(
|
||||
"Merged results: Songs={Songs}, Albums+Playlists={AlbumsPlaylists}, Artists={Artists}",
|
||||
@@ -710,6 +722,152 @@ public partial class JellyfinController
|
||||
return MaskSensitiveQueryString(query);
|
||||
}
|
||||
|
||||
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>
|
||||
/// 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.
|
||||
|
||||
@@ -202,25 +202,37 @@ public partial class JellyfinController : ControllerBase
|
||||
private async Task<IActionResult> GetExternalChildItems(string provider, string type, string externalId, string? includeItemTypes, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
var itemTypesUnspecified = itemTypes == null || itemTypes.Length == 0;
|
||||
|
||||
_logger.LogDebug("GetExternalChildItems: provider={Provider}, type={Type}, externalId={ExternalId}, itemTypes={ItemTypes}",
|
||||
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||
|
||||
// Check if asking for audio (album tracks or artist songs)
|
||||
if (itemTypes?.Contains("Audio") == true)
|
||||
// Albums are track containers in Jellyfin clients; when ParentId points to an album,
|
||||
// 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);
|
||||
var album = await _metadataService.GetAlbumAsync(provider, externalId, cancellationToken);
|
||||
if (album == null)
|
||||
{
|
||||
return _responseBuilder.CreateError(404, "Album not found");
|
||||
}
|
||||
|
||||
return _responseBuilder.CreateItemsResponse(album.Songs);
|
||||
return _responseBuilder.CreateError(404, "Album not found");
|
||||
}
|
||||
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
|
||||
_logger.LogDebug("Fetching artist tracks for {Provider}/{ExternalId}", provider, externalId);
|
||||
@@ -238,7 +250,7 @@ public partial class JellyfinController : ControllerBase
|
||||
}
|
||||
|
||||
// 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")
|
||||
{
|
||||
@@ -267,6 +279,78 @@ public partial class JellyfinController : ControllerBase
|
||||
provider, type, externalId, string.Join(",", itemTypes ?? Array.Empty<string>()));
|
||||
return _responseBuilder.CreateItemsResponse(new List<Song>());
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
var itemTypes = ParseItemTypes(includeItemTypes);
|
||||
@@ -509,7 +593,8 @@ public partial class JellyfinController : ControllerBase
|
||||
string imageType,
|
||||
int imageIndex = 0,
|
||||
[FromQuery] int? maxWidth = null,
|
||||
[FromQuery] int? maxHeight = null)
|
||||
[FromQuery] int? maxHeight = null,
|
||||
[FromQuery(Name = "tag")] string? tag = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(itemId))
|
||||
{
|
||||
@@ -531,7 +616,8 @@ public partial class JellyfinController : ControllerBase
|
||||
itemId,
|
||||
imageType,
|
||||
maxWidth,
|
||||
maxHeight);
|
||||
maxHeight,
|
||||
tag);
|
||||
|
||||
if (imageBytes == null || contentType == null)
|
||||
{
|
||||
@@ -1374,9 +1460,7 @@ public partial class JellyfinController : ControllerBase
|
||||
|
||||
// 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)
|
||||
if (_spotifySettings.Enabled &&
|
||||
result.RootElement.ValueKind == JsonValueKind.Object &&
|
||||
result.RootElement.TryGetProperty("Items", out var items))
|
||||
if (ShouldProcessSpotifyPlaylistCounts(result, Request.Query["IncludeItemTypes"].ToString()))
|
||||
{
|
||||
_logger.LogDebug("Response has Items property, checking for Spotify playlists to update counts");
|
||||
result = await UpdateSpotifyPlaylistCounts(result);
|
||||
|
||||
@@ -1155,7 +1155,7 @@ public class PlaylistController : ControllerBase
|
||||
public async Task<IActionResult> ClearPlaylistCache(string 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)
|
||||
{
|
||||
@@ -1164,7 +1164,7 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
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);
|
||||
|
||||
// Invalidate playlist summary cache
|
||||
@@ -1172,7 +1172,7 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
return Ok(new
|
||||
{
|
||||
message = $"Rebuilding {decodedName} from scratch (same as cron job)",
|
||||
message = $"Rebuilding {decodedName} from scratch",
|
||||
timestamp = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
@@ -1768,12 +1768,12 @@ public class PlaylistController : ControllerBase
|
||||
|
||||
/// <summary>
|
||||
/// Rebuild all playlists from scratch (clear cache, fetch fresh data, re-match).
|
||||
/// This is the same process as the scheduled cron job - used by "Rebuild All Remote" button.
|
||||
/// This is a manual bulk action across all playlists - used by "Rebuild All Remote" button.
|
||||
/// </summary>
|
||||
[HttpPost("playlists/rebuild-all")]
|
||||
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)
|
||||
{
|
||||
@@ -1783,7 +1783,7 @@ public class PlaylistController : ControllerBase
|
||||
try
|
||||
{
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -51,6 +51,7 @@ public class ScrobblingAdminController : ControllerBase
|
||||
{
|
||||
Enabled = _settings.Enabled,
|
||||
LocalTracksEnabled = _settings.LocalTracksEnabled,
|
||||
SyntheticLocalPlayedSignalEnabled = _settings.SyntheticLocalPlayedSignalEnabled,
|
||||
LastFm = new
|
||||
{
|
||||
Enabled = _settings.LastFm.Enabled,
|
||||
|
||||
@@ -24,11 +24,14 @@ public class RequestLoggingMiddleware
|
||||
|
||||
// Log initialization status
|
||||
var initialValue = _configuration.GetValue<bool>("Debug:LogAllRequests");
|
||||
var initialRedactionValue = _configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false);
|
||||
_logger.LogWarning("🔍 RequestLoggingMiddleware initialized - LogAllRequests={LogAllRequests}", 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
|
||||
{
|
||||
@@ -40,6 +43,7 @@ public class RequestLoggingMiddleware
|
||||
{
|
||||
// Check configuration on every request to allow dynamic toggling
|
||||
var logAllRequests = _configuration.GetValue<bool>("Debug:LogAllRequests");
|
||||
var redactSensitiveValues = _configuration.GetValue<bool>("Debug:RedactSensitiveRequestValues", false);
|
||||
|
||||
if (!logAllRequests)
|
||||
{
|
||||
@@ -49,11 +53,13 @@ public class RequestLoggingMiddleware
|
||||
|
||||
var stopwatch = Stopwatch.StartNew();
|
||||
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
|
||||
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($" Content-Type: {request.ContentType ?? "(none)"}");
|
||||
requestLog.AppendLine($" Content-Length: {request.ContentLength?.ToString() ?? "(none)"}");
|
||||
@@ -65,15 +71,18 @@ public class RequestLoggingMiddleware
|
||||
}
|
||||
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"))
|
||||
{
|
||||
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"))
|
||||
{
|
||||
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"))
|
||||
{
|
||||
|
||||
@@ -94,10 +94,6 @@ public class WebSocketProxyMiddleware
|
||||
_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
|
||||
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
|
||||
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
|
||||
@@ -146,6 +142,11 @@ public class WebSocketProxyMiddleware
|
||||
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
|
||||
_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
|
||||
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
|
||||
var serverToClient = ProxyMessagesAsync(serverWebSocket, clientWebSocket, "Server→Client", context.RequestAborted);
|
||||
@@ -157,10 +158,25 @@ public class WebSocketProxyMiddleware
|
||||
}
|
||||
catch (WebSocketException wsEx)
|
||||
{
|
||||
// 403 is expected when tokens expire or session ends - don't spam logs
|
||||
if (wsEx.Message.Contains("403"))
|
||||
var isAuthFailure =
|
||||
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
|
||||
{
|
||||
@@ -201,8 +217,8 @@ public class WebSocketProxyMiddleware
|
||||
clientWebSocket?.Dispose();
|
||||
serverWebSocket?.Dispose();
|
||||
|
||||
// CRITICAL: Notify session manager that client disconnected
|
||||
if (!string.IsNullOrEmpty(deviceId))
|
||||
// CRITICAL: Notify session manager only when a client socket was accepted.
|
||||
if (clientWebSocket != null && !string.IsNullOrEmpty(deviceId))
|
||||
{
|
||||
_logger.LogInformation("🧹 WEBSOCKET: Client disconnected, removing session for device {DeviceId}", deviceId);
|
||||
await _sessionManager.RemoveSessionAsync(deviceId);
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace allstarr.Models.Scrobbling;
|
||||
/// </summary>
|
||||
public class PlaybackSession
|
||||
{
|
||||
private const int ExternalStartToleranceSeconds = 5;
|
||||
|
||||
/// <summary>
|
||||
/// Unique identifier for this playback session.
|
||||
/// </summary>
|
||||
@@ -54,13 +56,18 @@ public class PlaybackSession
|
||||
{
|
||||
if (Scrobbled)
|
||||
return false; // Already scrobbled
|
||||
|
||||
|
||||
if (Track.DurationSeconds == null || Track.DurationSeconds <= 30)
|
||||
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 scrobbleThreshold = Math.Min(halfDuration, 240); // 4 minutes = 240 seconds
|
||||
|
||||
|
||||
return LastPositionSeconds >= scrobbleThreshold;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,4 +52,10 @@ public record ScrobbleTrack
|
||||
/// ListenBrainz only scrobbles external tracks.
|
||||
/// </summary>
|
||||
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>
|
||||
/// Search results cache duration in minutes.
|
||||
/// Default: 120 minutes (2 hours)
|
||||
/// Default: 1 minute (60 seconds)
|
||||
/// </summary>
|
||||
public int SearchResultsMinutes { get; set; } = 120;
|
||||
public int SearchResultsMinutes { get; set; } = 1;
|
||||
|
||||
/// <summary>
|
||||
/// Playlist cover images cache duration in hours.
|
||||
|
||||
@@ -18,6 +18,12 @@ public class ScrobblingSettings
|
||||
/// </summary>
|
||||
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>
|
||||
/// Last.fm settings.
|
||||
/// </summary>
|
||||
|
||||
@@ -731,6 +731,8 @@ builder.Services.Configure<allstarr.Models.Settings.ScrobblingSettings>(options
|
||||
|
||||
options.Enabled = builder.Configuration.GetValue<bool>("Scrobbling:Enabled");
|
||||
options.LocalTracksEnabled = builder.Configuration.GetValue<bool>("Scrobbling:LocalTracksEnabled");
|
||||
options.SyntheticLocalPlayedSignalEnabled =
|
||||
builder.Configuration.GetValue<bool>("Scrobbling:SyntheticLocalPlayedSignalEnabled");
|
||||
options.LastFm.Enabled = lastFmEnabled;
|
||||
|
||||
// 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($" Enabled: {options.Enabled}");
|
||||
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 Username: {options.LastFm.Username ?? "(not set)"}");
|
||||
Console.WriteLine($" Last.fm Session Key: {(string.IsNullOrEmpty(options.LastFm.SessionKey) ? "(not set)" : "***" + options.LastFm.SessionKey[^8..])}");
|
||||
|
||||
@@ -777,10 +777,11 @@ public class JellyfinProxyService
|
||||
string itemId,
|
||||
string imageType = "Primary",
|
||||
int? maxWidth = null,
|
||||
int? maxHeight = null)
|
||||
int? maxHeight = null,
|
||||
string? imageTag = null)
|
||||
{
|
||||
// Build cache key
|
||||
var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}";
|
||||
var cacheKey = $"image:{itemId}:{imageType}:{maxWidth}:{maxHeight}:{imageTag}";
|
||||
|
||||
// Try cache first
|
||||
var cached = await _cache.GetStringAsync(cacheKey);
|
||||
@@ -807,6 +808,12 @@ public class JellyfinProxyService
|
||||
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);
|
||||
|
||||
// Cache for 7 days if successful
|
||||
|
||||
@@ -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>
|
||||
/// Returns true if a local played-signal was already sent for this device+item.
|
||||
/// </summary>
|
||||
@@ -643,6 +685,8 @@ public class JellyfinSessionManager : IDisposable
|
||||
public long? LastPlayingPositionTicks { get; set; }
|
||||
public string? ClientIp { get; set; }
|
||||
public string? LastLocalPlayedSignalItemId { get; set; }
|
||||
public string? LastExplicitStopItemId { get; set; }
|
||||
public DateTime? LastExplicitStopAtUtc { get; set; }
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@@ -101,7 +101,8 @@ public class ScrobblingHelper
|
||||
DurationSeconds = durationSeconds,
|
||||
MusicBrainzId = musicBrainzId,
|
||||
Timestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
|
||||
IsExternal = isExternal
|
||||
IsExternal = isExternal,
|
||||
StartPositionSeconds = 0
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -119,7 +120,8 @@ public class ScrobblingHelper
|
||||
string artist,
|
||||
string? album = null,
|
||||
string? albumArtist = null,
|
||||
int? durationSeconds = null)
|
||||
int? durationSeconds = null,
|
||||
int? startPositionSeconds = null)
|
||||
{
|
||||
if (string.IsNullOrEmpty(title) || string.IsNullOrEmpty(artist))
|
||||
{
|
||||
@@ -134,7 +136,8 @@ public class ScrobblingHelper
|
||||
AlbumArtist = albumArtist,
|
||||
DurationSeconds = durationSeconds,
|
||||
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)
|
||||
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()}";
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ namespace allstarr.Services.Spotify;
|
||||
///
|
||||
/// 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.
|
||||
/// </summary>
|
||||
public class SpotifyTrackMatchingService : BackgroundService
|
||||
@@ -82,7 +84,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
var matchMode = _spotifyApiSettings.Enabled && _spotifyApiSettings.PreferIsrcMatching
|
||||
? "ISRC-preferred" : "fuzzy";
|
||||
_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
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
@@ -112,8 +114,10 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
{
|
||||
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 schedulerReference = now.AddMinutes(-1);
|
||||
var nextRuns = new List<(string PlaylistName, DateTime NextRun, CronExpression Cron)>();
|
||||
|
||||
foreach (var playlist in _spotifySettings.Playlists)
|
||||
@@ -123,7 +127,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
try
|
||||
{
|
||||
var cron = CronExpression.Parse(schedule);
|
||||
var nextRun = cron.GetNextOccurrence(now, TimeZoneInfo.Utc);
|
||||
var nextRun = cron.GetNextOccurrence(schedulerReference, TimeZoneInfo.Utc);
|
||||
|
||||
if (nextRun.HasValue)
|
||||
{
|
||||
@@ -149,44 +153,62 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
continue;
|
||||
}
|
||||
|
||||
// Find the next playlist that needs to run
|
||||
var nextPlaylist = nextRuns.OrderBy(x => x.NextRun).First();
|
||||
var waitTime = nextPlaylist.NextRun - now;
|
||||
// Run all playlists that are currently due.
|
||||
var duePlaylists = nextRuns
|
||||
.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)",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.NextRun, waitTime.TotalMinutes);
|
||||
|
||||
// Wait until next run (or max 1 hour to re-check schedules)
|
||||
var maxWait = TimeSpan.FromHours(1);
|
||||
var actualWait = waitTime > maxWait ? maxWait : waitTime;
|
||||
await Task.Delay(actualWait, stoppingToken);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Time to run this playlist
|
||||
_logger.LogInformation("=== CRON TRIGGER: Running scheduled sync for {Playlist} ===", nextPlaylist.PlaylistName);
|
||||
_logger.LogInformation(
|
||||
"=== CRON TRIGGER: Running scheduled rebuild for {Count} due playlists ===",
|
||||
duePlaylists.Count);
|
||||
|
||||
// Check cooldown to prevent duplicate runs
|
||||
if (_lastRunTimes.TryGetValue(nextPlaylist.PlaylistName, out var lastRun))
|
||||
var anySkippedForCooldown = false;
|
||||
|
||||
foreach (var due in duePlaylists)
|
||||
{
|
||||
var timeSinceLastRun = now - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
if (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
_logger.LogWarning("Skipping {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
nextPlaylist.PlaylistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
break;
|
||||
}
|
||||
|
||||
_logger.LogInformation("→ Running scheduled rebuild for {Playlist}", due.PlaylistName);
|
||||
|
||||
var rebuilt = await TryRunSinglePlaylistRebuildWithCooldownAsync(
|
||||
due.PlaylistName,
|
||||
stoppingToken,
|
||||
trigger: "cron");
|
||||
|
||||
if (!rebuilt)
|
||||
{
|
||||
anySkippedForCooldown = true;
|
||||
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)
|
||||
await RebuildSinglePlaylistAsync(nextPlaylist.PlaylistName, stoppingToken);
|
||||
_lastRunTimes[nextPlaylist.PlaylistName] = DateTime.UtcNow;
|
||||
|
||||
_logger.LogInformation("=== FINISHED: {Playlist} - Next run at {NextRun} UTC ===",
|
||||
nextPlaylist.PlaylistName, nextPlaylist.Cron.GetNextOccurrence(DateTime.UtcNow, TimeZoneInfo.Utc));
|
||||
// Avoid a tight loop if one or more due playlists were skipped by cooldown.
|
||||
if (anySkippedForCooldown)
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMinutes(1), stoppingToken);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -198,7 +220,7 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
/// <summary>
|
||||
/// 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>
|
||||
private async Task RebuildSinglePlaylistAsync(string playlistName, CancellationToken cancellationToken)
|
||||
{
|
||||
@@ -322,36 +344,64 @@ public class SpotifyTrackMatchingService : BackgroundService
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger full rebuild for all playlists (called from "Rebuild All Remote" button).
|
||||
/// This clears caches, fetches fresh data, and re-matches everything - same as cron job.
|
||||
/// This clears caches, fetches fresh data, and re-matches everything immediately.
|
||||
/// </summary>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Public method to trigger full rebuild for a single playlist (called from individual "Rebuild Remote" button).
|
||||
/// This clears cache, fetches fresh data, and re-matches - same as cron job.
|
||||
/// This clears cache, fetches fresh data, and re-matches - same workflow as scheduled cron rebuilds for a playlist.
|
||||
/// </summary>
|
||||
public async Task TriggerRebuildForPlaylistAsync(string playlistName)
|
||||
{
|
||||
_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))
|
||||
{
|
||||
var timeSinceLastRun = DateTime.UtcNow - lastRun;
|
||||
if (timeSinceLastRun < _minimumRunInterval)
|
||||
{
|
||||
_logger.LogWarning("Skipping manual rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
playlistName, (int)timeSinceLastRun.TotalSeconds, (int)_minimumRunInterval.TotalSeconds);
|
||||
throw new InvalidOperationException($"Please wait {(int)(_minimumRunInterval - timeSinceLastRun).TotalSeconds} more seconds before rebuilding again");
|
||||
_logger.LogWarning(
|
||||
"Skipping {Trigger} rebuild for {Playlist} - last run was {Seconds}s ago (cooldown: {Cooldown}s)",
|
||||
trigger,
|
||||
playlistName,
|
||||
(int)timeSinceLastRun.TotalSeconds,
|
||||
(int)_minimumRunInterval.TotalSeconds);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await RebuildSinglePlaylistAsync(playlistName, CancellationToken.None);
|
||||
await RebuildSinglePlaylistAsync(playlistName, cancellationToken);
|
||||
_lastRunTimes[playlistName] = DateTime.UtcNow;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
}
|
||||
},
|
||||
"Debug": {
|
||||
"LogAllRequests": false
|
||||
"LogAllRequests": false,
|
||||
"RedactSensitiveRequestValues": false
|
||||
},
|
||||
"Backend": {
|
||||
"Type": "Subsonic"
|
||||
@@ -65,7 +66,7 @@
|
||||
"ConnectionString": "localhost:6379"
|
||||
},
|
||||
"Cache": {
|
||||
"SearchResultsMinutes": 120,
|
||||
"SearchResultsMinutes": 1,
|
||||
"PlaylistImagesHours": 168,
|
||||
"SpotifyPlaylistItemsHours": 168,
|
||||
"SpotifyMatchedTracksDays": 30,
|
||||
|
||||
@@ -393,6 +393,11 @@
|
||||
<span class="value" id="local-tracks-enabled-value">-</span>
|
||||
<button onclick="toggleLocalTracksEnabled()">Toggle</button>
|
||||
</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 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/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><strong>Default:</strong> keep Synthetic Local Played Signal disabled to avoid duplicate plugin scrobbles.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ async function refreshPlaylist(name) {
|
||||
|
||||
async function clearPlaylistCache(name) {
|
||||
const result = await runAction({
|
||||
confirmMessage: `Rebuild "${name}" from scratch?\n\nThis will:\n• Clear all caches\n• Fetch fresh Spotify playlist data\n• Re-match all tracks\n\nThis is the SAME process as the scheduled cron job.\n\nUse this when the Spotify playlist has changed.\n\nThis may take a minute.`,
|
||||
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 () => {
|
||||
setMatchingBannerVisible(true);
|
||||
showToast(`Rebuilding ${name} from scratch...`, "info");
|
||||
@@ -208,10 +208,10 @@ async function matchAllPlaylists() {
|
||||
async function refreshAndMatchAll() {
|
||||
const result = await runAction({
|
||||
confirmMessage:
|
||||
"Rebuild all playlists from scratch?\n\nThis will:\n• Clear all playlist caches\n• Fetch fresh data from Spotify\n• Re-match all tracks against local library and external providers\n\nThis is the SAME process as the scheduled cron job.\n\nThis may take several minutes.",
|
||||
"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 () => {
|
||||
setMatchingBannerVisible(true);
|
||||
showToast("Starting full rebuild (same as cron job)...", "info", 3000);
|
||||
showToast("Starting full rebuild for all playlists...", "info", 3000);
|
||||
},
|
||||
task: () => API.rebuildAllPlaylists(),
|
||||
success: "✓ Full rebuild complete!",
|
||||
|
||||
@@ -70,6 +70,12 @@ async function loadScrobblingConfig() {
|
||||
? "Enabled"
|
||||
: "Disabled";
|
||||
|
||||
document.getElementById(
|
||||
"synthetic-local-played-signal-enabled-value",
|
||||
).textContent = data.scrobbling.syntheticLocalPlayedSignalEnabled
|
||||
? "Enabled"
|
||||
: "Disabled";
|
||||
|
||||
document.getElementById("lastfm-enabled-value").textContent = data
|
||||
.scrobbling.lastFm.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() {
|
||||
await toggleScrobblingSetting(
|
||||
"SCROBBLING_LASTFM_ENABLED",
|
||||
@@ -332,6 +346,8 @@ export function initScrobblingAdmin(options) {
|
||||
window.loadScrobblingConfig = loadScrobblingConfig;
|
||||
window.toggleScrobblingEnabled = toggleScrobblingEnabled;
|
||||
window.toggleLocalTracksEnabled = toggleLocalTracksEnabled;
|
||||
window.toggleSyntheticLocalPlayedSignalEnabled =
|
||||
toggleSyntheticLocalPlayedSignalEnabled;
|
||||
window.toggleLastFmEnabled = toggleLastFmEnabled;
|
||||
window.toggleListenBrainzEnabled = toggleListenBrainzEnabled;
|
||||
window.editLastFmUsername = editLastFmUsername;
|
||||
|
||||
@@ -134,6 +134,8 @@ services:
|
||||
|
||||
# ===== SCROBBLING (LAST.FM, LISTENBRAINZ) =====
|
||||
- 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__ApiKey=${SCROBBLING_LASTFM_API_KEY:-}
|
||||
- Scrobbling__LastFm__SharedSecret=${SCROBBLING_LASTFM_SHARED_SECRET:-}
|
||||
@@ -145,6 +147,7 @@ services:
|
||||
|
||||
# ===== DEBUG SETTINGS =====
|
||||
- Debug__LogAllRequests=${DEBUG_LOG_ALL_REQUESTS:-false}
|
||||
- Debug__RedactSensitiveRequestValues=${DEBUG_REDACT_SENSITIVE_REQUEST_VALUES:-false}
|
||||
|
||||
# ===== SHARED =====
|
||||
- Library__DownloadPath=/app/downloads
|
||||
|
||||
Reference in New Issue
Block a user