Compare commits

...

24 Commits

Author SHA1 Message Date
d9375405a5 add debug logging for websocket auth headers
Some checks failed
CI / build-and-test (push) Has been cancelled
2026-02-02 20:45:00 -05:00
83063f594a fix websocket session auth and header parsing for jellyfin dashboard 2026-02-02 20:41:25 -05:00
b40349206d websocket on behalf of client 2026-02-02 20:31:07 -05:00
8dbac23944 literally just logging 2026-02-02 20:24:43 -05:00
9fb86d3839 fixed universal 2026-02-02 20:16:02 -05:00
bb2bda1379 x-forwarded-for headers 2026-02-02 20:14:11 -05:00
e9f72efb01 websocket logging 2026-02-02 19:53:39 -05:00
ab36a43892 another 2026-02-02 19:06:33 -05:00
2ffb769a6f another 2026-02-02 18:48:19 -05:00
045c810abc lets try that again 2026-02-02 18:42:12 -05:00
4c55520ce0 entirely new session handling 2026-02-02 18:37:38 -05:00
04079223c2 sync window for spotify playlists 2026-02-02 18:24:49 -05:00
1bb902d96a better searching for the file 2026-02-02 18:22:16 -05:00
b5f3f54c8b I really hate time 2026-02-02 18:19:12 -05:00
3bcb60a09a logging 2026-02-02 18:16:18 -05:00
ba78ed0883 Artists, not artist but now for lyrics 2026-02-02 18:12:12 -05:00
d0f26c0182 Artists, not artist 2026-02-02 15:57:34 -05:00
91275a2835 timestamp logging 2026-02-02 14:40:40 -05:00
ccbc9cf859 multiple artists big fix! 2026-02-02 14:35:53 -05:00
97975f1e08 oh how i hate time 2026-02-02 13:31:10 -05:00
ff48891a5a oh how i hate time 2026-02-02 13:24:21 -05:00
273fac7a0a log time message 2026-02-02 13:19:05 -05:00
12436c2f9c Whoops! cache wasn't clearing for main provider! please remove the files in your downloads folder and pull this commit 2026-02-02 13:13:35 -05:00
2315d6ab9f reduce overlapping runs 2026-02-02 13:05:36 -05:00
13 changed files with 1130 additions and 290 deletions

View File

@@ -30,6 +30,7 @@ public class JellyfinController : ControllerBase
private readonly JellyfinResponseBuilder _responseBuilder; private readonly JellyfinResponseBuilder _responseBuilder;
private readonly JellyfinModelMapper _modelMapper; private readonly JellyfinModelMapper _modelMapper;
private readonly JellyfinProxyService _proxyService; private readonly JellyfinProxyService _proxyService;
private readonly JellyfinSessionManager _sessionManager;
private readonly PlaylistSyncService? _playlistSyncService; private readonly PlaylistSyncService? _playlistSyncService;
private readonly RedisCacheService _cache; private readonly RedisCacheService _cache;
private readonly ILogger<JellyfinController> _logger; private readonly ILogger<JellyfinController> _logger;
@@ -43,6 +44,7 @@ public class JellyfinController : ControllerBase
JellyfinResponseBuilder responseBuilder, JellyfinResponseBuilder responseBuilder,
JellyfinModelMapper modelMapper, JellyfinModelMapper modelMapper,
JellyfinProxyService proxyService, JellyfinProxyService proxyService,
JellyfinSessionManager sessionManager,
RedisCacheService cache, RedisCacheService cache,
ILogger<JellyfinController> logger, ILogger<JellyfinController> logger,
PlaylistSyncService? playlistSyncService = null) PlaylistSyncService? playlistSyncService = null)
@@ -55,6 +57,7 @@ public class JellyfinController : ControllerBase
_responseBuilder = responseBuilder; _responseBuilder = responseBuilder;
_modelMapper = modelMapper; _modelMapper = modelMapper;
_proxyService = proxyService; _proxyService = proxyService;
_sessionManager = sessionManager;
_playlistSyncService = playlistSyncService; _playlistSyncService = playlistSyncService;
_cache = cache; _cache = cache;
_logger = logger; _logger = logger;
@@ -864,12 +867,34 @@ public class JellyfinController : ControllerBase
} }
/// <summary> /// <summary>
/// Universal audio endpoint that redirects to the stream endpoint. /// Universal audio endpoint - handles transcoding, format negotiation, and adaptive streaming.
/// This is the primary endpoint used by Jellyfin Web and most clients.
/// </summary> /// </summary>
[HttpGet("Audio/{itemId}/universal")] [HttpGet("Audio/{itemId}/universal")]
public Task<IActionResult> UniversalAudio(string itemId) [HttpHead("Audio/{itemId}/universal")]
public async Task<IActionResult> UniversalAudio(string itemId)
{ {
return StreamAudio(itemId); if (string.IsNullOrWhiteSpace(itemId))
{
return BadRequest(new { error = "Missing item ID" });
}
var (isExternal, provider, externalId) = _localLibraryService.ParseSongId(itemId);
if (!isExternal)
{
// For local content, proxy the universal endpoint with all query parameters
var fullPath = $"Audio/{itemId}/universal";
if (Request.QueryString.HasValue)
{
fullPath = $"{fullPath}{Request.QueryString.Value}";
}
return await ProxyJellyfinStream(fullPath, itemId);
}
// For external content, use simple streaming (no transcoding support yet)
return await StreamExternalContent(provider!, externalId!);
} }
#endregion #endregion
@@ -1022,7 +1047,9 @@ public class JellyfinController : ControllerBase
} }
// Try to get lyrics from LRCLIB // Try to get lyrics from LRCLIB
_logger.LogInformation("Searching LRCLIB for lyrics: {Artist} - {Title}", song.Artist, song.Title); _logger.LogInformation("Searching LRCLIB for lyrics: {Artists} - {Title}",
song.Artists.Count > 0 ? string.Join(", ", song.Artists) : song.Artist,
song.Title);
var lyricsService = HttpContext.RequestServices.GetService<LrclibService>(); var lyricsService = HttpContext.RequestServices.GetService<LrclibService>();
if (lyricsService == null) if (lyricsService == null)
{ {
@@ -1031,7 +1058,7 @@ public class JellyfinController : ControllerBase
var lyrics = await lyricsService.GetLyricsAsync( var lyrics = await lyricsService.GetLyricsAsync(
song.Title, song.Title,
song.Artist ?? "", song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist ?? "" },
song.Album ?? "", song.Album ?? "",
song.Duration ?? 0); song.Duration ?? 0);
@@ -1778,96 +1805,7 @@ public class JellyfinController : ControllerBase
} }
Request.Body.Position = 0; Request.Body.Position = 0;
_logger.LogInformation("📻 Playback START reported - Body: {Body}", body); _logger.LogInformation("📻 Playback START reported");
_logger.LogInformation("Auth headers: {Headers}",
string.Join(", ", Request.Headers.Where(h => h.Key.Contains("Auth", StringComparison.OrdinalIgnoreCase)).Select(h => $"{h.Key}={h.Value}")));
// Extract device info from auth headers for session initialization
string? deviceId = null;
string? client = null;
string? device = null;
string? version = null;
if (Request.Headers.TryGetValue("Authorization", out var authHeader))
{
var authStr = authHeader.ToString();
// Parse: MediaBrowser Client="...", Device="...", DeviceId="...", Version="..."
var parts = authStr.Replace("MediaBrowser ", "").Split(',');
foreach (var part in parts)
{
var kv = part.Trim().Split('=');
if (kv.Length == 2)
{
var key = kv[0].Trim();
var value = kv[1].Trim('"');
if (key == "DeviceId") deviceId = value;
else if (key == "Client") client = value;
else if (key == "Device") device = value;
else if (key == "Version") version = value;
}
}
}
// Ensure session capabilities are posted to Jellyfin (if not already done)
// Jellyfin automatically creates a session when the client authenticates, but we need to
// post capabilities so the session shows up in the dashboard with proper device info
if (!string.IsNullOrEmpty(deviceId))
{
_logger.LogInformation("🔧 Ensuring session exists for device: {DeviceId} ({Client} {Version})", deviceId, client, version);
// First, check if a session exists for this device
try
{
var (sessionsResult, sessionsStatus) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers);
if (sessionsResult != null && sessionsStatus == 200)
{
var sessions = sessionsResult.RootElement;
_logger.LogInformation("📊 Jellyfin sessions for device {DeviceId}: {Sessions}", deviceId, sessions.GetRawText());
}
else
{
_logger.LogWarning("⚠ Could not query sessions ({StatusCode})", sessionsStatus);
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to query sessions");
}
// Post capabilities - Jellyfin will match this to the authenticated session by device ID
// The query parameters tell Jellyfin what this device can do
var capabilitiesEndpoint = $"Sessions/Capabilities/Full";
try
{
// Send full capabilities as JSON body (more reliable than query params)
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = Array.Empty<string>(),
SupportsMediaControl = false,
SupportsPersistentIdentifier = true,
SupportsSync = false,
DeviceProfile = (object?)null // Let Jellyfin use defaults
};
var capabilitiesJson = JsonSerializer.Serialize(capabilities);
_logger.LogInformation("📤 Posting capabilities: {Json}", capabilitiesJson);
var (capResult, capStatus) = await _proxyService.PostJsonAsync(capabilitiesEndpoint, capabilitiesJson, Request.Headers);
_logger.LogInformation("✓ Session capabilities posted ({StatusCode})", capStatus);
// Check sessions again after posting capabilities
var (sessionsResult2, sessionsStatus2) = await _proxyService.GetJsonAsync($"Sessions?deviceId={deviceId}", null, Request.Headers);
if (sessionsResult2 != null && sessionsStatus2 == 200)
{
var sessions2 = sessionsResult2.RootElement;
_logger.LogInformation("📊 Jellyfin sessions AFTER capabilities: {Sessions}", sessions2.GetRawText());
}
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to post session capabilities, continuing anyway");
}
}
// Parse the body to check if it's an external track // Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body); var doc = JsonDocument.Parse(body);
@@ -1901,11 +1839,10 @@ public class JellyfinController : ControllerBase
itemName ?? "Unknown", itemId); itemName ?? "Unknown", itemId);
} }
// For local tracks, forward to Jellyfin with client auth // For local tracks, forward playback start to Jellyfin FIRST
_logger.LogInformation("Forwarding playback start to Jellyfin..."); _logger.LogInformation("Forwarding playback start to Jellyfin...");
// Fetch full item details to include in playback report // Fetch full item details to include in playback report
// This makes the session show up properly in Jellyfin dashboard with "Now Playing"
try try
{ {
var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers); var (itemResult, itemStatus) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
@@ -1914,27 +1851,45 @@ public class JellyfinController : ControllerBase
var item = itemResult.RootElement; var item = itemResult.RootElement;
_logger.LogInformation("📦 Fetched item details for playback report"); _logger.LogInformation("📦 Fetched item details for playback report");
// Build enhanced playback start info with full item details // Build playback start info - Jellyfin will fetch item details itself
var enhancedBody = new var playbackStart = new
{ {
ItemId = itemId, ItemId = itemId,
PositionTicks = doc.RootElement.TryGetProperty("PositionTicks", out var posProp) ? posProp.GetInt64() : 0, PositionTicks = doc.RootElement.TryGetProperty("PositionTicks", out var posProp) ? posProp.GetInt64() : 0,
// Include the full item so Jellyfin can display "Now Playing" // Let Jellyfin fetch the item details - don't include NowPlayingItem
NowPlayingItem = item.Clone()
}; };
var enhancedJson = JsonSerializer.Serialize(enhancedBody); var playbackJson = JsonSerializer.Serialize(playbackStart);
_logger.LogInformation("📤 Sending enhanced playback start with item details"); _logger.LogInformation("📤 Sending playback start: {Json}", playbackJson);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", enhancedJson, Request.Headers); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", playbackJson, Request.Headers);
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
{ {
_logger.LogInformation("✓ Enhanced playback start forwarded to Jellyfin ({StatusCode})", statusCode); _logger.LogInformation("✓ Playback start forwarded to Jellyfin ({StatusCode})", statusCode);
// NOW ensure session exists with capabilities (after playback is reported)
var (deviceId, client, device, version) = ExtractDeviceInfo(Request.Headers);
if (!string.IsNullOrEmpty(deviceId))
{
var sessionCreated = await _sessionManager.EnsureSessionAsync(deviceId, client ?? "Unknown", device ?? "Unknown", version ?? "1.0", Request.Headers);
if (sessionCreated)
{
_logger.LogWarning("✓ SESSION: Session ensured for device {DeviceId} after playback start", deviceId);
}
else
{
_logger.LogWarning("⚠️ SESSION: Failed to ensure session for device {DeviceId}", deviceId);
}
}
else
{
_logger.LogWarning("⚠️ SESSION: No device ID found in headers for playback start");
}
} }
else else
{ {
_logger.LogWarning("⚠️ Enhanced playback start returned status {StatusCode}", statusCode); _logger.LogWarning("⚠️ Playback start returned status {StatusCode}", statusCode);
} }
} }
else else
@@ -1950,7 +1905,7 @@ public class JellyfinController : ControllerBase
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "Failed to send enhanced playback start, trying basic"); _logger.LogWarning(ex, "Failed to send playback start, trying basic");
// Fall back to basic playback start // Fall back to basic playback start
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers); var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Playing", body, Request.Headers);
if (statusCode == 204 || statusCode == 200) if (statusCode == 204 || statusCode == 200)
@@ -1984,6 +1939,13 @@ public class JellyfinController : ControllerBase
} }
Request.Body.Position = 0; Request.Body.Position = 0;
// Update session activity
var (deviceId, _, _, _) = ExtractDeviceInfo(Request.Headers);
if (!string.IsNullOrEmpty(deviceId))
{
_sessionManager.UpdateActivity(deviceId);
}
// Parse the body to check if it's an external track // Parse the body to check if it's an external track
var doc = JsonDocument.Parse(body); var doc = JsonDocument.Parse(body);
if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp)) if (doc.RootElement.TryGetProperty("ItemId", out var itemIdProp))
@@ -3011,7 +2973,7 @@ public class JellyfinController : ControllerBase
/// </summary> /// </summary>
[HttpGet("spotify/sync", Order = 1)] [HttpGet("spotify/sync", Order = 1)]
[ServiceFilter(typeof(ApiKeyAuthFilter))] [ServiceFilter(typeof(ApiKeyAuthFilter))]
public async Task<IActionResult> TriggerSpotifySync() public async Task<IActionResult> TriggerSpotifySync([FromServices] IEnumerable<IHostedService> hostedServices)
{ {
if (!_spotifySettings.Enabled) if (!_spotifySettings.Enabled)
{ {
@@ -3020,77 +2982,43 @@ public class JellyfinController : ControllerBase
_logger.LogInformation("Manual Spotify sync triggered"); _logger.LogInformation("Manual Spotify sync triggered");
var results = new Dictionary<string, object>(); // Find the SpotifyMissingTracksFetcher service
var fetcherService = hostedServices
.OfType<allstarr.Services.Spotify.SpotifyMissingTracksFetcher>()
.FirstOrDefault();
if (fetcherService == null)
{
return StatusCode(500, new { error = "SpotifyMissingTracksFetcher not found" });
}
// Trigger fetch manually
await fetcherService.TriggerFetchAsync();
// Check what was cached
var results = new Dictionary<string, object>();
for (int i = 0; i < _spotifySettings.PlaylistIds.Count; i++) for (int i = 0; i < _spotifySettings.PlaylistIds.Count; i++)
{ {
var playlistId = _spotifySettings.PlaylistIds[i]; var playlistName = i < _spotifySettings.PlaylistNames.Count
? _spotifySettings.PlaylistNames[i]
: _spotifySettings.PlaylistIds[i];
try var cacheKey = $"spotify:missing:{playlistName}";
var tracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(cacheKey);
if (tracks != null && tracks.Count > 0)
{ {
// Use configured name if available, otherwise use ID results[playlistName] = new {
var playlistName = i < _spotifySettings.PlaylistNames.Count status = "success",
? _spotifySettings.PlaylistNames[i] tracks = tracks.Count
: playlistId; };
_logger.LogInformation("Fetching missing tracks for {Playlist} (ID: {Id})", playlistName, playlistId);
// Try to fetch the missing tracks file - search last 24 hours
var now = DateTime.UtcNow;
var searchStart = now.AddHours(-24);
var httpClient = new HttpClient();
var found = false;
// Search every minute for the last 24 hours (1440 attempts max)
for (var time = searchStart; time <= now; time = time.AddMinutes(1))
{
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
var url = $"{_settings.Url}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
$"?name={Uri.EscapeDataString(filename)}&api_key={_settings.ApiKey}";
try
{
_logger.LogDebug("Trying {Filename}", filename);
var response = await httpClient.GetAsync(url);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync();
var tracks = ParseMissingTracksJson(json);
if (tracks.Count > 0)
{
var cacheKey = $"spotify:missing:{playlistName}";
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
results[playlistName] = new {
status = "success",
tracks = tracks.Count,
filename = filename
};
_logger.LogInformation("✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
tracks.Count, playlistName, filename);
found = true;
break;
}
}
}
catch (Exception ex)
{
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
}
}
if (!found)
{
results[playlistName] = new { status = "not_found", message = "No missing tracks file found" };
}
} }
catch (Exception ex) else
{ {
_logger.LogError(ex, "Error syncing playlist {PlaylistId}", playlistId); results[playlistName] = new {
results[playlistId] = new { status = "error", message = ex.Message }; status = "not_found",
message = "No missing tracks found"
};
} }
} }
@@ -3369,5 +3297,49 @@ public class JellyfinController : ControllerBase
return avgScore; return avgScore;
} }
/// <summary>
/// Extracts device information from Authorization header.
/// </summary>
private (string? deviceId, string? client, string? device, string? version) ExtractDeviceInfo(IHeaderDictionary headers)
{
string? deviceId = null;
string? client = null;
string? device = null;
string? version = null;
// Check X-Emby-Authorization FIRST (most Jellyfin clients use this)
// Then fall back to Authorization header
string? authStr = null;
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
{
authStr = embyAuthHeader.ToString();
}
else if (headers.TryGetValue("Authorization", out var authHeader))
{
authStr = authHeader.ToString();
}
if (!string.IsNullOrEmpty(authStr))
{
// Parse: MediaBrowser Client="...", Device="...", DeviceId="...", Version="..."
var parts = authStr.Replace("MediaBrowser ", "").Split(',');
foreach (var part in parts)
{
var kv = part.Trim().Split('=');
if (kv.Length == 2)
{
var key = kv[0].Trim();
var value = kv[1].Trim('"');
if (key == "DeviceId") deviceId = value;
else if (key == "Client") client = value;
else if (key == "Device") device = value;
else if (key == "Version") version = value;
}
}
}
return (deviceId, client, device, version);
}
} }
// force rebuild Sun Jan 25 13:22:47 EST 2026 // force rebuild Sun Jan 25 13:22:47 EST 2026

View File

@@ -23,25 +23,34 @@ public class WebSocketProxyMiddleware
_settings = settings.Value; _settings = settings.Value;
_logger = logger; _logger = logger;
_logger.LogInformation("🔧 WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url); _logger.LogWarning("🔧 WEBSOCKET: WebSocketProxyMiddleware initialized - Jellyfin URL: {Url}", _settings.Url);
} }
public async Task InvokeAsync(HttpContext context) public async Task InvokeAsync(HttpContext context)
{ {
// Log ALL requests to /socket path for debugging // Log ALL requests for debugging
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase)) var path = context.Request.Path.Value ?? "";
var isWebSocket = context.WebSockets.IsWebSocketRequest;
// Log any request that might be WebSocket-related
if (path.Contains("socket", StringComparison.OrdinalIgnoreCase) ||
path.Contains("ws", StringComparison.OrdinalIgnoreCase) ||
isWebSocket ||
context.Request.Headers.ContainsKey("Upgrade"))
{ {
_logger.LogInformation("📡 Request to /socket path - IsWebSocketRequest: {IsWs}, Method: {Method}, Headers: {Headers}", _logger.LogWarning("🔍 WEBSOCKET: Potential WebSocket request: Path={Path}, IsWs={IsWs}, Method={Method}, Upgrade={Upgrade}, Connection={Connection}",
context.WebSockets.IsWebSocketRequest, path,
isWebSocket,
context.Request.Method, context.Request.Method,
string.Join(", ", context.Request.Headers.Select(h => $"{h.Key}={h.Value}"))); context.Request.Headers["Upgrade"].ToString(),
context.Request.Headers["Connection"].ToString());
} }
// Check if this is a WebSocket request to /socket // Check if this is a WebSocket request to /socket
if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) && if (context.Request.Path.StartsWithSegments("/socket", StringComparison.OrdinalIgnoreCase) &&
context.WebSockets.IsWebSocketRequest) context.WebSockets.IsWebSocketRequest)
{ {
_logger.LogInformation("🔌 WebSocket connection request received from {RemoteIp}", _logger.LogWarning("🔌 WEBSOCKET: WebSocket connection request received from {RemoteIp}",
context.Connection.RemoteIpAddress); context.Connection.RemoteIpAddress);
await HandleWebSocketProxyAsync(context); await HandleWebSocketProxyAsync(context);
@@ -61,7 +70,7 @@ public class WebSocketProxyMiddleware
{ {
// Accept the WebSocket connection from the client // Accept the WebSocket connection from the client
clientWebSocket = await context.WebSockets.AcceptWebSocketAsync(); clientWebSocket = await context.WebSockets.AcceptWebSocketAsync();
_logger.LogInformation("✓ Client WebSocket accepted"); _logger.LogWarning("✓ WEBSOCKET: Client WebSocket accepted");
// Build Jellyfin WebSocket URL // Build Jellyfin WebSocket URL
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? ""; var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
@@ -75,28 +84,39 @@ public class WebSocketProxyMiddleware
jellyfinWsUrl += context.Request.QueryString.Value; jellyfinWsUrl += context.Request.QueryString.Value;
} }
_logger.LogInformation("Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl); _logger.LogWarning("🔗 WEBSOCKET: Connecting to Jellyfin WebSocket: {Url}", jellyfinWsUrl);
// Connect to Jellyfin WebSocket // Connect to Jellyfin WebSocket
serverWebSocket = new ClientWebSocket(); serverWebSocket = new ClientWebSocket();
// Forward authentication headers // Forward authentication headers - check X-Emby-Authorization FIRST
if (context.Request.Headers.TryGetValue("Authorization", out var authHeader)) // Most Jellyfin clients use X-Emby-Authorization, not Authorization
{ if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
serverWebSocket.Options.SetRequestHeader("Authorization", authHeader.ToString());
_logger.LogDebug("Forwarded Authorization header");
}
else if (context.Request.Headers.TryGetValue("X-Emby-Authorization", out var embyAuthHeader))
{ {
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString()); serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuthHeader.ToString());
_logger.LogDebug("Forwarded X-Emby-Authorization header"); _logger.LogWarning("🔑 WEBSOCKET: Forwarded X-Emby-Authorization header");
}
else if (context.Request.Headers.TryGetValue("Authorization", out var authHeader))
{
var authValue = authHeader.ToString();
// If it's a MediaBrowser auth header, use X-Emby-Authorization
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
serverWebSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
_logger.LogWarning("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization header");
}
else
{
serverWebSocket.Options.SetRequestHeader("Authorization", authValue);
_logger.LogWarning("🔑 WEBSOCKET: Forwarded Authorization header");
}
} }
// Set user agent // Set user agent
serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0"); serverWebSocket.Options.SetRequestHeader("User-Agent", "Allstarr/1.0");
await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted); await serverWebSocket.ConnectAsync(new Uri(jellyfinWsUrl), context.RequestAborted);
_logger.LogInformation("✓ Connected to Jellyfin WebSocket"); _logger.LogWarning("✓ WEBSOCKET: Connected to Jellyfin WebSocket");
// Start bidirectional proxying // Start bidirectional proxying
var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted); var clientToServer = ProxyMessagesAsync(clientWebSocket, serverWebSocket, "Client→Server", context.RequestAborted);
@@ -105,15 +125,15 @@ public class WebSocketProxyMiddleware
// Wait for either direction to complete // Wait for either direction to complete
await Task.WhenAny(clientToServer, serverToClient); await Task.WhenAny(clientToServer, serverToClient);
_logger.LogInformation("WebSocket proxy connection closed"); _logger.LogWarning("🔌 WEBSOCKET: WebSocket proxy connection closed");
} }
catch (WebSocketException wsEx) catch (WebSocketException wsEx)
{ {
_logger.LogWarning(wsEx, "WebSocket error: {Message}", wsEx.Message); _logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error: {Message}", wsEx.Message);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogError(ex, "Error in WebSocket proxy"); _logger.LogError(ex, "❌ WEBSOCKET: Error in WebSocket proxy");
} }
finally finally
{ {
@@ -145,7 +165,7 @@ public class WebSocketProxyMiddleware
clientWebSocket?.Dispose(); clientWebSocket?.Dispose();
serverWebSocket?.Dispose(); serverWebSocket?.Dispose();
_logger.LogInformation("WebSocket connections cleaned up"); _logger.LogWarning("🧹 WEBSOCKET: WebSocket connections cleaned up");
} }
} }
@@ -166,7 +186,7 @@ public class WebSocketProxyMiddleware
if (result.MessageType == WebSocketMessageType.Close) if (result.MessageType == WebSocketMessageType.Close)
{ {
_logger.LogInformation("{Direction}: Close message received", direction); _logger.LogWarning("🔌 WEBSOCKET {Direction}: Close message received", direction);
await destination.CloseAsync( await destination.CloseAsync(
result.CloseStatus ?? WebSocketCloseStatus.NormalClosure, result.CloseStatus ?? WebSocketCloseStatus.NormalClosure,
result.CloseStatusDescription, result.CloseStatusDescription,
@@ -206,15 +226,15 @@ public class WebSocketProxyMiddleware
} }
catch (OperationCanceledException) catch (OperationCanceledException)
{ {
_logger.LogInformation("{Direction}: Operation cancelled", direction); _logger.LogWarning("⚠️ WEBSOCKET {Direction}: Operation cancelled", direction);
} }
catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) catch (WebSocketException wsEx) when (wsEx.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely)
{ {
_logger.LogInformation("{Direction}: Connection closed prematurely", direction); _logger.LogWarning("⚠️ WEBSOCKET {Direction}: Connection closed prematurely", direction);
} }
catch (Exception ex) catch (Exception ex)
{ {
_logger.LogWarning(ex, "{Direction}: Error proxying messages", direction); _logger.LogWarning(ex, "⚠️ WEBSOCKET {Direction}: Error proxying messages", direction);
} }
} }
} }

View File

@@ -14,6 +14,11 @@ public class Song
public string Title { get; set; } = string.Empty; public string Title { get; set; } = string.Empty;
public string Artist { get; set; } = string.Empty; public string Artist { get; set; } = string.Empty;
public string? ArtistId { get; set; } public string? ArtistId { get; set; }
/// <summary>
/// All artists for this track (main + featured). For display in Jellyfin clients.
/// </summary>
public List<string> Artists { get; set; } = new();
public string Album { get; set; } = string.Empty; public string Album { get; set; } = string.Empty;
public string? AlbumId { get; set; } public string? AlbumId { get; set; }
public int? Duration { get; set; } // In seconds public int? Duration { get; set; } // In seconds

View File

@@ -177,6 +177,7 @@ if (backendType == BackendType.Jellyfin)
builder.Services.AddSingleton<JellyfinResponseBuilder>(); builder.Services.AddSingleton<JellyfinResponseBuilder>();
builder.Services.AddSingleton<JellyfinModelMapper>(); builder.Services.AddSingleton<JellyfinModelMapper>();
builder.Services.AddScoped<JellyfinProxyService>(); builder.Services.AddScoped<JellyfinProxyService>();
builder.Services.AddSingleton<JellyfinSessionManager>();
builder.Services.AddScoped<JellyfinAuthFilter>(); builder.Services.AddScoped<JellyfinAuthFilter>();
builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>(); builder.Services.AddScoped<allstarr.Filters.ApiKeyAuthFilter>();
} }

View File

@@ -146,6 +146,17 @@ public class JellyfinProxyService
{ {
using var request = new HttpRequestMessage(HttpMethod.Get, url); using var request = new HttpRequestMessage(HttpMethod.Get, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
bool authHeaderAdded = false; bool authHeaderAdded = false;
// Check if this is a browser request for static assets (favicon, etc.) // Check if this is a browser request for static assets (favicon, etc.)
@@ -262,6 +273,17 @@ public class JellyfinProxyService
using var request = new HttpRequestMessage(HttpMethod.Post, url); using var request = new HttpRequestMessage(HttpMethod.Post, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
// Handle special case for playback endpoints - Jellyfin expects wrapped body // Handle special case for playback endpoints - Jellyfin expects wrapped body
var bodyToSend = body; var bodyToSend = body;
if (!string.IsNullOrWhiteSpace(body)) if (!string.IsNullOrWhiteSpace(body))
@@ -372,11 +394,17 @@ public class JellyfinProxyService
if (!response.IsSuccessStatusCode) if (!response.IsSuccessStatusCode)
{ {
var errorContent = await response.Content.ReadAsStringAsync(); var errorContent = await response.Content.ReadAsStringAsync();
_logger.LogWarning("Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}", _logger.LogWarning("❌ SESSION: Jellyfin POST request failed: {StatusCode} for {Url}. Response: {Response}",
response.StatusCode, url, errorContent); response.StatusCode, url, errorContent);
return (null, statusCode); return (null, statusCode);
} }
// Log successful session-related responses
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase))
{
_logger.LogWarning("✓ SESSION: Jellyfin responded {StatusCode} for {Endpoint}", statusCode, endpoint);
}
// Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress) // Handle 204 No Content responses (e.g., /sessions/playing, /sessions/playing/progress)
if (response.StatusCode == System.Net.HttpStatusCode.NoContent) if (response.StatusCode == System.Net.HttpStatusCode.NoContent)
{ {
@@ -391,6 +419,13 @@ public class JellyfinProxyService
return (null, statusCode); return (null, statusCode);
} }
// Log response content for session endpoints
if (endpoint.Contains("Sessions", StringComparison.OrdinalIgnoreCase) && !string.IsNullOrWhiteSpace(responseContent))
{
var preview = responseContent.Length > 200 ? responseContent[..200] + "..." : responseContent;
_logger.LogWarning("📥 SESSION: Jellyfin response body: {Body}", preview);
}
return (JsonDocument.Parse(responseContent), statusCode); return (JsonDocument.Parse(responseContent), statusCode);
} }
@@ -424,6 +459,17 @@ public class JellyfinProxyService
using var request = new HttpRequestMessage(HttpMethod.Delete, url); using var request = new HttpRequestMessage(HttpMethod.Delete, url);
// Forward client IP address to Jellyfin so it can identify the real client
if (_httpContextAccessor.HttpContext != null)
{
var clientIp = _httpContextAccessor.HttpContext.Connection.RemoteIpAddress?.ToString();
if (!string.IsNullOrEmpty(clientIp))
{
request.Headers.TryAddWithoutValidation("X-Forwarded-For", clientIp);
request.Headers.TryAddWithoutValidation("X-Real-IP", clientIp);
}
}
bool authHeaderAdded = false; bool authHeaderAdded = false;
// Forward authentication headers from client (case-insensitive) // Forward authentication headers from client (case-insensitive)

View File

@@ -186,7 +186,7 @@ public class JellyfinResponseBuilder
["Type"] = "Audio", ["Type"] = "Audio",
["Album"] = song.Album, ["Album"] = song.Album,
["AlbumArtist"] = song.Artist, ["AlbumArtist"] = song.Artist,
["Artists"] = new[] { song.Artist }, ["Artists"] = song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist },
["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond, ["RunTimeTicks"] = (song.Duration ?? 0) * TimeSpan.TicksPerSecond,
["ImageTags"] = new Dictionary<string, string> ["ImageTags"] = new Dictionary<string, string>
{ {
@@ -242,15 +242,23 @@ public class JellyfinResponseBuilder
["Album"] = song.Album, ["Album"] = song.Album,
["AlbumId"] = song.AlbumId ?? song.Id, ["AlbumId"] = song.AlbumId ?? song.Id,
["AlbumArtist"] = song.AlbumArtist ?? song.Artist, ["AlbumArtist"] = song.AlbumArtist ?? song.Artist,
["Artists"] = new[] { song.Artist }, ["Artists"] = song.Artists.Count > 0 ? song.Artists.ToArray() : new[] { song.Artist },
["ArtistItems"] = new[] ["ArtistItems"] = song.Artists.Count > 0
{ ? song.Artists.Select((name, index) => new Dictionary<string, object?>
new Dictionary<string, object?>
{ {
["Id"] = song.ArtistId ?? song.Id, ["Name"] = name,
["Name"] = song.Artist ["Id"] = index == 0 && song.ArtistId != null
} ? song.ArtistId
}, : $"{song.Id}-artist-{index}"
}).ToArray()
: new[]
{
new Dictionary<string, object?>
{
["Id"] = song.ArtistId ?? song.Id,
["Name"] = song.Artist
}
},
["IndexNumber"] = song.Track, ["IndexNumber"] = song.Track,
["ParentIndexNumber"] = song.DiscNumber ?? 1, ["ParentIndexNumber"] = song.DiscNumber ?? 1,
["ProductionYear"] = song.Year, ["ProductionYear"] = song.Year,

View File

@@ -0,0 +1,450 @@
using System.Collections.Concurrent;
using System.Net.WebSockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Options;
using allstarr.Models.Settings;
namespace allstarr.Services.Jellyfin;
/// <summary>
/// Manages Jellyfin sessions for connected clients.
/// Creates sessions on first playback and keeps them alive with periodic pings.
/// Also maintains server-side WebSocket connections to Jellyfin on behalf of clients.
/// </summary>
public class JellyfinSessionManager : IDisposable
{
private readonly JellyfinProxyService _proxyService;
private readonly JellyfinSettings _settings;
private readonly ILogger<JellyfinSessionManager> _logger;
private readonly ConcurrentDictionary<string, SessionInfo> _sessions = new();
private readonly Timer _keepAliveTimer;
public JellyfinSessionManager(
JellyfinProxyService proxyService,
IOptions<JellyfinSettings> settings,
ILogger<JellyfinSessionManager> logger)
{
_proxyService = proxyService;
_settings = settings.Value;
_logger = logger;
// Keep sessions alive every 10 seconds (Jellyfin considers sessions stale after ~15 seconds of inactivity)
_keepAliveTimer = new Timer(KeepSessionsAlive, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(10));
_logger.LogWarning("🔧 SESSION: JellyfinSessionManager initialized with 10-second keep-alive and WebSocket support");
}
/// <summary>
/// Ensures a session exists for the given device. Creates one if needed.
/// </summary>
public async Task<bool> EnsureSessionAsync(string deviceId, string client, string device, string version, IHeaderDictionary headers)
{
if (string.IsNullOrEmpty(deviceId))
{
_logger.LogWarning("⚠️ SESSION: Cannot create session - no device ID");
return false;
}
// Check if we already have this session tracked
if (_sessions.TryGetValue(deviceId, out var existingSession))
{
existingSession.LastActivity = DateTime.UtcNow;
_logger.LogWarning("✓ SESSION: Session already exists for device {DeviceId}", deviceId);
// Refresh capabilities to keep session alive
await PostCapabilitiesAsync(headers);
return true;
}
_logger.LogWarning("🔧 SESSION: Creating new session for device: {DeviceId} ({Client} on {Device})", deviceId, client, device);
// Log the headers we received for debugging
_logger.LogWarning("🔍 SESSION: Headers received for session creation: {Headers}",
string.Join(", ", headers.Select(h => $"{h.Key}={h.Value.ToString().Substring(0, Math.Min(30, h.Value.ToString().Length))}...")));
try
{
// Post session capabilities to Jellyfin - this creates the session
await PostCapabilitiesAsync(headers);
_logger.LogWarning("✓ SESSION: Session created for {DeviceId}", deviceId);
// Track this session
_sessions[deviceId] = new SessionInfo
{
DeviceId = deviceId,
Client = client,
Device = device,
Version = version,
LastActivity = DateTime.UtcNow,
Headers = CloneHeaders(headers)
};
// Start a WebSocket connection to Jellyfin on behalf of this client
_ = Task.Run(() => MaintainWebSocketForSessionAsync(deviceId, headers));
return true;
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ SESSION: Error creating session for {DeviceId}", deviceId);
return false;
}
}
/// <summary>
/// Posts session capabilities to Jellyfin.
/// </summary>
private async Task PostCapabilitiesAsync(IHeaderDictionary headers)
{
var capabilities = new
{
PlayableMediaTypes = new[] { "Audio" },
SupportedCommands = new[]
{
"Play",
"Playstate",
"PlayNext"
},
SupportsMediaControl = true,
SupportsPersistentIdentifier = true,
SupportsSync = false
};
var json = JsonSerializer.Serialize(capabilities);
var (result, statusCode) = await _proxyService.PostJsonAsync("Sessions/Capabilities/Full", json, headers);
if (statusCode == 204 || statusCode == 200)
{
_logger.LogWarning("✓ SESSION: Posted capabilities successfully ({StatusCode})", statusCode);
}
else
{
_logger.LogWarning("⚠️ SESSION: Failed to post capabilities - status {StatusCode}", statusCode);
}
}
/// <summary>
/// Updates session activity timestamp.
/// </summary>
public void UpdateActivity(string deviceId)
{
if (_sessions.TryGetValue(deviceId, out var session))
{
session.LastActivity = DateTime.UtcNow;
_logger.LogWarning("🔄 SESSION: Updated activity for {DeviceId}", deviceId);
}
else
{
_logger.LogWarning("⚠️ SESSION: Cannot update activity - device {DeviceId} not found", deviceId);
}
}
/// <summary>
/// Removes a session when the client disconnects.
/// </summary>
public async Task RemoveSessionAsync(string deviceId)
{
if (_sessions.TryRemove(deviceId, out var session))
{
_logger.LogWarning("🗑️ SESSION: Removing session for device {DeviceId}", deviceId);
// Close WebSocket if it exists
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
{
try
{
await session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
_logger.LogWarning("🔌 WEBSOCKET: Closed WebSocket for device {DeviceId}", deviceId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "⚠️ WEBSOCKET: Error closing WebSocket for {DeviceId}", deviceId);
}
finally
{
session.WebSocket?.Dispose();
}
}
try
{
// Optionally notify Jellyfin that the session is ending
// (Jellyfin will auto-cleanup inactive sessions anyway)
await _proxyService.PostJsonAsync("Sessions/Logout", "{}", session.Headers);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "⚠️ SESSION: Error removing session for {DeviceId}", deviceId);
}
}
}
/// <summary>
/// Maintains a WebSocket connection to Jellyfin on behalf of a client session.
/// This allows the session to appear in Jellyfin's dashboard.
/// </summary>
private async Task MaintainWebSocketForSessionAsync(string deviceId, IHeaderDictionary headers)
{
if (!_sessions.TryGetValue(deviceId, out var session))
{
_logger.LogWarning("⚠️ WEBSOCKET: Cannot create WebSocket - session {DeviceId} not found", deviceId);
return;
}
ClientWebSocket? webSocket = null;
try
{
// Build Jellyfin WebSocket URL
var jellyfinUrl = _settings.Url?.TrimEnd('/') ?? "";
var wsScheme = jellyfinUrl.StartsWith("https://", StringComparison.OrdinalIgnoreCase) ? "wss://" : "ws://";
var jellyfinHost = jellyfinUrl.Replace("https://", "").Replace("http://", "");
var jellyfinWsUrl = $"{wsScheme}{jellyfinHost}/socket";
// IMPORTANT: Do NOT add api_key to URL - we want to authenticate as the CLIENT, not the server
// The client's token is passed via X-Emby-Authorization header
// Using api_key would create a session for the server/admin, not the actual user's client
webSocket = new ClientWebSocket();
session.WebSocket = webSocket;
// Log available headers for debugging
_logger.LogWarning("🔍 WEBSOCKET: Available headers for {DeviceId}: {Headers}",
deviceId, string.Join(", ", headers.Keys));
// Forward authentication headers from the CLIENT - this is critical for session to appear under the right user
bool authFound = false;
if (headers.TryGetValue("X-Emby-Authorization", out var embyAuth))
{
webSocket.Options.SetRequestHeader("X-Emby-Authorization", embyAuth.ToString());
_logger.LogWarning("🔑 WEBSOCKET: Using X-Emby-Authorization for {DeviceId}: {Auth}",
deviceId, embyAuth.ToString().Length > 50 ? embyAuth.ToString()[..50] + "..." : embyAuth.ToString());
authFound = true;
}
else if (headers.TryGetValue("Authorization", out var auth))
{
var authValue = auth.ToString();
if (authValue.Contains("MediaBrowser", StringComparison.OrdinalIgnoreCase))
{
webSocket.Options.SetRequestHeader("X-Emby-Authorization", authValue);
_logger.LogWarning("🔑 WEBSOCKET: Converted Authorization to X-Emby-Authorization for {DeviceId}: {Auth}",
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
authFound = true;
}
else
{
webSocket.Options.SetRequestHeader("Authorization", authValue);
_logger.LogWarning("🔑 WEBSOCKET: Using Authorization for {DeviceId}: {Auth}",
deviceId, authValue.Length > 50 ? authValue[..50] + "..." : authValue);
authFound = true;
}
}
if (!authFound)
{
// No client auth found - fall back to server API key as last resort
if (!string.IsNullOrEmpty(_settings.ApiKey))
{
jellyfinWsUrl += $"?api_key={_settings.ApiKey}";
_logger.LogWarning("⚠️ WEBSOCKET: No client auth found in headers, falling back to server API key for {DeviceId}", deviceId);
}
else
{
_logger.LogWarning("❌ WEBSOCKET: No authentication available for {DeviceId}!", deviceId);
}
}
_logger.LogWarning("🔗 WEBSOCKET: Connecting to Jellyfin for device {DeviceId}: {Url}", deviceId, jellyfinWsUrl);
// Set user agent
webSocket.Options.SetRequestHeader("User-Agent", $"Allstarr-Proxy/{session.Client}");
// Connect to Jellyfin
await webSocket.ConnectAsync(new Uri(jellyfinWsUrl), CancellationToken.None);
_logger.LogWarning("✓ WEBSOCKET: Connected to Jellyfin for device {DeviceId}", deviceId);
// CRITICAL: Send ForceKeepAlive message to initialize session in Jellyfin
// This tells Jellyfin to create/show the session in the dashboard
// Without this message, the WebSocket is connected but no session appears
var forceKeepAliveMessage = "{\"MessageType\":\"ForceKeepAlive\",\"Data\":100}";
var messageBytes = Encoding.UTF8.GetBytes(forceKeepAliveMessage);
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogWarning("📤 WEBSOCKET: Sent ForceKeepAlive to initialize session for {DeviceId}", deviceId);
// Also send SessionsStart to subscribe to session updates
var sessionsStartMessage = "{\"MessageType\":\"SessionsStart\",\"Data\":\"0,1500\"}";
messageBytes = Encoding.UTF8.GetBytes(sessionsStartMessage);
await webSocket.SendAsync(new ArraySegment<byte>(messageBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogWarning("📤 WEBSOCKET: Sent SessionsStart for {DeviceId}", deviceId);
// Keep the WebSocket alive by reading messages and sending periodic keep-alive
var buffer = new byte[1024 * 4];
var lastKeepAlive = DateTime.UtcNow;
using var cts = new CancellationTokenSource();
while (webSocket.State == WebSocketState.Open && _sessions.ContainsKey(deviceId))
{
try
{
// Use a timeout so we can send keep-alive messages periodically
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cts.Token);
timeoutCts.CancelAfter(TimeSpan.FromSeconds(30));
try
{
var result = await webSocket.ReceiveAsync(new ArraySegment<byte>(buffer), timeoutCts.Token);
if (result.MessageType == WebSocketMessageType.Close)
{
_logger.LogWarning("🔌 WEBSOCKET: Jellyfin closed WebSocket for device {DeviceId}", deviceId);
break;
}
// Log received messages for debugging
if (result.MessageType == WebSocketMessageType.Text)
{
var message = Encoding.UTF8.GetString(buffer, 0, result.Count);
_logger.LogWarning("📥 WEBSOCKET: Received from Jellyfin for {DeviceId}: {Message}",
deviceId, message.Length > 100 ? message[..100] + "..." : message);
// Respond to KeepAlive requests from Jellyfin
if (message.Contains("\"MessageType\":\"KeepAlive\""))
{
_logger.LogWarning("💓 WEBSOCKET: Received KeepAlive from Jellyfin for {DeviceId}", deviceId);
}
}
}
catch (OperationCanceledException) when (!cts.IsCancellationRequested)
{
// Timeout - this is expected, send keep-alive if needed
}
// Send periodic keep-alive every 30 seconds
if (DateTime.UtcNow - lastKeepAlive > TimeSpan.FromSeconds(30))
{
var keepAliveMsg = "{\"MessageType\":\"KeepAlive\"}";
var keepAliveBytes = Encoding.UTF8.GetBytes(keepAliveMsg);
await webSocket.SendAsync(new ArraySegment<byte>(keepAliveBytes), WebSocketMessageType.Text, true, CancellationToken.None);
_logger.LogWarning("💓 WEBSOCKET: Sent KeepAlive for {DeviceId}", deviceId);
lastKeepAlive = DateTime.UtcNow;
}
}
catch (WebSocketException wsEx)
{
_logger.LogWarning(wsEx, "⚠️ WEBSOCKET: WebSocket error for device {DeviceId}", deviceId);
break;
}
}
}
catch (Exception ex)
{
_logger.LogError(ex, "❌ WEBSOCKET: Failed to maintain WebSocket for device {DeviceId}", deviceId);
}
finally
{
if (webSocket != null)
{
if (webSocket.State == WebSocketState.Open)
{
try
{
await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Session ended", CancellationToken.None);
}
catch { }
}
webSocket.Dispose();
_logger.LogWarning("🧹 WEBSOCKET: Cleaned up WebSocket for device {DeviceId}", deviceId);
}
// Clear WebSocket reference from session
if (_sessions.TryGetValue(deviceId, out var sess))
{
sess.WebSocket = null;
}
}
}
/// <summary>
/// Periodically pings Jellyfin to keep sessions alive.
/// </summary>
private async void KeepSessionsAlive(object? state)
{
var now = DateTime.UtcNow;
var activeSessions = _sessions.Values.Where(s => now - s.LastActivity < TimeSpan.FromMinutes(5)).ToList();
if (activeSessions.Count == 0)
{
return;
}
_logger.LogWarning("💓 SESSION: Keeping {Count} sessions alive", activeSessions.Count);
foreach (var session in activeSessions)
{
try
{
// Post capabilities again to keep session alive
await PostCapabilitiesAsync(session.Headers);
_logger.LogWarning("✓ SESSION: Kept session alive for {DeviceId}", session.DeviceId);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "⚠️ SESSION: Error keeping session alive for {DeviceId}", session.DeviceId);
}
}
// Clean up stale sessions (inactive for > 10 minutes)
var staleSessions = _sessions.Where(kvp => now - kvp.Value.LastActivity > TimeSpan.FromMinutes(10)).ToList();
foreach (var stale in staleSessions)
{
_logger.LogWarning("🧹 SESSION: Removing stale session for {DeviceId}", stale.Key);
_sessions.TryRemove(stale.Key, out _);
}
}
private static IHeaderDictionary CloneHeaders(IHeaderDictionary headers)
{
var cloned = new HeaderDictionary();
foreach (var header in headers)
{
cloned[header.Key] = header.Value;
}
return cloned;
}
private class SessionInfo
{
public required string DeviceId { get; init; }
public required string Client { get; init; }
public required string Device { get; init; }
public required string Version { get; init; }
public DateTime LastActivity { get; set; }
public required IHeaderDictionary Headers { get; init; }
public ClientWebSocket? WebSocket { get; set; }
}
public void Dispose()
{
_keepAliveTimer?.Dispose();
// Close all WebSocket connections
foreach (var session in _sessions.Values)
{
if (session.WebSocket != null && session.WebSocket.State == WebSocketState.Open)
{
try
{
session.WebSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Service stopping", CancellationToken.None).Wait(TimeSpan.FromSeconds(5));
}
catch { }
finally
{
session.WebSocket?.Dispose();
}
}
}
}
}

View File

@@ -25,6 +25,12 @@ public class LrclibService
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string albumName, int durationSeconds) public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string artistName, string albumName, int durationSeconds)
{ {
return await GetLyricsAsync(trackName, new[] { artistName }, albumName, durationSeconds);
}
public async Task<LyricsInfo?> GetLyricsAsync(string trackName, string[] artistNames, string albumName, int durationSeconds)
{
var artistName = string.Join(", ", artistNames);
var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}"; var cacheKey = $"lyrics:{artistName}:{trackName}:{albumName}:{durationSeconds}";
var cached = await _cache.GetStringAsync(cacheKey); var cached = await _cache.GetStringAsync(cacheKey);
@@ -42,12 +48,15 @@ public class LrclibService
try try
{ {
// Try searching with all artists joined (space-separated for better matching)
var searchArtistName = string.Join(" ", artistNames);
// First try search API for fuzzy matching (more forgiving) // First try search API for fuzzy matching (more forgiving)
var searchUrl = $"{BaseUrl}/search?" + var searchUrl = $"{BaseUrl}/search?" +
$"track_name={Uri.EscapeDataString(trackName)}&" + $"track_name={Uri.EscapeDataString(trackName)}&" +
$"artist_name={Uri.EscapeDataString(artistName)}"; $"artist_name={Uri.EscapeDataString(searchArtistName)}";
_logger.LogInformation("Searching LRCLIB: {Url}", searchUrl); _logger.LogInformation("Searching LRCLIB: {Url} (expecting {ArtistCount} artists)", searchUrl, artistNames.Length);
var searchResponse = await _httpClient.GetAsync(searchUrl); var searchResponse = await _httpClient.GetAsync(searchUrl);
@@ -66,20 +75,29 @@ public class LrclibService
{ {
// Calculate similarity scores // Calculate similarity scores
var trackScore = CalculateSimilarity(trackName, result.TrackName ?? ""); var trackScore = CalculateSimilarity(trackName, result.TrackName ?? "");
var artistScore = CalculateSimilarity(artistName, result.ArtistName ?? "");
// Count artists in the result
var resultArtistCount = CountArtists(result.ArtistName ?? "");
var expectedArtistCount = artistNames.Length;
// Artist matching - check if all our artists are present
var artistScore = CalculateArtistSimilarity(artistNames, result.ArtistName ?? "");
// STRONG bonus for matching artist count (this is critical!)
var artistCountBonus = resultArtistCount == expectedArtistCount ? 50.0 : 0.0;
// Duration match (within 5 seconds is good) // Duration match (within 5 seconds is good)
var durationDiff = Math.Abs(result.Duration - durationSeconds); var durationDiff = Math.Abs(result.Duration - durationSeconds);
var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2)); var durationScore = durationDiff <= 5 ? 100.0 : Math.Max(0, 100 - (durationDiff * 2));
// Bonus for having synced lyrics (prefer synced over plain) // Bonus for having synced lyrics (prefer synced over plain)
var syncedBonus = !string.IsNullOrEmpty(result.SyncedLyrics) ? 20.0 : 0.0; var syncedBonus = !string.IsNullOrEmpty(result.SyncedLyrics) ? 15.0 : 0.0;
// Weighted score: track name most important, then artist, then duration, plus synced bonus // Weighted score: track name important, artist match critical, artist count VERY important
var totalScore = (trackScore * 0.5) + (artistScore * 0.3) + (durationScore * 0.2) + syncedBonus; var totalScore = (trackScore * 0.3) + (artistScore * 0.3) + (durationScore * 0.15) + artistCountBonus + syncedBonus;
_logger.LogDebug("Candidate: {Track} by {Artist} - Score: {Score:F1} (track:{TrackScore:F1}, artist:{ArtistScore:F1}, duration:{DurationScore:F1}, synced:{Synced})", _logger.LogDebug("Candidate: {Track} by {Artist} ({ArtistCount} artists) - Score: {Score:F1} (track:{TrackScore:F1}, artist:{ArtistScore:F1}, duration:{DurationScore:F1}, countBonus:{CountBonus:F1}, synced:{Synced})",
result.TrackName, result.ArtistName, totalScore, trackScore, artistScore, durationScore, !string.IsNullOrEmpty(result.SyncedLyrics)); result.TrackName, result.ArtistName, resultArtistCount, totalScore, trackScore, artistScore, durationScore, artistCountBonus, !string.IsNullOrEmpty(result.SyncedLyrics));
if (totalScore > bestScore) if (totalScore > bestScore)
{ {
@@ -173,6 +191,69 @@ public class LrclibService
} }
} }
/// <summary>
/// Counts the number of artists in an artist string (separated by comma, ampersand, or 'e')
/// </summary>
private static int CountArtists(string artistString)
{
if (string.IsNullOrWhiteSpace(artistString))
return 0;
// Split by common separators: comma, ampersand, " e " (Portuguese/Spanish "and")
var separators = new[] { ',', '&' };
var parts = artistString.Split(separators, StringSplitOptions.RemoveEmptyEntries);
// Also check for " e " pattern (like "Julia Michaels e Alessia Cara")
var count = parts.Length;
foreach (var part in parts)
{
if (part.Contains(" e ", StringComparison.OrdinalIgnoreCase))
{
count += part.Split(new[] { " e " }, StringSplitOptions.RemoveEmptyEntries).Length - 1;
}
}
return Math.Max(1, count);
}
/// <summary>
/// Calculates how well the expected artists match the result's artist string
/// </summary>
private static double CalculateArtistSimilarity(string[] expectedArtists, string resultArtistString)
{
if (expectedArtists.Length == 0 || string.IsNullOrWhiteSpace(resultArtistString))
return 0;
var resultLower = resultArtistString.ToLowerInvariant();
var matchedCount = 0;
foreach (var artist in expectedArtists)
{
var artistLower = artist.ToLowerInvariant();
// Check if this artist appears in the result string
if (resultLower.Contains(artistLower))
{
matchedCount++;
}
else
{
// Try token-based matching for partial matches
var artistTokens = artistLower.Split(new[] { ' ', '-', '_' }, StringSplitOptions.RemoveEmptyEntries);
var matchedTokens = artistTokens.Count(token => resultLower.Contains(token));
// If most tokens match, count it as a partial match
if (matchedTokens >= artistTokens.Length * 0.7)
{
matchedCount++;
}
}
}
// Return percentage of artists matched
return (matchedCount * 100.0) / expectedArtists.Length;
}
private static double CalculateSimilarity(string str1, string str2) private static double CalculateSimilarity(string str1, string str2)
{ {
if (string.IsNullOrEmpty(str1) || string.IsNullOrEmpty(str2)) if (string.IsNullOrEmpty(str1) || string.IsNullOrEmpty(str2))

View File

@@ -35,6 +35,15 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger = logger; _logger = logger;
} }
/// <summary>
/// Public method to trigger fetching manually (called from controller).
/// </summary>
public async Task TriggerFetchAsync()
{
_logger.LogInformation("Manual fetch triggered");
await FetchMissingTracksAsync(CancellationToken.None, bypassSyncWindowCheck: true);
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
_logger.LogInformation("========================================"); _logger.LogInformation("========================================");
@@ -63,9 +72,24 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogInformation("Spotify Import ENABLED"); _logger.LogInformation("Spotify Import ENABLED");
_logger.LogInformation("Configured Playlist IDs: {Count}", _spotifySettings.Value.PlaylistIds.Count); _logger.LogInformation("Configured Playlist IDs: {Count}", _spotifySettings.Value.PlaylistIds.Count);
// Log the search schedule
var settings = _spotifySettings.Value;
var syncTime = DateTime.Today
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var syncEndTime = syncTime.AddHours(settings.SyncWindowHours);
_logger.LogInformation("Search Schedule:");
_logger.LogInformation(" Plugin sync time: {Time:HH:mm} UTC (configured)", syncTime);
_logger.LogInformation(" Search window: {Start:HH:mm} - {End:HH:mm} UTC ({Hours}h window)",
syncTime, syncEndTime, settings.SyncWindowHours);
_logger.LogInformation(" Will search for new files once per day after sync window ends");
_logger.LogInformation(" Background check interval: 5 minutes");
// Fetch playlist names from Jellyfin // Fetch playlist names from Jellyfin
await LoadPlaylistNamesAsync(); await LoadPlaylistNamesAsync();
_logger.LogInformation("Configured Playlists:");
foreach (var kvp in _playlistIdToName) foreach (var kvp in _playlistIdToName)
{ {
_logger.LogInformation(" - {Name} (ID: {Id})", kvp.Value, kvp.Key); _logger.LogInformation(" - {Name} (ID: {Id})", kvp.Value, kvp.Key);
@@ -91,7 +115,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
else else
{ {
_logger.LogInformation("Skipping startup fetch - existing cache is still current"); _logger.LogInformation("Skipping startup fetch - already have current files");
_hasRunOnce = true; _hasRunOnce = true;
} }
} }
@@ -100,7 +124,13 @@ public class SpotifyMissingTracksFetcher : BackgroundService
{ {
try try
{ {
await FetchMissingTracksAsync(stoppingToken); // Only fetch if we're past today's sync window AND we haven't fetched today yet
var shouldFetch = await ShouldFetchNowAsync();
if (shouldFetch)
{
await FetchMissingTracksAsync(stoppingToken);
_hasRunOnce = true;
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -111,6 +141,47 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
} }
private async Task<bool> ShouldFetchNowAsync()
{
var settings = _spotifySettings.Value;
var now = DateTime.UtcNow;
// Calculate today's sync window
var todaySync = now.Date
.AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute);
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
// Only fetch if we're past today's sync window
if (now < todaySyncEnd)
{
return false;
}
// Check if we already have today's files
foreach (var playlistName in _playlistIdToName.Values)
{
var filePath = GetCacheFilePath(playlistName);
if (File.Exists(filePath))
{
var fileTime = File.GetLastWriteTimeUtc(filePath);
// If file is from today's sync or later, we already have it
if (fileTime >= todaySync)
{
continue;
}
}
// Missing today's file for this playlist
return true;
}
// All playlists have today's files
return false;
}
private async Task LoadPlaylistNamesAsync() private async Task LoadPlaylistNamesAsync()
{ {
_playlistIdToName.Clear(); _playlistIdToName.Clear();
@@ -134,19 +205,22 @@ public class SpotifyMissingTracksFetcher : BackgroundService
var settings = _spotifySettings.Value; var settings = _spotifySettings.Value;
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
// Calculate when today's sync window ends // Calculate today's sync window
var todaySync = now.Date var todaySync = now.Date
.AddHours(settings.SyncStartHour) .AddHours(settings.SyncStartHour)
.AddMinutes(settings.SyncStartMinute); .AddMinutes(settings.SyncStartMinute);
var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours); var todaySyncEnd = todaySync.AddHours(settings.SyncWindowHours);
// If we haven't reached today's sync window end yet, check if we have yesterday's file _logger.LogInformation("Today's sync window: {Start:yyyy-MM-dd HH:mm} - {End:yyyy-MM-dd HH:mm} UTC",
todaySync, todaySyncEnd);
_logger.LogInformation("Current time: {Now:yyyy-MM-dd HH:mm} UTC", now);
// If we're still before today's sync window end, we should have yesterday's or today's file
// Don't search again until after today's sync window ends
if (now < todaySyncEnd) if (now < todaySyncEnd)
{ {
_logger.LogInformation("Today's sync window hasn't ended yet (ends at {End})", todaySyncEnd); _logger.LogInformation("We're before today's sync window end - checking if we have recent cache...");
_logger.LogInformation("Checking if we have a recent cache file...");
// Check if we have any cache (file or Redis) for all playlists
var allPlaylistsHaveCache = true; var allPlaylistsHaveCache = true;
foreach (var playlistName in _playlistIdToName.Values) foreach (var playlistName in _playlistIdToName.Values)
@@ -183,13 +257,65 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (allPlaylistsHaveCache) if (allPlaylistsHaveCache)
{ {
_logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ==="); _logger.LogInformation("=== ALL PLAYLISTS HAVE CACHE - SKIPPING STARTUP FETCH ===");
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", todaySyncEnd);
return false; return false;
} }
} }
else
// If we're after today's sync window end, check if we already have today's file
if (now >= todaySyncEnd)
{ {
_logger.LogInformation("Today's sync window has passed (ended at {End})", todaySyncEnd); _logger.LogInformation("We're after today's sync window end - checking if we already fetched today's files...");
_logger.LogInformation("Will search for new files");
var allPlaylistsHaveTodaysFile = true;
foreach (var playlistName in _playlistIdToName.Values)
{
var filePath = GetCacheFilePath(playlistName);
var cacheKey = $"spotify:missing:{playlistName}";
// Check if file exists and was created today (after sync start)
if (File.Exists(filePath))
{
var fileTime = File.GetLastWriteTimeUtc(filePath);
// File should be from today's sync window or later
if (fileTime >= todaySync)
{
var fileAge = DateTime.UtcNow - fileTime;
_logger.LogInformation(" {Playlist}: Have today's file (created {Time:yyyy-MM-dd HH:mm}, age: {Age:F1}h)",
playlistName, fileTime, fileAge.TotalHours);
// Load into Redis if not already there
if (!await _cache.ExistsAsync(cacheKey))
{
await LoadFromFileCache(playlistName);
}
continue;
}
else
{
_logger.LogInformation(" {Playlist}: File is old (from {Time:yyyy-MM-dd HH:mm}, before today's sync)",
playlistName, fileTime);
}
}
else
{
_logger.LogInformation(" {Playlist}: No file found", playlistName);
}
allPlaylistsHaveTodaysFile = false;
}
if (allPlaylistsHaveTodaysFile)
{
_logger.LogInformation("=== ALL PLAYLISTS HAVE TODAY'S FILES - SKIPPING STARTUP FETCH ===");
// Calculate when to search next (tomorrow after sync window)
var tomorrowSyncEnd = todaySyncEnd.AddDays(1);
_logger.LogInformation("Will search again after {Time:yyyy-MM-dd HH:mm} UTC", tomorrowSyncEnd);
return false;
}
} }
_logger.LogInformation("=== WILL FETCH ON STARTUP ==="); _logger.LogInformation("=== WILL FETCH ON STARTUP ===");
@@ -273,31 +399,46 @@ public class SpotifyMissingTracksFetcher : BackgroundService
} }
_logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count); _logger.LogInformation("Processing {Count} playlists", _playlistIdToName.Count);
// Track when we find files to optimize search for other playlists
DateTime? firstFoundTime = null;
var foundPlaylists = new HashSet<string>();
foreach (var kvp in _playlistIdToName) foreach (var kvp in _playlistIdToName)
{ {
_logger.LogInformation("Fetching playlist: {Name}", kvp.Value); _logger.LogInformation("Fetching playlist: {Name}", kvp.Value);
await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken); var foundTime = await FetchPlaylistMissingTracksAsync(kvp.Value, cancellationToken, firstFoundTime);
if (foundTime.HasValue)
{
foundPlaylists.Add(kvp.Value);
if (!firstFoundTime.HasValue)
{
firstFoundTime = foundTime;
_logger.LogInformation(" → Will search within ±1h of {Time:yyyy-MM-dd HH:mm} for remaining playlists", firstFoundTime.Value);
}
}
} }
_logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ==="); _logger.LogInformation("=== FINISHED FETCHING MISSING TRACKS ({Found}/{Total} playlists found) ===",
foundPlaylists.Count, _playlistIdToName.Count);
} }
private async Task FetchPlaylistMissingTracksAsync( private async Task<DateTime?> FetchPlaylistMissingTracksAsync(
string playlistName, string playlistName,
CancellationToken cancellationToken) CancellationToken cancellationToken,
DateTime? hintTime = null)
{ {
var cacheKey = $"spotify:missing:{playlistName}"; var cacheKey = $"spotify:missing:{playlistName}";
// Check if we have existing cache and when it was last updated // Check if we have existing cache
var existingTracks = await _cache.GetAsync<List<MissingTrack>>(cacheKey); var existingTracks = await _cache.GetAsync<List<MissingTrack>>(cacheKey);
var existingFileTime = DateTime.MinValue;
var filePath = GetCacheFilePath(playlistName); var filePath = GetCacheFilePath(playlistName);
if (File.Exists(filePath)) if (File.Exists(filePath))
{ {
existingFileTime = File.GetLastWriteTimeUtc(filePath); var fileAge = DateTime.UtcNow - File.GetLastWriteTimeUtc(filePath);
_logger.LogInformation(" Existing cache file from: {Time} ({Age:F1}h ago)", _logger.LogInformation(" Existing cache file age: {Age:F1}h", fileAge.TotalHours);
existingFileTime, (DateTime.UtcNow - existingFileTime).TotalHours);
} }
if (existingTracks != null && existingTracks.Count > 0) if (existingTracks != null && existingTracks.Count > 0)
@@ -316,7 +457,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey)) if (string.IsNullOrEmpty(jellyfinUrl) || string.IsNullOrEmpty(apiKey))
{ {
_logger.LogWarning(" Jellyfin URL or API key not configured, skipping fetch"); _logger.LogWarning(" Jellyfin URL or API key not configured, skipping fetch");
return; return null;
} }
var httpClient = _httpClientFactory.CreateClient(); var httpClient = _httpClientFactory.CreateClient();
@@ -324,29 +465,67 @@ public class SpotifyMissingTracksFetcher : BackgroundService
// Search forward first (newest files), then backwards to handle timezone differences // Search forward first (newest files), then backwards to handle timezone differences
// We want the file with the furthest forward timestamp (most recent) // We want the file with the furthest forward timestamp (most recent)
var now = DateTime.UtcNow; var now = DateTime.UtcNow;
_logger.LogInformation(" Searching +24h forward, then -48h backward from {Now}", now); _logger.LogInformation(" Current UTC time: {Now:yyyy-MM-dd HH:mm}", now);
_logger.LogInformation(" Searching +24h forward, then -48h backward");
var found = false; var found = false;
DateTime? foundFileTime = null; DateTime? foundFileTime = null;
// If we have a hint time from another playlist, search ±1 hour around it first
if (hintTime.HasValue)
{
_logger.LogInformation(" Hint: Searching ±1h around {Time:yyyy-MM-dd HH:mm} (from another playlist)", hintTime.Value);
// Search ±60 minutes around the hint time
for (var minuteOffset = 0; minuteOffset <= 60; minuteOffset++)
{
if (cancellationToken.IsCancellationRequested) break;
// Try both forward and backward from hint
if (minuteOffset > 0)
{
// Try forward
var timeForward = hintTime.Value.AddMinutes(minuteOffset);
var resultForward = await TryFetchMissingTracksFile(playlistName, timeForward, jellyfinUrl, apiKey, httpClient, cancellationToken);
if (resultForward.found)
{
found = true;
foundFileTime = resultForward.fileTime;
_logger.LogInformation(" ✓ Found using hint (+{Minutes}min from hint)", minuteOffset);
return foundFileTime;
}
}
// Try backward
var timeBackward = hintTime.Value.AddMinutes(-minuteOffset);
var resultBackward = await TryFetchMissingTracksFile(playlistName, timeBackward, jellyfinUrl, apiKey, httpClient, cancellationToken);
if (resultBackward.found)
{
found = true;
foundFileTime = resultBackward.fileTime;
_logger.LogInformation(" ✓ Found using hint (-{Minutes}min from hint)", minuteOffset);
return foundFileTime;
}
}
_logger.LogInformation(" Not found within ±1h of hint, doing full search...");
}
// First search forward 24 hours (most likely to find newest files with timezone ahead) // First search forward 24 hours (most likely to find newest files with timezone ahead)
_logger.LogInformation(" Phase 1: Searching forward 24 hours..."); _logger.LogInformation(" Phase 1: Searching forward 24 hours...");
for (var minutesAhead = 1; minutesAhead <= 1440; minutesAhead++) for (var minutesAhead = 1; minutesAhead <= 1440; minutesAhead++)
{ {
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
var time = now.AddMinutes(minutesAhead); var time = now.AddMinutes(minutesAhead);
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime);
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken);
if (result.found) if (result.found)
{ {
found = true; found = true;
foundFileTime = result.fileTime; foundFileTime = result.fileTime;
if (foundFileTime.HasValue) return foundFileTime;
{
_logger.LogInformation(" ✓ Found file from {Time} (+{Offset:F1}h ahead)",
foundFileTime.Value, (foundFileTime.Value - now).TotalHours);
}
break; // Found newest file, stop searching
} }
// Small delay every 60 requests to avoid rate limiting // Small delay every 60 requests to avoid rate limiting
@@ -360,22 +539,19 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (!found) if (!found)
{ {
_logger.LogInformation(" Phase 2: Searching backward 48 hours..."); _logger.LogInformation(" Phase 2: Searching backward 48 hours...");
for (var minutesBehind = 0; minutesBehind <= 2880; minutesBehind++) for (var minutesBehind = 0; minutesBehind <= 2880; minutesBehind++)
{ {
if (cancellationToken.IsCancellationRequested) break; if (cancellationToken.IsCancellationRequested) break;
var time = now.AddMinutes(-minutesBehind); var time = now.AddMinutes(-minutesBehind);
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken, existingFileTime);
var result = await TryFetchMissingTracksFile(playlistName, time, jellyfinUrl, apiKey, httpClient, cancellationToken);
if (result.found) if (result.found)
{ {
found = true; found = true;
foundFileTime = result.fileTime; foundFileTime = result.fileTime;
if (foundFileTime.HasValue) return foundFileTime;
{
_logger.LogInformation(" ✓ Found file from {Time} (-{Offset:F1}h ago)",
foundFileTime.Value, (now - foundFileTime.Value).TotalHours);
}
break;
} }
// Small delay every 60 requests to avoid rate limiting // Small delay every 60 requests to avoid rate limiting
@@ -422,10 +598,8 @@ public class SpotifyMissingTracksFetcher : BackgroundService
_logger.LogWarning(" No existing cache to keep - playlist will be empty until tracks are found"); _logger.LogWarning(" No existing cache to keep - playlist will be empty until tracks are found");
} }
} }
else if (foundFileTime.HasValue)
{ return foundFileTime;
_logger.LogInformation(" ✓ Updated cache with newer file from {Time}", foundFileTime.Value);
}
} }
private async Task<(bool found, DateTime? fileTime)> TryFetchMissingTracksFile( private async Task<(bool found, DateTime? fileTime)> TryFetchMissingTracksFile(
@@ -434,8 +608,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
string jellyfinUrl, string jellyfinUrl,
string apiKey, string apiKey,
HttpClient httpClient, HttpClient httpClient,
CancellationToken cancellationToken, CancellationToken cancellationToken)
DateTime existingFileTime)
{ {
var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json"; var filename = $"{playlistName}_missing_{time:yyyy-MM-dd_HH-mm}.json";
var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" + var url = $"{jellyfinUrl}/Viperinius.Plugin.SpotifyImport/MissingTracksFile" +
@@ -443,7 +616,9 @@ public class SpotifyMissingTracksFetcher : BackgroundService
try try
{ {
_logger.LogDebug("Trying {Filename}", filename); // Log every request with the actual filename
_logger.LogInformation("Checking: {Playlist} at {DateTime}", playlistName, time.ToString("yyyy-MM-dd HH:mm"));
var response = await httpClient.GetAsync(url, cancellationToken); var response = await httpClient.GetAsync(url, cancellationToken);
if (response.IsSuccessStatusCode) if (response.IsSuccessStatusCode)
{ {
@@ -452,13 +627,6 @@ public class SpotifyMissingTracksFetcher : BackgroundService
if (tracks.Count > 0) if (tracks.Count > 0)
{ {
// Check if this file is newer than what we already have
if (time <= existingFileTime)
{
_logger.LogDebug(" Skipping {Filename} - not newer than existing cache", filename);
return (false, null);
}
var cacheKey = $"spotify:missing:{playlistName}"; var cacheKey = $"spotify:missing:{playlistName}";
// Save to both Redis and file with extended TTL until next job runs // Save to both Redis and file with extended TTL until next job runs
@@ -467,7 +635,7 @@ public class SpotifyMissingTracksFetcher : BackgroundService
await SaveToFileCache(playlistName, tracks); await SaveToFileCache(playlistName, tracks);
_logger.LogInformation( _logger.LogInformation(
"✓ Cached {Count} missing tracks for {Playlist} from {Filename} (no expiration until next Jellyfin job)", "✓ FOUND! Cached {Count} missing tracks for {Playlist} from {Filename}",
tracks.Count, playlistName, filename); tracks.Count, playlistName, filename);
return (true, time); return (true, time);
} }

View File

@@ -55,8 +55,12 @@ public class SpotifyTrackMatchingService : BackgroundService
_logger.LogError(ex, "Error during startup track matching"); _logger.LogError(ex, "Error during startup track matching");
} }
// Now start the periodic matching loop
while (!stoppingToken.IsCancellationRequested) while (!stoppingToken.IsCancellationRequested)
{ {
// Wait 30 minutes before next run
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
try try
{ {
await MatchAllPlaylistsAsync(stoppingToken); await MatchAllPlaylistsAsync(stoppingToken);
@@ -65,9 +69,6 @@ public class SpotifyTrackMatchingService : BackgroundService
{ {
_logger.LogError(ex, "Error in track matching service"); _logger.LogError(ex, "Error in track matching service");
} }
// Run every 30 minutes to catch new missing tracks
await Task.Delay(TimeSpan.FromMinutes(30), stoppingToken);
} }
} }

View File

@@ -130,7 +130,8 @@ public class SquidWTFDownloadService : BaseDownloadService
// Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles) // Build organized folder structure: Artist/Album/Track using AlbumArtist (fallback to Artist for singles)
var artistForPath = song.AlbumArtist ?? song.Artist; var artistForPath = song.AlbumArtist ?? song.Artist;
var outputPath = PathHelper.BuildTrackPath(DownloadPath, artistForPath, song.Album, song.Title, song.Track, extension); var basePath = SubsonicSettings.StorageMode == StorageMode.Cache ? CachePath : DownloadPath;
var outputPath = PathHelper.BuildTrackPath(basePath, artistForPath, song.Album, song.Title, song.Track, extension);
// Create directories if they don't exist // Create directories if they don't exist
var albumFolder = Path.GetDirectoryName(outputPath)!; var albumFolder = Path.GetDirectoryName(outputPath)!;

View File

@@ -551,26 +551,36 @@ public class SquidWTFMetadataService : IMusicMetadataService
? volNum.GetInt32() ? volNum.GetInt32()
: null; : null;
// Get artist name - handle both single artist and artists array // Get all artists - Tidal provides both "artist" (singular) and "artists" (plural array)
var allArtists = new List<string>();
string artistName = ""; string artistName = "";
if (track.TryGetProperty("artist", out var artist)) string? artistId = null;
// Prefer the "artists" array as it includes all collaborators
if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
{
foreach (var artistEl in artists.EnumerateArray())
{
var name = artistEl.GetProperty("name").GetString();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
}
}
// First artist is the main artist
if (allArtists.Count > 0)
{
artistName = allArtists[0];
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
}
}
// Fallback to singular "artist" field
else if (track.TryGetProperty("artist", out var artist))
{ {
artistName = artist.GetProperty("name").GetString() ?? ""; artistName = artist.GetProperty("name").GetString() ?? "";
} artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
else if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0) allArtists.Add(artistName);
{
artistName = artists[0].GetProperty("name").GetString() ?? "";
}
// Get artist ID
string? artistId = null;
if (track.TryGetProperty("artist", out var artistForId))
{
artistId = $"ext-squidwtf-artist-{artistForId.GetProperty("id").GetInt64()}";
}
else if (track.TryGetProperty("artists", out var artistsForId) && artistsForId.GetArrayLength() > 0)
{
artistId = $"ext-squidwtf-artist-{artistsForId[0].GetProperty("id").GetInt64()}";
} }
// Get album info // Get album info
@@ -596,6 +606,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
Title = track.GetProperty("title").GetString() ?? "", Title = track.GetProperty("title").GetString() ?? "",
Artist = artistName, Artist = artistName,
ArtistId = artistId, ArtistId = artistId,
Artists = allArtists,
Album = albumTitle, Album = albumTitle,
AlbumId = albumId, AlbumId = albumId,
Duration = track.TryGetProperty("duration", out var duration) Duration = track.TryGetProperty("duration", out var duration)
@@ -649,9 +660,34 @@ public class SquidWTFMetadataService : IMusicMetadataService
} }
} }
// Get artist info // Get all artists - prefer "artists" array for collaborations
string artistName = track.GetProperty("artist").GetProperty("name").GetString() ?? ""; var allArtists = new List<string>();
long artistIdNum = track.GetProperty("artist").GetProperty("id").GetInt64(); string artistName = "";
long artistIdNum = 0;
if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
{
foreach (var artistEl in artists.EnumerateArray())
{
var name = artistEl.GetProperty("name").GetString();
if (!string.IsNullOrEmpty(name))
{
allArtists.Add(name);
}
}
if (allArtists.Count > 0)
{
artistName = allArtists[0];
artistIdNum = artists[0].GetProperty("id").GetInt64();
}
}
else if (track.TryGetProperty("artist", out var artist))
{
artistName = artist.GetProperty("name").GetString() ?? "";
artistIdNum = artist.GetProperty("id").GetInt64();
allArtists.Add(artistName);
}
// Album artist - same as main artist for Tidal tracks // Album artist - same as main artist for Tidal tracks
string? albumArtist = artistName; string? albumArtist = artistName;
@@ -685,6 +721,7 @@ public class SquidWTFMetadataService : IMusicMetadataService
Title = track.GetProperty("title").GetString() ?? "", Title = track.GetProperty("title").GetString() ?? "",
Artist = artistName, Artist = artistName,
ArtistId = $"ext-squidwtf-artist-{artistIdNum}", ArtistId = $"ext-squidwtf-artist-{artistIdNum}",
Artists = allArtists,
Album = albumTitle, Album = albumTitle,
AlbumId = $"ext-squidwtf-album-{albumIdNum}", AlbumId = $"ext-squidwtf-album-{albumIdNum}",
AlbumArtist = albumArtist, AlbumArtist = albumArtist,

50
test-websocket.html Normal file
View File

@@ -0,0 +1,50 @@
<!DOCTYPE html>
<html>
<head>
<title>WebSocket Test</title>
</head>
<body>
<h1>Jellyfin WebSocket Test</h1>
<div id="status">Connecting...</div>
<div id="messages"></div>
<script>
// Replace with your actual token and device ID
const token = "4d19e81402394d40a7e787222606b3c2";
const deviceId = "test-device-123";
// Connect to your proxy
const wsUrl = `ws://jfm.joshpatra.me/socket?api_key=${token}&deviceId=${deviceId}`;
console.log("Connecting to:", wsUrl);
document.getElementById('status').textContent = `Connecting to: ${wsUrl}`;
const ws = new WebSocket(wsUrl);
ws.onopen = () => {
console.log("✓ WebSocket connected!");
document.getElementById('status').textContent = "✓ Connected!";
document.getElementById('status').style.color = "green";
};
ws.onmessage = (event) => {
console.log("Message received:", event.data);
const msgDiv = document.createElement('div');
msgDiv.textContent = `[${new Date().toLocaleTimeString()}] ${event.data}`;
document.getElementById('messages').appendChild(msgDiv);
};
ws.onerror = (error) => {
console.error("WebSocket error:", error);
document.getElementById('status').textContent = "✗ Error!";
document.getElementById('status').style.color = "red";
};
ws.onclose = (event) => {
console.log("WebSocket closed:", event.code, event.reason);
document.getElementById('status').textContent = `✗ Closed (${event.code})`;
document.getElementById('status').style.color = "orange";
};
</script>
</body>
</html>