diff --git a/allstarr/wwwroot/js/api.js b/allstarr/wwwroot/js/api.js
index fc06730..abf34ff 100644
--- a/allstarr/wwwroot/js/api.js
+++ b/allstarr/wwwroot/js/api.js
@@ -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",
);
}
@@ -100,9 +100,17 @@ export async function fetchTrackMappings() {
);
}
-export async function deleteTrackMapping(playlist, spotifyId) {
+export async function deleteTrackMapping(playlist, spotifyId, provider = null) {
+ const params = new URLSearchParams({
+ playlist,
+ spotifyId,
+ });
+ if (provider) {
+ params.append("provider", provider);
+ }
+
return requestJson(
- `/api/admin/mappings/tracks?playlist=${encodeURIComponent(playlist)}&spotifyId=${encodeURIComponent(spotifyId)}`,
+ `/api/admin/mappings/tracks?${params.toString()}`,
{ method: "DELETE" },
"Failed to remove mapping",
);
diff --git a/allstarr/wwwroot/js/auth-session.js b/allstarr/wwwroot/js/auth-session.js
index 5da26ad..57ec000 100644
--- a/allstarr/wwwroot/js/auth-session.js
+++ b/allstarr/wwwroot/js/auth-session.js
@@ -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 = "";
}
diff --git a/allstarr/wwwroot/js/dashboard-data.js b/allstarr/wwwroot/js/dashboard-data.js
index 29417af..7208cd6 100644
--- a/allstarr/wwwroot/js/dashboard-data.js
+++ b/allstarr/wwwroot/js/dashboard-data.js
@@ -15,6 +15,7 @@ let onCookieNeedsInit = async () => {};
let setCurrentConfigState = () => {};
let syncConfigUiExtras = () => {};
let loadScrobblingConfig = () => {};
+let injectedPlaylistRequestToken = 0;
let jellyfinPlaylistRequestToken = 0;
async function fetchStatus() {
@@ -39,10 +40,20 @@ async function fetchStatus() {
}
async function fetchPlaylists(silent = false) {
+ const requestToken = ++injectedPlaylistRequestToken;
+
try {
const data = await API.fetchPlaylists();
+ if (requestToken !== injectedPlaylistRequestToken) {
+ return;
+ }
+
UI.updatePlaylistsUI(data);
} catch (error) {
+ if (requestToken !== injectedPlaylistRequestToken) {
+ return;
+ }
+
if (!silent) {
console.error("Failed to fetch playlists:", error);
showToast("Failed to fetch playlists", "error");
@@ -371,6 +382,15 @@ function startDashboardRefresh() {
fetchDownloads();
}
+ const songMigrationTab = document.getElementById("tab-song-migration");
+ if (
+ songMigrationTab &&
+ songMigrationTab.classList.contains("active") &&
+ typeof window.fetchSongMigration === "function"
+ ) {
+ window.fetchSongMigration();
+ }
+
const endpointsTab = document.getElementById("tab-endpoints");
if (endpointsTab && endpointsTab.classList.contains("active")) {
fetchEndpointUsage();
@@ -407,13 +427,15 @@ async function loadDashboardData() {
function startDownloadActivityStream() {
if (!isAdminSession()) return;
-
+
if (downloadActivityEventSource) {
downloadActivityEventSource.close();
}
- downloadActivityEventSource = new EventSource("/api/admin/downloads/activity");
-
+ downloadActivityEventSource = new EventSource(
+ "/api/admin/downloads/activity",
+ );
+
downloadActivityEventSource.onmessage = (event) => {
try {
const downloads = JSON.parse(event.data);
@@ -439,40 +461,50 @@ function renderDownloadActivity(downloads) {
}
const statusIcons = {
- 0: '⏳', // NotStarted
+ 0: "⏳", // NotStarted
1: '
Downloading', // InProgress
- 2: '✅ Completed', // Completed
- 3: '❌ Failed' // Failed
+ 2: "✅ Completed", // Completed
+ 3: "❌ Failed", // Failed
};
- const html = downloads.map(d => {
- const downloadProgress = clampProgress(d.progress);
- const playbackProgress = clampProgress(d.playbackProgress);
+ const html = downloads
+ .map((d) => {
+ const downloadProgress = clampProgress(d.progress);
+ const playbackProgress = clampProgress(d.playbackProgress);
- // Determine elapsed/duration text
- let timeText = "";
- if (d.startedAt) {
- const start = new Date(d.startedAt);
- const end = d.completedAt ? new Date(d.completedAt) : new Date();
- const diffSecs = Math.floor((end.getTime() - start.getTime()) / 1000);
- timeText = diffSecs < 60 ? `${diffSecs}s` : `${Math.floor(diffSecs/60)}m ${diffSecs%60}s`;
- }
+ // Determine elapsed/duration text
+ let timeText = "";
+ if (d.startedAt) {
+ const start = new Date(d.startedAt);
+ const end = d.completedAt ? new Date(d.completedAt) : new Date();
+ const diffSecs = Math.floor((end.getTime() - start.getTime()) / 1000);
+ timeText =
+ diffSecs < 60
+ ? `${diffSecs}s`
+ : `${Math.floor(diffSecs / 60)}m ${diffSecs % 60}s`;
+ }
- const progressMeta = [];
- if (typeof d.durationSeconds === "number" && typeof d.playbackPositionSeconds === "number") {
- progressMeta.push(`${formatSeconds(d.playbackPositionSeconds)} / ${formatSeconds(d.durationSeconds)}`);
- } else if (typeof d.durationSeconds === "number") {
- progressMeta.push(formatSeconds(d.durationSeconds));
- }
- if (d.requestedForStreaming) {
- progressMeta.push("stream");
- }
+ const progressMeta = [];
+ if (
+ typeof d.durationSeconds === "number" &&
+ typeof d.playbackPositionSeconds === "number"
+ ) {
+ progressMeta.push(
+ `${formatSeconds(d.playbackPositionSeconds)} / ${formatSeconds(d.durationSeconds)}`,
+ );
+ } else if (typeof d.durationSeconds === "number") {
+ progressMeta.push(formatSeconds(d.durationSeconds));
+ }
+ if (d.requestedForStreaming) {
+ progressMeta.push("stream");
+ }
- const progressMetaText = progressMeta.length > 0
- ? `
`
- : "";
+ const progressMetaText =
+ progressMeta.length > 0
+ ? `
`
+ : "";
- const progressBar = `
+ const progressBar = `
@@ -480,17 +512,19 @@ function renderDownloadActivity(downloads) {
${progressMetaText}
`;
- const title = d.title || 'Unknown Title';
- const artist = d.artist || 'Unknown Artist';
- const errorText = d.errorMessage ? `
${escapeHtml(d.errorMessage)}
` : '';
- const streamBadge = d.requestedForStreaming
- ? '
Stream '
- : '';
- const playingBadge = d.isPlaying
- ? '
Playing '
- : '';
+ const title = d.title || "Unknown Title";
+ const artist = d.artist || "Unknown Artist";
+ const errorText = d.errorMessage
+ ? `
${escapeHtml(d.errorMessage)}
`
+ : "";
+ const streamBadge = d.requestedForStreaming
+ ? '
Stream '
+ : "";
+ const playingBadge = d.isPlaying
+ ? '
Playing '
+ : "";
- return `
+ return `
${escapeHtml(title)}
@@ -504,12 +538,13 @@ function renderDownloadActivity(downloads) {
${errorText}
- ${statusIcons[d.status] || 'Unknown'}
+ ${statusIcons[d.status] || "Unknown"}
${timeText}
`;
- }).join('');
+ })
+ .join("");
container.innerHTML = html;
}
@@ -523,7 +558,11 @@ function clampProgress(value) {
}
function formatSeconds(totalSeconds) {
- if (typeof totalSeconds !== "number" || Number.isNaN(totalSeconds) || totalSeconds < 0) {
+ if (
+ typeof totalSeconds !== "number" ||
+ Number.isNaN(totalSeconds) ||
+ totalSeconds < 0
+ ) {
return "0:00";
}
diff --git a/allstarr/wwwroot/js/issue-reporter.js b/allstarr/wwwroot/js/issue-reporter.js
new file mode 100644
index 0000000..7df2dad
--- /dev/null
+++ b/allstarr/wwwroot/js/issue-reporter.js
@@ -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();
+}
diff --git a/allstarr/wwwroot/js/main.js b/allstarr/wwwroot/js/main.js
index b18380f..1a5ccbd 100644
--- a/allstarr/wwwroot/js/main.js
+++ b/allstarr/wwwroot/js/main.js
@@ -32,11 +32,16 @@ import {
initPlaylistAdmin,
resetPlaylistAdminState,
} from "./playlist-admin.js";
+import {
+ initSongMigration,
+ resetSongMigrationState,
+} from "./song-migration.js";
import { initScrobblingAdmin } from "./scrobbling-admin.js";
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;
@@ -78,6 +83,13 @@ window.switchTab = function (tabName) {
if (tabName === "kept" && typeof window.fetchDownloads === "function") {
window.fetchDownloads();
}
+
+ if (
+ tabName === "song-migration" &&
+ typeof window.fetchSongMigration === "function"
+ ) {
+ window.fetchSongMigration();
+ }
}
};
@@ -137,12 +149,19 @@ initPlaylistAdmin({
fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists,
});
+initSongMigration({
+ isAdminSession: () => authSession?.isAdminSession() ?? false,
+});
+
+initIssueReporter();
+
const authSession = initAuthSession({
stopDashboardRefresh: dashboard.stopDashboardRefresh,
loadDashboardData: dashboard.loadDashboardData,
switchTab: window.switchTab,
onUnauthenticated: () => {
resetPlaylistAdminState();
+ resetSongMigrationState();
setCurrentConfigState(null);
},
});
@@ -175,7 +194,9 @@ document.addEventListener("DOMContentLoaded", () => {
window.switchTab(tab);
}
});
- dispatcher.register("logoutAdminSession", () => window.logoutAdminSession?.());
+ dispatcher.register("logoutAdminSession", () =>
+ window.logoutAdminSession?.(),
+ );
dispatcher.register("dismissRestartBanner", () =>
window.dismissRestartBanner?.(),
);
@@ -189,7 +210,9 @@ document.addEventListener("DOMContentLoaded", () => {
dispatcher.register("toggleDetailsRow", ({ event, args }) =>
window.toggleDetailsRow?.(event, args?.detailsRowId),
);
- dispatcher.register("viewTracks", ({ args }) => viewTracks(args?.playlistName));
+ dispatcher.register("viewTracks", ({ args }) =>
+ viewTracks(args?.playlistName),
+ );
dispatcher.register("refreshPlaylist", ({ args }) =>
window.refreshPlaylist?.(args?.playlistName),
);
@@ -244,6 +267,12 @@ document.addEventListener("DOMContentLoaded", () => {
dispatcher.register("deleteDownload", ({ args }) =>
window.deleteDownload?.(args?.path),
);
+ dispatcher.register("fetchSongMigration", () =>
+ window.fetchSongMigration?.(),
+ );
+ dispatcher.register("downloadSongMigrationCsv", () =>
+ window.downloadSongMigrationCsv?.(),
+ );
initNavigationView({ switchTab: window.switchTab });
diff --git a/allstarr/wwwroot/js/mapping-targets.js b/allstarr/wwwroot/js/mapping-targets.js
new file mode 100644
index 0000000..069ebb2
--- /dev/null
+++ b/allstarr/wwwroot/js/mapping-targets.js
@@ -0,0 +1,87 @@
+// Shared helpers for displaying Spotify/global external mapping targets.
+
+import { escapeHtml, capitalizeProvider } from "./utils.js";
+
+/**
+ * Normalizes external targets from API payloads (camelCase or PascalCase).
+ */
+export function collectExternalTargets(mapping) {
+ const targets = [];
+ const seenProviders = new Set();
+
+ const addTarget = (provider, externalId, source) => {
+ if (!provider || !externalId) {
+ return;
+ }
+
+ const key = String(provider).toLowerCase();
+ if (seenProviders.has(key)) {
+ return;
+ }
+
+ seenProviders.add(key);
+ targets.push({
+ provider: String(provider),
+ externalId: String(externalId),
+ source: source || "",
+ });
+ };
+
+ const externalTargets =
+ mapping.externalTargets ||
+ mapping.ExternalTargets ||
+ mapping.externalMappings ||
+ mapping.ExternalMappings ||
+ [];
+
+ for (const ext of externalTargets) {
+ addTarget(
+ ext.provider ?? ext.Provider,
+ ext.externalId ?? ext.ExternalId,
+ ext.source ?? ext.Source,
+ );
+ }
+
+ addTarget(
+ mapping.externalProvider ?? mapping.ExternalProvider,
+ mapping.externalId ?? mapping.ExternalId,
+ mapping.source ?? mapping.Source ?? "manual",
+ );
+
+ return targets;
+}
+
+/**
+ * Renders a stacked list of provider targets for dashboard tables.
+ */
+export function renderExternalTargetsHtml(targets, options = {}) {
+ const { showRemove = false, playlist = "", spotifyId = "" } = options;
+
+ if (!Array.isArray(targets) || targets.length === 0) {
+ return '
— ';
+ }
+
+ return `
${targets
+ .map((target) => {
+ const label = capitalizeProvider(target.provider) || target.provider;
+ const removeBtn = showRemove
+ ? `
× `
+ : "";
+
+ const sourceHint = target.source
+ ? `
${escapeHtml(target.source)} `
+ : "";
+
+ return `
+ ${escapeHtml(label)}
+ ${escapeHtml(target.externalId)}
+ ${sourceHint}
+ ${removeBtn}
+
`;
+ })
+ .join("")}
`;
+}
diff --git a/allstarr/wwwroot/js/operations.js b/allstarr/wwwroot/js/operations.js
index c8987be..b56a7b3 100644
--- a/allstarr/wwwroot/js/operations.js
+++ b/allstarr/wwwroot/js/operations.js
@@ -112,13 +112,16 @@ async function deleteDownload(path) {
}
}
-async function deleteTrackMapping(playlist, spotifyId) {
- const confirmMessage = `Remove manual external mapping for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Allow the track to be matched automatically again\n• The track may be re-matched with potentially better results\n\nThis action cannot be undone.`;
+async function deleteTrackMapping(playlist, spotifyId, provider = null) {
+ const providerLabel = provider ? ` (${provider})` : "";
+ const confirmMessage = provider
+ ? `Remove the ${provider} mapping for ${spotifyId} in playlist "${playlist}"?\n\nOther provider mappings for this track will be kept.`
+ : `Remove all mappings for ${spotifyId} in playlist "${playlist}"?\n\nThis will:\n• Delete the manual mapping from the cache\n• Remove the global Spotify mapping\n• Allow the track to be matched automatically again\n\nThis action cannot be undone.`;
const result = await runAction({
confirmMessage,
- task: () => API.deleteTrackMapping(playlist, spotifyId),
- success: "Mapping removed successfully",
+ task: () => API.deleteTrackMapping(playlist, spotifyId, provider),
+ success: `Mapping${providerLabel} removed successfully`,
error: (err) => err.message || "Failed to remove mapping",
});
diff --git a/allstarr/wwwroot/js/scrobbling-admin.js b/allstarr/wwwroot/js/scrobbling-admin.js
index 6f2f9e4..fda0150 100644
--- a/allstarr/wwwroot/js/scrobbling-admin.js
+++ b/allstarr/wwwroot/js/scrobbling-admin.js
@@ -58,7 +58,10 @@ function parseBoolean(value) {
async function loadScrobblingConfig() {
try {
- const data = await API.fetchConfig();
+ const [data, status] = await Promise.all([
+ API.fetchConfig(),
+ API.fetchScrobblingStatus(),
+ ]);
document.getElementById("scrobbling-enabled-value").textContent = data
.scrobbling.enabled
@@ -118,10 +121,18 @@ async function loadScrobblingConfig() {
const hasSessionKey =
sessionKey && sessionKey !== "(not set)" && sessionKey.length > 0;
- let status = "";
- if (data.scrobbling.lastFm.enabled && hasSessionKey) {
- status =
+ const lastFmStatus = status?.lastFm;
+ const usingLegacyKey = lastFmStatus?.usingHardcodedCredentials === true;
+ let statusHtml = "";
+ if (usingLegacyKey) {
+ statusHtml =
+ '
✗ Suspended API key — set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET in .env ';
+ } else if (data.scrobbling.lastFm.enabled && hasApiKey && hasSecret && hasSessionKey) {
+ statusHtml =
'
✓ Configured & Enabled ';
+ } else if (data.scrobbling.lastFm.enabled && hasSessionKey && (!hasApiKey || !hasSecret)) {
+ statusHtml =
+ '
✗ Missing API key/secret in .env — add SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET ';
} else if (
hasApiKey &&
hasSecret &&
@@ -129,18 +140,18 @@ async function loadScrobblingConfig() {
hasPassword &&
!hasSessionKey
) {
- status =
+ statusHtml =
'
⚠️ Ready to Authenticate ';
} else if (hasApiKey && hasSecret && (!hasUsername || !hasPassword)) {
- status =
+ statusHtml =
'
⚠️ Needs Username & Password ';
} else if (!hasApiKey || !hasSecret) {
- status =
- '
✓ Using hardcoded credentials ';
+ statusHtml =
+ '
⚠️ Set SCROBBLING_LASTFM_API_KEY and SCROBBLING_LASTFM_SHARED_SECRET in .env ';
} else {
- status = '
○ Not Configured ';
+ statusHtml = '
○ Not Configured ';
}
- document.getElementById("lastfm-status-value").innerHTML = status;
+ document.getElementById("lastfm-status-value").innerHTML = statusHtml;
document.getElementById("listenbrainz-enabled-value").textContent = data
.scrobbling.listenBrainz.enabled
diff --git a/allstarr/wwwroot/js/song-migration.js b/allstarr/wwwroot/js/song-migration.js
new file mode 100644
index 0000000..c2bd46f
--- /dev/null
+++ b/allstarr/wwwroot/js/song-migration.js
@@ -0,0 +1,622 @@
+// Song Migration view module.
+//
+// Renders a list of all injected Spotify playlists where each playlist can be
+// expanded to show every track that is NOT in the local Jellyfin library.
+// That means tracks matched via external providers (SquidWTF, Deezer, Qobuz)
+// and tracks that are still missing. A CSV download is provided so users can
+// grab all non-Jellyfin tracks and feed them into their preferred download tool.
+
+import { escapeHtml, showToast, capitalizeProvider } from "./utils.js";
+import * as API from "./api.js";
+
+let isAdminSession = () => false;
+let songMigrationRequestToken = 0;
+
+// Cache of playlist name -> tracks array, to avoid re-fetching for CSV export
+// after the table has already been populated.
+const trackCache = new Map();
+
+// Tracks which playlist rows are currently expanded so refreshes preserve
+// expansion state.
+const expandedSongMigrationPlaylists = new Set();
+
+// Tracks which playlists have been kicked off fetching so we don't double-fetch.
+const inFlightTrackFetches = new Map();
+
+function isNonLocalTrack(track) {
+ // A track is "non-local" if it is not confirmed local in Jellyfin.
+ // isLocal === true -> local (Jellyfin) : excluded
+ // isLocal === false -> external match : included
+ // isLocal === null/undefined -> missing : included
+ return track && track.isLocal !== true;
+}
+
+function summarizeTracks(tracks) {
+ let external = 0;
+ let missing = 0;
+ for (const track of tracks) {
+ if (!track) continue;
+ if (track.isLocal === false) {
+ external += 1;
+ } else if (track.isLocal === null || track.isLocal === undefined) {
+ missing += 1;
+ }
+ }
+ return { external, missing, total: external + missing };
+}
+
+async function fetchTracksForPlaylist(playlistName) {
+ if (trackCache.has(playlistName)) {
+ return trackCache.get(playlistName);
+ }
+
+ if (inFlightTrackFetches.has(playlistName)) {
+ return inFlightTrackFetches.get(playlistName);
+ }
+
+ const promise = (async () => {
+ try {
+ const data = await API.fetchPlaylistTracks(playlistName);
+ const tracks = Array.isArray(data?.tracks) ? data.tracks : [];
+ trackCache.set(playlistName, tracks);
+ return tracks;
+ } catch (error) {
+ console.error(
+ `Failed to fetch tracks for playlist "${playlistName}":`,
+ error,
+ );
+ return [];
+ } finally {
+ inFlightTrackFetches.delete(playlistName);
+ }
+ })();
+
+ inFlightTrackFetches.set(playlistName, promise);
+ return promise;
+}
+
+function formatDuration(ms) {
+ if (typeof ms !== "number" || Number.isNaN(ms) || ms < 0) {
+ return "-";
+ }
+ const totalSeconds = Math.floor(ms / 1000);
+ const minutes = Math.floor(totalSeconds / 60);
+ const seconds = totalSeconds % 60;
+ return `${minutes}:${String(seconds).padStart(2, "0")}`;
+}
+
+function renderNonLocalTrackRow(track, index) {
+ const artists = Array.isArray(track.artists) ? track.artists.join(", ") : "";
+ const isMissing = track.isLocal === null || track.isLocal === undefined;
+ const providerLabel = isMissing
+ ? '
Missing '
+ : `
${escapeHtml(
+ capitalizeProvider(track.externalProvider) ||
+ track.externalProvider ||
+ "External",
+ )} `;
+
+ const spotifyUrl = track.spotifyId
+ ? `https://open.spotify.com/track/${encodeURIComponent(track.spotifyId)}`
+ : null;
+
+ const spotifyLink = spotifyUrl
+ ? `
${escapeHtml(
+ track.spotifyId,
+ )} `
+ : "-";
+
+ const isrcCell = track.isrc
+ ? `
${escapeHtml(track.isrc)} `
+ : "-";
+
+ return `
+
+ ${index + 1}
+ ${escapeHtml(track.title || "-")}
+ ${escapeHtml(artists || "-")}
+ ${escapeHtml(track.album || "-")}
+ ${providerLabel}
+ ${isrcCell}
+ ${spotifyLink}
+ ${escapeHtml(formatDuration(track.durationMs))}
+
+ `;
+}
+
+function renderNonLocalTracksPanel(tracks) {
+ if (!Array.isArray(tracks) || tracks.length === 0) {
+ return `
+
+
+ 🎉 Every track in this playlist is already in your Jellyfin library.
+
+
+ `;
+ }
+
+ return `
+
+ `;
+}
+
+function renderGuidance(playlists, totals) {
+ const container = document.getElementById("song-migration-guidance");
+ if (!container) return;
+
+ const messages = [];
+
+ if (!playlists || playlists.length === 0) {
+ messages.push({
+ tone: "info",
+ title: "No injected playlists yet.",
+ detail:
+ "Link a Jellyfin playlist to Spotify and run a match before migrating songs.",
+ });
+ } else if (totals.total === 0) {
+ messages.push({
+ tone: "success",
+ title: "Every injected track is already in your Jellyfin library.",
+ detail: "Nothing to migrate right now.",
+ });
+ } else {
+ messages.push({
+ tone: "info",
+ title: `${totals.total} tracks across ${playlists.length} playlists are not in Jellyfin.`,
+ detail:
+ "Expand a playlist to review its non-Jellyfin tracks, or use Download CSV to grab the whole list for bulk downloading.",
+ });
+ if (totals.missing > 0) {
+ messages.push({
+ tone: "warning",
+ title: `${totals.missing} tracks still could not be matched to any provider.`,
+ detail:
+ "These are labelled Missing in the CSV. You can map them manually from the Injected Playlists tab.",
+ });
+ }
+ }
+
+ container.innerHTML = messages
+ .map((msg) => {
+ const toneClass = msg.tone || "info";
+ return `
+
+
${escapeHtml(msg.title)}
+ ${msg.detail ? `
${escapeHtml(msg.detail)}
` : ""}
+
+ `;
+ })
+ .join("");
+}
+
+function renderPlaylistRow(playlist, index) {
+ const playlistName = playlist.name || "";
+ const detailsRowId = `song-migration-details-${index}`;
+ const detailsKey = playlistName;
+ const isExpanded = expandedSongMigrationPlaylists.has(detailsKey);
+
+ const external = playlist.externalMatched || 0;
+ const missing = playlist.externalMissing || 0;
+ const nonLocal = external + missing;
+ const spotifyTotal = playlist.trackCount || 0;
+
+ const escapedPlaylistName = escapeHtml(playlistName);
+
+ const statusBadges = [];
+ if (external > 0) {
+ statusBadges.push(
+ `
${external} External `,
+ );
+ }
+ if (missing > 0) {
+ statusBadges.push(
+ `
${missing} Missing `,
+ );
+ }
+ if (statusBadges.length === 0) {
+ statusBadges.push('
All Local ');
+ }
+
+ const detailsLabel = isExpanded ? "Hide" : "Details";
+
+ return `
+
+
+
+ ${escapedPlaylistName}
+ ${escapeHtml(playlist.id || "-")}
+
+
+
+ ${nonLocal}
+ of ${spotifyTotal} Spotify tracks
+
+ ${statusBadges.join(" ")}
+
+ ${detailsLabel}
+ CSV
+
+
+
+
+
+
+ Loading tracks...
+
+
+
+
+ `;
+}
+
+async function populateDetailsContent(playlistName) {
+ const container = document.querySelector(
+ `[data-song-migration-details-content="${CSS.escape(playlistName)}"]`,
+ );
+ if (!container) return;
+
+ const tracks = await fetchTracksForPlaylist(playlistName);
+ const nonLocal = tracks.filter(isNonLocalTrack);
+ container.innerHTML = renderNonLocalTracksPanel(nonLocal);
+}
+
+function bindRowEvents(tbody) {
+ tbody
+ .querySelectorAll(".song-migration-details-trigger")
+ .forEach((button) => {
+ button.addEventListener("click", async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const playlistName = button.getAttribute("data-song-migration-target");
+ if (!playlistName) return;
+
+ const detailsRow = tbody.querySelector(
+ `tr[data-song-migration-details-for="${CSS.escape(playlistName)}"]`,
+ );
+ const mainRow = tbody.querySelector(
+ `tr[data-song-migration-row="${CSS.escape(playlistName)}"]`,
+ );
+ if (!detailsRow) return;
+
+ const isHidden = detailsRow.hasAttribute("hidden");
+ if (isHidden) {
+ detailsRow.removeAttribute("hidden");
+ expandedSongMigrationPlaylists.add(playlistName);
+ button.setAttribute("aria-expanded", "true");
+ button.textContent = "Hide";
+ if (mainRow) mainRow.classList.add("expanded");
+ await populateDetailsContent(playlistName);
+ } else {
+ detailsRow.setAttribute("hidden", "");
+ expandedSongMigrationPlaylists.delete(playlistName);
+ button.setAttribute("aria-expanded", "false");
+ button.textContent = "Details";
+ if (mainRow) mainRow.classList.remove("expanded");
+ }
+ });
+ });
+
+ tbody.querySelectorAll(".song-migration-csv-btn").forEach((button) => {
+ button.addEventListener("click", async (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+
+ const playlistName = button.getAttribute("data-song-migration-csv");
+ if (!playlistName) return;
+
+ await downloadPlaylistCsv(playlistName);
+ });
+ });
+}
+
+export async function fetchSongMigration() {
+ if (!isAdminSession()) {
+ return;
+ }
+
+ const tbody = document.getElementById("song-migration-table-body");
+ if (!tbody) return;
+
+ const requestToken = ++songMigrationRequestToken;
+
+ try {
+ const data = await API.fetchPlaylists();
+ if (requestToken !== songMigrationRequestToken) return;
+
+ const playlists = Array.isArray(data?.playlists) ? data.playlists : [];
+
+ // Invalidate caches so expanding rows reflects any fresh match state.
+ trackCache.clear();
+
+ const totalExternal = playlists.reduce(
+ (sum, playlist) => sum + (playlist.externalMatched || 0),
+ 0,
+ );
+ const totalMissing = playlists.reduce(
+ (sum, playlist) => sum + (playlist.externalMissing || 0),
+ 0,
+ );
+ const totalNonLocal = totalExternal + totalMissing;
+
+ const playlistCountEl = document.getElementById(
+ "song-migration-playlist-count",
+ );
+ if (playlistCountEl) {
+ playlistCountEl.textContent = String(playlists.length);
+ }
+
+ const externalCountEl = document.getElementById(
+ "song-migration-external-count",
+ );
+ if (externalCountEl) {
+ externalCountEl.textContent = String(totalExternal);
+ }
+
+ const missingCountEl = document.getElementById(
+ "song-migration-missing-count",
+ );
+ if (missingCountEl) {
+ missingCountEl.textContent = String(totalMissing);
+ }
+
+ const totalCountEl = document.getElementById("song-migration-total-count");
+ if (totalCountEl) {
+ totalCountEl.textContent = String(totalNonLocal);
+ }
+
+ renderGuidance(playlists, {
+ external: totalExternal,
+ missing: totalMissing,
+ total: totalNonLocal,
+ });
+
+ if (playlists.length === 0) {
+ tbody.innerHTML =
+ '
No playlists configured. Link playlists from the Link Playlists tab. ';
+ return;
+ }
+
+ tbody.innerHTML = playlists
+ .map((playlist, index) => renderPlaylistRow(playlist, index))
+ .join("");
+
+ bindRowEvents(tbody);
+
+ // Re-populate any previously expanded rows so state survives refresh.
+ for (const playlistName of expandedSongMigrationPlaylists) {
+ await populateDetailsContent(playlistName);
+ }
+ } catch (error) {
+ if (requestToken !== songMigrationRequestToken) return;
+ console.error("Failed to fetch song migration data:", error);
+ tbody.innerHTML = `
+
+
+ Failed to load playlists: ${escapeHtml(error?.message || "Unknown error")}
+
+
+ `;
+ }
+}
+
+function csvEscape(value) {
+ if (value === null || value === undefined) {
+ return "";
+ }
+ const str = String(value);
+ if (/[",\r\n]/.test(str)) {
+ return `"${str.replace(/"/g, '""')}"`;
+ }
+ return str;
+}
+
+function buildCsvRows(entries) {
+ const headers = [
+ "Playlist",
+ "Position",
+ "Title",
+ "Artists",
+ "Album",
+ "ISRC",
+ "Spotify ID",
+ "Spotify URL",
+ "Duration (ms)",
+ "Duration",
+ "Status",
+ "Provider",
+ "Manual Mapping ID",
+ ];
+
+ const lines = [headers.map(csvEscape).join(",")];
+
+ for (const entry of entries) {
+ const { playlistName, track } = entry;
+ const status = track.isLocal === false ? "External" : "Missing";
+ const provider =
+ track.isLocal === false ? track.externalProvider || "" : "";
+ const artists = Array.isArray(track.artists)
+ ? track.artists.join(", ")
+ : "";
+ const spotifyUrl = track.spotifyId
+ ? `https://open.spotify.com/track/${track.spotifyId}`
+ : "";
+
+ lines.push(
+ [
+ playlistName,
+ track.position ?? "",
+ track.title ?? "",
+ artists,
+ track.album ?? "",
+ track.isrc ?? "",
+ track.spotifyId ?? "",
+ spotifyUrl,
+ typeof track.durationMs === "number" ? track.durationMs : "",
+ formatDuration(track.durationMs),
+ status,
+ provider,
+ track.manualMappingId ?? "",
+ ]
+ .map(csvEscape)
+ .join(","),
+ );
+ }
+
+ return lines.join("\r\n");
+}
+
+function triggerCsvDownload(filename, csvContent) {
+ // Prefix BOM so Excel reads UTF-8 correctly.
+ const blob = new Blob(["\uFEFF" + csvContent], {
+ type: "text/csv;charset=utf-8;",
+ });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement("a");
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+}
+
+function sanitizeFilenameSegment(name) {
+ return (
+ String(name || "playlist")
+ .replace(/[^\w\-]+/g, "_")
+ .replace(/^_+|_+$/g, "")
+ .slice(0, 80) || "playlist"
+ );
+}
+
+async function downloadPlaylistCsv(playlistName) {
+ try {
+ showToast(`Preparing CSV for "${playlistName}"...`, "success", 1500);
+ const tracks = await fetchTracksForPlaylist(playlistName);
+ const nonLocal = tracks.filter(isNonLocalTrack);
+
+ if (nonLocal.length === 0) {
+ showToast(`No non-Jellyfin tracks in "${playlistName}"`, "warning");
+ return;
+ }
+
+ const entries = nonLocal.map((track) => ({ playlistName, track }));
+ const csv = buildCsvRows(entries);
+ const filename = `song-migration-${sanitizeFilenameSegment(playlistName)}.csv`;
+ triggerCsvDownload(filename, csv);
+ showToast(
+ `Downloaded ${nonLocal.length} tracks from "${playlistName}"`,
+ "success",
+ );
+ } catch (error) {
+ console.error("Failed to build playlist CSV:", error);
+ showToast(
+ `Failed to build CSV: ${error?.message || "Unknown error"}`,
+ "error",
+ );
+ }
+}
+
+export async function downloadSongMigrationCsv() {
+ try {
+ const data = await API.fetchPlaylists();
+ const playlists = Array.isArray(data?.playlists) ? data.playlists : [];
+
+ if (playlists.length === 0) {
+ showToast("No injected playlists configured.", "warning");
+ return;
+ }
+
+ showToast("Building CSV, this may take a moment...", "success", 2000);
+
+ const entries = [];
+ let scanned = 0;
+
+ for (const playlist of playlists) {
+ const tracks = await fetchTracksForPlaylist(playlist.name);
+ const nonLocal = tracks.filter(isNonLocalTrack);
+ for (const track of nonLocal) {
+ entries.push({ playlistName: playlist.name, track });
+ }
+ scanned += 1;
+ }
+
+ if (entries.length === 0) {
+ showToast(
+ "Every track across all playlists is already in Jellyfin.",
+ "success",
+ );
+ return;
+ }
+
+ const csv = buildCsvRows(entries);
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
+ const filename = `song-migration-all-${timestamp}.csv`;
+ triggerCsvDownload(filename, csv);
+
+ showToast(
+ `Downloaded ${entries.length} tracks across ${scanned} playlists.`,
+ "success",
+ );
+ } catch (error) {
+ console.error("Failed to build combined CSV:", error);
+ showToast(
+ `Failed to build CSV: ${error?.message || "Unknown error"}`,
+ "error",
+ );
+ }
+}
+
+export function resetSongMigrationState() {
+ trackCache.clear();
+ inFlightTrackFetches.clear();
+ expandedSongMigrationPlaylists.clear();
+ songMigrationRequestToken = 0;
+}
+
+export function initSongMigration(options = {}) {
+ isAdminSession = options.isAdminSession || (() => false);
+
+ // Expose to window so tab-switch hooks and the ActionDispatcher can call
+ // these without tight-coupling to this module's import path.
+ window.fetchSongMigration = fetchSongMigration;
+ window.downloadSongMigrationCsv = downloadSongMigrationCsv;
+
+ return {
+ fetchSongMigration,
+ downloadSongMigrationCsv,
+ resetSongMigrationState,
+ };
+}
diff --git a/allstarr/wwwroot/js/ui.js b/allstarr/wwwroot/js/ui.js
index f6621d9..03fda65 100644
--- a/allstarr/wwwroot/js/ui.js
+++ b/allstarr/wwwroot/js/ui.js
@@ -1,10 +1,15 @@
// UI updates and DOM manipulation
import { escapeHtml, escapeJs, capitalizeProvider } from "./utils.js";
+import {
+ collectExternalTargets,
+ renderExternalTargetsHtml,
+} from "./mapping-targets.js";
let rowMenuHandlersBound = false;
let tableRowHandlersBound = false;
const expandedInjectedPlaylistDetails = new Set();
+let openInjectedPlaylistMenuKey = null;
function bindRowMenuHandlers() {
if (rowMenuHandlersBound) {
@@ -57,8 +62,16 @@ function closeAllRowMenus(exceptId = null) {
document.querySelectorAll(".row-actions-menu.open").forEach((menu) => {
if (!exceptId || menu.id !== exceptId) {
menu.classList.remove("open");
+ const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
+ if (trigger) {
+ trigger.setAttribute("aria-expanded", "false");
+ }
}
});
+
+ if (!exceptId) {
+ openInjectedPlaylistMenuKey = null;
+ }
}
function closeRowMenu(event, menuId) {
@@ -69,6 +82,13 @@ function closeRowMenu(event, menuId) {
const menu = document.getElementById(menuId);
if (menu) {
menu.classList.remove("open");
+ const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
+ if (trigger) {
+ trigger.setAttribute("aria-expanded", "false");
+ }
+ if (menu.dataset.menuKey) {
+ openInjectedPlaylistMenuKey = null;
+ }
}
}
@@ -85,6 +105,14 @@ function toggleRowMenu(event, menuId) {
const isOpen = menu.classList.contains("open");
closeAllRowMenus(menuId);
menu.classList.toggle("open", !isOpen);
+ const trigger = menu.parentElement?.querySelector?.(".menu-trigger");
+ if (trigger) {
+ trigger.setAttribute("aria-expanded", String(!isOpen));
+ }
+
+ if (menu.dataset.menuKey) {
+ openInjectedPlaylistMenuKey = isOpen ? null : menu.dataset.menuKey;
+ }
}
function toggleDetailsRow(event, detailsRowId) {
@@ -224,6 +252,275 @@ function getPlaylistStatusSummary(playlist) {
};
}
+function syncElementAttributes(target, source) {
+ if (!target || !source) {
+ return;
+ }
+
+ const sourceAttributes = new Map(
+ Array.from(source.attributes || []).map((attribute) => [
+ attribute.name,
+ attribute.value,
+ ]),
+ );
+
+ Array.from(target.attributes || []).forEach((attribute) => {
+ if (!sourceAttributes.has(attribute.name)) {
+ target.removeAttribute(attribute.name);
+ }
+ });
+
+ sourceAttributes.forEach((value, name) => {
+ target.setAttribute(name, value);
+ });
+}
+
+function syncPlaylistRowActionsWrap(existingWrap, nextWrap) {
+ if (!existingWrap || !nextWrap) {
+ return;
+ }
+
+ syncElementAttributes(existingWrap, nextWrap);
+
+ const activeElement = document.activeElement;
+ let focusTarget = null;
+
+ if (activeElement && existingWrap.contains(activeElement)) {
+ if (activeElement.classList.contains("menu-trigger")) {
+ focusTarget = { type: "trigger" };
+ } else if (activeElement.tagName === "BUTTON") {
+ focusTarget = {
+ type: "menu-item",
+ action: activeElement.getAttribute("data-action") || "",
+ text: activeElement.textContent || "",
+ };
+ }
+ }
+
+ const existingTrigger = existingWrap.querySelector(".menu-trigger");
+ const nextTrigger = nextWrap.querySelector(".menu-trigger");
+ if (existingTrigger && nextTrigger) {
+ syncElementAttributes(existingTrigger, nextTrigger);
+ existingTrigger.textContent = nextTrigger.textContent;
+ } else if (nextTrigger && !existingTrigger) {
+ existingWrap.prepend(nextTrigger.cloneNode(true));
+ } else if (existingTrigger && !nextTrigger) {
+ existingTrigger.remove();
+ }
+
+ const existingMenu = existingWrap.querySelector(".row-actions-menu");
+ const nextMenu = nextWrap.querySelector(".row-actions-menu");
+ if (existingMenu && nextMenu) {
+ syncElementAttributes(existingMenu, nextMenu);
+ existingMenu.replaceChildren(
+ ...Array.from(nextMenu.children).map((child) => child.cloneNode(true)),
+ );
+ } else if (nextMenu && !existingMenu) {
+ existingWrap.append(nextMenu.cloneNode(true));
+ } else if (existingMenu && !nextMenu) {
+ existingMenu.remove();
+ }
+
+ if (!focusTarget) {
+ return;
+ }
+
+ if (focusTarget.type === "trigger") {
+ existingWrap.querySelector(".menu-trigger")?.focus();
+ return;
+ }
+
+ const matchingButton =
+ Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
+ (button) =>
+ (button.getAttribute("data-action") || "") === focusTarget.action &&
+ button.textContent === focusTarget.text,
+ ) ||
+ Array.from(existingWrap.querySelectorAll(".row-actions-menu button")).find(
+ (button) =>
+ (button.getAttribute("data-action") || "") === focusTarget.action,
+ );
+
+ matchingButton?.focus();
+}
+
+function syncPlaylistControlsCell(
+ existingControlsCell,
+ nextControlsCell,
+ preserveOpenMenu = false,
+) {
+ if (!existingControlsCell || !nextControlsCell) {
+ return;
+ }
+
+ syncElementAttributes(existingControlsCell, nextControlsCell);
+
+ if (!preserveOpenMenu) {
+ existingControlsCell.innerHTML = nextControlsCell.innerHTML;
+ return;
+ }
+
+ const existingDetailsTrigger =
+ existingControlsCell.querySelector(".details-trigger");
+ const nextDetailsTrigger = nextControlsCell.querySelector(".details-trigger");
+ const existingWrap = existingControlsCell.querySelector(".row-actions-wrap");
+ const nextWrap = nextControlsCell.querySelector(".row-actions-wrap");
+
+ if (
+ !existingDetailsTrigger ||
+ !nextDetailsTrigger ||
+ !existingWrap ||
+ !nextWrap
+ ) {
+ existingControlsCell.innerHTML = nextControlsCell.innerHTML;
+ return;
+ }
+
+ syncElementAttributes(existingDetailsTrigger, nextDetailsTrigger);
+ existingDetailsTrigger.textContent = nextDetailsTrigger.textContent;
+ syncPlaylistRowActionsWrap(existingWrap, nextWrap);
+}
+
+function syncPlaylistMainRow(
+ existingMainRow,
+ nextMainRow,
+ preserveOpenMenu = false,
+) {
+ if (!existingMainRow || !nextMainRow) {
+ return;
+ }
+
+ syncElementAttributes(existingMainRow, nextMainRow);
+
+ const nextCells = Array.from(nextMainRow.children);
+ const existingCells = Array.from(existingMainRow.children);
+
+ if (!preserveOpenMenu || nextCells.length !== existingCells.length) {
+ existingMainRow.innerHTML = nextMainRow.innerHTML;
+ return;
+ }
+
+ nextCells.forEach((nextCell, index) => {
+ const existingCell = existingCells[index];
+ if (!existingCell) {
+ existingMainRow.append(nextCell.cloneNode(true));
+ return;
+ }
+
+ if (index === nextCells.length - 1) {
+ syncPlaylistControlsCell(existingCell, nextCell, preserveOpenMenu);
+ return;
+ }
+
+ existingCell.replaceWith(nextCell.cloneNode(true));
+ });
+
+ while (existingMainRow.children.length > nextCells.length) {
+ existingMainRow.lastElementChild?.remove();
+ }
+}
+
+function syncPlaylistDetailsRow(existingDetailsRow, nextDetailsRow) {
+ if (!existingDetailsRow || !nextDetailsRow) {
+ return;
+ }
+
+ syncElementAttributes(existingDetailsRow, nextDetailsRow);
+ existingDetailsRow.innerHTML = nextDetailsRow.innerHTML;
+}
+
+function renderPlaylistRowPairMarkup(playlist, index) {
+ const summary = getPlaylistStatusSummary(playlist);
+ const detailsRowId = `playlist-details-${index}`;
+ const menuId = `playlist-menu-${index}`;
+ const detailsKey = `${playlist.id || playlist.name || index}`;
+ const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
+ const isMenuOpen = openInjectedPlaylistMenuKey === detailsKey;
+ const syncSchedule = playlist.syncSchedule || "0 8 * * *";
+ const escapedPlaylistName = escapeHtml(playlist.name);
+ const escapedSyncSchedule = escapeHtml(syncSchedule);
+ const escapedDetailsKey = escapeHtml(detailsKey);
+
+ const breakdownBadges = [
+ `
${summary.localCount} Local `,
+ `
${summary.externalMatched} External `,
+ ];
+
+ if (summary.externalMissing > 0) {
+ breakdownBadges.push(
+ `
${summary.externalMissing} Missing `,
+ );
+ }
+
+ return `
+
+
+
+ ${escapeHtml(playlist.name)}
+ ${escapeHtml(playlist.id || "-")}
+
+
+
+ ${summary.totalPlayable}/${summary.spotifyTotal}
+ ${summary.completionPct}% playable
+
+ ${summary.statusLabel}
+
+ ${isExpanded ? "Hide" : "Details"}
+
+
+
+
+
+
+
+
+
+
+
+ Sync Schedule
+
+ ${escapeHtml(syncSchedule)}
+ Edit
+
+
+
+ Cache Age
+ ${escapeHtml(playlist.cacheAge || "-")}
+
+
+ Track Breakdown
+ ${breakdownBadges.join(" ")}
+
+
+
+
+
+
+ `;
+}
+
+function createPlaylistRowPair(playlist, index) {
+ const template = document.createElement("template");
+ template.innerHTML = renderPlaylistRowPairMarkup(playlist, index).trim();
+ const [mainRow, detailsRow] = template.content.querySelectorAll("tr");
+ return { mainRow, detailsRow };
+}
+
if (typeof window !== "undefined") {
window.toggleRowMenu = toggleRowMenu;
window.closeRowMenu = closeRowMenu;
@@ -235,9 +532,6 @@ bindRowMenuHandlers();
bindTableRowHandlers();
export function updateStatusUI(data) {
- const versionEl = document.getElementById("version");
- if (versionEl) versionEl.textContent = "v" + data.version;
-
const sidebarVersionEl = document.getElementById("sidebar-version");
if (sidebarVersionEl) sidebarVersionEl.textContent = "v" + data.version;
@@ -321,10 +615,15 @@ export function updateStatusUI(data) {
export function updatePlaylistsUI(data) {
const tbody = document.getElementById("playlist-table-body");
+ if (!tbody) {
+ return;
+ }
+
const playlists = data.playlists || [];
if (playlists.length === 0) {
expandedInjectedPlaylistDetails.clear();
+ openInjectedPlaylistMenuKey = null;
tbody.innerHTML =
'
No playlists configured. Link playlists from the Link Playlists tab. ';
renderGuidance("playlists-guidance", [
@@ -378,91 +677,68 @@ export function updatePlaylistsUI(data) {
});
renderGuidance("playlists-guidance", guidance);
- tbody.innerHTML = playlists
- .map((playlist, index) => {
- const summary = getPlaylistStatusSummary(playlist);
- const detailsRowId = `playlist-details-${index}`;
- const menuId = `playlist-menu-${index}`;
- const detailsKey = `${playlist.id || playlist.name || index}`;
- const isExpanded = expandedInjectedPlaylistDetails.has(detailsKey);
- const syncSchedule = playlist.syncSchedule || "0 8 * * *";
- const escapedPlaylistName = escapeHtml(playlist.name);
- const escapedSyncSchedule = escapeHtml(syncSchedule);
- const escapedDetailsKey = escapeHtml(detailsKey);
+ const existingPairs = new Map();
+ Array.from(
+ tbody.querySelectorAll("tr.compact-row[data-details-key]"),
+ ).forEach((mainRow) => {
+ const detailsKey = mainRow.getAttribute("data-details-key");
+ if (!detailsKey || existingPairs.has(detailsKey)) {
+ return;
+ }
- const breakdownBadges = [
- `
${summary.localCount} Local `,
- `
${summary.externalMatched} External `,
- ];
+ const detailsRowId = mainRow.getAttribute("data-details-row");
+ const detailsRow =
+ (detailsRowId && document.getElementById(detailsRowId)) ||
+ mainRow.nextElementSibling;
+ if (!detailsRow) {
+ return;
+ }
- if (summary.externalMissing > 0) {
- breakdownBadges.push(
- `
${summary.externalMissing} Missing `,
- );
- }
+ existingPairs.set(detailsKey, { mainRow, detailsRow });
+ });
- return `
-
-
-
- ${escapeHtml(playlist.name)}
- ${escapeHtml(playlist.id || "-")}
-
-
-
- ${summary.totalPlayable}/${summary.spotifyTotal}
- ${summary.completionPct}% playable
-
- ${summary.statusLabel}
-
- ${isExpanded ? "Hide" : "Details"}
-
-
-
-
-
-
-
-
-
-
-
- Sync Schedule
-
- ${escapeHtml(syncSchedule)}
- Edit
-
-
-
- Cache Age
- ${escapeHtml(playlist.cacheAge || "-")}
-
-
- Track Breakdown
- ${breakdownBadges.join(" ")}
-
-
-
-
-
-
- `;
- })
- .join("");
+ const orderedRows = [];
+ playlists.forEach((playlist, index) => {
+ const detailsKey = `${playlist.id || playlist.name || index}`;
+ const { mainRow: nextMainRow, detailsRow: nextDetailsRow } =
+ createPlaylistRowPair(playlist, index);
+ const existingPair = existingPairs.get(detailsKey);
+
+ if (!existingPair) {
+ orderedRows.push(nextMainRow, nextDetailsRow);
+ return;
+ }
+
+ syncPlaylistMainRow(
+ existingPair.mainRow,
+ nextMainRow,
+ detailsKey === openInjectedPlaylistMenuKey,
+ );
+ syncPlaylistDetailsRow(existingPair.detailsRow, nextDetailsRow);
+
+ orderedRows.push(existingPair.mainRow, existingPair.detailsRow);
+ existingPairs.delete(detailsKey);
+ });
+
+ const activeRows = new Set(orderedRows);
+ orderedRows.forEach((row) => {
+ tbody.append(row);
+ });
+ Array.from(tbody.children).forEach((row) => {
+ if (!activeRows.has(row)) {
+ row.remove();
+ }
+ });
+
+ if (
+ openInjectedPlaylistMenuKey &&
+ !playlists.some(
+ (playlist, index) =>
+ `${playlist.id || playlist.name || index}` === openInjectedPlaylistMenuKey,
+ )
+ ) {
+ openInjectedPlaylistMenuKey = null;
+ }
}
export function updateTrackMappingsUI(data) {
@@ -491,7 +767,12 @@ export function updateTrackMappingsUI(data) {
.map((m) => {
const typeColor = "var(--success)";
const typeBadge = `
external `;
- const targetDisplay = `
${m.externalProvider}/${m.externalId} `;
+ const targets = collectExternalTargets(m);
+ const targetDisplay = renderExternalTargetsHtml(targets, {
+ showRemove: true,
+ playlist: m.playlist,
+ spotifyId: m.spotifyId,
+ });
const createdDate = m.createdAt
? new Date(m.createdAt).toLocaleString()
: "-";
@@ -499,17 +780,50 @@ export function updateTrackMappingsUI(data) {
return `
${escapeHtml(m.playlist)}
- ${m.spotifyId}
+ ${escapeHtml(m.spotifyId)}
${typeBadge}
${targetDisplay}
${createdDate}
- Remove
+ Remove all
`;
})
.join("");
+
+ bindTrackMappingDeleteHandlers(tbody);
+}
+
+function bindTrackMappingDeleteHandlers(tbody) {
+ tbody.querySelectorAll(".delete-mapping-provider-btn").forEach((button) => {
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const playlist = button.getAttribute("data-playlist");
+ const spotifyId = button.getAttribute("data-spotify-id");
+ const provider = button.getAttribute("data-provider");
+ if (!playlist || !spotifyId || !provider) {
+ return;
+ }
+ window.deleteTrackMapping?.(playlist, spotifyId, provider);
+ });
+ });
+
+ tbody.querySelectorAll(".delete-mapping-btn").forEach((button) => {
+ button.addEventListener("click", (event) => {
+ event.preventDefault();
+ event.stopPropagation();
+ const playlist = button.getAttribute("data-playlist");
+ const spotifyId = button.getAttribute("data-spotify-id");
+ if (!playlist || !spotifyId) {
+ return;
+ }
+ window.deleteTrackMapping?.(playlist, spotifyId);
+ });
+ });
}
export function updateDownloadsUI(data) {
diff --git a/allstarr/wwwroot/spotify-mappings.html b/allstarr/wwwroot/spotify-mappings.html
index d0d474c..88181b2 100644
--- a/allstarr/wwwroot/spotify-mappings.html
+++ b/allstarr/wwwroot/spotify-mappings.html
@@ -324,6 +324,71 @@
color: var(--text-secondary);
}
+ .target-list {
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+ }
+
+ .target-item {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ flex-wrap: wrap;
+ }
+
+ .target-item .badge {
+ min-width: 72px;
+ text-align: center;
+ text-transform: lowercase;
+ }
+
+ .target-source {
+ font-size: 0.75rem;
+ color: var(--text-secondary);
+ }
+
+ .target-empty {
+ color: var(--text-secondary);
+ }
+
+ .target-remove-btn {
+ margin-left: auto;
+ min-width: 28px;
+ padding: 2px 8px;
+ font-size: 1rem;
+ line-height: 1;
+ border-radius: 6px;
+ background: rgba(248, 81, 73, 0.12);
+ border: 1px solid rgba(248, 81, 73, 0.45);
+ color: var(--error);
+ cursor: pointer;
+ }
+
+ .target-remove-btn:hover {
+ background: rgba(248, 81, 73, 0.25);
+ }
+
+ .external-existing-panel {
+ margin-bottom: 16px;
+ padding: 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border);
+ background: rgba(13, 17, 23, 0.35);
+ }
+
+ .external-existing-panel h4 {
+ font-size: 0.85rem;
+ color: var(--text-secondary);
+ margin-bottom: 10px;
+ font-weight: 600;
+ }
+
+ .external-existing-empty {
+ color: var(--text-secondary);
+ font-size: 0.85rem;
+ }
+
.pagination {
display: flex;
justify-content: center;
@@ -642,8 +707,13 @@