Compare commits

...

6 Commits

12 changed files with 938 additions and 40 deletions
+29 -19
View File
@@ -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.
+5
View File
@@ -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.
+27 -6
View File
@@ -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]
+5 -1
View File
@@ -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
+7
View File
@@ -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);
@@ -198,6 +200,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>();
@@ -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; }
}
}
+84
View File
@@ -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">
+2 -2
View File
@@ -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",
);
}
+4 -1
View File
@@ -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 = "";
}
+501
View File
@@ -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();
}
+3
View File
@@ -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,
+78 -3
View File
@@ -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;