feat: add Song Migration tab with CSV export of non-Jellyfin tracks
CI / build-and-test (push) Has been cancelled

This commit is contained in:
joshpatra committed 2026-04-27 23:48:49 -04:00
1 parent cc519c7818
commit 7f54a8aa23
4 files changed
+776 -45

No files matched your search

+57
View File
@@ -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">
+71 -43
View File
@@ -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";
}
+28 -2
View File
@@ -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 });
+620
View File
@@ -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,
};
}