mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-08 14:05:02 -04:00
feat: add Song Migration tab with CSV export of non-Jellyfin tracks
CI / build-and-test (push) Has been cancelled
CI / build-and-test (push) Has been cancelled
This commit is contained in:
4 files changed
+776
-45
No files matched your search
@@ -87,6 +87,7 @@
|
||||
<button class="sidebar-link active" type="button" data-tab="dashboard">Dashboard</button>
|
||||
<button class="sidebar-link" type="button" data-tab="jellyfin-playlists">Link Playlists</button>
|
||||
<button class="sidebar-link" type="button" data-tab="playlists">Injected Playlists</button>
|
||||
<button class="sidebar-link" type="button" data-tab="song-migration">Song Migration</button>
|
||||
<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>
|
||||
@@ -106,6 +107,7 @@
|
||||
<div class="tab active" data-tab="dashboard">Dashboard</div>
|
||||
<div class="tab" data-tab="jellyfin-playlists">Link Playlists</div>
|
||||
<div class="tab" data-tab="playlists">Injected Playlists</div>
|
||||
<div class="tab" data-tab="song-migration">Song Migration</div>
|
||||
<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>
|
||||
@@ -374,6 +376,61 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Song Migration Tab -->
|
||||
<div class="tab-content" id="tab-song-migration">
|
||||
<div class="card">
|
||||
<h2>
|
||||
Song Migration
|
||||
<div class="actions">
|
||||
<button class="primary" data-action="downloadSongMigrationCsv"
|
||||
title="Download all non-Jellyfin tracks (external + missing) across all injected playlists as CSV">Download CSV (All)</button>
|
||||
<button data-action="fetchSongMigration">Refresh</button>
|
||||
</div>
|
||||
</h2>
|
||||
<p class="text-secondary mb-12">
|
||||
Tracks from your injected Spotify playlists that are <strong>not</strong> in your Jellyfin library.
|
||||
This includes tracks matched through external providers (SquidWTF, Deezer, Qobuz) and tracks that
|
||||
are still missing. Use the CSV export to download these tracks with your preferred tool.
|
||||
</p>
|
||||
<div id="song-migration-guidance" class="guidance-stack"></div>
|
||||
<div id="song-migration-summary" class="summary-box">
|
||||
<div>
|
||||
<span class="summary-label">Playlists:</span>
|
||||
<span class="summary-value accent" id="song-migration-playlist-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="summary-label">External:</span>
|
||||
<span class="summary-value success" id="song-migration-external-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="summary-label">Missing:</span>
|
||||
<span class="summary-value warning" id="song-migration-missing-count">0</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="summary-label">Total to Migrate:</span>
|
||||
<span class="summary-value accent" id="song-migration-total-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
<table class="playlist-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Non-Local Tracks</th>
|
||||
<th>Status</th>
|
||||
<th>...</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="song-migration-table-body">
|
||||
<tr>
|
||||
<td colspan="4" class="loading">
|
||||
<span class="spinner"></span> Loading playlists...
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Kept Downloads Tab -->
|
||||
<div class="tab-content" id="tab-kept">
|
||||
<div class="card">
|
||||
|
||||
@@ -382,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();
|
||||
@@ -418,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);
|
||||
@@ -450,40 +461,50 @@ function renderDownloadActivity(downloads) {
|
||||
}
|
||||
|
||||
const statusIcons = {
|
||||
0: '⏳', // NotStarted
|
||||
0: "⏳", // NotStarted
|
||||
1: '<span class="spinner" style="border-width:2px; height:12px; width:12px; display:inline-block; margin-right:4px;"></span> 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
|
||||
? `<div class="download-progress-meta">${progressMeta.map(escapeHtml).join(" • ")}</div>`
|
||||
: "";
|
||||
const progressMetaText =
|
||||
progressMeta.length > 0
|
||||
? `<div class="download-progress-meta">${progressMeta.map(escapeHtml).join(" • ")}</div>`
|
||||
: "";
|
||||
|
||||
const progressBar = `
|
||||
const progressBar = `
|
||||
<div class="download-progress-bar" aria-hidden="true">
|
||||
<div class="download-progress-buffer" style="width:${downloadProgress * 100}%"></div>
|
||||
<div class="download-progress-playback" style="width:${playbackProgress * 100}%"></div>
|
||||
@@ -491,17 +512,19 @@ function renderDownloadActivity(downloads) {
|
||||
${progressMetaText}
|
||||
`;
|
||||
|
||||
const title = d.title || 'Unknown Title';
|
||||
const artist = d.artist || 'Unknown Artist';
|
||||
const errorText = d.errorMessage ? `<div style="color:var(--error); font-size:0.8rem; margin-top:4px;">${escapeHtml(d.errorMessage)}</div>` : '';
|
||||
const streamBadge = d.requestedForStreaming
|
||||
? '<span class="download-queue-badge">Stream</span>'
|
||||
: '';
|
||||
const playingBadge = d.isPlaying
|
||||
? '<span class="download-queue-badge is-playing">Playing</span>'
|
||||
: '';
|
||||
const title = d.title || "Unknown Title";
|
||||
const artist = d.artist || "Unknown Artist";
|
||||
const errorText = d.errorMessage
|
||||
? `<div style="color:var(--error); font-size:0.8rem; margin-top:4px;">${escapeHtml(d.errorMessage)}</div>`
|
||||
: "";
|
||||
const streamBadge = d.requestedForStreaming
|
||||
? '<span class="download-queue-badge">Stream</span>'
|
||||
: "";
|
||||
const playingBadge = d.isPlaying
|
||||
? '<span class="download-queue-badge is-playing">Playing</span>'
|
||||
: "";
|
||||
|
||||
return `
|
||||
return `
|
||||
<div class="download-queue-item">
|
||||
<div class="download-queue-info">
|
||||
<div class="download-queue-title">${escapeHtml(title)}</div>
|
||||
@@ -515,12 +538,13 @@ function renderDownloadActivity(downloads) {
|
||||
${errorText}
|
||||
</div>
|
||||
<div class="download-queue-status">
|
||||
<span style="font-size:0.85rem;">${statusIcons[d.status] || 'Unknown'}</span>
|
||||
<span style="font-size:0.85rem;">${statusIcons[d.status] || "Unknown"}</span>
|
||||
<span class="download-queue-time">${timeText}</span>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
})
|
||||
.join("");
|
||||
|
||||
container.innerHTML = html;
|
||||
}
|
||||
@@ -534,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";
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,10 @@ 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";
|
||||
@@ -79,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();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,6 +149,10 @@ initPlaylistAdmin({
|
||||
fetchJellyfinPlaylists: dashboard.fetchJellyfinPlaylists,
|
||||
});
|
||||
|
||||
initSongMigration({
|
||||
isAdminSession: () => authSession?.isAdminSession() ?? false,
|
||||
});
|
||||
|
||||
initIssueReporter();
|
||||
|
||||
const authSession = initAuthSession({
|
||||
@@ -146,6 +161,7 @@ const authSession = initAuthSession({
|
||||
switchTab: window.switchTab,
|
||||
onUnauthenticated: () => {
|
||||
resetPlaylistAdminState();
|
||||
resetSongMigrationState();
|
||||
setCurrentConfigState(null);
|
||||
},
|
||||
});
|
||||
@@ -178,7 +194,9 @@ document.addEventListener("DOMContentLoaded", () => {
|
||||
window.switchTab(tab);
|
||||
}
|
||||
});
|
||||
dispatcher.register("logoutAdminSession", () => window.logoutAdminSession?.());
|
||||
dispatcher.register("logoutAdminSession", () =>
|
||||
window.logoutAdminSession?.(),
|
||||
);
|
||||
dispatcher.register("dismissRestartBanner", () =>
|
||||
window.dismissRestartBanner?.(),
|
||||
);
|
||||
@@ -192,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),
|
||||
);
|
||||
@@ -247,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 });
|
||||
|
||||
|
||||
@@ -0,0 +1,620 @@
|
||||
// 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
|
||||
? '<span class="status-pill warning">Missing</span>'
|
||||
: `<span class="status-pill info">${escapeHtml(
|
||||
capitalizeProvider(track.externalProvider) ||
|
||||
track.externalProvider ||
|
||||
"External",
|
||||
)}</span>`;
|
||||
|
||||
const spotifyUrl = track.spotifyId
|
||||
? `https://open.spotify.com/track/${encodeURIComponent(track.spotifyId)}`
|
||||
: null;
|
||||
|
||||
const spotifyLink = spotifyUrl
|
||||
? `<a href="${escapeHtml(
|
||||
spotifyUrl,
|
||||
)}" target="_blank" rel="noopener" class="mono" style="color:var(--accent);text-decoration:underline;">${escapeHtml(
|
||||
track.spotifyId,
|
||||
)}</a>`
|
||||
: "-";
|
||||
|
||||
const isrcCell = track.isrc
|
||||
? `<span class="mono">${escapeHtml(track.isrc)}</span>`
|
||||
: "-";
|
||||
|
||||
return `
|
||||
<tr>
|
||||
<td style="color:var(--text-secondary);">${index + 1}</td>
|
||||
<td><strong>${escapeHtml(track.title || "-")}</strong></td>
|
||||
<td>${escapeHtml(artists || "-")}</td>
|
||||
<td style="color:var(--text-secondary);">${escapeHtml(track.album || "-")}</td>
|
||||
<td>${providerLabel}</td>
|
||||
<td>${isrcCell}</td>
|
||||
<td>${spotifyLink}</td>
|
||||
<td style="color:var(--text-secondary);">${escapeHtml(formatDuration(track.durationMs))}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderNonLocalTracksPanel(tracks) {
|
||||
if (!Array.isArray(tracks) || tracks.length === 0) {
|
||||
return `
|
||||
<div class="details-panel">
|
||||
<p class="text-secondary" style="margin:0;padding:16px;">
|
||||
🎉 Every track in this playlist is already in your Jellyfin library.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return `
|
||||
<div class="details-panel">
|
||||
<table class="playlist-table" style="margin-top:8px;">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width:40px;">#</th>
|
||||
<th>Title</th>
|
||||
<th>Artist</th>
|
||||
<th>Album</th>
|
||||
<th>Status</th>
|
||||
<th>ISRC</th>
|
||||
<th>Spotify</th>
|
||||
<th>Duration</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
${tracks.map(renderNonLocalTrackRow).join("")}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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 `
|
||||
<div class="guidance-banner ${toneClass}">
|
||||
<strong>${escapeHtml(msg.title)}</strong>
|
||||
${msg.detail ? `<div>${escapeHtml(msg.detail)}</div>` : ""}
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.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(
|
||||
`<span class="status-pill info">${external} External</span>`,
|
||||
);
|
||||
}
|
||||
if (missing > 0) {
|
||||
statusBadges.push(
|
||||
`<span class="status-pill warning">${missing} Missing</span>`,
|
||||
);
|
||||
}
|
||||
if (statusBadges.length === 0) {
|
||||
statusBadges.push('<span class="status-pill success">All Local</span>');
|
||||
}
|
||||
|
||||
const detailsLabel = isExpanded ? "Hide" : "Details";
|
||||
|
||||
return `
|
||||
<tr class="compact-row ${isExpanded ? "expanded" : ""}"
|
||||
data-song-migration-row="${escapedPlaylistName}">
|
||||
<td>
|
||||
<div class="name-cell">
|
||||
<strong>${escapedPlaylistName}</strong>
|
||||
<span class="meta-text subtle-mono">${escapeHtml(playlist.id || "-")}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="track-count">${nonLocal}</span>
|
||||
<div class="meta-text">of ${spotifyTotal} Spotify tracks</div>
|
||||
</td>
|
||||
<td>${statusBadges.join(" ")}</td>
|
||||
<td class="row-controls">
|
||||
<button class="icon-btn song-migration-details-trigger"
|
||||
data-song-migration-target="${escapedPlaylistName}"
|
||||
aria-expanded="${isExpanded ? "true" : "false"}">${detailsLabel}</button>
|
||||
<button class="icon-btn song-migration-csv-btn"
|
||||
data-song-migration-csv="${escapedPlaylistName}"
|
||||
title="Download this playlist's non-Jellyfin tracks as CSV">CSV</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr id="${detailsRowId}"
|
||||
class="details-row"
|
||||
data-song-migration-details-for="${escapedPlaylistName}"
|
||||
${isExpanded ? "" : "hidden"}>
|
||||
<td colspan="4">
|
||||
<div class="song-migration-details-content"
|
||||
data-song-migration-details-content="${escapedPlaylistName}">
|
||||
<div class="loading" style="padding:16px;">
|
||||
<span class="spinner"></span> Loading tracks...
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
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 =
|
||||
'<tr><td colspan="4" style="text-align:center;color:var(--text-secondary);padding:40px;">No playlists configured. Link playlists from the Link Playlists tab.</td></tr>';
|
||||
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 = `
|
||||
<tr>
|
||||
<td colspan="4" style="text-align:center;color:var(--error);padding:40px;">
|
||||
Failed to load playlists: ${escapeHtml(error?.message || "Unknown error")}
|
||||
</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user