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:
2026-03-06 01:54:58 -05:00
parent 00a5d152a5
commit 639070556a
29 changed files with 1007 additions and 119 deletions
+10 -1
View File
@@ -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]
+64
View File
@@ -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
};
}
}
+1 -1
View File
@@ -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";
}
+16 -2
View File
@@ -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,
+44 -2
View File
@@ -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.
+102 -18
View File
@@ -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);
+6 -6
View File
@@ -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);
+10 -3
View File
@@ -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; }
}
+2 -2
View File
@@ -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>
+3
View File
@@ -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>
+3 -2
View File
@@ -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,
+6
View File
@@ -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>
+3 -3
View File
@@ -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!",
+16
View File
@@ -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;
+3
View File
@@ -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