mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-04-27 03:53:10 -04:00
Compare commits
12 Commits
d89dd5e7db
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
cc519c7818
|
|||
|
cae8eae509
|
|||
|
3e28af4f4f
|
|||
|
a87102c8d8
|
|||
|
1fb57aadf0
|
|||
|
a840a22cc2
|
|||
|
317369d120
|
|||
|
b23678e95a
|
|||
|
00a6cbc20e
|
|||
|
34d307fd4e
|
|||
|
ca9813f1ea
|
|||
|
dc4e5b907a
|
@@ -7,42 +7,52 @@ assignees: SoPat712
|
||||
|
||||
---
|
||||
|
||||
**Describe the bug**
|
||||
## Describe the bug
|
||||
|
||||
A clear and concise description of what the bug is.
|
||||
|
||||
**To Reproduce**
|
||||
## To Reproduce
|
||||
|
||||
Steps to reproduce the behavior:
|
||||
|
||||
1. Go to '...'
|
||||
2. Click on '....'
|
||||
3. Scroll down to '....'
|
||||
2. Click on '...'
|
||||
3. Scroll down to '...'
|
||||
4. See error
|
||||
|
||||
**Expected behavior**
|
||||
## Expected behavior
|
||||
|
||||
A clear and concise description of what you expected to happen.
|
||||
|
||||
**Screenshots**
|
||||
If applicable, add screenshots to help explain your problem.
|
||||
## Additional context
|
||||
|
||||
**Details (please complete the following information):**
|
||||
- Version [e.g. v1.1.3]
|
||||
- Client [e.g. Feishin]
|
||||
Add any other context, screenshots, or surrounding details here.
|
||||
|
||||
<details>
|
||||
## Safe diagnostics from Allstarr
|
||||
|
||||
<summary>Please paste your docker-compose.yaml in between the tickmarks</summary>
|
||||
- Sensitive values stay redacted in this block.
|
||||
- Allstarr Version: [e.g. v1.5.3]
|
||||
- Backend Type: [e.g. Jellyfin]
|
||||
- Music Service: [e.g. SquidWTF]
|
||||
- Storage Mode: [e.g. Cache]
|
||||
- Download Mode: [e.g. Track]
|
||||
- Redis Enabled: [e.g. Yes]
|
||||
- Spotify Import Enabled: [e.g. Yes]
|
||||
- Scrobbling Enabled: [e.g. Disabled]
|
||||
- Spotify Status: [e.g. Spotify Ready]
|
||||
- Jellyfin URL: [Configured (redacted) or Not configured]
|
||||
- Client: [e.g. Firefox 149 on macOS]
|
||||
- Generated At (UTC): [e.g. 2026-04-19T02:18:52.483Z]
|
||||
- Browser Time Zone: [e.g. America/New_York]
|
||||
|
||||
## docker-compose.yaml (optional)
|
||||
|
||||
```yaml
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary>Please paste your .env file REDACTED OF ALL PASSWORDS in between the tickmarks:</summary>
|
||||
## .env (redacted, optional)
|
||||
|
||||
```env
|
||||
|
||||
```
|
||||
</details>
|
||||
|
||||
**Additional context**
|
||||
Add any other context about the problem here.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Allstarr Documentation
|
||||
url: https://github.com/SoPat712/allstarr#readme
|
||||
about: Check the setup and usage docs before filing a new issue.
|
||||
@@ -7,14 +7,35 @@ assignees: SoPat712
|
||||
|
||||
---
|
||||
|
||||
**Is your feature request related to a problem? Please describe.**
|
||||
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
|
||||
## Problem to solve
|
||||
|
||||
A clear and concise description of the problem this feature should solve.
|
||||
|
||||
## Solution you'd like
|
||||
|
||||
**Describe the solution you'd like**
|
||||
A clear and concise description of what you want to happen.
|
||||
|
||||
**Describe alternatives you've considered**
|
||||
A clear and concise description of any alternative solutions or features you've considered.
|
||||
## Alternatives considered
|
||||
|
||||
A clear and concise description of any alternative solutions or workarounds you've considered.
|
||||
|
||||
## Additional context
|
||||
|
||||
**Additional context**
|
||||
Add any other context or screenshots about the feature request here.
|
||||
|
||||
## Safe diagnostics from Allstarr (optional)
|
||||
|
||||
- Sensitive values stay redacted in this block.
|
||||
- Allstarr Version: [e.g. v1.5.3]
|
||||
- Backend Type: [e.g. Jellyfin]
|
||||
- Music Service: [e.g. SquidWTF]
|
||||
- Storage Mode: [e.g. Cache]
|
||||
- Download Mode: [e.g. Track]
|
||||
- Redis Enabled: [e.g. Yes]
|
||||
- Spotify Import Enabled: [e.g. Yes]
|
||||
- Scrobbling Enabled: [e.g. Disabled]
|
||||
- Spotify Status: [e.g. Spotify Ready]
|
||||
- Jellyfin URL: [Configured (redacted) or Not configured]
|
||||
- Client: [e.g. Firefox 149 on macOS]
|
||||
- Generated At (UTC): [e.g. 2026-04-19T02:18:52.483Z]
|
||||
- Browser Time Zone: [e.g. America/New_York]
|
||||
|
||||
@@ -9,5 +9,5 @@ public static class AppVersion
|
||||
/// <summary>
|
||||
/// Current application version.
|
||||
/// </summary>
|
||||
public const string Version = "1.5.3";
|
||||
public const string Version = "1.5.4";
|
||||
}
|
||||
|
||||
@@ -114,7 +114,8 @@ public class AdminAuthController : ControllerBase
|
||||
userName: userName,
|
||||
isAdministrator: isAdministrator,
|
||||
jellyfinAccessToken: accessToken,
|
||||
jellyfinServerId: serverId);
|
||||
jellyfinServerId: serverId,
|
||||
isPersistent: request.RememberMe);
|
||||
|
||||
SetSessionCookie(session.SessionId, session.ExpiresAtUtc);
|
||||
|
||||
@@ -130,6 +131,7 @@ public class AdminAuthController : ControllerBase
|
||||
name = session.UserName,
|
||||
isAdministrator = session.IsAdministrator
|
||||
},
|
||||
rememberMe = session.IsPersistent,
|
||||
expiresAtUtc = session.ExpiresAtUtc
|
||||
});
|
||||
}
|
||||
@@ -159,6 +161,7 @@ public class AdminAuthController : ControllerBase
|
||||
name = session.UserName,
|
||||
isAdministrator = session.IsAdministrator
|
||||
},
|
||||
rememberMe = session.IsPersistent,
|
||||
expiresAtUtc = session.ExpiresAtUtc
|
||||
});
|
||||
}
|
||||
@@ -196,6 +199,7 @@ public class AdminAuthController : ControllerBase
|
||||
{
|
||||
public string? Username { get; set; }
|
||||
public string? Password { get; set; }
|
||||
public bool RememberMe { get; set; }
|
||||
}
|
||||
|
||||
private sealed class JellyfinAuthenticateRequest
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using allstarr.Services.Common;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using System.Net;
|
||||
|
||||
namespace allstarr.Controllers;
|
||||
|
||||
@@ -193,21 +194,73 @@ public partial class JellyfinController
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return HandleExternalStreamFailure(provider, externalId, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private IActionResult HandleExternalStreamFailure(string provider, string externalId, Exception ex)
|
||||
{
|
||||
if (HttpContext.RequestAborted.IsCancellationRequested && ex is OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Client aborted external stream request for {Provider}:{ExternalId}", provider, externalId);
|
||||
return StatusCode(499);
|
||||
}
|
||||
|
||||
var (statusCode, errorMessage) = MapExternalStreamException(ex);
|
||||
|
||||
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
|
||||
{
|
||||
_logger.LogError("Failed to stream external song {Provider}:{ExternalId}: {StatusCode}: {ReasonPhrase}",
|
||||
_logger.LogError("Failed to stream external song {Provider}:{ExternalId}: responding {StatusCode}; upstream returned {UpstreamStatus}: {ReasonPhrase}",
|
||||
provider,
|
||||
externalId,
|
||||
statusCode,
|
||||
(int)httpRequestException.StatusCode.Value,
|
||||
httpRequestException.StatusCode.Value);
|
||||
_logger.LogDebug(ex, "Detailed streaming failure for external song {Provider}:{ExternalId}", provider, externalId);
|
||||
}
|
||||
else
|
||||
{
|
||||
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}", provider, externalId);
|
||||
_logger.LogError(ex, "Failed to stream external song {Provider}:{ExternalId}: responding {StatusCode}",
|
||||
provider, externalId, statusCode);
|
||||
}
|
||||
return StatusCode(500, new { error = "Streaming failed" });
|
||||
|
||||
return StatusCode(statusCode, new { error = errorMessage });
|
||||
}
|
||||
|
||||
private static (int statusCode, string errorMessage) MapExternalStreamException(Exception ex)
|
||||
{
|
||||
if (ex is TimeoutException || ex is TaskCanceledException)
|
||||
{
|
||||
return (StatusCodes.Status504GatewayTimeout, "External provider timed out");
|
||||
}
|
||||
|
||||
if (ex is HttpRequestException httpRequestException)
|
||||
{
|
||||
return httpRequestException.StatusCode switch
|
||||
{
|
||||
HttpStatusCode.NotFound => (StatusCodes.Status404NotFound, "External track not found"),
|
||||
HttpStatusCode.TooManyRequests => (StatusCodes.Status503ServiceUnavailable, "External provider is rate limiting requests"),
|
||||
HttpStatusCode.BadGateway or
|
||||
HttpStatusCode.ServiceUnavailable or
|
||||
HttpStatusCode.GatewayTimeout or
|
||||
HttpStatusCode.InternalServerError => (StatusCodes.Status503ServiceUnavailable, "External provider is unavailable"),
|
||||
_ => (StatusCodes.Status502BadGateway, "External provider request failed")
|
||||
};
|
||||
}
|
||||
|
||||
if (ex is InvalidOperationException invalidOperationException &&
|
||||
invalidOperationException.Message.Contains("endpoints", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (StatusCodes.Status503ServiceUnavailable, "External provider has no healthy endpoints");
|
||||
}
|
||||
|
||||
if (ex.Message.Contains("endpoints failed", StringComparison.OrdinalIgnoreCase) ||
|
||||
ex.Message.Contains("No SquidWTF endpoints", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return (StatusCodes.Status503ServiceUnavailable, "External provider has no healthy endpoints");
|
||||
}
|
||||
|
||||
return (StatusCodes.Status502BadGateway, "External stream failed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -820,9 +820,14 @@ public partial class JellyfinController : ControllerBase
|
||||
{
|
||||
try
|
||||
{
|
||||
var (itemResult, statusCode) = await _proxyService.GetJsonAsyncInternal($"Items/{itemId}");
|
||||
var (itemResult, statusCode) = await _proxyService.GetJsonAsync($"Items/{itemId}", null, Request.Headers);
|
||||
if (itemResult == null || statusCode != 200)
|
||||
{
|
||||
_logger.LogDebug(
|
||||
"Skipping Jellyfin {ImageType} image tag resolution for Spotify playlist {PlaylistId}: upstream returned {StatusCode}",
|
||||
imageType,
|
||||
itemId,
|
||||
statusCode);
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@@ -173,8 +173,35 @@ public class SubsonicController : ControllerBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (HttpContext.RequestAborted.IsCancellationRequested && ex is OperationCanceledException)
|
||||
{
|
||||
_logger.LogInformation("Client aborted external Subsonic stream request for {Id}", id);
|
||||
return StatusCode(499);
|
||||
}
|
||||
|
||||
if (ex is HttpRequestException httpRequestException && httpRequestException.StatusCode.HasValue)
|
||||
{
|
||||
var statusCode = httpRequestException.StatusCode == System.Net.HttpStatusCode.NotFound ? 404 : 503;
|
||||
_logger.LogError(ex, "Failed to stream external Subsonic item {Id}: responding {StatusCode}; upstream returned {UpstreamStatus}",
|
||||
id, statusCode, (int)httpRequestException.StatusCode.Value);
|
||||
return StatusCode(statusCode, new { error = statusCode == 404 ? "External track not found" : "External provider unavailable" });
|
||||
}
|
||||
|
||||
if (ex is TimeoutException || ex is TaskCanceledException)
|
||||
{
|
||||
_logger.LogError(ex, "Timed out streaming external Subsonic item {Id}", id);
|
||||
return StatusCode(504, new { error = "External provider timed out" });
|
||||
}
|
||||
|
||||
if (ex is InvalidOperationException invalidOperationException &&
|
||||
invalidOperationException.Message.Contains("endpoints", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
_logger.LogError(ex, "No healthy endpoints available for external Subsonic item {Id}", id);
|
||||
return StatusCode(503, new { error = "External provider has no healthy endpoints" });
|
||||
}
|
||||
|
||||
_logger.LogError(ex, "Failed to stream external Subsonic item {Id}", id);
|
||||
return StatusCode(500, new { error = "Failed to stream" });
|
||||
return StatusCode(502, new { error = "External stream failed" });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+10
-2
@@ -12,8 +12,10 @@ using allstarr.Services.Lyrics;
|
||||
using allstarr.Services.Scrobbling;
|
||||
using allstarr.Middleware;
|
||||
using allstarr.Filters;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Http;
|
||||
using System.Net;
|
||||
using System.IO;
|
||||
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
RuntimeEnvConfiguration.AddDotEnvOverrides(builder.Configuration, builder.Environment, Console.Out);
|
||||
@@ -161,6 +163,7 @@ builder.Services.AddControllers()
|
||||
});
|
||||
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddHttpClient("SquidWTF");
|
||||
builder.Services.ConfigureAll<HttpClientFactoryOptions>(options =>
|
||||
{
|
||||
options.HttpMessageHandlerBuilderActions.Add(builder =>
|
||||
@@ -198,6 +201,11 @@ builder.Services.AddHttpClient(JellyfinProxyService.HttpClientName)
|
||||
builder.Services.AddEndpointsApiExplorer();
|
||||
builder.Services.AddSwaggerGen();
|
||||
builder.Services.AddHttpContextAccessor();
|
||||
var dataProtectionKeysDirectory = new DirectoryInfo("/app/cache/data-protection");
|
||||
dataProtectionKeysDirectory.Create();
|
||||
builder.Services.AddDataProtection()
|
||||
.PersistKeysToFileSystem(dataProtectionKeysDirectory)
|
||||
.SetApplicationName("allstarr-admin");
|
||||
|
||||
// Exception handling
|
||||
builder.Services.AddExceptionHandler<GlobalExceptionHandler>();
|
||||
@@ -636,7 +644,7 @@ builder.Services.AddSingleton<IStartupValidator, QobuzStartupValidator>();
|
||||
builder.Services.AddSingleton<IStartupValidator>(sp =>
|
||||
new SquidWTFStartupValidator(
|
||||
sp.GetRequiredService<Microsoft.Extensions.Options.IOptions<SquidWTFSettings>>(),
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient(),
|
||||
sp.GetRequiredService<IHttpClientFactory>().CreateClient("SquidWTF"),
|
||||
squidWtfApiUrls,
|
||||
squidWtfStreamingUrls,
|
||||
sp.GetRequiredService<EndpointBenchmarkService>(),
|
||||
@@ -946,7 +954,7 @@ app.UseMiddleware<BotProbeBlockMiddleware>();
|
||||
// Request logging middleware (when DEBUG_LOG_ALL_REQUESTS=true)
|
||||
app.UseMiddleware<RequestLoggingMiddleware>();
|
||||
|
||||
app.UseExceptionHandler(_ => { }); // Global exception handler
|
||||
app.UseExceptionHandler(); // Use registered GlobalExceptionHandler
|
||||
|
||||
// Enable response compression EARLY in the pipeline
|
||||
app.UseResponseCompression();
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
namespace allstarr.Services.Admin;
|
||||
|
||||
@@ -11,27 +14,83 @@ public sealed class AdminAuthSession
|
||||
public required bool IsAdministrator { get; init; }
|
||||
public required string JellyfinAccessToken { get; init; }
|
||||
public string? JellyfinServerId { get; init; }
|
||||
public required DateTime ExpiresAtUtc { get; init; }
|
||||
public bool IsPersistent { get; init; }
|
||||
public required DateTime ExpiresAtUtc { get; set; }
|
||||
public DateTime LastSeenUtc { get; set; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// In-memory authenticated admin sessions for the local Web UI.
|
||||
/// Cookie-backed admin sessions for the local Web UI.
|
||||
/// Session IDs stay in the browser cookie, while the authenticated Jellyfin
|
||||
/// session details are protected and persisted on disk so brief app restarts
|
||||
/// do not force a relogin.
|
||||
/// </summary>
|
||||
public class AdminAuthSessionService
|
||||
{
|
||||
public const string SessionCookieName = "allstarr_admin_session";
|
||||
public const string HttpContextSessionItemKey = "__allstarr_admin_auth_session";
|
||||
|
||||
private static readonly TimeSpan SessionLifetime = TimeSpan.FromHours(12);
|
||||
public static readonly TimeSpan DefaultSessionLifetime = TimeSpan.FromHours(12);
|
||||
public static readonly TimeSpan PersistentSessionLifetime = TimeSpan.FromDays(30);
|
||||
|
||||
private readonly ConcurrentDictionary<string, AdminAuthSession> _sessions = new();
|
||||
private readonly IDataProtector _protector;
|
||||
private readonly ILogger<AdminAuthSessionService> _logger;
|
||||
private readonly JsonSerializerOptions _jsonOptions = new(JsonSerializerDefaults.Web);
|
||||
private readonly object _persistLock = new();
|
||||
private readonly string _sessionStoreFilePath;
|
||||
|
||||
public AdminAuthSessionService(
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
ILogger<AdminAuthSessionService> logger)
|
||||
: this(
|
||||
dataProtectionProvider,
|
||||
logger,
|
||||
"/app/cache/admin-auth/sessions.protected")
|
||||
{
|
||||
}
|
||||
|
||||
private AdminAuthSessionService(
|
||||
IDataProtectionProvider dataProtectionProvider,
|
||||
ILogger<AdminAuthSessionService> logger,
|
||||
string sessionStoreFilePath)
|
||||
{
|
||||
_protector = dataProtectionProvider.CreateProtector("allstarr.admin.auth.sessions.v1");
|
||||
_logger = logger;
|
||||
_sessionStoreFilePath = sessionStoreFilePath;
|
||||
|
||||
var directory = Path.GetDirectoryName(_sessionStoreFilePath);
|
||||
if (!string.IsNullOrWhiteSpace(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
LoadSessionsFromDisk();
|
||||
}
|
||||
|
||||
public AdminAuthSessionService(ILogger<AdminAuthSessionService> logger)
|
||||
: this(
|
||||
CreateFallbackDataProtectionProvider(),
|
||||
logger,
|
||||
Path.Combine(Path.GetTempPath(), "allstarr-admin-auth", "sessions.protected"))
|
||||
{
|
||||
}
|
||||
|
||||
public AdminAuthSessionService()
|
||||
: this(
|
||||
CreateFallbackDataProtectionProvider(),
|
||||
NullLogger<AdminAuthSessionService>.Instance,
|
||||
Path.Combine(Path.GetTempPath(), "allstarr-admin-auth", "sessions.protected"))
|
||||
{
|
||||
}
|
||||
|
||||
public AdminAuthSession CreateSession(
|
||||
string userId,
|
||||
string userName,
|
||||
bool isAdministrator,
|
||||
string jellyfinAccessToken,
|
||||
string? jellyfinServerId)
|
||||
string? jellyfinServerId,
|
||||
bool isPersistent = false)
|
||||
{
|
||||
RemoveExpiredSessions();
|
||||
|
||||
@@ -44,11 +103,13 @@ public class AdminAuthSessionService
|
||||
IsAdministrator = isAdministrator,
|
||||
JellyfinAccessToken = jellyfinAccessToken,
|
||||
JellyfinServerId = jellyfinServerId,
|
||||
ExpiresAtUtc = now.Add(SessionLifetime),
|
||||
IsPersistent = isPersistent,
|
||||
ExpiresAtUtc = now.Add(isPersistent ? PersistentSessionLifetime : DefaultSessionLifetime),
|
||||
LastSeenUtc = now
|
||||
};
|
||||
|
||||
_sessions[session.SessionId] = session;
|
||||
PersistSessions();
|
||||
return session;
|
||||
}
|
||||
|
||||
@@ -69,6 +130,7 @@ public class AdminAuthSessionService
|
||||
if (existing.ExpiresAtUtc <= DateTime.UtcNow)
|
||||
{
|
||||
_sessions.TryRemove(sessionId, out _);
|
||||
PersistSessions();
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -84,17 +146,117 @@ public class AdminAuthSessionService
|
||||
return;
|
||||
}
|
||||
|
||||
_sessions.TryRemove(sessionId, out _);
|
||||
if (_sessions.TryRemove(sessionId, out _))
|
||||
{
|
||||
PersistSessions();
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveExpiredSessions()
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
var removedAny = false;
|
||||
foreach (var kvp in _sessions)
|
||||
{
|
||||
if (kvp.Value.ExpiresAtUtc <= now)
|
||||
if (kvp.Value.ExpiresAtUtc <= now &&
|
||||
_sessions.TryRemove(kvp.Key, out _))
|
||||
{
|
||||
_sessions.TryRemove(kvp.Key, out _);
|
||||
removedAny = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (removedAny)
|
||||
{
|
||||
PersistSessions();
|
||||
}
|
||||
}
|
||||
|
||||
private void LoadSessionsFromDisk()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(_sessionStoreFilePath))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var protectedPayload = File.ReadAllText(_sessionStoreFilePath);
|
||||
if (string.IsNullOrWhiteSpace(protectedPayload))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var json = _protector.Unprotect(protectedPayload);
|
||||
var sessions = JsonSerializer.Deserialize<List<PersistedAdminAuthSession>>(json, _jsonOptions)
|
||||
?? [];
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
foreach (var persisted in sessions)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(persisted.SessionId) ||
|
||||
string.IsNullOrWhiteSpace(persisted.UserId) ||
|
||||
string.IsNullOrWhiteSpace(persisted.UserName) ||
|
||||
string.IsNullOrWhiteSpace(persisted.JellyfinAccessToken) ||
|
||||
persisted.ExpiresAtUtc <= now)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
_sessions[persisted.SessionId] = new AdminAuthSession
|
||||
{
|
||||
SessionId = persisted.SessionId,
|
||||
UserId = persisted.UserId,
|
||||
UserName = persisted.UserName,
|
||||
IsAdministrator = persisted.IsAdministrator,
|
||||
JellyfinAccessToken = persisted.JellyfinAccessToken,
|
||||
JellyfinServerId = persisted.JellyfinServerId,
|
||||
IsPersistent = persisted.IsPersistent,
|
||||
ExpiresAtUtc = persisted.ExpiresAtUtc,
|
||||
LastSeenUtc = persisted.LastSeenUtc
|
||||
};
|
||||
}
|
||||
|
||||
if (_sessions.Count > 0)
|
||||
{
|
||||
_logger.LogInformation("Loaded {Count} persisted admin auth sessions", _sessions.Count);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(ex, "Failed to load persisted admin auth sessions; starting with an empty session store");
|
||||
_sessions.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void PersistSessions()
|
||||
{
|
||||
lock (_persistLock)
|
||||
{
|
||||
try
|
||||
{
|
||||
var activeSessions = _sessions.Values
|
||||
.Where(session => session.ExpiresAtUtc > DateTime.UtcNow)
|
||||
.Select(session => new PersistedAdminAuthSession
|
||||
{
|
||||
SessionId = session.SessionId,
|
||||
UserId = session.UserId,
|
||||
UserName = session.UserName,
|
||||
IsAdministrator = session.IsAdministrator,
|
||||
JellyfinAccessToken = session.JellyfinAccessToken,
|
||||
JellyfinServerId = session.JellyfinServerId,
|
||||
IsPersistent = session.IsPersistent,
|
||||
ExpiresAtUtc = session.ExpiresAtUtc,
|
||||
LastSeenUtc = session.LastSeenUtc
|
||||
})
|
||||
.ToList();
|
||||
|
||||
var json = JsonSerializer.Serialize(activeSessions, _jsonOptions);
|
||||
var protectedPayload = _protector.Protect(json);
|
||||
File.WriteAllText(_sessionStoreFilePath, protectedPayload);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogError(ex, "Failed to persist admin auth sessions");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,4 +267,27 @@ public class AdminAuthSessionService
|
||||
RandomNumberGenerator.Fill(bytes);
|
||||
return Convert.ToHexString(bytes).ToLowerInvariant();
|
||||
}
|
||||
|
||||
private static IDataProtectionProvider CreateFallbackDataProtectionProvider()
|
||||
{
|
||||
var keysDirectory = new DirectoryInfo(Path.Combine(Path.GetTempPath(), "allstarr-admin-auth-keys"));
|
||||
keysDirectory.Create();
|
||||
return DataProtectionProvider.Create(keysDirectory, configuration =>
|
||||
{
|
||||
configuration.SetApplicationName("allstarr-admin");
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class PersistedAdminAuthSession
|
||||
{
|
||||
public required string SessionId { get; init; }
|
||||
public required string UserId { get; init; }
|
||||
public required string UserName { get; init; }
|
||||
public required bool IsAdministrator { get; init; }
|
||||
public required string JellyfinAccessToken { get; init; }
|
||||
public string? JellyfinServerId { get; init; }
|
||||
public required bool IsPersistent { get; init; }
|
||||
public required DateTime ExpiresAtUtc { get; init; }
|
||||
public required DateTime LastSeenUtc { get; init; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,16 @@ public class RoundRobinFallbackHelper
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_serviceName = serviceName ?? "Service";
|
||||
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("API URLs list cannot be empty", nameof(apiUrls));
|
||||
}
|
||||
|
||||
// Create a dedicated HttpClient for health checks with short timeout
|
||||
_healthCheckClient = new HttpClient
|
||||
{
|
||||
Timeout = TimeSpan.FromSeconds(3) // Quick health check timeout
|
||||
};
|
||||
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("{Service} initialized with zero endpoints; external provider is currently unavailable", _serviceName);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -124,6 +124,11 @@ public class RoundRobinFallbackHelper
|
||||
/// </summary>
|
||||
private async Task<List<string>> GetHealthyEndpointsAsync()
|
||||
{
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
|
||||
var healthCheckTasks = _apiUrls.Select(async url => new
|
||||
{
|
||||
Url = url,
|
||||
@@ -212,6 +217,11 @@ public class RoundRobinFallbackHelper
|
||||
/// </summary>
|
||||
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action)
|
||||
{
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No {_serviceName} endpoints are configured");
|
||||
}
|
||||
|
||||
// Get healthy endpoints first (with caching to avoid excessive checks)
|
||||
var healthyEndpoints = await GetHealthyEndpointsAsync();
|
||||
|
||||
@@ -253,16 +263,17 @@ public class RoundRobinFallbackHelper
|
||||
throw new Exception($"All {_serviceName} endpoints failed");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Races all endpoints in parallel and returns the first successful result.
|
||||
/// Cancels remaining requests once one succeeds. Great for latency-sensitive operations.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Races the top N fastest endpoints in parallel and returns the first successful result.
|
||||
/// Cancels remaining requests once one succeeds. Used for latency-sensitive operations like search.
|
||||
/// </summary>
|
||||
public async Task<T> RaceTopEndpointsAsync<T>(int topN, Func<string, CancellationToken, Task<T>> action, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException($"No {_serviceName} endpoints are configured");
|
||||
}
|
||||
|
||||
if (_apiUrls.Count == 1 || topN <= 1)
|
||||
{
|
||||
// No point racing with one endpoint - use fallback instead
|
||||
@@ -277,6 +288,9 @@ public class RoundRobinFallbackHelper
|
||||
return await action(endpointsToRace[0], cancellationToken);
|
||||
}
|
||||
|
||||
_logger.LogInformation("Racing {Count} {Service} endpoints: {Endpoints}",
|
||||
endpointsToRace.Count, _serviceName, string.Join(", ", endpointsToRace));
|
||||
|
||||
using var raceCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
var tasks = new List<Task<(T result, string endpoint, bool success)>>();
|
||||
|
||||
@@ -309,7 +323,7 @@ public class RoundRobinFallbackHelper
|
||||
|
||||
if (success)
|
||||
{
|
||||
_logger.LogDebug("🏆 {Service} race won by {Endpoint}, canceling others", _serviceName, endpoint);
|
||||
_logger.LogInformation("{Service} race won by {Endpoint}", _serviceName, endpoint);
|
||||
raceCts.Cancel(); // Cancel all other requests
|
||||
return result;
|
||||
}
|
||||
@@ -317,6 +331,8 @@ public class RoundRobinFallbackHelper
|
||||
tasks.Remove(completedTask);
|
||||
}
|
||||
|
||||
_logger.LogError("All raced {Service} endpoints failed: {Endpoints}",
|
||||
_serviceName, string.Join(", ", endpointsToRace));
|
||||
throw new Exception($"All {topN} {_serviceName} endpoints failed in race");
|
||||
}
|
||||
|
||||
@@ -327,6 +343,12 @@ public class RoundRobinFallbackHelper
|
||||
/// </summary>
|
||||
public async Task<T> TryWithFallbackAsync<T>(Func<string, Task<T>> action, T defaultValue)
|
||||
{
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No {Service} endpoints are configured, returning default value", _serviceName);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Get healthy endpoints first (with caching to avoid excessive checks)
|
||||
var healthyEndpoints = await GetHealthyEndpointsAsync();
|
||||
|
||||
@@ -383,6 +405,12 @@ public class RoundRobinFallbackHelper
|
||||
throw new ArgumentNullException(nameof(isAcceptableResult));
|
||||
}
|
||||
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No {Service} endpoints are configured, returning default value", _serviceName);
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
// Get healthy endpoints first (with caching to avoid excessive checks)
|
||||
var healthyEndpoints = await GetHealthyEndpointsAsync();
|
||||
|
||||
@@ -483,6 +511,12 @@ public class RoundRobinFallbackHelper
|
||||
return new List<TResult>();
|
||||
}
|
||||
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
_logger.LogWarning("No {Service} endpoints are configured, skipping parallel processing", _serviceName);
|
||||
return new List<TResult>();
|
||||
}
|
||||
|
||||
var results = new List<TResult>();
|
||||
var resultsLock = new object();
|
||||
var itemQueue = new Queue<TItem>(items);
|
||||
|
||||
@@ -73,7 +73,7 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
List<string> apiUrls)
|
||||
: base(configuration, localLibraryService, metadataService, subsonicSettings.Value, serviceProvider, logger)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient = httpClientFactory.CreateClient("SquidWTF");
|
||||
_squidwtfSettings = SquidWTFSettings.Value;
|
||||
_odesliService = odesliService;
|
||||
_fallbackHelper = new RoundRobinFallbackHelper(apiUrls, logger, "SquidWTF");
|
||||
@@ -98,11 +98,21 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
|
||||
private async Task<string> RunDownloadWithFallbackAsync(string trackId, Song song, string quality, string basePath, CancellationToken cancellationToken)
|
||||
{
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async baseUrl =>
|
||||
{
|
||||
var songId = BuildTrackedSongId(trackId);
|
||||
var downloadInfo = await FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, cancellationToken);
|
||||
var raceCount = Math.Min(3, _fallbackHelper.EndpointCount);
|
||||
|
||||
if (raceCount > 1)
|
||||
{
|
||||
Logger.LogInformation(
|
||||
"Racing top {EndpointCount} SquidWTF endpoints for track {TrackId} manifest resolution",
|
||||
raceCount, trackId);
|
||||
}
|
||||
|
||||
var downloadInfo = await _fallbackHelper.RaceTopEndpointsAsync(
|
||||
Math.Max(1, raceCount),
|
||||
(baseUrl, ct) => FetchTrackDownloadInfoAsync(baseUrl, trackId, quality, ct),
|
||||
cancellationToken);
|
||||
|
||||
Logger.LogInformation(
|
||||
"Track download info resolved via {Endpoint} (Format: {Format}, Quality: {Quality})",
|
||||
@@ -179,7 +189,6 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
|
||||
await WriteMetadataAsync(outputPath, song, cancellationToken);
|
||||
return outputPath;
|
||||
});
|
||||
}
|
||||
|
||||
protected override async Task<string> DownloadTrackAsync(string trackId, Song song, CancellationToken cancellationToken)
|
||||
@@ -315,7 +324,9 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
{
|
||||
var url = $"{baseUrl}/track/?id={trackId}&quality={quality}";
|
||||
|
||||
Logger.LogDebug("Fetching track download info from: {Url}", url);
|
||||
Logger.LogInformation("Requesting SquidWTF track manifest for track {TrackId} from {Endpoint} at quality {Quality}",
|
||||
trackId, baseUrl, quality);
|
||||
Logger.LogDebug("Fetching SquidWTF track download info from: {Url}", url);
|
||||
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
|
||||
@@ -357,6 +368,9 @@ public class SquidWTFDownloadService : BaseDownloadService
|
||||
? audioQualityEl.GetString()
|
||||
: quality;
|
||||
|
||||
Logger.LogInformation("SquidWTF track manifest resolved for track {TrackId} via {Endpoint} (mimeType={MimeType}, audioQuality={AudioQuality})",
|
||||
trackId, baseUrl, mimeType ?? "audio/flac", audioQuality ?? quality);
|
||||
|
||||
return new DownloadResult
|
||||
{
|
||||
Endpoint = baseUrl,
|
||||
|
||||
@@ -76,7 +76,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
||||
List<string> apiUrls,
|
||||
GenreEnrichmentService? genreEnrichment = null)
|
||||
{
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
_httpClient = httpClientFactory.CreateClient("SquidWTF");
|
||||
_settings = settings.Value;
|
||||
_logger = logger;
|
||||
_cache = cache;
|
||||
@@ -583,21 +583,55 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
||||
{
|
||||
if (externalProvider != "squidwtf") return null;
|
||||
|
||||
return await _fallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
var raceCount = Math.Min(3, _fallbackHelper.EndpointCount);
|
||||
|
||||
if (raceCount > 1)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Racing top {EndpointCount} SquidWTF endpoints for track {TrackId} metadata resolution",
|
||||
raceCount,
|
||||
externalId);
|
||||
|
||||
try
|
||||
{
|
||||
return await _fallbackHelper.RaceTopEndpointsAsync(
|
||||
raceCount,
|
||||
(baseUrl, ct) => FetchSongAsync(baseUrl, externalId, ct),
|
||||
cancellationToken);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
ex,
|
||||
"Raced SquidWTF metadata lookup failed for track {TrackId}; falling back to sequential failover",
|
||||
externalId);
|
||||
}
|
||||
}
|
||||
|
||||
return await _fallbackHelper.TryWithFallbackAsync(
|
||||
baseUrl => FetchSongAsync(baseUrl, externalId, cancellationToken),
|
||||
(Song?)null);
|
||||
}
|
||||
|
||||
private async Task<Song> FetchSongAsync(string baseUrl, string externalId, CancellationToken cancellationToken)
|
||||
{
|
||||
// Per hifi-api spec: GET /info/?id={trackId} returns track metadata
|
||||
var url = $"{baseUrl}/info/?id={externalId}";
|
||||
|
||||
var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
_logger.LogInformation(
|
||||
"Requesting SquidWTF track metadata for track {TrackId} from {Endpoint}",
|
||||
externalId,
|
||||
baseUrl);
|
||||
_logger.LogDebug("Fetching SquidWTF track metadata from: {Url}", url);
|
||||
|
||||
using var response = await _httpClient.GetAsync(url, cancellationToken);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}");
|
||||
throw new HttpRequestException($"HTTP {response.StatusCode}", null, response.StatusCode);
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync(cancellationToken);
|
||||
var result = JsonDocument.Parse(json);
|
||||
using var result = JsonDocument.Parse(json);
|
||||
|
||||
// Per hifi-api spec: response is { "version": "2.0", "data": { track object } }
|
||||
if (!result.RootElement.TryGetProperty("data", out var track))
|
||||
{
|
||||
throw new InvalidOperationException($"SquidWTF /info response for track {externalId} did not contain data");
|
||||
@@ -605,10 +639,8 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
||||
|
||||
var song = ParseTidalTrackFull(track);
|
||||
|
||||
// Enrich with MusicBrainz genres if missing (SquidWTF/Tidal doesn't provide genres)
|
||||
if (_genreEnrichment != null && string.IsNullOrEmpty(song.Genre))
|
||||
{
|
||||
// Fire-and-forget: don't block the response waiting for genre enrichment
|
||||
_ = Task.Run(async () =>
|
||||
{
|
||||
try
|
||||
@@ -622,11 +654,7 @@ public class SquidWTFMetadataService : TrackParserBase, IMusicMetadataService
|
||||
});
|
||||
}
|
||||
|
||||
// NOTE: Spotify ID conversion happens during download (in SquidWTFDownloadService)
|
||||
// This avoids redundant conversions and ensures it's done in parallel with the download
|
||||
|
||||
return song;
|
||||
}, (Song?)null);
|
||||
}
|
||||
|
||||
public async Task<List<Song>> GetTrackRecommendationsAsync(string externalId, int limit = 20, CancellationToken cancellationToken = default)
|
||||
|
||||
@@ -57,11 +57,38 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
WriteStatus("SquidWTF API Endpoints", _apiUrls.Count.ToString(), ConsoleColor.Cyan);
|
||||
WriteStatus("SquidWTF Streaming Endpoints", _streamingUrls.Count.ToString(), ConsoleColor.Cyan);
|
||||
|
||||
if (_apiUrls.Count == 0)
|
||||
{
|
||||
WriteStatus("SquidWTF API", "UNAVAILABLE", ConsoleColor.Yellow);
|
||||
WriteDetail("No API endpoints were discovered from the uptime feeds");
|
||||
}
|
||||
else
|
||||
{
|
||||
await BenchmarkEndpointPoolAsync("API", _apiUrls, _apiFallbackHelper, cancellationToken);
|
||||
}
|
||||
|
||||
if (_streamingUrls.Count == 0)
|
||||
{
|
||||
WriteStatus("SquidWTF Streaming", "UNAVAILABLE", ConsoleColor.Yellow);
|
||||
WriteDetail("No streaming endpoints were discovered from the uptime feeds");
|
||||
}
|
||||
else
|
||||
{
|
||||
await BenchmarkEndpointPoolAsync("streaming", _streamingUrls, _streamingFallbackHelper, cancellationToken);
|
||||
}
|
||||
|
||||
if (_apiUrls.Count == 0 && _streamingUrls.Count == 0)
|
||||
{
|
||||
return ValidationResult.Failure(
|
||||
"UNAVAILABLE",
|
||||
"SquidWTF uptime feeds did not return any usable endpoints",
|
||||
ConsoleColor.Yellow);
|
||||
}
|
||||
|
||||
// Validate API endpoints and search functionality.
|
||||
var apiResult = await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
var apiResult = _apiUrls.Count == 0
|
||||
? ValidationResult.Failure("-1", "No SquidWTF API endpoints are currently available", ConsoleColor.Yellow)
|
||||
: await _apiFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
||||
|
||||
@@ -81,13 +108,15 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
}
|
||||
}, ValidationResult.Failure("-1", "All SquidWTF API endpoints failed"));
|
||||
|
||||
if (!apiResult.IsValid)
|
||||
if (_apiUrls.Count > 0 && !apiResult.IsValid)
|
||||
{
|
||||
return apiResult;
|
||||
}
|
||||
|
||||
// Validate streaming endpoints independently to avoid API-only endpoints for streaming.
|
||||
var streamingResult = await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
var streamingResult = _streamingUrls.Count == 0
|
||||
? ValidationResult.Failure("-2", "No SquidWTF streaming endpoints are currently available", ConsoleColor.Yellow)
|
||||
: await _streamingFallbackHelper.TryWithFallbackAsync(async (baseUrl) =>
|
||||
{
|
||||
var response = await _httpClient.GetAsync(baseUrl, cancellationToken);
|
||||
|
||||
@@ -100,7 +129,7 @@ public class SquidWTFStartupValidator : BaseStartupValidator
|
||||
throw new HttpRequestException($"HTTP {(int)response.StatusCode}");
|
||||
}, ValidationResult.Failure("-2", "All SquidWTF streaming endpoints failed"));
|
||||
|
||||
if (!streamingResult.IsValid)
|
||||
if (_streamingUrls.Count > 0 && !streamingResult.IsValid)
|
||||
{
|
||||
return streamingResult;
|
||||
}
|
||||
|
||||
@@ -19,16 +19,6 @@ public sealed class SquidWtfEndpointCatalog
|
||||
throw new ArgumentNullException(nameof(streamingUrls));
|
||||
}
|
||||
|
||||
if (apiUrls.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("API URL list cannot be empty.", nameof(apiUrls));
|
||||
}
|
||||
|
||||
if (streamingUrls.Count == 0)
|
||||
{
|
||||
throw new ArgumentException("Streaming URL list cannot be empty.", nameof(streamingUrls));
|
||||
}
|
||||
|
||||
ApiUrls = apiUrls;
|
||||
StreamingUrls = streamingUrls;
|
||||
LoadedAtUtc = DateTime.UtcNow;
|
||||
|
||||
@@ -6,6 +6,7 @@ public static class SquidWtfEndpointDiscovery
|
||||
{
|
||||
public static readonly IReadOnlyList<string> SourceUrls = new[]
|
||||
{
|
||||
"https://tidal-uptime.geeked.wtf/",
|
||||
"https://tidal-uptime.jiffy-puffs-1j.workers.dev/",
|
||||
"https://tidal-uptime.props-76styles.workers.dev/"
|
||||
};
|
||||
@@ -23,8 +24,12 @@ public static class SquidWtfEndpointDiscovery
|
||||
{
|
||||
try
|
||||
{
|
||||
Console.WriteLine($"Loading SquidWTF uptime feed: {sourceUrl}");
|
||||
var json = await httpClient.GetStringAsync(sourceUrl, cancellationToken);
|
||||
feeds.Add(ParseFeed(json));
|
||||
var feed = ParseFeed(json);
|
||||
feeds.Add(feed);
|
||||
Console.WriteLine(
|
||||
$"Loaded SquidWTF uptime feed {sourceUrl}: api={feed.ApiUrls.Count}, streaming={feed.StreamingUrls.Count}, down={feed.DownUrls.Count}, lastUpdated={feed.LastUpdated:O}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -34,7 +39,9 @@ public static class SquidWtfEndpointDiscovery
|
||||
|
||||
if (feeds.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("Could not load SquidWTF endpoint feeds from any source URL.");
|
||||
Console.WriteLine(
|
||||
"⚠️ No SquidWTF uptime feeds could be loaded. Starting with SquidWTF external features unavailable; local Jellyfin content will still work.");
|
||||
return new SquidWtfEndpointCatalog(new List<string>(), new List<string>());
|
||||
}
|
||||
|
||||
var orderedFeeds = feeds
|
||||
@@ -60,12 +67,12 @@ public static class SquidWtfEndpointDiscovery
|
||||
|
||||
if (apiUrls.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SquidWTF endpoint feed returned zero API endpoints.");
|
||||
Console.WriteLine("⚠️ SquidWTF uptime feeds returned zero API endpoints.");
|
||||
}
|
||||
|
||||
if (streamingUrls.Count == 0)
|
||||
{
|
||||
throw new InvalidOperationException("SquidWTF endpoint feed returned zero streaming endpoints.");
|
||||
Console.WriteLine("⚠️ SquidWTF uptime feeds returned zero streaming endpoints.");
|
||||
}
|
||||
|
||||
Console.WriteLine($"Loaded SquidWTF endpoints from uptime feeds: api={apiUrls.Count}, streaming={streamingUrls.Count}");
|
||||
|
||||
@@ -28,6 +28,12 @@
|
||||
<label for="auth-password">Password</label>
|
||||
<input id="auth-password" type="password" required>
|
||||
|
||||
<label class="auth-checkbox" for="auth-remember-me">
|
||||
<input id="auth-remember-me" type="checkbox">
|
||||
<span>Keep me signed in for 30 days on this browser</span>
|
||||
</label>
|
||||
<small class="auth-note">Use only on a device you trust.</small>
|
||||
|
||||
<button class="primary" type="submit">Sign In</button>
|
||||
<div class="auth-error" id="auth-error" role="alert"></div>
|
||||
</form>
|
||||
@@ -84,6 +90,7 @@
|
||||
<button class="sidebar-link" type="button" data-tab="kept">Kept Downloads</button>
|
||||
<button class="sidebar-link" type="button" data-tab="scrobbling">Scrobbling</button>
|
||||
<button class="sidebar-link" type="button" data-tab="config">Configuration</button>
|
||||
<button class="sidebar-link" type="button" data-tab="report-issues">Report Issues</button>
|
||||
<button class="sidebar-link" type="button" data-tab="endpoints">API Analytics</button>
|
||||
</nav>
|
||||
<div class="sidebar-footer">
|
||||
@@ -102,6 +109,7 @@
|
||||
<div class="tab" data-tab="kept">Kept Downloads</div>
|
||||
<div class="tab" data-tab="scrobbling">Scrobbling</div>
|
||||
<div class="tab" data-tab="config">Configuration</div>
|
||||
<div class="tab" data-tab="report-issues">Report Issues</div>
|
||||
<div class="tab" data-tab="endpoints">API Analytics</div>
|
||||
</div>
|
||||
|
||||
@@ -895,6 +903,82 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Report Issues Tab -->
|
||||
<div class="tab-content" id="tab-report-issues">
|
||||
<div class="card">
|
||||
<h2>Report Issues</h2>
|
||||
<div class="guidance-banner info mb-16">
|
||||
<span>ℹ️</span>
|
||||
<div class="guidance-content">
|
||||
<div class="guidance-title">Draft a GitHub issue from inside Allstarr.</div>
|
||||
<div class="guidance-detail">Allstarr includes only safe diagnostics here. Sensitive values stay redacted, and the final submit still happens on GitHub.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-issue-layout">
|
||||
<div class="report-issue-panel">
|
||||
<div class="form-group">
|
||||
<label for="issue-report-type">Report Type</label>
|
||||
<select id="issue-report-type">
|
||||
<option value="bug">Bug Report</option>
|
||||
<option value="feature">Feature Request</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="issue-report-title">Title</label>
|
||||
<input type="text" id="issue-report-title" placeholder="Short summary of the issue">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="issue-report-primary" id="issue-report-primary-label">Describe the bug</label>
|
||||
<textarea id="issue-report-primary" rows="5"
|
||||
placeholder="What happened? What looked wrong?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="issue-report-secondary" id="issue-report-secondary-label">To Reproduce</label>
|
||||
<textarea id="issue-report-secondary" rows="5"
|
||||
placeholder="List the steps needed to reproduce the issue"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="issue-report-tertiary" id="issue-report-tertiary-label">Expected behavior</label>
|
||||
<textarea id="issue-report-tertiary" rows="4"
|
||||
placeholder="What did you expect to happen instead?"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="issue-report-context" id="issue-report-context-label">Additional context</label>
|
||||
<textarea id="issue-report-context" rows="4"
|
||||
placeholder="Anything else that might help, including screenshots or surrounding context"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="card-actions-row">
|
||||
<button class="primary" type="button" id="open-github-issue-btn">Open Bug Report on GitHub</button>
|
||||
<button type="button" id="copy-issue-report-btn">Copy Report</button>
|
||||
<button type="button" id="clear-issue-report-btn">Clear Report</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="report-preview-panel">
|
||||
<div class="guidance-banner compact">
|
||||
Safe diagnostics only: version, runtime config, service state, and a concise client summary. Sensitive values stay redacted.
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="issue-report-preview">GitHub Issue Preview</label>
|
||||
<textarea id="issue-report-preview" rows="22" readonly></textarea>
|
||||
</div>
|
||||
|
||||
<div class="report-preview-help" id="issue-report-preview-help">
|
||||
GitHub drafts that exceed the URL size limit will open with a shorter body. The full report will also be copied to your clipboard.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- API Analytics Tab -->
|
||||
<div class="tab-content" id="tab-endpoints">
|
||||
<div class="card">
|
||||
|
||||
@@ -56,10 +56,10 @@ export async function fetchAdminSession() {
|
||||
);
|
||||
}
|
||||
|
||||
export async function loginAdminSession(username, password) {
|
||||
export async function loginAdminSession(username, password, rememberMe = false) {
|
||||
return requestJson(
|
||||
"/api/admin/auth/login",
|
||||
asJsonBody({ username, password }),
|
||||
asJsonBody({ username, password, rememberMe }),
|
||||
"Authentication failed",
|
||||
);
|
||||
}
|
||||
|
||||
@@ -72,6 +72,7 @@ function applyAuthorizationScope() {
|
||||
"kept",
|
||||
"scrobbling",
|
||||
"config",
|
||||
"report-issues",
|
||||
"endpoints",
|
||||
];
|
||||
|
||||
@@ -196,9 +197,11 @@ function wireLoginForm() {
|
||||
|
||||
const usernameInput = document.getElementById("auth-username");
|
||||
const passwordInput = document.getElementById("auth-password");
|
||||
const rememberMeInput = document.getElementById("auth-remember-me");
|
||||
const authError = document.getElementById("auth-error");
|
||||
const username = usernameInput?.value?.trim() || "";
|
||||
const password = passwordInput?.value || "";
|
||||
const rememberMe = Boolean(rememberMeInput?.checked);
|
||||
|
||||
if (!username || !password) {
|
||||
if (authError) {
|
||||
@@ -212,7 +215,7 @@ function wireLoginForm() {
|
||||
authError.textContent = "";
|
||||
}
|
||||
|
||||
const result = await API.loginAdminSession(username, password);
|
||||
const result = await API.loginAdminSession(username, password, rememberMe);
|
||||
if (passwordInput) {
|
||||
passwordInput.value = "";
|
||||
}
|
||||
|
||||
@@ -0,0 +1,501 @@
|
||||
import { showToast } from "./utils.js";
|
||||
|
||||
const GITHUB_NEW_ISSUE_URL = "https://github.com/SoPat712/allstarr/issues/new";
|
||||
const MAX_PREFILL_URL_LENGTH = 6500;
|
||||
const ISSUE_TEMPLATES = {
|
||||
bug: {
|
||||
template: "bug-report.md",
|
||||
titlePrefix: "[BUG] ",
|
||||
openLabel: "Open Bug Report on GitHub",
|
||||
primaryLabel: "Describe the bug",
|
||||
primaryPlaceholder: "What happened? What looked wrong?",
|
||||
secondaryLabel: "To Reproduce",
|
||||
secondaryPlaceholder: "List the steps needed to reproduce the issue",
|
||||
tertiaryLabel: "Expected behavior",
|
||||
tertiaryPlaceholder: "What did you expect to happen instead?",
|
||||
contextLabel: "Additional context",
|
||||
contextPlaceholder:
|
||||
"Anything else that might help, including screenshots or surrounding context",
|
||||
},
|
||||
feature: {
|
||||
template: "feature-request.md",
|
||||
titlePrefix: "[FEATURE] ",
|
||||
openLabel: "Open Feature Request on GitHub",
|
||||
primaryLabel: "Problem to solve",
|
||||
primaryPlaceholder: "What problem are you trying to solve?",
|
||||
secondaryLabel: "Solution you'd like",
|
||||
secondaryPlaceholder: "What should Allstarr do instead?",
|
||||
tertiaryLabel: "Alternatives considered",
|
||||
tertiaryPlaceholder: "What alternatives or workarounds have you considered?",
|
||||
contextLabel: "Additional context",
|
||||
contextPlaceholder:
|
||||
"Extra examples, mockups, or screenshots that explain the request",
|
||||
},
|
||||
};
|
||||
const DIAGNOSTIC_SOURCE_IDS = [
|
||||
"sidebar-version",
|
||||
"backend-type",
|
||||
"spotify-status",
|
||||
"jellyfin-url",
|
||||
"config-music-service",
|
||||
"config-storage-mode",
|
||||
"config-download-mode",
|
||||
"config-redis-enabled",
|
||||
"config-spotify-import-enabled",
|
||||
"config-deezer-quality",
|
||||
"config-squid-quality",
|
||||
"config-qobuz-quality",
|
||||
"scrobbling-enabled-value",
|
||||
];
|
||||
|
||||
function getElement(id) {
|
||||
return document.getElementById(id);
|
||||
}
|
||||
|
||||
function normalizeText(value, fallback = "Unavailable") {
|
||||
const normalized = String(value ?? "").trim();
|
||||
if (!normalized || normalized === "-" || /^loading/i.test(normalized)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function getIssueType() {
|
||||
return getElement("issue-report-type")?.value === "feature" ? "feature" : "bug";
|
||||
}
|
||||
|
||||
function getIssueConfig(type = getIssueType()) {
|
||||
return ISSUE_TEMPLATES[type] || ISSUE_TEMPLATES.bug;
|
||||
}
|
||||
|
||||
function sanitizeTitle(title, type) {
|
||||
const prefix = getIssueConfig(type).titlePrefix;
|
||||
const trimmed = String(title ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return prefix + (type === "feature" ? "Please add a short request title" : "Please add a short bug title");
|
||||
}
|
||||
|
||||
if (trimmed.toUpperCase().startsWith(prefix.trim())) {
|
||||
return trimmed;
|
||||
}
|
||||
|
||||
return prefix + trimmed;
|
||||
}
|
||||
|
||||
function getElementText(id, fallback = "Unavailable") {
|
||||
return normalizeText(getElement(id)?.textContent, fallback);
|
||||
}
|
||||
|
||||
function getMusicServiceQuality(musicService) {
|
||||
const normalized = String(musicService ?? "").trim().toLowerCase();
|
||||
if (normalized === "deezer") {
|
||||
return getElementText("config-deezer-quality");
|
||||
}
|
||||
if (normalized === "qobuz") {
|
||||
return getElementText("config-qobuz-quality");
|
||||
}
|
||||
if (normalized === "squidwtf") {
|
||||
return getElementText("config-squid-quality");
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function getClientSummary() {
|
||||
const ua = String(window.navigator?.userAgent ?? "");
|
||||
const browser =
|
||||
ua.match(/Firefox\/(\d+)/)?.[0]?.replace("/", " ") ||
|
||||
ua.match(/Edg\/(\d+)/)?.[0]?.replace("/", " ") ||
|
||||
ua.match(/Chrome\/(\d+)/)?.[0]?.replace("/", " ") ||
|
||||
(ua.includes("Safari/") && ua.match(/Version\/(\d+)/)?.[0]?.replace("/", " ")) ||
|
||||
"Unknown browser";
|
||||
|
||||
let platform = "Unknown OS";
|
||||
if (/Mac OS X/i.test(ua)) {
|
||||
platform = "macOS";
|
||||
} else if (/Windows/i.test(ua)) {
|
||||
platform = "Windows";
|
||||
} else if (/Android/i.test(ua)) {
|
||||
platform = "Android";
|
||||
} else if (/iPhone|iPad|iPod/i.test(ua)) {
|
||||
platform = "iOS";
|
||||
} else if (/Linux/i.test(ua)) {
|
||||
platform = "Linux";
|
||||
}
|
||||
|
||||
return `${browser} on ${platform}`;
|
||||
}
|
||||
|
||||
function getRedactedUrlState() {
|
||||
const jellyfinUrl = normalizeText(getElement("jellyfin-url")?.textContent, "");
|
||||
return jellyfinUrl ? "Configured (redacted)" : "Not configured";
|
||||
}
|
||||
|
||||
function getDiagnostics() {
|
||||
const timezone =
|
||||
Intl.DateTimeFormat().resolvedOptions().timeZone || "Unavailable";
|
||||
const musicService = getElementText("config-music-service");
|
||||
|
||||
return {
|
||||
version: getElementText("sidebar-version"),
|
||||
backendType: normalizeText(
|
||||
getElement("backend-type")?.textContent ||
|
||||
getElement("config-backend-type")?.textContent,
|
||||
),
|
||||
musicService,
|
||||
musicServiceQuality: getMusicServiceQuality(musicService),
|
||||
storageMode: getElementText("config-storage-mode"),
|
||||
downloadMode: getElementText("config-download-mode"),
|
||||
redisEnabled: getElementText("config-redis-enabled"),
|
||||
spotifyImportEnabled: getElementText("config-spotify-import-enabled"),
|
||||
scrobblingEnabled: getElementText("scrobbling-enabled-value"),
|
||||
spotifyStatus: getElementText("spotify-status"),
|
||||
jellyfinUrl: getRedactedUrlState(),
|
||||
client: getClientSummary(),
|
||||
generatedAt: new Date().toISOString(),
|
||||
timezone,
|
||||
};
|
||||
}
|
||||
|
||||
function getReportState() {
|
||||
const type = getIssueType();
|
||||
return {
|
||||
type,
|
||||
titleInput: String(getElement("issue-report-title")?.value ?? "").trim(),
|
||||
primary: String(getElement("issue-report-primary")?.value ?? "").trim(),
|
||||
secondary: String(getElement("issue-report-secondary")?.value ?? "").trim(),
|
||||
tertiary: String(getElement("issue-report-tertiary")?.value ?? "").trim(),
|
||||
context: String(getElement("issue-report-context")?.value ?? "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function renderIssueBody(state, includeDiagnostics = true) {
|
||||
const diagnostics = getDiagnostics();
|
||||
const diagnosticsLines = [
|
||||
"- Sensitive values stay redacted in this block.",
|
||||
`- Allstarr Version: ${diagnostics.version}`,
|
||||
`- Backend Type: ${diagnostics.backendType}`,
|
||||
`- Music Service: ${diagnostics.musicService}`,
|
||||
diagnostics.musicServiceQuality
|
||||
? `- Music Service Quality: ${diagnostics.musicServiceQuality}`
|
||||
: null,
|
||||
`- Storage Mode: ${diagnostics.storageMode}`,
|
||||
`- Download Mode: ${diagnostics.downloadMode}`,
|
||||
`- Redis Enabled: ${diagnostics.redisEnabled}`,
|
||||
`- Spotify Import Enabled: ${diagnostics.spotifyImportEnabled}`,
|
||||
`- Scrobbling Enabled: ${diagnostics.scrobblingEnabled}`,
|
||||
`- Spotify Status: ${diagnostics.spotifyStatus}`,
|
||||
`- Jellyfin URL: ${diagnostics.jellyfinUrl}`,
|
||||
`- Client: ${diagnostics.client}`,
|
||||
`- Generated At (UTC): ${diagnostics.generatedAt}`,
|
||||
`- Browser Time Zone: ${diagnostics.timezone}`,
|
||||
];
|
||||
const diagnosticsMarkdown = diagnosticsLines.filter(Boolean).join("\n");
|
||||
|
||||
if (state.type === "feature") {
|
||||
const sections = [
|
||||
[
|
||||
"## Problem to solve",
|
||||
state.primary || "_Please describe the problem you want to solve._",
|
||||
],
|
||||
[
|
||||
"## Solution you'd like",
|
||||
state.secondary || "_Please describe the solution you want._",
|
||||
],
|
||||
[
|
||||
"## Alternatives considered",
|
||||
state.tertiary || "_Please describe alternatives or workarounds you've considered._",
|
||||
],
|
||||
[
|
||||
"## Additional context",
|
||||
state.context || "_Add any other context, screenshots, or examples here._",
|
||||
],
|
||||
];
|
||||
|
||||
if (includeDiagnostics) {
|
||||
sections.push(["## Safe diagnostics from Allstarr", diagnosticsMarkdown]);
|
||||
}
|
||||
|
||||
return sections.map(([heading, content]) => `${heading}\n${content}`).join("\n\n");
|
||||
}
|
||||
|
||||
const sections = [
|
||||
[
|
||||
"## Describe the bug",
|
||||
state.primary || "_Please describe the bug._",
|
||||
],
|
||||
[
|
||||
"## To Reproduce",
|
||||
state.secondary ||
|
||||
"_Please list the steps needed to reproduce the issue._",
|
||||
],
|
||||
[
|
||||
"## Expected behavior",
|
||||
state.tertiary || "_Please describe what you expected to happen._",
|
||||
],
|
||||
[
|
||||
"## Additional context",
|
||||
state.context || "_Add any other context, screenshots, or examples here._",
|
||||
],
|
||||
];
|
||||
|
||||
if (includeDiagnostics) {
|
||||
sections.push(["## Safe diagnostics from Allstarr", diagnosticsMarkdown]);
|
||||
}
|
||||
|
||||
return sections.map(([heading, content]) => `${heading}\n${content}`).join("\n\n");
|
||||
}
|
||||
|
||||
function buildIssuePayload() {
|
||||
const state = getReportState();
|
||||
const config = getIssueConfig(state.type);
|
||||
const title = sanitizeTitle(state.titleInput, state.type);
|
||||
const fullBody = renderIssueBody(state, true);
|
||||
|
||||
const fullUrl = new URL(GITHUB_NEW_ISSUE_URL);
|
||||
fullUrl.searchParams.set("template", config.template);
|
||||
fullUrl.searchParams.set("title", title);
|
||||
fullUrl.searchParams.set("body", fullBody);
|
||||
|
||||
if (fullUrl.toString().length <= MAX_PREFILL_URL_LENGTH) {
|
||||
return {
|
||||
title,
|
||||
fullBody,
|
||||
url: fullUrl.toString(),
|
||||
truncated: false,
|
||||
};
|
||||
}
|
||||
|
||||
const shortenedBody = [
|
||||
renderIssueBody(state, false),
|
||||
"> Full safe diagnostics were copied to your clipboard by Allstarr.",
|
||||
"> Paste them below if GitHub opens with a shorter draft.",
|
||||
].join("\n\n");
|
||||
|
||||
const shortenedUrl = new URL(GITHUB_NEW_ISSUE_URL);
|
||||
shortenedUrl.searchParams.set("template", config.template);
|
||||
shortenedUrl.searchParams.set("title", title);
|
||||
shortenedUrl.searchParams.set("body", shortenedBody);
|
||||
|
||||
return {
|
||||
title,
|
||||
fullBody,
|
||||
url: shortenedUrl.toString(),
|
||||
truncated: true,
|
||||
};
|
||||
}
|
||||
|
||||
async function copyTextToClipboard(text) {
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (navigator.clipboard?.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch {
|
||||
// Fall back to a hidden textarea if direct clipboard access fails.
|
||||
}
|
||||
}
|
||||
|
||||
const helper = document.createElement("textarea");
|
||||
helper.value = text;
|
||||
helper.setAttribute("readonly", "");
|
||||
helper.style.position = "absolute";
|
||||
helper.style.left = "-9999px";
|
||||
document.body.appendChild(helper);
|
||||
helper.select();
|
||||
|
||||
let copied = false;
|
||||
try {
|
||||
copied = document.execCommand("copy");
|
||||
} catch {
|
||||
copied = false;
|
||||
}
|
||||
|
||||
document.body.removeChild(helper);
|
||||
return copied;
|
||||
}
|
||||
|
||||
async function copyIssueReport({ silent = false } = {}) {
|
||||
const payload = buildIssuePayload();
|
||||
const copied = await copyTextToClipboard(`${payload.title}\n\n${payload.fullBody}`);
|
||||
|
||||
if (!silent) {
|
||||
showToast(
|
||||
copied
|
||||
? "Issue draft copied to clipboard"
|
||||
: "Could not copy the report. You can still copy it from the preview.",
|
||||
copied ? "success" : "warning",
|
||||
4000,
|
||||
);
|
||||
}
|
||||
|
||||
return copied;
|
||||
}
|
||||
|
||||
function clearIssueReport() {
|
||||
const titleInput = getElement("issue-report-title");
|
||||
const primaryInput = getElement("issue-report-primary");
|
||||
const secondaryInput = getElement("issue-report-secondary");
|
||||
const tertiaryInput = getElement("issue-report-tertiary");
|
||||
const contextInput = getElement("issue-report-context");
|
||||
|
||||
const hasDraft = [
|
||||
titleInput?.value,
|
||||
primaryInput?.value,
|
||||
secondaryInput?.value,
|
||||
tertiaryInput?.value,
|
||||
contextInput?.value,
|
||||
].some((value) => String(value ?? "").trim().length > 0);
|
||||
|
||||
if (hasDraft && !window.confirm("Clear the current report draft?")) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (titleInput) titleInput.value = "";
|
||||
if (primaryInput) primaryInput.value = "";
|
||||
if (secondaryInput) secondaryInput.value = "";
|
||||
if (tertiaryInput) tertiaryInput.value = "";
|
||||
if (contextInput) contextInput.value = "";
|
||||
|
||||
refreshIssueReportPreview();
|
||||
titleInput?.focus();
|
||||
showToast("Report draft cleared", "success", 2500);
|
||||
}
|
||||
|
||||
function validateTitle() {
|
||||
const titleInput = getElement("issue-report-title");
|
||||
if (!titleInput?.value?.trim()) {
|
||||
titleInput?.focus();
|
||||
showToast("Add a short title before opening the GitHub draft.", "warning");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function openGithubIssueDraft() {
|
||||
if (!validateTitle()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const copied = await copyIssueReport({ silent: true });
|
||||
const payload = buildIssuePayload();
|
||||
const openedWindow = window.open(payload.url, "_blank", "noopener,noreferrer");
|
||||
|
||||
if (!openedWindow) {
|
||||
showToast(
|
||||
"GitHub draft popup was blocked. Allow popups for this site, then try again.",
|
||||
"warning",
|
||||
5000,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = payload.truncated
|
||||
? "Opened a shorter GitHub draft and copied the full report to your clipboard."
|
||||
: copied
|
||||
? "Opened the GitHub draft and copied the report to your clipboard."
|
||||
: "Opened the GitHub draft. If anything is missing, use Copy Report.";
|
||||
showToast(message, payload.truncated ? "warning" : "success", 5000);
|
||||
}
|
||||
|
||||
function updateIssueReporterCopy() {
|
||||
const type = getIssueType();
|
||||
const config = getIssueConfig(type);
|
||||
|
||||
getElement("issue-report-primary-label").textContent = config.primaryLabel;
|
||||
getElement("issue-report-primary").placeholder = config.primaryPlaceholder;
|
||||
getElement("issue-report-secondary-label").textContent = config.secondaryLabel;
|
||||
getElement("issue-report-secondary").placeholder = config.secondaryPlaceholder;
|
||||
getElement("issue-report-tertiary-label").textContent = config.tertiaryLabel;
|
||||
getElement("issue-report-tertiary").placeholder = config.tertiaryPlaceholder;
|
||||
getElement("issue-report-context-label").textContent = config.contextLabel;
|
||||
getElement("issue-report-context").placeholder = config.contextPlaceholder;
|
||||
getElement("open-github-issue-btn").textContent = config.openLabel;
|
||||
getElement("issue-report-title").placeholder =
|
||||
type === "feature"
|
||||
? "Short summary of the feature request"
|
||||
: "Short summary of the issue";
|
||||
}
|
||||
|
||||
export function refreshIssueReportPreview() {
|
||||
const preview = getElement("issue-report-preview");
|
||||
const previewHelp = getElement("issue-report-preview-help");
|
||||
if (!preview || !previewHelp) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateIssueReporterCopy();
|
||||
|
||||
const payload = buildIssuePayload();
|
||||
preview.value = `${payload.title}\n\n${payload.fullBody}`;
|
||||
previewHelp.textContent = payload.truncated
|
||||
? "This report is long enough that Allstarr will open GitHub with a shorter draft and copy the full report to your clipboard."
|
||||
: "This draft fits in a normal GitHub issue URL. Allstarr will still copy the full report to your clipboard when you open it.";
|
||||
}
|
||||
|
||||
export function initIssueReporter() {
|
||||
const typeSelect = getElement("issue-report-type");
|
||||
const titleInput = getElement("issue-report-title");
|
||||
const primaryInput = getElement("issue-report-primary");
|
||||
const secondaryInput = getElement("issue-report-secondary");
|
||||
const tertiaryInput = getElement("issue-report-tertiary");
|
||||
const contextInput = getElement("issue-report-context");
|
||||
const copyButton = getElement("copy-issue-report-btn");
|
||||
const clearButton = getElement("clear-issue-report-btn");
|
||||
const openButton = getElement("open-github-issue-btn");
|
||||
|
||||
if (
|
||||
!typeSelect ||
|
||||
!titleInput ||
|
||||
!primaryInput ||
|
||||
!secondaryInput ||
|
||||
!tertiaryInput ||
|
||||
!contextInput ||
|
||||
!copyButton ||
|
||||
!clearButton ||
|
||||
!openButton
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
[typeSelect, titleInput, primaryInput, secondaryInput, tertiaryInput, contextInput].forEach(
|
||||
(input) => {
|
||||
input.addEventListener("input", refreshIssueReportPreview);
|
||||
input.addEventListener("change", refreshIssueReportPreview);
|
||||
},
|
||||
);
|
||||
|
||||
copyButton.addEventListener("click", () => {
|
||||
copyIssueReport();
|
||||
});
|
||||
clearButton.addEventListener("click", () => {
|
||||
clearIssueReport();
|
||||
});
|
||||
openButton.addEventListener("click", () => {
|
||||
openGithubIssueDraft();
|
||||
});
|
||||
|
||||
const diagnosticsObserver = new MutationObserver(() => {
|
||||
refreshIssueReportPreview();
|
||||
});
|
||||
DIAGNOSTIC_SOURCE_IDS.forEach((id) => {
|
||||
const source = getElement(id);
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
|
||||
diagnosticsObserver.observe(source, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("hashchange", refreshIssueReportPreview);
|
||||
refreshIssueReportPreview();
|
||||
}
|
||||
@@ -37,6 +37,7 @@ import { initAuthSession } from "./auth-session.js";
|
||||
import { initActionDispatcher } from "./action-dispatcher.js";
|
||||
import { initNavigationView } from "./views/navigation-view.js";
|
||||
import { initScrobblingView } from "./views/scrobbling-view.js";
|
||||
import { initIssueReporter } from "./issue-reporter.js";
|
||||
|
||||
let cookieDateInitialized = false;
|
||||
let restartRequired = false;
|
||||
@@ -137,6 +138,8 @@ initPlaylistAdmin({
|
||||
fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists,
|
||||
});
|
||||
|
||||
initIssueReporter();
|
||||
|
||||
const authSession = initAuthSession({
|
||||
stopDashboardRefresh: dashboard.stopDashboardRefresh,
|
||||
loadDashboardData: dashboard.loadDashboardData,
|
||||
|
||||
@@ -58,6 +58,26 @@ body {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.auth-checkbox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.88rem;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.auth-checkbox input {
|
||||
width: auto;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-note {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.8rem;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.auth-card label {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.85rem;
|
||||
@@ -780,7 +800,8 @@ button.danger:hover {
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
select,
|
||||
textarea {
|
||||
background: var(--bg-tertiary);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-primary);
|
||||
@@ -790,15 +811,24 @@ select {
|
||||
}
|
||||
|
||||
input:focus,
|
||||
select:focus {
|
||||
select:focus,
|
||||
textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
input::placeholder,
|
||||
textarea::placeholder {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
textarea {
|
||||
width: 100%;
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr 120px auto;
|
||||
@@ -1045,6 +1075,14 @@ input::placeholder {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.report-issue-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.report-preview-panel textarea {
|
||||
min-height: 360px;
|
||||
}
|
||||
|
||||
.support-badge {
|
||||
right: 12px;
|
||||
bottom: 12px;
|
||||
@@ -1067,6 +1105,43 @@ input::placeholder {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.report-issue-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(320px, 0.95fr);
|
||||
gap: 20px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.report-issue-panel,
|
||||
.report-preview-panel {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.report-issue-panel .form-group,
|
||||
.report-preview-panel .form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.report-issue-panel label,
|
||||
.report-preview-panel label {
|
||||
display: block;
|
||||
margin-bottom: 6px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.report-preview-panel textarea {
|
||||
min-height: 520px;
|
||||
font-family: ui-monospace, Menlo, Monaco, "Cascadia Mono", "Segoe UI Mono",
|
||||
monospace;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.report-preview-help {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
/* Utility classes to reduce inline styles in index.html */
|
||||
.hidden {
|
||||
display: none;
|
||||
|
||||
Reference in New Issue
Block a user