mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-02-09 23:55:10 -05:00
Compare commits
24 Commits
6eaeee9a67
...
d9375405a5
| Author | SHA1 | Date | |
|---|---|---|---|
|
d9375405a5
|
|||
|
83063f594a
|
|||
|
b40349206d
|
|||
|
8dbac23944
|
|||
|
9fb86d3839
|
|||
|
bb2bda1379
|
|||
|
e9f72efb01
|
|||
|
ab36a43892
|
|||
|
2ffb769a6f
|
|||
|
045c810abc
|
|||
|
4c55520ce0
|
|||
|
04079223c2
|
|||
|
1bb902d96a
|
|||
|
b5f3f54c8b
|
|||
|
3bcb60a09a
|
|||
|
ba78ed0883
|
|||
|
d0f26c0182
|
|||
|
91275a2835
|
|||
|
ccbc9cf859
|
|||
|
97975f1e08
|
|||
|
ff48891a5a
|
|||
|
273fac7a0a
|
|||
|
12436c2f9c
|
|||
|
2315d6ab9f
|
@@ -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
|
else
|
||||||
{
|
{
|
||||||
_logger.LogWarning("⚠️ Enhanced playback start returned status {StatusCode}", statusCode);
|
_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
|
||||||
|
{
|
||||||
|
_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];
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Use configured name if available, otherwise use ID
|
|
||||||
var playlistName = i < _spotifySettings.PlaylistNames.Count
|
var playlistName = i < _spotifySettings.PlaylistNames.Count
|
||||||
? _spotifySettings.PlaylistNames[i]
|
? _spotifySettings.PlaylistNames[i]
|
||||||
: playlistId;
|
: _spotifySettings.PlaylistIds[i];
|
||||||
|
|
||||||
_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}";
|
var cacheKey = $"spotify:missing:{playlistName}";
|
||||||
await _cache.SetAsync(cacheKey, tracks, TimeSpan.FromHours(24));
|
var tracks = await _cache.GetAsync<List<allstarr.Models.Spotify.MissingTrack>>(cacheKey);
|
||||||
|
|
||||||
|
if (tracks != null && tracks.Count > 0)
|
||||||
|
{
|
||||||
results[playlistName] = new {
|
results[playlistName] = new {
|
||||||
status = "success",
|
status = "success",
|
||||||
tracks = tracks.Count,
|
tracks = tracks.Count
|
||||||
filename = filename
|
|
||||||
};
|
};
|
||||||
|
|
||||||
_logger.LogInformation("✓ Cached {Count} missing tracks for {Playlist} from {Filename}",
|
|
||||||
tracks.Count, playlistName, filename);
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
else
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
{
|
||||||
_logger.LogDebug(ex, "Failed to fetch {Filename}", filename);
|
results[playlistName] = new {
|
||||||
}
|
status = "not_found",
|
||||||
}
|
message = "No missing tracks found"
|
||||||
|
};
|
||||||
if (!found)
|
|
||||||
{
|
|
||||||
results[playlistName] = new { status = "not_found", message = "No missing tracks file found" };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
_logger.LogError(ex, "Error syncing playlist {PlaylistId}", playlistId);
|
|
||||||
results[playlistId] = new { status = "error", message = ex.Message };
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,8 +242,16 @@ 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?>
|
||||||
|
{
|
||||||
|
["Name"] = name,
|
||||||
|
["Id"] = index == 0 && song.ArtistId != null
|
||||||
|
? song.ArtistId
|
||||||
|
: $"{song.Id}-artist-{index}"
|
||||||
|
}).ToArray()
|
||||||
|
: new[]
|
||||||
{
|
{
|
||||||
new Dictionary<string, object?>
|
new Dictionary<string, object?>
|
||||||
{
|
{
|
||||||
|
|||||||
450
allstarr/Services/Jellyfin/JellyfinSessionManager.cs
Normal file
450
allstarr/Services/Jellyfin/JellyfinSessionManager.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -99,8 +123,14 @@ public class SpotifyMissingTracksFetcher : BackgroundService
|
|||||||
while (!stoppingToken.IsCancellationRequested)
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
{
|
||||||
|
// 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);
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we're after today's sync window end, check if we already have today's file
|
||||||
|
if (now >= todaySyncEnd)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("We're after today's sync window end - checking if we already fetched today's 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
|
else
|
||||||
{
|
{
|
||||||
_logger.LogInformation("Today's sync window has passed (ended at {End})", todaySyncEnd);
|
_logger.LogInformation(" {Playlist}: No file found", playlistName);
|
||||||
_logger.LogInformation("Will search for new files");
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)!;
|
||||||
|
|||||||
@@ -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)
|
||||||
{
|
{
|
||||||
artistName = artist.GetProperty("name").GetString() ?? "";
|
foreach (var artistEl in artists.EnumerateArray())
|
||||||
|
{
|
||||||
|
var name = artistEl.GetProperty("name").GetString();
|
||||||
|
if (!string.IsNullOrEmpty(name))
|
||||||
|
{
|
||||||
|
allArtists.Add(name);
|
||||||
}
|
}
|
||||||
else if (track.TryGetProperty("artists", out var artists) && artists.GetArrayLength() > 0)
|
|
||||||
{
|
|
||||||
artistName = artists[0].GetProperty("name").GetString() ?? "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get artist ID
|
// First artist is the main artist
|
||||||
string? artistId = null;
|
if (allArtists.Count > 0)
|
||||||
if (track.TryGetProperty("artist", out var artistForId))
|
|
||||||
{
|
{
|
||||||
artistId = $"ext-squidwtf-artist-{artistForId.GetProperty("id").GetInt64()}";
|
artistName = allArtists[0];
|
||||||
|
artistId = $"ext-squidwtf-artist-{artists[0].GetProperty("id").GetInt64()}";
|
||||||
}
|
}
|
||||||
else if (track.TryGetProperty("artists", out var artistsForId) && artistsForId.GetArrayLength() > 0)
|
}
|
||||||
|
// Fallback to singular "artist" field
|
||||||
|
else if (track.TryGetProperty("artist", out var artist))
|
||||||
{
|
{
|
||||||
artistId = $"ext-squidwtf-artist-{artistsForId[0].GetProperty("id").GetInt64()}";
|
artistName = artist.GetProperty("name").GetString() ?? "";
|
||||||
|
artistId = $"ext-squidwtf-artist-{artist.GetProperty("id").GetInt64()}";
|
||||||
|
allArtists.Add(artistName);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
50
test-websocket.html
Normal 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>
|
||||||
Reference in New Issue
Block a user