@@ -214,12 +319,16 @@ function Sidebar({
) : (
onMenuSelect(String(pl.id))}
onContextMenu={(e) => {
e.preventDefault();
setPlaylistMenu({ id: pl.id, x: e.clientX, y: e.clientY });
}}
+ onDragOver={handleDragOver}
+ onDragEnter={(e) => handleDragEnter(e, pl.id)}
+ onDragLeave={handleDragLeave}
+ onDrop={(e) => handleDrop(e, pl.id)}
>
{pl.color && (
@@ -238,6 +347,154 @@ function Sidebar({
Importing {importProgress.completed} / {importProgress.total}…
+
+ Analyzing
+
+ {analysisProgress.done} / {analysisProgress.total}
+
+
+
+
0 ? Math.round((analysisProgress.done / analysisProgress.total) * 100) : 0}%`,
+ }}
+ />
+
+
+ )}
+ {normalizeProgress && (
+
+
+ Normalizing
+
+ {normalizeProgress.completed} / {normalizeProgress.total}
+
+
+
+
+ )}
+ {waveformGenProgress && (
+
+
+ Waveforms
+
+ {waveformGenProgress.completed} / {waveformGenProgress.total}
+
+
+
+
0 ? Math.round((waveformGenProgress.completed / waveformGenProgress.total) * 100) : 0}%`,
+ }}
+ />
+
+
+ )}
+ {ytDlpCheckProgress && !ytDlpSidebarProgress && (
+
onMenuSelect('download')}
+ title="Go to YT-DLP"
+ >
+
+ Checking tracks…
+ {ytDlpCheckProgress.total > 0 && (
+
+ {ytDlpCheckProgress.checked} / {ytDlpCheckProgress.total}
+
+ )}
+
+
+
0 ? Math.round((ytDlpCheckProgress.checked / ytDlpCheckProgress.total) * 100) : 0}%`,
+ }}
+ />
+
+
+ )}
+ {ytDlpSidebarProgress && (
+
onMenuSelect('download')}
+ title="Go to YT-DLP"
+ >
+
+ Downloading
+
+ {ytDlpSidebarProgress.current} / {ytDlpSidebarProgress.total}
+
+
+
+ {ytDlpSidebarProgress.msg && (
+
+
+ {ytDlpSidebarProgress.msg}
+
+
+ )}
+
+ )}
+ {tidalSidebarProgress && (
+
onMenuSelect('tidal')}
+ title="Go to TIDAL"
+ >
+
+ TIDAL Downloading
+
+ {tidalSidebarProgress.current} / {tidalSidebarProgress.total}
+
+
+
+ {tidalSidebarProgress.msg && (
+
+
+ {tidalSidebarProgress.msg}
+
+
+ )}
+
+ )}
{exportProgress && (
Exporting {exportProgress.copied} / {exportProgress.total}… ({exportProgress.pct}%)
@@ -310,6 +567,14 @@ function Sidebar({
)}
+
+ {importDialogFiles && (
+ setImportDialogFiles(null)}
+ />
+ )}
);
}
diff --git a/renderer/src/TidalDownloadContext.jsx b/renderer/src/TidalDownloadContext.jsx
new file mode 100644
index 00000000..da320185
--- /dev/null
+++ b/renderer/src/TidalDownloadContext.jsx
@@ -0,0 +1,150 @@
+import { createContext, useContext, useState, useEffect, useCallback } from 'react';
+
+const TidalDownloadContext = createContext(null);
+
+export function TidalDownloadProvider({ children }) {
+ // ── shared ──────────────────────────────────────────────────────────────────
+ const [url, setUrl] = useState('');
+ const [step, setStep] = useState('url'); // 'url' | 'select' | 'download'
+
+ // ── step: url ───────────────────────────────────────────────────────────────
+ const [fetching, setFetching] = useState(false);
+ const [fetchError, setFetchError] = useState(null);
+
+ // ── step: select ─────────────────────────────────────────────────────────────
+ const [playlistInfo, setPlaylistInfo] = useState(null); // { type, title, entries }
+ const [selectedIndices, setSelectedIndices] = useState(new Set());
+ const [linkIndices, setLinkIndices] = useState(new Set()); // in library, not in target playlist
+ const [libraryMap, setLibraryMap] = useState(new Map()); // url → trackId
+ const [playlistMemberUrls, setPlaylistMemberUrls] = useState(new Set()); // urls already in target playlist
+ const [playlists, setPlaylists] = useState([]);
+ const [targetPlaylistId, setTargetPlaylistId] = useState(null);
+ const [targetPlaylistName, setTargetPlaylistName] = useState('');
+
+ // ── step: download ───────────────────────────────────────────────────────────
+ const [loading, setLoading] = useState(false);
+ const [progress, setProgress] = useState(null); // { msg }
+ const [trackStatuses, setTrackStatuses] = useState([]); // [{ index, title, artist, status }]
+ const [result, setResult] = useState(null); // { ok, trackIds, playlistId, error }
+
+ // Subscribe to IPC events — context never unmounts
+ useEffect(() => {
+ const unsubProgress = window.api.onTidalProgress((data) => {
+ if (data === null) {
+ setLoading(false);
+ setProgress(null);
+ } else {
+ setProgress(data);
+ }
+ });
+
+ const unsubTrack = window.api.onTidalTrackUpdate((update) => {
+ if (update.type === 'init') {
+ // Initialize the track status list from the selected entries
+ setTrackStatuses(
+ (update.tracks ?? []).map((e) => ({
+ index: e.index,
+ title: e.title,
+ artist: e.artist,
+ status: 'pending',
+ }))
+ );
+ } else {
+ setTrackStatuses((prev) => {
+ const next = [...prev];
+ const i = update.index;
+ while (next.length <= i) {
+ const n = next.length;
+ next.push({ index: n, title: `Track ${n + 1}`, artist: '', status: 'pending' });
+ }
+ next[i] = { ...next[i], ...update };
+ return next;
+ });
+ }
+ });
+
+ return () => {
+ unsubProgress();
+ unsubTrack();
+ };
+ }, []);
+
+ // ── derived ──────────────────────────────────────────────────────────────────
+ const completedCount = trackStatuses.filter(
+ (s) => s.status === 'done' || s.status === 'failed'
+ ).length;
+ const sbTotal = Math.max(trackStatuses.length, 1);
+ const sidebarProgress = loading
+ ? {
+ current: completedCount,
+ total: sbTotal,
+ pct: sbTotal > 0 ? Math.round((completedCount / sbTotal) * 100) : 0,
+ msg: progress?.msg ?? 'Downloading…',
+ }
+ : null;
+
+ const resetToUrl = useCallback(() => {
+ setStep('url');
+ setPlaylistInfo(null);
+ setSelectedIndices(new Set());
+ setLinkIndices(new Set());
+ setLibraryMap(new Map());
+ setPlaylistMemberUrls(new Set());
+ setTargetPlaylistId(null);
+ setTargetPlaylistName('');
+ setFetchError(null);
+ setResult(null);
+ setTrackStatuses([]);
+ setProgress(null);
+ }, []);
+
+ return (
+
+ {children}
+
+ );
+}
+
+// eslint-disable-next-line react-refresh/only-export-components
+export function useTidalDownload() {
+ const ctx = useContext(TidalDownloadContext);
+ if (!ctx) throw new Error('useTidalDownload must be used inside TidalDownloadProvider');
+ return ctx;
+}
diff --git a/renderer/src/TidalDownloadView.css b/renderer/src/TidalDownloadView.css
new file mode 100644
index 00000000..54dff331
--- /dev/null
+++ b/renderer/src/TidalDownloadView.css
@@ -0,0 +1,402 @@
+/* ── Install prompt ───────────────────────────────────────────────────────── */
+
+.tidal-install-box {
+ display: flex;
+ flex-direction: column;
+ gap: 14px;
+ max-width: 520px;
+ padding: 24px;
+ background: var(--bg-secondary, #1e1e1e);
+ border: 1px solid var(--border, #333);
+ border-radius: 10px;
+}
+
+.tidal-install-title {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--text-primary, #e0e0e0);
+}
+
+.tidal-install-cmd {
+ display: block;
+ padding: 10px 14px;
+ background: #111;
+ border: 1px solid #2a2a2a;
+ border-radius: 6px;
+ font-family: monospace;
+ font-size: 13px;
+ color: #a6e3a1;
+ user-select: all;
+}
+
+.tidal-install-note {
+ font-size: 12px;
+ color: var(--text-secondary, #888);
+ margin: 0;
+ line-height: 1.5;
+}
+
+.tidal-install-log {
+ background: #111;
+ border: 1px solid #2a2a2a;
+ border-radius: 6px;
+ padding: 10px 14px;
+ font-family: monospace;
+ font-size: 12px;
+ color: #a6e3a1;
+ min-height: 60px;
+ max-height: 160px;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 2px;
+}
+
+.tidal-install-log-line {
+ white-space: pre-wrap;
+ word-break: break-all;
+}
+
+/* ── Login box ────────────────────────────────────────────────────────────── */
+
+.tidal-login-box {
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ max-width: 520px;
+ padding: 24px;
+ background: var(--bg-secondary, #1e1e1e);
+ border: 1px solid var(--border, #333);
+ border-radius: 10px;
+}
+
+.tidal-login-desc {
+ font-size: 13px;
+ color: var(--text-secondary, #aaa);
+ margin: 0;
+ line-height: 1.6;
+}
+
+.tidal-login-btn {
+ align-self: flex-start;
+}
+
+.tidal-login-waiting {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ font-size: 14px;
+ color: var(--text-primary, #e0e0e0);
+}
+
+.tidal-spinner {
+ font-size: 20px;
+ animation: tidal-blink 1s steps(1) infinite;
+}
+
+@keyframes tidal-blink {
+ 0%,
+ 100% {
+ opacity: 1;
+ }
+ 50% {
+ opacity: 0.2;
+ }
+}
+
+.tidal-login-url-box {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 12px;
+ background: rgba(124, 92, 252, 0.08);
+ border: 1px solid rgba(124, 92, 252, 0.2);
+ border-radius: 6px;
+}
+
+.tidal-login-url-label {
+ font-size: 12px;
+ color: var(--text-secondary, #888);
+ margin: 0;
+}
+
+.tidal-login-url-link {
+ font-size: 12px;
+ color: var(--accent, #7c5cfc);
+ word-break: break-all;
+ text-decoration: underline;
+ cursor: pointer;
+}
+
+.tidal-login-url-link:hover {
+ opacity: 0.8;
+}
+
+/* ── Indeterminate progress ──────────────────────────────────────────────── */
+
+.tidal-progress-indeterminate {
+ height: 100%;
+ width: 40%;
+ background: var(--accent, #7c5cfc);
+ border-radius: 2px;
+ animation: tidal-slide 1.4s ease-in-out infinite;
+}
+
+@keyframes tidal-slide {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(300%);
+ }
+}
+
+/* ── Re-auth footer ──────────────────────────────────────────────────────── */
+
+.tidal-reauth {
+ margin-top: auto;
+ padding-top: 32px;
+}
+
+.tidal-reauth-btn {
+ background: none;
+ border: none;
+ color: var(--text-secondary, #555);
+ cursor: pointer;
+ font-size: 12px;
+ padding: 0;
+ text-decoration: underline;
+ text-underline-offset: 2px;
+}
+
+.tidal-reauth-btn:hover {
+ color: var(--text-secondary, #888);
+}
+
+.tidal-checking {
+ animation: tidal-blink 1.2s ease-in-out infinite;
+}
+
+/* ── Fetch error ─────────────────────────────────────────────────────────── */
+
+.dl-fetch-error {
+ font-size: 12px;
+ color: #fc8181;
+}
+
+/* ── Select step ─────────────────────────────────────────────────────────── */
+
+.dl-select-list {
+ flex: 1;
+ overflow-y: auto;
+ min-height: 0;
+ max-height: min(420px, 50vh);
+ border: 1px solid var(--border, #2a2a2a);
+ border-radius: 6px;
+ margin-bottom: 12px;
+}
+
+.dl-select-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 8px 12px;
+ border-bottom: 1px solid var(--border, #2a2a2a);
+ background: var(--bg-secondary, #1a1a1a);
+ border-radius: 6px 6px 0 0;
+}
+
+.dl-select-all {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 12px;
+ color: var(--text-secondary, #aaa);
+ cursor: pointer;
+ user-select: none;
+}
+
+.dl-select-all input[type='checkbox'] {
+ accent-color: var(--accent, #7c5cfc);
+}
+
+.dl-select-count {
+ font-size: 12px;
+ color: var(--text-secondary, #666);
+}
+
+.dl-entries {
+ overflow-y: auto;
+ max-height: calc(min(420px, 50vh) - 38px);
+}
+
+.dl-entry {
+ display: grid;
+ grid-template-columns: auto 28px 1fr auto;
+ align-items: center;
+ gap: 8px;
+ padding: 6px 12px;
+ border-bottom: 1px solid var(--border, #1e1e1e);
+ cursor: pointer;
+ transition: background 0.1s;
+ user-select: none;
+}
+
+.dl-entry:last-child {
+ border-bottom: none;
+}
+
+.dl-entry:hover {
+ background: rgba(255, 255, 255, 0.04);
+}
+
+.dl-entry input[type='checkbox'] {
+ accent-color: var(--accent, #7c5cfc);
+ width: 14px;
+ height: 14px;
+ cursor: pointer;
+ flex-shrink: 0;
+}
+
+.dl-entry-num {
+ font-size: 11px;
+ color: var(--text-secondary, #555);
+ text-align: right;
+}
+
+.dl-entry-info {
+ display: flex;
+ flex-direction: column;
+ gap: 1px;
+ overflow: hidden;
+}
+
+.dl-entry-title {
+ font-size: 13px;
+ color: var(--text-primary, #ddd);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dl-entry-artist {
+ font-size: 11px;
+ color: var(--text-secondary, #888);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.dl-entry-dur {
+ font-size: 11px;
+ color: var(--text-secondary, #666);
+ white-space: nowrap;
+}
+
+.dl-entry--library {
+ opacity: 0.75;
+}
+
+.dl-entry-library-badge {
+ font-size: 11px;
+ color: #4caf50;
+ white-space: nowrap;
+ flex-shrink: 0;
+ margin-left: auto;
+}
+
+.dl-entry-playlist-badge {
+ color: #2196f3;
+}
+
+.dl-select-actions {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+ margin-top: 4px;
+}
+
+/* ── Download step track list ────────────────────────────────────────────── */
+
+.dl-track-list {
+ border: 1px solid var(--border, #2a2a2a);
+ border-radius: 6px;
+ overflow-y: auto;
+ max-height: min(480px, 55vh);
+ margin-bottom: 12px;
+}
+
+.dl-track-row {
+ display: grid;
+ grid-template-columns: 20px 1fr auto;
+ align-items: center;
+ gap: 10px;
+ padding: 7px 12px;
+ border-bottom: 1px solid var(--border, #1e1e1e);
+}
+
+.dl-track-row:last-child {
+ border-bottom: none;
+}
+
+.dl-track-icon {
+ font-size: 14px;
+ font-weight: 600;
+ text-align: center;
+}
+
+.dl-track-icon--pending {
+ color: var(--text-secondary, #555);
+}
+.dl-track-icon--downloading {
+ color: #f6ad55;
+}
+.dl-track-icon--importing {
+ color: #63b3ed;
+}
+.dl-track-icon--done {
+ color: #68d391;
+}
+.dl-track-icon--failed {
+ color: #fc8181;
+}
+
+.dl-track-row--done {
+ background: rgba(104, 211, 145, 0.04);
+}
+.dl-track-row--failed {
+ background: rgba(252, 129, 129, 0.04);
+}
+
+.dl-track-info {
+ overflow: hidden;
+ white-space: nowrap;
+ text-overflow: ellipsis;
+}
+
+.dl-track-title {
+ font-size: 13px;
+ color: var(--text-primary, #ddd);
+}
+
+.dl-track-artist {
+ font-size: 12px;
+ color: var(--text-secondary, #888);
+}
+
+.dl-track-error {
+ font-size: 11px;
+ color: #fc8181;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* ── Progress bar fill (deterministic) ──────────────────────────────────── */
+
+.dl-progress-fill {
+ height: 100%;
+ background: var(--accent, #7c5cfc);
+ border-radius: 2px;
+ transition: width 0.3s ease;
+}
diff --git a/renderer/src/TidalDownloadView.jsx b/renderer/src/TidalDownloadView.jsx
new file mode 100644
index 00000000..257b92fa
--- /dev/null
+++ b/renderer/src/TidalDownloadView.jsx
@@ -0,0 +1,857 @@
+import { useState, useEffect, useRef, useCallback } from 'react';
+import { useTidalDownload } from './TidalDownloadContext.jsx';
+import './DownloadView.css';
+import './TidalDownloadView.css';
+
+// Supported TIDAL URL types for the helper footer
+const TIDAL_URL_TYPES = [
+ { label: 'Track', example: 'tidal.com/browse/track/…' },
+ { label: 'Album', example: 'tidal.com/browse/album/…' },
+ { label: 'Playlist', example: 'tidal.com/browse/playlist/…' },
+ { label: 'Mix', example: 'tidal.com/browse/mix/…' },
+];
+
+const STATUS_ICON = {
+ pending: { icon: '□', label: 'Pending' },
+ downloading: { icon: '⋯', label: 'Downloading' },
+ importing: { icon: '↓', label: 'Importing' },
+ done: { icon: '✓', label: 'Done' },
+ failed: { icon: '✗', label: 'Failed' },
+};
+
+function fmtDuration(secs) {
+ if (!secs) return '';
+ const m = Math.floor(secs / 60);
+ const s = Math.floor(secs % 60);
+ return `${m}:${String(s).padStart(2, '0')}`;
+}
+
+export default function TidalDownloadView({ onGoToLibrary, onGoToPlaylist, style }) {
+ // ── context state (persists across tab switches) ──────────────────────────
+ const {
+ url,
+ setUrl,
+ step,
+ setStep,
+ fetching,
+ setFetching,
+ fetchError,
+ setFetchError,
+ playlistInfo,
+ setPlaylistInfo,
+ selectedIndices,
+ setSelectedIndices,
+ linkIndices,
+ setLinkIndices,
+ libraryMap,
+ setLibraryMap,
+ playlistMemberUrls,
+ setPlaylistMemberUrls,
+ playlists,
+ setPlaylists,
+ targetPlaylistId,
+ setTargetPlaylistId,
+ targetPlaylistName,
+ setTargetPlaylistName,
+ loading,
+ setLoading,
+ trackStatuses,
+ result,
+ setResult,
+ resetToUrl,
+ } = useTidalDownload();
+
+ // ── local state (UI gates — do not need to persist) ───────────────────────
+ const [setup, setSetup] = useState(null); // null = checking | { installed, loggedIn }
+ const [loginState, setLoginState] = useState('idle'); // 'idle'|'waiting'|'done'|'error'
+ const [loginUrl, setLoginUrl] = useState(null);
+ const [loginError, setLoginError] = useState(null);
+ const [installing, setInstalling] = useState(false);
+ const [installLog, setInstallLog] = useState([]);
+ const [installError, setInstallError] = useState(null);
+
+ const inputRef = useRef(null);
+
+ const checkSetup = useCallback(() => {
+ setSetup(null);
+ window.api.tidalCheck().then(setSetup);
+ }, []);
+
+ // Subscribe to login/install IPC events on mount
+ useEffect(() => {
+ checkSetup();
+ const unsubLoginUrl = window.api.onTidalLoginUrl((u) => {
+ setLoginUrl(u);
+ window.api.openExternal(u);
+ });
+ const unsubInstall = window.api.onTidalInstallProgress((data) => {
+ setInstallLog((prev) => [...prev.slice(-199), data.msg]);
+ });
+ return () => {
+ unsubLoginUrl();
+ unsubInstall();
+ };
+ }, [checkSetup]);
+
+ // Load playlists once logged in
+ useEffect(() => {
+ if (setup?.loggedIn) {
+ window.api
+ .getPlaylists()
+ .then(setPlaylists)
+ .catch(() => setPlaylists([]));
+ setTimeout(() => inputRef.current?.focus(), 50);
+ }
+ }, [setup?.loggedIn, setPlaylists]);
+
+ // ── login flow ─────────────────────────────────────────────────────────────
+ const handleLogin = async () => {
+ setLoginState('waiting');
+ setLoginUrl(null);
+ setLoginError(null);
+ const res = await window.api.tidalLogin();
+ if (res.ok) {
+ setLoginState('done');
+ checkSetup();
+ } else {
+ setLoginState('error');
+ setLoginError(res.error);
+ }
+ };
+
+ // ── step url → select: fetch track info ───────────────────────────────────
+ const handleLoad = async (e) => {
+ e.preventDefault();
+ const trimmed = url.trim();
+ if (!trimmed || fetching) return;
+
+ setFetching(true);
+ setFetchError(null);
+
+ try {
+ const res = await window.api.tidalFetchInfo(trimmed);
+ if (!res.ok) {
+ setFetchError(res.error);
+ return;
+ }
+
+ setPlaylistInfo(res);
+
+ // Check which entries are already in the library
+ const newLibraryMap = new Map();
+ try {
+ const entryChecks = (res.entries ?? [])
+ .filter((e) => e.url || e.id)
+ .map((e) => ({ url: e.url, id: String(e.id) }));
+ if (entryChecks.length > 0) {
+ const found = await window.api.checkDuplicateUrls(entryChecks);
+ for (const { url: u, trackId } of found) {
+ if (u) newLibraryMap.set(u, trackId);
+ }
+ }
+ } catch {
+ // non-fatal
+ }
+
+ const pls = await window.api.getPlaylists().catch(() => []);
+ setPlaylists(pls);
+ const match = pls.find((p) => p.name.toLowerCase() === (res.title ?? '').toLowerCase());
+ if (match) {
+ setTargetPlaylistId(match.id);
+ setTargetPlaylistName('');
+ } else {
+ setTargetPlaylistId(null);
+ setTargetPlaylistName(res.title ?? '');
+ }
+
+ // Single tracks and mixes skip the select step — go straight to download
+ if (res.type === 'track' || res.type === 'mix' || (res.entries?.length ?? 0) === 0) {
+ const allIndices = new Set(
+ (res.entries ?? []).filter((e) => !newLibraryMap.has(e.url)).map((e) => e.index)
+ );
+ const linkIdx = new Set(
+ (res.entries ?? []).filter((e) => newLibraryMap.has(e.url)).map((e) => e.index)
+ );
+ setLibraryMap(newLibraryMap);
+ setSelectedIndices(allIndices);
+ setLinkIndices(linkIdx);
+ setStep('download');
+ await runDownload(
+ res,
+ allIndices,
+ linkIdx,
+ newLibraryMap,
+ match?.id ?? null,
+ res.title ?? ''
+ );
+ return;
+ }
+
+ // Pre-select non-library entries; pre-link library entries not already in matched playlist
+ let newMemberUrls = new Set();
+ if (match) {
+ try {
+ const memberRows = await window.api.getPlaylistSourceUrls(match.id);
+ const memberTrackIds = new Set(memberRows.map((r) => r.trackId));
+ for (const entry of res.entries ?? []) {
+ const entryId = String(entry.id ?? '');
+ // Primary: trackId via libraryMap
+ const libTid = newLibraryMap.get(entry.url);
+ if (libTid && memberTrackIds.has(libTid)) {
+ newMemberUrls.add(entry.url);
+ continue;
+ }
+ // Fallback: direct pattern match (handles old source_url format)
+ if (entryId) {
+ const hit = memberRows.find(
+ (r) =>
+ (r.source_url && r.source_url.includes(entryId)) ||
+ (r.source_link && r.source_link.includes(entryId))
+ );
+ if (hit) {
+ newMemberUrls.add(entry.url);
+ if (!newLibraryMap.has(entry.url)) newLibraryMap.set(entry.url, hit.trackId);
+ }
+ }
+ }
+ } catch {
+ // non-fatal
+ }
+ }
+ setPlaylistMemberUrls(newMemberUrls);
+ setLibraryMap(newLibraryMap);
+
+ setSelectedIndices(
+ new Set(res.entries.filter((e) => !newLibraryMap.has(e.url)).map((e) => e.index))
+ );
+ // linkIndices = in library but NOT already in the matched playlist
+ setLinkIndices(
+ new Set(
+ res.entries
+ .filter((e) => newLibraryMap.has(e.url) && !newMemberUrls.has(e.url))
+ .map((e) => e.index)
+ )
+ );
+ setStep('select');
+ } catch (err) {
+ setFetchError(err.message);
+ } finally {
+ setFetching(false);
+ }
+ };
+
+ // ── target playlist change: re-check membership ────────────────────────────
+ const handleTargetPlaylistChange = useCallback(
+ async (newPlaylistId) => {
+ setTargetPlaylistId(newPlaylistId);
+ if (!newPlaylistId || !playlistInfo) {
+ setPlaylistMemberUrls(new Set());
+ if (playlistInfo) {
+ setLinkIndices(
+ new Set(playlistInfo.entries.filter((e) => libraryMap.has(e.url)).map((e) => e.index))
+ );
+ }
+ return;
+ }
+ try {
+ const memberRows = await window.api.getPlaylistSourceUrls(newPlaylistId);
+ const memberTrackIds = new Set(memberRows.map((r) => r.trackId));
+
+ const inPlaylist = new Set();
+ const updatedLibraryMap = new Map(libraryMap);
+
+ for (const entry of playlistInfo.entries) {
+ const entryId = String(entry.id ?? '');
+ // Primary: trackId match via libraryMap
+ const libTid = libraryMap.get(entry.url);
+ if (libTid && memberTrackIds.has(libTid)) {
+ inPlaylist.add(entry.url);
+ continue;
+ }
+ // Fallback: direct pattern match for tracks imported with old source_url format
+ if (entryId) {
+ const hit = memberRows.find(
+ (r) =>
+ (r.source_url && r.source_url.includes(entryId)) ||
+ (r.source_link && r.source_link.includes(entryId))
+ );
+ if (hit) {
+ inPlaylist.add(entry.url);
+ if (!updatedLibraryMap.has(entry.url)) updatedLibraryMap.set(entry.url, hit.trackId);
+ }
+ }
+ }
+
+ if (updatedLibraryMap.size !== libraryMap.size) setLibraryMap(updatedLibraryMap);
+ setPlaylistMemberUrls(inPlaylist);
+ setLinkIndices(
+ new Set(
+ playlistInfo.entries
+ .filter((e) => updatedLibraryMap.has(e.url) && !inPlaylist.has(e.url))
+ .map((e) => e.index)
+ )
+ );
+ } catch {
+ setPlaylistMemberUrls(new Set());
+ }
+ },
+ [
+ libraryMap,
+ playlistInfo,
+ setTargetPlaylistId,
+ setPlaylistMemberUrls,
+ setLinkIndices,
+ setLibraryMap,
+ ]
+ );
+
+ // ── step select → download ─────────────────────────────────────────────────
+ const handleDownload = async () => {
+ if (selectedIndices.size === 0 && linkIndices.size === 0) return;
+ if (!playlistInfo) return;
+ setStep('download');
+ await runDownload(
+ playlistInfo,
+ selectedIndices,
+ linkIndices,
+ libraryMap,
+ targetPlaylistId,
+ targetPlaylistName
+ );
+ };
+
+ async function runDownload(info, indices, links, libMap, playlistId, playlistName) {
+ const selectedEntries = (info.entries ?? [])
+ .filter((e) => indices.has(e.index))
+ .sort((a, b) => a.index - b.index);
+
+ const linkEntries = (info.entries ?? [])
+ .filter((e) => links.has(e.index))
+ .sort((a, b) => a.index - b.index);
+
+ const linkTrackIds = linkEntries.map((e) => libMap.get(e.url)).filter(Boolean);
+
+ setLoading(true);
+ setResult(null);
+
+ const res = await window.api.tidalDownloadUrl({
+ url,
+ selectedEntries,
+ linkTrackIds,
+ existingPlaylistId: playlistId || null,
+ newPlaylistName: !playlistId && playlistName?.trim() ? playlistName.trim() : null,
+ });
+
+ setLoading(false);
+ setResult(res);
+
+ if (res.ok) {
+ await window.api
+ .getPlaylists()
+ .then(setPlaylists)
+ .catch(() => {});
+ }
+ }
+
+ // ── toggle selection ────────────────────────────────────────────────────────
+ const handleToggleEntry = useCallback(
+ (index, entry) => {
+ const isInLibrary = entry && libraryMap.has(entry.url);
+ if (isInLibrary) {
+ // library entries toggle in linkIndices
+ setLinkIndices((prev) => {
+ const next = new Set(prev);
+ if (next.has(index)) next.delete(index);
+ else next.add(index);
+ return next;
+ });
+ } else {
+ setSelectedIndices((prev) => {
+ const next = new Set(prev);
+ if (next.has(index)) next.delete(index);
+ else next.add(index);
+ return next;
+ });
+ }
+ },
+ [libraryMap, setSelectedIndices, setLinkIndices]
+ );
+
+ const handleToggleAll = useCallback(() => {
+ if (!playlistInfo) return;
+ const downloadable = playlistInfo.entries.filter((e) => !libraryMap.has(e.url));
+ const linkable = playlistInfo.entries.filter(
+ (e) => libraryMap.has(e.url) && !playlistMemberUrls.has(e.url)
+ );
+ const allDownSelected = downloadable.every((e) => selectedIndices.has(e.index));
+ const allLinkSelected = linkable.every((e) => linkIndices.has(e.index));
+ if (allDownSelected && allLinkSelected) {
+ setSelectedIndices(new Set());
+ setLinkIndices(new Set());
+ } else {
+ setSelectedIndices(new Set(downloadable.map((e) => e.index)));
+ setLinkIndices(new Set(linkable.map((e) => e.index)));
+ }
+ }, [
+ playlistInfo,
+ libraryMap,
+ playlistMemberUrls,
+ selectedIndices,
+ linkIndices,
+ setSelectedIndices,
+ setLinkIndices,
+ ]);
+
+ // ── render: checking setup ────────────────────────────────────────────────
+ if (setup === null) {
+ return (
+
+
+
TIDAL Download
+
Checking setup…
+
+
+ );
+ }
+
+ // ── render: not installed ─────────────────────────────────────────────────
+ if (!setup.installed) {
+ const handleRetry = async () => {
+ setInstalling(true);
+ setInstallLog([]);
+ setInstallError(null);
+ const res = await window.api.tidalInstall();
+ setInstalling(false);
+ if (res.ok) checkSetup();
+ else setInstallError(res.error);
+ };
+ return (
+
+
+
TIDAL Download
+
tidal-dl-ng could not be installed during startup.
+
+
+ {!installing && !installError && (
+ <>
+
Installation failed
+
+ tidal-dl-ng could not be installed automatically. Click Retry to try again, or check
+ Settings → Dependencies.
+
+
+ Retry
+
+ >
+ )}
+ {installing && (
+ <>
+
Installing…
+
+ {installLog.slice(-8).map((line, i) => (
+
+ {line}
+
+ ))}
+ {installLog.length === 0 && (
+
Starting installer…
+ )}
+
+ >
+ )}
+ {installError && (
+ <>
+
+ Installation failed
+
+
{installError}
+
+ Retry
+
+ >
+ )}
+
+
+ );
+ }
+
+ // ── render: login required ────────────────────────────────────────────────
+ if (!setup.loggedIn) {
+ return (
+
+
+
TIDAL Download
+
Connect your TIDAL account to start downloading.
+
+
+ {loginState === 'idle' && (
+ <>
+
+ Click below to start the TIDAL login flow. A browser page will open — approve the
+ request there, then return here.
+
+
+ Connect TIDAL account
+
+ >
+ )}
+ {loginState === 'waiting' && (
+ <>
+
+ ⋯
+ Waiting for TIDAL authentication…
+
+ {loginUrl ? (
+
+ ) : (
+
Opening browser…
+ )}
+ >
+ )}
+ {loginState === 'done' && (
+
✓ Logged in successfully
+ )}
+ {loginState === 'error' && (
+
+ ✗ Login failed: {loginError}
+ setLoginState('idle')}
+ style={{ marginTop: 8, alignSelf: 'flex-start' }}
+ >
+ Try again
+
+
+ )}
+
+
+ );
+ }
+
+ // ── render: step — url ────────────────────────────────────────────────────
+ if (step === 'url') {
+ return (
+
+
+
TIDAL Download
+
Paste a TIDAL URL to import tracks into your library.
+
+
+
+
+
+
Supported URL types
+
+ {TIDAL_URL_TYPES.map((t) => (
+
+ ♫
+ {t.label}
+
+ ))}
+
+
+
+
+ {
+ setSetup({ ...setup, loggedIn: false });
+ setLoginState('idle');
+ }}
+ >
+ Switch TIDAL account
+
+
+
+ );
+ }
+
+ // ── render: step — select ─────────────────────────────────────────────────
+ if (step === 'select') {
+ const entries = playlistInfo?.entries ?? [];
+ const downloadable = entries.filter((e) => !libraryMap.has(e.url));
+ const linkable = entries.filter((e) => libraryMap.has(e.url) && !playlistMemberUrls.has(e.url));
+ const allDownSelected = downloadable.every((e) => selectedIndices.has(e.index));
+ const allLinkSelected = linkable.every((e) => linkIndices.has(e.index));
+ const allSelected =
+ entries.length > 0 &&
+ allDownSelected &&
+ allLinkSelected &&
+ downloadable.length + linkable.length > 0;
+ const totalActive = selectedIndices.size + linkIndices.size;
+
+ return (
+
+
+
{playlistInfo?.title ?? 'Select tracks'}
+
+ {entries.length} track{entries.length !== 1 ? 's' : ''}
+ {libraryMap.size > 0 ? ` · ${libraryMap.size} in library` : ''}
+ {playlistMemberUrls.size > 0 ? ` · ${playlistMemberUrls.size} in playlist` : ''}
+ {' · '}select which to download
+
+
+
+
+ 1. Save to playlist
+ handleTargetPlaylistChange(e.target.value || null)}
+ >
+ None / new playlist
+ {playlists.map((pl) => (
+
+ {pl.name}
+
+ ))}
+
+ {!targetPlaylistId && (
+ setTargetPlaylistName(e.target.value)}
+ />
+ )}
+
+
+
+
+
+
+ ← Back
+
+
+ {selectedIndices.size > 0 && linkIndices.size > 0
+ ? `Download ${selectedIndices.size} + link ${linkIndices.size} →`
+ : selectedIndices.size > 0
+ ? `Download ${selectedIndices.size} track${selectedIndices.size !== 1 ? 's' : ''} →`
+ : `Link ${linkIndices.size} track${linkIndices.size !== 1 ? 's' : ''} to playlist →`}
+
+
+
+ );
+ }
+
+ // ── render: step — download ───────────────────────────────────────────────
+ const doneCount = trackStatuses.filter((s) => s.status === 'done').length;
+ const failCount = trackStatuses.filter((s) => s.status === 'failed').length;
+ const totalCount = trackStatuses.length;
+ const progressPct = totalCount > 0 ? Math.round(((doneCount + failCount) / totalCount) * 100) : 0;
+
+ return (
+
+
+
{playlistInfo?.title ?? 'Downloading…'}
+
+ {loading
+ ? `${doneCount} / ${totalCount} tracks added`
+ : result?.ok
+ ? `✓ Done — ${doneCount} track${doneCount !== 1 ? 's' : ''} added`
+ : result?.error
+ ? '✗ Download failed'
+ : 'Starting…'}
+
+
+
+ {/* Progress bar */}
+ {totalCount > 0 && (
+
+
+
+ {doneCount + failCount} / {totalCount}
+
+
+ )}
+
+ {/* Indeterminate progress when track list is unknown (mix / raw URL) */}
+ {loading && totalCount === 0 && (
+
+ )}
+
+ {/* Per-track status list */}
+ {trackStatuses.length > 0 && (
+
+ {trackStatuses.map((s) => {
+ const si = STATUS_ICON[s.status] ?? STATUS_ICON.pending;
+ return (
+
+
+ {si.icon}
+
+
+ {s.title}
+ {s.artist && — {s.artist} }
+
+ {s.error && {s.error} }
+
+ );
+ })}
+
+ )}
+
+ {/* Result actions */}
+ {!loading && result?.ok && (
+
+
+ {result.playlistId ? (
+ onGoToPlaylist(result.playlistId)}
+ >
+ Go to Playlist →
+
+ ) : (
+
+ View in Music →
+
+ )}
+
+ ← New download
+
+
+
+ )}
+ {!loading && result?.error && (
+
+ ✗ {result.error}
+
+ ← Try again
+
+
+ )}
+
+ );
+}
diff --git a/renderer/src/__tests__/DownloadView.test.jsx b/renderer/src/__tests__/DownloadView.test.jsx
index 51ae56b7..158f26ce 100644
--- a/renderer/src/__tests__/DownloadView.test.jsx
+++ b/renderer/src/__tests__/DownloadView.test.jsx
@@ -1,6 +1,11 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
import DownloadView from '../DownloadView.jsx';
+import { DownloadProvider } from '../DownloadContext.jsx';
+
+function renderWithProvider(ui) {
+ return render(
{ui} );
+}
const PLAYLIST_INFO = {
ok: true,
@@ -23,7 +28,7 @@ beforeEach(() => {
describe('DownloadView', () => {
it('step 1 renders URL input and Load button; does not show selection or progress view', () => {
- render(
);
+ renderWithProvider(
);
expect(screen.getByRole('textbox')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /Load/i })).toBeInTheDocument();
expect(screen.queryByText(/Acid House/)).not.toBeInTheDocument();
@@ -32,13 +37,13 @@ describe('DownloadView', () => {
});
it('Load button is disabled when input is empty', () => {
- render(
);
+ renderWithProvider(
);
expect(screen.getByRole('button', { name: /Load/i })).toBeDisabled();
});
it('shows error when ytDlpFetchInfo returns ok:false', async () => {
window.api.ytDlpFetchInfo.mockResolvedValue({ ok: false, error: 'Network error' });
- render(
);
+ renderWithProvider(
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://www.youtube.com/watch?v=abc' },
});
@@ -48,7 +53,7 @@ describe('DownloadView', () => {
it('shows restart error when ytDlpFetchInfo is not a function', async () => {
window.api.ytDlpFetchInfo = undefined;
- render(
);
+ renderWithProvider(
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://www.youtube.com/watch?v=abc' },
});
@@ -58,7 +63,7 @@ describe('DownloadView', () => {
it('transitions to selection view on success (playlist)', async () => {
window.api.ytDlpFetchInfo.mockResolvedValue(PLAYLIST_INFO);
- render(
);
+ renderWithProvider(
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://www.youtube.com/playlist?list=xyz' },
});
@@ -71,7 +76,7 @@ describe('DownloadView', () => {
it('select/deselect single track updates Download button count', async () => {
window.api.ytDlpFetchInfo.mockResolvedValue(PLAYLIST_INFO);
- render(
);
+ renderWithProvider(
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://www.youtube.com/playlist?list=xyz' },
});
@@ -98,7 +103,7 @@ describe('DownloadView', () => {
{ index: 0, id: 'x', title: 'Short Track', url: 'https://yt.com/x', duration: 125 },
],
});
- render(
);
+ renderWithProvider(
);
fireEvent.change(screen.getByRole('textbox'), {
target: { value: 'https://www.youtube.com/playlist?list=dur' },
});
diff --git a/renderer/src/__tests__/ExportModal.test.jsx b/renderer/src/__tests__/ExportModal.test.jsx
index aabe36e4..5d946cba 100644
--- a/renderer/src/__tests__/ExportModal.test.jsx
+++ b/renderer/src/__tests__/ExportModal.test.jsx
@@ -215,25 +215,26 @@ describe('ExportModal', () => {
});
});
- // ── initialMode (skip idle) ───────────────────────────────────────────────────
-
- it('calls openDirDialog immediately when initialMode is provided', async () => {
- window.api.openDirDialog.mockResolvedValueOnce(null);
+ // ── initialMode (shows confirm step first) ───────────────────────────────────
+ it('shows confirm step (not folder dialog) when initialMode is provided', async () => {
render(
);
await waitFor(() => {
- expect(window.api.openDirDialog).toHaveBeenCalledOnce();
+ expect(screen.getByText('Choose folder & Export')).toBeInTheDocument();
});
+ expect(window.api.openDirDialog).not.toHaveBeenCalled();
});
- it('does not call openDirDialog more than once in StrictMode (ref guard)', async () => {
- window.api.openDirDialog.mockResolvedValue(null);
+ it('calls openDirDialog after clicking proceed in confirm step', async () => {
+ window.api.openDirDialog.mockResolvedValueOnce(null);
render(
);
+ await screen.findByText('Choose folder & Export');
+ fireEvent.click(screen.getByText('Choose folder & Export'));
await waitFor(() => {
- expect(window.api.openDirDialog).toHaveBeenCalledTimes(1);
+ expect(window.api.openDirDialog).toHaveBeenCalledOnce();
});
});
diff --git a/renderer/src/__tests__/MusicLibrary.contextmenu.test.jsx b/renderer/src/__tests__/MusicLibrary.contextmenu.test.jsx
index 7da56eac..d2a22986 100644
--- a/renderer/src/__tests__/MusicLibrary.contextmenu.test.jsx
+++ b/renderer/src/__tests__/MusicLibrary.contextmenu.test.jsx
@@ -19,7 +19,12 @@ vi.mock('react-window', () => ({
}));
vi.mock('../PlayerContext.jsx', () => ({
- usePlayer: () => ({ play: vi.fn(), currentTrack: null, currentPlaylistId: null }),
+ usePlayer: () => ({
+ play: vi.fn(),
+ currentTrack: null,
+ currentPlaylistId: null,
+ updateQueue: vi.fn(),
+ }),
}));
vi.mock('@dnd-kit/core', () => ({
@@ -168,7 +173,7 @@ describe('context menu — submenu CSS classes', () => {
renderLibrary();
await openContextMenu('Track One');
- const bpmParent = getSubmenuParent('🎵 BPM');
+ const bpmParent = getSubmenuParent('🥁 Beat Grid');
expect(bpmParent).toBeTruthy();
const submenu = bpmParent.querySelector(':scope > .context-submenu');
@@ -182,7 +187,7 @@ describe('context menu — submenu CSS classes', () => {
const analysisParent = getSubmenuParent('🔬 Analysis');
const analysisSubmenu = analysisParent.querySelector(':scope > .context-submenu');
- const bpmParent = getSubmenuParent('🎵 BPM');
+ const bpmParent = getSubmenuParent('🥁 Beat Grid');
// BPM item must be a descendant of the Analysis submenu
expect(analysisSubmenu.contains(bpmParent)).toBe(true);
@@ -192,7 +197,7 @@ describe('context menu — submenu CSS classes', () => {
renderLibrary();
await openContextMenu('Track One');
- const bpmParent = getSubmenuParent('🎵 BPM');
+ const bpmParent = getSubmenuParent('🥁 Beat Grid');
const directSubmenus = [...bpmParent.children].filter((el) =>
el.classList.contains('context-submenu')
);
@@ -215,13 +220,14 @@ describe('context menu — submenu CSS classes', () => {
expect(submenu.classList.contains('context-submenu--scrollable')).toBe(true);
});
- it('"Add to playlist" is NOT shown when there are no playlists', async () => {
+ it('shows "Add to new playlist" option directly when there are no playlists', async () => {
window.api.getPlaylistsForTrack.mockResolvedValue([]);
renderLibrary();
await openContextMenu('Track One');
- // Should show a disabled "No playlists" item, not the submenu
- await waitFor(() => expect(screen.getByText(/➕ No playlists/)).toBeInTheDocument());
+ // When no playlists exist, a direct "Add to new playlist…" item is shown
+ await waitFor(() => expect(screen.getByText(/➕ Add to new playlist…/)).toBeInTheDocument());
+ // No submenu parent for "Add to playlist"
expect(getSubmenuParent('➕ Add to playlist')).toBeNull();
});
});
diff --git a/renderer/src/__tests__/PlayerContext.test.jsx b/renderer/src/__tests__/PlayerContext.test.jsx
index 52729fa0..454e6d49 100644
--- a/renderer/src/__tests__/PlayerContext.test.jsx
+++ b/renderer/src/__tests__/PlayerContext.test.jsx
@@ -1,4 +1,4 @@
-import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { renderHook, waitFor } from '@testing-library/react';
import { PlayerProvider, usePlayer } from '../PlayerContext.jsx';
@@ -38,8 +38,117 @@ describe('PlayerProvider — context API', () => {
expect(typeof ctx.stop).toBe('function');
expect(typeof ctx.toggleShuffle).toBe('function');
expect(typeof ctx.cycleRepeat).toBe('function');
+ expect(typeof ctx.reloadCurrentTrack).toBe('function');
expect(ctx.isPlaying).toBe(false);
expect(ctx.currentTime).toBe(0);
expect(ctx.duration).toBe(0);
});
});
+
+// ── Black screen regression (AudioContext crash guard) ────────────────────────
+// If the Web Audio graph setup throws (e.g. NotSupportedError in some GPU/Electron
+// configurations), the PlayerProvider must still mount and expose its full API.
+// A missing try-catch here caused a renderer crash → black screen on launch.
+
+describe('PlayerProvider — AudioContext crash guard', () => {
+ let originalAudioContext;
+
+ beforeEach(() => {
+ originalAudioContext = window.AudioContext;
+ });
+
+ afterEach(() => {
+ window.AudioContext = originalAudioContext;
+ });
+
+ it('renders without crashing when AudioContext constructor throws', () => {
+ window.AudioContext = class {
+ constructor() {
+ throw new Error('NotSupportedError: AudioContext is not supported');
+ }
+ };
+
+ expect(() => renderProvider()).not.toThrow();
+ });
+
+ it('still exposes full API surface when AudioContext constructor throws', () => {
+ window.AudioContext = class {
+ constructor() {
+ throw new Error('NotSupportedError: AudioContext is not supported');
+ }
+ };
+
+ const { result } = renderProvider();
+ const ctx = result.current;
+ expect(typeof ctx.play).toBe('function');
+ expect(typeof ctx.stop).toBe('function');
+ expect(typeof ctx.seek).toBe('function');
+ expect(typeof ctx.toggleShuffle).toBe('function');
+ expect(typeof ctx.cycleRepeat).toBe('function');
+ expect(ctx.isPlaying).toBe(false);
+ });
+
+ it('renders without crashing when createMediaElementSource throws', () => {
+ window.AudioContext = class {
+ constructor() {
+ this.destination = {};
+ this.resume = vi.fn().mockResolvedValue(undefined);
+ this.close = vi.fn().mockResolvedValue(undefined);
+ this.createMediaElementSource = vi.fn(() => {
+ throw new Error('InvalidStateError: media element already connected');
+ });
+ this.createGain = vi.fn().mockReturnValue({ gain: { value: 1 }, connect: vi.fn() });
+ this.createDynamicsCompressor = vi.fn().mockReturnValue({
+ threshold: { value: 0 },
+ knee: { value: 0 },
+ ratio: { value: 1 },
+ attack: { value: 0 },
+ release: { value: 0 },
+ connect: vi.fn(),
+ });
+ }
+ };
+
+ expect(() => renderProvider()).not.toThrow();
+ });
+
+ it('still exposes full API surface when createMediaElementSource throws', () => {
+ window.AudioContext = class {
+ constructor() {
+ this.destination = {};
+ this.resume = vi.fn().mockResolvedValue(undefined);
+ this.close = vi.fn().mockResolvedValue(undefined);
+ this.createMediaElementSource = vi.fn(() => {
+ throw new Error('InvalidStateError: media element already connected');
+ });
+ this.createGain = vi.fn().mockReturnValue({ gain: { value: 1 }, connect: vi.fn() });
+ this.createDynamicsCompressor = vi.fn().mockReturnValue({
+ threshold: { value: 0 },
+ knee: { value: 0 },
+ ratio: { value: 1 },
+ attack: { value: 0 },
+ release: { value: 0 },
+ connect: vi.fn(),
+ });
+ }
+ };
+
+ const { result } = renderProvider();
+ const ctx = result.current;
+ expect(typeof ctx.play).toBe('function');
+ expect(typeof ctx.stop).toBe('function');
+ expect(typeof ctx.seek).toBe('function');
+ expect(ctx.isPlaying).toBe(false);
+ });
+
+ it('calls getMediaPort even when AudioContext is unavailable', async () => {
+ window.AudioContext = class {
+ constructor() {
+ throw new Error('NotSupportedError');
+ }
+ };
+
+ renderProvider();
+ await waitFor(() => expect(window.api.getMediaPort).toHaveBeenCalledTimes(1));
+ });
+});
diff --git a/renderer/src/__tests__/Sidebar.test.jsx b/renderer/src/__tests__/Sidebar.test.jsx
index 742588a7..b961ee44 100644
--- a/renderer/src/__tests__/Sidebar.test.jsx
+++ b/renderer/src/__tests__/Sidebar.test.jsx
@@ -1,6 +1,18 @@
-import { describe, it, expect, vi } from 'vitest';
-import { render, screen, fireEvent, waitFor } from '@testing-library/react';
+import { describe, it, expect, vi, beforeEach } from 'vitest';
+import { render, screen, fireEvent, waitFor, act } from '@testing-library/react';
import Sidebar from '../Sidebar.jsx';
+import { DownloadProvider } from '../DownloadContext.jsx';
+import { TidalDownloadProvider } from '../TidalDownloadContext.jsx';
+
+function renderSidebar(props = {}) {
+ return render(
+
+
+
+
+
+ );
+}
describe('Sidebar', () => {
const defaultProps = {
@@ -11,17 +23,17 @@ describe('Sidebar', () => {
};
it('renders the Music menu item', () => {
- render(
);
+ renderSidebar({ ...defaultProps });
expect(screen.getByText('Music')).toBeInTheDocument();
});
it('renders the PLAYLISTS heading', () => {
- render(
);
+ renderSidebar({ ...defaultProps });
expect(screen.getByText('PLAYLISTS')).toBeInTheDocument();
});
it('shows empty state when no playlists exist', async () => {
- render(
);
+ renderSidebar({ ...defaultProps });
await waitFor(() => {
expect(screen.getByText('No playlists yet')).toBeInTheDocument();
});
@@ -33,7 +45,7 @@ describe('Sidebar', () => {
{ id: 2, name: 'House Vibes', color: null, track_count: 8, total_duration: 2400 },
]);
- render(
);
+ renderSidebar({ ...defaultProps });
await waitFor(() => {
expect(screen.getByText('Techno Set')).toBeInTheDocument();
expect(screen.getByText('House Vibes')).toBeInTheDocument();
@@ -42,7 +54,7 @@ describe('Sidebar', () => {
it('calls onMenuSelect when Music is clicked', () => {
const onMenuSelect = vi.fn();
- render(
);
+ renderSidebar({ ...defaultProps, onMenuSelect });
fireEvent.click(screen.getByText('Music'));
expect(onMenuSelect).toHaveBeenCalledWith('music');
});
@@ -53,14 +65,14 @@ describe('Sidebar', () => {
{ id: 42, name: 'My Set', color: null, track_count: 5, total_duration: 1500 },
]);
- render(
);
+ renderSidebar({ ...defaultProps, onMenuSelect });
await waitFor(() => screen.getByText('My Set'));
fireEvent.click(screen.getByText('My Set'));
expect(onMenuSelect).toHaveBeenCalledWith('42');
});
it('shows new playlist input when + button is clicked', () => {
- render(
);
+ renderSidebar({ ...defaultProps });
fireEvent.click(screen.getByTitle('New playlist'));
expect(screen.getByPlaceholderText('Playlist name')).toBeInTheDocument();
});
@@ -70,7 +82,7 @@ describe('Sidebar', () => {
{ id: 1, name: 'Techno Set', color: null, track_count: 0, total_duration: 0 },
]);
- render(
);
+ renderSidebar({ ...defaultProps });
await waitFor(() => screen.getByText('Techno Set'));
fireEvent.contextMenu(screen.getByText('Techno Set'));
@@ -84,7 +96,7 @@ describe('Sidebar', () => {
{ id: 1, name: 'Techno Set', color: null, track_count: 0, total_duration: 0 },
]);
- render(
);
+ renderSidebar({ ...defaultProps });
await waitFor(() => screen.getByText('Techno Set'));
fireEvent.contextMenu(screen.getByText('Techno Set'));
@@ -98,9 +110,10 @@ describe('Sidebar', () => {
{ id: 42, name: 'My Set', color: null, track_count: 0, total_duration: 0 },
]);
- render(
-
- );
+ renderSidebar({
+ ...defaultProps,
+ onExportPlaylistRekordboxUsb,
+ });
await waitFor(() => screen.getByText('My Set'));
fireEvent.contextMenu(screen.getByText('My Set'));
fireEvent.click(screen.getByText(/Export Rekordbox USB/));
@@ -114,7 +127,7 @@ describe('Sidebar', () => {
{ id: 42, name: 'My Set', color: null, track_count: 0, total_duration: 0 },
]);
- render(
);
+ renderSidebar({ ...defaultProps, onExportPlaylistAll });
await waitFor(() => screen.getByText('My Set'));
fireEvent.contextMenu(screen.getByText('My Set'));
fireEvent.click(screen.getByText(/Export All to USB/));
@@ -123,7 +136,121 @@ describe('Sidebar', () => {
});
it('does not render an "Export USB…" bottom button', () => {
- render(
);
+ renderSidebar({ ...defaultProps });
expect(screen.queryByText(/Export USB/)).toBeNull();
});
});
+
+describe('Sidebar — import dialog playlist association', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ const defaultProps = {
+ selectedMenuItemId: 'music',
+ onMenuSelect: vi.fn(),
+ onExportPlaylistRekordboxUsb: vi.fn(),
+ onExportPlaylistAll: vi.fn(),
+ };
+
+ it('passes playlist id (not the whole object) to importAudioFiles when creating new playlist', async () => {
+ window.api.selectAudioFiles.mockResolvedValueOnce(['/tmp/track.mp3']);
+ window.api.createPlaylist.mockResolvedValueOnce({ id: 7 });
+
+ renderSidebar({ ...defaultProps });
+ fireEvent.click(screen.getByText('Import Audio Files'));
+
+ await waitFor(() => screen.getByText('Import to Playlist'));
+
+ fireEvent.click(screen.getByRole('radio', { name: /Create new playlist/ }));
+ fireEvent.change(screen.getByPlaceholderText('New playlist name'), {
+ target: { value: 'My New Set' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Import' }));
+
+ await waitFor(() => {
+ expect(window.api.createPlaylist).toHaveBeenCalledWith('My New Set');
+ // Regression: must pass the integer id, not the whole { id } object
+ expect(window.api.importAudioFiles).toHaveBeenCalledWith(['/tmp/track.mp3'], 7);
+ });
+ });
+
+ it('passes playlist id to importAudioFiles when selecting an existing playlist', async () => {
+ window.api.getPlaylists.mockResolvedValue([
+ { id: 42, name: 'Techno Set', color: null, track_count: 5, total_duration: 1500 },
+ ]);
+ window.api.selectAudioFiles.mockResolvedValueOnce(['/tmp/track.mp3']);
+
+ renderSidebar({ ...defaultProps });
+ await waitFor(() => screen.getByText('Techno Set'));
+
+ fireEvent.click(screen.getByText('Import Audio Files'));
+ await waitFor(() => screen.getByText('Import to Playlist'));
+
+ fireEvent.click(screen.getByRole('radio', { name: /Techno Set/ }));
+ fireEvent.click(screen.getByRole('button', { name: 'Import' }));
+
+ await waitFor(() => {
+ expect(window.api.importAudioFiles).toHaveBeenCalledWith(['/tmp/track.mp3'], 42);
+ });
+ });
+
+ it('passes null to importAudioFiles when "Library only" is selected', async () => {
+ window.api.selectAudioFiles.mockResolvedValueOnce(['/tmp/track.mp3']);
+
+ renderSidebar({ ...defaultProps });
+ fireEvent.click(screen.getByText('Import Audio Files'));
+ await waitFor(() => screen.getByText('Import to Playlist'));
+
+ // "Library only" is the default — just click Import
+ fireEvent.click(screen.getByRole('button', { name: 'Import' }));
+
+ await waitFor(() => {
+ expect(window.api.importAudioFiles).toHaveBeenCalledWith(['/tmp/track.mp3'], null);
+ });
+ });
+});
+
+describe('Sidebar — normalization progress bar', () => {
+ beforeEach(() => vi.clearAllMocks());
+
+ const defaultProps = {
+ selectedMenuItemId: 'music',
+ onMenuSelect: vi.fn(),
+ onExportPlaylistRekordboxUsb: vi.fn(),
+ onExportPlaylistAll: vi.fn(),
+ };
+
+ it('shows normalize progress when onNormalizeProgress fires with progress data', async () => {
+ let progressCallback;
+ window.api.onNormalizeProgress.mockImplementation((cb) => {
+ progressCallback = cb;
+ return vi.fn(); // unsub
+ });
+
+ renderSidebar({ ...defaultProps });
+
+ act(() => {
+ progressCallback({ completed: 3, total: 10, done: false });
+ });
+
+ await waitFor(() => {
+ expect(screen.getByText('Normalizing')).toBeInTheDocument();
+ expect(screen.getByText('3 / 10')).toBeInTheDocument();
+ });
+ });
+
+ it('hides normalize progress bar when done event fires', async () => {
+ let progressCallback;
+ window.api.onNormalizeProgress.mockImplementation((cb) => {
+ progressCallback = cb;
+ return vi.fn();
+ });
+
+ renderSidebar({ ...defaultProps });
+
+ act(() => progressCallback({ completed: 5, total: 5, done: false }));
+ await waitFor(() => expect(screen.getByText('Normalizing')).toBeInTheDocument());
+
+ act(() => progressCallback({ done: true }));
+ await waitFor(() => expect(screen.queryByText('Normalizing')).toBeNull(), { timeout: 2000 });
+ });
+});
diff --git a/renderer/src/__tests__/setup.js b/renderer/src/__tests__/setup.js
index a1fefc0a..c2228301 100644
--- a/renderer/src/__tests__/setup.js
+++ b/renderer/src/__tests__/setup.js
@@ -6,9 +6,16 @@ const noop = () => () => {}; // returns unsubscribe fn
window.api = {
getTracks: vi.fn().mockResolvedValue([]),
getTrackIds: vi.fn().mockResolvedValue([]),
+ getCuePoints: vi.fn().mockResolvedValue([]),
+ addCuePoint: vi.fn().mockResolvedValue({ id: 1 }),
+ updateCuePoint: vi.fn().mockResolvedValue({ ok: true }),
+ deleteCuePoint: vi.fn().mockResolvedValue({ ok: true }),
+ generateCuePoints: vi.fn().mockResolvedValue([]),
+ generateCuePointsLibrary: vi.fn().mockResolvedValue({ generated: 0, skipped: 0, total: 0 }),
+ deleteAllCuePointsLibrary: vi.fn().mockResolvedValue({ deleted: 0 }),
getPlaylists: vi.fn().mockResolvedValue([]),
getPlaylist: vi.fn().mockResolvedValue(null),
- createPlaylist: vi.fn().mockResolvedValue(1),
+ createPlaylist: vi.fn().mockResolvedValue({ id: 1 }),
renamePlaylist: vi.fn().mockResolvedValue(undefined),
updatePlaylistColor: vi.fn().mockResolvedValue(undefined),
deletePlaylist: vi.fn().mockResolvedValue(undefined),
@@ -19,37 +26,82 @@ window.api = {
selectAudioFiles: vi.fn().mockResolvedValue([]),
importAudioFiles: vi.fn().mockResolvedValue([]),
reanalyzeTrack: vi.fn().mockResolvedValue({ ok: true }),
+ getZoomFactor: vi.fn().mockReturnValue(1.0),
+ setZoomFactor: vi.fn(),
removeTrack: vi.fn().mockResolvedValue({ ok: true }),
+ removeLinkedFile: vi.fn().mockResolvedValue({ ok: true }),
adjustBpm: vi.fn().mockResolvedValue([]),
updateTrack: vi.fn().mockResolvedValue({}),
+ getEditorWaveform: vi.fn().mockResolvedValue(null),
exportPlaylistAsM3U: vi.fn().mockResolvedValue({ canceled: true }),
getSetting: vi.fn().mockResolvedValue(null),
setSetting: vi.fn().mockResolvedValue(undefined),
- normalizeLibrary: vi.fn().mockResolvedValue({ updated: 0 }),
+ normalizeLibrary: vi.fn().mockResolvedValue({ normalized: 0, skipped: 0, total: 0 }),
+ normalizeTracksAudio: vi.fn().mockResolvedValue({ normalized: 0, skipped: 0 }),
+ getNormalizedCount: vi.fn().mockResolvedValue(0),
getLibraryPath: vi.fn().mockResolvedValue('/tmp/audio'),
moveLibrary: vi.fn().mockResolvedValue({ moved: 0, total: 0 }),
openDirDialog: vi.fn().mockResolvedValue(null),
getDepVersions: vi.fn().mockResolvedValue({}),
checkDepUpdates: vi.fn().mockResolvedValue({}),
updateAllDeps: vi.fn().mockResolvedValue(undefined),
+ retryDeps: vi.fn().mockResolvedValue(undefined),
+ updateTidalDlNg: vi.fn().mockResolvedValue({ ok: true }),
clearLibrary: vi.fn().mockResolvedValue(undefined),
clearUserData: vi.fn().mockResolvedValue(undefined),
getLogDir: vi.fn().mockResolvedValue('/tmp/logs'),
openLogDir: vi.fn().mockResolvedValue(undefined),
log: vi.fn(),
onTrackUpdated: vi.fn().mockImplementation(noop),
+ onCuePointsUpdated: vi.fn().mockImplementation(noop),
onLibraryUpdated: vi.fn().mockImplementation(noop),
onPlaylistsUpdated: vi.fn().mockImplementation(noop),
onOpenSettings: vi.fn().mockImplementation(noop),
onDepsProgress: vi.fn().mockImplementation(noop),
onMoveLibraryProgress: vi.fn().mockImplementation(noop),
onExportM3UProgress: vi.fn().mockImplementation(noop),
+ onImportProgress: vi.fn().mockImplementation(noop),
+ onNormalizeProgress: vi.fn().mockImplementation(noop),
+ onAnalysisProgress: vi.fn().mockImplementation(noop),
+ onCueGenProgress: vi.fn().mockImplementation(noop),
+ getTrackWaveform: vi.fn().mockResolvedValue(null),
+ onWaveformReady: vi.fn().mockImplementation(noop),
+ generateWaveformsLibrary: vi.fn().mockResolvedValue({ generated: 0, skipped: 0, total: 0 }),
+ onWaveformGenProgress: vi.fn().mockImplementation(noop),
getMediaPort: vi.fn().mockResolvedValue(19876),
ytDlpFetchInfo: vi.fn().mockResolvedValue({ ok: false, error: 'not configured' }),
+ checkDuplicateUrls: vi.fn().mockResolvedValue([]),
+ getPlaylistSourceUrls: vi.fn().mockResolvedValue([]),
ytDlpDownloadUrl: vi.fn().mockResolvedValue({ ok: true, trackIds: [] }),
onYtDlpProgress: vi.fn().mockImplementation(() => () => {}),
+ onYtDlpCheckProgress: vi.fn().mockImplementation(() => () => {}),
+ onYtDlpEntriesReady: vi.fn().mockImplementation(() => () => {}),
+ onYtDlpEntryChecked: vi.fn().mockImplementation(() => () => {}),
onYtDlpTrackUpdate: vi.fn().mockImplementation(() => () => {}),
+ tidalCheck: vi.fn().mockResolvedValue({ installed: false, loggedIn: false, path: null }),
+ tidalInstall: vi.fn().mockResolvedValue({ ok: true }),
+ tidalFetchInfo: vi.fn().mockResolvedValue({ ok: false, error: 'not configured' }),
+ tidalLogin: vi.fn().mockResolvedValue({ ok: true }),
+ tidalDownloadUrl: vi.fn().mockResolvedValue({ ok: true, trackIds: [], playlistId: null }),
+ onTidalProgress: vi.fn().mockImplementation(() => () => {}),
+ onTidalLoginUrl: vi.fn().mockImplementation(() => () => {}),
+ onTidalInstallProgress: vi.fn().mockImplementation(() => () => {}),
+ onTidalTrackUpdate: vi.fn().mockImplementation(() => () => {}),
openExternal: vi.fn().mockResolvedValue(undefined),
+ getComputerRoot: vi.fn().mockResolvedValue({ root: '/', home: '/home/user' }),
+ browseDirectory: vi.fn().mockResolvedValue({ dirs: [], files: [] }),
+ selectExplorerFolder: vi.fn().mockResolvedValue(null),
+ getTracksByPaths: vi.fn().mockResolvedValue([]),
+ explorerStartRecursive: vi.fn().mockResolvedValue(undefined),
+ explorerCancelRecursive: vi.fn().mockResolvedValue(undefined),
+ onExplorerRecursiveBatch: vi.fn().mockImplementation(noop),
+ onExplorerRecursiveDone: vi.fn().mockImplementation(noop),
+ linkAudioFiles: vi.fn().mockResolvedValue([]),
+ linkDirectory: vi.fn().mockResolvedValue({ ok: true, linked: 0, total: 0 }),
+ remapTrack: vi.fn().mockResolvedValue({ ok: true }),
+ remapFolder: vi.fn().mockResolvedValue({ ok: true, count: 0 }),
+ checkLinkedTrackStatus: vi.fn().mockResolvedValue([]),
+ getLinkedTracksBasic: vi.fn().mockResolvedValue([]),
checkUsbFormat: vi
.fn()
.mockResolvedValue({ needsFormat: false, fs: 'fat32', fsLabel: 'fat32', device: '/dev/sdb1' }),
@@ -62,3 +114,24 @@ window.api = {
onExportAllProgress: vi.fn().mockImplementation(noop),
onFormatUsbProgress: vi.fn().mockImplementation(noop),
};
+
+// jsdom does not implement Web Audio API — stub the minimum PlayerContext needs
+class MockAudioContext {
+ constructor() {
+ this.destination = {};
+ this.createMediaElementSource = vi.fn().mockReturnValue({ connect: vi.fn() });
+ this.createGain = vi.fn().mockReturnValue({ gain: { value: 1 }, connect: vi.fn() });
+ this.createDynamicsCompressor = vi.fn().mockReturnValue({
+ threshold: { value: 0 },
+ knee: { value: 0 },
+ ratio: { value: 1 },
+ attack: { value: 0 },
+ release: { value: 0 },
+ connect: vi.fn(),
+ });
+ this.setSinkId = vi.fn().mockResolvedValue(undefined);
+ this.resume = vi.fn().mockResolvedValue(undefined);
+ this.close = vi.fn().mockResolvedValue(undefined);
+ }
+}
+window.AudioContext = MockAudioContext;
diff --git a/renderer/src/index.css b/renderer/src/index.css
index 08a3ac9e..83ea6cac 100644
--- a/renderer/src/index.css
+++ b/renderer/src/index.css
@@ -51,7 +51,7 @@ button:hover {
}
button:focus,
button:focus-visible {
- outline: 4px auto -webkit-focus-ring-color;
+ outline: none;
}
@media (prefers-color-scheme: light) {
diff --git a/reverse-engineering/CAPTURE_GUIDE.md b/reverse-engineering/CAPTURE_GUIDE.md
new file mode 100644
index 00000000..31e399d5
--- /dev/null
+++ b/reverse-engineering/CAPTURE_GUIDE.md
@@ -0,0 +1,121 @@
+# Rekordbox USB Export Capture Guide
+
+Master reference for all reverse-engineering captures. Read this file first.
+Actual capture steps are split into two files:
+
+- **`software_captures.md`** — everything you can do with Rekordbox + a USB drive alone
+- **`hardware_captures.md`** — captures that require a CDJ-2000NXS2 or CDJ-3000
+
+**Software required:**
+
+- Rekordbox 6.x (latest stable)
+- A USB drive formatted as FAT32 or exFAT (call it `RBDECK` throughout)
+- A hex viewer: `xxd`, `hexdump`, or [ImHex](https://github.com/WerWolv/ImHex)
+
+**Hardware required (for `hardware_captures.md` only):**
+
+- CDJ-2000NXS2 or CDJ-3000
+
+**Golden rule:** change exactly ONE thing between consecutive captures. If you
+change two things at once the diff is unreadable.
+
+---
+
+## Setup — Test Tracks
+
+All test tracks live in `test-tracks/` at the root of this repository. They
+are gitignored and must be generated locally before starting.
+
+| File | Description | How to generate |
+| -------------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------------- |
+| `track-silence.wav` | 3 minutes of silence | `ffmpeg -f lavfi -i anullsrc=r=44100:cl=mono -t 180 track-silence.wav` |
+| `track-sine-60hz.wav` | 3 min, 60 Hz sine at −6 dBFS | `ffmpeg -f lavfi -i sine=frequency=60:sample_rate=44100 -af volume=0.5 -t 180 track-sine-60hz.wav` |
+| `track-sine-500hz.wav` | 3 min, 500 Hz sine at −6 dBFS | same, `frequency=500` |
+| `track-sine-8khz.wav` | 3 min, 8 kHz sine at −6 dBFS | same, `frequency=8000` |
+| `track-normal.mp3` | Real music track, 3–5 min, 320 kbps MP3 | Copy from your library |
+| `track-normal.flac` | Same content as `track-normal.mp3`, FLAC | `ffmpeg -i track-normal.mp3 track-normal.flac` |
+| `track-normal.wav` | Same content as WAV (44.1 kHz) | `ffmpeg -i track-normal.mp3 track-normal.wav` |
+| `track-normal.m4a` | Same content as M4A/AAC | `ffmpeg -i track-normal.mp3 track-normal.m4a` |
+| `track-normal-128kbps.mp3` | Same content re-encoded at 128 kbps | `ffmpeg -i track-normal.mp3 -b:a 128k track-normal-128kbps.mp3` |
+| `track-normal-48khz.wav` | Same content resampled to 48 kHz | `ffmpeg -i track-normal.wav -ar 48000 track-normal-48khz.wav` |
+| `track-160bpm.mp3` | Real track with constant ~160 BPM, clear beats | Copy from your library |
+| `track-190bpm.mp3` | Real track with constant ~140 BPM, clear beats | Copy from your library |
+| `track-variable-bpm.mp3` | Synthetic sine, linear ramp 120→130 BPM over 3 min | `ffmpeg -f lavfi -i "aevalsrc=0.5*sin(2*PI*(2*t+t*t/2160)):s=44100:c=mono" -t 180 track-variable-bpm.mp3` |
+| `artwork.jpg` | 500×500 JPEG for artwork captures | `ffmpeg -f lavfi -i color=c=0x1a1a2e:size=500x500:rate=1 -vframes 1 artwork.jpg` |
+| `artwork.png` | 500×500 PNG version of the same artwork | `ffmpeg -i artwork.jpg artwork.png` |
+| `artwork-large.jpg` | 3000×3000 JPEG for the large-artwork capture | `ffmpeg -f lavfi -i color=c=0x1a1a2e:size=3000x3000:rate=1 -vframes 1 artwork-large.jpg` |
+
+**Before capture 32 only** — create 8 extra copies of `track-normal.mp3` for
+the all-12-keys capture (Rekordbox requires each imported file to be unique):
+
+```bash
+for key in d dm eb ebm e em f fm; do
+ cp test-tracks/track-normal.mp3 test-tracks/track-key-$key.mp3
+done
+```
+
+---
+
+## How to Export to USB in Rekordbox
+
+1. Open Rekordbox 6.
+2. Drag the track(s) into a Collection or playlist as instructed per capture.
+3. Connect the USB drive.
+4. In the left sidebar, expand **Devices** → your USB.
+5. Drag tracks or playlists to the device as instructed.
+6. Click **Sync** (cloud icon) or right-click → **Export to Device**.
+7. After export completes, eject the USB safely.
+8. Copy the required files from the USB into the capture folder on your computer.
+
+---
+
+## Files to Copy Per Capture — Checklist
+
+```
+[ ] export.pdb
+[ ] PIONEER/USBANLZ/
/ANLZ0000.DAT
+[ ] PIONEER/USBANLZ//ANLZ0000.EXT
+[ ] PIONEER/USBANLZ//ANLZ0000.2EX (if present — CDJ-3000 format)
+[ ] PIONEER/MYSETTING.DAT
+[ ] PIONEER/MYSETTING2.DAT
+[ ] PIONEER/DEVSETTING.DAT
+[ ] PIONEER/Artwork/ (artwork captures only)
+[ ] notes.txt (any measured values: BPM, times, gain dB)
+```
+
+When multiple tracks are exported, copy the ANLZ folder for each track.
+Name them `ANLZ-track1/`, `ANLZ-track2/` etc. and record which is which in
+`notes.txt`.
+
+---
+
+## Diff Workflow
+
+```bash
+# Quick binary diff — prints byte offset + both values for every difference
+cmp -l captures/20-gain-default/export.pdb \
+ captures/21-gain-plus6db/export.pdb | head -40
+
+# Human-readable hex diff
+xxd captures/20-gain-default/export.pdb > /tmp/a.hex
+xxd captures/21-gain-plus6db/export.pdb > /tmp/b.hex
+diff /tmp/a.hex /tmp/b.hex
+
+# Find a known section tag in an ANLZ file
+xxd captures/10-beatgrid-constant-160/PIONEER/USBANLZ/.../ANLZ0000.EXT \
+ | grep -A 4 "5051 5432" # PQT2 in hex
+
+# ImHex (recommended for large files)
+# File → Open both files → View → Diff
+```
+
+SETTING.DAT: always mask bytes 6–7 before comparing:
+
+```bash
+# Strip CRC bytes before diff
+python3 -c "
+import sys
+d = open(sys.argv[1],'rb').read()
+print(d[:6].hex(), '????', d[8:].hex())
+" captures/110-settings-default/PIONEER/MYSETTING.DAT
+```
diff --git a/reverse-engineering/README.md b/reverse-engineering/README.md
new file mode 100644
index 00000000..74c08bc7
--- /dev/null
+++ b/reverse-engineering/README.md
@@ -0,0 +1,212 @@
+# Reverse Engineering Captures — Index
+
+Internal reference only. Each subdirectory under `captures/` holds a complete
+Rekordbox USB export (or the relevant slice of one) for a single isolated
+feature. The naming convention is `NN-slug/` where `NN` is the capture number
+and `slug` describes what was changed from the baseline.
+
+**How to use:** diff two capture folders side-by-side with a hex viewer
+(e.g. `xxd`, `hexdump`, or ImHex). The delta between two exports reveals
+which bytes encode a specific feature.
+
+---
+
+## Capture Index
+
+### Baseline
+
+| Folder | What it captures | Decodes |
+| -------------- | ------------------------------------------------------- | ---------------------------------- |
+| `00-baseline/` | 1 track, no analysis, no cues, no artwork, no playlists | Minimum valid PDB + ANLZ structure |
+
+---
+
+### Waveforms
+
+| Folder | What it captures | Decodes |
+| -------------------------- | ------------------------------------- | ------------------------------------------------------------- |
+| `01-waveform-silence/` | Track that is pure silence | Zero waveform baseline (all sections present but data = 0) |
+| `02-waveform-sine-bass/` | 60 Hz sine wave (bass-only content) | PWV5/PWV7 bass channel mapping; confirms band-separation math |
+| `03-waveform-sine-mid/` | 500 Hz sine wave (mid-only content) | PWV5/PWV7 mid channel; confirms green channel in u16BE |
+| `04-waveform-sine-treble/` | 8 kHz sine wave (treble-only content) | PWV5/PWV7 treble channel; confirms red channel |
+| `05-waveform-normal/` | Normal music track, fully analyzed | Full waveform set; validates PWV4 byte 1 complement formula |
+
+---
+
+### Beat Grid
+
+| Folder | What it captures | Decodes |
+| --------------------------- | ----------------------------------------------------- | -------------------------------------------------------------------- |
+| `10-beatgrid-constant-120/` | 120 BPM constant track, analyzed | PQTZ entry format; PQT2 body u16 values at known BPM |
+| `11-beatgrid-constant-140/` | 140 BPM constant track, analyzed | PQT2 body values at different BPM — finds the exact encoding formula |
+| `12-beatgrid-variable/` | Track with tempo automation (start 120 → end 130 BPM) | Whether PQTZ tempo field varies per-entry or is constant |
+| `13-beatgrid-offset/` | Track with beatgrid manually shifted by exactly 10 ms | Confirms beatgrid_offset storage location |
+
+---
+
+### Gain / Loudness / Normalization ← **primary unknown**
+
+| Folder | What it captures | Decodes |
+| ------------------- | ------------------------------------------------ | ---------------------------------------------------------- |
+| `20-gain-default/` | Track with no gain change (factory default) | Baseline gain bytes in PDB track row and all ANLZ sections |
+| `21-gain-plus6db/` | Same track, track gain set to +6 dB in Rekordbox | Which byte(s) encode gain; field size and scale factor |
+| `22-gain-minus6db/` | Same track, track gain set to −6 dB | Negative gain encoding (signed? float? fixed-point?) |
+| `23-gain-zero/` | Same track, gain explicitly set to 0 dB | Confirms zero-gain encoding is 0x00 or some other sentinel |
+| `24-autogain-on/` | Auto-gain analysis enabled before export | Whether auto-gain writes to PDB row or a separate section |
+| `25-autogain-off/` | Same track, auto-gain disabled in preferences | What changes when auto-gain is skipped |
+
+---
+
+### Key
+
+| Folder | What it captures | Decodes |
+| ----------------- | ------------------------------ | ----------------------------------------------------------------- |
+| `30-key-c-major/` | Track with key = C major | Key row format; whether ID is sequential or musically fixed |
+| `31-key-a-minor/` | Track with key = A minor | Minor key abbreviated name (`Am` vs `A minor`) |
+| `32-key-all-12/` | 12 tracks covering all 12 keys | Full key ID → name mapping; confirms IDs are sequential not fixed |
+
+---
+
+### Cue Points
+
+| Folder | What it captures | Decodes |
+| ----------------------- | --------------------------------------------- | ------------------------------------------------------------------- |
+| `40-cue-hot-abc/` | Hot cues A, B, C only (first 3 slots) | PCOB slot 1 in DAT — exactly which cues go here |
+| `41-cue-hot-all/` | All 8 hot cues A–H filled | EXT PCOB split — confirms D–H go only in EXT, not DAT |
+| `42-cue-memory/` | Memory cues only (no hot cues) | PCO2 slot 2 format in EXT; whether PCOB2 can be non-empty |
+| `43-cue-colors-all/` | 8 hot cues, one per Pioneer color | Full PCP2 64-step color wheel codes; PCPT 1–8 palette per slot |
+| `44-cue-labels/` | 3 hot cues with text labels of varying length | PCP2 `len_comment` + UTF-16BE label encoding; padding rules |
+| `45-cue-label-long/` | 1 cue with label > 7 characters | PCP2 size growth for labels > 7 chars |
+| `46-cue-loop/` | 1 loop cue (A = 4-beat loop) | PCPT/PCP2 type=2; `loop_time` field — duration or end position? |
+| `47-cue-loop-multiple/` | 4 loop cues of different lengths | Confirms loop_time units (ms) and whether it is end_ms or length_ms |
+
+---
+
+### Track Metadata (PDB)
+
+| Folder | What it captures | Decodes |
+| --------------------------- | ------------------------------------------- | ------------------------------------------------------------ |
+| `50-metadata-minimal/` | Title only, no artist/album/genre/label | Which string fields default to `""` vs absent |
+| `51-metadata-full/` | All metadata fields filled | Artist, Album, Genre, Label rows; confirms row Subtype bytes |
+| `52-metadata-genre/` | Single genre set | Genre table row format + genreId link in track row |
+| `53-metadata-multi-genre/` | Multiple genres (if Rekordbox allows) | How Rekordbox encodes multi-genre — multiple rows? JSON? |
+| `54-metadata-label/` | Label field set | Label table row format + labelId link |
+| `55-metadata-album-artist/` | Album linked to an artist | Whether Album row ArtistId field is populated by Rekordbox |
+| `56-metadata-comment/` | Comment / Notes field filled | Comment string slot in track row (slot 16 in string heap) |
+| `57-metadata-isrc/` | ISRC set | ISRC string encoding (`0x90 … 0x03 … 0x00` variant) |
+| `58-metadata-rating-1star/` | 1-star rating | Rating encoding: 51 per star (0→0, 1→51, …5→255) — validate |
+| `59-metadata-rating-5star/` | 5-star rating | Confirms upper bound |
+| `60-metadata-color-tag/` | Track color tag set (Rekordbox label color) | `ColorId` field in track row; Colors table ID mapping |
+| `61-metadata-year/` | Year field set | `Year` u16LE in track row |
+| `62-metadata-track-number/` | Track number set | `TrackNumber` u32LE — is it disc+track or track only? |
+
+---
+
+### PDB Track Row Unknown Fields
+
+| Folder | What it captures | Decodes |
+| ------------------------ | ------------------------------------------ | ------------------------------------------------------------------- |
+| `70-trackrow-bitmask/` | Same track exported as MP3, FLAC, WAV, AAC | Whether `Bitmask = 0x000C0700` changes per file type |
+| `71-trackrow-unnamed78/` | Track analyzed vs not analyzed | Whether `Unnamed7=0x758A` / `Unnamed8=0x57A2` change after analysis |
+| `72-trackrow-checksum/` | Same file duplicated with 1 byte changed | Whether `Checksum` field is a CRC of the audio data |
+| `73-trackrow-unnamed26/` | Vary bitrate and sample depth | Whether `Unnamed26=0x0029` changes |
+
+---
+
+### Artwork
+
+| Folder | What it captures | Decodes |
+| ----------------------------- | ------------------------------------ | --------------------------------------------------------------- |
+| `80-artwork-none/` | Track with no artwork | Confirms `artworkId = 0` sentinel in track row |
+| `81-artwork-jpeg/` | Track with JPEG artwork embedded | Artwork table row format; `PIONEER/Artwork/` folder structure |
+| `82-artwork-png/` | Track with PNG artwork | Whether Rekordbox converts to JPEG or stores original format |
+| `83-artwork-large/` | Track with very large artwork image | Whether Rekordbox downscales; max stored dimensions |
+| `84-artwork-two-tracks-same/` | Two tracks sharing identical artwork | Whether Artwork table deduplicates (1 row shared) or duplicates |
+
+---
+
+### Playlists
+
+| Folder | What it captures | Decodes |
+| --------------------- | ---------------------------------------------- | -------------------------------------------------------------- |
+| `90-playlist-flat/` | Single playlist with 3 tracks | PlaylistTree + PlaylistEntry row format — already mostly known |
+| `91-playlist-nested/` | Folder containing 2 playlists | PlaylistTree `isFolder=1` + `parentId` nesting |
+| `92-playlist-order/` | Playlist with tracks in non-alphabetical order | `entryIndex` meaning — is it 0-based or 1-based? |
+
+---
+
+### History (CDJ writes this on eject)
+
+| Folder | What it captures | Decodes |
+| --------------------- | ---------------------------------------------------- | ------------------------------------------------------------- |
+| `100-history-empty/` | Fresh export, no playback | Baseline empty History table pages |
+| `101-history-played/` | Same USB after playing 3 tracks on CDJ then ejecting | HistoryPlaylists + HistoryEntries + History table row formats |
+
+> **Note:** Captures 100/101 require a physical CDJ or XDJ. Load the USB,
+> play the tracks, and eject. The CDJ writes the history back to the USB.
+
+---
+
+### SETTING.DAT Field Mapping
+
+Each capture changes exactly **one setting** in Rekordbox then re-exports.
+Diff against `110-settings-default/` to find the byte that changed.
+
+| Folder | Setting changed |
+| ------------------------------------ | ------------------------------------ |
+| `110-settings-default/` | Factory default — all settings reset |
+| `111-settings-quantize-off/` | Quantize → OFF |
+| `112-settings-sync-off/` | Sync → OFF |
+| `113-settings-jog-vinyl/` | Jog mode → Vinyl |
+| `114-settings-jog-cdj/` | Jog mode → CDJ |
+| `115-settings-needle-search-off/` | Needle search → OFF |
+| `116-settings-master-tempo-on/` | Master tempo → ON |
+| `117-settings-slip-on/` | Slip mode → ON |
+| `118-settings-hotcue-autoload-off/` | Hot cue auto-load → OFF |
+| `119-settings-beat-jump-1/` | Beat jump size → 1 beat |
+| `120-settings-beat-jump-32/` | Beat jump size → 32 beats |
+| `121-settings-loop-1/` | Loop size → 1 beat |
+| `122-settings-loop-16/` | Loop size → 16 beats |
+| `123-settings-track-end-warning-on/` | Track end warning → ON |
+| `124-settings-cue-play/` | Cue/Play behaviour → momentary |
+| `125-settings-display-waveform/` | Waveform display → large |
+
+---
+
+## Files to Capture Per Export
+
+For each export, copy the following from the USB root:
+
+```
+export.pdb
+PIONEER/USBANLZ//ANLZ0000.DAT
+PIONEER/USBANLZ//ANLZ0000.EXT
+PIONEER/USBANLZ//ANLZ0000.2EX (if present — CDJ-3000 format)
+PIONEER/MYSETTING.DAT
+PIONEER/MYSETTING2.DAT
+PIONEER/DEVSETTING.DAT
+PIONEER/Artwork/ (full folder, if present)
+```
+
+Preserve the subfolder structure inside each capture directory.
+
+---
+
+## Diff Commands
+
+```bash
+# Quick binary diff — shows byte offsets that differ
+cmp -l captures/20-gain-default/export.pdb \
+ captures/21-gain-plus6db/export.pdb | head -40
+
+# Human-readable hex diff
+xxd captures/20-gain-default/export.pdb > /tmp/a.hex
+xxd captures/21-gain-plus6db/export.pdb > /tmp/b.hex
+diff /tmp/a.hex /tmp/b.hex
+
+# Diff a specific ANLZ section
+xxd captures/20-gain-default/PIONEER/USBANLZ/.../ANLZ0000.DAT | grep -A2 -B2 "PQTZ"
+```
+
+For SETTING.DAT files, the CRC at bytes 6–7 will always change even if only
+one setting byte changed — ignore bytes 6–7 when comparing.
diff --git a/reverse-engineering/captures/.gitkeep b/reverse-engineering/captures/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/reverse-engineering/hardware_captures.md b/reverse-engineering/hardware_captures.md
new file mode 100644
index 00000000..5368c12d
--- /dev/null
+++ b/reverse-engineering/hardware_captures.md
@@ -0,0 +1,56 @@
+# Hardware Captures
+
+All captures in this file require a **CDJ-2000NXS2 or CDJ-3000**.
+
+Read `CAPTURE_GUIDE.md` first — it covers the per-capture file checklist and
+the diff workflow.
+
+---
+
+## Before you start
+
+You will receive a USB drive pre-loaded by the person running
+`software_captures.md`. **Do not reformat or re-export anything to that USB.**
+The USB already contains 3 analyzed tracks exported from Rekordbox and an
+`export.pdb` baseline saved as `captures/100-history-empty/export.pdb` on
+the computer. Your job is to play the tracks on the CDJ and then hand the USB
+back so the post-playback `export.pdb` can be compared against the baseline.
+
+---
+
+## 101 — History After Playback
+
+**Goal:** Capture the `export.pdb` after the CDJ has written playback history
+to the USB on eject. This lets us diff the HistoryPlaylists and HistoryEntries
+row formats against the pre-playback baseline from capture 100.
+
+**Tracks on the USB:**
+
+- `track-normal.mp3`
+- `track-160bpm.mp3`
+- `track-190bpm.mp3`
+
+**Steps:**
+
+1. Insert the USB into the CDJ-2000NXS2 or CDJ-3000.
+2. Browse to the USB on the CDJ and load `track-normal.mp3` into a deck.
+3. Play it for at least 30 seconds, then let it play to the end (or skip to
+ the end). The CDJ must register it as played.
+4. Repeat for `track-160bpm.mp3` and `track-190bpm.mp3`.
+5. **Eject the USB using the CDJ eject button** — do not pull it out while the
+ CDJ is on. The CDJ writes history data to `export.pdb` on safe eject.
+6. Copy `export.pdb` from the USB root into `captures/101-history-played/`.
+
+**Copy from USB:**
+
+```
+export.pdb → captures/101-history-played/export.pdb
+```
+
+Return the USB and the `captures/101-history-played/` folder to the person
+running the software captures so they can run the diff:
+
+```bash
+cmp -l captures/100-history-empty/export.pdb \
+ captures/101-history-played/export.pdb | head -60
+```
diff --git a/reverse-engineering/scripts/_lib.py b/reverse-engineering/scripts/_lib.py
new file mode 100755
index 00000000..4068d4ca
--- /dev/null
+++ b/reverse-engineering/scripts/_lib.py
@@ -0,0 +1,384 @@
+"""
+_lib.py — Shared binary parsing library for rekordbox reverse-engineering scripts.
+"""
+
+import struct
+import os
+
+# ── ANLZ ─────────────────────────────────────────────────────────────────────
+
+KNOWN_SECTIONS = {
+ "PPTH": "File path",
+ "PVBR": "VBR seek table",
+ "PQTZ": "Beat grid (legacy, CDJ-NXS2 and below)",
+ "PQT2": "Beat grid (extended, Rekordbox 6+ / CDJ-3000)",
+ "PWAV": "Mono overview waveform (400 cols)",
+ "PWV2": "Tiny mono overview (CDJ-900, 100 cols)",
+ "PWV3": "Mono scroll waveform (10 ms/col)",
+ "PWV4": "Colour overview (NXS2, 1200 × 6 bytes/col)",
+ "PWV5": "Colour scroll waveform (NXS2/3000, 10 ms/col)",
+ "PWV6": "RGB overview (CDJ-3000, 1200 × 3 bytes/col)",
+ "PWV7": "RGB scroll waveform (CDJ-3000, 10 ms/col)",
+ "PWVC": "Colour waveform calibration",
+ "PCOB": "Cue object container (PCPT sub-tags)",
+ "PCPT": "Hot/memory cue point",
+ "PCO2": "Extended cue container (PCP2 sub-tags)",
+ "PCP2": "Extended cue point with label + colour",
+}
+
+
+def u32be(data, off):
+ return struct.unpack_from(">I", data, off)[0]
+
+
+def u32le(data, off):
+ return struct.unpack_from("H", data, off)[0]
+
+
+def u16le(data, off):
+ return struct.unpack_from(" limit:
+ data = data[:limit]
+ truncated = True
+ else:
+ truncated = False
+ lines = []
+ for i in range(0, len(data), 16):
+ chunk = data[i : i + 16]
+ hex_part = " ".join(f"{b:02x}" for b in chunk).ljust(47)
+ asc_part = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk)
+ lines.append(f"{indent}{i:04x} {hex_part} {asc_part}")
+ if truncated:
+ lines.append(f"{indent}... (truncated at {limit} bytes)")
+ return "\n".join(lines)
+
+
+def hexdiff_row(row_off, chunk_a, chunk_b):
+ """Return two coloured hex rows highlighting bytes that differ."""
+ RED = "\033[1;31m"
+ RST = "\033[0m"
+
+ def fmt(chunk, ref):
+ parts = []
+ for i in range(16):
+ a = chunk[i] if i < len(chunk) else None
+ r = ref[i] if i < len(ref) else None
+ s = f"{a:02x}" if a is not None else " "
+ if a != r:
+ s = f"{RED}{s}{RST}"
+ parts.append(s)
+ return " ".join(parts)
+
+ return (
+ f" +{row_off:04x} A: {fmt(chunk_a, chunk_b)}\n"
+ f" B: {fmt(chunk_b, chunk_a)}"
+ )
+
+
+def parse_anlz(data):
+ """Parse a PMAI file → list of section dicts."""
+ if data[:4] != b"PMAI":
+ raise ValueError("Not a PMAI file (wrong magic bytes)")
+ file_len = u32be(data, 8)
+ sections = []
+ offset = 28
+ while offset < len(data):
+ if offset + 12 > len(data):
+ break
+ tag = data[offset : offset + 4].decode("ascii", errors="replace")
+ len_header = u32be(data, offset + 4)
+ len_tag = u32be(data, offset + 8)
+ if len_tag == 0:
+ break
+ body_start = offset + len_header
+ body_end = offset + len_tag
+ sections.append({
+ "tag": tag,
+ "offset": offset,
+ "len_header": len_header,
+ "len_tag": len_tag,
+ "body": data[body_start:body_end],
+ "raw": data[offset : offset + len_tag],
+ })
+ offset += len_tag
+ return sections, file_len
+
+
+def decode_section_body(tag, body):
+ """Return a human-readable string for a section's body."""
+ try:
+ if tag == "PPTH":
+ lp = u32be(body, 0)
+ raw = body[4 : 4 + lp - 2]
+ return f"path: {raw.decode('utf-16-be', errors='replace')}"
+ if tag == "PVBR":
+ unk = u32be(body, 0)
+ entries = [u32be(body, 4 + i * 4) for i in range(min(6, 400))]
+ return f"unknown={unk:#010x} seek[0..5]={entries}"
+ if tag == "PQTZ":
+ count = u32be(body, 8)
+ beats = []
+ for i in range(min(count, 4)):
+ bn = u16be(body, 12 + i * 8)
+ t = u16be(body, 14 + i * 8)
+ ms = u32be(body, 16 + i * 8)
+ beats.append(f" beat#{i}: num={bn} bpm={t/100:.2f} t={ms}ms")
+ return (f"beat_count={count}" +
+ (" (showing first 4)" if count > 4 else "") +
+ ("\n" + "\n".join(beats) if beats else ""))
+ if tag == "PQT2":
+ const = u32be(body, 4)
+ ec = u32be(body, 28)
+ fb_ms = u32be(body, 16)
+ lb_ms = u32be(body, 24)
+ bpm = u16be(body, 14) / 100
+ vals = [u16be(body, 36 + i * 2) for i in range(min(ec, 8))]
+ return (f"const={const:#010x} entry_count={ec} bpm={bpm:.2f}\n"
+ f" first_beat_ms={fb_ms} last_beat_ms={lb_ms}\n"
+ f" body u16[0..7]={vals}")
+ if tag in ("PWAV", "PWV2", "PWV3", "PWV4", "PWV5", "PWV6", "PWV7"):
+ bpe_map = {"PWAV": None, "PWV2": None, "PWV3": 1, "PWV4": 6, "PWV5": 2, "PWV6": 3, "PWV7": 3}
+ bpe = bpe_map[tag] or u32be(body, 0)
+ num = u32be(body, 4)
+ const = u32be(body, 8)
+ return (f"bytes_per_entry={bpe} num_entries={num} const={const:#010x}"
+ f" data_size={num * bpe}")
+ if tag == "PWVC":
+ v1, v2, v3 = u16be(body, 2), u16be(body, 4), u16be(body, 6)
+ return f"calibration values: {v1} {v2} {v3}"
+ if tag == "PCOB":
+ slot = u32be(body, 0)
+ nc = u16be(body, 6)
+ sentinel = u32be(body, 8)
+ return f"slot={'hot_cues' if slot==1 else 'memory_cues'} num_cues={nc} sentinel={sentinel:#010x}"
+ if tag == "PCO2":
+ slot = u32be(body, 0)
+ nc = u16be(body, 4)
+ return f"slot={'hot_cues' if slot==1 else 'memory_cues'} num_cues={nc}"
+ except Exception as e:
+ return f"(decode error: {e})"
+ return ""
+
+
+def find_anlz(root, ext=".DAT"):
+ """Walk root directory, return path to first matching ANLZ file."""
+ for dirpath, _, files in os.walk(root):
+ for f in files:
+ if f.upper() == f"ANLZ0000{ext.upper()}":
+ return os.path.join(dirpath, f)
+ return None
+
+
+# ── PDB ──────────────────────────────────────────────────────────────────────
+
+PAGE_SIZE = 4096
+TABLE_NAMES = {
+ 0: "Tracks", 1: "Genres", 2: "Artists", 3: "Albums", 4: "Labels",
+ 5: "Keys", 6: "Colors", 7: "PlaylistTree", 8: "PlaylistEntries",
+ 9: "Unknown9", 10: "Unknown10", 11: "HistoryPlaylists",
+ 12: "HistoryEntries", 13: "Artwork", 14: "Unknown14", 15: "Unknown15",
+ 16: "Columns", 17: "Unknown17", 18: "Unknown18", 19: "History",
+}
+
+# Named fields in the 94-byte track row header (offset, size, name)
+TRACK_HEADER_FIELDS = [
+ (0, 2, "u16LE", "Unnamed0 (expect 0x0024)"),
+ (2, 2, "u16LE", "IndexShift"),
+ (4, 4, "u32LE", "Bitmask (expect 0x000C0700)"),
+ (8, 4, "u32LE", "SampleRate"),
+ (12, 4, "u32LE", "ComposerId"),
+ (16, 4, "u32LE", "FileSize"),
+ (20, 4, "u32LE", "Checksum ← unknown: CRC? always 0?"),
+ (24, 2, "u16LE", "Unnamed7 (expect 0x758A) ← unknown"),
+ (26, 2, "u16LE", "Unnamed8 (expect 0x57A2) ← unknown"),
+ (28, 4, "u32LE", "ArtworkId"),
+ (32, 4, "u32LE", "KeyId"),
+ (36, 4, "u32LE", "OriginalArtistId"),
+ (40, 4, "u32LE", "LabelId"),
+ (44, 4, "u32LE", "RemixerId"),
+ (48, 4, "u32LE", "Bitrate"),
+ (52, 4, "u32LE", "TrackNumber"),
+ (56, 4, "u32LE", "Tempo (BPM × 100)"),
+ (60, 4, "u32LE", "GenreId"),
+ (64, 4, "u32LE", "AlbumId"),
+ (68, 4, "u32LE", "ArtistId"),
+ (72, 4, "u32LE", "Id"),
+ (76, 2, "u16LE", "DiscNumber"),
+ (78, 2, "u16LE", "PlayCount"),
+ (80, 2, "u16LE", "Year"),
+ (82, 2, "u16LE", "SampleDepth"),
+ (84, 2, "u16LE", "Duration (seconds)"),
+ (86, 2, "u16LE", "Unnamed26 (expect 0x0029) ← unknown"),
+ (88, 1, "u8", "ColorId"),
+ (89, 1, "u8", "Rating (0/51/102/153/204/255)"),
+ (90, 2, "u16LE", "FileType (1=mp3 4=aac 5=flac 11=wav)"),
+ (92, 2, "u16LE", "Unnamed30 (expect 0x0003) ← unknown"),
+]
+
+STRING_SLOTS = [
+ "ISRC", "Composer", "KeyAnalyzed(num1)", "PhraseAnalyzed(num2)",
+ "UnknownStr4", "Message", "KuvoPublic", "AutoloadHotcues",
+ "UnknownStr5", "UnknownStr6", "DateAdded", "ReleaseDate",
+ "MixName", "UnknownStr7", "AnalyzePath", "AnalyzeDate",
+ "Comment", "Title", "UnknownStr8", "Filename", "FilePath",
+]
+
+
+def read_devicesql_string(data, off):
+ """Decode a DeviceSQL string at the given absolute offset."""
+ if off >= len(data):
+ return "(out of bounds)"
+ b0 = data[off]
+ if b0 & 1: # short ASCII: header = ((len+1)<<1)|1
+ length = (b0 >> 1) - 1
+ return data[off + 1 : off + 1 + length].decode("ascii", errors="replace")
+ elif b0 == 0x40: # long ASCII
+ total = u16le(data, off + 1)
+ length = total - 4
+ return data[off + 4 : off + 4 + length].decode("ascii", errors="replace")
+ elif b0 == 0x90: # UTF-16LE or ISRC
+ total = u16le(data, off + 1)
+ b3 = data[off + 3]
+ if b3 == 0x03: # ISRC variant
+ length = total - 6
+ return data[off + 5 : off + 5 + length].decode("ascii", errors="replace")
+ else:
+ length = total - 4
+ return data[off + 4 : off + 4 + length].decode("utf-16-le", errors="replace")
+ return f"(unknown string type 0x{b0:02x})"
+
+
+def parse_pdb_header(data):
+ """Parse page 0 file header. Returns (num_tables, tables) list."""
+ if len(data) < PAGE_SIZE:
+ raise ValueError("File too small to be a PDB")
+ num_tables = u32le(data, 8)
+ next_unused = u32le(data, 12)
+ sequence = u32le(data, 20)
+ tables = []
+ for i in range(num_tables):
+ off = 28 + i * 16
+ tables.append({
+ "type": u32le(data, off),
+ "empty_candidate": u32le(data, off + 4),
+ "first_page": u32le(data, off + 8),
+ "last_page": u32le(data, off + 12),
+ })
+ return num_tables, next_unused, sequence, tables
+
+
+def iter_table_rows(data, first_page, table_type):
+ """Yield raw row bytes for every row in a table's page chain."""
+ PAGE_HEADER = 32
+ DATA_HEADER = 8
+ HEAP_OFFSET = PAGE_HEADER + DATA_HEADER # 40
+ ROWSET_SIZE = 36
+ MAX_PER_ROWSET = 16
+
+ visited = set()
+ page_idx = first_page
+ while True:
+ if page_idx in visited or page_idx == 0x03FFFFFF or page_idx == 0:
+ break
+ visited.add(page_idx)
+ page_off = page_idx * PAGE_SIZE
+ if page_off + PAGE_SIZE > len(data):
+ break
+ page = data[page_off : page_off + PAGE_SIZE]
+
+ flags = page[27]
+ if flags == 0x64: # index page — skip
+ next_pg = u32le(page, 12)
+ page_idx = next_pg
+ continue
+
+ num_rows = page[24]
+ next_page = u32le(page, 12)
+
+ # RowSets grow backwards from end of page
+ num_rowsets = (num_rows + MAX_PER_ROWSET - 1) // MAX_PER_ROWSET
+ for rs_i in range(num_rowsets):
+ rs_off = PAGE_SIZE - (rs_i + 1) * ROWSET_SIZE
+ # positions are reversed: pos[15] first, pos[0] last
+ positions = []
+ for j in range(MAX_PER_ROWSET):
+ pos = u16le(page, rs_off + (MAX_PER_ROWSET - 1 - j) * 2)
+ positions.append(pos)
+ active = u16le(page, rs_off + MAX_PER_ROWSET * 2)
+ for bit in range(MAX_PER_ROWSET):
+ if active & (1 << bit):
+ row_heap_off = HEAP_OFFSET + positions[bit]
+ if row_heap_off < PAGE_SIZE:
+ yield page[row_heap_off:], page_off + row_heap_off
+
+ page_idx = next_page
+
+
+def decode_track_row(row_data, abs_row_off, full_pdb):
+ """Parse a track row and return dict of named fields + strings."""
+ if len(row_data) < 136:
+ return None
+ result = {"_raw_header": row_data[:94]}
+ for off, size, fmt, name in TRACK_HEADER_FIELDS:
+ if fmt == "u32LE":
+ result[name] = u32le(row_data, off)
+ elif fmt == "u16LE":
+ result[name] = u16le(row_data, off)
+ elif fmt == "u8":
+ result[name] = u8(row_data, off)
+
+ # String offsets (21 × u16LE at bytes 94–135, absolute into full_pdb)
+ # The offset stored is relative to the start of the row in the file
+ strings = {}
+ for i, slot in enumerate(STRING_SLOTS):
+ str_off = u16le(row_data, 94 + i * 2)
+ abs_str_off = abs_row_off + str_off
+ strings[slot] = read_devicesql_string(full_pdb, abs_str_off)
+ result["_strings"] = strings
+ return result
+
+
+def decode_key_row(row_data):
+ if len(row_data) < 8:
+ return None
+ small_id = u16le(row_data, 0)
+ pk_id = u32le(row_data, 4)
+ name = read_devicesql_string(row_data, 8)
+ return {"SmallId": small_id, "Id": pk_id, "Name": name}
+
+
+def decode_artist_row(row_data):
+ if len(row_data) < 10:
+ return None
+ pk_id = u32le(row_data, 4)
+ name = read_devicesql_string(row_data, 10)
+ return {"Id": pk_id, "Name": name}
+
+
+def decode_genre_row(row_data):
+ if len(row_data) < 10:
+ return None
+ pk_id = u32le(row_data, 4)
+ name = read_devicesql_string(row_data, 10)
+ return {"Id": pk_id, "Name": name}
+
+
+def decode_album_row(row_data):
+ if len(row_data) < 22:
+ return None
+ artist_id = u32le(row_data, 8)
+ pk_id = u32le(row_data, 12)
+ name = read_devicesql_string(row_data, 22)
+ return {"Id": pk_id, "ArtistId": artist_id, "Name": name}
diff --git a/reverse-engineering/scripts/anlz-diff.py b/reverse-engineering/scripts/anlz-diff.py
new file mode 100755
index 00000000..de20a72f
--- /dev/null
+++ b/reverse-engineering/scripts/anlz-diff.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""
+anlz-diff.py — Diff two ANLZ files section by section (Python complement to
+ scripts/anlz-diff.js which handles PCOB/PCO2 detail).
+
+This script focuses on waveform sections and beatgrid math — areas the JS
+tool doesn't decode. For cue point detail use the JS tool.
+
+Usage:
+ python3 anlz-diff.py A/ANLZ0000.DAT B/ANLZ0000.DAT
+ python3 anlz-diff.py captures/20-gain-default captures/21-gain-plus6db
+ (auto-finds first ANLZ0000.DAT inside each folder)
+ python3 anlz-diff.py captures/20-gain-default captures/21-gain-plus6db --ext .EXT
+ python3 anlz-diff.py A.DAT B.DAT --section PQT2
+ python3 anlz-diff.py A.DAT B.DAT --all-sections # include identical sections
+"""
+
+import sys
+import os
+import argparse
+
+sys.path.insert(0, os.path.dirname(__file__))
+from _lib import parse_anlz, decode_section_body, hexlines, hexdiff_row, KNOWN_SECTIONS, find_anlz
+
+
+def diff_sections(secs_a, secs_b, limit, section_filter=None, show_all=False):
+ map_a = {}
+ map_b = {}
+ # Use list of (tag, instance_index) to handle duplicate tags (PCOB appears twice)
+ tags_ordered = []
+ seen = {}
+ for s in secs_a:
+ i = seen.get(s["tag"], 0)
+ map_a[(s["tag"], i)] = s
+ seen[s["tag"]] = i + 1
+ tags_ordered.append((s["tag"], i))
+ seen = {}
+ for s in secs_b:
+ i = seen.get(s["tag"], 0)
+ map_b[(s["tag"], i)] = s
+ seen[s["tag"]] = i + 1
+ if (s["tag"], i) not in tags_ordered:
+ tags_ordered.append((s["tag"], i))
+
+ found_diff = False
+ for key in tags_ordered:
+ tag, idx = key
+ label = tag if idx == 0 else f"{tag}#{idx}"
+ if section_filter and tag != section_filter:
+ continue
+
+ a = map_a.get(key)
+ b = map_b.get(key)
+
+ if a is None:
+ found_diff = True
+ print(f"\n[{label}] ADDED in B ({KNOWN_SECTIONS.get(tag, 'unknown')})")
+ print(hexlines(b["body"], limit=limit))
+ continue
+ if b is None:
+ found_diff = True
+ print(f"\n[{label}] REMOVED in B ({KNOWN_SECTIONS.get(tag, 'unknown')})")
+ print(hexlines(a["body"], limit=limit))
+ continue
+ if a["raw"] == b["raw"]:
+ if show_all:
+ print(f"[{label}] identical ({a['len_tag']} bytes)")
+ continue
+
+ found_diff = True
+ raw_a, raw_b = a["raw"], b["raw"]
+ changed = [i for i in range(max(len(raw_a), len(raw_b)))
+ if (raw_a[i] if i < len(raw_a) else None) != (raw_b[i] if i < len(raw_b) else None)]
+
+ print(f"\n[{label}] CHANGED ({KNOWN_SECTIONS.get(tag, 'unknown')})")
+ print(f" A: {a['len_tag']} bytes B: {b['len_tag']} bytes")
+ print(f" {len(changed)} byte(s) differ at section-relative offsets: "
+ f"{changed[:40]}{'...' if len(changed) > 40 else ''}")
+
+ dec_a = decode_section_body(tag, a["body"])
+ dec_b = decode_section_body(tag, b["body"])
+ if dec_a or dec_b:
+ if dec_a != dec_b:
+ print(f" A: {dec_a.replace(chr(10), chr(10)+' ')}")
+ print(f" B: {dec_b.replace(chr(10), chr(10)+' ')}")
+
+ rows_shown = sorted({(off // 16) * 16 for off in changed})
+ for row in rows_shown:
+ ca = raw_a[row : row + 16] if row < len(raw_a) else b""
+ cb = raw_b[row : row + 16] if row < len(raw_b) else b""
+ print(hexdiff_row(row, ca, cb))
+
+ if not found_diff:
+ print("No differences found between the two ANLZ files.")
+
+
+def main():
+ ap = argparse.ArgumentParser(
+ description="Diff two ANLZ files section by section (waveform/beatgrid focus)")
+ ap.add_argument("a", help="First ANLZ file or capture folder")
+ ap.add_argument("b", help="Second ANLZ file or capture folder")
+ ap.add_argument("--ext", default=".DAT",
+ help="Extension to search when paths are folders (.DAT/.EXT/.2EX)")
+ ap.add_argument("--section", "-s", help="Only compare this section tag (e.g. PQT2)")
+ ap.add_argument("--hex-limit", "-l", type=int, default=256,
+ help="Max bytes to show per diff row (0 = unlimited)")
+ ap.add_argument("--all-sections", "-a", action="store_true",
+ help="Also print identical sections")
+ args = ap.parse_args()
+
+ path_a, path_b = args.a, args.b
+ if os.path.isdir(path_a):
+ path_a = find_anlz(path_a, args.ext)
+ if not path_a:
+ sys.exit(f"No ANLZ0000{args.ext} found under {args.a}")
+ if os.path.isdir(path_b):
+ path_b = find_anlz(path_b, args.ext)
+ if not path_b:
+ sys.exit(f"No ANLZ0000{args.ext} found under {args.b}")
+
+ data_a = open(path_a, "rb").read()
+ data_b = open(path_b, "rb").read()
+ secs_a, _ = parse_anlz(data_a)
+ secs_b, _ = parse_anlz(data_b)
+
+ print(f"A: {path_a} ({len(data_a)} bytes)")
+ print(f"B: {path_b} ({len(data_b)} bytes)")
+ print(f"A sections: {' '.join(s['tag'] for s in secs_a)}")
+ print(f"B sections: {' '.join(s['tag'] for s in secs_b)}")
+
+ limit = None if args.hex_limit == 0 else args.hex_limit
+ diff_sections(secs_a, secs_b, limit=limit,
+ section_filter=args.section, show_all=args.all_sections)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/reverse-engineering/scripts/anlz-dump.py b/reverse-engineering/scripts/anlz-dump.py
new file mode 100755
index 00000000..392a27f7
--- /dev/null
+++ b/reverse-engineering/scripts/anlz-dump.py
@@ -0,0 +1,72 @@
+#!/usr/bin/env python3
+"""
+anlz-dump.py — Parse and display every section of a PMAI ANLZ file.
+
+Companion to scripts/anlz-diff.js (which focuses on PCOB/PCO2 cue decoding).
+This script focuses on waveform headers, beatgrid math, and raw hex inspection.
+
+Usage:
+ python3 anlz-dump.py ANLZ0000.DAT
+ python3 anlz-dump.py ANLZ0000.EXT --section PQT2
+ python3 anlz-dump.py ANLZ0000.DAT --hex-limit 0 # full hex
+ python3 anlz-dump.py ANLZ0000.2EX --section PWV7 --raw # raw section bytes
+"""
+
+import sys
+import os
+import argparse
+
+sys.path.insert(0, os.path.dirname(__file__))
+from _lib import parse_anlz, decode_section_body, hexlines, KNOWN_SECTIONS
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Dump PMAI ANLZ file sections")
+ ap.add_argument("file", help="ANLZ0000.DAT / .EXT / .2EX")
+ ap.add_argument("--section", "-s", help="Only show this tag (e.g. PQT2)")
+ ap.add_argument("--hex-limit", "-l", type=int, default=128,
+ help="Max body bytes to hex-dump per section (0 = unlimited)")
+ ap.add_argument("--raw", action="store_true",
+ help="Hex-dump full raw section bytes (incl. 12-byte common header)")
+ ap.add_argument("--list", action="store_true",
+ help="Only list section names and sizes, no hex")
+ args = ap.parse_args()
+
+ data = open(args.file, "rb").read()
+ sections, file_len = parse_anlz(data)
+
+ print(f"File : {args.file}")
+ print(f"Size : {len(data)} bytes (header says {file_len})")
+ print(f"Sections : {' → '.join(s['tag'] for s in sections)}")
+ print()
+
+ if args.list:
+ print(f"{'Tag':<6} {'Offset':>10} {'len_hdr':>9} {'len_tag':>9} {'body':>7} Description")
+ print("-" * 75)
+ for s in sections:
+ tag = s["tag"]
+ print(f"{tag:<6} {s['offset']:#10x} {s['len_header']:>9} {s['len_tag']:>9} "
+ f"{len(s['body']):>7} {KNOWN_SECTIONS.get(tag, 'unknown')}")
+ return
+
+ for s in sections:
+ tag = s["tag"]
+ if args.section and tag != args.section:
+ continue
+ desc = KNOWN_SECTIONS.get(tag, "unknown")
+ print(f"[{tag}] offset={s['offset']:#08x} len_header={s['len_header']} "
+ f"len_tag={s['len_tag']} body={len(s['body'])} bytes")
+ print(f" {desc}")
+ decoded = decode_section_body(tag, s["body"])
+ if decoded:
+ for line in decoded.splitlines():
+ print(f" {line}")
+ limit = None if args.hex_limit == 0 else args.hex_limit
+ payload = s["raw"] if args.raw else s["body"]
+ if payload:
+ print(hexlines(payload, limit=limit))
+ print()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/reverse-engineering/scripts/capture-diff.py b/reverse-engineering/scripts/capture-diff.py
new file mode 100755
index 00000000..8dfcf208
--- /dev/null
+++ b/reverse-engineering/scripts/capture-diff.py
@@ -0,0 +1,228 @@
+#!/usr/bin/env python3
+"""
+capture-diff.py — Umbrella diff of two complete capture folders.
+
+Runs all relevant diffs (ANLZ .DAT, .EXT, .2EX, PDB, all SETTING.DAT files)
+and prints a structured summary. Designed to produce output short enough to
+paste into a conversation without burning tokens on raw hex.
+
+Usage:
+ python3 capture-diff.py captures/20-gain-default captures/21-gain-plus6db
+ python3 capture-diff.py captures/20-gain-default captures/21-gain-plus6db --verbose
+ python3 capture-diff.py captures/20-gain-default captures/21-gain-plus6db --pdb-raw
+"""
+
+import sys
+import os
+import argparse
+import importlib.util
+
+sys.path.insert(0, os.path.dirname(__file__))
+from _lib import (
+ parse_anlz, decode_section_body, hexdiff_row, KNOWN_SECTIONS,
+ parse_pdb_header, iter_table_rows, decode_track_row,
+ TRACK_HEADER_FIELDS, STRING_SLOTS, find_anlz,
+)
+
+
+# ── helpers ───────────────────────────────────────────────────────────────────
+
+SETTING_FILES = ["MYSETTING.DAT", "MYSETTING2.DAT", "DEVSETTING.DAT"]
+
+
+def find_file(root, name):
+ for dirpath, _, files in os.walk(root):
+ for f in files:
+ if f.upper() == name.upper():
+ return os.path.join(dirpath, f)
+ return None
+
+
+def section_summary(tag, a, b):
+ """Return one-line summary of what changed in a section."""
+ dec_a = decode_section_body(tag, a["body"])
+ dec_b = decode_section_body(tag, b["body"])
+ raw_a, raw_b = a["raw"], b["raw"]
+ changed = sum(1 for i in range(max(len(raw_a), len(raw_b)))
+ if (raw_a[i] if i < len(raw_a) else None) != (raw_b[i] if i < len(raw_b) else None))
+ lines = [f" [{tag}] {changed} byte(s) changed ({KNOWN_SECTIONS.get(tag,'unknown')})"]
+ if dec_a and dec_a != dec_b:
+ lines.append(f" A: {dec_a.splitlines()[0]}")
+ lines.append(f" B: {dec_b.splitlines()[0]}")
+ return "\n".join(lines)
+
+
+def diff_anlz_file(path_a, path_b, ext, verbose):
+ data_a = open(path_a, "rb").read()
+ data_b = open(path_b, "rb").read()
+ secs_a, _ = parse_anlz(data_a)
+ secs_b, _ = parse_anlz(data_b)
+
+ map_a = {}
+ map_b = {}
+ seen = {}
+ for s in secs_a:
+ i = seen.get(s["tag"], 0)
+ map_a[(s["tag"], i)] = s
+ seen[s["tag"]] = i + 1
+ seen = {}
+ for s in secs_b:
+ i = seen.get(s["tag"], 0)
+ map_b[(s["tag"], i)] = s
+ seen[s["tag"]] = i + 1
+
+ all_keys = list(dict.fromkeys(list(map_a) + list(map_b)))
+ diffs = []
+ for key in all_keys:
+ tag, idx = key
+ a = map_a.get(key)
+ b = map_b.get(key)
+ if a is None:
+ diffs.append(f" [{tag}] ADDED in B")
+ elif b is None:
+ diffs.append(f" [{tag}] REMOVED in B")
+ elif a["raw"] != b["raw"]:
+ diffs.append(section_summary(tag, a, b))
+
+ print(f"\n ANLZ0000{ext} {'CHANGED' if diffs else 'identical'}")
+ if diffs:
+ for d in diffs:
+ print(d)
+ if verbose:
+ print(f" A: {path_a}")
+ print(f" B: {path_b}")
+ print(f" tip: python3 reverse-engineering/scripts/anlz-diff.py {path_a} {path_b} --ext {ext}")
+
+
+def diff_pdb(path_a, path_b, show_raw, verbose):
+ data_a = open(path_a, "rb").read()
+ data_b = open(path_b, "rb").read()
+ _, _, _, tables_a = parse_pdb_header(data_a)
+ _, _, _, tables_b = parse_pdb_header(data_b)
+
+ def load_tracks(data, tables):
+ tt = next((t for t in tables if t["type"] == 0), None)
+ if not tt:
+ return {}
+ out = {}
+ for row_data, abs_off in iter_table_rows(data, tt["first_page"], 0):
+ tr = decode_track_row(row_data, abs_off, data)
+ if tr:
+ out[tr["_strings"].get("Title") or f"id={tr.get('Id')}"] = tr
+ return out
+
+ tracks_a = load_tracks(data_a, tables_a)
+ tracks_b = load_tracks(data_b, tables_b)
+
+ changed_tracks = 0
+ for key in sorted(set(tracks_a) | set(tracks_b)):
+ a = tracks_a.get(key)
+ b = tracks_b.get(key)
+ if a is None or b is None:
+ print(f" PDB: track {key!r} {'only in B' if a is None else 'only in A'}")
+ continue
+ field_diffs = [(off, size, name, a.get(name), b.get(name))
+ for off, size, fmt, name in TRACK_HEADER_FIELDS
+ if a.get(name) != b.get(name)]
+ str_diffs = [(slot, a["_strings"].get(slot,""), b["_strings"].get(slot,""))
+ for slot in STRING_SLOTS
+ if a["_strings"].get(slot) != b["_strings"].get(slot)]
+ if not field_diffs and not str_diffs:
+ continue
+ changed_tracks += 1
+ print(f"\n PDB track {key!r}: {len(field_diffs)} header field(s) + {len(str_diffs)} string(s) changed")
+ for off, size, name, va, vb in field_diffs:
+ print(f" offset {off:>3} {name:<40} {va!r} → {vb!r}")
+ for slot, sa, sb in str_diffs:
+ print(f" string {slot:<38} {sa!r} → {sb!r}")
+ if show_raw:
+ raw_a = a["_raw_header"]
+ raw_b = b["_raw_header"]
+ for row in range(0, 94, 16):
+ ca, cb = raw_a[row:row+16], raw_b[row:row+16]
+ if ca != cb:
+ print(hexdiff_row(row, ca, cb))
+
+ if changed_tracks == 0:
+ print(" PDB track rows: identical")
+
+
+def diff_setting(path_a, path_b, filename):
+ data_a = open(path_a, "rb").read()
+ data_b = open(path_b, "rb").read()
+
+ def mask(d):
+ b = bytearray(d)
+ if len(b) > 7:
+ b[6] = 0
+ b[7] = 0
+ return bytes(b)
+
+ if mask(data_a) == mask(data_b):
+ print(f" {filename}: identical (CRC masked)")
+ return
+
+ changed = [i for i in range(max(len(data_a), len(data_b)))
+ if i not in (6, 7) and
+ (data_a[i] if i < len(data_a) else None) != (data_b[i] if i < len(data_b) else None)]
+ print(f" {filename}: {len(changed)} byte(s) changed at offsets {changed[:20]}"
+ f"{'...' if len(changed) > 20 else ''}")
+
+
+# ── main ──────────────────────────────────────────────────────────────────────
+
+def main():
+ ap = argparse.ArgumentParser(
+ description="Full diff of two capture folders (ANLZ + PDB + SETTING.DAT)")
+ ap.add_argument("a", help="First capture folder")
+ ap.add_argument("b", help="Second capture folder")
+ ap.add_argument("--verbose", "-v", action="store_true",
+ help="Print file paths and drill-down tips")
+ ap.add_argument("--pdb-raw", action="store_true",
+ help="Print raw header byte diffs for changed track rows")
+ args = ap.parse_args()
+
+ print(f"Comparing:")
+ print(f" A = {args.a}")
+ print(f" B = {args.b}")
+
+ # ── ANLZ files ────────────────────────────────────────────────────────────
+ print("\n=== ANLZ ===")
+ for ext in [".DAT", ".EXT", ".2EX"]:
+ pa = find_anlz(args.a, ext)
+ pb = find_anlz(args.b, ext)
+ if not pa and not pb:
+ continue
+ if not pa:
+ print(f" ANLZ0000{ext}: only in B ({pb})")
+ continue
+ if not pb:
+ print(f" ANLZ0000{ext}: only in A ({pa})")
+ continue
+ diff_anlz_file(pa, pb, ext, args.verbose)
+
+ # ── PDB ───────────────────────────────────────────────────────────────────
+ print("\n=== PDB ===")
+ pa = find_file(args.a, "export.pdb")
+ pb = find_file(args.b, "export.pdb")
+ if not pa or not pb:
+ print(f" export.pdb missing in {'A' if not pa else 'B'}")
+ else:
+ diff_pdb(pa, pb, args.pdb_raw, args.verbose)
+ if args.verbose:
+ print(f"\n tip: python3 reverse-engineering/scripts/pdb-diff.py {args.a} {args.b}")
+
+ # ── SETTING.DAT ───────────────────────────────────────────────────────────
+ print("\n=== SETTING.DAT ===")
+ for fname in SETTING_FILES:
+ pa = find_file(args.a, fname)
+ pb = find_file(args.b, fname)
+ if not pa or not pb:
+ continue
+ diff_setting(pa, pb, fname)
+ if args.verbose:
+ print(f"\n tip: python3 reverse-engineering/scripts/setting-diff.py {args.a} {args.b}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/reverse-engineering/scripts/pdb-diff.py b/reverse-engineering/scripts/pdb-diff.py
new file mode 100755
index 00000000..6088c437
--- /dev/null
+++ b/reverse-engineering/scripts/pdb-diff.py
@@ -0,0 +1,164 @@
+#!/usr/bin/env python3
+"""
+pdb-diff.py — Diff two export.pdb files at track-row field level.
+
+Identifies exactly which named fields changed between two captures.
+Most useful for gain/normalization reverse-engineering (series 20-25).
+
+Usage:
+ python3 pdb-diff.py captures/20-gain-default/export.pdb captures/21-gain-plus6db/export.pdb
+ python3 pdb-diff.py A/export.pdb B/export.pdb --match-by title
+ python3 pdb-diff.py captures/20-gain-default captures/21-gain-plus6db
+ (auto-finds export.pdb in each folder)
+ python3 pdb-diff.py A B --raw # also show raw byte diff of changed rows
+"""
+
+import sys
+import os
+import argparse
+
+sys.path.insert(0, os.path.dirname(__file__))
+from _lib import (
+ parse_pdb_header, iter_table_rows, decode_track_row,
+ TRACK_HEADER_FIELDS, STRING_SLOTS, TABLE_NAMES, hexlines, hexdiff_row,
+)
+
+
+def find_pdb(path):
+ if os.path.isfile(path):
+ return path
+ for root, _, files in os.walk(path):
+ for f in files:
+ if f.lower() == "export.pdb":
+ return os.path.join(root, f)
+ return None
+
+
+def load_tracks(data, tables):
+ """Return dict of title → decoded row for all tracks."""
+ track_table = next((t for t in tables if t["type"] == 0), None)
+ if not track_table:
+ return {}
+ result = {}
+ for row_data, abs_off in iter_table_rows(data, track_table["first_page"], 0):
+ tr = decode_track_row(row_data, abs_off, data)
+ if tr is None:
+ continue
+ key = tr["_strings"].get("Title") or f"id={tr.get('Id', '?')}"
+ result[key] = tr
+ return result
+
+
+def diff_track_row(title, a, b, show_raw):
+ print(f"\n── Track: {title!r} ─────────────────────────────────")
+
+ diffs = []
+ same = []
+
+ for off, size, fmt, name in TRACK_HEADER_FIELDS:
+ va = a.get(name)
+ vb = b.get(name)
+ if va != vb:
+ diffs.append((off, size, name, va, vb))
+ else:
+ same.append(name)
+
+ # String fields
+ str_diffs = []
+ for slot in STRING_SLOTS:
+ sa = a["_strings"].get(slot, "")
+ sb = b["_strings"].get(slot, "")
+ if sa != sb:
+ str_diffs.append((slot, sa, sb))
+
+ if not diffs and not str_diffs:
+ print(" (identical)")
+ return
+
+ print(f" {len(diffs)} header field(s) changed, "
+ f"{len(str_diffs)} string field(s) changed")
+ print(f" {len(same)} header field(s) unchanged")
+
+ if diffs:
+ print("\n Changed header fields:")
+ print(f" {'Off':>4} {'Field':<42} {'A':>12} {'B':>12}")
+ print(" " + "-" * 75)
+ for off, size, name, va, vb in diffs:
+ # Extra decode hints
+ hint = ""
+ if "Tempo" in name:
+ hint = f" ({va/100:.2f} → {vb/100:.2f} BPM)"
+ elif "Rating" in name:
+ hint = f" ({va//51}★ → {vb//51}★)"
+ print(f" {off:>4} {name:<42} {va!r:>12} {vb!r:>12}{hint}")
+
+ if str_diffs:
+ print("\n Changed string fields:")
+ for slot, sa, sb in str_diffs:
+ print(f" {slot:<25} A={sa!r}")
+ print(f" {'':25} B={sb!r}")
+
+ if show_raw:
+ print("\n Raw header diff (94 bytes):")
+ raw_a = a["_raw_header"]
+ raw_b = b["_raw_header"]
+ for row in range(0, 94, 16):
+ ca = raw_a[row : row + 16]
+ cb = raw_b[row : row + 16]
+ if ca != cb:
+ print(hexdiff_row(row, ca, cb))
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Diff PDB track rows between two captures")
+ ap.add_argument("a", help="First export.pdb or capture folder")
+ ap.add_argument("b", help="Second export.pdb or capture folder")
+ ap.add_argument("--raw", action="store_true",
+ help="Show raw header byte diff for changed tracks")
+ ap.add_argument("--match-by", choices=["title", "id", "filename"],
+ default="title",
+ help="Field to use to match tracks between files")
+ args = ap.parse_args()
+
+ path_a = find_pdb(args.a)
+ path_b = find_pdb(args.b)
+ if not path_a:
+ sys.exit(f"export.pdb not found under: {args.a}")
+ if not path_b:
+ sys.exit(f"export.pdb not found under: {args.b}")
+
+ data_a = open(path_a, "rb").read()
+ data_b = open(path_b, "rb").read()
+ _, _, _, tables_a = parse_pdb_header(data_a)
+ _, _, _, tables_b = parse_pdb_header(data_b)
+
+ tracks_a = load_tracks(data_a, tables_a)
+ tracks_b = load_tracks(data_b, tables_b)
+
+ print(f"A: {path_a} ({len(data_a)} bytes, {len(tracks_a)} tracks)")
+ print(f"B: {path_b} ({len(data_b)} bytes, {len(tracks_b)} tracks)")
+
+ all_keys = sorted(set(tracks_a) | set(tracks_b))
+ changed_count = 0
+
+ for key in all_keys:
+ a = tracks_a.get(key)
+ b = tracks_b.get(key)
+ if a is None:
+ print(f"\n── Track {key!r}: ONLY IN B")
+ continue
+ if b is None:
+ print(f"\n── Track {key!r}: ONLY IN A")
+ continue
+ if a["_raw_header"] != b["_raw_header"] or a["_strings"] != b["_strings"]:
+ changed_count += 1
+ diff_track_row(key, a, b, args.raw)
+
+ if changed_count == 0:
+ print("\nAll track rows are identical.")
+ else:
+ print(f"\n{changed_count} track row(s) changed.")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/reverse-engineering/scripts/pdb-dump.py b/reverse-engineering/scripts/pdb-dump.py
new file mode 100755
index 00000000..af85b124
--- /dev/null
+++ b/reverse-engineering/scripts/pdb-dump.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""
+pdb-dump.py — Dump the contents of a Rekordbox export.pdb file.
+
+Decodes track rows with all named fields and string heap values.
+Also lists Artists, Albums, Keys, Genres, Labels with their IDs.
+
+Usage:
+ python3 pdb-dump.py export.pdb
+ python3 pdb-dump.py export.pdb --table tracks
+ python3 pdb-dump.py export.pdb --table keys
+ python3 pdb-dump.py export.pdb --table tracks --id 1 # single track by pdb id
+ python3 pdb-dump.py export.pdb --raw-header # show raw 94-byte header bytes
+"""
+
+import sys
+import os
+import argparse
+
+sys.path.insert(0, os.path.dirname(__file__))
+from _lib import (
+ parse_pdb_header, iter_table_rows, decode_track_row, decode_key_row,
+ decode_artist_row, decode_album_row, decode_genre_row,
+ TRACK_HEADER_FIELDS, STRING_SLOTS, TABLE_NAMES, hexlines,
+)
+
+
+def dump_tracks(data, tables, target_id=None, raw_header=False):
+ track_table = next((t for t in tables if t["type"] == 0), None)
+ if not track_table or track_table["first_page"] == track_table["empty_candidate"]:
+ print("Tracks table: empty")
+ return
+
+ row_count = 0
+ for row_data, abs_off in iter_table_rows(data, track_table["first_page"], 0):
+ tr = decode_track_row(row_data, abs_off, data)
+ if tr is None:
+ continue
+ track_id = tr.get("Id", 0)
+ if target_id and track_id != target_id:
+ continue
+ row_count += 1
+
+ print(f"\n── Track id={track_id} ─────────────────────────────────")
+ if raw_header:
+ print(" Raw 94-byte header:")
+ print(hexlines(tr["_raw_header"], indent=" "))
+ print()
+
+ for off, size, fmt, name in TRACK_HEADER_FIELDS:
+ val = tr.get(name)
+ # Extra decoding for known fields
+ extra = ""
+ if "Tempo" in name and val:
+ extra = f" → {val / 100:.2f} BPM"
+ elif "Rating" in name:
+ stars = val // 51 if val else 0
+ extra = f" → {stars}★"
+ elif "FileType" in name:
+ ft = {1: "mp3", 4: "aac/m4a", 5: "flac", 11: "wav"}
+ extra = f" → {ft.get(val, 'unknown')}"
+ elif "Duration" in name:
+ extra = f" → {val}s = {val//60}:{val%60:02d}"
+ print(f" {off:>3} {name:<40} {val!r}{extra}")
+
+ print()
+ print(" String heap:")
+ for slot, val in tr["_strings"].items():
+ if val:
+ print(f" {slot:<25} {val!r}")
+
+ if row_count == 0:
+ print("(no matching track rows found)")
+ else:
+ print(f"\nTotal: {row_count} track row(s)")
+
+
+def dump_simple_table(data, tables, table_type, decoder, label):
+ tbl = next((t for t in tables if t["type"] == table_type), None)
+ if not tbl or tbl["first_page"] == tbl["empty_candidate"]:
+ print(f"{label} table: empty")
+ return
+ rows = []
+ for row_data, _ in iter_table_rows(data, tbl["first_page"], table_type):
+ r = decoder(row_data)
+ if r:
+ rows.append(r)
+ if not rows:
+ print(f"{label} table: no decodable rows")
+ return
+ print(f"{label} ({len(rows)} rows):")
+ for r in rows:
+ print(f" {r}")
+
+
+def dump_table_overview(tables):
+ print(f"{'Type':>5} {'Name':<22} {'first_pg':>9} {'last_pg':>8} {'empty_cand':>11}")
+ print("-" * 65)
+ for t in tables:
+ name = TABLE_NAMES.get(t["type"], f"Unknown{t['type']}")
+ has_data = "data" if t["first_page"] != t["empty_candidate"] else "empty"
+ print(f" {t['type']:>3} {name:<22} {t['first_page']:>9} {t['last_page']:>8}"
+ f" {t['empty_candidate']:>11} {has_data}")
+
+
+def main():
+ ap = argparse.ArgumentParser(description="Dump Rekordbox export.pdb contents")
+ ap.add_argument("file", help="export.pdb path")
+ ap.add_argument("--table", "-t",
+ choices=["all", "tracks", "artists", "albums", "keys", "genres", "labels"],
+ default="all", help="Which table to dump")
+ ap.add_argument("--id", type=int, help="Only show track with this PDB id")
+ ap.add_argument("--raw-header", action="store_true",
+ help="Print raw 94 header bytes for each track row")
+ args = ap.parse_args()
+
+ data = open(args.file, "rb").read()
+ num_tables, next_unused, sequence, tables = parse_pdb_header(data)
+
+ print(f"File : {args.file}")
+ print(f"Size : {len(data)} bytes ({len(data)//4096} pages)")
+ print(f"Tables : {num_tables}")
+ print(f"NextUnused : page {next_unused}")
+ print(f"Sequence : {sequence}")
+ print()
+ dump_table_overview(tables)
+ print()
+
+ t = args.table
+ if t in ("all", "tracks"):
+ dump_tracks(data, tables, target_id=args.id, raw_header=args.raw_header)
+ if t in ("all", "artists"):
+ dump_simple_table(data, tables, 2, decode_artist_row, "Artists")
+ if t in ("all", "albums"):
+ dump_simple_table(data, tables, 3, decode_album_row, "Albums")
+ if t in ("all", "keys"):
+ dump_simple_table(data, tables, 5, decode_key_row, "Keys")
+ if t in ("all", "genres"):
+ dump_simple_table(data, tables, 1, decode_genre_row, "Genres")
+ if t in ("all", "labels"):
+ dump_simple_table(data, tables, 4, decode_artist_row, "Labels")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/reverse-engineering/scripts/setting-diff.py b/reverse-engineering/scripts/setting-diff.py
new file mode 100755
index 00000000..8730f5fe
--- /dev/null
+++ b/reverse-engineering/scripts/setting-diff.py
@@ -0,0 +1,113 @@
+#!/usr/bin/env python3
+"""
+setting-diff.py — Diff PIONEER SETTING.DAT files, masking the CRC bytes.
+
+The CRC lives at bytes 6-7 of every SETTING.DAT and changes whenever any
+other byte changes, so it's always masked out before comparison.
+
+Usage:
+ python3 setting-diff.py captures/110-settings-default captures/111-settings-quantize-off
+ (auto-finds MYSETTING.DAT, MYSETTING2.DAT, DEVSETTING.DAT in each folder)
+ python3 setting-diff.py A/PIONEER/MYSETTING.DAT B/PIONEER/MYSETTING.DAT
+ python3 setting-diff.py A B --file DEVSETTING.DAT
+"""
+
+import sys
+import os
+import argparse
+
+sys.path.insert(0, os.path.dirname(__file__))
+from _lib import hexlines, hexdiff_row
+
+
+SETTING_FILES = ["MYSETTING.DAT", "MYSETTING2.DAT", "DEVSETTING.DAT"]
+CRC_OFFSET = 6 # bytes 6-7 are CRC-16/XMODEM — always ignore when comparing
+
+
+def find_setting_file(root, filename):
+ for dirpath, _, files in os.walk(root):
+ for f in files:
+ if f.upper() == filename.upper():
+ return os.path.join(dirpath, f)
+ return None
+
+
+def diff_dat(path_a, path_b, filename):
+ data_a = open(path_a, "rb").read()
+ data_b = open(path_b, "rb").read()
+
+ # Mask CRC at bytes 6-7
+ def mask(d):
+ b = bytearray(d)
+ if len(b) > 7:
+ b[6] = 0
+ b[7] = 0
+ return bytes(b)
+
+ ma = mask(data_a)
+ mb = mask(data_b)
+
+ print(f"\n── {filename} ─────────────────────────────────")
+ print(f" A: {path_a} ({len(data_a)} bytes)")
+ print(f" B: {path_b} ({len(data_b)} bytes)")
+
+ crc_a = int.from_bytes(data_a[6:8], "big")
+ crc_b = int.from_bytes(data_b[6:8], "big")
+ print(f" CRC A={crc_a:#06x} CRC B={crc_b:#06x} (masked in comparison)")
+
+ if ma == mb:
+ print(" (no differences beyond CRC)")
+ return
+
+ changed = [i for i in range(max(len(ma), len(mb)))
+ if (ma[i] if i < len(ma) else None) != (mb[i] if i < len(mb) else None)]
+ print(f" {len(changed)} byte(s) differ at offsets: {changed[:40]}"
+ f"{'...' if len(changed) > 40 else ''}")
+
+ rows = sorted({(off // 16) * 16 for off in changed})
+ for row in rows:
+ ca = data_a[row : row + 16] if row < len(data_a) else b""
+ cb = data_b[row : row + 16] if row < len(data_b) else b""
+ # Mark CRC bytes as not-changed even if they differ
+ note = " (includes CRC offset 6-7)" if row <= 6 < row + 16 else ""
+ print(hexdiff_row(row, ca, cb) + note)
+
+
+def main():
+ ap = argparse.ArgumentParser(
+ description="Diff SETTING.DAT files between two captures (CRC-masked)")
+ ap.add_argument("a", help="First capture folder or specific .DAT file")
+ ap.add_argument("b", help="Second capture folder or specific .DAT file")
+ ap.add_argument("--file", "-f", default=None,
+ help="Specific file to compare (MYSETTING.DAT / MYSETTING2.DAT / DEVSETTING.DAT)")
+ args = ap.parse_args()
+
+ # Direct file comparison
+ if os.path.isfile(args.a) and os.path.isfile(args.b):
+ filename = os.path.basename(args.a)
+ diff_dat(args.a, args.b, filename)
+ return
+
+ # Folder comparison — find all three files
+ target_files = [args.file] if args.file else SETTING_FILES
+ found_any = False
+ for filename in target_files:
+ pa = find_setting_file(args.a, filename)
+ pb = find_setting_file(args.b, filename)
+ if not pa and not pb:
+ continue
+ if not pa:
+ print(f"\n{filename}: only in B ({pb})")
+ continue
+ if not pb:
+ print(f"\n{filename}: only in A ({pa})")
+ continue
+ found_any = True
+ diff_dat(pa, pb, filename)
+
+ if not found_any:
+ print(f"No SETTING.DAT files found in {args.a} or {args.b}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/reverse-engineering/software_captures.md b/reverse-engineering/software_captures.md
new file mode 100644
index 00000000..6dc6d95c
--- /dev/null
+++ b/reverse-engineering/software_captures.md
@@ -0,0 +1,547 @@
+# Software Captures
+
+All captures in this file require only **Rekordbox 6.x** and a USB drive.
+No CDJ hardware is needed.
+
+Read `CAPTURE_GUIDE.md` first — it covers test-track setup, how to export to
+USB, the per-capture file checklist, and the diff workflow.
+
+Every capture folder goes into `captures//`.
+
+---
+
+## 00 — Baseline
+
+**Goal:** Minimum valid export. `test-tracks/track-normal.mp3`, no analysis,
+no cues, no artwork, no playlists.
+
+1. Create a fresh Rekordbox collection (File → Manage Library → Delete All if needed).
+2. Import `test-tracks/track-normal.mp3`.
+3. Do **not** run Beat/BPM analysis. Do **not** add any cues. Do **not** add artwork.
+4. Export to a freshly formatted USB.
+
+**Copy from USB:**
+
+```
+export.pdb
+PIONEER/USBANLZ//ANLZ0000.DAT
+PIONEER/USBANLZ//ANLZ0000.EXT
+PIONEER/MYSETTING.DAT
+PIONEER/MYSETTING2.DAT
+PIONEER/DEVSETTING.DAT
+```
+
+Save in `captures/00-baseline/`. Preserve subfolder structure.
+
+---
+
+## 01–05 — Waveforms
+
+These captures use the synthetic sine-wave tracks to confirm the frequency-band
+encoding in the colour waveform sections (PWV5, PWV7, PWV4).
+
+**For each capture 01–05:**
+
+1. Clear the collection.
+2. Import the specified track from `test-tracks/` (see table below).
+3. In Rekordbox Preferences → Analysis → **Track Analysis Setting**:
+ check **BPM / Grid** only. Uncheck KEY, Phrase, and Vocal.
+ Waveform data is generated automatically — there is no separate toggle.
+4. Right-click the track → **Analyze**.
+5. Export to USB.
+
+| Capture | Track to import |
+| -------------------------- | ---------------------------------- |
+| `01-waveform-silence/` | `test-tracks/track-silence.wav` |
+| `02-waveform-sine-bass/` | `test-tracks/track-sine-60hz.wav` |
+| `03-waveform-sine-mid/` | `test-tracks/track-sine-500hz.wav` |
+| `04-waveform-sine-treble/` | `test-tracks/track-sine-8khz.wav` |
+| `05-waveform-normal/` | `test-tracks/track-normal.mp3` |
+
+**Copy from USB for each:** `export.pdb` + full `PIONEER/USBANLZ/` tree.
+
+---
+
+## 10–13 — Beat Grid
+
+### 10 — Constant 160 BPM
+
+1. Import `test-tracks/track-160bpm.mp3`.
+2. Before analyzing, go to Preferences → Analysis → BPM Range and set it to
+ **145–200** (or any range that includes 160) so Rekordbox doesn't
+ half-tempo detect it as 80 BPM.
+3. Run full analysis.
+4. Open the Beat Grid editor. Confirm the BPM reads close to 160.
+ Note the exact BPM Rekordbox detected (write it down).
+5. Export to USB.
+
+Save in `captures/10-beatgrid-constant-160/`. Include a `notes.txt` with the
+exact detected BPM.
+
+### 11 — Constant 190 BPM
+
+1. Import `test-tracks/track-190bpm.mp3`.
+2. Before analyzing, go to Preferences → Analysis → BPM Range and set it to
+ **165–200** (or any range that includes 190) so Rekordbox doesn't
+ half-tempo detect it.
+3. Run full analysis.
+4. Open the Beat Grid editor. Confirm the BPM reads close to 190.
+ Note the exact detected BPM (write it down).
+5. Export to USB.
+
+Save in `captures/11-beatgrid-constant-190/`. Include `notes.txt` with the
+exact detected BPM.
+
+### 12 — Variable BPM
+
+1. Import `test-tracks/track-variable-bpm.mp3`.
+2. Run full analysis.
+3. Export to USB.
+4. Note the single BPM value Rekordbox detected (it will not show a range).
+
+Save in `captures/12-beatgrid-variable/`. Include `notes.txt` with the detected BPM.
+
+### 13 — Beatgrid Offset
+
+1. Import `test-tracks/track-160bpm.mp3`.
+2. Run full analysis.
+3. Open the Beat Grid editor → click the single-step **move right** arrow (►) once
+ to shift the grid forward by one step.
+4. Export to USB.
+
+Save in `captures/13-beatgrid-offset/`. In `notes.txt` record how many times
+you clicked and in which direction.
+
+---
+
+## 20–25 — Gain / Loudness ← most important section
+
+The location of gain data in the binary is completely unknown. These captures
+are designed to isolate every candidate field through diffing.
+
+**Use `test-tracks/track-normal.mp3` for all gain captures.** The audio content
+must be identical across all six so that waveform and beatgrid data stays constant.
+
+### 20 — Gain Default
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Open the Beat Grid editor for the track. The **Auto Gain** value is shown there.
+3. Note the displayed gain value. Do not change it.
+4. Export to USB.
+
+Save in `captures/20-gain-default/`. Record the displayed gain value in `notes.txt`.
+
+### 21 — Gain +6 dB
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Open the Beat Grid editor. Adjust the **Auto Gain** to `+6.1 dB`
+ (Rekordbox does not allow exact +6 dB; +6.1 dB is the closest available step).
+3. Export to USB.
+
+Save in `captures/21-gain-6.1db/`.
+
+### 22 — Gain −6 dB
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Open the Beat Grid editor. Adjust the **Auto Gain** to `−6 dB`.
+3. Export to USB.
+
+Save in `captures/22-gain-minus6db/`.
+
+### 23 — Gain 0 dB (explicit)
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Open the Beat Grid editor. Adjust the **Auto Gain** to exactly `0 dB`.
+3. Export to USB.
+
+Save in `captures/23-gain-zero/`.
+
+**Diff strategy:** Start with `diff(20-gain-default, 21-gain-plus6db)` on the
+`export.pdb`. Any byte that changes is a gain candidate. Then diff the ANLZ
+files to check whether gain is also stored there.
+
+---
+
+## 30–32 — Key
+
+### 30 — C Major
+
+1. Import `test-tracks/track-normal.mp3`.
+2. In the track Properties panel, manually set **Key** to `C`.
+3. Export to USB.
+
+Save in `captures/30-key-c-major/`.
+
+### 31 — A Minor
+
+1. Import `test-tracks/track-normal.mp3`.
+2. In the track Properties panel, manually set **Key** to `Am`.
+3. Export to USB.
+
+Save in `captures/31-key-a-minor/`.
+
+### 32 — All 12 Keys
+
+**Prerequisite:** generate the 8 extra key copies listed in the Setup section
+of `CAPTURE_GUIDE.md` (`track-key-d.mp3` through `track-key-fm.mp3`) before
+starting.
+
+1. Import all 12 files listed in the table below.
+2. Assign keys exactly as shown — one key per file.
+3. Export all 12 tracks to USB.
+4. Copy `notes.txt` from the table into `captures/32-key-all-12/notes.txt`.
+
+| File | Key to assign |
+| ------------------------------------ | ------------- |
+| `test-tracks/track-normal.mp3` | C |
+| `test-tracks/track-160bpm.mp3` | Cm |
+| `test-tracks/track-190bpm.mp3` | Db |
+| `test-tracks/track-variable-bpm.mp3` | Dbm |
+| `test-tracks/track-key-d.mp3` | D |
+| `test-tracks/track-key-dm.mp3` | Dm |
+| `test-tracks/track-key-eb.mp3` | Eb |
+| `test-tracks/track-key-ebm.mp3` | Ebm |
+| `test-tracks/track-key-e.mp3` | E |
+| `test-tracks/track-key-em.mp3` | Em |
+| `test-tracks/track-key-f.mp3` | F |
+| `test-tracks/track-key-fm.mp3` | Fm |
+
+Save in `captures/32-key-all-12/`. Include `notes.txt` recording which file
+received which key (copy the table above).
+
+---
+
+## 40–47 — Cue Points
+
+All cue captures use `test-tracks/track-normal.mp3`, fully analyzed. Clear the
+collection and re-import between each capture so cue data from a previous
+capture does not carry over.
+
+### 40 — Hot Cues A, B, C Only
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. In the Cue section, set **Hot Cue A** at 5 s, **B** at 10 s, **C** at 15 s.
+3. Do not set D–H or any memory cues.
+4. Export.
+
+### 41 — All 8 Hot Cues A–H
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Set A=5 s, B=10 s, C=15 s, D=20 s, E=25 s, F=30 s, G=35 s, H=40 s.
+3. Export.
+
+Record positions in `notes.txt`.
+
+### 42 — Memory Cues Only
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Set 3 **memory cues** at 5 s, 10 s, 15 s. No hot cues.
+3. Export.
+
+### 43 — All 8 Hot Cues, All 8 Colors
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Set hot cues A–H at 5 s, 10 s, 15 s, 20 s, 25 s, 30 s, 35 s, 40 s.
+3. Color them: A=red, B=orange, C=yellow, D=green, E=cyan, F=blue, G=violet, H=pink
+ (the exact Rekordbox color names — pick one color per slot using the palette picker).
+4. Export.
+5. In `notes.txt`: record which color name was assigned to which slot.
+
+### 44 — Labeled Cues (short labels)
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Set hot cues A, B, C with labels:
+ - A at 5 s = `Intro` (5 chars)
+ - B at 10 s = `Drop` (4 chars)
+ - C at 15 s = `Break` (5 chars)
+3. Export.
+
+### 45 — Labeled Cue (long label)
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Set hot cue A at 5 s with label `This is a very long label` (25 chars).
+3. Export.
+
+### 46 — Loop Cue
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Set **hot cue A as a loop**: position = 10 s, loop length = 4 beats
+ (use the Loop section → Set Loop, then assign to Hot Cue A).
+3. Export.
+4. In `notes.txt`: record loop start time (ms) and loop end time (ms) exactly
+ as displayed by Rekordbox.
+
+### 47 — Multiple Loops
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Set 4 loop hot cues:
+ - A = 1-beat loop at 5 s
+ - B = 2-beat loop at 10 s
+ - C = 4-beat loop at 15 s
+ - D = 8-beat loop at 20 s
+3. Export.
+4. Record all loop start and end times in ms in `notes.txt`.
+
+---
+
+## 50–62 — Track Metadata (PDB fields)
+
+All metadata captures use `test-tracks/track-normal.mp3` as the audio file.
+Clear the collection and re-import between each capture so metadata from a
+previous capture does not carry over.
+
+### 50 — Minimal (title only)
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set only the **Title** field to `Test Track`. Leave all other metadata blank.
+3. Export.
+
+### 51 — Full Metadata
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Fill every editable field:
+ - Title, Artist, Album, Genre, Label, Year, Track Number, Comment, ISRC,
+ Composer, Mix Name, Release Date, Rating (3 stars), Color tag.
+3. Export.
+
+### 52 — Single Genre
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set Genre to `Techno`. Leave all other metadata blank.
+3. Export.
+
+### 53 — Two Tracks, Two Different Genres
+
+1. Import `test-tracks/track-normal.mp3`. Set Genre = `Techno`. Leave all else blank.
+2. Import `test-tracks/track-160bpm.mp3`. Set Genre = `House`. Leave all else blank.
+3. Export both.
+
+### 54 — Label Set
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set Label to `Drumcode`. Leave genre and all other fields empty.
+3. Export.
+
+### 55 — Album with Artist
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set Artist = `Test Artist`, Album = `Test Album`. Leave all other fields empty.
+3. Export.
+
+### 56 — Comment Field
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set Comment = `This is a test comment with unicode: ñ é ü`. Leave all other fields empty.
+3. Export.
+
+### 57 — ISRC
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set ISRC = `USRC17607839`. Leave all other fields empty.
+3. Export.
+
+### 58 — Rating 1 Star
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set rating to 1 star. Leave all other fields empty.
+3. Export.
+
+### 59 — Rating 5 Stars
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set rating to 5 stars. Leave all other fields empty.
+3. Export.
+
+### 60 — Color Tag
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Apply a **Color** tag using the Rekordbox label color
+ (the colored dot shown in the track list — pink, red, orange, etc.).
+3. Export. Record which color was used in `notes.txt`.
+
+### 61 — Year
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set Year = `2024`. Leave all other fields empty.
+3. Export.
+
+### 62 — Track Number
+
+1. Import `test-tracks/track-normal.mp3`.
+2. Set Track Number = `7`. Leave all other fields empty.
+3. Export.
+
+---
+
+## 70–73 — PDB Track Row Unknown Fields
+
+These captures probe the constant-looking bytes in the track row binary.
+
+### 70 — Same Content, Four File Types
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis. Export → rename `export.pdb` to `pdb-mp3.bin`.
+2. Clear collection. Import `test-tracks/track-normal.flac`. Run full analysis. Export → `pdb-flac.bin`.
+3. Clear collection. Import `test-tracks/track-normal.wav`. Run full analysis. Export → `pdb-wav.bin`.
+4. Clear collection. Import `test-tracks/track-normal.m4a`. Run full analysis. Export → `pdb-m4a.bin`.
+
+Save all four in `captures/70-trackrow-bitmask/`.
+
+### 71 — Analyzed vs Unanalyzed
+
+1. Import `test-tracks/track-normal.mp3`. **Do not run analysis.** Export → `pdb-unanalyzed.bin`.
+2. Run full analysis on the same track (right-click → Analyze). Export → `pdb-analyzed.bin`.
+
+Save in `captures/71-trackrow-unnamed78/`.
+
+### 72 — Checksum Field
+
+1. Copy `test-tracks/track-normal.mp3` to `test-tracks/track-checksum-b.mp3`.
+2. Open `test-tracks/track-checksum-b.mp3` in a hex editor and change exactly
+ 1 byte somewhere in the audio payload (not the ID3 header).
+3. Import `test-tracks/track-normal.mp3`. Run full analysis. Export → `pdb-original.bin`.
+4. Clear collection. Import `test-tracks/track-checksum-b.mp3`. Run full analysis.
+ Export → `pdb-modified.bin`.
+5. Compare the two track rows in the PDB to find the checksum field.
+
+Save in `captures/72-trackrow-checksum/`.
+
+### 73 — Bitrate and Sample Depth
+
+1. Import `test-tracks/track-normal.mp3` (320 kbps). Run full analysis. Export → `pdb-320kbps.bin`.
+2. Clear collection. Import `test-tracks/track-normal-128kbps.mp3` (128 kbps). Run full analysis.
+ Export → `pdb-128kbps.bin`.
+3. Clear collection. Import `test-tracks/track-normal.wav` (44.1 kHz). Run full analysis.
+ Export → `pdb-44100.bin`.
+4. Clear collection. Import `test-tracks/track-normal-48khz.wav` (48 kHz). Run full analysis.
+ Export → `pdb-48000.bin`.
+
+Save all four in `captures/73-trackrow-unnamed26/`.
+
+---
+
+## 80–84 — Artwork
+
+### 80 — No Artwork (baseline)
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. Confirm no artwork is set in Properties.
+3. Export.
+
+### 81 — JPEG Artwork
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. In Properties, add `test-tracks/artwork.jpg` (500×500 JPEG).
+3. Export. Copy the entire `PIONEER/Artwork/` folder from the USB.
+
+### 82 — PNG Artwork
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. In Properties, replace the artwork with `test-tracks/artwork.png` (500×500 PNG).
+3. Export. Copy `PIONEER/Artwork/`.
+
+### 83 — Large Artwork
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+2. In Properties, add `test-tracks/artwork-large.jpg` (3000×3000 JPEG).
+3. Export. Note the file size stored on USB in `notes.txt`.
+
+### 84 — Two Tracks Sharing Artwork
+
+1. Import `test-tracks/track-normal.mp3`. Run full analysis.
+ In Properties, add `test-tracks/artwork.jpg`.
+2. Import `test-tracks/track-160bpm.mp3`. Run full analysis.
+ In Properties, add the exact same `test-tracks/artwork.jpg`.
+3. Export both. Check whether `PIONEER/Artwork/` has 1 or 2 files; record in `notes.txt`.
+
+---
+
+## 90–92 — Playlists
+
+### 90 — Flat Playlist
+
+1. Import `test-tracks/track-normal.mp3`, `test-tracks/track-160bpm.mp3`,
+ and `test-tracks/track-190bpm.mp3`. Run full analysis on all three.
+2. Create a playlist named `TestPlaylist`.
+3. Add all 3 tracks to it.
+4. Export the playlist to USB.
+
+### 91 — Nested Playlist (Folder)
+
+1. Import `test-tracks/track-normal.mp3`, `test-tracks/track-160bpm.mp3`,
+ `test-tracks/track-190bpm.mp3`, and `test-tracks/track-variable-bpm.mp3`.
+ Run full analysis on all four.
+2. Create a **folder** named `TestFolder`.
+3. Create 2 playlists inside it:
+ - `SubA`: add `track-normal.mp3` and `track-160bpm.mp3`
+ - `SubB`: add `track-190bpm.mp3` and `track-variable-bpm.mp3`
+4. Export `TestFolder` to USB.
+
+### 92 — Playlist Track Order
+
+1. Import `test-tracks/track-normal.mp3`, `test-tracks/track-160bpm.mp3`,
+ and `test-tracks/track-190bpm.mp3`. Run full analysis on all three.
+2. Create a playlist named `OrderTest`.
+3. Add them in this deliberate non-alphabetical order:
+ `track-190bpm.mp3` first, then `track-normal.mp3`, then `track-160bpm.mp3`.
+4. Export. Record the intended playback order in `notes.txt`.
+
+---
+
+## 100 — History Baseline (prerequisite for hardware capture 101)
+
+This capture prepares the USB that your friend will load into a CDJ for
+`hardware_captures.md` capture 101. Do this capture first, then hand the USB
+to your friend.
+
+1. Import `test-tracks/track-normal.mp3`, `test-tracks/track-160bpm.mp3`,
+ and `test-tracks/track-190bpm.mp3`. Run full analysis on all three.
+2. Export all three to USB. Do **not** load the USB into a CDJ.
+3. Copy `export.pdb` from the USB into `captures/100-history-empty/`.
+
+Hand the USB (do not eject again after copying — keep the filesystem intact)
+to your friend along with `hardware_captures.md`.
+
+---
+
+## 110–125 — SETTING.DAT Field Mapping
+
+**Strategy:** Start from `110-settings-default/`. Change exactly one setting,
+export, copy the three `.DAT` files. Diff against the default to find the byte
+that changed.
+
+Note: bytes 6–7 of every SETTING.DAT are the CRC — they change even if only
+one unrelated byte changes. **Always ignore bytes 6–7 when comparing.**
+
+### 110 — Default Settings
+
+1. In Rekordbox, go to Preferences → My Settings.
+2. Click **Restore Defaults** (or manually reset all settings to factory).
+3. Export to USB. Copy:
+ - `PIONEER/MYSETTING.DAT`
+ - `PIONEER/MYSETTING2.DAT`
+ - `PIONEER/DEVSETTING.DAT`
+
+Save in `captures/110-settings-default/`.
+
+### 111–125 — One Setting Each
+
+For each capture below, restore defaults first, change only the listed setting,
+then export. Copy only the three `.DAT` files (no audio or ANLZ needed).
+
+| Capture | Menu path in Rekordbox | Change |
+| ------------------------------------ | ------------------------------------- | --------- |
+| `111-settings-quantize-off/` | Preferences → My Settings → Quantize | OFF |
+| `112-settings-sync-off/` | My Settings → Sync | OFF |
+| `113-settings-jog-vinyl/` | My Settings → Jog Mode | Vinyl |
+| `114-settings-jog-cdj/` | My Settings → Jog Mode | CDJ |
+| `115-settings-needle-search-off/` | My Settings → Needle Search | OFF |
+| `116-settings-master-tempo-on/` | My Settings → Master Tempo | ON |
+| `117-settings-slip-on/` | My Settings → Slip | ON |
+| `118-settings-hotcue-autoload-off/` | My Settings → Hot Cue Auto Load | OFF |
+| `119-settings-beat-jump-1/` | My Settings → Beat Jump | 1 Beat |
+| `120-settings-beat-jump-32/` | My Settings → Beat Jump | 32 Beats |
+| `121-settings-loop-1/` | My Settings → Loop | 1 Beat |
+| `122-settings-loop-16/` | My Settings → Loop | 16 Beats |
+| `123-settings-track-end-warning-on/` | My Settings → Track End Warning | ON |
+| `124-settings-cue-play/` | My Settings → Cue/Play | Momentary |
+| `125-settings-display-waveform/` | My Settings → Display → Waveform Size | Large |
diff --git a/screenshot.png b/screenshot.png
index c1935768..c4e151b2 100644
Binary files a/screenshot.png and b/screenshot.png differ
diff --git a/scripts/anlz-diff.js b/scripts/anlz-diff.js
new file mode 100644
index 00000000..5977c6b7
--- /dev/null
+++ b/scripts/anlz-diff.js
@@ -0,0 +1,275 @@
+#!/usr/bin/env node
+/**
+ * anlz-diff.js — ANLZ file parser and hex-diff tool
+ *
+ * Usage:
+ * # Parse and pretty-print a single ANLZ file:
+ * node scripts/anlz-diff.js path/to/ANLZ0000.DAT
+ *
+ * # Compare native Rekordbox file against ours:
+ * node scripts/anlz-diff.js path/to/native/ANLZ0000.DAT path/to/ours/ANLZ0000.DAT
+ *
+ * Purpose: reverse-engineer the PCOB2 (memory cue) format for issue #208.
+ * Export a track with memory cues from Rekordbox to USB, then run:
+ * node scripts/anlz-diff.js /PIONEER/USBANLZ/Pxxx/xxxxxxxx/ANLZ0000.DAT /PIONEER/USBANLZ/Pxxx/xxxxxxxx/ANLZ0000.DAT
+ */
+
+import fs from 'fs';
+import path from 'path';
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+function hex(n, width = 8) {
+ return '0x' + n.toString(16).toUpperCase().padStart(width, '0');
+}
+
+function hexBytes(buf, start, len) {
+ return Array.from(buf.slice(start, start + len))
+ .map((b) => b.toString(16).padStart(2, '0').toUpperCase())
+ .join(' ');
+}
+
+function fourcc(buf, offset) {
+ return buf.slice(offset, offset + 4).toString('ascii');
+}
+
+// ── Section parser ────────────────────────────────────────────────────────────
+
+function parseSections(buf) {
+ const sections = [];
+ let pos = 28; // skip 28-byte PMAI header
+ while (pos + 12 <= buf.length) {
+ const tag = fourcc(buf, pos);
+ const lenHdr = buf.readUInt32BE(pos + 4);
+ const lenTag = buf.readUInt32BE(pos + 8);
+ if (lenTag === 0 || pos + lenTag > buf.length) break;
+ sections.push({ tag, lenHdr, lenTag, pos, buf: buf.slice(pos, pos + lenTag) });
+ pos += lenTag;
+ }
+ return sections;
+}
+
+// ── Section-specific decoders ─────────────────────────────────────────────────
+
+function decodePcob(sec) {
+ const b = sec.buf;
+ const type = b.readUInt32BE(12);
+ const numCues = b.readUInt16BE(18);
+ const memoryCount = b.readUInt32BE(20);
+
+ const lines = [
+ ` type = ${type} (${type === 1 ? 'hot_cues' : 'memory_cues'})`,
+ ` num_cues = ${numCues}`,
+ ` memory_count = ${hex(memoryCount)} (${memoryCount === 0xffffffff ? 'sentinel' : memoryCount})`,
+ ];
+
+ // Parse PCPT sub-tags
+ let off = 24;
+ for (let i = 0; i < numCues && off + 56 <= b.length; i++) {
+ const ptag = fourcc(b, off);
+ if (ptag !== 'PCPT') {
+ lines.push(` [entry ${i}] unexpected tag: ${ptag}`);
+ break;
+ }
+ const lenHdr = b.readUInt32BE(off + 4);
+ const lenTag = b.readUInt32BE(off + 8);
+ const hotCue = b.readUInt32BE(off + 12);
+ const status = b.readUInt32BE(off + 16);
+ const unk20 = b.readUInt32BE(off + 20);
+ const orderFirst = b.readUInt16BE(off + 24);
+ const orderLast = b.readUInt16BE(off + 26);
+ const cueType = b[off + 28];
+ const pad29 = b[off + 29];
+ const unk30 = b.readUInt16BE(off + 30);
+ const timeMs = b.readUInt32BE(off + 32);
+ const loopTime = b.readUInt32BE(off + 36);
+ const colorIdx = b[off + 40];
+ const rawHex = hexBytes(b, off, lenTag);
+
+ lines.push(` [PCPT entry ${i}]`);
+ lines.push(` tag = ${ptag}`);
+ lines.push(` len_header = ${lenHdr}`);
+ lines.push(` len_tag = ${lenTag}`);
+ lines.push(
+ ` hot_cue = ${hotCue} (${hotCue === 0 ? 'memory' : `hot ${String.fromCharCode(64 + hotCue)}`})`
+ );
+ lines.push(` status = ${status} (${statusName(status)})`);
+ lines.push(` unk[20-23] = ${hex(unk20)}`);
+ lines.push(` order_first = ${hex(orderFirst, 4)}`);
+ lines.push(` order_last = ${hex(orderLast, 4)}`);
+ lines.push(
+ ` type = ${cueType} (${cueType === 1 ? 'cue_point' : cueType === 2 ? 'loop' : 'unknown'})`
+ );
+ lines.push(` pad[29] = ${hex(pad29, 2)}`);
+ lines.push(` unk[30-31] = ${hex(unk30, 4)}`);
+ lines.push(` time_ms = ${timeMs} (${(timeMs / 1000).toFixed(3)}s)`);
+ lines.push(` loop_time = ${hex(loopTime)}`);
+ lines.push(` color_idx = ${colorIdx}`);
+ lines.push(` raw hex = ${rawHex}`);
+ off += lenTag;
+ }
+
+ return lines.join('\n');
+}
+
+function statusName(s) {
+ return s === 0 ? 'disabled' : s === 1 ? 'enabled' : s === 4 ? 'active_loop' : `unknown(${s})`;
+}
+
+function decodePco2(sec) {
+ const b = sec.buf;
+ const type = b.readUInt32BE(12);
+ const numCues = b.readUInt16BE(16);
+
+ const lines = [
+ ` type = ${type} (${type === 1 ? 'hot_cues' : 'memory_cues'})`,
+ ` num_cues = ${numCues}`,
+ ];
+
+ let off = 20;
+ for (let i = 0; i < numCues && off + 16 <= b.length; i++) {
+ const ptag = fourcc(b, off);
+ if (ptag !== 'PCP2') {
+ lines.push(` [entry ${i}] unexpected tag: ${ptag}`);
+ break;
+ }
+ const lenHdr = b.readUInt32BE(off + 4);
+ const lenTag = b.readUInt32BE(off + 8);
+ const hotCue = b.readUInt32BE(off + 12);
+ const cueType = b[off + 16];
+ const timeMs = b.readUInt32BE(off + 20);
+ const loopTime = b.readUInt32BE(off + 24);
+ const colorId = b[off + 28];
+ const rawHex = hexBytes(b, off, Math.min(lenTag, 64));
+
+ lines.push(` [PCP2 entry ${i}]`);
+ lines.push(
+ ` hot_cue = ${hotCue} (${hotCue === 0 ? 'memory' : `hot ${String.fromCharCode(64 + hotCue)}`})`
+ );
+ lines.push(
+ ` type = ${cueType} (${cueType === 1 ? 'cue_point' : cueType === 2 ? 'loop' : 'unknown'})`
+ );
+ lines.push(` time_ms = ${timeMs} (${(timeMs / 1000).toFixed(3)}s)`);
+ lines.push(` loop_time = ${hex(loopTime)}`);
+ lines.push(` color_id = ${colorId}`);
+ lines.push(` len_tag = ${lenTag}`);
+ const fullHex = hexBytes(b, off, lenTag);
+ lines.push(` full hex = ${fullHex}`);
+ off += lenTag;
+ }
+
+ return lines.join('\n');
+}
+
+// ── Print a single file ───────────────────────────────────────────────────────
+
+function printAnlz(filePath, label) {
+ const buf = fs.readFileSync(filePath);
+ const magic = fourcc(buf, 0);
+ console.log(`\n${'='.repeat(70)}`);
+ console.log(`${label}: ${path.basename(filePath)}`);
+ console.log(` file size : ${buf.length} bytes`);
+ console.log(` magic : ${magic}`);
+ if (magic !== 'PMAI') {
+ console.log(' WARNING: not a PMAI file!');
+ return;
+ }
+
+ const sections = parseSections(buf);
+ console.log(` sections : ${sections.map((s) => s.tag).join(', ')}\n`);
+
+ for (const sec of sections) {
+ console.log(`── ${sec.tag} pos=${sec.pos} len_hdr=${sec.lenHdr} len_tag=${sec.lenTag}`);
+ if (sec.tag === 'PCOB') {
+ console.log(decodePcob(sec));
+ } else if (sec.tag === 'PCO2') {
+ console.log(decodePco2(sec));
+ }
+ }
+}
+
+// ── Diff two files section by section ────────────────────────────────────────
+
+function diffAnlz(nativePath, oursPath) {
+ const nBuf = fs.readFileSync(nativePath);
+ const oBuf = fs.readFileSync(oursPath);
+
+ const nSecs = parseSections(nBuf);
+ const oSecs = parseSections(oBuf);
+
+ console.log('\n' + '='.repeat(70));
+ console.log('DIFF: native vs ours');
+ console.log(` native sections : ${nSecs.map((s) => s.tag).join(', ')}`);
+ console.log(` ours sections : ${oSecs.map((s) => s.tag).join(', ')}`);
+
+ const allTags = [...new Set([...nSecs.map((s) => s.tag), ...oSecs.map((s) => s.tag)])];
+
+ for (const tag of allTags) {
+ const nInstances = nSecs.filter((s) => s.tag === tag);
+ const oInstances = oSecs.filter((s) => s.tag === tag);
+
+ const count = Math.max(nInstances.length, oInstances.length);
+ for (let i = 0; i < count; i++) {
+ const n = nInstances[i];
+ const o = oInstances[i];
+
+ if (!n) {
+ console.log(`\n[${tag}#${i}] MISSING in native (only in ours)`);
+ continue;
+ }
+ if (!o) {
+ console.log(`\n[${tag}#${i}] MISSING in ours (only in native)`);
+ continue;
+ }
+
+ const same = n.buf.equals(o.buf);
+ console.log(
+ `\n[${tag}#${i}] native_len=${n.lenTag} ours_len=${o.lenTag} ${same ? '✓ IDENTICAL' : '✗ DIFFERS'}`
+ );
+
+ if (!same) {
+ // Show byte-level diff for PCOB and PCO2
+ if (tag === 'PCOB' || tag === 'PCO2') {
+ console.log(' NATIVE:');
+ console.log(tag === 'PCOB' ? decodePcob(n) : decodePco2(n));
+ console.log(' OURS:');
+ console.log(tag === 'PCOB' ? decodePcob(o) : decodePco2(o));
+ }
+
+ // First 128 differing bytes
+ const maxLen = Math.max(n.buf.length, o.buf.length);
+ const diffs = [];
+ for (let b = 0; b < maxLen && diffs.length < 32; b++) {
+ const nb = n.buf[b] ?? -1;
+ const ob = o.buf[b] ?? -1;
+ if (nb !== ob) {
+ diffs.push(` [+${b}] native=${hex(nb, 2)} ours=${hex(ob, 2)}`);
+ }
+ }
+ if (diffs.length > 0) {
+ console.log(` First ${diffs.length} byte differences:`);
+ console.log(diffs.join('\n'));
+ }
+ }
+ }
+ }
+}
+
+// ── Entry point ───────────────────────────────────────────────────────────────
+
+const args = process.argv.slice(2);
+
+if (args.length === 0) {
+ console.error('Usage:');
+ console.error(' node scripts/anlz-diff.js # parse single file');
+ console.error(' node scripts/anlz-diff.js # diff two files');
+ process.exit(1);
+}
+
+if (args.length === 1) {
+ printAnlz(args[0], 'FILE');
+} else {
+ printAnlz(args[0], 'NATIVE');
+ printAnlz(args[1], 'OURS ');
+ diffAnlz(args[0], args[1]);
+}
diff --git a/src/__tests__/anlzWriter.test.js b/src/__tests__/anlzWriter.test.js
index 4ec3e94b..65e2e7b6 100644
--- a/src/__tests__/anlzWriter.test.js
+++ b/src/__tests__/anlzWriter.test.js
@@ -25,7 +25,13 @@ vi.mock('fs', () => {
});
// Import after mocks
-import { writeAnlz, getAnlzFolder } from '../audio/anlzWriter.js';
+import {
+ writeAnlz,
+ getAnlzFolder,
+ buildPcobSections,
+ buildExtPcobSections,
+ buildPco2Sections,
+} from '../audio/anlzWriter.js';
import fs from 'fs';
// ── Helpers ───────────────────────────────────────────────────────────────────
@@ -433,3 +439,177 @@ describe('writeAnlz', () => {
expect(exBuf.readUInt16BE(pwvcPos + 18)).toBe(0x00c5);
});
});
+
+// ── buildPcobSections ─────────────────────────────────────────────────────────
+
+describe('buildPcobSections', () => {
+ const hotCue = { position_ms: 1000, color: '#ff0000', hot_cue_index: 0 }; // A
+ const memoryCue = { position_ms: 5000, color: '#00ff00', hot_cue_index: -1 };
+
+ it('returns empty stubs when cuePoints is empty', () => {
+ const [pcob1, pcob2] = buildPcobSections([]);
+ expect(pcob1.slice(0, 4).toString('ascii')).toBe('PCOB');
+ expect(pcob2.slice(0, 4).toString('ascii')).toBe('PCOB');
+ // Both empty: len_tag = 24 (header only, no entries)
+ expect(pcob1.readUInt32BE(8)).toBe(24);
+ expect(pcob2.readUInt32BE(8)).toBe(24);
+ });
+
+ it('PCOB1 type field = 1 (hot_cues slot)', () => {
+ const [pcob1] = buildPcobSections([hotCue]);
+ expect(pcob1.readUInt32BE(12)).toBe(1);
+ });
+
+ it('PCOB2 is always empty stub (memory cues go to PCO2 until PCOB2 format is confirmed)', () => {
+ // Non-empty PCOB2 causes Rekordbox to reject the file — see issue #208
+ const [, pcob2] = buildPcobSections([memoryCue]);
+ expect(pcob2.readUInt32BE(8)).toBe(24); // len_tag = 24 = header only
+ expect(pcob2.readUInt16BE(18)).toBe(0); // num_cues = 0
+ });
+
+ it('PCOB2 stays empty even when there are memory cues', () => {
+ const [, pcob2] = buildPcobSections([hotCue, memoryCue]);
+ expect(pcob2.readUInt32BE(8)).toBe(24);
+ });
+
+ it('PCPT entry for hot cue has status = 0 (native Rekordbox value)', () => {
+ // Verified by hex-diff of native Rekordbox USB export — KSY "disabled" label is misleading
+ const [pcob1] = buildPcobSections([hotCue]);
+ const pcptStart = 24; // first PCPT entry after 24-byte PCOB header
+ expect(pcob1.readUInt32BE(pcptStart + 16)).toBe(0);
+ });
+
+ it('PCPT entry for hot cue A has hot_cue = 1', () => {
+ const [pcob1] = buildPcobSections([hotCue]);
+ const pcptStart = 24;
+ expect(pcob1.readUInt32BE(pcptStart + 12)).toBe(1);
+ });
+
+ it('PCPT time_ms matches position_ms', () => {
+ const [pcob1] = buildPcobSections([hotCue]);
+ const pcptStart = 24;
+ expect(pcob1.readUInt32BE(pcptStart + 32)).toBe(1000);
+ });
+
+ it('PCOB1 len_tag = 24 + N×56 for N hot cues', () => {
+ const [pcob1] = buildPcobSections([hotCue, hotCue]);
+ expect(pcob1.readUInt32BE(8)).toBe(24 + 2 * 56);
+ });
+
+ it('memory cues are NOT placed in PCOB1', () => {
+ const [pcob1] = buildPcobSections([memoryCue]);
+ // No entries in PCOB1 since no hot cues
+ expect(pcob1.readUInt32BE(8)).toBe(24); // empty
+ });
+});
+
+// ── Pioneer color palette (hexToPioneerCode via PCPT / PCP2) ──────────────────
+
+describe('Pioneer color palette — PCPT color_code byte', () => {
+ const pcptStart = 24; // first PCPT entry after 24-byte PCOB header
+ const colorByteOffset = pcptStart + 40; // byte [40] of the PCPT entry
+
+ it('orange (#ff9900) → code 3 (confirmed by native Rekordbox hex-diff)', () => {
+ const [pcob1] = buildPcobSections([{ position_ms: 1000, color: '#ff9900', hot_cue_index: 0 }]);
+ expect(pcob1[colorByteOffset]).toBe(3);
+ });
+
+ it('cyan (#00b4d8) → code 6 (confirmed by native Rekordbox hex-diff)', () => {
+ const [pcob1] = buildPcobSections([{ position_ms: 1000, color: '#00b4d8', hot_cue_index: 0 }]);
+ expect(pcob1[colorByteOffset]).toBe(6);
+ });
+
+ it('unknown color hex → code 0 (no color / CDJ default)', () => {
+ const [pcob1] = buildPcobSections([{ position_ms: 1000, color: '#123456', hot_cue_index: 0 }]);
+ expect(pcob1[colorByteOffset]).toBe(0);
+ });
+
+ it('null/missing color → code 0', () => {
+ const [pcob1] = buildPcobSections([{ position_ms: 1000, color: null, hot_cue_index: 0 }]);
+ expect(pcob1[colorByteOffset]).toBe(0);
+ });
+});
+
+// ── buildExtPcobSections ──────────────────────────────────────────────────────
+
+describe('buildExtPcobSections', () => {
+ it('returns empty stubs when cuePoints is empty', () => {
+ const [ext1, ext2] = buildExtPcobSections([]);
+ expect(ext1.slice(0, 4).toString('ascii')).toBe('PCOB');
+ expect(ext1.readUInt32BE(8)).toBe(24);
+ expect(ext2.readUInt32BE(8)).toBe(24);
+ });
+
+ it('places hot_cue_index 3-7 (D-H) in EXT PCOB1', () => {
+ const cues = [
+ { position_ms: 1000, color: '#ff9900', hot_cue_index: 3 }, // D
+ { position_ms: 2000, color: '#00b4d8', hot_cue_index: 4 }, // E
+ ];
+ const [ext1] = buildExtPcobSections(cues);
+ expect(ext1.readUInt32BE(8)).toBe(24 + 2 * 56);
+ expect(ext1.readUInt16BE(18)).toBe(2); // num_cues
+ });
+
+ it('ignores cues with hot_cue_index 0-2 (A-C belong in DAT)', () => {
+ const datCues = [{ position_ms: 1000, color: '#ff9900', hot_cue_index: 0 }];
+ const [ext1] = buildExtPcobSections(datCues);
+ expect(ext1.readUInt32BE(8)).toBe(24); // empty — no D-H cues
+ });
+
+ it('EXT PCOB2 is always empty stub', () => {
+ const cues = [{ position_ms: 1000, color: '#ff9900', hot_cue_index: 3 }];
+ const [, ext2] = buildExtPcobSections(cues);
+ expect(ext2.readUInt32BE(8)).toBe(24);
+ });
+});
+
+// ── buildPco2Sections ─────────────────────────────────────────────────────────
+
+describe('buildPco2Sections', () => {
+ const hotCue = { position_ms: 1000, color: '#ff9900', label: 'Drop', hot_cue_index: 0 };
+ const memoryCue = { position_ms: 5000, color: '#00b4d8', label: '', hot_cue_index: -1 };
+
+ it('returns empty stubs when cuePoints is empty', () => {
+ const [pco2hot, pco2mem] = buildPco2Sections([]);
+ expect(pco2hot.slice(0, 4).toString('ascii')).toBe('PCO2');
+ expect(pco2mem.slice(0, 4).toString('ascii')).toBe('PCO2');
+ });
+
+ it('slot 1 type field = 1 (hot cues)', () => {
+ const [pco2hot] = buildPco2Sections([hotCue]);
+ expect(pco2hot.readUInt32BE(12)).toBe(1);
+ });
+
+ it('slot 2 type field = 0 (memory cues)', () => {
+ const [, pco2mem] = buildPco2Sections([memoryCue]);
+ expect(pco2mem.readUInt32BE(12)).toBe(0);
+ });
+
+ it('memory cues go to slot 2, not slot 1', () => {
+ const [pco2hot, pco2mem] = buildPco2Sections([memoryCue]);
+ expect(pco2hot.readUInt16BE(16)).toBe(0); // slot 1: 0 cues
+ expect(pco2mem.readUInt16BE(16)).toBe(1); // slot 2: 1 cue
+ });
+
+ it('PCP2 color_code for orange (#ff9900) = 0x23 (extended wheel code 35)', () => {
+ // PCP2 uses a ~64-step extended color wheel, NOT the PCPT 1-8 palette.
+ // code 35 (0x23) corresponds to orange on the wheel (hue ≈ 38°, Δ2° from #FF9900).
+ const cue = { position_ms: 1000, color: '#ff9900', label: '', hot_cue_index: 0 };
+ const [pco2hot] = buildPco2Sections([cue]);
+ // PCO2 header=20; PCP2 entry starts at 20.
+ // Inside PCP2 buf: colorOff = 44 + labelByteLen(0) = 44.
+ // Absolute offset in PCO2 buf: 20 + 44 = 64.
+ const colorOff = 20 + 44;
+ expect(pco2hot[colorOff]).toBe(0x23);
+ });
+
+ it('PCP2 RGB bytes use native Rekordbox wheel RGB (not raw hex) for known palette entry', () => {
+ // #ff9900 maps to wheel code 35 with native RGB (0xff, 0xa2, 0x00).
+ const cue = { position_ms: 1000, color: '#ff9900', label: '', hot_cue_index: 0 };
+ const [pco2hot] = buildPco2Sections([cue]);
+ const colorOff = 20 + 44;
+ expect(pco2hot[colorOff + 1]).toBe(0xff); // R
+ expect(pco2hot[colorOff + 2]).toBe(0xa2); // G (native wheel, not 0x99)
+ expect(pco2hot[colorOff + 3]).toBe(0x00); // B
+ });
+});
diff --git a/src/__tests__/cuePointRepository.test.js b/src/__tests__/cuePointRepository.test.js
new file mode 100644
index 00000000..8be3bb87
--- /dev/null
+++ b/src/__tests__/cuePointRepository.test.js
@@ -0,0 +1,161 @@
+import { describe, it, expect, afterEach } from 'vitest';
+import db from '../db/database.js';
+import { addTrack } from '../db/trackRepository.js';
+import {
+ getCuePoints,
+ addCuePoint,
+ updateCuePoint,
+ deleteCuePoint,
+ deleteAllCuePoints,
+ deleteAllCuePointsLibrary,
+} from '../db/cuePointRepository.js';
+
+const TRACK = {
+ title: 'Test Track',
+ artist: 'Artist',
+ album: '',
+ duration: 180,
+ file_path: '/tmp/t.mp3',
+ file_hash: 'abc123',
+ format: 'mp3',
+ bitrate: 320000,
+};
+
+afterEach(() => {
+ db.prepare('DELETE FROM cue_points').run();
+ db.prepare('DELETE FROM tracks').run();
+});
+
+describe('getCuePoints', () => {
+ it('returns empty array when track has no cue points', () => {
+ const id = addTrack(TRACK);
+ expect(getCuePoints(id)).toEqual([]);
+ });
+
+ it('returns cue points ordered by position_ms', () => {
+ const id = addTrack(TRACK);
+ addCuePoint({ trackId: id, positionMs: 5000, label: 'B', color: '#ff0000', hotCueIndex: -1 });
+ addCuePoint({ trackId: id, positionMs: 1000, label: 'A', color: '#00ff00', hotCueIndex: 0 });
+ const pts = getCuePoints(id);
+ expect(pts).toHaveLength(2);
+ expect(pts[0].position_ms).toBe(1000);
+ expect(pts[1].position_ms).toBe(5000);
+ });
+});
+
+describe('addCuePoint', () => {
+ it('inserts a cue point and returns its id', () => {
+ const trackId = addTrack(TRACK);
+ const cueId = addCuePoint({
+ trackId,
+ positionMs: 2000,
+ label: 'Drop',
+ color: '#ff9900',
+ hotCueIndex: 1,
+ });
+ expect(typeof cueId).toBe('number');
+ const pts = getCuePoints(trackId);
+ expect(pts).toHaveLength(1);
+ expect(pts[0].label).toBe('Drop');
+ expect(pts[0].color).toBe('#ff9900');
+ expect(pts[0].hot_cue_index).toBe(1);
+ expect(pts[0].position_ms).toBe(2000);
+ });
+
+ it('uses default values when optional fields are omitted', () => {
+ const trackId = addTrack(TRACK);
+ addCuePoint({ trackId, positionMs: 0 });
+ const [pt] = getCuePoints(trackId);
+ expect(pt.label).toBe('');
+ expect(pt.color).toBe('#00b4d8');
+ expect(pt.hot_cue_index).toBe(-1);
+ });
+});
+
+describe('updateCuePoint', () => {
+ it('updates label', () => {
+ const trackId = addTrack(TRACK);
+ const cueId = addCuePoint({ trackId, positionMs: 0 });
+ updateCuePoint(cueId, { label: 'Intro' });
+ expect(getCuePoints(trackId)[0].label).toBe('Intro');
+ });
+
+ it('updates color', () => {
+ const trackId = addTrack(TRACK);
+ const cueId = addCuePoint({ trackId, positionMs: 0 });
+ updateCuePoint(cueId, { color: '#cc00ff' });
+ expect(getCuePoints(trackId)[0].color).toBe('#cc00ff');
+ });
+
+ it('updates hotCueIndex', () => {
+ const trackId = addTrack(TRACK);
+ const cueId = addCuePoint({ trackId, positionMs: 0, hotCueIndex: -1 });
+ updateCuePoint(cueId, { hotCueIndex: 3 });
+ expect(getCuePoints(trackId)[0].hot_cue_index).toBe(3);
+ });
+
+ it('updates enabled flag', () => {
+ const trackId = addTrack(TRACK);
+ const cueId = addCuePoint({ trackId, positionMs: 0 });
+ updateCuePoint(cueId, { enabled: false });
+ expect(getCuePoints(trackId)[0].enabled).toBe(0);
+ updateCuePoint(cueId, { enabled: true });
+ expect(getCuePoints(trackId)[0].enabled).toBe(1);
+ });
+
+ it('is a no-op when no fields are provided', () => {
+ const trackId = addTrack(TRACK);
+ const cueId = addCuePoint({ trackId, positionMs: 0, label: 'X' });
+ updateCuePoint(cueId, {});
+ expect(getCuePoints(trackId)[0].label).toBe('X');
+ });
+});
+
+describe('deleteCuePoint', () => {
+ it('removes a single cue point by id', () => {
+ const trackId = addTrack(TRACK);
+ const cueId = addCuePoint({ trackId, positionMs: 1000 });
+ addCuePoint({ trackId, positionMs: 2000 });
+ deleteCuePoint(cueId);
+ const pts = getCuePoints(trackId);
+ expect(pts).toHaveLength(1);
+ expect(pts[0].position_ms).toBe(2000);
+ });
+});
+
+describe('deleteAllCuePoints', () => {
+ it('removes all cue points for a track', () => {
+ const trackId = addTrack(TRACK);
+ addCuePoint({ trackId, positionMs: 1000 });
+ addCuePoint({ trackId, positionMs: 2000 });
+ deleteAllCuePoints(trackId);
+ expect(getCuePoints(trackId)).toHaveLength(0);
+ });
+
+ it('does not affect cue points of other tracks', () => {
+ const t1 = addTrack(TRACK);
+ const t2 = addTrack({ ...TRACK, file_hash: 'xyz', file_path: '/tmp/t2.mp3' });
+ addCuePoint({ trackId: t1, positionMs: 1000 });
+ addCuePoint({ trackId: t2, positionMs: 2000 });
+ deleteAllCuePoints(t1);
+ expect(getCuePoints(t1)).toHaveLength(0);
+ expect(getCuePoints(t2)).toHaveLength(1);
+ });
+});
+
+describe('deleteAllCuePointsLibrary', () => {
+ it('returns affected track ids and deletes all cue points', () => {
+ const t1 = addTrack(TRACK);
+ const t2 = addTrack({ ...TRACK, file_hash: 'xyz', file_path: '/tmp/t2.mp3' });
+ addCuePoint({ trackId: t1, positionMs: 1000 });
+ addCuePoint({ trackId: t2, positionMs: 2000 });
+ const affected = deleteAllCuePointsLibrary();
+ expect(affected.sort()).toEqual([t1, t2].sort());
+ expect(getCuePoints(t1)).toHaveLength(0);
+ expect(getCuePoints(t2)).toHaveLength(0);
+ });
+
+ it('returns empty array when no cue points exist', () => {
+ expect(deleteAllCuePointsLibrary()).toEqual([]);
+ });
+});
diff --git a/src/__tests__/importManager.test.js b/src/__tests__/importManager.test.js
index 1048c1b5..8e05b604 100644
--- a/src/__tests__/importManager.test.js
+++ b/src/__tests__/importManager.test.js
@@ -24,17 +24,30 @@ vi.mock('electron', () => ({
vi.mock('worker_threads', () => ({
Worker: vi.fn(function () {
this.on = vi.fn();
+ this.terminate = vi.fn();
}),
}));
vi.mock('../deps.js', () => ({
getAnalyzerRuntimePath: vi.fn().mockReturnValue('/fake/analyzer'),
+ getFfmpegRuntimePath: vi.fn().mockReturnValue('/fake/ffmpeg'),
+}));
+
+// child_process mock — execFile calls succeed by default
+const mockExecFile = vi.fn((bin, args, cb) => cb(null, '', ''));
+vi.mock('child_process', () => ({
+ execFile: (...args) => mockExecFile(...args),
}));
vi.mock('../db/settingsRepository.js', () => ({
getSetting: vi.fn().mockReturnValue(null),
}));
+vi.mock('../db/cuePointRepository.js', () => ({
+ getCuePoints: vi.fn().mockReturnValue([]),
+ addCuePoint: vi.fn().mockReturnValue(1),
+}));
+
const FAKE_HASH = 'deadbeef1234567890abcdef1234567890abcdef';
const ALT_HASH = 'aaaa1111bbbb2222cccc3333dddd4444eeee5555';
@@ -105,6 +118,7 @@ beforeEach(() => {
vi.clearAllMocks();
mockAddTrack.mockReturnValue(99);
mockGetTrackByHash.mockReturnValue(undefined);
+ mockExecFile.mockImplementation((bin, args, cb) => cb(null, '', ''));
// Restore default hash implementation after clearAllMocks
cryptoDefault.createHash.mockImplementation(() => ({
update() {
@@ -184,3 +198,126 @@ describe('importAudioFile — duplicate prevention', () => {
expect(mockAddTrack).toHaveBeenCalledTimes(2);
});
});
+
+// ── Artist detection from filename ────────────────────────────────────────────
+
+import { ffprobe } from '../audio/ffmpeg.js';
+
+describe('importAudioFile — artist detection from filename', () => {
+ it('uses ID3 artist tag when present, ignoring filename', async () => {
+ ffprobe.mockResolvedValueOnce({
+ format: {
+ format_name: 'mp3',
+ duration: '180.0',
+ bit_rate: '320000',
+ tags: { title: 'My Song', artist: 'Tag Artist' },
+ },
+ streams: [],
+ });
+
+ await importAudioFile('/music/Someone Else - My Song.mp3');
+
+ expect(mockAddTrack.mock.calls[0][0].artist).toBe('Tag Artist');
+ });
+
+ it('parses artist from "Artist - Title" filename when artist tag is missing', async () => {
+ ffprobe.mockResolvedValueOnce({
+ format: {
+ format_name: 'mp3',
+ duration: '180.0',
+ bit_rate: '320000',
+ tags: { title: '', artist: '' },
+ },
+ streams: [],
+ });
+
+ await importAudioFile('/music/Deadmau5 - Some Chords.mp3');
+
+ expect(mockAddTrack.mock.calls[0][0].artist).toBe('Deadmau5');
+ expect(mockAddTrack.mock.calls[0][0].title).toBe('Some Chords');
+ });
+
+ it('leaves artist empty when no tag and no dash in filename', async () => {
+ ffprobe.mockResolvedValueOnce({
+ format: {
+ format_name: 'mp3',
+ duration: '180.0',
+ bit_rate: '320000',
+ tags: { title: '', artist: '' },
+ },
+ streams: [],
+ });
+
+ await importAudioFile('/music/untitled_track.mp3');
+
+ expect(mockAddTrack.mock.calls[0][0].artist).toBe('');
+ expect(mockAddTrack.mock.calls[0][0].title).toBe('untitled_track');
+ });
+
+ it('uses channel name as artist when no tag, no dash in filename, and channel provided', async () => {
+ ffprobe.mockResolvedValueOnce({
+ format: {
+ format_name: 'mp3',
+ duration: '180.0',
+ bit_rate: '320000',
+ tags: { title: 'Midnight Dreams', artist: '' },
+ },
+ streams: [],
+ });
+
+ await importAudioFile('/music/Midnight Dreams [abc123].mp3', { channel: 'DJ Koze' });
+
+ expect(mockAddTrack.mock.calls[0][0].artist).toBe('DJ Koze');
+ expect(mockAddTrack.mock.calls[0][0].title).toBe('Midnight Dreams');
+ });
+
+ it('does not overwrite ID3 artist with channel name', async () => {
+ ffprobe.mockResolvedValueOnce({
+ format: {
+ format_name: 'mp3',
+ duration: '180.0',
+ bit_rate: '320000',
+ tags: { title: 'Some Track', artist: 'Real Artist' },
+ },
+ streams: [],
+ });
+
+ await importAudioFile('/music/Some Track [abc123].mp3', { channel: 'Channel Name' });
+
+ expect(mockAddTrack.mock.calls[0][0].artist).toBe('Real Artist');
+ });
+
+ it('does not overwrite filename-parsed artist with channel name', async () => {
+ ffprobe.mockResolvedValueOnce({
+ format: {
+ format_name: 'mp3',
+ duration: '180.0',
+ bit_rate: '320000',
+ tags: { title: '', artist: '' },
+ },
+ streams: [],
+ });
+
+ await importAudioFile('/music/Deadmau5 - Some Track [abc123].mp3', { channel: 'Channel Name' });
+
+ expect(mockAddTrack.mock.calls[0][0].artist).toBe('Deadmau5');
+ });
+
+ it('keeps ID3 title when artist is missing but filename has dash', async () => {
+ ffprobe.mockResolvedValueOnce({
+ format: {
+ format_name: 'mp3',
+ duration: '180.0',
+ bit_rate: '320000',
+ tags: { title: 'ID3 Title', artist: '' },
+ },
+ streams: [],
+ });
+
+ await importAudioFile('/music/Filename Artist - Other Title.mp3');
+
+ expect(mockAddTrack.mock.calls[0][0].artist).toBe('Filename Artist');
+ // ID3 title wins over filename-derived title
+ expect(mockAddTrack.mock.calls[0][0].title).toBe('ID3 Title');
+ });
+});
diff --git a/src/__tests__/pdbWriter.test.js b/src/__tests__/pdbWriter.test.js
index 8c6559c5..3c94699c 100644
--- a/src/__tests__/pdbWriter.test.js
+++ b/src/__tests__/pdbWriter.test.js
@@ -282,10 +282,21 @@ describe('buildTrackRow', () => {
expect(buf.readUInt32LE(16)).toBe(5000000);
});
- it('Unnamed7=0x758a and Unnamed8=0x57a2 at offsets 24/26', () => {
+ it('auto_gain defaults (0x4A68 / 0x78F7) written at offsets 24/26 when no replayGain', () => {
const buf = buildTrackRow(minimal);
- expect(buf.readUInt16LE(24)).toBe(0x758a);
- expect(buf.readUInt16LE(26)).toBe(0x57a2);
+ expect(buf.readUInt16LE(24)).toBe(0x4a68); // 19048 — CDJ unanalyzed reference
+ expect(buf.readUInt16LE(26)).toBe(0x78f7); // 30967 — secondary unanalyzed reference
+ });
+
+ it('auto_gain computed from replayGain at offsets 24/26', () => {
+ const buf = buildTrackRow({ ...minimal, replayGain: 0 });
+ expect(buf.readUInt16LE(24)).toBe(19048);
+ expect(buf.readUInt16LE(26)).toBe(30967);
+
+ const bufMinus6 = buildTrackRow({ ...minimal, replayGain: -6 });
+ // 10^(-6/20) * 19048 ≈ 9546
+ expect(bufMinus6.readUInt16LE(24)).toBe(Math.round(10 ** (-6 / 20) * 19048));
+ expect(bufMinus6.readUInt16LE(26)).toBe(Math.round(10 ** (-6 / 20) * 30967));
});
it('ArtistId at offset 68', () => {
diff --git a/src/__tests__/resetCleanup.test.js b/src/__tests__/resetCleanup.test.js
new file mode 100644
index 00000000..85f225ac
--- /dev/null
+++ b/src/__tests__/resetCleanup.test.js
@@ -0,0 +1,74 @@
+import path from 'path';
+import { describe, it, expect, vi } from 'vitest';
+import { getResetCleanupTargets, startResetCleanup } from '../resetCleanup.js';
+
+describe('getResetCleanupTargets', () => {
+ it('includes app data directories and legacy dev database files', () => {
+ const targets = getResetCleanupTargets({
+ userDataPath: 'C:\\Users\\me\\AppData\\Roaming\\DJ Manager',
+ cachePath: 'C:\\Users\\me\\AppData\\Local\\DJ Manager\\Cache',
+ logsPath: 'C:\\Users\\me\\AppData\\Roaming\\DJ Manager\\logs',
+ cwd: 'C:\\Users\\me\\DjManager',
+ });
+
+ expect(targets).toEqual([
+ 'C:\\Users\\me\\AppData\\Roaming\\DJ Manager',
+ 'C:\\Users\\me\\AppData\\Local\\DJ Manager\\Cache',
+ 'C:\\Users\\me\\AppData\\Roaming\\DJ Manager\\logs',
+ path.join('C:\\Users\\me\\DjManager', 'library.db'),
+ path.join('C:\\Users\\me\\DjManager', 'library.db-shm'),
+ path.join('C:\\Users\\me\\DjManager', 'library.db-wal'),
+ ]);
+ });
+
+ it('deduplicates repeated targets', () => {
+ const targets = getResetCleanupTargets({
+ userDataPath: 'C:\\temp\\userData',
+ cachePath: 'C:\\temp\\userData',
+ logsPath: 'C:\\temp\\userData',
+ cwd: 'C:\\temp',
+ });
+
+ expect(targets).toEqual([
+ 'C:\\temp\\userData',
+ path.join('C:\\temp', 'library.db'),
+ path.join('C:\\temp', 'library.db-shm'),
+ path.join('C:\\temp', 'library.db-wal'),
+ ]);
+ });
+});
+
+describe('startResetCleanup', () => {
+ it('spawns a detached node-mode helper and unreferences it', () => {
+ const unref = vi.fn();
+ const spawnImpl = vi.fn().mockReturnValue({ unref });
+
+ startResetCleanup({
+ parentPid: 4242,
+ targets: ['C:\\temp\\userData'],
+ spawnImpl,
+ execPath: 'C:\\Program Files\\DjManager\\DJ Manager.exe',
+ env: { PATH: 'C:\\Windows\\System32' },
+ scriptPath: 'C:\\Users\\me\\DjManager\\src\\resetCleanupWorker.js',
+ });
+
+ expect(spawnImpl).toHaveBeenCalledWith(
+ 'C:\\Program Files\\DjManager\\DJ Manager.exe',
+ [
+ 'C:\\Users\\me\\DjManager\\src\\resetCleanupWorker.js',
+ '4242',
+ JSON.stringify(['C:\\temp\\userData']),
+ ],
+ {
+ detached: true,
+ stdio: 'ignore',
+ windowsHide: true,
+ env: {
+ PATH: 'C:\\Windows\\System32',
+ ELECTRON_RUN_AS_NODE: '1',
+ },
+ }
+ );
+ expect(unref).toHaveBeenCalled();
+ });
+});
diff --git a/src/__tests__/setup.js b/src/__tests__/setup.js
index acc94661..202df4f5 100644
--- a/src/__tests__/setup.js
+++ b/src/__tests__/setup.js
@@ -8,6 +8,7 @@ beforeAll(() => {
afterEach(() => {
// Clear all data between tests for isolation
+ db.prepare('DELETE FROM cue_points').run();
db.prepare('DELETE FROM playlist_tracks').run();
db.prepare('DELETE FROM playlists').run();
db.prepare('DELETE FROM tracks').run();
diff --git a/src/__tests__/trackRepository.test.js b/src/__tests__/trackRepository.test.js
index 288460e2..96fd3b61 100644
--- a/src/__tests__/trackRepository.test.js
+++ b/src/__tests__/trackRepository.test.js
@@ -9,6 +9,11 @@ import {
getTrackIds,
normalizeLibrary,
clearTracks,
+ getTrackIdsNeedingNormalization,
+ getNormalizedTrackCount,
+ getLegacyNormalizedTracks,
+ clearLegacyNormalizedPaths,
+ resetNormalization,
} from '../db/trackRepository.js';
const SAMPLE = {
@@ -367,3 +372,126 @@ describe('source_link field', () => {
expect(track.source_link).toBeNull();
});
});
+
+describe('getTrackIdsNeedingNormalization', () => {
+ it('returns ids of all analyzed tracks with loudness data', () => {
+ const id1 = addTrack({ ...SAMPLE, file_hash: 'nin1', file_path: '/tmp/nin1.mp3' });
+ const id2 = addTrack({ ...SAMPLE, file_hash: 'nin2', file_path: '/tmp/nin2.mp3' });
+ updateTrack(id1, { loudness: -14 });
+ updateTrack(id2, { loudness: -12 });
+ const ids = getTrackIdsNeedingNormalization();
+ expect(ids).toContain(id1);
+ expect(ids).toContain(id2);
+ });
+
+ it('includes tracks regardless of normalized_file_path (legacy column)', () => {
+ const id = addTrack({ ...SAMPLE, file_hash: 'nin3', file_path: '/tmp/nin3.mp3' });
+ updateTrack(id, { loudness: -14, normalized_file_path: '/tmp/nin3_norm.mp3' });
+ const ids = getTrackIdsNeedingNormalization();
+ expect(ids).toContain(id);
+ });
+
+ it('excludes tracks with no loudness data', () => {
+ const id = addTrack({ ...SAMPLE, file_hash: 'nin4', file_path: '/tmp/nin4.mp3' });
+ // no loudness set
+ const ids = getTrackIdsNeedingNormalization();
+ expect(ids).not.toContain(id);
+ });
+});
+
+describe('getNormalizedTrackCount', () => {
+ it('returns 0 when no tracks have a normalized_file_path', () => {
+ addTrack({ ...SAMPLE, file_hash: 'gnt1', file_path: '/tmp/gnt1.mp3' });
+ expect(getNormalizedTrackCount()).toBe(0);
+ });
+
+ it('returns correct count when some tracks are normalized', () => {
+ const id1 = addTrack({ ...SAMPLE, file_hash: 'gnt2', file_path: '/tmp/gnt2.mp3' });
+ const id2 = addTrack({ ...SAMPLE, file_hash: 'gnt3', file_path: '/tmp/gnt3.mp3' });
+ updateTrack(id1, { normalized_file_path: '/tmp/gnt2_norm.mp3' });
+ updateTrack(id2, { normalized_file_path: '/tmp/gnt3_norm.mp3' });
+ expect(getNormalizedTrackCount()).toBe(2);
+ });
+});
+
+describe('resetNormalization', () => {
+ it('clears normalized_file_path and source_loudness for all tracks when called with no args', () => {
+ const id1 = addTrack({ ...SAMPLE, file_hash: 'rn1', file_path: '/tmp/rn1.mp3' });
+ const id2 = addTrack({ ...SAMPLE, file_hash: 'rn2', file_path: '/tmp/rn2.mp3' });
+ updateTrack(id1, {
+ loudness: -14,
+ normalized_file_path: '/tmp/rn1_norm.mp3',
+ source_loudness: -14,
+ });
+ updateTrack(id2, {
+ loudness: -12,
+ normalized_file_path: '/tmp/rn2_norm.mp3',
+ source_loudness: -12,
+ });
+
+ const count = resetNormalization();
+ expect(count).toBe(2);
+ expect(getTrackById(id1).normalized_file_path).toBeNull();
+ expect(getTrackById(id1).source_loudness).toBeNull();
+ expect(getTrackById(id2).normalized_file_path).toBeNull();
+ });
+
+ it('returns 0 when the table is empty', () => {
+ // Don't add any tracks — table is already cleared by beforeEach
+ const count = resetNormalization();
+ expect(count).toBe(0);
+ });
+
+ it('clears only specified track ids when called with an array', () => {
+ const id1 = addTrack({ ...SAMPLE, file_hash: 'rn4', file_path: '/tmp/rn4.mp3' });
+ const id2 = addTrack({ ...SAMPLE, file_hash: 'rn5', file_path: '/tmp/rn5.mp3' });
+ updateTrack(id1, { normalized_file_path: '/tmp/rn4_norm.mp3', source_loudness: -14 });
+ updateTrack(id2, { normalized_file_path: '/tmp/rn5_norm.mp3', source_loudness: -12 });
+
+ resetNormalization([id1]);
+ expect(getTrackById(id1).normalized_file_path).toBeNull();
+ expect(getTrackById(id1).source_loudness).toBeNull();
+ // id2 should be untouched
+ expect(getTrackById(id2).normalized_file_path).toBe('/tmp/rn5_norm.mp3');
+ });
+});
+
+describe('getLegacyNormalizedTracks / clearLegacyNormalizedPaths', () => {
+ it('getLegacyNormalizedTracks returns empty array when no legacy paths exist', () => {
+ expect(getLegacyNormalizedTracks()).toEqual([]);
+ });
+
+ it('getLegacyNormalizedTracks returns tracks that have normalized_file_path set', () => {
+ const id1 = addTrack({ ...SAMPLE, file_hash: 'leg1', file_path: '/tmp/leg1.mp3' });
+ const id2 = addTrack({ ...SAMPLE, file_hash: 'leg2', file_path: '/tmp/leg2.mp3' });
+ updateTrack(id1, { normalized_file_path: '/tmp/leg1_norm.mp3' });
+
+ const legacy = getLegacyNormalizedTracks();
+ expect(legacy).toHaveLength(1);
+ expect(legacy[0].id).toBe(id1);
+ expect(legacy[0].normalized_file_path).toBe('/tmp/leg1_norm.mp3');
+
+ // id2 has no normalized path so it should not appear
+ expect(legacy.find((r) => r.id === id2)).toBeUndefined();
+ });
+
+ it('clearLegacyNormalizedPaths nullifies normalized_file_path and source_loudness', () => {
+ const id = addTrack({ ...SAMPLE, file_hash: 'leg3', file_path: '/tmp/leg3.mp3' });
+ updateTrack(id, { normalized_file_path: '/tmp/leg3_norm.mp3', source_loudness: -14 });
+
+ clearLegacyNormalizedPaths();
+
+ const track = getTrackById(id);
+ expect(track.normalized_file_path).toBeNull();
+ expect(track.source_loudness).toBeNull();
+ });
+
+ it('clearLegacyNormalizedPaths does not touch tracks without a legacy path', () => {
+ const id = addTrack({ ...SAMPLE, file_hash: 'leg4', file_path: '/tmp/leg4.mp3' });
+ updateTrack(id, { loudness: -12 });
+
+ clearLegacyNormalizedPaths();
+
+ expect(getTrackById(id).loudness).toBeCloseTo(-12);
+ });
+});
diff --git a/src/audio/anlzWriter.js b/src/audio/anlzWriter.js
index 771c03d1..eba26f65 100644
--- a/src/audio/anlzWriter.js
+++ b/src/audio/anlzWriter.js
@@ -85,7 +85,7 @@ function buildPathTag(usbFilePath) {
*
* Pioneer beat entry: beatNumber (1-4), tempo (BPM * 100), time (ms, u32)
*/
-function computeBeats(beatgridJson, bpm) {
+function computeBeats(beatgridJson, bpm, beatgridOffset = 0) {
let beats = [];
try {
@@ -112,6 +112,11 @@ function computeBeats(beatgridJson, bpm) {
beats = generateBeatsFromBpm(bpm, 600); // 600 seconds max
}
+ // Apply beatgrid offset (ms) — shifts the entire grid left/right; clamp to ≥ 0
+ if (beatgridOffset) {
+ beats = beats.map((b) => ({ ...b, time: b.time + beatgridOffset })).filter((b) => b.time >= 0);
+ }
+
return beats;
}
@@ -235,37 +240,120 @@ function buildPvbrSection(fileSize) {
return buildSection('PVBR', body, 16);
}
-// ─── Stub sections (cue placeholders required by Rekordbox) ───────────────────
+// ─── Cue point sections (PCOB / PCO2) ─────────────────────────────────────────
+//
+// Rekordbox 6+ / CDJ-3000 format uses sub-tagged entries inside PCOB and PCO2.
+// Each PCOB entry is wrapped in a PCPT sub-tag (56 bytes fixed).
+// Each PCO2 entry is wrapped in a PCP2 sub-tag (variable, min 104 bytes).
+//
+// Confirmed by hex-comparing native Rekordbox USB exports.
+// The older flat-entry format (documented in crate-digger for early CDJ firmware)
+// causes Rekordbox to reject the entire ANLZ file, silently dropping waveforms
+// and beatgrids even though those sections precede PCOB in the stream.
+//
+// PCOB header (24 bytes): fourcc + len_header(24) + len_tag + type(u4) + pad(u2) + num_cues(u2) + memory_count(u4)
+// memory_count = 0xffffffff sentinel in all observed native files.
+// PCPT sub-tag (56 bytes, fixed) — verified by hex-diff against native Rekordbox USB export:
+// [0-11]: standard header fourcc='PCPT', len_header=28, len_tag=56
+// [12-15]: hot_cue (u4): 0=memory cue, 1=A, 2=B, …
+// [16-19]: status (u4): 0 — native Rekordbox writes 0 here; KSY label "disabled" is misleading
+// [20-23]: 0x00010000 (constant)
+// [24-25]: order_first (u2): 0xffff
+// [26-27]: order_last (u2): 0xffff
+// [28]: type (u1): 1=cue_point, 2=loop
+// [29]: 0x00
+// [30-31]: 0x03e8 (constant observed in all native files)
+// [32-35]: time_ms (u32BE)
+// [36-39]: loop_time (u32BE, 0xffffffff=none)
+// [40-55]: zeros
+//
+// PCOB split (verified): hot_cue numbers 1-3 (A,B,C) → DAT PCOB1
+// hot_cue numbers 4-8 (D-H) → EXT PCOB1
+//
+// PCO2 header (20 bytes): fourcc + len_header(20) + len_tag + type(u4) + num_cues(u2) + pad(u2)
+// PCP2 sub-tag (variable) — verified by hex-diff against native Rekordbox USB export:
+// [0-11]: standard header fourcc='PCP2', len_header=16, len_tag=variable
+// [12-15]: hot_cue (u4): 0=memory, 1=A, 2=B, …
+// body at [16+]:
+// [0]: type (u1): 1=cue_point
+// [1]: 0x00
+// [2-3]: 0x03e8 (constant)
+// [4-7]: time_ms (u32BE)
+// [8-11]: loop_time (u32BE, 0xffffffff=none)
+// [12]: color_id (0x00)
+// [13]: 0x01 (constant)
+// [14-23]: zeros
+// [24-27]: len_comment (u32BE, byte count incl null terminator, 0=no label)
+// [28+]: UTF-16BE label (null-terminated), labelByteLen bytes
+// [28+labelByteLen+0]: color_code (u1): Pioneer palette 1-8 (0=no color)
+// [28+labelByteLen+1]: color_red (u1)
+// [28+labelByteLen+2]: color_green (u1)
+// [28+labelByteLen+3]: color_blue (u1)
+// rest: 40 trailing zeros
+// Total body = 28 + labelByteLen + 44 (72 for no-label, 88 for 16-byte label)
+//
+// PCPT (DAT/EXT PCOB sections) color palette — read by CDJ hardware.
+// Codes 1–8 are Pioneer's per-slot palette: 1=orange-red(A)…8=violet(H).
+// ✓ = confirmed from native Rekordbox USB hex-diff ○ = inferred
+const PIONEER_PALETTE = new Map([
+ ['#ff6b35', 1], // orange-red ○
+ ['#ff0000', 2], // red ○
+ ['#ff9900', 3], // orange ✓
+ ['#ffff00', 4], // yellow ○
+ ['#00ff00', 5], // green ○
+ ['#00b4d8', 6], // cyan ✓
+ ['#0080ff', 7], // blue ○
+ ['#cc00ff', 8], // violet ○
+]);
+
+function hexToPioneerCode(hex) {
+ if (!hex) return 0;
+ return PIONEER_PALETTE.get(hex.toLowerCase()) ?? 0;
+}
-// PCOB #1 and #2: empty cue object stubs (24 bytes each, no body)
-// Observed in every native Rekordbox DAT and EXT file.
-const PCOB1 = Buffer.from([
+// PCP2 (EXT PCO2 section) uses a DIFFERENT color encoding — a ~64-step extended color wheel
+// where code 1 = blue (0x00,0x00,0xFF) and code 42 = red (0xFF,0x00,0x00).
+// This is NOT the same numbering as PCPT (1-8). Confirmed from native Rekordbox USB dumps
+// of "Riders on the Storm" with 16 cues using all available colors.
+// ✓ = confirmed exact RGB from native dump ~ = interpolated between confirmed neighbors
+const PIONEER_PCP2_MAP = new Map([
+ ['#ff6b35', { code: 0x27, r: 0xff, g: 0x46, b: 0x00 }], // orange-red ~ code 39 (hue≈16°, Δ0.5°)
+ ['#ff0000', { code: 0x2a, r: 0xff, g: 0x00, b: 0x00 }], // red ✓ code 42
+ ['#ff9900', { code: 0x23, r: 0xff, g: 0xa2, b: 0x00 }], // orange ~ code 35 (hue≈38°, Δ2°)
+ ['#ffff00', { code: 0x1f, r: 0xf3, g: 0xf4, b: 0x00 }], // yellow ~ code 31 (hue≈57°, Δ3°)
+ ['#00ff00', { code: 0x16, r: 0x1a, g: 0xff, b: 0x00 }], // green ✓ code 22 (hue=114°, Δ6°)
+ ['#00b4d8', { code: 0x09, r: 0x00, g: 0xe0, b: 0xff }], // cyan ✓ code 9 (hue=187°, Δ3°)
+ ['#0080ff', { code: 0x05, r: 0x00, g: 0x70, b: 0xff }], // blue ✓ code 5 (hue=214°, Δ4°)
+ ['#cc00ff', { code: 0x38, r: 0xb3, g: 0x00, b: 0xff }], // violet ✓ code 56 (hue=282°, Δ6°)
+]);
+
+const EMPTY_PCOB_1 = Buffer.from([
0x50,
0x43,
0x4f,
- 0x42,
+ 0x42, // 'PCOB'
+ 0x00,
0x00,
0x00,
+ 0x18, // len_header = 24
0x00,
- 0x18, // 'PCOB', len_header=24
0x00,
0x00,
+ 0x18, // len_tag = 24 (no entries)
0x00,
- 0x18, // len_tag=24 (no body)
0x00,
0x00,
+ 0x01, // count_indicator = 1 (slot 1 header sentinel)
0x00,
- 0x01, // flag=1
0x00,
0x00,
0x00,
- 0x00, // zero
0xff,
0xff,
0xff,
- 0xff, // value=FFFFFFFF
+ 0xff,
]);
-const PCOB2 = Buffer.from([
+const EMPTY_PCOB_2 = Buffer.from([
0x50,
0x43,
0x4f,
@@ -281,7 +369,7 @@ const PCOB2 = Buffer.from([
0x00,
0x00,
0x00,
- 0x00, // flag=0
+ 0x00, // count_indicator = 0 (slot 2)
0x00,
0x00,
0x00,
@@ -291,54 +379,207 @@ const PCOB2 = Buffer.from([
0xff,
0xff,
]);
-
-// PCO2 #1 and #2: empty extended cue stubs (20 bytes each, no body)
-// Present in native Rekordbox EXT files only (not DAT).
-const PCO2_1 = Buffer.from([
+const EMPTY_PCO2_1 = Buffer.from([
0x50,
0x43,
0x4f,
- 0x32,
+ 0x32, // 'PCO2'
0x00,
0x00,
0x00,
- 0x14, // 'PCO2', len_header=20
+ 0x14, // len_header = 20
0x00,
0x00,
0x00,
- 0x14, // len_tag=20 (no body)
+ 0x14, // len_tag = 20
0x00,
0x00,
0x00,
- 0x01, // flag=1
+ 0x01,
0x00,
0x00,
0x00,
0x00,
]);
-const PCO2_2 = Buffer.from([
- 0x50,
- 0x43,
- 0x4f,
- 0x32,
- 0x00,
- 0x00,
- 0x00,
- 0x14,
- 0x00,
- 0x00,
- 0x00,
- 0x14,
- 0x00,
- 0x00,
- 0x00,
- 0x00, // flag=0
- 0x00,
- 0x00,
- 0x00,
- 0x00,
+const EMPTY_PCO2_2 = Buffer.from([
+ 0x50, 0x43, 0x4f, 0x32, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x14, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00,
]);
+/**
+ * Builds a single PCPT sub-tag entry (56 bytes, fixed size).
+ * Per crate-digger ksy: [12-15]=hot_cue number (0=memory,1=A,2=B…), [28]=type (1=point,2=loop).
+ */
+function buildPcptEntry(hotCueNum, positionMs, color) {
+ const buf = Buffer.alloc(56, 0);
+ buf.write('PCPT', 0, 'ascii');
+ buf.writeUInt32BE(28, 4); // len_header = 28
+ buf.writeUInt32BE(56, 8); // len_tag = 56
+ buf.writeUInt32BE(hotCueNum, 12); // hot_cue: 0=memory, 1=A, 2=B, …
+ // [16-19]: status = 0 — native Rekordbox writes 0 here (KSY "disabled" is a misnomer)
+ buf.writeUInt32BE(0x00010000, 20); // constant observed in all native Rekordbox files
+ buf.writeUInt16BE(0xffff, 24); // order_first
+ buf.writeUInt16BE(0xffff, 26); // order_last
+ buf[28] = 1; // type: 1=cue_point
+ buf.writeUInt16BE(0x03e8, 30); // constant
+ buf.writeUInt32BE(positionMs, 32); // time_ms
+ buf.writeUInt32BE(0xffffffff, 36); // loop_time: none
+ buf[40] = hexToPioneerCode(color); // Pioneer palette code (1-8; 0=no color/use CDJ default)
+ // [41-55]: zeros
+ return buf;
+}
+
+function buildPcobSlot(slotType, cues) {
+ // slotType: 1=hot_cues (slot 1), 0=memory_cues (slot 2)
+ if (cues.length === 0) return slotType === 1 ? EMPTY_PCOB_1 : EMPTY_PCOB_2;
+ const headerSize = 24;
+ const tagLen = headerSize + cues.length * 56;
+ const buf = Buffer.alloc(tagLen, 0);
+ buf.write('PCOB', 0, 'ascii');
+ buf.writeUInt32BE(headerSize, 4); // len_header = 24
+ buf.writeUInt32BE(tagLen, 8); // len_tag
+ buf.writeUInt32BE(slotType, 12); // type: 1=hot_cues, 0=memory_cues
+ // [16-17]: padding = 0
+ buf.writeUInt16BE(cues.length, 18); // num_cues (u16BE)
+ buf.writeUInt32BE(0xffffffff, 20); // memory_count sentinel
+ cues.forEach((cue, i) => {
+ // DB hot_cue_index: <0 = memory cue, >=0 = hot cue (0=A, 1=B, …)
+ // Pioneer format: 0=memory, 1=A, 2=B, …
+ const hotCueNum = cue.hot_cue_index >= 0 ? cue.hot_cue_index + 1 : 0;
+ buildPcptEntry(hotCueNum, Math.round(cue.position_ms), cue.color).copy(
+ buf,
+ headerSize + i * 56
+ );
+ });
+ return buf;
+}
+
+/**
+ * Build PCOB buffers for the DAT file [slot1, slot2].
+ * Verified split from native Rekordbox: hot_cue numbers 1-3 (A,B,C) go in DAT PCOB1.
+ * Cues D-H (hot_cue numbers 4-8) go in EXT PCOB1 — see buildExtPcobSections().
+ * PCOB2 is always the empty stub (PCOB2 memory cue format still under investigation, #208).
+ *
+ * @param {Array<{position_ms, color, hot_cue_index}>} cuePoints
+ * @returns {[Buffer, Buffer]}
+ */
+export function buildPcobSections(cuePoints) {
+ if (!cuePoints || cuePoints.length === 0) return [EMPTY_PCOB_1, EMPTY_PCOB_2];
+ // hot_cue_index 0,1,2 → hot_cue numbers 1,2,3 (A,B,C) — DAT only
+ const datHotCues = cuePoints.filter((c) => c.hot_cue_index >= 0 && c.hot_cue_index <= 2);
+ return [buildPcobSlot(1, datHotCues), EMPTY_PCOB_2];
+}
+
+/**
+ * Build PCOB buffers for the EXT file [slot1, slot2].
+ * Verified split: hot_cue numbers 4-8 (D-H, hot_cue_index 3-7) go in EXT PCOB1.
+ *
+ * @param {Array<{position_ms, color, hot_cue_index}>} cuePoints
+ * @returns {[Buffer, Buffer]}
+ */
+export function buildExtPcobSections(cuePoints) {
+ if (!cuePoints || cuePoints.length === 0) return [EMPTY_PCOB_1, EMPTY_PCOB_2];
+ // hot_cue_index 3-7 → hot_cue numbers 4-8 (D-H) — EXT only
+ const extHotCues = cuePoints.filter((c) => c.hot_cue_index >= 3 && c.hot_cue_index <= 7);
+ return [buildPcobSlot(1, extHotCues), EMPTY_PCOB_2];
+}
+
+/**
+ * Builds a single PCP2 sub-tag entry.
+ * Verified against native Rekordbox USB exports (issue #208 hex-diff):
+ * - No-label entry: len_tag=88 (body=72)
+ * - 16-byte label entry: len_tag=104 (body=88)
+ * - Formula: body = 28 + labelByteLen + 44 (28 fixed + label + 4 color + 40 zeros)
+ * - Color: color_code(u1, Pioneer palette 1-8, via hexToPioneerCode()) + R + G + B
+ */
+function buildPcp2Entry(hotCueNum, positionMs, label, color) {
+ const labelStr = label ?? '';
+ const labelByteLen = labelStr.length > 0 ? (labelStr.length + 1) * 2 : 0; // UTF-16BE + null terminator
+ // When a label is present, body is always at least 88 bytes (native Rekordbox
+ // always produces lenTag=104 regardless of label length ≤ 7 chars).
+ // For labels > 7 chars the body grows proportionally.
+ const bodySize = labelStr.length > 0 ? Math.max(88, 28 + labelByteLen + 44) : 72;
+ const lenTag = 16 + bodySize;
+
+ const buf = Buffer.alloc(lenTag, 0);
+ buf.write('PCP2', 0, 'ascii');
+ buf.writeUInt32BE(16, 4); // len_header = 16
+ buf.writeUInt32BE(lenTag, 8); // len_tag
+ buf.writeUInt32BE(hotCueNum, 12); // hot_cue: 0=memory, 1=A, 2=B, …
+
+ // body at offset 16:
+ buf[16] = 1; // type: 1=cue_point
+ // [17] = 0x00
+ buf.writeUInt16BE(0x03e8, 18); // constant (verified in native)
+ buf.writeUInt32BE(positionMs, 20); // time_ms
+ buf.writeUInt32BE(0xffffffff, 24); // loop_time: none
+ // [28] = 0x00 (color_id)
+ buf[29] = 0x01; // constant (verified in native)
+ // [30-39]: zeros
+ buf.writeUInt32BE(labelByteLen, 40); // len_comment (byte count incl null terminator)
+
+ if (labelStr.length > 0) {
+ buf.write(labelStr, 44, 'utf16le'); // write LE then byte-swap to BE
+ for (let j = 44; j < 44 + labelStr.length * 2; j += 2) {
+ const tmp = buf[j];
+ buf[j] = buf[j + 1];
+ buf[j + 1] = tmp;
+ }
+ // null terminator bytes remain 0x00 0x00
+ }
+
+ // Color at [28+labelByteLen]: color_code(u1) + R + G + B
+ // PCP2 color_code uses the extended wheel (PIONEER_PCP2_MAP) — NOT the PCPT 1-8 palette.
+ const colorOff = 44 + labelByteLen;
+ const pcp2Color = color ? PIONEER_PCP2_MAP.get(color.toLowerCase()) : null;
+ if (pcp2Color) {
+ buf[colorOff] = pcp2Color.code;
+ buf[colorOff + 1] = pcp2Color.r;
+ buf[colorOff + 2] = pcp2Color.g;
+ buf[colorOff + 3] = pcp2Color.b;
+ }
+ // else: bytes remain 0x00 (no color / use Rekordbox default per-slot color)
+ // trailing 40 zeros already set by Buffer.alloc
+
+ return buf;
+}
+
+function buildPco2Slot(slotType, cues) {
+ // slotType: 1=hot_cues (slot 1), 0=memory_cues (slot 2)
+ if (cues.length === 0) return slotType === 1 ? EMPTY_PCO2_1 : EMPTY_PCO2_2;
+ const headerSize = 20;
+ const entries = cues.map((cue) => {
+ const hotCueNum = cue.hot_cue_index >= 0 ? cue.hot_cue_index + 1 : 0;
+ return buildPcp2Entry(hotCueNum, Math.round(cue.position_ms), cue.label, cue.color);
+ });
+ const bodyLen = entries.reduce((s, e) => s + e.length, 0);
+ const tagLen = headerSize + bodyLen;
+
+ const header = Buffer.alloc(headerSize, 0);
+ header.write('PCO2', 0, 'ascii');
+ header.writeUInt32BE(headerSize, 4); // len_header = 20
+ header.writeUInt32BE(tagLen, 8); // len_tag
+ header.writeUInt32BE(slotType, 12); // type: 1=hot_cues, 0=memory_cues
+ header.writeUInt16BE(cues.length, 16); // num_cues (u16BE)
+ // [18-19]: padding = 0
+
+ return Buffer.concat([header, ...entries]);
+}
+
+/**
+ * Build populated PCO2 section buffers [slot1, slot2] (EXT file only).
+ * Slot 1 (type=1) contains hot cues; slot 2 (type=0) contains memory cues.
+ *
+ * @param {Array<{position_ms, label, color, hot_cue_index}>} cuePoints
+ * @returns {[Buffer, Buffer]}
+ */
+export function buildPco2Sections(cuePoints) {
+ if (!cuePoints || cuePoints.length === 0) return [EMPTY_PCO2_1, EMPTY_PCO2_2];
+ const hotCues = cuePoints.filter((c) => c.hot_cue_index >= 0);
+ const memoryCues = cuePoints.filter((c) => c.hot_cue_index < 0);
+ return [buildPco2Slot(1, hotCues), buildPco2Slot(0, memoryCues)];
+}
+
// ─── PMAI file header ──────────────────────────────────────────────────────────
function buildFileHeader(totalSize) {
@@ -491,14 +732,25 @@ function buildSectionWithBigHeader(fourcc, specificHeader, data) {
* Includes real waveforms generated from the source audio via ffmpeg.
*
* @param {object} opts
- * @param {string} opts.usbFilePath - USB-relative path e.g. "/music/Artist - Title.mp3"
+ * @param {string} opts.usbFilePath - USB-relative path e.g. "/music/Artist - Title.mp3"
* @param {string} opts.sourceFilePath - Absolute path to original audio on disk
- * @param {string|null} opts.beatgrid - JSON string from DB (mixxx-analyzer output)
- * @param {number} opts.bpm - BPM value from DB
- * @param {string} opts.usbRoot - Absolute path to USB root on disk
+ * @param {string|null} opts.beatgrid - JSON string from DB (mixxx-analyzer output)
+ * @param {number} opts.bpm - BPM value from DB (already bpm_override ?? bpm)
+ * @param {number} [opts.beatgridOffset=0] - Grid shift in ms (beatgrid_offset from DB)
+ * @param {string} opts.usbRoot - Absolute path to USB root on disk
+ * @param {Array} [opts.cuePoints] - Cue point rows from cue_points table
*/
export async function writeAnlz(opts) {
- const { usbFilePath, sourceFilePath, beatgrid, bpm, usbRoot, ffmpegPath } = opts;
+ const {
+ usbFilePath,
+ sourceFilePath,
+ beatgrid,
+ bpm,
+ beatgridOffset = 0,
+ usbRoot,
+ ffmpegPath,
+ cuePoints,
+ } = opts;
const folderHash = getFolderName(usbFilePath);
const anlzDir = path.join(usbRoot, 'PIONEER', 'USBANLZ', folderHash);
@@ -515,7 +767,7 @@ export async function writeAnlz(opts) {
}
// ── Compute beat array once — shared by PQTZ (DAT) and PQT2 (EXT) ──────────
- const beats = computeBeats(beatgrid, bpm);
+ const beats = computeBeats(beatgrid, bpm, beatgridOffset);
// ── PVBR seek table ───────────────────────────────────────────────────────────
// Native Rekordbox always includes PVBR between PPTH and PQTZ in the DAT file.
@@ -527,6 +779,13 @@ export async function writeAnlz(opts) {
}
const pvbrSection = buildPvbrSection(audioFileSize);
+ // ── Build cue sections ──────────────────────────────────────────────────────
+ // DAT PCOB: hot cues A,B,C (hot_cue numbers 1-3) only
+ const [pcob1, pcob2] = buildPcobSections(cuePoints ?? []);
+ // EXT PCOB: hot cues D-H (hot_cue numbers 4-8) only
+ const [extPcob1, extPcob2] = buildExtPcobSections(cuePoints ?? []);
+ const [pco2_1, pco2_2] = buildPco2Sections(cuePoints ?? []);
+
// ── ANLZ0000.DAT ─────────────────────────────────────────────────────────────
// Section order confirmed from native Rekordbox: PPTH, PVBR, PQTZ, PWAV, PWV2, PCOB×2
const datSections = [buildPathTag(usbFilePath), pvbrSection, buildBeatGrid(beats, bpm)];
@@ -534,18 +793,20 @@ export async function writeAnlz(opts) {
datSections.push(buildPwavSection(waveforms.pwav));
datSections.push(buildPwv2Section(waveforms.pwv2));
}
- datSections.push(PCOB1, PCOB2);
+ datSections.push(pcob1, pcob2);
const datSize = 28 + datSections.reduce((s, b) => s + b.length, 0);
const datBuffer = Buffer.concat([buildFileHeader(datSize), ...datSections]);
fs.writeFileSync(path.join(anlzDir, 'ANLZ0000.DAT'), datBuffer);
// ── ANLZ0000.EXT ─────────────────────────────────────────────────────────────
// Section order confirmed from native Rekordbox: PPTH, PWV3, PCOB×2, PCO2×2, PQT2, PWV5, PWV4
+ // EXT PCOB1: hot cues D-H (numbers 4-8); EXT PCOB2: empty stub (#208)
+ // PCO2 carries all cues with labels/colors for both DAT and EXT cues.
const extSections = [buildPathTag(usbFilePath)];
if (waveforms) {
extSections.push(buildPwv3Section(waveforms.pwv3));
}
- extSections.push(PCOB1, PCOB2, PCO2_1, PCO2_2);
+ extSections.push(extPcob1, extPcob2, pco2_1, pco2_2);
extSections.push(buildPqt2Section(beats, bpm));
if (waveforms) {
extSections.push(buildPwv5Section(waveforms.pwv5));
diff --git a/src/audio/cueGen.js b/src/audio/cueGen.js
new file mode 100644
index 00000000..33699354
--- /dev/null
+++ b/src/audio/cueGen.js
@@ -0,0 +1,145 @@
+/**
+ * CueGen — auto-generate cue points from existing track analysis.
+ *
+ * Inspired by https://github.com/mganss/CueGen but implemented natively
+ * using the analysis data already stored by mixxx-analyzer (intro_secs,
+ * outro_secs, beatgrid, bpm) — no external .NET runtime required.
+ *
+ * Generated cues (all assigned as hot cues A–H, indices 0–7):
+ * Hot cue A (index 0) — intro end: first beat after the intro (mix-in point)
+ * Hot cues B–G — every 32 bars from the intro end (section markers)
+ * Hot cue H (or last) — outro start: last strong beat before the fade/outro
+ *
+ * Memory cues (hotCueIndex = -1) are NOT used because their PCOB2 binary
+ * format is not yet reverse-engineered and they are invisible in Rekordbox.
+ */
+
+const HOT_CUE_COLOR = '#ff0000'; // red → Pioneer palette code 4 (distinct from orange Mix Out)
+const SECTION_COLOR = '#00b4d8'; // cyan for phrase markers
+const OUTRO_COLOR = '#ff9900'; // amber for the outro/mix-out marker
+
+/**
+ * Parse beatgrid JSON produced by mixxx-analyzer.
+ * Returns array of { positionSecs } objects sorted by time, or null.
+ */
+function parseBeatgrid(beatgridJson) {
+ if (!beatgridJson) return null;
+ try {
+ const raw = JSON.parse(beatgridJson);
+ if (!Array.isArray(raw) || raw.length === 0) return null;
+ // mixxx-analyzer produces [{ beat_number, position_seconds, bpm }]
+ const beats = raw
+ .filter((b) => typeof b.position_seconds === 'number')
+ .map((b) => ({ positionSecs: b.position_seconds }))
+ .sort((a, b) => a.positionSecs - b.positionSecs);
+ return beats.length > 0 ? beats : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Find the beat index closest to targetSecs.
+ */
+function nearestBeatIndex(beats, targetSecs) {
+ let best = 0;
+ let bestDiff = Infinity;
+ for (let i = 0; i < beats.length; i++) {
+ const diff = Math.abs(beats[i].positionSecs - targetSecs);
+ if (diff < bestDiff) {
+ bestDiff = diff;
+ best = i;
+ }
+ }
+ return best;
+}
+
+/**
+ * Generate cue points for a track using its stored analysis data.
+ *
+ * @param {object} track Row from the tracks table
+ * @returns {Array<{positionMs, label, color, hotCueIndex}>}
+ */
+export function generateCuePoints(track) {
+ const duration = track.duration ?? 0;
+ if (duration < 10) return []; // too short to be meaningful
+
+ const introSecs = track.intro_secs ?? 0;
+ // outro_secs is the absolute position (from track start) where the outro begins
+ const outroSecs = track.outro_secs ?? 0;
+ const bpm = track.bpm_override ?? track.bpm ?? 0;
+
+ const beats = parseBeatgrid(track.beatgrid);
+
+ // Cues are collected in order and assigned to hot cue slots A–H (0–7).
+ // The outro cue is reserved for the last available slot (H if 8+ cues, or
+ // whatever slot comes after the phrase markers).
+ const raw = []; // { positionMs, label, color }
+
+ // ── Hot cue A: mix-in point (intro end) ────────────────────────────────────
+ let introEndSecs = introSecs;
+ if (beats && introEndSecs > 0) {
+ // Snap to nearest beat after introSecs
+ const idx = nearestBeatIndex(beats, introSecs);
+ introEndSecs = beats[idx].positionSecs;
+ }
+ raw.push({
+ positionMs: Math.round(introEndSecs * 1000),
+ label: 'Mix In',
+ color: HOT_CUE_COLOR,
+ });
+
+ // outro_secs is absolute — use directly as the cut-off for phrase markers
+ const outroStartSecs = outroSecs > 0 ? outroSecs : duration;
+
+ // ── Phrase markers every 32 bars ───────────────────────────────────────────
+ if (bpm > 0) {
+ const secsPerBar = (60 / bpm) * 4; // 4/4 time
+ const phraseSecs = secsPerBar * 32;
+
+ if (beats) {
+ // Walk 32-bar intervals using actual beat positions
+ const startIdx = nearestBeatIndex(beats, introEndSecs);
+ let phraseIdx = startIdx + 128; // 32 bars × 4 beats
+ while (phraseIdx < beats.length) {
+ const pos = beats[phraseIdx].positionSecs;
+ if (pos >= outroStartSecs - 2) break;
+ raw.push({
+ positionMs: Math.round(pos * 1000),
+ label: `Bar ${Math.round((pos - introEndSecs) / secsPerBar) + 1}`,
+ color: SECTION_COLOR,
+ });
+ phraseIdx += 128;
+ }
+ } else if (phraseSecs > 0) {
+ // No beatgrid — use BPM arithmetic
+ let pos = introEndSecs + phraseSecs;
+ while (pos < outroStartSecs - 2) {
+ raw.push({
+ positionMs: Math.round(pos * 1000),
+ label: `Bar ${Math.round((pos - introEndSecs) / secsPerBar) + 1}`,
+ color: SECTION_COLOR,
+ });
+ pos += phraseSecs;
+ }
+ }
+ }
+
+ // ── Outro start (mix-out point) ─────────────────────────────────────────────
+ if (outroSecs > 0 && outroSecs < duration) {
+ let mixOutSecs = outroSecs;
+ if (beats) {
+ const idx = nearestBeatIndex(beats, outroSecs);
+ mixOutSecs = beats[idx].positionSecs;
+ }
+ raw.push({
+ positionMs: Math.round(mixOutSecs * 1000),
+ label: 'Mix Out',
+ color: OUTRO_COLOR,
+ });
+ }
+
+ // Assign hot cue slots A–H (indices 0–7). Cues beyond index 7 are dropped
+ // since memory cue format is not yet supported (see issue #208).
+ return raw.slice(0, 8).map((cue, i) => ({ ...cue, hotCueIndex: i }));
+}
diff --git a/src/audio/ffmpeg.js b/src/audio/ffmpeg.js
index 9be32b0b..cfdf4885 100644
--- a/src/audio/ffmpeg.js
+++ b/src/audio/ffmpeg.js
@@ -1,6 +1,7 @@
import { spawn } from 'child_process';
import fs from 'fs';
-import { getFfprobeRuntimePath } from '../deps.js';
+import path from 'path';
+import { getFfprobeRuntimePath, getFfmpegRuntimePath } from '../deps.js';
export function ffprobe(filePath) {
const ffprobePath = getFfprobeRuntimePath();
@@ -28,3 +29,46 @@ export function ffprobe(filePath) {
});
});
}
+
+/**
+ * Copy srcPath to destPath via ffmpeg, optionally applying a gain adjustment.
+ * destPath is always overwritten (-y). Parent directory must already exist.
+ */
+export function convertAudio(srcPath, destPath, { gainDb = 0, sourceBitrateKbps = null } = {}) {
+ const ffmpegPath = getFfmpegRuntimePath();
+ if (!fs.existsSync(ffmpegPath))
+ throw new Error(`ffmpeg not found at ${ffmpegPath} — still downloading?`);
+
+ fs.mkdirSync(path.dirname(destPath), { recursive: true });
+
+ const args = ['-y', '-i', srcPath];
+ if (gainDb !== 0) {
+ // Positive gain can push peaks above 0 dBFS — chain a true-peak limiter to prevent
+ // clipping in the output file. alimiter is a no-op when all peaks stay below the limit.
+ const filter =
+ gainDb > 0
+ ? `volume=${gainDb.toFixed(2)}dB,alimiter=level_in=1:level_out=1:limit=1:attack=5:release=50:asc=1`
+ : `volume=${gainDb.toFixed(2)}dB`;
+ args.push('-filter:a', filter);
+ }
+ // Copy video/artwork stream unchanged; re-encode audio only when gain is applied
+ if (gainDb === 0) {
+ args.push('-c', 'copy');
+ } else {
+ args.push('-c:v', 'copy');
+ // Preserve source bitrate to avoid silent quality downgrade (ffmpeg default is 128 kbps)
+ if (sourceBitrateKbps) args.push('-b:a', `${Math.round(sourceBitrateKbps)}k`);
+ }
+ args.push(destPath);
+
+ return new Promise((resolve, reject) => {
+ const proc = spawn(ffmpegPath, args);
+ let err = '';
+ proc.stderr.on('data', (d) => (err += d));
+ proc.on('close', (code) => {
+ if (code !== 0) reject(new Error(err.trim().split('\n').pop() || 'ffmpeg error'));
+ else resolve(destPath);
+ });
+ proc.on('error', reject);
+ });
+}
diff --git a/src/audio/importManager.js b/src/audio/importManager.js
index 40416a78..d9234204 100644
--- a/src/audio/importManager.js
+++ b/src/audio/importManager.js
@@ -7,12 +7,51 @@ import { app } from 'electron';
import { Worker } from 'worker_threads';
import { ffprobe } from './ffmpeg.js';
import { getFfmpegRuntimePath } from '../deps.js';
-import { addTrack, updateTrack, getTrackById, getTrackByHash } from '../db/trackRepository.js';
+import {
+ addTrack,
+ updateTrack,
+ getTrackById,
+ getTrackByHash,
+ getTracksByPaths,
+ updateTrackWaveform,
+} from '../db/trackRepository.js';
import { getAnalyzerRuntimePath } from '../deps.js';
import { getSetting } from '../db/settingsRepository.js';
+import { generateCuePoints } from './cueGen.js';
+import { getCuePoints, addCuePoint } from '../db/cuePointRepository.js';
+import { generateWaveformOverview } from './waveformGenerator.js';
const execFileAsync = promisify(execFile);
+// ─── Analysis progress tracking ─────────────────────────────────────────────
+
+let analysisActive = 0; // workers currently running
+let analysisTotal = 0; // total spawned in the current batch
+let analysisDone = 0; // completed in the current batch
+
+function sendAnalysisProgress() {
+ if (!global.mainWindow) return;
+ global.mainWindow.webContents.send('analysis-progress', {
+ active: analysisActive,
+ total: analysisTotal,
+ done: analysisDone,
+ finished: analysisActive === 0,
+ });
+}
+
+// Map of trackId → Worker for active analysis jobs (enables cancellation)
+const activeAnalysisWorkers = new Map();
+
+export function cancelAnalysis(trackId) {
+ const worker = activeAnalysisWorkers.get(trackId);
+ if (!worker) return false;
+ worker.terminate();
+ activeAnalysisWorkers.delete(trackId);
+ return true;
+}
+
+// ─── File hashing ────────────────────────────────────────────────────────────
+
function hashFile(filePath) {
const hash = crypto.createHash('sha1');
const stream = fs.createReadStream(filePath);
@@ -78,16 +117,40 @@ function parseTags(ffprobeData) {
};
}
-export function spawnAnalysis(trackId, filePath) {
+export function spawnAnalysis(trackId, filePath, { silent = false } = {}) {
+ // Cancel any existing analysis for this track before spawning a new one
+ cancelAnalysis(trackId);
+
+ // Track this worker in the batch counter; reset totals when starting fresh.
+ // Silent re-analyses (e.g. post-normalization) don't affect the progress bar.
+ if (!silent) {
+ if (analysisActive === 0) {
+ analysisTotal = 0;
+ analysisDone = 0;
+ }
+ analysisActive++;
+ analysisTotal++;
+ sendAnalysisProgress();
+ }
+
const worker = new Worker(new URL('./analysisWorker.js', import.meta.url), {
workerData: { filePath, trackId, analyzerPath: getAnalyzerRuntimePath() },
});
+ activeAnalysisWorkers.set(trackId, worker);
+
worker.on('error', (err) => {
+ activeAnalysisWorkers.delete(trackId);
console.error(`Analysis worker error for track ID ${trackId}:`, err.message);
+ if (!silent) {
+ analysisActive--;
+ analysisDone++;
+ sendAnalysisProgress();
+ }
});
worker.on('exit', (code) => {
+ activeAnalysisWorkers.delete(trackId);
if (code !== 0)
console.warn(`Analysis worker exited with code ${code} for track ID ${trackId}`);
});
@@ -95,6 +158,11 @@ export function spawnAnalysis(trackId, filePath) {
worker.on('message', ({ ok, result, error }) => {
if (!ok) {
console.error(`Analysis failed for track ID ${trackId}:`, error);
+ if (!silent) {
+ analysisActive--;
+ analysisDone++;
+ sendAnalysisProgress();
+ }
return;
}
console.log(`Analysis finished for track ID ${trackId}:`, result);
@@ -113,12 +181,61 @@ export function spawnAnalysis(trackId, filePath) {
}
const update = { ...analysisFields, bpm_override: null, ...mergedTags };
+
+ // Re-apply normalization if configured — prevents re-analysis from wiping manual gain
+ const normTarget = getSetting('normalize_target_lufs', null);
+ if (normTarget != null && update.loudness != null) {
+ const parsed = Number(normTarget);
+ if (Number.isFinite(parsed)) {
+ update.replay_gain = Math.round((parsed - update.loudness) * 10) / 10;
+ }
+ }
+
updateTrack(trackId, update);
+ // Generate waveform overview for in-app seek bar (fire-and-forget — does not
+ // block analysis progress or track-updated event)
+ generateWaveformOverview(filePath, getFfmpegRuntimePath())
+ .then((buf) => {
+ updateTrackWaveform(trackId, buf);
+ if (global.mainWindow) {
+ global.mainWindow.webContents.send('waveform-ready', { trackId });
+ }
+ })
+ .catch((err) =>
+ console.warn(`[waveform] overview failed for track ${trackId}:`, err.message)
+ );
+
// Notify renderer
if (global.mainWindow) {
global.mainWindow.webContents.send('track-updated', { trackId, analysis: update });
}
+
+ // Mark this worker as done (silent re-analyses don't affect the counter)
+ if (!silent) {
+ analysisActive--;
+ analysisDone++;
+ sendAnalysisProgress();
+ }
+
+ // Auto-generate cue points: only when setting is enabled and track has no cue points yet
+ const autoCue = getSetting('auto_cue_on_import', 'false') === 'true';
+ if (autoCue) {
+ try {
+ const existing = getCuePoints(trackId);
+ if (existing.length === 0) {
+ const freshTrack = getTrackById(trackId);
+ const generated = generateCuePoints(freshTrack);
+ generated.forEach((cue) => addCuePoint({ trackId, ...cue }));
+ console.log(`[auto-cue] generated ${generated.length} cue points for track ${trackId}`);
+ if (global.mainWindow) {
+ global.mainWindow.webContents.send('cue-points-updated', { trackId });
+ }
+ }
+ } catch (err) {
+ console.error(`[auto-cue] failed for track ${trackId}:`, err.message);
+ }
+ }
});
}
@@ -148,12 +265,29 @@ export async function importAudioFile(filePath, sourceMeta = {}) {
// Extract tags
const { title, artist, album, genre, year, label, bpm } = parseTags(probe);
+ // Fallback: parse "Artist - Title" from filename when artist tag is absent
+ const basename = path.basename(filePath, ext);
+ let resolvedArtist = artist;
+ let resolvedTitle = title;
+ if (!artist) {
+ const dashIdx = basename.indexOf(' - ');
+ if (dashIdx !== -1) {
+ resolvedArtist = basename.slice(0, dashIdx).trim();
+ resolvedTitle = resolvedTitle || basename.slice(dashIdx + 3).trim();
+ }
+ }
+
+ // Last-resort fallback: use channel/uploader name as artist when still empty
+ if (!resolvedArtist && sourceMeta.channel) {
+ resolvedArtist = sourceMeta.channel;
+ }
+
// Extract embedded album art (best-effort, non-blocking)
const artworkPath = await extractArtwork(dest, hash);
const trackId = addTrack({
- title: title || path.basename(filePath, ext),
- artist,
+ title: resolvedTitle || basename,
+ artist: resolvedArtist,
album,
duration,
file_path: dest,
@@ -172,8 +306,74 @@ export async function importAudioFile(filePath, sourceMeta = {}) {
artwork_path: artworkPath ?? null,
});
- console.log(`Added track ID ${trackId}: ${title || path.basename(filePath, ext)}`);
+ console.log(`Added track ID ${trackId}: ${resolvedTitle || basename}`);
spawnAnalysis(trackId, dest);
return trackId;
}
+
+export async function linkAudioFile(filePath) {
+ const byPath = getTracksByPaths([filePath]);
+ if (byPath.length > 0) return { id: byPath[0].id, duplicate: true };
+
+ const basename = path.basename(filePath, path.extname(filePath));
+ let title = basename;
+ let artist = null;
+ let album = null;
+ let duration = 0;
+ let format = path.extname(filePath).slice(1).toLowerCase();
+ let bitrate = null;
+ let year = null;
+ let label = null;
+ let bpm = null;
+ let genre = [];
+
+ try {
+ const meta = await ffprobe(filePath);
+ const tags = meta.format?.tags ?? {};
+ title = tags.title || tags.TITLE || '';
+ artist = tags.artist || tags.ARTIST || null;
+ album = tags.album || tags.ALBUM || null;
+ duration = parseFloat(meta.format?.duration ?? 0);
+ bitrate = parseInt(meta.format?.bit_rate ?? 0, 10) || null;
+ year = parseInt(tags.date || tags.year || '', 10) || null;
+ label = tags.label || tags.publisher || null;
+ bpm = parseFloat(tags.bpm || tags.BPM || '') || null;
+ const g = tags.genre || tags.GENRE || '';
+ genre = g ? [g] : [];
+ } catch {}
+
+ // Fallback: parse "Artist - Title" from filename when tags are absent
+ if (!artist) {
+ const dashIdx = basename.indexOf(' - ');
+ if (dashIdx !== -1) {
+ artist = basename.slice(0, dashIdx).trim();
+ if (!title) title = basename.slice(dashIdx + 3).trim();
+ }
+ }
+
+ const trackId = addTrack({
+ title: title || basename,
+ artist,
+ album,
+ duration,
+ file_path: filePath,
+ file_hash: null,
+ format,
+ bitrate,
+ year,
+ label,
+ bpm,
+ genres: JSON.stringify(genre),
+ source_url: null,
+ source_platform: null,
+ source_quality: null,
+ source_link: null,
+ has_artwork: 0,
+ artwork_path: null,
+ is_linked: 1,
+ });
+
+ spawnAnalysis(trackId, filePath);
+ return { id: trackId, duplicate: false };
+}
diff --git a/src/audio/mediaServer.js b/src/audio/mediaServer.js
index a41da8a8..2193f31d 100644
--- a/src/audio/mediaServer.js
+++ b/src/audio/mediaServer.js
@@ -21,9 +21,10 @@ const IMAGE_MIME = {
/**
* Build the HTTP request handler that serves audio files from `audioBase`
* and optionally artwork files from `artworkBase`.
+ * `allowedBases` is a mutable array; entries added at runtime are respected immediately.
* Exported separately so it can be unit-tested without spinning up a server.
*/
-export function createMediaRequestHandler(audioBase, artworkBase = null) {
+export function createMediaRequestHandler(audioBase, artworkBase = null, allowedBases = []) {
return (req, res) => {
try {
let urlPath = decodeURIComponent(new URL(req.url, 'http://localhost').pathname);
@@ -32,10 +33,11 @@ export function createMediaRequestHandler(audioBase, artworkBase = null) {
urlPath = urlPath.slice(1).replace(/\//g, '\\');
}
- // Security: only serve files inside the managed audio or artwork directories.
+ // Security: only serve files inside the managed audio, artwork, or explorer-linked directories.
const inAudio = urlPath.startsWith(audioBase);
const inArtwork = artworkBase && urlPath.startsWith(artworkBase);
- if (!inAudio && !inArtwork) {
+ const inAllowed = allowedBases.some((base) => urlPath.startsWith(base));
+ if (!inAudio && !inArtwork && !inAllowed) {
res.writeHead(403);
res.end();
return;
@@ -47,11 +49,24 @@ export function createMediaRequestHandler(audioBase, artworkBase = null) {
const mime = IMAGE_MIME[ext] || AUDIO_MIME[ext] || (inArtwork ? 'image/jpeg' : 'audio/mpeg');
const rangeHeader = req.headers['range'];
+ // Allow Web Audio API (createMediaElementSource) to process audio from any
+ // renderer origin. In dev mode the renderer runs at localhost:517x while the
+ // server is 127.0.0.1:PORT — different origins — so without this header
+ // Chromium outputs zeroes and the user hears silence.
+ const corsHeaders = { 'Access-Control-Allow-Origin': '*' };
+
+ if (req.method === 'OPTIONS') {
+ res.writeHead(204, corsHeaders);
+ res.end();
+ return;
+ }
+
if (rangeHeader) {
const [, s, e] = rangeHeader.match(/bytes=(\d+)-(\d*)/) || [];
const start = parseInt(s, 10);
const end = e ? Math.min(parseInt(e, 10), total - 1) : total - 1;
res.writeHead(206, {
+ ...corsHeaders,
'Content-Type': mime,
'Content-Range': `bytes ${start}-${end}/${total}`,
'Accept-Ranges': 'bytes',
@@ -60,6 +75,7 @@ export function createMediaRequestHandler(audioBase, artworkBase = null) {
fs.createReadStream(urlPath, { start, end }).pipe(res);
} else {
res.writeHead(200, {
+ ...corsHeaders,
'Content-Type': mime,
'Accept-Ranges': 'bytes',
'Content-Length': String(total),
@@ -78,11 +94,14 @@ export function createMediaRequestHandler(audioBase, artworkBase = null) {
* Start the local HTTP media server.
* @param {string} audioBase Absolute path to the audio directory.
* @param {string|null} artworkBase Optional absolute path to the artwork directory.
+ * @param {string[]} allowedBases Mutable array of extra allowed base paths (explorer-linked dirs).
* @returns {Promise<{server: http.Server, port: number}>}
*/
-export function startMediaServer(audioBase, artworkBase = null) {
+export function startMediaServer(audioBase, artworkBase = null, allowedBases = []) {
return new Promise((resolve, reject) => {
- const server = http.createServer(createMediaRequestHandler(audioBase, artworkBase));
+ const server = http.createServer(
+ createMediaRequestHandler(audioBase, artworkBase, allowedBases)
+ );
server.listen(0, '127.0.0.1', () => {
const port = server.address().port;
console.log(`[media-server] listening on http://127.0.0.1:${port}`);
diff --git a/src/audio/tidalDlManager.js b/src/audio/tidalDlManager.js
new file mode 100644
index 00000000..a518bca6
--- /dev/null
+++ b/src/audio/tidalDlManager.js
@@ -0,0 +1,645 @@
+/**
+ * tidal-dl-ng download manager.
+ * Wraps the `tdn` CLI (from the tidal-dl-ng Python package).
+ * Authentication uses TIDAL's OAuth device-link flow.
+ */
+import { spawn, execSync } from 'child_process';
+import path from 'path';
+import fs from 'fs';
+import os from 'os';
+
+const AUDIO_EXTS = new Set(['.mp3', '.flac', '.m4a', '.aac', '.wav', '.ogg', '.opus']);
+
+// Embedded Python script for fetching TIDAL track listings via tidalapi.
+// Written to a temp file and executed with the uv-managed Python interpreter.
+const FETCH_INFO_SCRIPT = `
+import sys, json, re
+try:
+ import tidalapi
+except ImportError:
+ print(json.dumps({'ok': False, 'error': 'tidalapi not installed'}))
+ sys.exit(1)
+
+def parse_url(url):
+ patterns = [
+ (r'/album/(\\d+)', 'album'),
+ (r'/playlist/([0-9a-f-]{36})', 'playlist'),
+ (r'/mix/([a-zA-Z0-9_-]+)', 'mix'),
+ (r'/track/(\\d+)', 'track'),
+ ]
+ for pattern, rtype in patterns:
+ m = re.search(pattern, url)
+ if m:
+ return rtype, m.group(1)
+ return None, None
+
+if len(sys.argv) < 3:
+ print(json.dumps({'ok': False, 'error': 'Usage: script.py '}))
+ sys.exit(1)
+
+url = sys.argv[1]
+token_path = sys.argv[2]
+
+try:
+ with open(token_path) as f:
+ token = json.load(f)
+except Exception as e:
+ print(json.dumps({'ok': False, 'error': f'Token error: {str(e)}'}))
+ sys.exit(1)
+
+try:
+ session = tidalapi.Session()
+ session.load_oauth_session(
+ token.get('token_type', 'Bearer'),
+ token['access_token'],
+ token.get('refresh_token')
+ )
+ if not session.check_login():
+ print(json.dumps({'ok': False, 'error': 'Not logged in to TIDAL'}))
+ sys.exit(1)
+except Exception as e:
+ print(json.dumps({'ok': False, 'error': f'Session error: {str(e)}'}))
+ sys.exit(1)
+
+rtype, rid = parse_url(url)
+if not rtype:
+ print(json.dumps({'ok': False, 'error': 'Could not parse TIDAL URL. Use tidal.com/browse/album/123, /track/123, or /playlist/uuid'}))
+ sys.exit(1)
+
+def track_to_entry(t, idx, entry_url=None):
+ return {
+ 'index': idx,
+ 'id': str(t.id),
+ 'title': t.name,
+ 'artist': t.artist.name if t.artist else '',
+ 'duration': t.duration,
+ 'url': entry_url or f'https://tidal.com/browse/track/{t.id}',
+ }
+
+try:
+ if rtype == 'track':
+ t = session.track(int(rid))
+ entries = [track_to_entry(t, 0, url)]
+ title = ((t.artist.name + ' - ') if t.artist else '') + t.name
+ elif rtype == 'album':
+ a = session.album(int(rid))
+ tracks = list(a.tracks())
+ title = a.name
+ entries = [track_to_entry(t, i) for i, t in enumerate(tracks)]
+ elif rtype == 'playlist':
+ pl = session.playlist(rid)
+ tracks = list(pl.tracks())
+ title = pl.name
+ entries = [track_to_entry(t, i) for i, t in enumerate(tracks)]
+ elif rtype == 'mix':
+ print(json.dumps({'ok': True, 'type': 'mix', 'title': 'TIDAL Mix', 'entries': []}))
+ sys.exit(0)
+ else:
+ print(json.dumps({'ok': False, 'error': f'Unsupported type: {rtype}'}))
+ sys.exit(1)
+ print(json.dumps({'ok': True, 'type': rtype, 'title': title, 'entries': entries}))
+except Exception as e:
+ print(json.dumps({'ok': False, 'error': str(e)}))
+ sys.exit(1)
+`;
+
+// Strip ANSI escape codes from terminal output
+function stripAnsi(str) {
+ return str.replace(/\x1B\[[0-9;]*[mGKHFABCDST]/g, '');
+}
+
+/**
+ * Find the `tdn` binary in common locations.
+ * @returns {string|null}
+ */
+export function findTidalDlPath() {
+ const candidates = [
+ path.join(os.homedir(), '.local', 'bin', 'tdn'),
+ path.join(os.homedir(), '.local', 'bin', 'tidal-dl-ng'),
+ '/usr/local/bin/tdn',
+ '/usr/bin/tdn',
+ ];
+
+ if (process.platform === 'win32') {
+ candidates.push(
+ path.join(os.homedir(), '.local', 'bin', 'tdn.exe'),
+ path.join(os.homedir(), 'AppData', 'Roaming', 'Python', 'Scripts', 'tdn.exe'),
+ path.join(os.homedir(), 'AppData', 'Local', 'Programs', 'Python', 'Scripts', 'tdn.exe')
+ );
+ } else if (process.platform === 'darwin') {
+ candidates.push(
+ path.join(os.homedir(), 'Library', 'Python', '3.12', 'bin', 'tdn'),
+ path.join(os.homedir(), 'Library', 'Python', '3.11', 'bin', 'tdn'),
+ '/opt/homebrew/bin/tdn'
+ );
+ }
+
+ for (const candidate of candidates) {
+ if (fs.existsSync(candidate)) return candidate;
+ }
+
+ // Try PATH resolution
+ try {
+ const which = process.platform === 'win32' ? 'where' : 'which';
+ const result = execSync(`${which} tdn`, {
+ encoding: 'utf8',
+ stdio: ['pipe', 'pipe', 'pipe'],
+ }).trim();
+ if (result) return result.split('\n')[0].trim();
+ } catch {
+ /* not in PATH */
+ }
+
+ return null;
+}
+
+/**
+ * Find the Python interpreter bundled with the uv-managed tidal-dl-ng-for-dj environment.
+ * Falls back to system Python if the uv env is not found.
+ * @returns {string|null}
+ */
+export function findTidalPython() {
+ const uvToolDir = path.join(os.homedir(), '.local', 'share', 'uv', 'tools', 'tidal-dl-ng-for-dj');
+ const candidates =
+ process.platform === 'win32'
+ ? [
+ path.join(uvToolDir, 'Scripts', 'python.exe'),
+ path.join(uvToolDir, 'Scripts', 'python3.exe'),
+ ]
+ : [path.join(uvToolDir, 'bin', 'python3'), path.join(uvToolDir, 'bin', 'python')];
+
+ for (const c of candidates) {
+ if (fs.existsSync(c)) return c;
+ }
+
+ // Fall back to system Python
+ const which = process.platform === 'win32' ? 'where' : 'which';
+ for (const cmd of ['python3', 'python']) {
+ try {
+ const result = execSync(`${which} ${cmd}`, {
+ encoding: 'utf8',
+ stdio: ['pipe', 'pipe', 'pipe'],
+ }).trim();
+ if (result) return result.split('\n')[0].trim();
+ } catch {
+ /* not in PATH */
+ }
+ }
+ return null;
+}
+
+/**
+ * Fetch TIDAL track/album/playlist info for a given URL using tidalapi.
+ * Uses the uv-managed Python interpreter and the embedded fetch script.
+ * @param {string} url
+ * @returns {Promise<{ ok: boolean, type?: string, title?: string, entries?: Array, error?: string }>}
+ */
+export async function fetchTidalInfo(url) {
+ const pythonPath = findTidalPython();
+ if (!pythonPath) {
+ return { ok: false, error: 'Python interpreter not found. Ensure tidal-dl-ng is installed.' };
+ }
+
+ const tokenPath = getTokenPath();
+ if (!fs.existsSync(tokenPath)) {
+ return { ok: false, error: 'Not logged in to TIDAL. Please connect your account first.' };
+ }
+
+ // Write the embedded script to a temp file
+ const scriptPath = path.join(os.tmpdir(), 'dj_manager_tidal_fetch.py');
+ try {
+ fs.writeFileSync(scriptPath, FETCH_INFO_SCRIPT.trimStart());
+ } catch (e) {
+ return { ok: false, error: `Failed to write fetch script: ${e.message}` };
+ }
+
+ return new Promise((resolve) => {
+ let stdout = '';
+ let stderr = '';
+
+ const proc = spawn(pythonPath, [scriptPath, url, tokenPath], {
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ proc.stdout.on('data', (chunk) => {
+ stdout += chunk.toString();
+ });
+ proc.stderr.on('data', (chunk) => {
+ stderr += chunk.toString();
+ });
+
+ proc.on('close', () => {
+ try {
+ const result = JSON.parse(stdout.trim());
+ resolve(result);
+ } catch {
+ resolve({ ok: false, error: stderr.trim() || stdout.trim() || 'Failed to parse response' });
+ }
+ });
+
+ proc.on('error', (err) => {
+ resolve({ ok: false, error: err.message });
+ });
+ });
+}
+
+/**
+ * Return all possible tidal-dl-ng config directory base paths.
+ * The fork may use 'tidal_dl_ng' or 'tidal_dl_ng-dev' depending on
+ * how it was installed. We operate on every dir that exists.
+ */
+function getTidalConfigDirs() {
+ let bases;
+ if (process.platform === 'win32') {
+ bases = [
+ path.join(os.homedir(), 'AppData', 'Local', 'tidal_dl_ng'),
+ path.join(os.homedir(), 'AppData', 'Local', 'tidal_dl_ng-dev'),
+ ];
+ } else if (process.platform === 'darwin') {
+ bases = [
+ path.join(os.homedir(), 'Library', 'Application Support', 'tidal_dl_ng'),
+ path.join(os.homedir(), 'Library', 'Application Support', 'tidal_dl_ng-dev'),
+ ];
+ } else {
+ bases = [
+ path.join(os.homedir(), '.config', 'tidal_dl_ng'),
+ path.join(os.homedir(), '.config', 'tidal_dl_ng-dev'),
+ ];
+ }
+ return bases.filter((d) => fs.existsSync(d));
+}
+
+/**
+ * Return the config dir that has a settings.json (prefer the one tdn
+ * is currently writing to, identified by the most-recently-modified file).
+ */
+function getActiveConfigDir() {
+ const dirs = getTidalConfigDirs();
+ if (dirs.length === 0) return null;
+ if (dirs.length === 1) return dirs[0];
+ // Pick whichever settings.json was modified most recently
+ let best = dirs[0];
+ let bestMtime = 0;
+ for (const d of dirs) {
+ try {
+ const mtime = fs.statSync(path.join(d, 'settings.json')).mtimeMs;
+ if (mtime > bestMtime) {
+ bestMtime = mtime;
+ best = d;
+ }
+ } catch {
+ /* no settings.json in this dir */
+ }
+ }
+ return best;
+}
+
+function getTokenPath() {
+ const dir = getActiveConfigDir();
+ const base =
+ dir ??
+ (process.platform === 'win32'
+ ? path.join(os.homedir(), 'AppData', 'Local', 'tidal_dl_ng')
+ : process.platform === 'darwin'
+ ? path.join(os.homedir(), 'Library', 'Application Support', 'tidal_dl_ng')
+ : path.join(os.homedir(), '.config', 'tidal_dl_ng'));
+ return path.join(base, 'token.json');
+}
+
+/**
+ * Clear the download history in ALL tidal config dirs before each download.
+ * tdn skips tracks listed in downloaded_history.json — clearing it ensures
+ * all requested tracks are fetched. The library's SHA-1 dedup prevents
+ * re-importing tracks already in the library.
+ *
+ * The history schema is { _schema_version, settings, tracks: { id: {...} } }
+ * — we preserve schema_version and set tracks to {} and preventDuplicates to false.
+ */
+function clearDownloadHistory() {
+ for (const dir of getTidalConfigDirs()) {
+ const p = path.join(dir, 'downloaded_history.json');
+ try {
+ let existing = {};
+ try {
+ existing = JSON.parse(fs.readFileSync(p, 'utf8'));
+ } catch {
+ /* file missing or corrupt — start fresh */
+ }
+ const cleared = {
+ _schema_version: existing._schema_version ?? 1,
+ _last_updated: new Date().toISOString(),
+ settings: { preventDuplicates: false },
+ tracks: {},
+ };
+ fs.writeFileSync(p, JSON.stringify(cleared));
+ } catch (e) {
+ console.warn('[tidal-dl] failed to clear download history in', dir, ':', e.message);
+ }
+ }
+}
+
+/**
+ * Install tidal-dl-ng via pip, streaming output to onProgress.
+ * Tries pip3 → pip → python3 -m pip → python -m pip in order.
+ * @param {(line: string) => void} onProgress
+ * @returns {Promise}
+ */
+export function installTidalDlNg(onProgress) {
+ const candidates =
+ process.platform === 'win32'
+ ? [
+ ['pip', ['install', 'tidal-dl-ng']],
+ ['python', ['-m', 'pip', 'install', 'tidal-dl-ng']],
+ ]
+ : [
+ ['pip3', ['install', 'tidal-dl-ng']],
+ ['pip', ['install', 'tidal-dl-ng']],
+ ['python3', ['-m', 'pip', 'install', 'tidal-dl-ng']],
+ ['python', ['-m', 'pip', 'install', 'tidal-dl-ng']],
+ ];
+
+ function tryNext(index) {
+ if (index >= candidates.length) {
+ return Promise.reject(
+ new Error('Could not find pip or python. Please install Python 3.12+ and try again.')
+ );
+ }
+ const [cmd, args] = candidates[index];
+ return new Promise((resolve, reject) => {
+ const proc = spawn(cmd, args, {
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+
+ proc.stdout.on('data', (chunk) => {
+ for (const line of chunk.toString().split('\n')) {
+ const t = line.trim();
+ if (t) onProgress(t);
+ }
+ });
+ proc.stderr.on('data', (chunk) => {
+ for (const line of chunk.toString().split('\n')) {
+ const t = line.trim();
+ if (t) onProgress(t);
+ }
+ });
+
+ proc.on('close', (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`${cmd} exited with code ${code}`));
+ });
+ proc.on('error', () => {
+ // This candidate not available — try the next one
+ reject(new Error(`spawn ${cmd} failed`));
+ });
+ }).catch((err) => {
+ console.warn(`[tidal-install] ${err.message} — trying next candidate`);
+ return tryNext(index + 1);
+ });
+ }
+
+ return tryNext(0);
+}
+
+/**
+ * Check if tdn is installed and the user is logged in.
+ * @returns {{ installed: boolean, loggedIn: boolean, path: string|null }}
+ */
+export function checkTidalSetup() {
+ const binPath = findTidalDlPath();
+ if (!binPath) return { installed: false, loggedIn: false, path: null };
+
+ try {
+ const tokenPath = getTokenPath();
+ if (!fs.existsSync(tokenPath)) return { installed: true, loggedIn: false, path: binPath };
+ const token = JSON.parse(fs.readFileSync(tokenPath, 'utf8'));
+ if (!token.access_token) return { installed: true, loggedIn: false, path: binPath };
+ // Treat as logged in even if expiry is close — tidalapi handles token refresh
+ return { installed: true, loggedIn: true, path: binPath };
+ } catch {
+ return { installed: true, loggedIn: false, path: binPath };
+ }
+}
+
+/**
+ * Start the TIDAL OAuth login flow.
+ * Spawns `tdn login`, parses the device-link URL from stdout/stderr,
+ * and calls onUrl once the URL is available.
+ * Resolves when login completes (process exits 0).
+ *
+ * @param {(url: string) => void} onUrl
+ * @returns {Promise}
+ */
+export function startLogin(onUrl) {
+ const binPath = findTidalDlPath();
+ if (!binPath) {
+ return Promise.reject(
+ new Error('tidal-dl-ng not found. Install it with: pip install tidal-dl-ng')
+ );
+ }
+
+ return new Promise((resolve, reject) => {
+ const proc = spawn(binPath, ['login'], {
+ env: { ...process.env, TERM: 'dumb', NO_COLOR: '1', FORCE_COLOR: '0' },
+ });
+
+ let urlSent = false;
+
+ function scanForUrl(text) {
+ if (urlSent) return;
+ // Match TIDAL device-link URLs
+ const match = text.match(/https?:\/\/[^\s]*(link\.tidal\.com|tidal\.com)[^\s]*/i);
+ if (match) {
+ urlSent = true;
+ onUrl(match[0].replace(/[.,;!?]+$/, ''));
+ }
+ }
+
+ proc.stdout.on('data', (chunk) => {
+ const text = stripAnsi(chunk.toString());
+ console.log('[tidal-login] stdout:', text.trim());
+ scanForUrl(text);
+ });
+
+ proc.stderr.on('data', (chunk) => {
+ const text = stripAnsi(chunk.toString());
+ console.log('[tidal-login] stderr:', text.trim());
+ scanForUrl(text);
+ });
+
+ proc.on('close', (code) => {
+ if (code === 0) resolve();
+ else reject(new Error(`tdn login exited with code ${code}`));
+ });
+
+ proc.on('error', reject);
+ });
+}
+
+/**
+ * Recursively scan a directory for audio files newer than a given timestamp.
+ */
+async function scanForAudioFiles(dir, sinceMs) {
+ const results = [];
+ async function walk(current) {
+ let entries;
+ try {
+ entries = await fs.promises.readdir(current, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const full = path.join(current, entry.name);
+ if (entry.isDirectory()) {
+ await walk(full);
+ } else if (AUDIO_EXTS.has(path.extname(entry.name).toLowerCase())) {
+ try {
+ const stat = await fs.promises.stat(full);
+ if (stat.mtimeMs >= sinceMs - 5000) results.push(full);
+ } catch {
+ /* ignore */
+ }
+ }
+ }
+ }
+ await walk(dir);
+ return results;
+}
+
+/**
+ * Download one or more TIDAL URLs using `tdn dl`.
+ * Temporarily sets download_base_path to outputDir, restores after.
+ *
+ * When `onFileReady` is provided, it is called for each audio file as soon as
+ * tdn signals "Downloaded item '...'." — enabling progressive library import.
+ *
+ * @param {string|string[]} urlOrUrls Single URL or array of track URLs
+ * @param {string} outputDir Directory to download into
+ * @param {(msg: string) => void} onProgress
+ * @param {{ onFileReady?: (filePath: string) => void }} [opts]
+ * @returns {Promise} Paths of all downloaded audio files
+ */
+export async function downloadTidal(urlOrUrls, outputDir, onProgress, { onFileReady } = {}) {
+ const binPath = findTidalDlPath();
+ if (!binPath) {
+ throw new Error('tidal-dl-ng not found. Install it with: pip install tidal-dl-ng');
+ }
+
+ await fs.promises.mkdir(outputDir, { recursive: true });
+
+ // Patch settings in ALL config dirs so whichever one tdn reads gets the right values.
+ const allDirs = getTidalConfigDirs();
+ const originalCfgs = new Map();
+ for (const dir of allDirs) {
+ const cfgPath = path.join(dir, 'settings.json');
+ let original = {};
+ try {
+ original = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
+ } catch {
+ /* missing — will create */
+ }
+ originalCfgs.set(cfgPath, original);
+ const patched = {
+ ...original,
+ download_base_path: outputDir,
+ quality_audio: original.quality_audio ?? 'HiRes_Lossless',
+ extract_flac: original.extract_flac ?? true,
+ skip_existing: false,
+ cover_album_file: false,
+ };
+ try {
+ fs.mkdirSync(dir, { recursive: true });
+ fs.writeFileSync(cfgPath, JSON.stringify(patched, null, 2));
+ } catch (e) {
+ console.warn('[tidal-dl] failed to patch config in', dir, ':', e.message);
+ }
+ }
+
+ // Clear download history in all config dirs so tdn never skips tracks.
+ // Library-level SHA-1 dedup prevents re-importing existing tracks.
+ clearDownloadHistory();
+
+ const startTime = Date.now();
+ const urlArray = Array.isArray(urlOrUrls) ? urlOrUrls : [urlOrUrls];
+ // Track which files we've already reported to onFileReady
+ const seenFiles = new Set();
+
+ function restore() {
+ for (const [cfgPath, original] of originalCfgs) {
+ try {
+ fs.writeFileSync(cfgPath, JSON.stringify(original, null, 2));
+ } catch (e) {
+ console.warn('[tidal-dl] failed to restore config', cfgPath, ':', e.message);
+ }
+ }
+ }
+
+ /**
+ * Scan outputDir for newly appeared audio files and call onFileReady for each.
+ * Called after tdn logs "Downloaded item" so we detect files right after each track.
+ */
+ async function reportNewFiles() {
+ if (!onFileReady) return;
+ const allFiles = await scanForAudioFiles(outputDir, startTime);
+ for (const f of allFiles) {
+ if (!seenFiles.has(f)) {
+ seenFiles.add(f);
+ onFileReady(f);
+ }
+ }
+ }
+
+ return new Promise((resolve, reject) => {
+ const proc = spawn(binPath, ['dl', ...urlArray], {
+ env: { ...process.env, TERM: 'dumb', NO_COLOR: '1', FORCE_COLOR: '0' },
+ });
+
+ let stderr = '';
+
+ proc.stdout.on('data', (chunk) => {
+ const text = stripAnsi(chunk.toString());
+ for (const line of text.split('\n')) {
+ const t = line.trim();
+ if (!t) continue;
+ console.log('[tidal-dl] stdout:', t);
+ onProgress(t);
+
+ // tdn logs "Downloaded item 'Artist - Title'." right before it moves the file.
+ // Wait 800ms for the shutil.move to complete, then pick up the new file.
+ if (/Downloaded item '/i.test(t)) {
+ setTimeout(() => reportNewFiles(), 800);
+ }
+ }
+ });
+
+ proc.stderr.on('data', (chunk) => {
+ const text = stripAnsi(chunk.toString());
+ stderr += text;
+ for (const line of text.split('\n')) {
+ const t = line.trim();
+ if (t) console.log('[tidal-dl] stderr:', t);
+ }
+ });
+
+ proc.on('close', async (code) => {
+ restore();
+
+ if (code !== 0) {
+ reject(new Error(`tidal-dl-ng exited with code ${code}: ${stderr.trim().slice(0, 400)}`));
+ return;
+ }
+
+ // Catch any files the progressive scan may have missed (e.g. fast downloads)
+ await reportNewFiles();
+
+ const allFiles = await scanForAudioFiles(outputDir, startTime);
+ resolve(allFiles);
+ });
+
+ proc.on('error', (err) => {
+ restore();
+ reject(err);
+ });
+ });
+}
diff --git a/src/audio/waveformGenerator.js b/src/audio/waveformGenerator.js
index c6e1a6b2..8fb885f6 100644
--- a/src/audio/waveformGenerator.js
+++ b/src/audio/waveformGenerator.js
@@ -12,11 +12,22 @@ export const PWV2_COLS = 100; // PWV2: tiny overview (CDJ-900)
export const PWV4_COLS = 1200; // PWV4: colour overview (NXS2), 6 bytes/col
export const PWV6_COLS = 1200; // PWV6: colour overview for 2EX (CDJ-3000), 3 bytes/col
+// Two-stage EMA cutoffs for frequency band separation (applied to |sample|).
+// α ≈ 2π·f_c / f_s → 0.03 ≈ 105 Hz (bass), 0.28 ≈ 980 Hz (bass+mid)
+const ALPHA_BASS = 0.03;
+const ALPHA_MID = 0.28;
+
// ─── Per-slice analysis ───────────────────────────────────────────────────────
/**
* Compute RMS, peak, and approximate frequency-band energies for a sample slice.
- * Uses a two-stage IIR to separate bass (<~500 Hz) from treble (>~2 kHz).
+ * Uses two cascaded EMA low-pass filters on |sample| to separate bass/mid/treble.
+ * bass ≈ 0–105 Hz (EMA α=0.03)
+ * mid ≈ 105–980 Hz (difference of the two EMAs)
+ * treble ≈ >980 Hz (residual above upper EMA)
+ *
+ * For per-column overview segments (thousands of samples) the EMA settles fully
+ * within the slice, so initialising from the first sample is accurate enough.
*/
function analyzeSlice(samples, start, end) {
const len = end - start;
@@ -25,27 +36,30 @@ function analyzeSlice(samples, start, end) {
let sumSq = 0;
let peak = 0;
let bassSum = 0;
+ let midSum = 0;
let trebleSum = 0;
- // EMA low-pass: alpha=0.1 approximates a ~450 Hz cutoff at 22050 Hz
- let ema = Math.abs(samples[start] || 0);
+ let emaBass = Math.abs(samples[start] || 0);
+ let emaMid = emaBass;
for (let i = start; i < end; i++) {
const s = samples[i] || 0;
const abs = Math.abs(s);
sumSq += s * s;
if (abs > peak) peak = abs;
- ema = 0.1 * abs + 0.9 * ema;
- bassSum += ema;
- trebleSum += Math.max(0, abs - ema);
+ emaBass = ALPHA_BASS * abs + (1 - ALPHA_BASS) * emaBass;
+ emaMid = ALPHA_MID * abs + (1 - ALPHA_MID) * emaMid;
+ bassSum += emaBass;
+ midSum += Math.max(0, emaMid - emaBass);
+ trebleSum += Math.max(0, abs - emaMid);
}
- const rms = Math.sqrt(sumSq / len);
- const bassRms = bassSum / len;
- const trebleRms = trebleSum / len;
- // Mid is energy that sits between bass and treble approximations
- const midRms = Math.max(0, rms - bassRms - trebleRms * 0.5);
-
- return { rms, peak, bassRms, midRms, trebleRms };
+ return {
+ rms: Math.sqrt(sumSq / len),
+ peak,
+ bassRms: bassSum / len,
+ midRms: midSum / len,
+ trebleRms: trebleSum / len,
+ };
}
// ─── Column encoders ──────────────────────────────────────────────────────────
@@ -81,7 +95,7 @@ function computeColumns(samples) {
// PWV3: 1 byte per col — (whiteness[0-7] << 5) | height[0-31]
const pwv3 = Buffer.alloc(numCols);
- // PWV5: 2 bytes per col — correct RGB+height u16be per Pioneer/crate-digger spec:
+ // PWV5: 2 bytes per col — RGB+height u16be per Pioneer/crate-digger spec:
// bits 15-13: red (treble, 3 bits)
// bits 12-10: green (mid, 3 bits)
// bits 9- 7: blue (bass, 3 bits)
@@ -91,15 +105,37 @@ function computeColumns(samples) {
// PWV7: 3 bytes per col — [treble, mid, bass] each 0-255 (CDJ-3000 / .2EX)
const pwv7 = Buffer.alloc(numCols * 3);
+ // Carry EMA state across columns — critical for the bass channel where the
+ // time constant (1/α_bass = 33 samples) is comparable to SAMPLES_PER_COL (147).
+ let emaBass = 0;
+ let emaMid = 0;
+
for (let col = 0; col < numCols; col++) {
const start = col * SAMPLES_PER_COL;
- const { rms, peak, bassRms, midRms, trebleRms } = analyzeSlice(
- samples,
- start,
- start + SAMPLES_PER_COL
- );
- const { height, whiteness } = monoHeightWhiteness(rms, peak);
+ let sumSq = 0;
+ let peak = 0;
+ let bassSum = 0;
+ let midSum = 0;
+ let trebleSum = 0;
+
+ for (let i = start; i < start + SAMPLES_PER_COL; i++) {
+ const s = samples[i] || 0;
+ const abs = Math.abs(s);
+ sumSq += s * s;
+ if (abs > peak) peak = abs;
+ emaBass = ALPHA_BASS * abs + (1 - ALPHA_BASS) * emaBass;
+ emaMid = ALPHA_MID * abs + (1 - ALPHA_MID) * emaMid;
+ bassSum += emaBass;
+ midSum += Math.max(0, emaMid - emaBass);
+ trebleSum += Math.max(0, abs - emaMid);
+ }
+ const rms = Math.sqrt(sumSq / SAMPLES_PER_COL);
+ const bassRms = bassSum / SAMPLES_PER_COL;
+ const midRms = midSum / SAMPLES_PER_COL;
+ const trebleRms = trebleSum / SAMPLES_PER_COL;
+
+ const { height, whiteness } = monoHeightWhiteness(rms, peak);
pwv3[col] = ((whiteness & 7) << 5) | (height & 31);
const r = Math.min(7, Math.round(trebleRms * 28));
@@ -135,20 +171,19 @@ function computeColumns(samples) {
);
// PWV4: 1200 × 6 bytes — colour overview (NXS2)
- // byte 0: whiteness/brightness indicator
- // byte 1: whiteness/brightness indicator
- // byte 2: energy_bottom_half_freq (overall RMS, < ~10 kHz)
- // byte 3: energy_bottom_third_freq (bass, < ~3.5 kHz)
- // byte 4: energy_mid_third_freq (mid, 3.5–7 kHz)
- // byte 5: energy_top_third_freq (treble, > 7 kHz)
+ // byte 0: peak intensity (peak * 255) — confirmed from native files
+ // byte 1: complement (255 - byte0) — native avg b0+b1 ≈ 255
+ // byte 2: overall RMS (rms * 510, capped)
+ // byte 3: bass energy (0–105 Hz)
+ // byte 4: mid energy (105–980 Hz)
+ // byte 5: treble energy (>980 Hz)
const pwv4 = Buffer.concat(
computeFixedColumns(samples, PWV4_COLS, (s, a, b) => {
const { rms, peak, bassRms, midRms, trebleRms } = analyzeSlice(s, a, b);
- const transientRatio = rms > 0.001 ? Math.min(peak / (rms + 0.001), 4) : 0;
- const whiteness = Math.min(255, Math.round(transientRatio * 64));
+ const peakByte = Math.min(255, Math.round(peak * 255));
return Buffer.from([
- whiteness,
- whiteness,
+ peakByte,
+ 255 - peakByte,
Math.min(255, Math.round(rms * 510)),
Math.min(255, Math.round(bassRms * 510)),
Math.min(255, Math.round(midRms * 510)),
@@ -231,3 +266,75 @@ export async function generateWaveform(filePath, ffmpegBin = 'ffmpeg') {
const samples = await extractPcm(filePath, ffmpegBin);
return computeColumns(samples);
}
+
+/**
+ * Generate waveform data optimised for the Beat Grid Editor UI.
+ *
+ * Returns:
+ * detail — pwv7 scroll waveform (3 bytes/col: treble, mid, bass each 0-255)
+ * at COLS_PER_SEC columns per second (variable length)
+ * overview — 4 bytes/col × PWV4_COLS cols [rms, bass, mid, treble] each 0-255
+ * for the full-track navigation strip
+ * numCols — number of detail columns
+ * colsPerSec — COLS_PER_SEC (150)
+ */
+export async function generateEditorWaveform(filePath, ffmpegBin = 'ffmpeg') {
+ const { pwv7, pwv4, numCols } = await generateWaveform(filePath, ffmpegBin);
+ // Build 4-byte/col overview [rms, bass, mid, treble] from pwv4
+ // pwv4 layout: [peak, complement, rms, bass, mid, treble] per col (6 bytes/col)
+ const overview = Buffer.alloc(PWV4_COLS * 4);
+ for (let i = 0; i < PWV4_COLS; i++) {
+ overview[i * 4 + 0] = pwv4[i * 6 + 2]; // rms
+ overview[i * 4 + 1] = pwv4[i * 6 + 3]; // bass
+ overview[i * 4 + 2] = pwv4[i * 6 + 4]; // mid
+ overview[i * 4 + 3] = pwv4[i * 6 + 5]; // treble
+ }
+ return { detail: pwv7, overview, numCols, colsPerSec: COLS_PER_SEC };
+}
+
+/**
+ * Generate a compact waveform overview suitable for in-app seek bar rendering.
+ *
+ * Returns a flat Buffer of PWV4_COLS (1200) columns × 4 bytes each:
+ * [rms, bass, mid, treble] per column, each 0-255.
+ *
+ * Supports all color modes (Classic / RGB / 3-Band) in the renderer.
+ * Total size: 4 800 bytes per track.
+ */
+export async function generateWaveformOverview(filePath, ffmpegBin = 'ffmpeg') {
+ const samples = await extractPcm(filePath, ffmpegBin);
+ const { pwv4 } = computeColumns(samples);
+ // pwv4 layout per column: [peak, 255-peak, rms, bass, mid, treble]
+ const numCols = pwv4.length / 6;
+
+ // Collect raw band values for per-band 95th-percentile normalisation.
+ // EMA-derived values have bass >> mid >> treble by ~10-30x; without this
+ // normalisation every track renders almost entirely blue regardless of
+ // colour mode. Each band is scaled to its own 95th percentile so the full
+ // 0-220 range is used for every channel.
+ const bassArr = new Array(numCols);
+ const midArr = new Array(numCols);
+ const trebleArr = new Array(numCols);
+ for (let i = 0; i < numCols; i++) {
+ bassArr[i] = pwv4[i * 6 + 3];
+ midArr[i] = pwv4[i * 6 + 4];
+ trebleArr[i] = pwv4[i * 6 + 5];
+ }
+
+ const p95 = (arr) => {
+ const sorted = arr.slice().sort((a, b) => a - b);
+ return sorted[Math.floor(sorted.length * 0.95)] || 1;
+ };
+ const maxBass = p95(bassArr);
+ const maxMid = p95(midArr);
+ const maxTreble = p95(trebleArr);
+
+ const out = Buffer.alloc(numCols * 4);
+ for (let i = 0; i < numCols; i++) {
+ out[i * 4 + 0] = pwv4[i * 6 + 2]; // rms (unchanged)
+ out[i * 4 + 1] = Math.min(255, Math.round((bassArr[i] / maxBass) * 220));
+ out[i * 4 + 2] = Math.min(255, Math.round((midArr[i] / maxMid) * 220));
+ out[i * 4 + 3] = Math.min(255, Math.round((trebleArr[i] / maxTreble) * 220));
+ }
+ return out;
+}
diff --git a/src/audio/ytDlpManager.js b/src/audio/ytDlpManager.js
index 0e68249c..f414d133 100644
--- a/src/audio/ytDlpManager.js
+++ b/src/audio/ytDlpManager.js
@@ -136,7 +136,14 @@ function isFormatUnavailableError(err) {
export async function fetchPlaylistInfo(url, options = {}) {
try {
- return await _fetchPlaylistInfoOnce(url, options);
+ const info = await _fetchPlaylistInfoOnce(url, options);
+ // For YouTube playlists, do a fast parallel oEmbed availability check so
+ // unavailable/private/deleted videos are flagged before the selection screen.
+ if (detectPlatform(url) === 'youtube' && info.type === 'playlist') {
+ options.onBeforeCheck?.(info.entries);
+ await checkYouTubeAvailability(info.entries, options.onCheckProgress, options.onEntryChecked);
+ }
+ return info;
} catch (err) {
if (isFormatUnavailableError(err) && options.cookiesBrowser) {
console.warn(
@@ -148,6 +155,125 @@ export async function fetchPlaylistInfo(url, options = {}) {
}
}
+const YTDLP_CHECK_CONCURRENCY = 16;
+const YTDLP_CHECK_TIMEOUT_MS = 15000;
+// Availability values from yt-dlp that mean the video cannot be downloaded
+const UNAVAILABLE_STATUSES = new Set(['private', 'premium_only', 'subscriber_only', 'needs_auth']);
+
+/**
+ * Batch-check YouTube video availability by running yt-dlp --print availability
+ * for each entry. This is the most reliable approach since it uses the exact same
+ * mechanism as the actual download. Mutates entries in-place.
+ */
+async function checkYouTubeAvailability(entries, onProgress, onEntryChecked) {
+ const toCheck = entries.filter((e) => !e.unavailable && e.id);
+ if (toCheck.length === 0) return;
+
+ const ytDlp = getYtDlpRuntimePath();
+ if (!fs.existsSync(ytDlp)) return; // binary not ready yet — skip check
+
+ console.log(`[ytdlp] availability check for ${toCheck.length} entries via yt-dlp…`);
+
+ async function checkOne(entry) {
+ return new Promise((resolve) => {
+ const args = [
+ '--no-playlist',
+ '--print',
+ 'availability',
+ '--no-warnings',
+ '--extractor-args',
+ 'youtube:player_client=web',
+ `https://www.youtube.com/watch?v=${entry.id}`,
+ ];
+ const proc = spawn(ytDlp, args);
+ let stdout = '';
+ let timedOut = false;
+ const timer = setTimeout(() => {
+ timedOut = true;
+ proc.kill();
+ resolve(); // timeout → assume available
+ }, YTDLP_CHECK_TIMEOUT_MS);
+
+ proc.stdout.on('data', (d) => (stdout += d.toString()));
+ proc.on('close', (code) => {
+ clearTimeout(timer);
+ if (timedOut) return;
+ const availability = stdout.trim().toLowerCase();
+ console.log(`[ytdlp] ${entry.id} availability=${availability || '(exit ' + code + ')'}`);
+ if (
+ code !== 0 ||
+ UNAVAILABLE_STATUSES.has(availability) ||
+ availability === 'unavailable'
+ ) {
+ entry.unavailable = true;
+ entry.unavailableReason =
+ availability === 'private'
+ ? 'Private video'
+ : availability === 'premium_only'
+ ? 'YouTube Premium only'
+ : 'Video unavailable';
+ }
+ resolve();
+ });
+ proc.on('error', () => {
+ clearTimeout(timer);
+ resolve(); // spawn error → assume available
+ });
+ });
+ }
+
+ let checked = 0;
+ const total = toCheck.length;
+ const queue = [...toCheck];
+
+ async function worker() {
+ while (queue.length > 0) {
+ const entry = queue.shift();
+ await checkOne(entry);
+ checked++;
+ onProgress?.({ checked, total });
+ onEntryChecked?.({
+ id: entry.id,
+ index: entry.index,
+ unavailable: entry.unavailable ?? false,
+ });
+ }
+ }
+
+ await Promise.allSettled(Array.from({ length: YTDLP_CHECK_CONCURRENCY }, worker));
+ const unavailCount = entries.filter((e) => e.unavailable).length;
+ console.log(`[ytdlp] availability check done — ${unavailCount}/${entries.length} unavailable`);
+}
+
+const UNAVAILABLE_TITLE_RE = /^\[(Private|Deleted|Unavailable|Removed)\s*(video|track)?\]$/i;
+
+const UNAVAILABLE_AVAILABILITY = new Set([
+ 'private',
+ 'premium_only',
+ 'subscriber_only',
+ 'needs_auth',
+ 'exclusive_content',
+]);
+
+function isEntryUnavailable(entry) {
+ if (UNAVAILABLE_AVAILABILITY.has(entry.availability)) return true;
+ if (entry.title && UNAVAILABLE_TITLE_RE.test(entry.title.trim())) return true;
+ return false;
+}
+
+function describeUnavailability(entry) {
+ if (entry.availability === 'private') return 'Private video';
+ if (entry.availability === 'premium_only') return 'YouTube Premium only';
+ if (entry.availability === 'subscriber_only') return 'Channel members only';
+ if (entry.availability === 'needs_auth') return 'Sign-in required';
+ if (entry.availability === 'exclusive_content') return 'Exclusive content';
+ if (entry.title && UNAVAILABLE_TITLE_RE.test(entry.title.trim())) {
+ const m = entry.title.match(UNAVAILABLE_TITLE_RE);
+ return `${m[1]} video`;
+ }
+ return 'Unavailable';
+}
+
function _fetchPlaylistInfoOnce(url, options = {}) {
const ytDlp = getYtDlpRuntimePath();
if (!fs.existsSync(ytDlp)) throw new Error('yt-dlp binary not found. Please reinstall deps.');
@@ -193,13 +319,18 @@ function _fetchPlaylistInfoOnce(url, options = {}) {
resolve({
type: 'playlist',
title: data.title || data.playlist_title || null,
- entries: entries.map((e, i) => ({
- index: i,
- id: e.id || String(i),
- title: e.title || `Track ${i + 1}`,
- url: e.url || e.webpage_url || url,
- duration: e.duration ?? null,
- })),
+ entries: entries.map((e, i) => {
+ const unavailable = isEntryUnavailable(e);
+ return {
+ index: i,
+ id: e.id || String(i),
+ title: e.title || `Track ${i + 1}`,
+ url: e.url || e.webpage_url || url,
+ duration: e.duration ?? null,
+ unavailable,
+ unavailableReason: unavailable ? describeUnavailability(e) : null,
+ };
+ }),
});
} else {
resolve({
@@ -232,7 +363,7 @@ function _fetchPlaylistInfoOnce(url, options = {}) {
* @param {(data: object) => void} [onProgress] - Progress callback, receives { msg, pct, trackPct, overallCurrent, overallTotal }
* @param {{
* cookiesBrowser?: string|null,
- * onFileReady?: (file: { filePath, originalUrl, trackUrl, platform, quality, title, index }) => void,
+ * onFileReady?: (file: { filePath, originalUrl, trackUrl, platform, quality, title, channel, index }) => void,
* onPlaylistDetected?: (info: { name: string|null, total: number }) => void,
* onTrackMeta?: (info: { index: number, title: string }) => void,
* }} [options]
@@ -267,6 +398,8 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
// Unique marker so we can reliably identify --print output lines among other stdout noise
const FILE_MARKER = '__YTDLP_FILE__:';
+ const TRACK_MARKER = '__YTDLP_TRACK__:';
+ const CHANNEL_MARKER = '__YTDLP_CHANNEL__:';
const args = [
'-f',
@@ -280,8 +413,14 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
'0',
'--no-warnings',
'--newline',
- '--progress-template',
- 'download:[download] %(progress._percent_str)s of %(progress._total_bytes_str)s at %(progress._speed_str)s',
+ '--no-colors',
+ '--ignore-errors', // skip unavailable/deleted/restricted videos instead of aborting
+ // Reliable per-track progress: fires before each download starts, goes to stdout
+ '--print',
+ `before_dl:${TRACK_MARKER}%(n_entries|1)s:%(title)s`,
+ // Channel/uploader for artist fallback when video title has no "Artist - Title" delimiter
+ '--print',
+ `before_dl:${CHANNEL_MARKER}%(channel|uploader|NA)s`,
// --print after_move gives us the definitive final filepath after all post-processors
// (audio extraction, remux, etc.) have run. This is our primary file detection mechanism.
'--print',
@@ -305,17 +444,19 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
args.push(url);
return new Promise((resolve, reject) => {
- const proc = spawn(ytDlp, args);
+ const proc = spawn(ytDlp, args, { env: { ...process.env, PYTHONUNBUFFERED: '1' } });
const startTime = Date.now();
let currentQuality = 'unknown';
let playlistTotal = null;
let playlistCurrent = 0;
+ let trackStartCount = 0; // own sequential counter, unaffected by original playlist positions
let playlistName = null;
let currentTrackUrl = null;
let currentTrackPct = 0;
let playlistDetectedFired = false;
let currentTrackTitle = null;
+ let currentTrackChannel = null;
let stderr = '';
const destinationFiles = [];
@@ -331,16 +472,27 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
platform,
quality: currentQuality,
title,
+ channel: currentTrackChannel || null,
index: destinationFiles.length - 1,
});
// Reset per-track state for the next item
currentTrackTitle = null;
+ currentTrackChannel = null;
currentTrackUrl = null;
};
/**
* Process a single output line from yt-dlp (stdout or stderr).
*/
+ let lastProgressSent = 0;
+ const throttledProgress = (data) => {
+ const now = Date.now();
+ if (now - lastProgressSent >= 100) {
+ lastProgressSent = now;
+ onProgress?.(data);
+ }
+ };
+
const processLine = (trimmed) => {
if (!trimmed) return;
@@ -351,7 +503,50 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
return;
}
- // Download progress: [download] 42.5% of 5.20MiB at 1.20MiB/s
+ // Channel/uploader for artist fallback: --print before_dl emits CHANNEL_MARKER:
+ if (trimmed.startsWith(CHANNEL_MARKER)) {
+ const name = trimmed.slice(CHANNEL_MARKER.length).trim();
+ currentTrackChannel = name && name !== 'NA' ? name : null;
+ return;
+ }
+
+ // Reliable track-start marker: --print before_dl emits TRACK_MARKER::
+ // We use our own sequential counter (trackStartCount) so the index is always 1,2,3,4
+ // regardless of the original playlist positions (%(playlist_index)s would give 70 for
+ // a track at position 70 in a 70-item playlist, even if only 4 tracks are selected).
+ if (trimmed.startsWith(TRACK_MARKER)) {
+ const rest = trimmed.slice(TRACK_MARKER.length);
+ const colonIdx = rest.indexOf(':');
+ if (colonIdx !== -1) {
+ const total = parseInt(rest.slice(0, colonIdx), 10);
+ const title = rest.slice(colonIdx + 1).trim();
+ if (!isNaN(total)) {
+ trackStartCount++;
+ const idx = trackStartCount;
+ playlistCurrent = idx;
+ playlistTotal = total;
+ currentTrackPct = 0;
+ currentTrackTitle = title || null;
+ if (!playlistDetectedFired && total > 1) {
+ playlistDetectedFired = true;
+ options.onPlaylistDetected?.({ name: playlistName, total });
+ }
+ if (title) {
+ options.onTrackMeta?.({ index: idx - 1, title });
+ }
+ onProgress?.({
+ msg: title || `Track ${idx} / ${total}`,
+ pct: Math.round(((idx - 1) / total) * 100),
+ trackPct: 0,
+ overallCurrent: idx,
+ overallTotal: total,
+ });
+ }
+ }
+ return;
+ }
+
+ // Download progress with known size: [download] 42.5% of 5.20MiB at 1.20MiB/s ETA 00:03
const pctMatch = trimmed.match(/\[download\]\s+([\d.]+)%/);
if (pctMatch) {
currentTrackPct = Math.round(parseFloat(pctMatch[1]));
@@ -359,11 +554,8 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
const current = playlistCurrent || 1;
const overallPct =
total > 1 ? Math.round(((current - 1) * 100 + currentTrackPct) / total) : currentTrackPct;
- onProgress?.({
- msg: trimmed
- .replace(/^download:/, '')
- .replace('[download] ', '')
- .trim(),
+ throttledProgress({
+ msg: trimmed.replace(/^\[download\]\s+/, '').trim(),
pct: overallPct,
trackPct: currentTrackPct,
overallCurrent: current,
@@ -372,6 +564,23 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
return;
}
+ // Download progress with unknown size: [download] 5.20MiB at 1.20MiB/s
+ const unknownSizeMatch = trimmed.match(
+ /\[download\]\s+([\d.]+\s*\w+iB)\s+at\s+([\d.]+\s*\w+iB\/s)/
+ );
+ if (unknownSizeMatch) {
+ const total = playlistTotal ?? 1;
+ const current = playlistCurrent || 1;
+ throttledProgress({
+ msg: `${unknownSizeMatch[1]} at ${unknownSizeMatch[2]}`,
+ pct: total > 1 ? Math.round(((current - 1) / total) * 100) : 50,
+ trackPct: 50,
+ overallCurrent: current,
+ overallTotal: total,
+ });
+ return;
+ }
+
// Playlist item counter — yt-dlp says "item" on most sites, "video" on some
const itemMatch = trimmed.match(/Downloading (?:item|video) (\d+) of (\d+)/);
if (itemMatch) {
@@ -426,18 +635,54 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
};
proc.stdout.on('data', (chunk) => {
- for (const line of chunk.toString().split('\n')) processLine(line.trim());
+ for (const line of chunk.toString().split(/[\r\n]+/)) processLine(line.trim());
});
proc.stderr.on('data', (chunk) => {
const text = chunk.toString();
stderr += text;
// Also scan stderr — some yt-dlp builds emit info lines there
- for (const line of text.split('\n')) processLine(line.trim());
+ for (const line of text.split(/[\r\n]+/)) processLine(line.trim());
});
+ let unavailableCount = 0;
+
proc.on('close', async (code) => {
- if (code !== 0) {
+ // Parse unavailable/error videos from stderr and fire callbacks.
+ // With --ignore-errors, yt-dlp may emit these as WARNING: lines instead of ERROR:.
+ const unavailablePattern = /(?:ERROR|WARNING): \[[\w:]+\] ([^:\s][^:]*): (.+)/g;
+ let match;
+ while ((match = unavailablePattern.exec(stderr)) !== null) {
+ const videoId = match[1].trim();
+ const reason = match[2].trim();
+ // Only fire for actual unavailability reasons, not generic yt-dlp messages
+ if (
+ reason.toLowerCase().includes('unavailable') ||
+ reason.toLowerCase().includes('private') ||
+ reason.toLowerCase().includes('deleted') ||
+ reason.toLowerCase().includes('removed') ||
+ reason.toLowerCase().includes('not available')
+ ) {
+ console.warn(`[ytdlp] unavailable: ${videoId} — ${reason}`);
+ options.onTrackUnavailable?.({ videoId, reason });
+ unavailableCount++;
+ }
+ }
+
+ // Secondary heuristic: if stderr mentions unavailability but regex found nothing,
+ // treat it as an all-unavailable run so we don't show a raw error.
+ const stderrHasUnavailable =
+ unavailableCount === 0 &&
+ (stderr.includes('Video unavailable') ||
+ stderr.includes('Private video') ||
+ stderr.includes('Deleted video') ||
+ stderr.includes('This video is not available'));
+ if (stderrHasUnavailable) unavailableCount = 1; // sentinel — at least one unavailable
+
+ // Exit code 1 with --ignore-errors means some videos failed. If ALL failures were
+ // unavailability errors (already reported via onTrackUnavailable), resolve gracefully
+ // so the UI can show per-track ✗ marks rather than a raw error string.
+ if (code !== 0 && destinationFiles.length === 0 && unavailableCount === 0) {
reject(new Error(`yt-dlp exited with code ${code}:\n${stderr}`));
return;
}
@@ -479,7 +724,7 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
}
}
- if (destinationFiles.length === 0) {
+ if (destinationFiles.length === 0 && unavailableCount === 0) {
reject(new Error('yt-dlp finished but no output file found'));
return;
}
@@ -497,6 +742,7 @@ async function _downloadUrlOnce(url, onProgress, options = {}) {
index: i,
})),
playlistName: playlistName || null,
+ unavailableCount,
});
});
diff --git a/src/db/cuePointRepository.js b/src/db/cuePointRepository.js
new file mode 100644
index 00000000..db961461
--- /dev/null
+++ b/src/db/cuePointRepository.js
@@ -0,0 +1,65 @@
+import db from './database.js';
+
+export function getCuePoints(trackId) {
+ return db
+ .prepare('SELECT * FROM cue_points WHERE track_id = ? ORDER BY position_ms ASC')
+ .all(trackId);
+}
+
+export function addCuePoint({
+ trackId,
+ positionMs,
+ label = '',
+ color = '#00b4d8',
+ hotCueIndex = -1,
+}) {
+ const info = db
+ .prepare(
+ `INSERT INTO cue_points (track_id, position_ms, label, color, hot_cue_index, created_at)
+ VALUES (?, ?, ?, ?, ?, ?)`
+ )
+ .run(trackId, positionMs, label, color, hotCueIndex, Date.now());
+ return info.lastInsertRowid;
+}
+
+export function updateCuePoint(id, { label, color, hotCueIndex, enabled }) {
+ const fields = [];
+ const vals = [];
+ if (label !== undefined) {
+ fields.push('label = ?');
+ vals.push(label);
+ }
+ if (color !== undefined) {
+ fields.push('color = ?');
+ vals.push(color);
+ }
+ if (hotCueIndex !== undefined) {
+ fields.push('hot_cue_index = ?');
+ vals.push(hotCueIndex);
+ }
+ if (enabled !== undefined) {
+ fields.push('enabled = ?');
+ vals.push(enabled ? 1 : 0);
+ }
+ if (fields.length === 0) return;
+ vals.push(id);
+ db.prepare(`UPDATE cue_points SET ${fields.join(', ')} WHERE id = ?`).run(...vals);
+}
+
+export function deleteCuePoint(id) {
+ db.prepare('DELETE FROM cue_points WHERE id = ?').run(id);
+}
+
+export function deleteAllCuePoints(trackId) {
+ db.prepare('DELETE FROM cue_points WHERE track_id = ?').run(trackId);
+}
+
+export function deleteAllCuePointsLibrary() {
+ // Returns the list of affected track IDs before wiping
+ const affected = db
+ .prepare('SELECT DISTINCT track_id FROM cue_points')
+ .all()
+ .map((r) => r.track_id);
+ db.prepare('DELETE FROM cue_points').run();
+ return affected;
+}
diff --git a/src/db/database.js b/src/db/database.js
index 945de5d5..fe3558dc 100644
--- a/src/db/database.js
+++ b/src/db/database.js
@@ -30,4 +30,10 @@ const db = new Database(dbPath);
db.pragma('journal_mode = WAL'); // Write-Ahead Logging
db.pragma('foreign_keys = ON'); // Enforce foreign keys
+export function closeDB() {
+ try {
+ db.close();
+ } catch {}
+}
+
export default db;
diff --git a/src/db/migrations.js b/src/db/migrations.js
index 1e219d92..0c349f90 100644
--- a/src/db/migrations.js
+++ b/src/db/migrations.js
@@ -32,6 +32,7 @@ export function initDB() {
intro_secs REAL,
outro_secs REAL,
beatgrid TEXT,
+ beatgrid_offset INTEGER DEFAULT 0,
-- User
rating INTEGER,
@@ -64,6 +65,11 @@ export function initDB() {
'ALTER TABLE tracks ADD COLUMN user_tags TEXT',
'ALTER TABLE tracks ADD COLUMN has_artwork INTEGER DEFAULT 0',
'ALTER TABLE tracks ADD COLUMN artwork_path TEXT',
+ 'ALTER TABLE tracks ADD COLUMN normalized_file_path TEXT',
+ 'ALTER TABLE tracks ADD COLUMN source_loudness REAL',
+ 'ALTER TABLE tracks ADD COLUMN beatgrid_offset INTEGER DEFAULT 0',
+ 'ALTER TABLE tracks ADD COLUMN waveform_overview BLOB',
+ 'ALTER TABLE tracks ADD COLUMN is_linked INTEGER DEFAULT 0',
]) {
try {
db.prepare(col).run();
@@ -162,4 +168,30 @@ export function initDB() {
)
`
).run();
+
+ db.prepare(
+ `
+ CREATE TABLE IF NOT EXISTS cue_points (
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
+ track_id INTEGER NOT NULL REFERENCES tracks(id) ON DELETE CASCADE,
+ position_ms REAL NOT NULL,
+ label TEXT NOT NULL DEFAULT '',
+ color TEXT NOT NULL DEFAULT '#00b4d8',
+ hot_cue_index INTEGER NOT NULL DEFAULT -1,
+ created_at INTEGER NOT NULL
+ )
+ `
+ ).run();
+
+ db.prepare(
+ `
+ CREATE INDEX IF NOT EXISTS idx_cue_points_track_id
+ ON cue_points(track_id)
+ `
+ ).run();
+
+ // #209: per-cue export enable/disable toggle
+ try {
+ db.prepare('ALTER TABLE cue_points ADD COLUMN enabled INTEGER NOT NULL DEFAULT 1').run();
+ } catch {}
}
diff --git a/src/db/trackRepository.js b/src/db/trackRepository.js
index 25884189..6be4bc0b 100644
--- a/src/db/trackRepository.js
+++ b/src/db/trackRepository.js
@@ -1,4 +1,5 @@
// src/db/trackRepository.js
+import path from 'path';
import db from './database.js';
// ─── Camelot helpers (mirrors renderer/src/searchParser.js) ─────────────────
@@ -160,14 +161,14 @@ export function addTrack(track) {
file_path, file_hash, format, bitrate,
year, label, genres, bpm,
source_url, source_platform, source_quality, source_link,
- user_tags, has_artwork, artwork_path,
+ user_tags, has_artwork, artwork_path, is_linked,
created_at
) VALUES (
@title, @artist, @album, @duration,
@file_path, @file_hash, @format, @bitrate,
@year, @label, @genres, @bpm,
@source_url, @source_platform, @source_quality, @source_link,
- @user_tags, @has_artwork, @artwork_path,
+ @user_tags, @has_artwork, @artwork_path, @is_linked,
@created_at
)
`);
@@ -192,6 +193,7 @@ export function addTrack(track) {
user_tags: track.user_tags ?? null,
has_artwork: track.has_artwork ?? 0,
artwork_path: track.artwork_path ?? null,
+ is_linked: track.is_linked ?? 0,
created_at: Date.now(),
});
@@ -228,9 +230,11 @@ export function getTracks({ limit = 50, offset = 0, search = '', filters = [], p
return db
.prepare(
`
- SELECT t.*
+ SELECT t.*, COALESCE(cp.cnt, 0) AS cue_count
FROM playlist_tracks pt
JOIN tracks t ON t.id = pt.track_id
+ LEFT JOIN (SELECT track_id, COUNT(*) AS cnt FROM cue_points GROUP BY track_id) cp
+ ON cp.track_id = t.id
WHERE pt.playlist_id = @playlistId ${extra}
ORDER BY pt.position ASC
LIMIT @limit OFFSET @offset
@@ -243,9 +247,12 @@ export function getTracks({ limit = 50, offset = 0, search = '', filters = [], p
return db
.prepare(
`
- SELECT * FROM tracks
+ SELECT t.*, COALESCE(cp.cnt, 0) AS cue_count
+ FROM tracks t
+ LEFT JOIN (SELECT track_id, COUNT(*) AS cnt FROM cue_points GROUP BY track_id) cp
+ ON cp.track_id = t.id
${where}
- ORDER BY created_at DESC
+ ORDER BY t.created_at DESC
LIMIT @limit OFFSET @offset
`
)
@@ -292,6 +299,34 @@ export function getTrackById(id) {
return db.prepare('SELECT * FROM tracks WHERE id = ?').get(id);
}
+/** Returns IDs of all analyzed tracks that can have gain computed. */
+export function getTrackIdsNeedingNormalization() {
+ return db
+ .prepare(`SELECT id FROM tracks WHERE loudness IS NOT NULL`)
+ .all()
+ .map((r) => r.id);
+}
+
+export function getNormalizedTrackCount() {
+ return db
+ .prepare(`SELECT COUNT(*) as cnt FROM tracks WHERE normalized_file_path IS NOT NULL`)
+ .get().cnt;
+}
+
+/** Returns tracks that still have a legacy normalized_file_path set (pre-#260 exports). */
+export function getLegacyNormalizedTracks() {
+ return db
+ .prepare(`SELECT id, normalized_file_path FROM tracks WHERE normalized_file_path IS NOT NULL`)
+ .all();
+}
+
+/** Clears normalized_file_path and source_loudness for all tracks (legacy cleanup). */
+export function clearLegacyNormalizedPaths() {
+ db.prepare(
+ `UPDATE tracks SET normalized_file_path = NULL, source_loudness = NULL WHERE normalized_file_path IS NOT NULL`
+ ).run();
+}
+
export function removeTrack(id) {
db.prepare('DELETE FROM tracks WHERE id = ?').run(id);
}
@@ -309,8 +344,123 @@ export function normalizeLibrary(targetLufs) {
return info.changes ?? 0;
}
+export function normalizeTracksByIds(trackIds, targetLufs) {
+ const update = db.prepare(
+ `UPDATE tracks SET replay_gain = ROUND((? - loudness) * 10) / 10 WHERE id = ? AND loudness IS NOT NULL`
+ );
+ const read = db.prepare(`SELECT replay_gain FROM tracks WHERE id = ?`);
+ const gains = {};
+ db.transaction(() => {
+ for (const id of trackIds) {
+ const info = update.run(targetLufs, id);
+ if (info.changes) {
+ const row = read.get(id);
+ if (row) gains[id] = row.replay_gain;
+ }
+ }
+ })();
+ return gains;
+}
+
+export function resetNormalization(trackIds = null) {
+ if (trackIds && trackIds.length > 0) {
+ const stmt = db.prepare(
+ `UPDATE tracks SET replay_gain = NULL, normalized_file_path = NULL, source_loudness = NULL WHERE id = ?`
+ );
+ db.transaction(() => {
+ for (const id of trackIds) stmt.run(id);
+ })();
+ return trackIds.length;
+ }
+ const info = db
+ .prepare(
+ `UPDATE tracks SET replay_gain = NULL, normalized_file_path = NULL, source_loudness = NULL`
+ )
+ .run();
+ return info.changes ?? 0;
+}
+
export function clearTracks() {
console.log('Clearing all tracks from database');
db.prepare(`DELETE FROM tracks`).run();
db.prepare(`VACUUM`).run();
}
+
+/**
+ * Given an array of { url, id } entry objects, returns a Set of URLs whose
+ * video ID already exists in the library.
+ * Checks source_link, source_url, AND title (yt-dlp stores the video ID in
+ * brackets at the end of the title when source_link is not captured).
+ */
+/**
+ * For each entry check whether a track already exists in the library.
+ * Returns an array of { url, trackId } for every entry that matches.
+ */
+export function getExistingSourceUrls(entries) {
+ if (!entries || entries.length === 0) return [];
+ const results = [];
+ const stmt = db.prepare(
+ `SELECT id FROM tracks
+ WHERE source_link LIKE ? OR source_url LIKE ? OR title LIKE ?
+ LIMIT 1`
+ );
+ for (const { url, id } of entries) {
+ if (!id && !url) continue;
+ const pattern = `%${id || url}%`;
+ const row = stmt.get(pattern, pattern, pattern);
+ if (row) results.push({ url, trackId: row.id });
+ }
+ return results;
+}
+
+export function updateTrackWaveform(trackId, buf) {
+ db.prepare('UPDATE tracks SET waveform_overview = ? WHERE id = ?').run(buf, trackId);
+}
+
+export function getTrackWaveform(trackId) {
+ const row = db.prepare('SELECT waveform_overview FROM tracks WHERE id = ?').get(trackId);
+ return row?.waveform_overview ?? null;
+}
+
+/**
+ * Returns all tracks in a playlist with their source URL fields,
+ * used to determine "already in playlist" status on the selection screen.
+ */
+export function getPlaylistSourceUrls(playlistId) {
+ return db
+ .prepare(
+ `SELECT t.id AS trackId, t.source_url, t.source_link
+ FROM playlist_tracks pt
+ JOIN tracks t ON t.id = pt.track_id
+ WHERE pt.playlist_id = ?`
+ )
+ .all(playlistId);
+}
+
+export function getTracksByPaths(filePaths) {
+ if (!filePaths || filePaths.length === 0) return [];
+ const placeholders = filePaths.map(() => '?').join(',');
+ return db.prepare(`SELECT * FROM tracks WHERE file_path IN (${placeholders})`).all(filePaths);
+}
+
+export function getLinkedTracksBasic() {
+ return db.prepare(`SELECT id, file_path, title, artist FROM tracks WHERE is_linked = 1`).all();
+}
+
+export function getLinkedTrackDirs() {
+ const rows = db.prepare(`SELECT DISTINCT file_path FROM tracks WHERE is_linked = 1`).all();
+ return [...new Set(rows.map((r) => path.dirname(r.file_path)))];
+}
+
+export function remapTracksByPrefix(oldPrefix, newPrefix) {
+ const rows = db
+ .prepare(`SELECT id, file_path FROM tracks WHERE file_path LIKE ?`)
+ .all(oldPrefix + '%');
+ let count = 0;
+ for (const row of rows) {
+ const newPath = newPrefix + row.file_path.slice(oldPrefix.length);
+ db.prepare(`UPDATE tracks SET file_path = ? WHERE id = ?`).run(newPath, row.id);
+ count++;
+ }
+ return count;
+}
diff --git a/src/deps.js b/src/deps.js
index ad51396d..c951b99f 100644
--- a/src/deps.js
+++ b/src/deps.js
@@ -8,9 +8,9 @@ import fs from 'fs';
import https from 'https';
import { createWriteStream } from 'fs';
import { app } from 'electron';
-import { exec } from 'child_process';
+import { exec, spawn } from 'child_process';
import { promisify } from 'util';
-
+import { findTidalDlPath } from './audio/tidalDlManager.js';
const execAsync = promisify(exec);
// ── Paths ─────────────────────────────────────────────────────────────────────
@@ -45,6 +45,10 @@ export function getYtDlpRuntimePath() {
return path.join(getBinDir(), 'yt-dlp');
}
+export function getUvRuntimePath() {
+ return path.join(getBinDir(), process.platform === 'win32' ? 'uv.exe' : 'uv');
+}
+
function versionFile(name) {
return path.join(getBinDir(), `${name}.version`);
}
@@ -67,9 +71,206 @@ export function getInstalledVersions() {
ffmpeg: readVersion('ffmpeg'),
analyzer: readVersion('analyzer'),
ytDlp: readVersion('yt-dlp'),
+ tidalDlNg: readVersion('tidal-dl-ng'),
};
}
+async function getTidalDlNgVersion() {
+ const uvPath = getUvRuntimePath();
+ if (fs.existsSync(uvPath)) {
+ try {
+ const { stdout } = await execAsync(`"${uvPath}" tool list`);
+ const match = stdout.match(/tidal-dl-ng(?:-for-dj)?\s+v?([\d.]+)/i);
+ if (match) return match[1];
+ } catch {
+ /* fall through */
+ }
+ }
+ // Fallback: pip show
+ const cmds =
+ process.platform === 'win32'
+ ? ['pip show tidal-dl-ng', 'python -m pip show tidal-dl-ng']
+ : ['pip3 show tidal-dl-ng', 'pip show tidal-dl-ng', 'python3 -m pip show tidal-dl-ng'];
+ for (const cmd of cmds) {
+ try {
+ const { stdout } = await execAsync(cmd);
+ const match = stdout.match(/^Version:\s*(.+)$/m);
+ if (match) return match[1].trim();
+ } catch {
+ /* try next */
+ }
+ }
+ return 'installed';
+}
+
+async function downloadUvBinary(onProgress) {
+ const { platform, arch } = process;
+ const assetMap = {
+ linux:
+ arch === 'arm64'
+ ? 'uv-aarch64-unknown-linux-gnu.tar.gz'
+ : 'uv-x86_64-unknown-linux-gnu.tar.gz',
+ darwin: arch === 'arm64' ? 'uv-aarch64-apple-darwin.tar.gz' : 'uv-x86_64-apple-darwin.tar.gz',
+ win32: 'uv-x86_64-pc-windows-msvc.zip',
+ };
+ const assetName = assetMap[platform];
+ if (!assetName) throw new Error(`Unsupported platform for uv: ${platform}`);
+
+ const release = await getLatestRelease('astral-sh', 'uv');
+ const asset = release.assets.find((a) => a.name === assetName);
+ if (!asset) throw new Error(`No uv asset found: ${assetName}`);
+
+ const tmp = path.join(app.getPath('temp'), 'djman-uv-dl');
+ await fs.promises.mkdir(tmp, { recursive: true });
+ try {
+ const archive = path.join(tmp, assetName);
+ await downloadFile(
+ asset.browser_download_url,
+ archive,
+ (r, t) => t > 0 && onProgress?.(`Downloading uv… ${Math.round((r / t) * 100)}%`, -1)
+ );
+ const dir = path.join(tmp, 'extracted');
+ if (assetName.endsWith('.tar.gz')) await extractTarGz(archive, dir);
+ else await extractZip(archive, dir);
+
+ const uvBinName = platform === 'win32' ? 'uv.exe' : 'uv';
+ const uvSrc = await findFile(dir, uvBinName);
+ if (!uvSrc) throw new Error('uv binary not found in archive');
+
+ const dest = getUvRuntimePath();
+ fs.mkdirSync(path.dirname(dest), { recursive: true });
+ fs.copyFileSync(uvSrc, dest);
+ if (platform !== 'win32') fs.chmodSync(dest, 0o755);
+ } finally {
+ fs.rmSync(tmp, { recursive: true, force: true });
+ }
+}
+
+async function installTidalDlNgDep(onProgress) {
+ let uvPath = getUvRuntimePath();
+ if (!fs.existsSync(uvPath)) {
+ onProgress?.('Downloading uv…', -1);
+ await downloadUvBinary(onProgress);
+ uvPath = getUvRuntimePath();
+ }
+
+ onProgress?.('Installing tidal-dl-ng…', -1);
+ await new Promise((resolve, reject) => {
+ const proc = spawn(
+ uvPath,
+ ['tool', 'install', '--reinstall', 'git+https://github.com/Radexito/tidal-dl-ng-For-DJ.git'],
+ {
+ env: { ...process.env },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ }
+ );
+ proc.stdout.on('data', (chunk) => {
+ for (const line of chunk.toString().split('\n')) {
+ const t = line.trim();
+ if (t) onProgress?.(t, -1);
+ }
+ });
+ proc.stderr.on('data', (chunk) => {
+ for (const line of chunk.toString().split('\n')) {
+ const t = line.trim();
+ if (t) onProgress?.(t, -1);
+ }
+ });
+ proc.on('close', (code) =>
+ code === 0 ? resolve() : reject(new Error(`uv tool install exited with code ${code}`))
+ );
+ proc.on('error', reject);
+ });
+
+ const version = await getTidalDlNgVersion();
+ writeVersion('tidal-dl-ng', { version, installedAt: new Date().toISOString() });
+}
+
+export { installTidalDlNgDep as ensureTidalDlNg };
+
+async function upgradeTidalDlNgDep(onProgress) {
+ const uvPath = getUvRuntimePath();
+ if (fs.existsSync(uvPath)) {
+ await new Promise((resolve, reject) => {
+ const proc = spawn(
+ uvPath,
+ [
+ 'tool',
+ 'install',
+ '--reinstall',
+ 'git+https://github.com/Radexito/tidal-dl-ng-For-DJ.git',
+ ],
+ {
+ env: { ...process.env },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ }
+ );
+ proc.stdout.on('data', (chunk) => {
+ for (const line of chunk.toString().split('\n')) {
+ const t = line.trim();
+ if (t) onProgress?.(t, -1);
+ }
+ });
+ proc.stderr.on('data', (chunk) => {
+ for (const line of chunk.toString().split('\n')) {
+ const t = line.trim();
+ if (t) onProgress?.(t, -1);
+ }
+ });
+ proc.on('close', (code) =>
+ code === 0 ? resolve() : reject(new Error(`uv tool upgrade exited with code ${code}`))
+ );
+ proc.on('error', reject);
+ });
+ } else {
+ // Fallback: pip upgrade
+ const candidates =
+ process.platform === 'win32'
+ ? [
+ ['pip', ['install', '--upgrade', 'tidal-dl-ng']],
+ ['python', ['-m', 'pip', 'install', '--upgrade', 'tidal-dl-ng']],
+ ]
+ : [
+ ['pip3', ['install', '--upgrade', 'tidal-dl-ng']],
+ ['pip', ['install', '--upgrade', 'tidal-dl-ng']],
+ ['python3', ['-m', 'pip', 'install', '--upgrade', 'tidal-dl-ng']],
+ ['python', ['-m', 'pip', 'install', '--upgrade', 'tidal-dl-ng']],
+ ];
+ let lastErr;
+ for (const [cmd, args] of candidates) {
+ try {
+ await new Promise((resolve, reject) => {
+ const proc = spawn(cmd, args, {
+ env: { ...process.env, PYTHONUNBUFFERED: '1' },
+ stdio: ['ignore', 'pipe', 'pipe'],
+ });
+ proc.stdout.on('data', (chunk) => {
+ for (const line of chunk.toString().split('\n')) {
+ const t = line.trim();
+ if (t) onProgress?.(t, -1);
+ }
+ });
+ proc.stderr.on('data', (chunk) => {
+ for (const line of chunk.toString().split('\n')) {
+ const t = line.trim();
+ if (t) onProgress?.(t, -1);
+ }
+ });
+ proc.on('close', (code) => (code === 0 ? resolve() : reject(new Error(`exit ${code}`))));
+ proc.on('error', reject);
+ });
+ break;
+ } catch (err) {
+ lastErr = err;
+ }
+ }
+ if (lastErr) throw lastErr;
+ }
+
+ const version = await getTidalDlNgVersion();
+ writeVersion('tidal-dl-ng', { version, installedAt: new Date().toISOString() });
+}
+
// ── Readiness ─────────────────────────────────────────────────────────────────
export function areDepsReady() {
@@ -164,16 +365,19 @@ export function getReleaseByTag(owner, repo, tag) {
// ── Archive helpers ───────────────────────────────────────────────────────────
async function extractTarGz(archive, destDir) {
+ if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true, force: true });
await fs.promises.mkdir(destDir, { recursive: true });
await execAsync(`tar -xzf "${archive}" -C "${destDir}"`);
}
async function extractTarXz(archive, destDir) {
+ if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true, force: true });
await fs.promises.mkdir(destDir, { recursive: true });
await execAsync(`tar -xJf "${archive}" -C "${destDir}"`);
}
async function extractZip(archive, destDir) {
+ if (fs.existsSync(destDir)) fs.rmSync(destDir, { recursive: true, force: true });
await fs.promises.mkdir(destDir, { recursive: true });
if (process.platform === 'win32') {
await execAsync(
@@ -210,7 +414,10 @@ async function downloadFFmpeg(tmp, onProgress) {
archive,
(r, t) =>
t > 0 &&
- onProgress?.(`Downloading FFmpeg… ${Math.round((r / t) * 100)}%`, Math.round((r / t) * 100))
+ onProgress?.(`Downloading FFmpeg…`, Math.round((r / t) * 100), {
+ bytesReceived: r,
+ bytesTotal: t,
+ })
);
onProgress?.('Extracting FFmpeg…', 99);
const dir = path.join(tmp, 'ffmpeg-extracted');
@@ -233,7 +440,10 @@ async function downloadFFmpeg(tmp, onProgress) {
archive,
(r, t) =>
t > 0 &&
- onProgress?.(`Downloading FFmpeg… ${Math.round((r / t) * 100)}%`, Math.round((r / t) * 100))
+ onProgress?.(`Downloading FFmpeg…`, Math.round((r / t) * 100), {
+ bytesReceived: r,
+ bytesTotal: t,
+ })
);
onProgress?.('Extracting FFmpeg…', 99);
const dir = path.join(tmp, 'ffmpeg-win-extracted');
@@ -263,17 +473,20 @@ async function downloadFFmpeg(tmp, onProgress) {
ffmpegZip,
(r, t) =>
t > 0 &&
- onProgress?.(`Downloading FFmpeg… ${Math.round((r / t) * 50)}%`, Math.round((r / t) * 50))
+ onProgress?.(`Downloading FFmpeg…`, Math.round((r / t) * 50), {
+ bytesReceived: r,
+ bytesTotal: t,
+ })
);
await downloadFile(
'https://evermeet.cx/ffmpeg/getrelease/ffprobe/zip',
ffprobeZip,
(r, t) =>
t > 0 &&
- onProgress?.(
- `Downloading FFprobe… ${50 + Math.round((r / t) * 49)}%`,
- 50 + Math.round((r / t) * 49)
- )
+ onProgress?.(`Downloading FFprobe…`, 50 + Math.round((r / t) * 49), {
+ bytesReceived: r,
+ bytesTotal: t,
+ })
);
onProgress?.('Extracting FFmpeg…', 99);
await extractZip(ffmpegZip, path.join(tmp, 'ffmpeg-mac'));
@@ -323,10 +536,10 @@ async function downloadAnalyzer(tmp, onProgress) {
archive,
(r, t) =>
t > 0 &&
- onProgress?.(
- `Downloading mixxx-analyzer… ${Math.round((r / t) * 100)}%`,
- Math.round((r / t) * 100)
- )
+ onProgress?.(`Downloading mixxx-analyzer…`, Math.round((r / t) * 100), {
+ bytesReceived: r,
+ bytesTotal: t,
+ })
);
onProgress?.('Extracting mixxx-analyzer…', 99);
@@ -410,7 +623,10 @@ async function downloadYtDlp(tmp, onProgress, tag = null) {
dest,
(r, t) =>
t > 0 &&
- onProgress?.(`Downloading yt-dlp… ${Math.round((r / t) * 100)}%`, Math.round((r / t) * 100))
+ onProgress?.(`Downloading yt-dlp…`, Math.round((r / t) * 100), {
+ bytesReceived: r,
+ bytesTotal: t,
+ })
);
if (platform !== 'win32') fs.chmodSync(dest, 0o755);
@@ -429,31 +645,106 @@ export async function ensureDeps(onProgress) {
fs.existsSync(getFfmpegRuntimePath()) && fs.existsSync(getFfprobeRuntimePath());
const analyzerReady = fs.existsSync(getAnalyzerRuntimePath());
const ytDlpReady = fs.existsSync(getYtDlpRuntimePath());
- if (ffmpegReady && analyzerReady && ytDlpReady) return;
+ const tidalReady = Boolean(findTidalDlPath());
+ if (ffmpegReady && analyzerReady && ytDlpReady && tidalReady) return;
const binDir = getBinDir();
await fs.promises.mkdir(binDir, { recursive: true });
const tmp = path.join(app.getPath('temp'), 'djman-deps');
await fs.promises.mkdir(tmp, { recursive: true });
- const totalSteps = (!ffmpegReady ? 1 : 0) + (!analyzerReady ? 1 : 0) + (!ytDlpReady ? 1 : 0);
- let step = 0;
- const stepCb = (msg, pct) => onProgress?.(`[${step}/${totalSteps}] ${msg}`, pct);
+ const STEP_DEFS = [
+ !ffmpegReady && { id: 'ffmpeg', label: 'FFmpeg' },
+ !analyzerReady && { id: 'analyzer', label: 'mixxx-analyzer' },
+ !ytDlpReady && { id: 'ytdlp', label: 'yt-dlp' },
+ !tidalReady && { id: 'tidal', label: 'tidal-dl-ng' },
+ ].filter(Boolean);
+ const totalSteps = STEP_DEFS.length;
+ let stepIndex = 0;
+ let currentStep = null;
+
+ // Per-step speed/ETA tracker — reset when step changes
+ let _lastBytes = 0,
+ _lastBytesTime = Date.now(),
+ _speedSamples = [];
+ const resetTracker = () => {
+ _lastBytes = 0;
+ _lastBytesTime = Date.now();
+ _speedSamples = [];
+ };
+
+ const stepCb = (msg, pct, meta = {}) => {
+ let bytesPerSec = 0,
+ etaSec = -1;
+ const { bytesReceived, bytesTotal } = meta;
+ if (bytesReceived != null && bytesTotal > 0) {
+ const now = Date.now();
+ const dt = (now - _lastBytesTime) / 1000;
+ if (dt > 0.25) {
+ const speed = (bytesReceived - _lastBytes) / dt;
+ _speedSamples = [..._speedSamples.slice(-4), speed];
+ _lastBytesTime = now;
+ _lastBytes = bytesReceived;
+ }
+ const avg = _speedSamples.length
+ ? _speedSamples.reduce((a, b) => a + b) / _speedSamples.length
+ : 0;
+ bytesPerSec = avg;
+ etaSec = avg > 0 ? (bytesTotal - bytesReceived) / avg : -1;
+ }
+ onProgress?.({
+ msg,
+ pct,
+ stepId: currentStep?.id ?? null,
+ stepLabel: currentStep?.label ?? null,
+ stepIndex,
+ stepTotal: totalSteps,
+ stepPct: pct,
+ bytesDownloaded: bytesReceived ?? 0,
+ bytesTotal: bytesTotal ?? -1,
+ bytesPerSec,
+ etaSec,
+ });
+ };
try {
if (!ffmpegReady) {
- step++;
+ currentStep = STEP_DEFS.find((s) => s.id === 'ffmpeg');
+ stepIndex++;
+ resetTracker();
await downloadFFmpeg(tmp, stepCb);
}
if (!analyzerReady) {
- step++;
+ currentStep = STEP_DEFS.find((s) => s.id === 'analyzer');
+ stepIndex++;
+ resetTracker();
await downloadAnalyzer(tmp, stepCb);
}
if (!ytDlpReady) {
- step++;
+ currentStep = STEP_DEFS.find((s) => s.id === 'ytdlp');
+ stepIndex++;
+ resetTracker();
await downloadYtDlp(tmp, stepCb);
}
- onProgress?.('Setup complete.', 100);
+ if (!tidalReady) {
+ currentStep = STEP_DEFS.find((s) => s.id === 'tidal');
+ stepIndex++;
+ resetTracker();
+ stepCb('Installing tidal-dl-ng…', 0);
+ try {
+ await installTidalDlNgDep((msg) => stepCb(msg, -1));
+ stepCb('tidal-dl-ng installed.', 100);
+ } catch (err) {
+ console.warn('[deps] tidal-dl-ng install failed (non-fatal):', err.message);
+ stepCb('tidal-dl-ng install failed — Python 3.12+ may not be available.', -1);
+ }
+ }
+ onProgress?.({
+ msg: 'Setup complete.',
+ pct: 100,
+ stepIndex: totalSteps,
+ stepTotal: totalSteps,
+ });
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
}
@@ -516,15 +807,32 @@ export async function updateYtDlp(onProgress, tag = null) {
}
}
+export async function updateTidalDlNg(onProgress) {
+ try {
+ onProgress?.('Upgrading tidal-dl-ng…', 0);
+ await upgradeTidalDlNgDep(onProgress);
+ onProgress?.('tidal-dl-ng updated.', 100);
+ } catch (err) {
+ onProgress?.(`tidal-dl-ng update failed: ${err.message}`, -1);
+ throw err;
+ }
+}
+
export async function updateAll(onProgress) {
const binDir = getBinDir();
await fs.promises.mkdir(binDir, { recursive: true });
const tmp = path.join(app.getPath('temp'), 'djman-deps');
await fs.promises.mkdir(tmp, { recursive: true });
try {
- await downloadFFmpeg(tmp, (msg, pct) => onProgress?.(`[1/3] ${msg}`, pct));
- await downloadAnalyzer(tmp, (msg, pct) => onProgress?.(`[2/3] ${msg}`, pct));
- await downloadYtDlp(tmp, (msg, pct) => onProgress?.(`[3/3] ${msg}`, pct));
+ await downloadFFmpeg(tmp, (msg, pct) => onProgress?.(`[1/4] ${msg}`, pct));
+ await downloadAnalyzer(tmp, (msg, pct) => onProgress?.(`[2/4] ${msg}`, pct));
+ await downloadYtDlp(tmp, (msg, pct) => onProgress?.(`[3/4] ${msg}`, pct));
+ onProgress?.('[4/4] Upgrading tidal-dl-ng…', 0);
+ try {
+ await upgradeTidalDlNgDep((msg) => onProgress?.(`[4/4] ${msg}`, -1));
+ } catch (err) {
+ console.warn('[deps] tidal-dl-ng upgrade failed (non-fatal):', err.message);
+ }
onProgress?.('All dependencies updated.', 100);
} finally {
fs.rmSync(tmp, { recursive: true, force: true });
diff --git a/src/main.js b/src/main.js
index 71413a98..1c851e7c 100644
--- a/src/main.js
+++ b/src/main.js
@@ -1,7 +1,8 @@
import path from 'path';
import fs from 'fs';
+import os from 'os';
import { fileURLToPath } from 'url';
-import { app, BrowserWindow, ipcMain, dialog, Menu, shell } from 'electron';
+import { app, BrowserWindow, ipcMain, dialog, Menu, MenuItem, shell } from 'electron';
// Fix for Linux/Wayland + AMD radeonsi/Mesa stability issues.
// Root cause chain (diagnosed 2025-03):
@@ -14,6 +15,8 @@ import { app, BrowserWindow, ipcMain, dialog, Menu, shell } from 'electron';
//
// NOTE: --ozone-platform=wayland is ONLY set when WAYLAND_DISPLAY is present.
// Forcing Wayland on X11/xvfb (e.g. CI) breaks Playwright click interactions.
+app.name = 'Dj Manager';
+
if (process.platform === 'linux') {
app.disableHardwareAcceleration();
if (process.env.WAYLAND_DISPLAY) {
@@ -26,6 +29,7 @@ if (process.platform === 'linux') {
app.commandLine.appendSwitch('no-zygote');
}
import { initDB } from './db/migrations.js';
+import { closeDB } from './db/database.js';
import {
createPlaylist,
findOrCreatePlaylist,
@@ -47,13 +51,34 @@ import {
getTracks,
getTrackIds,
getTrackById,
+ getTracksByPaths,
+ getLinkedTrackDirs,
+ getLinkedTracksBasic,
+ remapTracksByPrefix,
removeTrack,
updateTrack,
- normalizeLibrary,
+ resetNormalization,
clearTracks,
+ getTrackIdsNeedingNormalization,
+ getNormalizedTrackCount,
+ getLegacyNormalizedTracks,
+ clearLegacyNormalizedPaths,
+ normalizeLibrary,
+ normalizeTracksByIds,
+ getExistingSourceUrls,
+ getPlaylistSourceUrls,
+ getTrackWaveform,
+ updateTrackWaveform,
} from './db/trackRepository.js';
import { getSetting, setSetting } from './db/settingsRepository.js';
-import { importAudioFile, spawnAnalysis, getLibraryBase } from './audio/importManager.js';
+import {
+ importAudioFile,
+ linkAudioFile,
+ spawnAnalysis,
+ cancelAnalysis,
+ getLibraryBase,
+} from './audio/importManager.js';
+import { convertAudio } from './audio/ffmpeg.js';
import {
searchMusicBrainz,
@@ -65,12 +90,22 @@ import {
downloadUrl as ytDlpDownloadUrl,
fetchPlaylistInfo as ytDlpFetchPlaylistInfo,
} from './audio/ytDlpManager.js';
+import {
+ checkTidalSetup,
+ startLogin as tidalStartLogin,
+ downloadTidal,
+ fetchTidalInfo,
+} from './audio/tidalDlManager.js';
+import { generateWaveformOverview } from './audio/waveformGenerator.js';
import { ensureDeps, getFfmpegRuntimePath } from './deps.js';
+import { generateEditorWaveform } from './audio/waveformGenerator.js';
import {
getInstalledVersions,
checkForUpdates,
updateAnalyzer,
updateYtDlp,
+ updateTidalDlNg,
+ ensureTidalDlNg,
updateAll,
} from './deps.js';
import { initLogger, getLogDir } from './logger.js';
@@ -78,6 +113,16 @@ import { detectFilesystem, formatDrive, describeFilesystem } from './usb/usbUtil
import { writeAnlz, getAnlzFolder } from './audio/anlzWriter.js';
import { writeSettingFiles } from './usb/settingWriter.js';
import { writePdb } from './usb/pdbWriter.js';
+import { getResetCleanupTargets, startResetCleanup } from './resetCleanup.js';
+import {
+ getCuePoints,
+ addCuePoint,
+ updateCuePoint,
+ deleteCuePoint,
+ deleteAllCuePoints,
+ deleteAllCuePointsLibrary,
+} from './db/cuePointRepository.js';
+import { generateCuePoints } from './audio/cueGen.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -93,10 +138,15 @@ import { writeId3Tags } from './audio/id3Writer.js';
// unreliable Range support in Electron 28+ and cause PIPELINE_ERROR_READ on seek.
let mediaServerPort = null;
+// Mutable list of extra allowed base paths for the media server.
+// Push the explorer root folder here when the user picks one so the server
+// will serve files from that directory tree.
+const explorerAllowedBases = [];
+
function startMediaServer() {
const audioBase = path.join(app.getPath('userData'), 'audio');
const artworkBase = getArtworkBase();
- return _startMediaServer(audioBase, artworkBase).then(({ port }) => {
+ return _startMediaServer(audioBase, artworkBase, explorerAllowedBases).then(({ port }) => {
mediaServerPort = port;
});
}
@@ -107,6 +157,7 @@ function createWindow() {
width: 1200,
height: 800,
backgroundColor: '#0f0f0f',
+ icon: path.join(app.getAppPath(), 'build-resources/icon.png'),
webPreferences: {
preload: path.join(__dirname, 'preload.js'),
contextIsolation: true,
@@ -117,14 +168,41 @@ function createWindow() {
global.mainWindow = mainWindow; // make accessible to workers
mainWindow.maximize();
+ // Native right-click context menu for editable inputs and text selections
+ mainWindow.webContents.on('context-menu', (_e, params) => {
+ const menu = new Menu();
+ if (params.isEditable) {
+ if (params.editFlags.canUndo) menu.append(new MenuItem({ role: 'undo', label: 'Undo' }));
+ if (params.editFlags.canRedo) menu.append(new MenuItem({ role: 'redo', label: 'Redo' }));
+ if (params.editFlags.canUndo || params.editFlags.canRedo)
+ menu.append(new MenuItem({ type: 'separator' }));
+ menu.append(new MenuItem({ role: 'cut', label: 'Cut', enabled: params.editFlags.canCut }));
+ menu.append(new MenuItem({ role: 'copy', label: 'Copy', enabled: params.editFlags.canCopy }));
+ menu.append(
+ new MenuItem({ role: 'paste', label: 'Paste', enabled: params.editFlags.canPaste })
+ );
+ menu.append(new MenuItem({ type: 'separator' }));
+ menu.append(new MenuItem({ role: 'selectAll', label: 'Select All' }));
+ } else if (params.selectionText) {
+ menu.append(new MenuItem({ role: 'copy', label: 'Copy' }));
+ }
+ if (menu.items.length > 0) menu.popup();
+ });
+
if (process.env.E2E_TEST === '1') {
mainWindow.loadFile(path.join(__dirname, '../renderer/dist/index.html'));
} else if (!app.isPackaged) {
mainWindow.loadURL(fs.readFileSync(path.join(__dirname, '../.dev-url'), 'utf8').trim());
mainWindow.webContents.openDevTools();
+ // Forward renderer console to terminal so we can debug without DevTools window
+ mainWindow.webContents.on('console-message', (_e, level, msg) => {
+ const tag =
+ ['[renderer:verbose]', '[renderer:info]', '[renderer:warn]', '[renderer:error]'][level] ??
+ '[renderer]';
+ console.log(tag, msg);
+ });
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/dist/index.html'));
- // Block DevTools keyboard shortcut in production
mainWindow.webContents.on('before-input-event', (event, input) => {
if (input.key === 'F12' || (input.control && input.shift && input.key === 'I')) {
event.preventDefault();
@@ -133,10 +211,114 @@ function createWindow() {
}
}
+function logDiagnostics() {
+ const userData = app.getPath('userData');
+ const binDir = path.join(userData, 'bin');
+ const keyPaths = {
+ userData,
+ bin: binDir,
+ 'ffmpeg.exe': path.join(binDir, 'ffmpeg', 'ffmpeg.exe'),
+ 'ffprobe.exe': path.join(binDir, 'ffmpeg', 'ffprobe.exe'),
+ 'analysis.exe': path.join(binDir, 'analysis.exe'),
+ 'yt-dlp.exe': path.join(binDir, 'yt-dlp.exe'),
+ };
+
+ console.log('[diag] ── Windows 11 diagnostics ──────────────────────────');
+ console.log(`[diag] os.platform = ${os.platform()}`);
+ console.log(`[diag] os.release = ${os.release()}`);
+ console.log(`[diag] os.version = ${os.version()}`);
+ console.log(`[diag] process.arch = ${process.arch}`);
+ console.log(`[diag] app.version = ${app.getVersion()}`);
+ console.log('[diag] key paths (length / exists):');
+ for (const [label, p] of Object.entries(keyPaths)) {
+ const exists = fs.existsSync(p);
+ const tooLong = p.length >= 260;
+ console.log(
+ `[diag] ${label.padEnd(14)} len=${p.length}${tooLong ? ' ⚠ NEAR/OVER MAX_PATH' : ''} exists=${exists} ${p}`
+ );
+ }
+ console.log('[diag] ─────────────────────────────────────────────────────');
+}
+
+async function autoGenerateMissingWaveforms() {
+ const tracks = getTracks({ limit: 999999 });
+ const missing = tracks.filter((t) => t.analyzed === 1 && t.waveform_overview == null);
+ if (missing.length === 0) return;
+
+ console.log(`[waveform] generating overviews for ${missing.length} tracks…`);
+ let completed = 0;
+
+ const sendProgress = (done = false) => {
+ if (global.mainWindow) {
+ global.mainWindow.webContents.send('waveform-gen-progress', {
+ completed,
+ total: missing.length,
+ done,
+ });
+ }
+ };
+
+ for (const track of missing) {
+ try {
+ const buf = await generateWaveformOverview(track.file_path, getFfmpegRuntimePath());
+ updateTrackWaveform(track.id, buf);
+ } catch (err) {
+ console.warn(`[waveform] failed for track ${track.id}:`, err.message);
+ }
+ completed++;
+ sendProgress();
+ }
+
+ sendProgress(true);
+ console.log(`[waveform] done — generated ${completed} overviews`);
+}
+
+function cleanupLegacyNormalizedFiles() {
+ const tracks = getLegacyNormalizedTracks();
+ if (tracks.length === 0) return;
+ let deleted = 0;
+ for (const t of tracks) {
+ try {
+ if (fs.existsSync(t.normalized_file_path)) {
+ fs.unlinkSync(t.normalized_file_path);
+ deleted++;
+ }
+ } catch (err) {
+ console.warn(
+ `[cleanup] could not delete legacy normalized file ${t.normalized_file_path}:`,
+ err.message
+ );
+ }
+ }
+ clearLegacyNormalizedPaths();
+ console.log(
+ `[cleanup] removed ${deleted} legacy normalized file(s), cleared ${tracks.length} DB entries`
+ );
+}
+
+let _lastDepLog = '';
+function sendDepsProgress(data) {
+ if (data && (data.pct === 0 || data.pct === 100) && data.msg !== _lastDepLog) {
+ _lastDepLog = data.msg;
+ console.log('[deps]', data.msg);
+ }
+ if (global.mainWindow) global.mainWindow.webContents.send('deps-progress', data);
+}
+
async function initApp() {
initLogger();
+ if (process.platform === 'win32') logDiagnostics();
console.log('Initializing database...');
initDB();
+ cleanupLegacyNormalizedFiles();
+ // Ensure the normalization target is stored so replay_gain is computed for every
+ // newly-analyzed track even before the user visits the Settings page.
+ if (getSetting('normalize_target_lufs') == null) setSetting('normalize_target_lufs', '-9');
+ // Pre-allow all directories of existing linked tracks so the media server
+ // can serve them without requiring the user to re-open the Explorer.
+ for (const dir of getLinkedTrackDirs()) {
+ if (!explorerAllowedBases.includes(dir)) explorerAllowedBases.push(dir);
+ }
await startMediaServer();
console.log('Creating window.');
createWindow();
@@ -146,24 +328,15 @@ async function initApp() {
if (process.env.E2E_TEST === '1') return;
// Download deps if not already present
- let _lastDepLog = '';
- ensureDeps((msg, pct) => {
- if ((pct === 0 || pct === 100 || pct === undefined) && msg !== _lastDepLog) {
- _lastDepLog = msg;
- console.log('[deps]', msg);
- }
- if (global.mainWindow) global.mainWindow.webContents.send('deps-progress', { msg, pct });
- })
+ ensureDeps(sendDepsProgress)
.then(() => {
- if (global.mainWindow) global.mainWindow.webContents.send('deps-progress', null);
+ sendDepsProgress(null);
+ // Auto-generate waveforms for any analyzed tracks missing overview data
+ autoGenerateMissingWaveforms();
})
.catch((err) => {
console.error('[deps] Failed to download FFmpeg:', err.message);
- if (global.mainWindow)
- global.mainWindow.webContents.send('deps-progress', {
- msg: `Error: ${err.message}`,
- pct: -1,
- });
+ sendDepsProgress({ msg: `Error: ${err.message}`, pct: -1, error: err.message });
});
Menu.setApplicationMenu(null);
@@ -175,8 +348,20 @@ async function initApp() {
// IPC Handlers
ipcMain.handle('get-media-port', () => mediaServerPort);
+
+ipcMain.handle('retry-deps', () => {
+ ensureDeps(sendDepsProgress)
+ .then(() => sendDepsProgress(null))
+ .catch((err) =>
+ sendDepsProgress({ msg: `Error: ${err.message}`, pct: -1, error: err.message })
+ );
+});
ipcMain.handle('get-tracks', (_, params) => getTracks(params));
ipcMain.handle('get-track-ids', (_, params) => getTrackIds(params));
+ipcMain.handle('get-track-waveform', (_, trackId) => {
+ const buf = getTrackWaveform(trackId);
+ return buf ? new Uint8Array(buf) : null;
+});
ipcMain.handle('get-setting', (_, key, def) => getSetting(key, def));
ipcMain.handle('set-setting', (_, key, value) => setSetting(key, value));
ipcMain.handle('get-library-path', () => getLibraryBase());
@@ -228,30 +413,107 @@ ipcMain.handle('move-library', async (event, newDir) => {
return { moved, total };
});
-ipcMain.handle('normalize-library', (_, { targetLufs }) => {
- const parsed = Number(targetLufs);
- if (!Number.isFinite(parsed) || parsed < -60 || parsed > 0) {
- throw new Error(`Invalid targetLufs: must be a finite number between -60 and 0`);
+ipcMain.handle('normalize-library', () => {
+ const targetLufs = Number(getSetting('normalize_target_lufs', '-9'));
+ const normalized = normalizeLibrary(targetLufs);
+ const trackIds = getTrackIdsNeedingNormalization();
+ // Push updated replay_gain to renderer for every affected track
+ if (global.mainWindow) {
+ for (const trackId of trackIds) {
+ const track = getTrackById(trackId);
+ if (track?.replay_gain != null) {
+ global.mainWindow.webContents.send('track-updated', {
+ trackId,
+ analysis: { replay_gain: track.replay_gain },
+ });
+ }
+ }
+ global.mainWindow.webContents.send('normalize-progress', {
+ completed: normalized,
+ total: normalized,
+ done: true,
+ });
+ }
+ return { normalized, skipped: 0, total: normalized };
+});
+
+ipcMain.handle('reset-normalization', (_, { trackIds } = {}) => {
+ const ids = trackIds?.length ? trackIds : null;
+ const updated = resetNormalization(ids);
+ // Notify renderer so replay_gain is cleared in the track list
+ if (global.mainWindow) {
+ const affectedIds = ids ?? getTrackIdsNeedingNormalization();
+ for (const id of affectedIds) {
+ global.mainWindow.webContents.send('track-updated', {
+ trackId: id,
+ analysis: { replay_gain: null },
+ });
+ }
}
- const updated = normalizeLibrary(parsed);
- setSetting('normalize_target_lufs', String(parsed));
return { updated };
});
+
+ipcMain.handle('get-normalized-count', () => getNormalizedTrackCount());
+
+ipcMain.handle('normalize-tracks-audio', (_, { trackIds }) => {
+ const targetLufs = Number(getSetting('normalize_target_lufs', '-9'));
+ const gains = normalizeTracksByIds(trackIds, targetLufs);
+ const normalized = Object.keys(gains).length;
+ const skipped = trackIds.length - normalized;
+ // Push updated replay_gain to renderer
+ if (global.mainWindow) {
+ for (const [id, replay_gain] of Object.entries(gains)) {
+ global.mainWindow.webContents.send('track-updated', {
+ trackId: Number(id),
+ analysis: { replay_gain },
+ });
+ }
+ global.mainWindow.webContents.send('normalize-progress', {
+ completed: trackIds.length,
+ total: trackIds.length,
+ done: true,
+ });
+ }
+ return { normalized, skipped };
+});
+
ipcMain.handle('reanalyze-track', (_, trackId) => {
const track = getTrackById(trackId);
if (!track) throw new Error(`Track ${trackId} not found`);
spawnAnalysis(trackId, track.file_path);
return { ok: true };
});
+ipcMain.handle('cancel-analysis', (_, trackId) => {
+ const cancelled = cancelAnalysis(trackId);
+ return { cancelled };
+});
ipcMain.handle('remove-track', (_, trackId) => {
removeTrack(trackId); // ON DELETE CASCADE removes playlist_tracks rows
if (global.mainWindow) global.mainWindow.webContents.send('playlists-updated');
return { ok: true };
});
+ipcMain.handle('remove-linked-file', async (_, trackId) => {
+ const track = getTrackById(trackId);
+ if (!track) return { ok: false, error: 'not found' };
+ const filePath = track.file_path;
+ removeTrack(trackId);
+ try {
+ fs.unlinkSync(filePath);
+ } catch {
+ /* already gone */
+ }
+ send('library-updated');
+ if (global.mainWindow) global.mainWindow.webContents.send('playlists-updated');
+ return { ok: true };
+});
ipcMain.handle('update-track', (_, { id, data }) => {
updateTrack(id, data);
- // Fire-and-forget ID3 tag write-back (non-blocking, best-effort)
const track = getTrackById(id);
+ // Notify renderer so MusicLibrary + PlayerContext stay in sync
+ if (global.mainWindow) {
+ global.mainWindow.webContents.send('track-updated', { trackId: id, analysis: data });
+ }
+ // Fire-and-forget ID3 tag write-back (non-blocking, best-effort)
if (track?.file_path) {
writeId3Tags(track.file_path, data).catch((e) =>
console.error('[update-track] id3 write failed:', e.message)
@@ -259,6 +521,18 @@ ipcMain.handle('update-track', (_, { id, data }) => {
}
return { ok: true };
});
+ipcMain.handle('get-editor-waveform', async (_, trackId) => {
+ const track = getTrackById(trackId);
+ if (!track?.file_path) return null;
+ try {
+ const result = await generateEditorWaveform(track.file_path, getFfmpegRuntimePath());
+ return result;
+ } catch (e) {
+ console.error('[get-editor-waveform]', e.message);
+ return null;
+ }
+});
+
ipcMain.handle('adjust-bpm', (_, { trackIds, factor }) => {
if (factor !== 2 && factor !== 0.5) throw new Error('Invalid factor: must be 2 or 0.5');
if (!Array.isArray(trackIds) || trackIds.length === 0 || trackIds.length > 500) {
@@ -276,6 +550,123 @@ ipcMain.handle('adjust-bpm', (_, { trackIds, factor }) => {
}
return results;
});
+// ── Cue point IPC handlers ────────────────────────────────────────────────────
+ipcMain.handle('get-cue-points', (_, trackId) => getCuePoints(trackId));
+
+ipcMain.handle('add-cue-point', (_, { trackId, positionMs, label, color, hotCueIndex }) => {
+ const id = addCuePoint({ trackId, positionMs, label, color, hotCueIndex });
+ return { id };
+});
+
+ipcMain.handle('update-cue-point', (_, { id, label, color, hotCueIndex, enabled }) => {
+ updateCuePoint(id, { label, color, hotCueIndex, enabled });
+ return { ok: true };
+});
+
+ipcMain.handle('delete-cue-point', (_, id) => {
+ deleteCuePoint(id);
+ return { ok: true };
+});
+
+ipcMain.handle('generate-cue-points', (_, trackId) => {
+ const track = getTrackById(trackId);
+ if (!track) throw new Error(`Track ${trackId} not found`);
+ deleteAllCuePoints(trackId);
+ const generated = generateCuePoints(track);
+ generated.forEach((cue) => addCuePoint({ trackId, ...cue }));
+ return getCuePoints(trackId);
+});
+
+ipcMain.handle('generate-cue-points-library', (_, { overwrite = false } = {}) => {
+ const tracks = getTracks({ limit: 999999 });
+ const analyzed = tracks.filter((t) => t.analyzed === 1);
+ const total = analyzed.length;
+ let generated = 0;
+ let skipped = 0;
+
+ const sendProgress = (done = false) => {
+ if (global.mainWindow) {
+ global.mainWindow.webContents.send('cue-gen-progress', {
+ completed: generated + skipped,
+ total,
+ done,
+ });
+ }
+ };
+
+ for (const track of analyzed) {
+ const existing = getCuePoints(track.id);
+ if (!overwrite && existing.length > 0) {
+ skipped++;
+ sendProgress();
+ continue;
+ }
+ deleteAllCuePoints(track.id);
+ const cues = generateCuePoints(track);
+ cues.forEach((cue) => addCuePoint({ trackId: track.id, ...cue }));
+ generated++;
+ if (global.mainWindow) {
+ global.mainWindow.webContents.send('cue-points-updated', {
+ trackId: track.id,
+ cueCount: cues.length,
+ });
+ }
+ sendProgress();
+ }
+
+ sendProgress(true);
+ return { generated, skipped, total };
+});
+
+ipcMain.handle('delete-all-cue-points-library', () => {
+ const affected = deleteAllCuePointsLibrary();
+ if (global.mainWindow) {
+ for (const trackId of affected) {
+ global.mainWindow.webContents.send('cue-points-updated', { trackId, cueCount: 0 });
+ }
+ }
+ return { deleted: affected.length };
+});
+
+// Generate waveform overviews for all analyzed tracks in the library
+ipcMain.handle('generate-waveforms-library', async (_, { overwrite = false } = {}) => {
+ const tracks = getTracks({ limit: 999999 });
+ const analyzed = tracks.filter((t) => t.analyzed === 1);
+ const total = analyzed.length;
+ let generated = 0;
+ let skipped = 0;
+
+ const sendProgress = (done = false) => {
+ if (global.mainWindow) {
+ global.mainWindow.webContents.send('waveform-gen-progress', {
+ completed: generated + skipped,
+ total,
+ done,
+ });
+ }
+ };
+
+ for (const track of analyzed) {
+ if (!overwrite && track.waveform_overview != null) {
+ skipped++;
+ sendProgress();
+ continue;
+ }
+ try {
+ const buf = await generateWaveformOverview(track.file_path, getFfmpegRuntimePath());
+ updateTrackWaveform(track.id, buf);
+ generated++;
+ } catch (err) {
+ console.warn(`[waveform-gen] failed for track ${track.id}:`, err.message);
+ skipped++;
+ }
+ sendProgress();
+ }
+
+ sendProgress(true);
+ return { generated, skipped, total };
+});
+
// Playlist IPC handlers
ipcMain.handle('get-playlists', () => getPlaylists());
ipcMain.handle('create-playlist', (_, { name, color }) => {
@@ -390,20 +781,28 @@ ipcMain.handle('open-dir-dialog', async () => {
const result = await dialog.showOpenDialog({ properties: ['openDirectory', 'createDirectory'] });
return result.canceled ? null : result.filePaths[0];
});
-ipcMain.handle('import-audio-files', async (event, filePaths) => {
+ipcMain.handle('import-audio-files', async (event, filePaths, playlistId) => {
console.log('Importing audio files:', filePaths);
const trackIds = [];
+ const total = filePaths.length;
- for (const filePath of filePaths) {
+ for (let i = 0; i < total; i++) {
try {
- const trackId = await importAudioFile(filePath);
+ const trackId = await importAudioFile(filePaths[i]);
trackIds.push(trackId);
} catch (err) {
- console.error('Import failed:', filePath, err);
+ console.error('Import failed:', filePaths[i], err);
+ }
+ if (global.mainWindow) {
+ global.mainWindow.webContents.send('import-progress', { completed: i + 1, total });
}
}
if (trackIds.length > 0 && global.mainWindow) {
+ if (playlistId) {
+ addTracksToPlaylist(playlistId, trackIds);
+ global.mainWindow.webContents.send('playlists-updated');
+ }
global.mainWindow.webContents.send('library-updated');
}
@@ -422,14 +821,16 @@ ipcMain.handle('clear-library', async () => {
});
ipcMain.handle('clear-user-data', async () => {
- const toDelete = [app.getPath('userData'), app.getPath('cache'), app.getPath('logs')];
- app.on('quit', () => {
- for (const p of toDelete) {
- try {
- if (fs.existsSync(p)) fs.rmSync(p, { recursive: true, force: true });
- } catch {}
- }
+ const toDelete = getResetCleanupTargets({
+ userDataPath: app.getPath('userData'),
+ cachePath: app.getPath('cache'),
+ logsPath: app.getPath('logs'),
});
+ // Run the actual deletion in a detached helper after this process exits so
+ // Windows/Electron file handles cannot keep the database or userData tree
+ // alive during the reset.
+ closeDB();
+ startResetCleanup({ parentPid: process.pid, targets: toDelete });
app.quit();
});
@@ -465,6 +866,19 @@ ipcMain.handle('update-all-deps', async (_event) => {
if (global.mainWindow) global.mainWindow.webContents.send('deps-progress', null);
});
+ipcMain.handle('update-tidal-dl-ng', async (_event) => {
+ try {
+ await updateTidalDlNg((msg, pct) => {
+ if (global.mainWindow) global.mainWindow.webContents.send('deps-progress', { msg, pct });
+ });
+ if (global.mainWindow) global.mainWindow.webContents.send('deps-progress', null);
+ return { ok: true };
+ } catch (err) {
+ if (global.mainWindow) global.mainWindow.webContents.send('deps-progress', null);
+ return { ok: false, error: err.message };
+ }
+});
+
// ─── Auto-tagger ──────────────────────────────────────────────────────────────
ipcMain.handle('auto-tag-search', async (_, { query }) => {
@@ -517,15 +931,37 @@ ipcMain.handle('ytdlp-fetch-info', async (_event, url) => {
const cookiesBrowser = getSetting('ytdlp_cookies_browser', '') || null;
if (cookiesBrowser)
console.log('[ytdlp-fetch-info] using cookies from browser:', cookiesBrowser);
- const info = await ytDlpFetchPlaylistInfo(url, { cookiesBrowser });
+ const info = await ytDlpFetchPlaylistInfo(url, {
+ cookiesBrowser,
+ onBeforeCheck: (entries) => {
+ if (global.mainWindow) global.mainWindow.webContents.send('ytdlp-entries-ready', entries);
+ },
+ onCheckProgress: ({ checked, total }) => {
+ if (global.mainWindow)
+ global.mainWindow.webContents.send('ytdlp-check-progress', { checked, total });
+ },
+ onEntryChecked: (entry) => {
+ if (global.mainWindow) global.mainWindow.webContents.send('ytdlp-entry-checked', entry);
+ },
+ });
+ if (global.mainWindow) global.mainWindow.webContents.send('ytdlp-check-progress', null);
console.log(`[ytdlp-fetch-info] ok — type=${info.type} entries=${info.entries?.length}`);
return { ok: true, ...info };
} catch (err) {
+ if (global.mainWindow) global.mainWindow.webContents.send('ytdlp-check-progress', null);
console.error('[ytdlp-fetch-info] error:', err.message);
return { ok: false, error: err.message };
}
});
+ipcMain.handle('check-duplicate-urls', (_event, entries) => {
+ return getExistingSourceUrls(entries); // [{url, trackId}]
+});
+
+ipcMain.handle('get-playlist-source-urls', (_event, playlistId) => {
+ return getPlaylistSourceUrls(playlistId); // [{trackId, source_url, source_link}]
+});
+
// ─── yt-dlp URL download ──────────────────────────────────────────────────────
ipcMain.handle(
@@ -574,6 +1010,7 @@ ipcMain.handle(
platform,
quality,
title,
+ channel,
index,
}) => {
handledPaths.add(filePath);
@@ -584,6 +1021,7 @@ ipcMain.handle(
source_link: trackUrl !== originalUrl ? trackUrl : null,
source_platform: platform,
source_quality: quality,
+ channel: channel || null,
});
trackIds.push(trackId);
if (playlistId) {
@@ -606,7 +1044,11 @@ ipcMain.handle(
let lastOverallCurrent = 0;
- const { files, playlistName: detectedPlaylistName } = await ytDlpDownloadUrl(
+ const {
+ files,
+ playlistName: detectedPlaylistName,
+ unavailableCount = 0,
+ } = await ytDlpDownloadUrl(
url,
(data) => {
// When a new playlist item starts downloading, emit a 'downloading' track update
@@ -629,6 +1071,10 @@ ipcMain.handle(
onTrackMeta: ({ index, title }) => {
sendTrackUpdate({ type: 'update', index, title, status: 'downloading' });
},
+ onTrackUnavailable: ({ videoId, reason }) => {
+ // Find the track index by matching videoId in the pre-populated track list
+ sendTrackUpdate({ type: 'unavailable', videoId, reason, status: 'failed' });
+ },
onPlaylistDetected: ({ name, total }) => {
if (total > 1) {
// Create playlist if not already assigned (fallback for non-interactive downloads)
@@ -702,7 +1148,7 @@ ipcMain.handle(
}
}
- return { ok: true, trackIds, playlistId: playlistId ?? null };
+ return { ok: true, trackIds, playlistId: playlistId ?? null, unavailableCount };
} catch (err) {
if (global.mainWindow) global.mainWindow.webContents.send('ytdlp-progress', null);
return { ok: false, error: err.message };
@@ -714,6 +1160,177 @@ ipcMain.handle('open-external', async (_event, url) => {
shell.openExternal(url);
});
+// ─── TIDAL download ───────────────────────────────────────────────────────────
+
+ipcMain.handle('tidal-check', async () => {
+ return checkTidalSetup();
+});
+
+ipcMain.handle('tidal-install', async () => {
+ try {
+ await ensureTidalDlNg((line) => {
+ if (global.mainWindow)
+ global.mainWindow.webContents.send('tidal-install-progress', { msg: line });
+ });
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, error: err.message };
+ }
+});
+
+ipcMain.handle('tidal-fetch-info', async (_event, url) => {
+ console.log('[tidal-fetch-info] fetching info for:', url);
+ try {
+ const info = await fetchTidalInfo(url);
+ console.log(`[tidal-fetch-info] ok — type=${info.type} entries=${info.entries?.length}`);
+ return info;
+ } catch (err) {
+ console.error('[tidal-fetch-info] error:', err.message);
+ return { ok: false, error: err.message };
+ }
+});
+
+ipcMain.handle('tidal-login', async () => {
+ try {
+ await tidalStartLogin((url) => {
+ if (global.mainWindow) global.mainWindow.webContents.send('tidal-login-url', url);
+ });
+ return { ok: true };
+ } catch (err) {
+ return { ok: false, error: err.message };
+ }
+});
+
+ipcMain.handle(
+ 'tidal-download-url',
+ async (_event, { url, selectedEntries, linkTrackIds, existingPlaylistId, newPlaylistName }) => {
+ const send = (ch, data) => {
+ if (global.mainWindow) global.mainWindow.webContents.send(ch, data);
+ };
+ const sendTrackUpdate = (data) => send('tidal-track-update', data);
+ const sendProgress = (msg) => send('tidal-progress', { msg });
+
+ try {
+ const tmpDir = path.join(app.getPath('userData'), 'tidal_tmp');
+
+ // Resolve the download URLs: individual track URLs when selectedEntries are provided,
+ // otherwise the raw URL (for mixes and direct single-URL downloads).
+ const downloadUrls =
+ selectedEntries?.length > 0
+ ? selectedEntries.map((e) => `https://tidal.com/browse/track/${e.id}`)
+ : [url];
+
+ // Create playlist before starting download so tracks can be added progressively.
+ let playlistId = null;
+ if (existingPlaylistId) {
+ playlistId = existingPlaylistId;
+ } else if (newPlaylistName?.trim()) {
+ try {
+ const { id } = findOrCreatePlaylist(newPlaylistName.trim(), null, url);
+ playlistId = id;
+ send('playlists-updated');
+ } catch (err) {
+ console.error('[tidal] findOrCreatePlaylist failed:', err.message);
+ }
+ }
+
+ // Emit init event so the UI can render the full track list immediately.
+ if (selectedEntries?.length > 0) {
+ sendTrackUpdate({ type: 'init', tracks: selectedEntries });
+ }
+
+ const trackIds = [];
+ // fileIndex tracks which selectedEntry corresponds to the next file reported by onFileReady.
+ // tdn downloads in the order we pass URLs, so positional matching is reliable.
+ let fileIndex = 0;
+
+ const onFileReady = async (filePath) => {
+ const entry = selectedEntries?.[fileIndex] ?? null;
+ const idx = fileIndex;
+ fileIndex++;
+
+ if (entry) {
+ sendTrackUpdate({
+ index: idx,
+ title: entry.title,
+ artist: entry.artist,
+ status: 'importing',
+ });
+ } else {
+ // No entry info (e.g. mix download) — emit a generic update
+ sendTrackUpdate({
+ index: idx,
+ title: path.basename(filePath),
+ artist: '',
+ status: 'importing',
+ });
+ }
+
+ try {
+ const trackSourceUrl = entry?.id ? `https://tidal.com/browse/track/${entry.id}` : url;
+ const trackId = await importAudioFile(filePath, {
+ source_url: trackSourceUrl,
+ source_link: url !== trackSourceUrl ? url : null,
+ source_platform: 'tidal',
+ });
+ trackIds.push(trackId);
+ if (playlistId) {
+ addTrackToPlaylist(playlistId, trackId);
+ send('playlists-updated');
+ }
+ send('library-updated');
+ sendTrackUpdate({
+ index: idx,
+ title: entry?.title ?? path.basename(filePath),
+ artist: entry?.artist ?? '',
+ status: 'done',
+ trackId,
+ });
+ } catch (err) {
+ console.error('[tidal] importAudioFile failed:', err.message);
+ sendTrackUpdate({
+ index: idx,
+ title: entry?.title ?? path.basename(filePath),
+ artist: entry?.artist ?? '',
+ status: 'failed',
+ error: err.message,
+ });
+ }
+ };
+
+ sendProgress('Starting download…');
+
+ // Only call tdn if there are new tracks to download
+ const hasDownloads = selectedEntries?.length > 0 || !selectedEntries;
+ if (hasDownloads) {
+ const files = await downloadTidal(downloadUrls, tmpDir, sendProgress, { onFileReady });
+ if (files.length === 0 && trackIds.length === 0 && (linkTrackIds?.length ?? 0) === 0) {
+ send('tidal-progress', null);
+ return { ok: false, error: 'Download finished but no audio files were found.' };
+ }
+ }
+
+ // Link already-in-library tracks to the playlist (no re-download needed)
+ if (linkTrackIds?.length > 0 && playlistId) {
+ for (const tid of linkTrackIds) {
+ try {
+ addTrackToPlaylist(playlistId, tid);
+ } catch {
+ // ignore duplicate playlist entry errors
+ }
+ }
+ send('playlists-updated');
+ }
+
+ send('tidal-progress', null);
+ return { ok: true, trackIds, playlistId: playlistId ?? null };
+ } catch (err) {
+ send('tidal-progress', null);
+ return { ok: false, error: err.message };
+ }
+ }
+);
+
// ─── USB / Rekordbox Export ────────────────────────────────────────────────────
function send(channel, data) {
@@ -731,6 +1348,400 @@ function trackToFilename(track, ext) {
);
}
+// ── File Explorer IPC ──────────────────────────────────────────────────────────
+
+const AUDIO_EXTENSIONS = new Set([
+ '.mp3',
+ '.flac',
+ '.wav',
+ '.ogg',
+ '.m4a',
+ '.aac',
+ '.aiff',
+ '.aif',
+ '.opus',
+]);
+
+ipcMain.handle('select-explorer-folder', async () => {
+ const result = await dialog.showOpenDialog(mainWindow, {
+ properties: ['openDirectory'],
+ title: 'Select Folder to Browse',
+ });
+ if (result.canceled || !result.filePaths.length) return null;
+ const folderPath = result.filePaths[0];
+ if (!explorerAllowedBases.includes(folderPath)) {
+ explorerAllowedBases.push(folderPath);
+ }
+ return folderPath;
+});
+
+ipcMain.handle('browse-directory', (_, dirPath) => {
+ if (!explorerAllowedBases.some((base) => dirPath.startsWith(base))) {
+ explorerAllowedBases.push(dirPath);
+ }
+ try {
+ const entries = fs.readdirSync(dirPath, { withFileTypes: true });
+ const dirs = [];
+ const files = [];
+ for (const entry of entries) {
+ const fullPath = path.join(dirPath, entry.name);
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
+ dirs.push({ name: entry.name, path: fullPath });
+ } else if (entry.isFile()) {
+ const ext = path.extname(entry.name).toLowerCase();
+ if (AUDIO_EXTENSIONS.has(ext)) {
+ let size = 0;
+ try {
+ size = fs.statSync(fullPath).size;
+ } catch {}
+ files.push({ name: entry.name, path: fullPath, size });
+ }
+ }
+ }
+ dirs.sort((a, b) => a.name.localeCompare(b.name));
+ files.sort((a, b) => a.name.localeCompare(b.name));
+ return { dirs, files };
+ } catch (err) {
+ return { dirs: [], files: [], error: err.message };
+ }
+});
+
+ipcMain.handle('get-explorer-track-metadata', async (_, filePath) => {
+ try {
+ const { ffprobe: runFfprobe } = await import('./audio/ffmpeg.js');
+ const data = await runFfprobe(filePath);
+ const tags = data.format?.tags || {};
+ const stream = data.streams?.find((s) => s.codec_type === 'audio') || {};
+ const bpmTag = tags.bpm || tags.BPM || tags.TBPM || tags['tbpm'];
+ const keyTag = tags.key || tags.KEY || tags.initialkey || tags.INITIALKEY || null;
+ return {
+ title: tags.title || path.basename(filePath, path.extname(filePath)),
+ artist: tags.artist || '',
+ album: tags.album || '',
+ year: tags.date ? parseInt(tags.date.slice(0, 4)) : null,
+ label: tags.label || '',
+ genre: tags.genre ? tags.genre.split(',').map((g) => g.trim()) : [],
+ bpm: bpmTag ? parseFloat(bpmTag) || null : null,
+ key_raw: keyTag,
+ duration: parseFloat(data.format?.duration) || null,
+ bitrate: parseInt(stream.bit_rate || data.format?.bit_rate || 0, 10) || null,
+ };
+ } catch (err) {
+ return {
+ title: path.basename(filePath, path.extname(filePath)),
+ artist: '',
+ album: '',
+ bpm: null,
+ key_raw: null,
+ duration: null,
+ bitrate: null,
+ error: err.message,
+ };
+ }
+});
+
+ipcMain.handle('export-explorer-to-usb', async (_, { filePaths, usbRoot, playlistName }) => {
+ try {
+ const total = filePaths.length;
+ send('export-explorer-progress', { msg: `Exporting ${total} tracks to USB…`, pct: 0 });
+
+ const usedNames = new Map();
+ const pdbTracks = [];
+ const anlzPaths = new Map();
+
+ for (let i = 0; i < filePaths.length; i++) {
+ const srcPath = filePaths[i];
+ const ext = path.extname(srcPath);
+
+ // Extract metadata
+ let meta = {
+ title: path.basename(srcPath, ext),
+ artist: '',
+ album: '',
+ bpm: null,
+ key_raw: '',
+ duration: 0,
+ bitrate: 0,
+ };
+ try {
+ const { ffprobe: runFfprobe } = await import('./audio/ffmpeg.js');
+ const data = await runFfprobe(srcPath);
+ const tags = data.format?.tags || {};
+ const stream = data.streams?.find((s) => s.codec_type === 'audio') || {};
+ const bpmTag = tags.bpm || tags.BPM || tags.TBPM || tags['tbpm'];
+ meta = {
+ title: tags.title || path.basename(srcPath, ext),
+ artist: tags.artist || '',
+ album: tags.album || '',
+ bpm: bpmTag ? parseFloat(bpmTag) || null : null,
+ key_raw: tags.key || tags.KEY || tags.initialkey || tags.INITIALKEY || '',
+ duration: parseFloat(data.format?.duration) || 0,
+ bitrate: parseInt(stream.bit_rate || data.format?.bit_rate || 0, 10) || 0,
+ };
+ } catch {}
+
+ // Copy to USB /music/
+ const rawBase =
+ [meta.artist, meta.title].filter(Boolean).join(' - ') || path.basename(srcPath, ext);
+ const safeBase = rawBase.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim();
+ let filename = `${safeBase}${ext}`;
+ let n = 1;
+ while (usedNames.has(filename.toLowerCase())) {
+ filename = `${safeBase} (${n++})${ext}`;
+ }
+ usedNames.set(filename.toLowerCase(), true);
+
+ const destDir = path.join(usbRoot, 'music');
+ fs.mkdirSync(destDir, { recursive: true });
+ const destPath = path.join(destDir, filename);
+ if (!fs.existsSync(destPath)) fs.copyFileSync(srcPath, destPath);
+ const usbFilePath = `/music/${filename}`;
+
+ // Write minimal ANLZ (path + beatgrid only, no waveform for speed)
+ try {
+ const anlzDat = await writeAnlz({
+ usbFilePath,
+ sourceFilePath: null,
+ beatgrid: null,
+ bpm: meta.bpm || 0,
+ beatgridOffset: 0,
+ usbRoot,
+ ffmpegPath: getFfmpegRuntimePath(),
+ cuePoints: [],
+ });
+ anlzPaths.set(i, anlzDat);
+ } catch {}
+
+ let fileSize = 0;
+ try {
+ fileSize = fs.statSync(destPath).size;
+ } catch {}
+
+ pdbTracks.push({
+ id: i + 1,
+ title: meta.title,
+ artist: meta.artist,
+ album: meta.album,
+ duration: meta.duration,
+ bpm: meta.bpm || 0,
+ key_raw: meta.key_raw,
+ file_path: usbFilePath,
+ track_number: i + 1,
+ year: '',
+ label: '',
+ genres: [],
+ file_size: fileSize,
+ bitrate: meta.bitrate,
+ comments: '',
+ rating: 0,
+ analyzePath: anlzPaths.get(i) || '',
+ });
+
+ const pct = Math.round(((i + 1) / total) * 90);
+ send('export-explorer-progress', { msg: `Copying ${i + 1}/${total}: ${filename}`, pct });
+ }
+
+ send('export-explorer-progress', { msg: 'Writing PDB database…', pct: 92 });
+
+ const pdbPlaylists = playlistName
+ ? [{ id: 1, name: playlistName, track_ids: pdbTracks.map((t) => t.id) }]
+ : [];
+
+ const outputPath = path.join(usbRoot, 'PIONEER', 'rekordbox', 'export.pdb');
+ fs.mkdirSync(path.dirname(outputPath), { recursive: true });
+ writePdb({ tracks: pdbTracks, playlists: pdbPlaylists }, outputPath);
+
+ send('export-explorer-progress', { msg: 'Writing settings files…', pct: 96 });
+ try {
+ await writeSettingFiles(usbRoot);
+ } catch {}
+
+ send('export-explorer-progress', null);
+ return { ok: true, trackCount: pdbTracks.length, usbRoot };
+ } catch (err) {
+ send('export-explorer-progress', null);
+ return { ok: false, error: err.message };
+ }
+});
+
+// ── File Explorer v2 IPC ───────────────────────────────────────────────────────
+
+ipcMain.handle('get-computer-root', () => {
+ const home = os.homedir();
+ let root;
+ if (process.platform === 'win32') {
+ root = path.parse(home).root || 'C:\\';
+ } else {
+ root = '/';
+ }
+ return { root, home };
+});
+
+ipcMain.handle('get-tracks-by-paths', (_, filePaths) => {
+ return getTracksByPaths(filePaths);
+});
+
+let activeRecursiveWalker = null;
+
+ipcMain.handle('explorer-start-recursive', (_, dirPath) => {
+ if (activeRecursiveWalker) activeRecursiveWalker.cancelled = true;
+ const walker = { cancelled: false };
+ activeRecursiveWalker = walker;
+
+ if (!explorerAllowedBases.includes(dirPath)) explorerAllowedBases.push(dirPath);
+
+ async function walk(d) {
+ if (walker.cancelled) return;
+ let entries;
+ try {
+ entries = fs.readdirSync(d, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ const batch = [];
+ const dirs = [];
+ for (const entry of entries) {
+ if (walker.cancelled) return;
+ const fullPath = path.join(d, entry.name);
+ if (entry.isDirectory() && !entry.name.startsWith('.')) {
+ dirs.push(fullPath);
+ } else if (entry.isFile()) {
+ const ext = path.extname(entry.name).toLowerCase();
+ if (AUDIO_EXTENSIONS.has(ext)) {
+ let size = 0;
+ try {
+ size = fs.statSync(fullPath).size;
+ } catch {}
+ batch.push({ name: entry.name, path: fullPath, size });
+ }
+ }
+ }
+ if (batch.length > 0 && !walker.cancelled) {
+ send('explorer-recursive-batch', batch);
+ }
+ for (const subdir of dirs) {
+ if (walker.cancelled) return;
+ await new Promise((r) => setImmediate(r));
+ await walk(subdir);
+ }
+ }
+
+ walk(dirPath).then(() => {
+ if (!walker.cancelled) send('explorer-recursive-done', null);
+ });
+
+ return { ok: true };
+});
+
+ipcMain.handle('explorer-cancel-recursive', () => {
+ if (activeRecursiveWalker) activeRecursiveWalker.cancelled = true;
+ activeRecursiveWalker = null;
+});
+
+ipcMain.handle('link-audio-files', async (_, { filePaths, playlistId }) => {
+ const results = [];
+ for (const filePath of filePaths) {
+ try {
+ const result = await linkAudioFile(filePath);
+ if (!result.duplicate && playlistId) {
+ await addTrackToPlaylist(playlistId, result.id);
+ }
+ const dir = path.dirname(filePath);
+ if (!explorerAllowedBases.includes(dir)) explorerAllowedBases.push(dir);
+ results.push(result);
+ } catch (err) {
+ results.push({ id: null, duplicate: false, error: err.message, path: filePath });
+ }
+ }
+ send('library-updated');
+ if (playlistId) send('playlists-updated');
+ return results;
+});
+
+ipcMain.handle('link-directory', async (_, { dirPath, recursive, playlistId }) => {
+ if (!explorerAllowedBases.includes(dirPath)) explorerAllowedBases.push(dirPath);
+ const filePaths = [];
+
+ function collectFiles(d) {
+ let entries;
+ try {
+ entries = fs.readdirSync(d, { withFileTypes: true });
+ } catch {
+ return;
+ }
+ for (const entry of entries) {
+ const fullPath = path.join(d, entry.name);
+ if (recursive && entry.isDirectory() && !entry.name.startsWith('.')) {
+ collectFiles(fullPath);
+ } else if (entry.isFile()) {
+ const ext = path.extname(entry.name).toLowerCase();
+ if (AUDIO_EXTENSIONS.has(ext)) filePaths.push(fullPath);
+ }
+ }
+ }
+ collectFiles(dirPath);
+
+ let linked = 0;
+ for (const filePath of filePaths) {
+ try {
+ const result = await linkAudioFile(filePath);
+ if (!result.duplicate) linked++;
+ if (!result.duplicate && playlistId) await addTrackToPlaylist(playlistId, result.id);
+ const dir = path.dirname(filePath);
+ if (!explorerAllowedBases.includes(dir)) explorerAllowedBases.push(dir);
+ } catch {}
+ }
+
+ send('library-updated');
+ if (playlistId) send('playlists-updated');
+ return { ok: true, linked, total: filePaths.length };
+});
+
+ipcMain.handle('remap-track', async (_, { trackId, newPath }) => {
+ const result = await dialog.showOpenDialog(mainWindow, {
+ properties: ['openFile'],
+ defaultPath: newPath || undefined,
+ filters: [
+ {
+ name: 'Audio Files',
+ extensions: ['mp3', 'flac', 'wav', 'ogg', 'm4a', 'aac', 'aiff', 'aif', 'opus'],
+ },
+ ],
+ });
+ if (result.canceled || !result.filePaths.length) return { ok: false };
+ const resolvedPath = result.filePaths[0];
+ updateTrack(trackId, { file_path: resolvedPath });
+ const dir = path.dirname(resolvedPath);
+ if (!explorerAllowedBases.includes(dir)) explorerAllowedBases.push(dir);
+ return { ok: true, newPath: resolvedPath };
+});
+
+ipcMain.handle('remap-folder', async (_, { oldDir }) => {
+ const result = await dialog.showOpenDialog(mainWindow, {
+ properties: ['openDirectory'],
+ title: `Select new location for folder: ${path.basename(oldDir)}`,
+ });
+ if (result.canceled || !result.filePaths.length) return { ok: false };
+ const newDir = result.filePaths[0];
+ const oldSep = oldDir.endsWith(path.sep) ? oldDir : oldDir + path.sep;
+ const newSep = newDir.endsWith(path.sep) ? newDir : newDir + path.sep;
+ const count = remapTracksByPrefix(oldSep, newSep);
+ if (!explorerAllowedBases.includes(newDir)) explorerAllowedBases.push(newDir);
+ return { ok: true, count, newDir };
+});
+
+ipcMain.handle('check-linked-track-status', (_, trackIds) => {
+ return trackIds.map((id) => {
+ const t = getTrackById(id);
+ if (!t) return { id, exists: false };
+ return { id, exists: !t.is_linked || fs.existsSync(t.file_path) };
+ });
+});
+
+ipcMain.handle('get-linked-tracks-basic', () => {
+ return getLinkedTracksBasic();
+});
+
ipcMain.handle('check-usb-format', async (_, mountPath) => {
const info = await detectFilesystem(mountPath);
return {
@@ -751,8 +1762,14 @@ ipcMain.handle('format-usb', async (_, { device, mountPoint }) => {
});
/** Copies a track's audio file to {usbRoot}/music/, returns the USB path or null on error. */
-function copyTrackToUsb(track, usbRoot, usedNames) {
- const ext = path.extname(track.file_path || '');
+async function copyTrackToUsb(
+ track,
+ usbRoot,
+ usedNames,
+ { useNormalized = false, targetLufs = null } = {}
+) {
+ const srcPath = track.file_path;
+ const ext = path.extname(srcPath || '');
const filename = trackToFilename(track, ext);
// Deduplicate filename
let finalName = filename;
@@ -766,8 +1783,15 @@ function copyTrackToUsb(track, usbRoot, usedNames) {
fs.mkdirSync(destDir, { recursive: true });
const destPath = path.join(destDir, finalName);
- if (!fs.existsSync(destPath) && fs.existsSync(track.file_path)) {
- fs.copyFileSync(track.file_path, destPath);
+ if (!fs.existsSync(destPath) && fs.existsSync(srcPath)) {
+ const sourceLoudness = track.loudness;
+ if (useNormalized && targetLufs != null && sourceLoudness != null) {
+ const gainDb = targetLufs - sourceLoudness;
+ const sourceBitrateKbps = track.bitrate ? track.bitrate / 1000 : null;
+ await convertAudio(srcPath, destPath, { gainDb, sourceBitrateKbps });
+ } else {
+ fs.copyFileSync(srcPath, destPath);
+ }
}
return `/music/${finalName}`;
@@ -817,282 +1841,304 @@ function saveManifest(usbRoot, tracksMap, playlistsMap) {
);
}
-ipcMain.handle('export-rekordbox', async (_, { usbRoot, playlistIds, playlistId }) => {
- try {
- const ids = playlistIds?.length ? playlistIds : playlistId ? [playlistId] : null;
- const allPlaylists = ids?.length
- ? ids.map((id) => getPlaylist(id)).filter(Boolean)
- : getPlaylists();
-
- const trackMap = new Map();
- for (const pl of allPlaylists) {
- for (const t of getPlaylistTracks(pl.id)) {
- if (!trackMap.has(t.id)) trackMap.set(t.id, t);
+ipcMain.handle(
+ 'export-rekordbox',
+ async (_, { usbRoot, playlistIds, playlistId, useNormalized = false }) => {
+ try {
+ const targetLufs = useNormalized ? Number(getSetting('normalize_target_lufs', '-9')) : null;
+ const ids = playlistIds?.length ? playlistIds : playlistId ? [playlistId] : null;
+ const allPlaylists = ids?.length
+ ? ids.map((id) => getPlaylist(id)).filter(Boolean)
+ : getPlaylists();
+
+ const trackMap = new Map();
+ for (const pl of allPlaylists) {
+ for (const t of getPlaylistTracks(pl.id)) {
+ if (!trackMap.has(t.id)) trackMap.set(t.id, t);
+ }
}
- }
- const tracks = [...trackMap.values()];
- const total = tracks.length;
-
- // Load existing manifest so we can merge with previously exported tracks/playlists
- const { tracks: existingTracks, playlists: existingPlaylists } = loadManifest(usbRoot);
- const existingCount = existingTracks.size;
-
- send('export-rekordbox-progress', {
- msg: existingCount
- ? `Merging ${total} tracks into existing export (${existingCount} tracks already on USB)…`
- : `Exporting ${total} tracks…`,
- pct: 0,
- });
+ const tracks = [...trackMap.values()];
+ const total = tracks.length;
- // Pre-populate usedNames from existing manifest so copyTrackToUsb won't assign duplicate filenames
- const usedNames = new Map();
- for (const et of existingTracks.values()) {
- const name = path.basename(et.file_path || '').toLowerCase();
- if (name) usedNames.set(name, true);
- }
+ // Load existing manifest so we can merge with previously exported tracks/playlists
+ const { tracks: existingTracks, playlists: existingPlaylists } = loadManifest(usbRoot);
+ const existingCount = existingTracks.size;
- // 2. Copy files to USB, build USB path map
- const usbPaths = new Map(); // trackId → USB path
- for (let i = 0; i < tracks.length; i++) {
- const t = tracks[i];
- const usbPath = copyTrackToUsb(t, usbRoot, usedNames);
- usbPaths.set(t.id, usbPath);
send('export-rekordbox-progress', {
- msg: `Copying files… ${i + 1}/${total}`,
- pct: Math.round(((i + 1) / total) * 40),
+ msg: existingCount
+ ? `Merging ${total} tracks into existing export (${existingCount} tracks already on USB)…`
+ : `Exporting ${total} tracks…`,
+ pct: 0,
});
- }
- // 3. Write ANLZ beat grid files (only for tracks in the current export)
- send('export-rekordbox-progress', { msg: 'Writing beat grids & waveforms…', pct: 40 });
- const anlzPaths = new Map(); // trackId → Pioneer analyze_path string for PDB
- for (let i = 0; i < tracks.length; i++) {
- const t = tracks[i];
- const usbFilePath = usbPaths.get(t.id);
- if (!usbFilePath) continue;
- const anlzFolder = getAnlzFolder(usbFilePath).replace(/\\/g, '/');
- anlzPaths.set(t.id, `/${anlzFolder}/ANLZ0000.DAT`);
- try {
- await writeAnlz({
- usbFilePath,
- sourceFilePath: t.file_path || null,
- beatgrid: t.beatgrid ?? null,
- bpm: t.bpm_override ?? t.bpm ?? 0,
- usbRoot,
- ffmpegPath: getFfmpegRuntimePath(),
+ // Pre-populate usedNames from existing manifest so copyTrackToUsb won't assign duplicate filenames
+ const usedNames = new Map();
+ for (const et of existingTracks.values()) {
+ const name = path.basename(et.file_path || '').toLowerCase();
+ if (name) usedNames.set(name, true);
+ }
+
+ // 2. Copy files to USB, build USB path map
+ const usbPaths = new Map(); // trackId → USB path
+ for (let i = 0; i < tracks.length; i++) {
+ const t = tracks[i];
+ const usbPath = await copyTrackToUsb(t, usbRoot, usedNames, { useNormalized, targetLufs });
+ usbPaths.set(t.id, usbPath);
+ send('export-rekordbox-progress', {
+ msg: `Copying files… ${i + 1}/${total}`,
+ pct: Math.round(((i + 1) / total) * 40),
});
- } catch (err) {
- console.warn(`ANLZ write failed for track ${t.id}:`, err.message);
}
- send('export-rekordbox-progress', {
- msg: `Beat grids & waveforms… ${i + 1}/${total}`,
- pct: 40 + Math.round(((i + 1) / total) * 30),
- });
- }
- // 4. Build PDB tracks for the current export
- send('export-rekordbox-progress', { msg: 'Writing Rekordbox database…', pct: 70 });
- const newPdbTracks = tracks.map((t) => ({
- id: t.id,
- title: t.title || '',
- artist: t.artist || '',
- album: t.album || '',
- duration: t.duration || 0,
- bpm: t.bpm_override ?? t.bpm ?? 0,
- key_raw: t.key_raw || '',
- file_path: usbPaths.get(t.id) || '',
- track_number: t.track_number || 0,
- year: t.year || '',
- label: t.label || '',
- genres: t.genres ? JSON.parse(t.genres) : [],
- file_size: t.file_size || 0,
- bitrate: t.bitrate || 0,
- comments: t.comments || '',
- rating: t.rating || 0,
- analyzePath: anlzPaths.get(t.id) || '',
- }));
-
- const newPdbPlaylists = allPlaylists.map((pl) => ({
- id: pl.id,
- name: pl.name,
- track_ids: getPlaylistTracks(pl.id)
- .map((t) => t.id)
- .filter((id) => usbPaths.has(id)),
- }));
-
- // Merge: existing data is the base; new export overrides by id
- const mergedTracks = new Map(existingTracks);
- for (const t of newPdbTracks) mergedTracks.set(t.id, t);
-
- const mergedPlaylists = new Map(existingPlaylists);
- for (const pl of newPdbPlaylists) mergedPlaylists.set(pl.id, pl);
-
- runPdbExporter(
- { usbRoot, tracks: [...mergedTracks.values()], playlists: [...mergedPlaylists.values()] },
- usbRoot
- );
- writeSettingFiles(usbRoot);
- saveManifest(usbRoot, mergedTracks, mergedPlaylists);
+ // 3. Write ANLZ beat grid files (only for tracks in the current export)
+ send('export-rekordbox-progress', { msg: 'Writing beat grids & waveforms…', pct: 40 });
+ const anlzPaths = new Map(); // trackId → Pioneer analyze_path string for PDB
+ for (let i = 0; i < tracks.length; i++) {
+ const t = tracks[i];
+ const usbFilePath = usbPaths.get(t.id);
+ if (!usbFilePath) continue;
+ const anlzFolder = getAnlzFolder(usbFilePath).replace(/\\/g, '/');
+ anlzPaths.set(t.id, `/${anlzFolder}/ANLZ0000.DAT`);
+ const sourceFilePath = t.file_path || null;
+ try {
+ await writeAnlz({
+ usbFilePath,
+ sourceFilePath,
+ beatgrid: t.beatgrid ?? null,
+ bpm: t.bpm_override ?? t.bpm ?? 0,
+ beatgridOffset: t.beatgrid_offset ?? 0,
+ usbRoot,
+ ffmpegPath: getFfmpegRuntimePath(),
+ cuePoints: getCuePoints(t.id).filter((c) => c.enabled !== 0),
+ });
+ } catch (err) {
+ console.warn(`ANLZ write failed for track ${t.id}:`, err.message);
+ }
+ send('export-rekordbox-progress', {
+ msg: `Beat grids & waveforms… ${i + 1}/${total}`,
+ pct: 40 + Math.round(((i + 1) / total) * 30),
+ });
+ }
- send('export-rekordbox-progress', { msg: 'Done!', pct: 100 });
- send('export-rekordbox-progress', null);
- return { ok: true, trackCount: mergedTracks.size, newTrackCount: total, usbRoot };
- } catch (err) {
- send('export-rekordbox-progress', null);
- return { ok: false, error: err.message };
+ // 4. Build PDB tracks for the current export
+ send('export-rekordbox-progress', { msg: 'Writing Rekordbox database…', pct: 70 });
+ const newPdbTracks = tracks.map((t) => ({
+ id: t.id,
+ title: t.title || '',
+ artist: t.artist || '',
+ album: t.album || '',
+ duration: t.duration || 0,
+ bpm: t.bpm_override ?? t.bpm ?? 0,
+ key_raw: t.key_raw || '',
+ file_path: usbPaths.get(t.id) || '',
+ track_number: t.track_number || 0,
+ year: t.year || '',
+ label: t.label || '',
+ genres: t.genres ? JSON.parse(t.genres) : [],
+ file_size: t.file_size || 0,
+ bitrate: t.bitrate || 0,
+ comments: t.comments || '',
+ rating: t.rating || 0,
+ replay_gain: t.replay_gain ?? null,
+ analyzePath: anlzPaths.get(t.id) || '',
+ }));
+
+ const newPdbPlaylists = allPlaylists.map((pl) => ({
+ id: pl.id,
+ name: pl.name,
+ track_ids: getPlaylistTracks(pl.id)
+ .map((t) => t.id)
+ .filter((id) => usbPaths.has(id)),
+ }));
+
+ // Merge: existing data is the base; new export overrides by id
+ const mergedTracks = new Map(existingTracks);
+ for (const t of newPdbTracks) mergedTracks.set(t.id, t);
+
+ const mergedPlaylists = new Map(existingPlaylists);
+ for (const pl of newPdbPlaylists) mergedPlaylists.set(pl.id, pl);
+
+ runPdbExporter(
+ { usbRoot, tracks: [...mergedTracks.values()], playlists: [...mergedPlaylists.values()] },
+ usbRoot
+ );
+ writeSettingFiles(usbRoot);
+ saveManifest(usbRoot, mergedTracks, mergedPlaylists);
+
+ send('export-rekordbox-progress', { msg: 'Done!', pct: 100 });
+ send('export-rekordbox-progress', null);
+ return { ok: true, trackCount: mergedTracks.size, newTrackCount: total, usbRoot };
+ } catch (err) {
+ send('export-rekordbox-progress', null);
+ return { ok: false, error: err.message };
+ }
}
-});
+);
-ipcMain.handle('export-all', async (_, { usbRoot, playlistIds, playlistId }) => {
- try {
- const ids = playlistIds?.length ? playlistIds : playlistId ? [playlistId] : null;
- const allPlaylists = ids?.length
- ? ids.map((id) => getPlaylist(id)).filter(Boolean)
- : getPlaylists();
-
- // Build deduped track map once, shared by both M3U and Rekordbox
- const trackMap = new Map();
- for (const pl of allPlaylists) {
- for (const t of getPlaylistTracks(pl.id)) {
- if (!trackMap.has(t.id)) trackMap.set(t.id, t);
+ipcMain.handle(
+ 'export-all',
+ async (_, { usbRoot, playlistIds, playlistId, useNormalized = false }) => {
+ try {
+ const targetLufs = useNormalized ? Number(getSetting('normalize_target_lufs', '-9')) : null;
+ const ids = playlistIds?.length ? playlistIds : playlistId ? [playlistId] : null;
+ const allPlaylists = ids?.length
+ ? ids.map((id) => getPlaylist(id)).filter(Boolean)
+ : getPlaylists();
+
+ // Build deduped track map once, shared by both M3U and Rekordbox
+ const trackMap = new Map();
+ for (const pl of allPlaylists) {
+ for (const t of getPlaylistTracks(pl.id)) {
+ if (!trackMap.has(t.id)) trackMap.set(t.id, t);
+ }
}
- }
- const allTracks = [...trackMap.values()];
- const total = allTracks.length;
-
- // Load existing manifest for merging
- const { tracks: existingTracks, playlists: existingPlaylists } = loadManifest(usbRoot);
- const existingCount = existingTracks.size;
-
- send('export-all-progress', {
- msg: existingCount
- ? `Merging ${total} tracks into existing export (${existingCount} tracks already on USB)…`
- : `Exporting ${total} tracks…`,
- pct: 0,
- });
+ const allTracks = [...trackMap.values()];
+ const total = allTracks.length;
- // Pre-populate usedNames from manifest to avoid filename collisions
- const usedNames = new Map();
- for (const et of existingTracks.values()) {
- const name = path.basename(et.file_path || '').toLowerCase();
- if (name) usedNames.set(name, true);
- }
+ // Load existing manifest for merging
+ const { tracks: existingTracks, playlists: existingPlaylists } = loadManifest(usbRoot);
+ const existingCount = existingTracks.size;
- // Copy files once
- const usbPaths = new Map();
- for (let i = 0; i < allTracks.length; i++) {
- const t = allTracks[i];
- usbPaths.set(t.id, copyTrackToUsb(t, usbRoot, usedNames));
send('export-all-progress', {
- msg: `Copying files… ${i + 1}/${total}`,
- pct: Math.round(((i + 1) / total) * 35),
+ msg: existingCount
+ ? `Merging ${total} tracks into existing export (${existingCount} tracks already on USB)…`
+ : `Exporting ${total} tracks…`,
+ pct: 0,
});
- }
- // Write M3U playlists (USB path mode)
- send('export-all-progress', { msg: 'Writing M3U playlists…', pct: 35 });
- const playlistDir = path.join(usbRoot, 'playlists');
- fs.mkdirSync(playlistDir, { recursive: true });
- for (const pl of allPlaylists) {
- const tracks = getPlaylistTracks(pl.id);
- const safeName = pl.name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim();
- const lines = ['#EXTM3U'];
- for (const t of tracks) {
- const usbPath = usbPaths.get(t.id);
- if (!usbPath) continue;
- const duration = Math.floor(t.duration ?? -1);
- const label = [t.artist, t.title].filter(Boolean).join(' - ') || path.basename(usbPath);
- lines.push(`#EXTINF:${duration},${label}`);
- lines.push(usbPath);
+ // Pre-populate usedNames from manifest to avoid filename collisions
+ const usedNames = new Map();
+ for (const et of existingTracks.values()) {
+ const name = path.basename(et.file_path || '').toLowerCase();
+ if (name) usedNames.set(name, true);
}
- fs.writeFileSync(path.join(playlistDir, `${safeName}.m3u`), lines.join('\n') + '\n', 'utf8');
- }
- // Write ANLZ beat grids + waveforms (only for tracks in the current export)
- send('export-all-progress', { msg: 'Writing beat grids & waveforms…', pct: 50 });
- for (let i = 0; i < allTracks.length; i++) {
- const t = allTracks[i];
- const usbFilePath = usbPaths.get(t.id);
- if (!usbFilePath) continue;
- try {
- await writeAnlz({
- usbFilePath,
- sourceFilePath: t.file_path || null,
- beatgrid: t.beatgrid ?? null,
- bpm: t.bpm_override ?? t.bpm ?? 0,
- usbRoot,
- ffmpegPath: getFfmpegRuntimePath(),
+ // Copy files once
+ const usbPaths = new Map();
+ for (let i = 0; i < allTracks.length; i++) {
+ const t = allTracks[i];
+ usbPaths.set(
+ t.id,
+ await copyTrackToUsb(t, usbRoot, usedNames, { useNormalized, targetLufs })
+ );
+ send('export-all-progress', {
+ msg: `Copying files… ${i + 1}/${total}`,
+ pct: Math.round(((i + 1) / total) * 35),
});
- } catch (err) {
- console.warn(`ANLZ write failed for track ${t.id}:`, err.message);
}
- send('export-all-progress', {
- msg: `Beat grids & waveforms… ${i + 1}/${total}`,
- pct: 50 + Math.round(((i + 1) / total) * 20),
- });
- }
- // Write PDB — merge with existing manifest
- send('export-all-progress', { msg: 'Writing Rekordbox database…', pct: 70 });
- const newPdbTracks = allTracks.map((t) => ({
- id: t.id,
- title: t.title || '',
- artist: t.artist || '',
- album: t.album || '',
- duration: t.duration || 0,
- bpm: t.bpm_override ?? t.bpm ?? 0,
- key_raw: t.key_raw || '',
- file_path: usbPaths.get(t.id) || '',
- track_number: t.track_number || 0,
- year: t.year || '',
- label: t.label || '',
- genres: t.genres ? JSON.parse(t.genres) : [],
- file_size: t.file_size || 0,
- bitrate: t.bitrate || 0,
- comments: t.comments || '',
- rating: t.rating || 0,
- analyzePath: (() => {
- const usbFP = usbPaths.get(t.id);
- if (!usbFP) return '';
- const folder = getAnlzFolder(usbFP).replace(/\\/g, '/');
- return folder ? `/${folder}/ANLZ0000.DAT` : '';
- })(),
- }));
- const newPdbPlaylists = allPlaylists.map((pl) => ({
- id: pl.id,
- name: pl.name,
- track_ids: getPlaylistTracks(pl.id)
- .map((t) => t.id)
- .filter((id) => usbPaths.has(id)),
- }));
-
- const mergedTracks = new Map(existingTracks);
- for (const t of newPdbTracks) mergedTracks.set(t.id, t);
-
- const mergedPlaylists = new Map(existingPlaylists);
- for (const pl of newPdbPlaylists) mergedPlaylists.set(pl.id, pl);
-
- runPdbExporter(
- { usbRoot, tracks: [...mergedTracks.values()], playlists: [...mergedPlaylists.values()] },
- usbRoot
- );
- writeSettingFiles(usbRoot);
- saveManifest(usbRoot, mergedTracks, mergedPlaylists);
+ // Write M3U playlists (USB path mode)
+ send('export-all-progress', { msg: 'Writing M3U playlists…', pct: 35 });
+ const playlistDir = path.join(usbRoot, 'playlists');
+ fs.mkdirSync(playlistDir, { recursive: true });
+ for (const pl of allPlaylists) {
+ const tracks = getPlaylistTracks(pl.id);
+ const safeName = pl.name.replace(/[<>:"/\\|?*\x00-\x1f]/g, '_').trim();
+ const lines = ['#EXTM3U'];
+ for (const t of tracks) {
+ const usbPath = usbPaths.get(t.id);
+ if (!usbPath) continue;
+ const duration = Math.floor(t.duration ?? -1);
+ const label = [t.artist, t.title].filter(Boolean).join(' - ') || path.basename(usbPath);
+ lines.push(`#EXTINF:${duration},${label}`);
+ lines.push(usbPath);
+ }
+ fs.writeFileSync(
+ path.join(playlistDir, `${safeName}.m3u`),
+ lines.join('\n') + '\n',
+ 'utf8'
+ );
+ }
- send('export-all-progress', { msg: 'Done!', pct: 100 });
- send('export-all-progress', null);
- return {
- ok: true,
- trackCount: mergedTracks.size,
- newTrackCount: total,
- playlistCount: mergedPlaylists.size,
- usbRoot,
- };
- } catch (err) {
- send('export-all-progress', null);
- return { ok: false, error: err.message };
+ // Write ANLZ beat grids + waveforms (only for tracks in the current export)
+ send('export-all-progress', { msg: 'Writing beat grids & waveforms…', pct: 50 });
+ for (let i = 0; i < allTracks.length; i++) {
+ const t = allTracks[i];
+ const usbFilePath = usbPaths.get(t.id);
+ if (!usbFilePath) continue;
+ try {
+ await writeAnlz({
+ usbFilePath,
+ sourceFilePath: t.file_path || null,
+ beatgrid: t.beatgrid ?? null,
+ bpm: t.bpm_override ?? t.bpm ?? 0,
+ beatgridOffset: t.beatgrid_offset ?? 0,
+ usbRoot,
+ ffmpegPath: getFfmpegRuntimePath(),
+ cuePoints: getCuePoints(t.id).filter((c) => c.enabled !== 0),
+ });
+ } catch (err) {
+ console.warn(`ANLZ write failed for track ${t.id}:`, err.message);
+ }
+ send('export-all-progress', {
+ msg: `Beat grids & waveforms… ${i + 1}/${total}`,
+ pct: 50 + Math.round(((i + 1) / total) * 20),
+ });
+ }
+
+ // Write PDB — merge with existing manifest
+ send('export-all-progress', { msg: 'Writing Rekordbox database…', pct: 70 });
+ const newPdbTracks = allTracks.map((t) => ({
+ id: t.id,
+ title: t.title || '',
+ artist: t.artist || '',
+ album: t.album || '',
+ duration: t.duration || 0,
+ bpm: t.bpm_override ?? t.bpm ?? 0,
+ key_raw: t.key_raw || '',
+ file_path: usbPaths.get(t.id) || '',
+ track_number: t.track_number || 0,
+ year: t.year || '',
+ label: t.label || '',
+ genres: t.genres ? JSON.parse(t.genres) : [],
+ file_size: t.file_size || 0,
+ bitrate: t.bitrate || 0,
+ comments: t.comments || '',
+ rating: t.rating || 0,
+ replay_gain: t.replay_gain ?? null,
+ analyzePath: (() => {
+ const usbFP = usbPaths.get(t.id);
+ if (!usbFP) return '';
+ const folder = getAnlzFolder(usbFP).replace(/\\/g, '/');
+ return folder ? `/${folder}/ANLZ0000.DAT` : '';
+ })(),
+ }));
+ const newPdbPlaylists = allPlaylists.map((pl) => ({
+ id: pl.id,
+ name: pl.name,
+ track_ids: getPlaylistTracks(pl.id)
+ .map((t) => t.id)
+ .filter((id) => usbPaths.has(id)),
+ }));
+
+ const mergedTracks = new Map(existingTracks);
+ for (const t of newPdbTracks) mergedTracks.set(t.id, t);
+
+ const mergedPlaylists = new Map(existingPlaylists);
+ for (const pl of newPdbPlaylists) mergedPlaylists.set(pl.id, pl);
+
+ runPdbExporter(
+ { usbRoot, tracks: [...mergedTracks.values()], playlists: [...mergedPlaylists.values()] },
+ usbRoot
+ );
+ writeSettingFiles(usbRoot);
+ saveManifest(usbRoot, mergedTracks, mergedPlaylists);
+
+ send('export-all-progress', { msg: 'Done!', pct: 100 });
+ send('export-all-progress', null);
+ return {
+ ok: true,
+ trackCount: mergedTracks.size,
+ newTrackCount: total,
+ playlistCount: mergedPlaylists.size,
+ usbRoot,
+ };
+ } catch (err) {
+ send('export-all-progress', null);
+ return { ok: false, error: err.message };
+ }
}
-});
+);
app.on('ready', initApp);
app.on('window-all-closed', () => {
diff --git a/src/preload.js b/src/preload.js
index f940ee6d..51938b08 100644
--- a/src/preload.js
+++ b/src/preload.js
@@ -1,17 +1,42 @@
-const { contextBridge, ipcRenderer } = require('electron');
+const { contextBridge, ipcRenderer, webFrame } = require('electron');
contextBridge.exposeInMainWorld('api', {
// Track library
getTracks: (params) => ipcRenderer.invoke('get-tracks', params),
getTrackIds: (params) => ipcRenderer.invoke('get-track-ids', params),
+ getTrackWaveform: (trackId) => ipcRenderer.invoke('get-track-waveform', trackId),
+ onWaveformReady: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('waveform-ready', handler);
+ return () => ipcRenderer.removeListener('waveform-ready', handler);
+ },
+ generateWaveformsLibrary: (opts) => ipcRenderer.invoke('generate-waveforms-library', opts),
+ onWaveformGenProgress: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('waveform-gen-progress', handler);
+ return () => ipcRenderer.removeListener('waveform-gen-progress', handler);
+ },
reanalyzeTrack: (trackId) => ipcRenderer.invoke('reanalyze-track', trackId),
+ cancelAnalysis: (trackId) => ipcRenderer.invoke('cancel-analysis', trackId),
removeTrack: (trackId) => ipcRenderer.invoke('remove-track', trackId),
+ removeLinkedFile: (trackId) => ipcRenderer.invoke('remove-linked-file', trackId),
updateTrack: (id, data) => ipcRenderer.invoke('update-track', { id, data }),
+ getEditorWaveform: (trackId) => ipcRenderer.invoke('get-editor-waveform', trackId),
adjustBpm: (payload) => ipcRenderer.invoke('adjust-bpm', payload),
+ // Cue points
+ getCuePoints: (trackId) => ipcRenderer.invoke('get-cue-points', trackId),
+ addCuePoint: (payload) => ipcRenderer.invoke('add-cue-point', payload),
+ updateCuePoint: (id, update) => ipcRenderer.invoke('update-cue-point', { id, ...update }),
+ deleteCuePoint: (id) => ipcRenderer.invoke('delete-cue-point', id),
+ generateCuePoints: (trackId) => ipcRenderer.invoke('generate-cue-points', trackId),
+ generateCuePointsLibrary: (opts) => ipcRenderer.invoke('generate-cue-points-library', opts),
+ deleteAllCuePointsLibrary: () => ipcRenderer.invoke('delete-all-cue-points-library'),
+
// Import
selectAudioFiles: () => ipcRenderer.invoke('select-audio-files'),
- importAudioFiles: (files) => ipcRenderer.invoke('import-audio-files', files),
+ importAudioFiles: (files, playlistId) =>
+ ipcRenderer.invoke('import-audio-files', files, playlistId),
// Playlists
getPlaylists: () => ipcRenderer.invoke('get-playlists'),
@@ -65,7 +90,10 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('move-library-progress', (_, data) => cb(data));
return () => ipcRenderer.removeAllListeners('move-library-progress');
},
- normalizeLibrary: (payload) => ipcRenderer.invoke('normalize-library', payload),
+ normalizeLibrary: () => ipcRenderer.invoke('normalize-library'),
+ getNormalizedCount: () => ipcRenderer.invoke('get-normalized-count'),
+ normalizeTracksAudio: (payload) => ipcRenderer.invoke('normalize-tracks-audio', payload),
+ resetNormalization: (payload) => ipcRenderer.invoke('reset-normalization', payload),
// Events
onTrackUpdated: (callback) => {
@@ -73,11 +101,36 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('track-updated', handler);
return () => ipcRenderer.removeListener('track-updated', handler);
},
+ onCuePointsUpdated: (callback) => {
+ const handler = (_, data) => callback(data);
+ ipcRenderer.on('cue-points-updated', handler);
+ return () => ipcRenderer.removeListener('cue-points-updated', handler);
+ },
+ onNormalizeProgress: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('normalize-progress', handler);
+ return () => ipcRenderer.removeListener('normalize-progress', handler);
+ },
+ onAnalysisProgress: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('analysis-progress', handler);
+ return () => ipcRenderer.removeListener('analysis-progress', handler);
+ },
+ onCueGenProgress: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('cue-gen-progress', handler);
+ return () => ipcRenderer.removeListener('cue-gen-progress', handler);
+ },
onLibraryUpdated: (callback) => {
const handler = () => callback();
ipcRenderer.on('library-updated', handler);
return () => ipcRenderer.removeListener('library-updated', handler);
},
+ onImportProgress: (callback) => {
+ const handler = (_, data) => callback(data);
+ ipcRenderer.on('import-progress', handler);
+ return () => ipcRenderer.removeListener('import-progress', handler);
+ },
onPlaylistsUpdated: (callback) => {
const handler = () => callback();
ipcRenderer.on('playlists-updated', handler);
@@ -95,6 +148,8 @@ contextBridge.exposeInMainWorld('api', {
// yt-dlp URL download
getMediaPort: () => ipcRenderer.invoke('get-media-port'),
ytDlpFetchInfo: (url) => ipcRenderer.invoke('ytdlp-fetch-info', url),
+ checkDuplicateUrls: (urls) => ipcRenderer.invoke('check-duplicate-urls', urls),
+ getPlaylistSourceUrls: (playlistId) => ipcRenderer.invoke('get-playlist-source-urls', playlistId),
ytDlpDownloadUrl: ({ url, playlistItems, playlistTitle, existingPlaylistId, newPlaylistName }) =>
ipcRenderer.invoke('ytdlp-download-url', {
url,
@@ -108,14 +163,86 @@ contextBridge.exposeInMainWorld('api', {
ipcRenderer.on('ytdlp-progress', handler);
return () => ipcRenderer.removeListener('ytdlp-progress', handler);
},
+ onYtDlpCheckProgress: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('ytdlp-check-progress', handler);
+ return () => ipcRenderer.removeListener('ytdlp-check-progress', handler);
+ },
+ onYtDlpEntriesReady: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('ytdlp-entries-ready', handler);
+ return () => ipcRenderer.removeListener('ytdlp-entries-ready', handler);
+ },
+ onYtDlpEntryChecked: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('ytdlp-entry-checked', handler);
+ return () => ipcRenderer.removeListener('ytdlp-entry-checked', handler);
+ },
onYtDlpTrackUpdate: (cb) => {
const handler = (_, data) => cb(data);
ipcRenderer.on('ytdlp-track-update', handler);
return () => ipcRenderer.removeListener('ytdlp-track-update', handler);
},
updateYtDlp: (tag) => ipcRenderer.invoke('update-yt-dlp', tag ?? null),
+ updateTidalDlNg: () => ipcRenderer.invoke('update-tidal-dl-ng'),
openExternal: (url) => ipcRenderer.invoke('open-external', url),
+ // TIDAL download
+ tidalCheck: () => ipcRenderer.invoke('tidal-check'),
+ tidalInstall: () => ipcRenderer.invoke('tidal-install'),
+ tidalFetchInfo: (url) => ipcRenderer.invoke('tidal-fetch-info', url),
+ tidalLogin: () => ipcRenderer.invoke('tidal-login'),
+ tidalDownloadUrl: (opts) => ipcRenderer.invoke('tidal-download-url', opts),
+ onTidalProgress: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('tidal-progress', handler);
+ return () => ipcRenderer.removeListener('tidal-progress', handler);
+ },
+ onTidalLoginUrl: (cb) => {
+ const handler = (_, url) => cb(url);
+ ipcRenderer.on('tidal-login-url', handler);
+ return () => ipcRenderer.removeListener('tidal-login-url', handler);
+ },
+ onTidalInstallProgress: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('tidal-install-progress', handler);
+ return () => ipcRenderer.removeListener('tidal-install-progress', handler);
+ },
+ onTidalTrackUpdate: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('tidal-track-update', handler);
+ return () => ipcRenderer.removeListener('tidal-track-update', handler);
+ },
+
+ getZoomFactor: () => webFrame.getZoomFactor(),
+ setZoomFactor: (factor) => webFrame.setZoomFactor(factor),
+
+ // File Explorer
+ getComputerRoot: () => ipcRenderer.invoke('get-computer-root'),
+ browseDirectory: (dirPath) => ipcRenderer.invoke('browse-directory', dirPath),
+ selectExplorerFolder: () => ipcRenderer.invoke('select-explorer-folder'),
+ getTracksByPaths: (filePaths) => ipcRenderer.invoke('get-tracks-by-paths', filePaths),
+ explorerStartRecursive: (dirPath) => ipcRenderer.invoke('explorer-start-recursive', dirPath),
+ explorerCancelRecursive: () => ipcRenderer.invoke('explorer-cancel-recursive'),
+ onExplorerRecursiveBatch: (cb) => {
+ const handler = (_, data) => cb(data);
+ ipcRenderer.on('explorer-recursive-batch', handler);
+ return () => ipcRenderer.removeListener('explorer-recursive-batch', handler);
+ },
+ onExplorerRecursiveDone: (cb) => {
+ const handler = () => cb();
+ ipcRenderer.on('explorer-recursive-done', handler);
+ return () => ipcRenderer.removeListener('explorer-recursive-done', handler);
+ },
+ linkAudioFiles: (filePaths, playlistId) =>
+ ipcRenderer.invoke('link-audio-files', { filePaths, playlistId }),
+ linkDirectory: (dirPath, recursive, playlistId) =>
+ ipcRenderer.invoke('link-directory', { dirPath, recursive, playlistId }),
+ remapTrack: (trackId, newPath) => ipcRenderer.invoke('remap-track', { trackId, newPath }),
+ remapFolder: (oldDir) => ipcRenderer.invoke('remap-folder', { oldDir }),
+ checkLinkedTrackStatus: (trackIds) => ipcRenderer.invoke('check-linked-track-status', trackIds),
+ getLinkedTracksBasic: () => ipcRenderer.invoke('get-linked-tracks-basic'),
+
clearLibrary: () => ipcRenderer.invoke('clear-library'),
clearUserData: () => ipcRenderer.invoke('clear-user-data'),
getLogDir: () => ipcRenderer.invoke('get-log-dir'),
@@ -125,6 +252,7 @@ contextBridge.exposeInMainWorld('api', {
checkDepUpdates: () => ipcRenderer.invoke('check-dep-updates'),
updateAnalyzer: () => ipcRenderer.invoke('update-analyzer'),
updateAllDeps: () => ipcRenderer.invoke('update-all-deps'),
+ retryDeps: () => ipcRenderer.invoke('retry-deps'),
onDepsProgress: (callback) => {
const handler = (_, data) => callback(data);
ipcRenderer.on('deps-progress', handler);
diff --git a/src/resetCleanup.js b/src/resetCleanup.js
new file mode 100644
index 00000000..1a2937fa
--- /dev/null
+++ b/src/resetCleanup.js
@@ -0,0 +1,50 @@
+import path from 'path';
+import { spawn } from 'child_process';
+import { fileURLToPath } from 'url';
+
+const RESET_CLEANUP_WORKER = fileURLToPath(new URL('./resetCleanupWorker.js', import.meta.url));
+const LEGACY_DB_FILES = ['library.db', 'library.db-shm', 'library.db-wal'];
+
+export function getResetCleanupTargets({
+ userDataPath,
+ cachePath,
+ logsPath,
+ cwd = process.cwd(),
+} = {}) {
+ const targets = [userDataPath, cachePath, logsPath];
+
+ for (const fileName of LEGACY_DB_FILES) {
+ targets.push(path.join(cwd, fileName));
+ }
+
+ const seen = new Set();
+ return targets.filter((target) => {
+ if (!target) return false;
+ const resolved = path.resolve(target);
+ if (seen.has(resolved)) return false;
+ seen.add(resolved);
+ return true;
+ });
+}
+
+export function startResetCleanup({
+ parentPid,
+ targets,
+ spawnImpl = spawn,
+ execPath = process.execPath,
+ env = process.env,
+ scriptPath = RESET_CLEANUP_WORKER,
+} = {}) {
+ const child = spawnImpl(execPath, [scriptPath, String(parentPid), JSON.stringify(targets)], {
+ detached: true,
+ stdio: 'ignore',
+ windowsHide: true,
+ env: {
+ ...env,
+ ELECTRON_RUN_AS_NODE: '1',
+ },
+ });
+
+ child.unref();
+ return child;
+}
diff --git a/src/resetCleanupWorker.js b/src/resetCleanupWorker.js
new file mode 100644
index 00000000..c75cb6d0
--- /dev/null
+++ b/src/resetCleanupWorker.js
@@ -0,0 +1,53 @@
+import fs from 'fs';
+
+function sleep(ms) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+function isProcessRunning(pid) {
+ try {
+ process.kill(pid, 0);
+ return true;
+ } catch (error) {
+ return error.code !== 'ESRCH';
+ }
+}
+
+async function waitForProcessExit(pid) {
+ while (isProcessRunning(pid)) {
+ await sleep(250);
+ }
+}
+
+async function removeWithRetries(target) {
+ for (let attempt = 0; attempt < 20; attempt += 1) {
+ try {
+ fs.rmSync(target, { recursive: true, force: true });
+ return;
+ } catch (error) {
+ if (attempt === 19) throw error;
+ await sleep(250);
+ }
+ }
+}
+
+async function main() {
+ const parentPid = Number(process.argv[2]);
+ const targets = JSON.parse(process.argv[3] ?? '[]');
+
+ if (!Number.isInteger(parentPid) || parentPid <= 0) {
+ throw new Error(`Invalid parent pid: ${process.argv[2]}`);
+ }
+
+ if (!Array.isArray(targets)) {
+ throw new Error('Reset cleanup targets must be an array');
+ }
+
+ await waitForProcessExit(parentPid);
+
+ for (const target of targets) {
+ await removeWithRetries(target);
+ }
+}
+
+await main();
diff --git a/src/usb/pdbWriter.js b/src/usb/pdbWriter.js
index 3fb8a0fa..2a351551 100644
--- a/src/usb/pdbWriter.js
+++ b/src/usb/pdbWriter.js
@@ -346,8 +346,19 @@ export function buildTrackRow(params) {
unknownStr6 = '',
unknownStr7 = '',
unknownStr8 = '',
+ replayGain = null,
} = params;
+ // Converts replay_gain dB to the linear amplitude scale factor CDJs use for Auto Gain.
+ // Reference point 19048 (0x4A68) and 30967 (0x78F7) are the "unanalyzed" defaults
+ // written by native Rekordbox when no loudness analysis has run.
+ const gainToAutoGain = (ref) =>
+ replayGain == null
+ ? ref
+ : Math.max(0, Math.min(0xffff, Math.round(10 ** (replayGain / 20) * ref)));
+ const autoGain7 = gainToAutoGain(19048); // offset 24 — CDJ-NXS2 auto-gain field
+ const autoGain8 = gainToAutoGain(30967); // offset 26 — second gain reference
+
// String encoding order matches rex track.go StringOffsets struct and MarshalBinary
const strBufs = [
encodeISRCString(isrc), // [0] Isrc
@@ -404,10 +415,10 @@ export function buildTrackRow(params) {
pos += 4; // FileSize
result.writeUInt32LE(checksum, pos);
pos += 4; // Checksum
- result.writeUInt16LE(0x758a, pos);
- pos += 2; // Unnamed7
- result.writeUInt16LE(0x57a2, pos);
- pos += 2; // Unnamed8
+ result.writeUInt16LE(autoGain7, pos);
+ pos += 2; // Unnamed7 — auto_gain (CDJ-NXS2 trim)
+ result.writeUInt16LE(autoGain8, pos);
+ pos += 2; // Unnamed8 — auto_gain secondary reference
result.writeUInt32LE(artworkId, pos);
pos += 4; // ArtworkId
result.writeUInt32LE(keyId, pos);
@@ -861,6 +872,7 @@ function buildPdbBuffer(input) {
analyzeDate: now,
sampleRate: 44100,
sampleDepth: 16,
+ replayGain: t.replay_gain ?? null,
})
);
}
diff --git a/src/usb/usbUtils.js b/src/usb/usbUtils.js
index f0bb7488..7e3a9130 100644
--- a/src/usb/usbUtils.js
+++ b/src/usb/usbUtils.js
@@ -1,3 +1,4 @@
+import fs from 'fs';
import { exec } from 'child_process';
import { promisify } from 'util';
@@ -32,8 +33,28 @@ async function detectFilesystemWindows(mountPath) {
const { stdout } = await execAsync(`fsutil fsinfo volumeinfo ${drive}: 2>&1`, {
windowsHide: true,
});
+ console.log(`[diag] fsutil volumeinfo ${drive}: stdout:\n${stdout.trim()}`);
const fsMatch = stdout.match(/File System Name\s*:\s*(\S+)/i);
const fsName = fsMatch ? fsMatch[1].toLowerCase() : 'unknown';
+
+ // Log drive size so we can tell if FAT32 format will be rejected (> 32 GB limit)
+ try {
+ const { stdout: freeOut } = await execAsync(`fsutil volume diskfree ${drive}: 2>&1`, {
+ windowsHide: true,
+ });
+ const totalMatch = freeOut.match(/Total \S+ bytes\s*:\s*([\d,]+)/i);
+ if (totalMatch) {
+ const totalBytes = parseInt(totalMatch[1].replace(/,/g, ''), 10);
+ const totalGB = (totalBytes / 1024 ** 3).toFixed(1);
+ const over32 = totalBytes > 32 * 1024 ** 3;
+ console.log(
+ `[diag] drive ${drive}: total=${totalGB} GB over32GB=${over32}${over32 ? ' ⚠ Windows format /FS:FAT32 will likely fail' : ''}`
+ );
+ }
+ } catch (e) {
+ console.log(`[diag] drive size check failed: ${e.message}`);
+ }
+
return {
fs: fsName,
device: `${drive}:`,
@@ -129,8 +150,35 @@ async function formatWindows(device, onProgress) {
onProgress(`Formatting ${drive}: as FAT32…`);
// Use format command (requires admin). /Q = quick format, /Y = suppress confirmation
const cmd = `format ${drive}: /FS:FAT32 /Q /V:REKORDBOX /Y`;
- const { stderr } = await execAsync(cmd, { windowsHide: true, timeout: 120000 });
+ console.log(`[diag] format cmd: ${cmd}`);
+ const { stdout, stderr } = await execAsync(cmd, { windowsHide: true, timeout: 120000 });
+ console.log(`[diag] format stdout: ${stdout?.trim()}`);
+ if (stderr) console.log(`[diag] format stderr: ${stderr?.trim()}`);
if (stderr) throw new Error(stderr.trim());
+
+ // After format, Windows unmounts and remounts the volume. The drive root is
+ // briefly inaccessible — wait until it's ready before returning, otherwise the
+ // export starts immediately and gets ENOENT trying to mkdir on the drive root.
+ onProgress(`Waiting for ${drive}: to remount…`);
+ await waitForDriveReady(drive);
+ console.log(`[diag] drive ${drive}: is ready after format`);
+}
+
+async function waitForDriveReady(drive, timeoutMs = 15000) {
+ const root = `${drive}:\\`;
+ const deadline = Date.now() + timeoutMs;
+ while (Date.now() < deadline) {
+ try {
+ fs.readdirSync(root);
+ return;
+ } catch {
+ await new Promise((r) => setTimeout(r, 500));
+ }
+ }
+ throw new Error(
+ `Drive ${drive}: was not accessible within ${timeoutMs / 1000}s after format. ` +
+ `Try ejecting and re-inserting the drive, then export again.`
+ );
}
async function formatMac(device, onProgress) {
diff --git a/vitest.config.js b/vitest.config.js
index b4bb6d55..db9d8473 100644
--- a/vitest.config.js
+++ b/vitest.config.js
@@ -24,6 +24,7 @@ export default defineConfig({
include: [
'src/__tests__/trackRepository.test.js',
'src/__tests__/playlistRepository.test.js',
+ 'src/__tests__/cuePointRepository.test.js',
],
setupFiles: ['./src/__tests__/setup.js'],
},
@@ -39,6 +40,7 @@ export default defineConfig({
'src/__tests__/mediaServer.test.js',
'src/__tests__/anlzWriter.test.js',
'src/__tests__/waveformGenerator.test.js',
+ 'src/__tests__/resetCleanup.test.js',
'src/__tests__/usbUtils.test.js',
'src/__tests__/settingWriter.test.js',
'src/__tests__/pdbWriter.test.js',