diff --git a/.dev-url b/.dev-url index daf04d24..b8889173 100644 --- a/.dev-url +++ b/.dev-url @@ -1 +1 @@ -http://localhost:5174 \ No newline at end of file +http://localhost:5175 \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 541af652..2b82a610 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: if: github.event_name != 'pull_request' || github.base_ref != 'master' || github.head_ref == 'dev' runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 @@ -61,7 +61,7 @@ jobs: if: github.event_name != 'pull_request' || github.base_ref != 'master' || github.head_ref == 'dev' runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - name: Setup Node.js uses: actions/setup-node@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 93f37488..8568830c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,7 +44,7 @@ jobs: name: Build (${{ matrix.platform }}) steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 - uses: actions/setup-node@v6 with: @@ -128,60 +128,60 @@ jobs: # Requires secret: CHOCOLATEY_API_KEY # One-time setup: submit chocolatey/djmanager.nuspec to chocolatey.org for # initial package approval, then CI pushes all future updates automatically. - chocolatey: - if: github.ref_name == 'master' - needs: [setup, create-release] - runs-on: windows-latest - steps: - - uses: actions/checkout@v6 - - - uses: actions/download-artifact@v8 - with: - name: dist-win - path: dist-win/ - - - name: Compute installer SHA256 and build download URL - id: installer - shell: pwsh - run: | - $exe = Get-ChildItem dist-win\*.exe | Select-Object -First 1 - if (-not $exe) { throw "No .exe found in dist-win/" } - $sha256 = (Get-FileHash $exe.FullName -Algorithm SHA256).Hash.ToLower() - $url = "https://github.com/${{ github.repository }}/releases/download/${{ needs.setup.outputs.tag }}/$($exe.Name)" - "sha256=$sha256" >> $env:GITHUB_OUTPUT - "url=$url" >> $env:GITHUB_OUTPUT - Write-Host "Installer: $($exe.Name)" - Write-Host "SHA256: $sha256" - Write-Host "URL: $url" - - - name: Generate chocolateyInstall.ps1 from template - shell: pwsh - run: | - $script = Get-Content chocolatey/tools/chocolateyInstall.ps1.template -Raw - $script = $script -replace '{{INSTALLER_URL}}', '${{ steps.installer.outputs.url }}' - $script = $script -replace '{{INSTALLER_SHA256}}', '${{ steps.installer.outputs.sha256 }}' - Set-Content chocolatey/tools/chocolateyInstall.ps1 $script - Write-Host "Generated install script:" - Get-Content chocolatey/tools/chocolateyInstall.ps1 - - - name: Stamp version in nuspec - shell: pwsh - run: | - (Get-Content chocolatey/djmanager.nuspec) ` - -replace '.*', '${{ needs.setup.outputs.version }}' | - Set-Content chocolatey/djmanager.nuspec - - - name: Pack Chocolatey package - shell: pwsh - run: choco pack chocolatey/djmanager.nuspec --out dist-choco/ - - - name: Push to Chocolatey.org - shell: pwsh - run: | - $pkg = Get-ChildItem dist-choco\*.nupkg | Select-Object -First 1 - if (-not $pkg) { throw "No .nupkg found in dist-choco/" } - Write-Host "Pushing $($pkg.Name)" - choco push $pkg.FullName --source https://push.chocolatey.org --api-key ${{ secrets.CHOCOLATEY_API_KEY }} + #chocolatey: + # if: github.ref_name == 'master' + # needs: [setup, create-release] + # runs-on: windows-latest + # steps: + # - uses: actions/checkout@v7 + # + # - uses: actions/download-artifact@v8 + # with: + # name: dist-win + # path: dist-win/ + # + # - name: Compute installer SHA256 and build download URL + # id: installer + # shell: pwsh + # run: | + # $exe = Get-ChildItem dist-win\*.exe | Select-Object -First 1 + # if (-not $exe) { throw "No .exe found in dist-win/" } + # $sha256 = (Get-FileHash $exe.FullName -Algorithm SHA256).Hash.ToLower() + # $url = "https://github.com/${{ github.repository }}/releases/download/${{ needs.setup.outputs.tag }}/$($exe.Name)" + # "sha256=$sha256" >> $env:GITHUB_OUTPUT + # "url=$url" >> $env:GITHUB_OUTPUT + # Write-Host "Installer: $($exe.Name)" + # Write-Host "SHA256: $sha256" + # Write-Host "URL: $url" + # + # - name: Generate chocolateyInstall.ps1 from template + # shell: pwsh + # run: | + # $script = Get-Content chocolatey/tools/chocolateyInstall.ps1.template -Raw + # $script = $script -replace '{{INSTALLER_URL}}', '${{ steps.installer.outputs.url }}' + # $script = $script -replace '{{INSTALLER_SHA256}}', '${{ steps.installer.outputs.sha256 }}' + # Set-Content chocolatey/tools/chocolateyInstall.ps1 $script + # Write-Host "Generated install script:" + # Get-Content chocolatey/tools/chocolateyInstall.ps1 + # + # - name: Stamp version in nuspec + # shell: pwsh + # run: | + # (Get-Content chocolatey/djmanager.nuspec) ` + # -replace '.*', '${{ needs.setup.outputs.version }}' | + # Set-Content chocolatey/djmanager.nuspec + # + # - name: Pack Chocolatey package + # shell: pwsh + # run: choco pack chocolatey/djmanager.nuspec --out dist-choco/ + # + # - name: Push to Chocolatey.org + # shell: pwsh + # run: | + # $pkg = Get-ChildItem dist-choco\*.nupkg | Select-Object -First 1 + # if (-not $pkg) { throw "No .nupkg found in dist-choco/" } + # Write-Host "Pushing $($pkg.Name)" + # choco push $pkg.FullName --source https://push.chocolatey.org --api-key ${{ secrets.CHOCOLATEY_API_KEY }} # ── Sync dev + bump version after master release ───────────────────────────── # Always merges master → dev first so dev has the exact released code, @@ -193,7 +193,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@v7 with: ref: dev fetch-depth: 0 diff --git a/.gitignore b/.gitignore index a0a4a6ba..9722104b 100644 --- a/.gitignore +++ b/.gitignore @@ -165,6 +165,9 @@ env/ # Sample/test audio files samples/ +# Reverse-engineering test tracks (generated locally, not for version control) +reverse-engineering/test-tracks/ + # Dev-only downloaded binaries (generated by scripts/download-analyzer.sh) build-resources/analysis build-resources/analysis.exe diff --git a/CLAUDE.md b/CLAUDE.md index 2ffb022f..bd15b5fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -140,6 +140,27 @@ Audio is served over a local HTTP server (`src/audio/mediaServer.js`) instead of `--playlist-items "1,3,5"` is passed when the user deselects tracks in the selection step. +### TIDAL Download + +`src/audio/tidalDlManager.js` wraps the `tdn` CLI from the [tidal-dl-ng](https://github.com/Radexito/tidal-dl-ng-For-DJ) Python package: + +- `findTidalDlPath()` — searches `~/.local/bin`, common platform paths, then falls back to `which tdn` +- `checkTidalSetup()` — checks binary presence + reads `~/.config/tidal-dl-ng/token.json` for login state +- `startLogin(onUrl)` — spawns `tdn login` with `NO_COLOR=1 TERM=dumb`, parses `link.tidal.com` URL from stdout via regex, resolves when the process exits 0 +- `downloadTidal(url, outputDir, onProgress)` — saves the tidal-dl-ng `settings.json`, patches `download_base_path` to `userData/tidal_tmp` and `quality_audio` to `HiRes_Lossless`, spawns `tdn dl `, **always restores** the original config in both success and error paths, then scans the output dir for new audio files by mtime + +IPC channels: `tidal-check`, `tidal-login`, `tidal-download-url`. Events pushed to renderer: `tidal-progress` (per-line stdout), `tidal-login-url` (device-link URL for browser). + +### Top-level navigation + +`Sidebar.jsx` has a `MENU_ITEMS` array that drives the top nav. Each item has an `id` string. `App.jsx` receives `selectedPlaylistId` and branches on it: + +- `'download'` → `` (yt-dlp) +- `'tidal'` → `` +- anything else → `` (handles both `'music'` and playlist UUIDs) + +**Adding a new top-level view**: add entry to `MENU_ITEMS`, add a branch in `App.jsx`, create the view component. + ### Renderer / UI - Track list uses `react-window` (`FixedSizeList`) for virtualization — `ROW_HEIGHT = 50`, `PAGE_SIZE = 50` @@ -171,6 +192,17 @@ Handled client-side by `renderer/src/searchParser.js`. Supports field-qualified - **Platform-specific test stubs**: `usbUtils.test.js` stubs `process.platform` via `vi.stubGlobal` so Linux-branch tests run correctly on Windows. Use the same pattern for any test that branches on `process.platform`. - **Windows path in tests**: when constructing HTTP URLs from OS file paths in tests, convert with `'/' + filePath.replace(/\\/g, '/')` so paths are valid on Windows (e.g. `/C:/path/to/file`). +## GitHub API + +`gh` CLI is **not installed** on this machine. Use `git credential fill` to retrieve the stored OAuth token, then call the GitHub REST API directly with `curl`: + +```bash +TOKEN=$(git credential fill <<< $'protocol=https\nhost=github.com' | grep password | cut -d= -f2) +curl -s -X POST "https://api.github.com/repos/Radexito/DjManager/issues" \ + -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \ + -d '{"title":"...", "body":"..."}' +``` + ## Known Issues - **yt-dlp ffmpeg path**: yt-dlp spawned as subprocess can't find bundled ffmpeg. Pass `--ffmpeg-location ` to the yt-dlp spawn call using `getFfmpegRuntimePath()` from `deps.js`. diff --git a/README.md b/README.md new file mode 100644 index 00000000..37ff80f4 --- /dev/null +++ b/README.md @@ -0,0 +1,234 @@ +# DJ Manager + +A DJ-focused music library manager built with Electron. Manage your tracks, analyze BPM and key, export to Pioneer CDJ USB drives, and download from streaming platforms — all in one offline-first desktop app. + +![DJ Manager screenshot](screenshot.png) + +--- + +## Features + +### 🎵 Music Library + +- Import **MP3, FLAC, WAV, OGG, M4A, AAC, OPUS** with full metadata extraction +- SHA-1 deduplication — importing the same file twice is a no-op +- Virtualized infinite-scroll list (handles tens of thousands of tracks) +- Sort by any column: title, artist, album, BPM, key, loudness, duration, bitrate, year… +- Customizable column visibility and order, persisted between sessions +- Multi-select with **Ctrl+Click**, **Shift+Click range**, and **Ctrl+A** +- Inline track preview — click the play icon in any row to audition without leaving the library +- Per-track normalization status badge + +### 🔍 Search & Filter + +- Advanced field-qualified query syntax directly in the search bar: + ``` + BPM >= 128 AND KEY:8A GENRE is Techno + ARTIST:Burial YEAR > 2010 + BPM >= 120 AND KEY:12A + ``` +- Supports `AND`, `OR`, field names (`BPM`, `KEY`, `ARTIST`, `ALBUM`, `LABEL`, `GENRE`, `YEAR`, `BITRATE`), and comparison operators (`>=`, `<=`, `>`, `<`, `is`, `contains`) + +### 📊 Auto-Analysis + +- **BPM** detection via Mixxx analyzer (runs in background worker threads — import never blocks the UI) +- **Musical key** — raw notation + Camelot wheel (e.g. `8B`) +- **Loudness** — LUFS / ReplayGain +- **Intro / Outro** timestamps +- **Beatgrid** generation for CDJ export +- **Waveform** data (PWAV / PWV2 / PWV4 / PWV6) generated via FFmpeg +- Frequency band analysis (bass, mid, treble RMS) per slice + +### 🎧 Audio Normalization + +- Target loudness configurable in Settings (default **-9 LUFS**, range -60 to 0) +- Original file preserved — normalized copy stored separately, allowing export in either form +- Bulk normalize entire library or selected tracks +- Reset normalization per track or library-wide +- Auto-normalize on import (optional toggle in Settings) +- Player automatically prefers the normalized file when available + +### 📝 Metadata & Auto-Tagging + +- Edit title, artist, album, label, year, genres, comments — inline or in the details panel +- Bulk metadata editing across multiple selected tracks +- **Auto-tagger** searches MusicBrainz, Discogs, iTunes, and Deezer simultaneously +- Visual diff of current vs. suggested values — accept or reject per field +- Cover art picker with zoom/preview, sourced from MusicBrainz Cover Art Archive, iTunes, and Deezer +- BPM adjust shortcuts: ×2, ×0.5 + +### 📋 Playlists + +- Create, rename, delete playlists (tracks remain in library) +- Add/remove tracks via context menu or drag-and-drop +- Drag-and-drop track reordering within a playlist +- Assign a colour to each playlist (8 presets) +- Import playlist from file — prompts which library playlist to add tracks to +- Export playlist as **M3U** + +### 📁 File Explorer + +Browse your filesystem directly inside DJ Manager — no need to import files to include them in analysis and USB export. + +- **Native folder browser** — navigate your filesystem with a breadcrumb toolbar and back/forward navigation +- **Favourites sidebar** — right-click any folder to add it to a pinned favourites list; persisted between sessions +- **Quick-jump buttons** — home directory and native folder picker +- **🔗 Linked files** — link audio files or entire folders to the library without copying them; originals stay in place + - Linked tracks shown with a 🔗 badge in the Music Library list + - Artist / title extracted from ID3 tags with `"Artist - Title"` filename fallback +- **Link folder to library** — right-click a folder or use `+Library` toolbar button to link its audio files into: + - All Music (no playlist) + - A new named playlist + - An existing playlist + - Optional recursive scan (all subdirectories) +- **Recursive tree scan** (`🌲 Tree`) — scan a folder recursively and stream results in batches; cancel mid-scan +- **Live analysis** — `Analyze` button links + analyzes all audio files in the current folder; rows update in real-time as each track finishes analysis; button becomes `Cancel Analyzing…` and persists across navigation until all workers complete +- **Explicit confirm dialogs** for all mass operations (recursive scan, analyze, +Library) — describes the scope before running +- **Track details side panel** — click any row to open the same edit/metadata panel as the Music Library (non-breaking table layout) +- **🎛 Prepare Track (BeatGrid Editor)** — edit beatgrid directly from the explorer context menu +- **Status icons** — each row shows whether a file is linked (`🔗`), broken (`✗`), or untracked +- **Context menu per file**: play, link, link to playlist, open track details, prepare track, remap file, remove from library +- **Context menu per folder**: link folder, tree scan, add/remove favourite +- **🗑️ Remove file** — removes the file from the library **and deletes it from disk** (with confirmation dialog explaining the permanent deletion) + +### ⬇️ Downloads (yt-dlp) + +Paste any URL from **YouTube, SoundCloud, Bandcamp, Mixcloud, Vimeo, Twitch, Twitter/X, Instagram, Facebook, TikTok, Dailymotion, Deezer**, and 1000+ other yt-dlp-supported sites. + +- Fetch playlist metadata before downloading — preview titles, durations, availability +- Deselect individual tracks from a playlist before starting the download +- Duplicate detection — URLs already in your library are highlighted +- Per-track and overall download progress in the sidebar +- Cancel in-progress downloads +- Browser cookie authentication (Chrome, Chromium, Brave, Firefox, LibreWolf, Edge) for sites requiring login +- Downloaded tracks import directly into the library and optionally into a playlist +- Channel name used as artist when video title contains no artist delimiter + +### 🎵 TIDAL Download + +Download from TIDAL via the [tidal-dl-ng](https://github.com/Radexito/tidal-dl-ng-For-DJ) integration. + +- One-click login via device-link URL (opens in your browser) +- Paste any TIDAL track, album, or playlist URL to download at **HiRes Lossless** quality +- 3-step UI: login → paste URL → select tracks → download +- Duplicate detection — tracks already in your library are flagged before downloading +- Progress shown per track; imported directly into the library with full metadata + +### 🎯 Cue Points & Auto-Cue + +- **Cue Points Editor** — add, label, colour, and delete hot cues (A–H) and memory cues per track +- **CueGen Auto-Cue** — automatically generates hot cues A–H from the beatgrid (every N bars, configurable) +- Hot cues exported to Rekordbox USB with correct slot assignments, labels, and colours +- Cue marker overlay on the seekbar for visual reference during playback + +### 🎮 Player + +- Built-in player streaming from a local HTTP server (reliable Range request support for seeking) +- Keyboard shortcuts: **Space** (play/pause), media keys +- Seek bar, volume control, current time / duration +- Output device selection +- Queue management — queue stays in sync when tracks are added to the library or playlist during playback +- **Shuffle** and **Repeat** modes (none / all / one) +- 50-track play history ring buffer + +### 💾 Rekordbox USB Export + +Full **Pioneer CDJ / XDJ-compatible** export — plug the USB in and it just works. + +- Exports the full library or individual playlists +- Writes **ANLZ0000.DAT / .EXT / .2EX** — waveform, beatgrid, intro/outro cue data + - Hot cue slots **A–H** with correct Pioneer palette colour codes for CDJ hardware (PCPT) and Rekordbox PC (PCP2 extended colour wheel) + - Memory cues, beatgrid (PQT2), high-res waveform (PWV5), colour waveform (PWV4), preview waveform (PWV3) +- Writes **export.pdb** — full DeviceSQL binary database (tracks, playlists, artwork, keys, ratings) +- Writes **MYSETTING.DAT / MYSETTING2.DAT / DEVSETTING.DAT** — hardware settings with correct CRC-16/XMODEM checksums +- USB filesystem validation (FAT32 / exFAT detection, format warnings) +- Export progress tracking + +### ⚙️ Settings + +| Section | Options | +| ------------- | ----------------------------------------------------------------------------------------- | +| Library | Custom library path, move library to new location | +| Normalization | Target LUFS, auto-normalize on import, bulk normalize / reset | +| Downloads | Browser cookie source, preferred audio format | +| Dependencies | View installed versions of ffmpeg / yt-dlp / analyzer, update individually or all at once | +| Advanced | Clear library, reset all user data, view log files | + +--- + +## Download + +Pre-built releases are available on the [GitHub Releases](https://github.com/Radexito/DjManager/releases) page. + +| Platform | Format | +| -------- | ------------------- | +| Linux | AppImage (x64) | +| macOS | dmg (Apple Silicon) | +| Windows | NSIS installer | + +On first launch, FFmpeg and the mixxx-analyzer binary are downloaded automatically. + +--- + +## Development + +```bash +# Install dependencies +npm install +cd renderer && npm install && cd .. + +# Start dev server (Vite + Electron) +npm run dev + +# Lint +npm run lint:all + +# Format +npm run format + +# Run tests +npm test # main process (Vitest) +cd renderer && npm test # renderer (React Testing Library) + +# Build distributable +npm run dist:linux # or :mac / :win +``` + +> **Note:** Close the Electron app before running `npm test` — the pretest step rebuilds `better-sqlite3` for Node.js and will fail if Electron holds the binary open. + +--- + +## Tech Stack + +| Layer | Technology | +| ---------------- | ------------------------------------------------- | +| Shell | **Electron** 40 | +| UI | **React 19** + **Vite 8** | +| Database | **better-sqlite3** (synchronous SQLite) | +| Analysis | **Mixxx analyzer** — BPM, key, loudness, beatgrid | +| Audio processing | **FFmpeg** — decode, waveform, format conversion | +| Downloads | **yt-dlp** | +| Drag-and-drop | **@dnd-kit** | +| Virtual list | **react-window** | + +--- + +## Acknowledgements + +The Rekordbox USB export feature stands on the shoulders of a lot of excellent prior work in the Pioneer reverse engineering community. + +- **[meiremans](https://github.com/meiremans)** — [beirbox-gui](https://github.com/meiremans/beirbox-gui) gave us our starting point for understanding the overall USB layout, ANLZ file sections, and DeviceSQL PDB structure. + +- **[kimtore](https://github.com/kimtore)** — [rex](https://github.com/kimtore/rex), a Rekordbox USB exporter whose DeviceSQL PDB writing logic we studied closely and rewrote in JavaScript for DJ Manager. + +- **[Deep-Symmetry](https://github.com/Deep-Symmetry)** — [crate-digger](https://github.com/Deep-Symmetry/crate-digger) and its Kaitai Struct definitions for `.DAT` / `.EXT` file parsing were invaluable for understanding the binary layout of ANLZ sections. + +- **[jandk](https://github.com/jandk)** — for figuring out how Pioneer derives the USBANLZ folder path hash from the track's USB file path. + +- **[bartvg](https://github.com/bartvg)** (Vettige Weust) — for patiently listening to way too much bacon. 🥓 + +--- + +## License + +MIT © [Radexito](https://github.com/Radexito) diff --git a/build-resources/icon.png b/build-resources/icon.png new file mode 100644 index 00000000..9759e4e5 Binary files /dev/null and b/build-resources/icon.png differ diff --git a/e2e/library.spec.js b/e2e/library.spec.js index 143e22b5..f833be4d 100644 --- a/e2e/library.spec.js +++ b/e2e/library.spec.js @@ -2,11 +2,12 @@ import { test, expect } from '@playwright/test'; import { launchApp } from './fixtures.js'; test.describe('Music library', () => { - let app, window; + let app, window, library; test.beforeEach(async () => { ({ app, window } = await launchApp()); await expect(window.locator('.music-library')).toBeVisible(); + library = window.locator('.music-library'); }); test.afterEach(async () => { @@ -14,12 +15,12 @@ test.describe('Music library', () => { }); test('column headers are visible', async () => { - await expect(window.locator('.header-cell', { hasText: '#' })).toBeVisible(); - await expect(window.locator('.header-cell', { hasText: 'Title' })).toBeVisible(); - await expect(window.locator('.header-cell', { hasText: 'Artist' })).toBeVisible(); - await expect(window.locator('.header-cell', { hasText: 'BPM' })).toBeVisible(); - await expect(window.locator('.header-cell', { hasText: 'Key' })).toBeVisible(); - await expect(window.locator('.header-cell', { hasText: 'Loudness (LUFS)' })).toBeVisible(); + await expect(library.locator('.header-cell', { hasText: '#' })).toBeVisible(); + await expect(library.locator('.header-cell', { hasText: 'Title' })).toBeVisible(); + await expect(library.locator('.header-cell', { hasText: 'Artist' })).toBeVisible(); + await expect(library.locator('.header-cell', { hasText: 'BPM' })).toBeVisible(); + await expect(library.locator('.header-cell', { hasText: 'Key' })).toBeVisible(); + await expect(library.locator('.header-cell', { hasText: 'Loudness (LUFS)' })).toBeVisible(); }); test('search input is visible', async () => { @@ -27,18 +28,18 @@ test.describe('Music library', () => { }); test('empty library shows no track rows', async () => { - await expect(window.locator('.track-list')).toBeVisible(); - await expect(window.locator('.row')).toHaveCount(0); + await expect(library.locator('.track-list')).toBeVisible(); + await expect(library.locator('.row')).toHaveCount(0); }); test('clicking a column header sets sort indicator', async () => { - const bpmHeader = window.locator('.header-cell', { hasText: 'BPM' }); + const bpmHeader = library.locator('.header-cell', { hasText: 'BPM' }); await bpmHeader.click(); await expect(bpmHeader).toContainText('▲'); }); test('clicking same column header again reverses sort direction', async () => { - const bpmHeader = window.locator('.header-cell', { hasText: 'BPM' }); + const bpmHeader = library.locator('.header-cell', { hasText: 'BPM' }); await bpmHeader.click(); await expect(bpmHeader).toContainText('▲'); await bpmHeader.click(); diff --git a/package-lock.json b/package-lock.json index 500ee5da..e0a2edfc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,37 +1,37 @@ { "name": "dj_manager", - "version": "1.0.11", + "version": "1.0.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dj_manager", - "version": "1.0.11", + "version": "1.0.18", "hasInstallScript": true, "license": "ISC", "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "better-sqlite3": "^12.8.0", + "better-sqlite3": "^12.10.0", "react-virtualized": "^9.22.6", "react-window": "^2.2.7" }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.58.2", - "@vitest/coverage-v8": "^4.1.0", - "concurrently": "^9.2.1", - "electron": "^40.8.0", + "@playwright/test": "^1.60.0", + "@vitest/coverage-v8": "^4.1.6", + "concurrently": "^10.0.3", + "electron": "^41.5.0", "electron-builder": "^26.8.1", "electron-rebuild": "^3.2.9", - "eslint": "^10.0.3", - "globals": "^17.4.0", + "eslint": "^10.5.0", + "globals": "^17.6.0", "husky": "^9.1.7", - "lint-staged": "^16.4.0", - "prettier": "^3.8.1", - "vitest": "^4.1.0", - "wait-on": "^9.0.4" + "lint-staged": "^17.0.7", + "prettier": "^3.8.4", + "vitest": "^4.1.9", + "wait-on": "^9.0.10" } }, "node_modules/@babel/helper-string-parser": { @@ -993,21 +993,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.1.tgz", - "integrity": "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.1.tgz", - "integrity": "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -1016,9 +1016,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -1069,13 +1069,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.3", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" }, @@ -1094,9 +1094,9 @@ } }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1107,13 +1107,13 @@ } }, "node_modules/@eslint/config-array/node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -1123,22 +1123,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -1170,9 +1170,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -1180,13 +1180,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { @@ -1235,9 +1235,9 @@ "license": "BSD-3-Clause" }, "node_modules/@hapi/tlds": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.4.tgz", - "integrity": "sha512-Fq+20dxsxLaUn5jSSWrdtSRcIUba2JquuorF9UW1wIJS5cSUwxIsO2GIhaWynPRflvxSzFN+gxKte2HEW1OuoA==", + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@hapi/tlds/-/tlds-1.1.6.tgz", + "integrity": "sha512-xdi7A/4NZokvV0ewovme3aUO5kQhW9pQ2YD1hRqZGhhSi5rBv4usHYidVocXSi9eihYsznZxLtAiEYYUL6VBGw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -1539,20 +1539,22 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", - "@tybys/wasm-util": "^0.10.1" + "@tybys/wasm-util": "^0.10.2" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@npmcli/agent": { @@ -1675,9 +1677,9 @@ } }, "node_modules/@oxc-project/types": { - "version": "0.120.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.120.0.tgz", - "integrity": "sha512-k1YNu55DuvAip/MGE1FTsIuU3FUCn6v/ujG9V7Nq5Df/kX2CWb13hhwD0lmJGMGqE+bE1MXvv9SZVnMzEXlWcg==", + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", "dev": true, "license": "MIT", "funding": { @@ -1696,13 +1698,13 @@ } }, "node_modules/@playwright/test": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", - "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", + "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.58.2" + "playwright": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -1712,9 +1714,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-jOHxwXhxmFKuXztiu1ORieJeTbx5vrTkcOkkkn2d35726+iwhrY1w/+nYY/AGgF12thg33qC3R1LMBF5tHTZHg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", "cpu": [ "arm64" ], @@ -1729,9 +1731,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-gED05Teg/vtTZbIJBc4VNMAxAFDUPkuO/rAIyyxZjTj1a1/s6z5TII/5yMGZ0uLRCifEtwUQn8OlYzuYc0m70w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", "cpu": [ "arm64" ], @@ -1746,9 +1748,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-rI15NcM1mA48lqrIxVkHfAqcyFLcQwyXWThy+BQ5+mkKKPvSO26ir+ZDp36AgYoYVkqvMcdS8zOE6SeBsR9e8A==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", "cpu": [ "x64" ], @@ -1763,9 +1765,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.10.tgz", - "integrity": "sha512-XZRXHdTa+4ME1MuDVp021+doQ+z6Ei4CCFmNc5/sKbqb8YmkiJdj8QKlV3rCI0AJtAeSB5n0WGPuJWNL9p/L2w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", "cpu": [ "x64" ], @@ -1780,9 +1782,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.10.tgz", - "integrity": "sha512-R0SQMRluISSLzFE20sPWYHVmJdDQnRyc/FzSCN72BqQmh2SOZUFG+N3/vBZpR4C6WpEUVYJLrYUXaj43sJsNLA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", "cpu": [ "arm" ], @@ -1797,9 +1799,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-Y1reMrV/o+cwpduYhJuOE3OMKx32RMYCidf14y+HssARRmhDuWXJ4yVguDg2R/8SyyGNo+auzz64LnPK9Hq6jg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", "cpu": [ "arm64" ], @@ -1814,9 +1816,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-vELN+HNb2IzuzSBUOD4NHmP9yrGwl1DVM29wlQvx1OLSclL0NgVWnVDKl/8tEks79EFek/kebQKnNJkIAA4W2g==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", "cpu": [ "arm64" ], @@ -1831,9 +1833,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-ZqrufYTgzxbHwpqOjzSsb0UV/aV2TFIY5rP8HdsiPTv/CuAgCRjM6s9cYFwQ4CNH+hf9Y4erHW1GjZuZ7WoI7w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", "cpu": [ "ppc64" ], @@ -1848,9 +1850,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-gSlmVS1FZJSRicA6IyjoRoKAFK7IIHBs7xJuHRSmjImqk3mPPWbR7RhbnfH2G6bcmMEllCt2vQ/7u9e6bBnByg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", "cpu": [ "s390x" ], @@ -1865,9 +1867,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.10.tgz", - "integrity": "sha512-eOCKUpluKgfObT2pHjztnaWEIbUabWzk3qPZ5PuacuPmr4+JtQG4k2vGTY0H15edaTnicgU428XW/IH6AimcQw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", "cpu": [ "x64" ], @@ -1882,9 +1884,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.10.tgz", - "integrity": "sha512-Xdf2jQbfQowJnLcgYfD/m0Uu0Qj5OdxKallD78/IPPfzaiaI4KRAwZzHcKQ4ig1gtg1SuzC7jovNiM2TzQsBXA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", "cpu": [ "x64" ], @@ -1899,9 +1901,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.10.tgz", - "integrity": "sha512-o1hYe8hLi1EY6jgPFyxQgQ1wcycX+qz8eEbVmot2hFkgUzPxy9+kF0u0NIQBeDq+Mko47AkaFFaChcvZa9UX9Q==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", "cpu": [ "arm64" ], @@ -1916,9 +1918,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.10.tgz", - "integrity": "sha512-Ugv9o7qYJudqQO5Y5y2N2SOo6S4WiqiNOpuQyoPInnhVzCY+wi/GHltcLHypG9DEUYMB0iTB/huJrpadiAcNcA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", "cpu": [ "wasm32" ], @@ -1926,16 +1928,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-7UODQb4fQUNT/vmgDZBl3XOBAIOutP5R3O/rkxg0aLfEGQ4opbCgU5vOw/scPe4xOqBwL9fw7/RP1vAMZ6QlAQ==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", "cpu": [ "arm64" ], @@ -1950,9 +1954,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.10.tgz", - "integrity": "sha512-PYxKHMVHOb5NJuDL53vBUl1VwUjymDcYI6rzpIni0C9+9mTiJedvUxSk7/RPp7OOAm3v+EjgMu9bIy3N6b408w==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", "cpu": [ "x64" ], @@ -1967,9 +1971,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.10.tgz", - "integrity": "sha512-UkVDEFk1w3mveXeKgaTuYfKWtPbvgck1dT8TUG3bnccrH0XtLTuAyfCoks4Q/M5ZGToSVJTIQYCzy2g/atAOeg==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -2017,9 +2021,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -2175,14 +2179,14 @@ } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", - "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.9", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -2190,14 +2194,14 @@ "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.0", - "vitest": "4.1.0" + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -2206,31 +2210,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2239,7 +2243,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2251,26 +2255,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.9", "pathe": "^2.0.3" }, "funding": { @@ -2278,14 +2282,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2294,9 +2298,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", "dev": true, "license": "MIT", "funding": { @@ -2304,15 +2308,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.9", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2887,15 +2891,16 @@ } }, "node_modules/axios": { - "version": "1.13.6", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.6.tgz", - "integrity": "sha512-ChTCHMouEe2kn713WHbQGcuYrr6fXTBiu460OTwWrWob16g1bXn4vtz07Ope7ewMozJAnEquLk5lWQWtBig9DQ==", + "version": "1.16.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.1.tgz", + "integrity": "sha512-caYkukvroVPO8KrzuJEb50Hm07KwfBZPEC3VeFHTsqWHvKTsy54hjJz9BS/cdaypROE2rH6xvm9mHX4fgWkr3A==", "dev": true, "license": "MIT", "dependencies": { - "follow-redirects": "^1.15.11", + "follow-redirects": "^1.16.0", "form-data": "^4.0.5", - "proxy-from-env": "^1.1.0" + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" } }, "node_modules/balanced-match": { @@ -2926,9 +2931,9 @@ "license": "MIT" }, "node_modules/better-sqlite3": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.8.0.tgz", - "integrity": "sha512-RxD2Vd96sQDjQr20kdP+F+dK/1OUNiVOl200vKBZY8u0vTwysfolF6Hq+3ZK2+h8My9YvZhHsF+RSGZW2VYrPQ==", + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.10.0.tgz", + "integrity": "sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==", "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -2936,7 +2941,7 @@ "prebuild-install": "^7.1.1" }, "engines": { - "node": "20.x || 22.x || 23.x || 24.x || 25.x" + "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x" } }, "node_modules/bindings": { @@ -3462,13 +3467,6 @@ "color-support": "bin.js" } }, - "node_modules/colorette": { - "version": "2.0.20", - "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", - "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", - "dev": true, - "license": "MIT" - }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -3510,30 +3508,171 @@ "license": "MIT" }, "node_modules/concurrently": { - "version": "9.2.1", - "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-9.2.1.tgz", - "integrity": "sha512-fsfrO0MxV64Znoy8/l1vVIjjHa29SZyyqPgQBwhiDcaW8wJc2W3XWVOGx4M3oJBnv/zdUZIIp1gDeS98GzP8Ng==", + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/concurrently/-/concurrently-10.0.3.tgz", + "integrity": "sha512-hc3LH4UaKWd/bbyDK/IGVa4RB6PtQ3CUYwtrkzqHn+wIG3Hr5fhpRlk0L/gCa8ZE1L/Ufj50Zho69cI5w8SQBA==", "dev": true, "license": "MIT", "dependencies": { - "chalk": "4.1.2", + "chalk": "5.6.2", "rxjs": "7.8.2", - "shell-quote": "1.8.3", - "supports-color": "8.1.1", + "shell-quote": "1.8.4", + "supports-color": "10.2.2", "tree-kill": "1.2.2", - "yargs": "17.7.2" + "yargs": "18.0.0" }, "bin": { - "conc": "dist/bin/concurrently.js", - "concurrently": "dist/bin/concurrently.js" + "conc": "dist/bin/index.js", + "concurrently": "dist/bin/index.js" }, "engines": { - "node": ">=18" + "node": ">=22" }, "funding": { "url": "https://github.com/open-cli-tools/concurrently?sponsor=1" } }, + "node_modules/concurrently/node_modules/ansi-regex": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", + "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-regex?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/ansi-styles": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz", + "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/cliui": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz", + "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^7.2.0", + "strip-ansi": "^7.1.0", + "wrap-ansi": "^9.0.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/concurrently/node_modules/emoji-regex": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", + "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", + "dev": true, + "license": "MIT" + }, + "node_modules/concurrently/node_modules/string-width": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", + "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^10.3.0", + "get-east-asian-width": "^1.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/concurrently/node_modules/strip-ansi": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz", + "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^6.2.2" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/chalk/strip-ansi?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/wrap-ansi": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", + "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^6.2.1", + "string-width": "^7.0.0", + "strip-ansi": "^7.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/concurrently/node_modules/yargs": { + "version": "18.0.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz", + "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^9.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "string-width": "^7.2.0", + "y18n": "^5.0.5", + "yargs-parser": "^22.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, + "node_modules/concurrently/node_modules/yargs-parser": { + "version": "22.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz", + "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==", + "dev": true, + "license": "ISC", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=23" + } + }, "node_modules/console-control-strings": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", @@ -3924,9 +4063,9 @@ } }, "node_modules/electron": { - "version": "40.8.3", - "resolved": "https://registry.npmjs.org/electron/-/electron-40.8.3.tgz", - "integrity": "sha512-MH6LK4xM6VVmmtz0nRE0Fe8l2jTKSYTvH1t0ZfbNLw3o6dlBCVTRqQha6uL8ZQVoMy74JyLguGwK7dU7rCKIhw==", + "version": "41.5.0", + "resolved": "https://registry.npmjs.org/electron/-/electron-41.5.0.tgz", + "integrity": "sha512-x9j9//PubUA4EjDtQbZhtk3prolandqCKgit0uCIqc1jb8FTskPbnJtxcDFB1aejczJcuERgjPixBUaMwoWyJg==", "dev": true, "hasInstallScript": true, "license": "MIT", @@ -4338,18 +4477,21 @@ } }, "node_modules/eslint": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.1.0.tgz", - "integrity": "sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", "dev": true, "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.3", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -4743,9 +4885,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.11", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", - "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", "dev": true, "funding": [ { @@ -4908,9 +5050,9 @@ } }, "node_modules/get-east-asian-width": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.5.0.tgz", - "integrity": "sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", "dev": true, "license": "MIT", "engines": { @@ -5050,9 +5192,9 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -5618,9 +5760,9 @@ } }, "node_modules/joi": { - "version": "18.0.2", - "resolved": "https://registry.npmjs.org/joi/-/joi-18.0.2.tgz", - "integrity": "sha512-RuCOQMIt78LWnktPoeBL0GErkNaJPTBGcYuyaBvUOQSpcpcLfWrHPPihYdOGbV5pam9VTWbeoF7TsGiHugcjGA==", + "version": "18.2.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-18.2.1.tgz", + "integrity": "sha512-2/OKlogiESf2Nh3TFCrRjrr9z1DRHeW0I+KReF67+4J0Ns+8hBtHRmoWAZ2OFU6I5+TWLEe6sVlSdXPjHm5UbQ==", "dev": true, "license": "BSD-3-Clause", "dependencies": { @@ -5630,7 +5772,7 @@ "@hapi/pinpoint": "^2.0.1", "@hapi/tlds": "^1.1.1", "@hapi/topo": "^6.0.2", - "@standard-schema/spec": "^1.0.0" + "@standard-schema/spec": "^1.1.0" }, "engines": { "node": ">= 20" @@ -6000,55 +6142,45 @@ } }, "node_modules/lint-staged": { - "version": "16.4.0", - "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-16.4.0.tgz", - "integrity": "sha512-lBWt8hujh/Cjysw5GYVmZpFHXDCgZzhrOm8vbcUdobADZNOK/bRshr2kM3DfgrrtR1DQhfupW9gnIXOfiFi+bw==", + "version": "17.0.7", + "resolved": "https://registry.npmjs.org/lint-staged/-/lint-staged-17.0.7.tgz", + "integrity": "sha512-JrSobt+tW3rH8IOMi8tDZd3foorM5yPEkLD/V2NxobgHrFfHWGee4MOLVuZeScgxftEwbHrPHIFA/ZL+nUJeuA==", "dev": true, "license": "MIT", "dependencies": { - "commander": "^14.0.3", - "listr2": "^9.0.5", - "picomatch": "^4.0.3", + "listr2": "^10.2.1", + "picomatch": "^4.0.4", "string-argv": "^0.3.2", - "tinyexec": "^1.0.4", - "yaml": "^2.8.2" + "tinyexec": "^1.2.4" }, "bin": { "lint-staged": "bin/lint-staged.js" }, "engines": { - "node": ">=20.17" + "node": ">=22.22.1" }, "funding": { "url": "https://opencollective.com/lint-staged" - } - }, - "node_modules/lint-staged/node_modules/commander": { - "version": "14.0.3", - "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", - "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20" + }, + "optionalDependencies": { + "yaml": "^2.9.0" } }, "node_modules/listr2": { - "version": "9.0.5", - "resolved": "https://registry.npmjs.org/listr2/-/listr2-9.0.5.tgz", - "integrity": "sha512-ME4Fb83LgEgwNw96RKNvKV4VTLuXfoKudAmm2lP8Kk87KaMK0/Xrx/aAkMWmT8mDb+3MlFDspfbCs7adjRxA2g==", + "version": "10.2.1", + "resolved": "https://registry.npmjs.org/listr2/-/listr2-10.2.1.tgz", + "integrity": "sha512-7I5knELsJKTUjXG+A6BkKAiGkW1i25fNa/xlUl9hFtk15WbE9jndA89xu5FzQKrY5llajE1hfZZFMILXkDHk/Q==", "dev": true, "license": "MIT", "dependencies": { - "cli-truncate": "^5.0.0", - "colorette": "^2.0.20", - "eventemitter3": "^5.0.1", + "cli-truncate": "^5.2.0", + "eventemitter3": "^5.0.4", "log-update": "^6.1.0", "rfdc": "^1.4.1", - "wrap-ansi": "^9.0.0" + "wrap-ansi": "^10.0.0" }, "engines": { - "node": ">=20.0.0" + "node": ">=22.13.0" } }, "node_modules/listr2/node_modules/ansi-regex": { @@ -6078,14 +6210,14 @@ } }, "node_modules/listr2/node_modules/cli-truncate": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.1.1.tgz", - "integrity": "sha512-SroPvNHxUnk+vIW/dOSfNqdy1sPEFkrTk6TUtqLCnBlo3N7TNYYkzzN7uSD6+jVjrdO4+p8nH7JzH6cIvUem6A==", + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/cli-truncate/-/cli-truncate-5.2.0.tgz", + "integrity": "sha512-xRwvIOMGrfOAnM1JYtqQImuaNtDEv9v6oIYAs4LIHwTiKee8uwvIi363igssOC0O5U04i4AlENs79LQLu9tEMw==", "dev": true, "license": "MIT", "dependencies": { - "slice-ansi": "^7.1.0", - "string-width": "^8.0.0" + "slice-ansi": "^8.0.0", + "string-width": "^8.2.0" }, "engines": { "node": ">=20" @@ -6094,13 +6226,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/listr2/node_modules/emoji-regex": { - "version": "10.6.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz", - "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==", - "dev": true, - "license": "MIT" - }, "node_modules/listr2/node_modules/is-fullwidth-code-point": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz", @@ -6118,26 +6243,26 @@ } }, "node_modules/listr2/node_modules/slice-ansi": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz", - "integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz", + "integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "is-fullwidth-code-point": "^5.0.0" + "ansi-styles": "^6.2.3", + "is-fullwidth-code-point": "^5.1.0" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/slice-ansi?sponsor=1" } }, "node_modules/listr2/node_modules/string-width": { - "version": "8.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.0.tgz", - "integrity": "sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==", + "version": "8.2.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz", + "integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==", "dev": true, "license": "MIT", "dependencies": { @@ -6168,41 +6293,23 @@ } }, "node_modules/listr2/node_modules/wrap-ansi": { - "version": "9.0.2", - "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz", - "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-10.0.0.tgz", + "integrity": "sha512-SGcvg80f0wUy2/fXES19feHMz8E0JoXv2uNgHOu4Dgi2OrCy1lqwFYEJz1BLbDI0exjPMe/ZdzZ/YpGECBG/aQ==", "dev": true, "license": "MIT", "dependencies": { - "ansi-styles": "^6.2.1", - "string-width": "^7.0.0", - "strip-ansi": "^7.1.0" + "ansi-styles": "^6.2.3", + "string-width": "^8.2.0", + "strip-ansi": "^7.1.2" }, "engines": { - "node": ">=18" + "node": ">=20" }, "funding": { "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, - "node_modules/listr2/node_modules/wrap-ansi/node_modules/string-width": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz", - "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^10.3.0", - "get-east-asian-width": "^1.0.0", - "strip-ansi": "^7.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -6220,9 +6327,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -6812,9 +6919,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -7268,9 +7375,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -7281,13 +7388,13 @@ } }, "node_modules/playwright": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", - "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", + "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.58.2" + "playwright-core": "1.60.0" }, "bin": { "playwright": "cli.js" @@ -7300,9 +7407,9 @@ } }, "node_modules/playwright-core": { - "version": "1.58.2", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", - "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "version": "1.60.0", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", + "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -7328,9 +7435,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -7348,7 +7455,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -7423,9 +7530,9 @@ } }, "node_modules/prettier": { - "version": "3.8.1", - "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz", - "integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==", + "version": "3.8.4", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.4.tgz", + "integrity": "sha512-N2MylSdi48+5N/6S5j+maeHbUSIzzZ5uOcX5Hm4QpV8Dkb1HFjfAKTKX6yNPJQD9AhcT3ifHNB66tWTTJDi11Q==", "dev": true, "license": "MIT", "bin": { @@ -7503,11 +7610,14 @@ } }, "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=10" + } }, "node_modules/pump": { "version": "3.0.3", @@ -7763,14 +7873,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.10", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.10.tgz", - "integrity": "sha512-q7j6vvarRFmKpgJUT8HCAUljkgzEp4LAhPlJUvQhA5LA1SUL36s5QCysMutErzL3EbNOZOkoziSx9iZC4FddKA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.120.0", - "@rolldown/pluginutils": "1.0.0-rc.10" + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -7779,21 +7889,21 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.10", - "@rolldown/binding-darwin-x64": "1.0.0-rc.10", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.10", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.10", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.10", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.10", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.10", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.10", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.10", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.10" + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" } }, "node_modules/rxjs": { @@ -7926,9 +8036,9 @@ } }, "node_modules/shell-quote": { - "version": "1.8.3", - "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.3.tgz", - "integrity": "sha512-ObmnIF4hXNg1BqhnHmgbDETF8dLPCggZWBjkQfhZpbszZnYur5DUljTcCHii5LC3J5E0yeO/1LIMyH+UvHQgyw==", + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/shell-quote/-/shell-quote-1.8.4.tgz", + "integrity": "sha512-VsC6n6vz1ihYYyZZwX7YZSF5l5x36ca17OC+a69h94YqB7X6XLwf+5MOgynYir2SLFUbl8gIYvBo8K8RoNQ6bQ==", "dev": true, "license": "MIT", "engines": { @@ -8256,16 +8366,13 @@ } }, "node_modules/supports-color": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", - "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "version": "10.2.2", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz", + "integrity": "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==", "dev": true, "license": "MIT", - "dependencies": { - "has-flag": "^4.0.0" - }, "engines": { - "node": ">=10" + "node": ">=18" }, "funding": { "url": "https://github.com/chalk/supports-color?sponsor=1" @@ -8459,9 +8566,9 @@ "license": "MIT" }, "node_modules/tinyexec": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.4.tgz", - "integrity": "sha512-u9r3uZC0bdpGOXtlxUIdwf9pkmvhqJdrVCH9fapQtgy/OeTTMZ1nqH7agtvEfmGui6e1XxjcdrlxvxJvc3sMqw==", + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", "dev": true, "license": "MIT", "engines": { @@ -8469,14 +8576,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -8663,17 +8770,17 @@ } }, "node_modules/vite": { - "version": "8.0.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", - "integrity": "sha512-wt+Z2qIhfFt85uiyRt5LPU4oVEJBXj8hZNWKeqFG4gRG/0RaRGJ7njQCwzFVjO+v4+Ipmf5CY7VdmZRAYYBPHw==", + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", "dev": true, "license": "MIT", "dependencies": { "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.10", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" }, "bin": { "vite": "bin/vite.js" @@ -8689,8 +8796,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.1.0", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -8756,19 +8863,19 @@ } }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -8779,8 +8886,8 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -8796,13 +8903,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -8823,6 +8932,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -8838,15 +8953,15 @@ } }, "node_modules/wait-on": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.4.tgz", - "integrity": "sha512-k8qrgfwrPVJXTeFY8tl6BxVHiclK11u72DVKhpybHfUL/K6KM4bdyK9EhIVYGytB5MJe/3lq4Tf0hrjM+pvJZQ==", + "version": "9.0.10", + "resolved": "https://registry.npmjs.org/wait-on/-/wait-on-9.0.10.tgz", + "integrity": "sha512-rCoJEhvMr0X6alHmwc9abbrA5ZrLZFKpFQVKPNFwl2h7DapXOGdmimIHDtLOWhT4PjhZhxFEtZoQgEXbkDWdZw==", "dev": true, "license": "MIT", "dependencies": { - "axios": "^1.13.5", - "joi": "^18.0.2", - "lodash": "^4.17.23", + "axios": "^1.16.0", + "joi": "^18.2.1", + "lodash": "^4.18.1", "minimist": "^1.2.8", "rxjs": "^7.8.2" }, @@ -8991,11 +9106,12 @@ "license": "ISC" }, "node_modules/yaml": { - "version": "2.8.2", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz", - "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==", + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", "dev": true, "license": "ISC", + "optional": true, "bin": { "yaml": "bin.mjs" }, diff --git a/package.json b/package.json index 953ad847..941a8b1b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "dj_manager", - "version": "1.0.11", + "version": "1.0.18", "description": "DJ-focused music library manager", "main": "src/main.js", "scripts": { @@ -124,25 +124,25 @@ }, "devDependencies": { "@eslint/js": "^10.0.1", - "@playwright/test": "^1.58.2", - "@vitest/coverage-v8": "^4.1.0", - "concurrently": "^9.2.1", - "electron": "^40.8.0", + "@playwright/test": "^1.60.0", + "@vitest/coverage-v8": "^4.1.6", + "concurrently": "^10.0.3", + "electron": "^41.5.0", "electron-builder": "^26.8.1", "electron-rebuild": "^3.2.9", - "eslint": "^10.0.3", - "globals": "^17.4.0", + "eslint": "^10.5.0", + "globals": "^17.6.0", "husky": "^9.1.7", - "lint-staged": "^16.4.0", - "prettier": "^3.8.1", - "vitest": "^4.1.0", - "wait-on": "^9.0.4" + "lint-staged": "^17.0.7", + "prettier": "^3.8.4", + "vitest": "^4.1.9", + "wait-on": "^9.0.10" }, "dependencies": { "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "better-sqlite3": "^12.8.0", + "better-sqlite3": "^12.10.0", "react-virtualized": "^9.22.6", "react-window": "^2.2.7" } diff --git a/protocol_rekordbox.md b/protocol_rekordbox.md index 468d21a4..465fb86d 100644 --- a/protocol_rekordbox.md +++ b/protocol_rekordbox.md @@ -146,10 +146,61 @@ Followed by `beat_count × 8` bytes, one entry per beat: Same structure as PWAV but 100 bytes of data. Byte format: `height & 0x0F`. -### PCOB × 2 — Cue Object Stubs (required, empty) - -Two empty 24-byte stubs. First has `flag = 1`, second has `flag = 0`. -Both have `value = 0xFFFFFFFF`. +### PCOB × 2 — Cue Points (DAT file) + +Two PCOB sections (slot 1 = hot cues, slot 2 = memory cues). + +**Slot 1 (hot cues, type = 1)** — contains PCPT sub-tags for hot cue slots A–C (indices 0–2). +Hot cue slots D–H (indices 3–7) go in EXT PCOB slot 1 (see EXT section below). + +**Slot 2 (memory cues, type = 0)** — always the empty 24-byte stub. +Memory cue format in PCOB2 is unconfirmed (non-empty PCOB2 causes Rekordbox to reject the +entire file). Memory cues are stored in EXT PCO2 slot 2 instead. + +#### PCOB header (24 bytes) + +| Offset | Size | Value | +| ------ | ---- | ------------------------------------------ | +| 0 | 4 | `PCOB` | +| 4 | 4 | `24` (len_header) | +| 8 | 4 | `len_tag` = 24 + N × 56 (0 for empty stub) | +| 12 | 4 | `type`: 1 = hot_cues, 0 = memory_cues | +| 16 | 2 | `0x0000` (padding) | +| 18 | 2 | `num_cues` (u16BE) | +| 20 | 4 | `0xFFFFFFFF` (memory_count sentinel) | + +#### PCPT sub-tag (56 bytes fixed, one per cue) + +| Offset | Size | Value | +| ------ | ---- | ------------------------------------------------- | +| 0 | 4 | `PCPT` | +| 4 | 4 | `28` (len_header) | +| 8 | 4 | `56` (len_tag) | +| 12 | 4 | `hot_cue`: 0 = memory, 1 = A, 2 = B, … 8 = H | +| 16 | 4 | `0x00000000` (status — native Rekordbox writes 0) | +| 20 | 4 | `0x00010000` (constant) | +| 24 | 2 | `0xFFFF` (order_first) | +| 26 | 2 | `0xFFFF` (order_last) | +| 28 | 1 | `type`: 1 = cue_point, 2 = loop | +| 29 | 1 | `0x00` | +| 30 | 2 | `0x03E8` (constant) | +| 32 | 4 | `time_ms` (u32BE) | +| 36 | 4 | `0xFFFFFFFF` (loop_time: none for cue points) | +| 40 | 1 | `color_code` (Pioneer palette 1–8; 0 = no color) | +| 41 | 15 | zeros | + +**Pioneer palette codes** (codes 3 and 6 confirmed by native Rekordbox hex-diff): + +| Code | Color | Hex | +| ---- | ---------- | --------- | +| 1 | orange-red | `#ff6b35` | +| 2 | red | `#ff0000` | +| 3 ✓ | orange | `#ff9900` | +| 4 | yellow | `#ffff00` | +| 5 | green | `#00ff00` | +| 6 ✓ | cyan | `#00b4d8` | +| 7 | blue | `#0080ff` | +| 8 | violet | `#cc00ff` | --- @@ -175,9 +226,60 @@ Subheader (12 bytes at offset 12): Body: `num_entries` bytes, each `(whiteness[0–7] << 5) | height[0–31]`. -### PCO2 × 2 — Extended Cue Stubs (required, empty) - -Two empty 20-byte stubs. First has `flag = 1`, second has `flag = 0`. +### PCOB × 2 — Cue Object Stubs (EXT file — always empty) + +Both EXT PCOB sections are always the empty 24-byte stub (same header format as DAT PCOB). +Populated cue data is **not** placed in EXT PCOB; use EXT PCO2 instead. + +### PCO2 × 2 — Extended Cue Points (EXT file) + +**Slot 1 (hot cues, type = 1)** — populated with PCP2 sub-tags for **all** hot cue slots A–H +(hot_cue indices 0–7). This is the source of truth for cue labels and colors in Rekordbox PC. + +**Slot 2 (memory cues, type = 0)** — populated with PCP2 sub-tags for memory cues. + +#### PCO2 header (20 bytes) + +| Offset | Size | Value | +| ------ | ---- | -------------------------------------------- | +| 0 | 4 | `PCO2` | +| 4 | 4 | `20` (len_header) | +| 8 | 4 | `len_tag` = 20 + sum of all PCP2 entry sizes | +| 12 | 4 | `type`: 1 = hot_cues, 0 = memory_cues | +| 16 | 2 | `num_cues` (u16BE) | +| 18 | 2 | `0x0000` (padding) | + +#### PCP2 sub-tag (variable size) + +`len_tag = 16 + bodySize` + +- No label: `bodySize = 72`, `len_tag = 88` +- With label ≤ 7 chars: `bodySize = 88`, `len_tag = 104` (native Rekordbox always pads to 104) +- With label > 7 chars: `bodySize = 28 + labelByteLen + 44`, `len_tag = 16 + bodySize` + +where `labelByteLen = (label.length + 1) × 2` (UTF-16BE + null terminator). + +| Offset | Size | Value | +| --------------- | ------------ | -------------------------------------------------- | +| 0 | 4 | `PCP2` | +| 4 | 4 | `16` (len_header) | +| 8 | 4 | `len_tag` | +| 12 | 4 | `hot_cue`: 0 = memory, 1 = A, 2 = B, … 8 = H | +| 16 | 1 | `type`: 1 = cue_point, 2 = loop | +| 17 | 1 | `0x00` | +| 18 | 2 | `0x03E8` (constant) | +| 20 | 4 | `time_ms` (u32BE) | +| 24 | 4 | `0xFFFFFFFF` (loop_time: none for cue points) | +| 28 | 1 | `0x00` (color_id — unused) | +| 29 | 1 | `0x01` (constant) | +| 30 | 10 | zeros | +| 40 | 4 | `len_comment` (byte count incl. null terminator) | +| 44 | labelByteLen | UTF-16BE label, null-terminated (0 bytes if empty) | +| 44+labelByteLen | 1 | `color_code` (Pioneer palette 1–8; 0 = no color) | +| 45+labelByteLen | 1 | `color_red` | +| 46+labelByteLen | 1 | `color_green` | +| 47+labelByteLen | 1 | `color_blue` | +| 48+labelByteLen | 40 | zeros | ### PQT2 — Extended Beat Grid (Rekordbox 6+) @@ -236,7 +338,10 @@ Subheader (12 bytes at offset 12): | 16 | 4 | `num_entries` (1200 fixed) | | 20 | 4 | `0x00000000` | -Body: 1200 × 6 bytes. Per column: `[whiteness, whiteness, overall_rms, bass, mid, treble]`. +Body: 1200 × 6 bytes. Per column: `[peak_byte, 255 - peak_byte, overall_rms, bass, mid, treble]`. + +- `peak_byte` = `min(255, round(peak * 255))` — peak amplitude, confirmed from hex-diff of native files (avg b0+b1 ≈ 255). +- `overall_rms`, `bass`, `mid`, `treble` each scaled by 510, capped at 255. --- @@ -361,6 +466,196 @@ Strings are length-prefixed. The first byte determines encoding: --- +## exportLibrary.db — SQLCipher Track Index (Rekordbox PC + CDJ browse menus) + +Located at `{usbRoot}/PIONEER/rekordbox/exportLibrary.db`. Used by Rekordbox PC and CDJ/XDJ hardware for browse menus, playlist navigation, cue recall, and track metadata display. CDJs do **not** use it for audio playback — that relies on `export.pdb` and ANLZ files. + +### Encryption + +SQLCipher-encrypted SQLite. Key and cipher parameters were extracted by hooking `sqlite3_key` in Rekordbox's bundled `sqlite3.dll` via Frida (script: `reverse-engineering/capture_key.py`). + +**Key** (64 ASCII bytes, passed verbatim to `sqlite3_key`): + +``` +r8gddnr4k847830ar6cqzbkk0el6qytmb3trbbx805jm74vez64i5o8fnrqryqls +``` + +Standard SQLCipher parameter combinations (v3 SHA1/64k, v4 SHA512/256k) do **not** work — Rekordbox uses non-default cipher parameters. The only reliable way to create or open this file is to load Rekordbox's own `sqlite3.dll` via `ffi-napi` or ctypes. DLL path: `C:/Program Files/rekordbox/rekordbox 7.x.x/sqlite3.dll`. + +### Schema + +#### `property` — one row, USB-level metadata + +```sql +CREATE TABLE property( + deviceName varchar, + dbVersion varchar, -- '10000' + numberOfContents integer, -- 0 (unused) + createdDate varchar, -- 'YYYY-MM-DD' + backGroundColorType integer, -- 0 + myTagMasterDBID integer +) +``` + +#### `content` — one row per exported track + +```sql +CREATE TABLE content( + content_id integer primary key, + title varchar, + titleForSearch varchar, + subtitle varchar, + bpmx100 integer, -- BPM × 100 (e.g. 12800 = 128.00 BPM) + length integer, -- duration in seconds + trackNo integer, + discNo integer, + artist_id_artist integer, -- FK → artist + artist_id_remixer integer, + artist_id_originalArtist integer, + artist_id_composer integer, + artist_id_lyricist integer, + album_id integer, -- FK → album + genre_id integer, -- FK → genre + label_id integer, -- FK → label + key_id integer, -- FK → key + color_id integer, -- FK → color (0 = none) + image_id integer, -- FK → image (0 = none) + djComment varchar, + rating integer, -- 0–5 stars + releaseYear integer, + releaseDate varchar, + dateCreated varchar, -- 'YYYY-MM-DD' + dateAdded varchar, -- 'YYYY-MM-DD' + path varchar, -- USB-relative path, e.g. '/music/filename.mp3' + fileName varchar, + fileSize integer, -- bytes + fileType integer, -- 1=MP3, 11=WAV + bitrate integer, -- bits/sec + bitDepth integer, -- 16 or 24 + samplingRate integer, -- 44100, 48000 + isrc varchar, + djPlayCount integer, + isHotCueAutoLoadOn integer, -- 1 = auto-load hot cues on track load + isKuvoDeliverStatusOn integer, + kuvoDeliveryComment varchar, + masterDbId integer, -- track id in master.db (PC library link) + masterContentId integer, -- 0 for user tracks + analysisDataFilePath varchar, -- '/PIONEER/USBANLZ/XX/XXXXXXXX/ANLZ0000.DAT' + analysedBits integer, -- bitmask: 41 (0b101001) = fully analysed + contentLink integer, -- 0x000C0700 for all observed tracks (meaning TBD) + hasModified integer, -- 0 + cueUpdateCount integer, + analysisDataUpdateCount integer, + informationUpdateCount integer +) +``` + +#### Lookup / normalisation tables + +```sql +CREATE TABLE artist(artist_id integer primary key, name varchar, nameForSearch varchar) +CREATE TABLE album(album_id integer primary key, name varchar, artist_id integer, image_id integer, isComplation integer, nameForSearch varchar) +CREATE TABLE genre(genre_id integer primary key, name varchar) +CREATE TABLE label(label_id integer primary key, name varchar) +CREATE TABLE key(key_id integer primary key, name varchar) +-- key.name examples: 'D', 'Am', 'F#m', 'Abm' +CREATE TABLE color(color_id integer primary key, name varchar) +-- 1=Pink 2=Red 3=Orange 4=Yellow 5=Green 6=Aqua 7=Blue 8=Purple (same palette as export.pdb) +CREATE TABLE image(image_id integer primary key, path varchar) +-- image.path is USB-relative, e.g. '/PIONEER/rekordbox/artwork/xxx.jpg' +``` + +#### Playlist tables + +```sql +CREATE TABLE playlist( + playlist_id integer primary key, + sequenceNo integer, + name varchar, + image_id integer, + attribute integer, -- 0=playlist, 1=folder + playlist_id_parent integer -- 0 = root +) +CREATE TABLE playlist_content(playlist_id integer, content_id integer, sequenceNo integer) +``` + +#### `cue` — hot cues and memory cues (mirrors ANLZ cue data) + +```sql +CREATE TABLE cue( + cue_id integer primary key, + content_id integer, + kind integer, -- cue type: hot cue, memory cue, loop (exact values TBD) + colorTableIndex integer, + cueComment varchar, + isActiveLoop integer, + beatLoopNumerator integer, + beatLoopDenominator integer, + inUsec integer, -- start position in microseconds + outUsec integer, -- end position; -1 for non-loops + in150FramePerSec integer, -- inUsec × 150 / 1000000 + out150FramePerSec integer, + inMpegFrameNumber integer, + outMpegFrameNumber integer, + inMpegAbs integer, + outMpegAbs integer, + inDecodingStartFramePosition integer, + outDecodingStartFramePosition integer, + inFileOffsetInBlock integer, + OutFileOffsetInBlock integer, + inNumberOfSampleInBlock integer, + outNumberOfSampleInBlock integer +) +``` + +#### History (written by CDJ hardware, read-only for us) + +```sql +CREATE TABLE history(history_id integer primary key, sequenceNo integer, name varchar, attribute integer, history_id_parent integer) +CREATE TABLE history_content(history_id integer, content_id integer, sequenceNo integer) +``` + +#### Hot cue banks + +```sql +CREATE TABLE hotCueBankList(hotCueBankList_id integer primary key, sequenceNo integer, name varchar, image_id integer, attribute integer, hotCueBankList_id_parent integer) +CREATE TABLE hotCueBankList_cue(hotCueBankList_id integer, cue_id integer, sequenceNo integer) +``` + +#### Static UI tables (populate once from native Rekordbox values) + +```sql +CREATE TABLE menuItem(menuItem_id integer primary key, kind integer, name varchar) +-- kind values: GENRE=128, ARTIST=129, ALBUM=130, TRACK=131, BPM=133, RATING=134, +-- YEAR=135, REMIXER=136, LABEL=137, ORIGINAL ARTIST=138, KEY=139, CUE=140, +-- COLOR=141, TIME=146, BITRATE=147, FILE NAME=148, PLAYLIST=145, HISTORY=149, +-- SEARCH=148, DATE ADDED=150, DJ PLAY COUNT=151, FOLDER=152, DEFAULT=161, +-- ALPHABET=162, MATCHING=171, HOT CUE BANK=152 +CREATE TABLE category(category_id integer primary key, menuItem_id integer, sequenceNo integer, isVisible integer) +CREATE TABLE sort(sort_id integer primary key, menuItem_id integer, sequenceNo integer, isVisible integer, isSelectedAsSubColumn integer) +CREATE TABLE myTag(myTag_id integer primary key, sequenceNo integer, name varchar, attribute integer, myTag_id_parent integer) +-- Default top-level myTag folders: Genre, Components, Situation, Untitled Column +CREATE TABLE myTag_content(myTag_id integer, content_id integer) +CREATE TABLE recommendedLike(content_id_1 integer, content_id_2 integer, rating integer, createdDate integer) +``` + +### What is NOT in this database + +- **Per-track manual gain slider** — stored only in `master.db` on the PC, never exported to USB. CDJ auto-gain normalisation comes entirely from `Unnamed7`/`Unnamed8` in `export.pdb`. +- **Waveform / beatgrid / key analysis** — stored in ANLZ files. `content.analysisDataFilePath` points to `ANLZ0000.DAT` on USB. +- **Audio files** — stored under `{usbRoot}/music/`. + +### Implementation notes + +- Load Rekordbox's own `sqlite3.dll` via `ffi-napi` to create/open the file (standard SQLCipher builds will not decrypt it). +- On export: full rebuild, same approach as `export.pdb`. +- `content.masterDbId` = DjManager track `id` from the local SQLite library. +- `content.contentLink = 0x000C0700` — use this constant for all tracks until meaning is confirmed. +- `content.analysedBits = 41` for fully-analysed tracks (BPM + waveform + key complete). +- `content.analysisDataFilePath` must match the ANLZ path written by `writeAnlz()`. +- Populate `cue` rows in parallel with ANLZ cue writing — same source data, different encoding. +- See issue #300 for discovery method and issue #299 for gain field research. + ## SETTING.DAT Files Three files written to `PIONEER/`: diff --git a/readme.md b/readme.md deleted file mode 100644 index 6ac9ebdf..00000000 --- a/readme.md +++ /dev/null @@ -1,131 +0,0 @@ -# DJ Manager - -Your music library, built for DJs. Import tracks, analyse BPM and key automatically, build playlists, download from anywhere, and prepare sets — all stored locally on your machine. - -[![CI](https://github.com/Radexito/DjManager/actions/workflows/ci.yml/badge.svg)](https://github.com/Radexito/DjManager/actions/workflows/ci.yml) -[![Release](https://github.com/Radexito/DjManager/actions/workflows/release.yml/badge.svg)](https://github.com/Radexito/DjManager/actions/workflows/release.yml) -[![code style: prettier](https://img.shields.io/badge/code_style-prettier-ff69b4.svg)](https://github.com/prettier/prettier) -[![ESLint](https://img.shields.io/badge/linting-ESLint-4B32C3)](https://eslint.org/) -[![Tested with Vitest](https://img.shields.io/badge/tested_with-Vitest-6E9F18)](https://vitest.dev/) -[![E2E with Playwright](https://img.shields.io/badge/e2e-Playwright-45ba4b)](https://playwright.dev/) - -![DJ Manager screenshot](screenshot.png) - ---- - -## Download - -Grab the latest build for your platform from [**Releases**](https://github.com/Radexito/DjManager/releases). - -FFmpeg and the audio analyser download automatically on first launch — no manual setup required. - -| Platform | File | -| -------- | ------------------------------------------------- | -| Linux | `DJ.Manager-x.x.x-Linux` (AppImage — just run it) | -| Windows | `DJ.Manager-x.x.x-Setup.exe` | -| macOS | `DJ.Manager-x.x.x.dmg` | - -### Windows — Chocolatey - -If you use [Chocolatey](https://chocolatey.org/), you can install and keep DJ Manager up to date with a single command: - -```powershell -choco install djmanager -``` - -Package page: [community.chocolatey.org/packages/djmanager](https://community.chocolatey.org/packages/djmanager) - ---- - -## What it does - -**Library** — Import audio files once; DJ Manager copies them into managed storage and deduplicates by content hash. Sort and filter by any column. Select multiple tracks with click, Shift+click, Ctrl+click, or Ctrl+A. - -**Advanced search** — Type a query into the search bar to filter your library with precision. Filters can be stacked with `AND`: - -``` -GENRE is Psytrance AND BPM IN RANGE 140-145 -KEY matches 8A AND BPM > 130 -ARTIST contains Burial AND YEAR > 2010 -TITLE contains intro AND LOUDNESS > -10 -``` - -Supported fields: `TITLE`, `ARTIST`, `ALBUM`, `GENRE`, `BPM`, `KEY`, `YEAR`, `LOUDNESS`. -Supported operators vary by field — `is`, `is not`, `contains`, `in range`, `>`, `<` for numbers; `is`, `matches`, `adjacent`, `mode switch` for keys (Camelot notation: `8A`, `8B`, etc.). -The search bar shows field and operator suggestions as you type, and completed filters appear as removable chips above the track list. - -**Analysis** — Every track is analysed automatically on import for BPM, musical key (Camelot notation), loudness (LUFS), replay gain, and intro/outro markers. Right-click any track to re-analyse, or halve/double the detected BPM if the analyzer picked the wrong grid. - -**Find Similar** — Right-click a track to find others with a matching or adjacent Camelot key, or within a close BPM range. Results are applied as a live search filter. - -**Auto-tag** — Right-click any track and choose **Auto-tag** to look up metadata from [MusicBrainz](https://musicbrainz.org/) and [Discogs](https://www.discogs.com/) simultaneously. A side-by-side diff shows the current value next to every candidate found; pick the value you want per field (Title, Artist, Album, Label, Year, Genres) from a dropdown and apply in one click. - -**URL Import** — Paste a URL from YouTube, SoundCloud, Bandcamp, Spotify, or [1000+ other sources](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) into the **Download** tab. DJ Manager fetches the track list first so you can review and deselect anything before downloading. Tracks are imported into the library one by one as they finish — no waiting for the full playlist. A dual progress bar tracks both the current download and overall playlist progress, and a status table shows each track's state (pending → downloading → importing → done / failed). - -**Playlists** — Create colour-coded playlists in the sidebar, drag tracks in from the library, reorder by drag-and-drop, and sort by any column. Track count and total duration are shown at all times. Exporting a playlist to M3U is one click. Playlists imported via URL remember their source link. - -**Player** — Full playback with seekbar, shuffle, repeat, previous/next, and hardware media key support. Intro and outro zones are shown visually on the seekbar so you know exactly when to mix. Double-click any track to play. - -**Settings** — Move your library to any location, including an external drive. Update FFmpeg and the audio analyser in-app without reinstalling. Clear the track library, all playlists, or all user data from the Advanced tab. - ---- - -## Running from source - -```bash -git clone https://github.com/Radexito/DjManager.git -cd DjManager -npm install -cd renderer && npm install && cd .. -npm run dev -``` - -FFmpeg and mixxx-analyzer are downloaded automatically to `~/.config/dj_manager/bin/` on first run. - -### Other useful commands - -| Command | What it does | -| ----------------------- | ------------------------------------------------------------------- | -| `npm run dev` | Start Electron + Vite dev server together (default for development) | -| `npm run react` | Start the Vite renderer only (UI dev without Electron) | -| `npm run build` | Build the renderer for production | -| `npm run electron-prod` | Run Electron against the production build | -| `npm run dist` | Build + package for the current platform | -| `npm run dist:linux` | Build + package for Linux (AppImage) | -| `npm run dist:win` | Build + package for Windows (NSIS installer) | -| `npm run dist:mac` | Build + package for macOS (DMG) | -| `npm run lint:all` | Lint main process + renderer | -| `npm test` | Run unit tests (Vitest) | -| `npm run test:e2e` | Run E2E tests (Playwright) | - ---- - -Upcoming work is tracked on the [**Issues**](https://github.com/Radexito/DjManager/issues) page. - ---- - -## Rekordbox USB export - -Right-click any playlist in the sidebar and choose **Export Rekordbox USB** to write a Pioneer CDJ-compatible USB drive — no Rekordbox software required. DJ Manager writes the binary formats CDJs read directly: `export.pdb` (track database), `ANLZ0000.DAT/.EXT/.2EX` (waveforms and beat grids), and `PIONEER/MYSETTING.DAT` (player settings). - -### Re-exporting and incremental behaviour - -Each export to the same USB folder **merges** with whatever was previously exported there. A manifest (`PIONEER/rekordbox/export-manifest.json`) records all tracks and playlists on the USB; subsequent exports read it and inject new content into the existing database without removing anything. - -| What gets written | Behaviour | -| -------------------------------- | ---------------------------------------------------------------------------- | -| Audio files (`/music/`) | **Skipped if already present** — existing files are never overwritten | -| ANLZ files (waveform / beatgrid) | **Regenerated for new tracks only** — existing ANLZ files are left untouched | -| `export.pdb` (track database) | **Rebuilt from all tracks** — current export merged with previous exports | -| `PIONEER/MYSETTING.DAT` etc. | **Always regenerated** | -| `export-manifest.json` | **Always updated** — records the full set of tracks and playlists on the USB | - -You can export playlists to the same USB one at a time — each export adds its tracks and playlists to the CDJ database without touching the ones already there. - ---- - -## How files are stored - -Audio is stored at `~/.config/dj_manager/audio//.` (configurable via Settings → Library). The two-character hash prefix keeps directory sizes manageable. Playlists reference tracks by ID — no duplicates, no copies. - -Logs are written daily to `~/.config/dj_manager/logs/app-YYYY-MM-DD.log`. diff --git a/renderer/.npmrc b/renderer/.npmrc new file mode 100644 index 00000000..521a9f7c --- /dev/null +++ b/renderer/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/renderer/package-lock.json b/renderer/package-lock.json index 26de3e03..49aecc3c 100644 --- a/renderer/package-lock.json +++ b/renderer/package-lock.json @@ -11,8 +11,8 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "react": "^19.2.4", - "react-dom": "^19.2.4", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-window": "^2.2.7" }, "devDependencies": { @@ -21,26 +21,19 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/react": "^19.2.14", + "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.4", - "@vitest/coverage-v8": "^4.1.0", - "eslint": "^10.0.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.7", + "eslint": "^10.4.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", - "jsdom": "^28.1.0", - "vite": "^8.0.0", - "vitest": "^4.0.18" + "globals": "^17.6.0", + "jsdom": "^29.1.1", + "vite": "^8.0.14", + "vitest": "^4.1.5" } }, - "node_modules/@acemir/cssom": { - "version": "0.9.31", - "resolved": "https://registry.npmjs.org/@acemir/cssom/-/cssom-0.9.31.tgz", - "integrity": "sha512-ZnR3GSaH+/vJ0YlHau21FjfLYjMpYVIzTD8M8vIEQvIGxeOXyXdzCI140rrCY862p/C/BbzWsjc1dgnM9mkoTA==", - "dev": true, - "license": "MIT" - }, "node_modules/@adobe/css-tools": { "version": "4.4.4", "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.4.tgz", @@ -49,54 +42,47 @@ "license": "MIT" }, "node_modules/@asamuzakjp/css-color": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.0.1.tgz", - "integrity": "sha512-2SZFvqMyvboVV1d15lMf7XiI3m7SDqXUuKaTymJYLN6dSGadqp+fVojqJlVoMlbZnlTmu3S0TLwLTJpvBMO1Aw==", + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", "dev": true, "license": "MIT", "dependencies": { - "@csstools/css-calc": "^3.1.1", - "@csstools/css-color-parser": "^4.0.2", + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", "@csstools/css-parser-algorithms": "^4.0.0", - "@csstools/css-tokenizer": "^4.0.0", - "lru-cache": "^11.2.6" + "@csstools/css-tokenizer": "^4.0.0" }, "engines": { "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/css-color/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/@asamuzakjp/dom-selector": { - "version": "6.8.1", - "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-6.8.1.tgz", - "integrity": "sha512-MvRz1nCqW0fsy8Qz4dnLIvhOlMzqDVBabZx6lH+YywFDdjXhMY37SmpV1XFX3JzG5GWHn63j6HX6QPr3lZXHvQ==", + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", "dev": true, "license": "MIT", "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", "@asamuzakjp/nwsapi": "^2.3.9", "bidi-js": "^1.0.3", - "css-tree": "^3.1.0", - "is-potential-custom-element-name": "^1.0.1", - "lru-cache": "^11.2.6" + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, - "node_modules/@asamuzakjp/dom-selector/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", "dev": true, - "license": "BlueOak-1.0.0", + "license": "MIT", "engines": { - "node": "20 || >=22" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, "node_modules/@asamuzakjp/nwsapi": { @@ -238,16 +224,6 @@ "@babel/core": "^7.0.0" } }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", - "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, "node_modules/@babel/helper-string-parser": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", @@ -308,38 +284,6 @@ "node": ">=6.0.0" } }, - "node_modules/@babel/plugin-transform-react-jsx-self": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", - "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-source": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", - "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.27.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/runtime": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz", @@ -442,9 +386,9 @@ } }, "node_modules/@csstools/css-calc": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.1.1.tgz", - "integrity": "sha512-HJ26Z/vmsZQqs/o3a6bgKslXGFAungXGbinULZO3eMsOyNJHeBBZfup5FiZInOghgoM4Hwnmw+OgbJCNg1wwUQ==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.0.tgz", + "integrity": "sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==", "dev": true, "funding": [ { @@ -466,9 +410,9 @@ } }, "node_modules/@csstools/css-color-parser": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.0.2.tgz", - "integrity": "sha512-0GEfbBLmTFf0dJlpsNU7zwxRIH0/BGEMuXLTCvFYxuL1tNhqzTbtnFICyJLTNK4a+RechKP75e7w42ClXSnJQw==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.0.tgz", + "integrity": "sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==", "dev": true, "funding": [ { @@ -483,7 +427,7 @@ "license": "MIT", "dependencies": { "@csstools/color-helpers": "^6.0.2", - "@csstools/css-calc": "^3.1.1" + "@csstools/css-calc": "^3.2.0" }, "engines": { "node": ">=20.19.0" @@ -517,9 +461,9 @@ } }, "node_modules/@csstools/css-syntax-patches-for-csstree": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.1.tgz", - "integrity": "sha512-BvqN0AMWNAnLk9G8jnUT77D+mUbY/H2b3uDTvg2isJkHaOufUE2R3AOwxWo7VBQKT1lOdwdvorddo2B/lk64+w==", + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.3.tgz", + "integrity": "sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==", "dev": true, "funding": [ { @@ -615,21 +559,21 @@ } }, "node_modules/@emnapi/core": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.9.0.tgz", - "integrity": "sha512-0DQ98G9ZQZOxfUcQn1waV2yS8aWdZ6kJMbYCJB3oUBecjWYO1fqJ+a1DRfPF3O5JEkwqwP1A9QEN/9mYm2Yd0w==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/wasi-threads": "1.2.0", + "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "node_modules/@emnapi/runtime": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.9.0.tgz", - "integrity": "sha512-QN75eB0IH2ywSpRpNddCRfQIhmJYBCJ1x5Lb3IscKAL8bMnVAKnRg8dCoXbHzVLLH7P38N2Z3mtulB7W0J0FKw==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", "dev": true, "license": "MIT", "optional": true, @@ -638,9 +582,9 @@ } }, "node_modules/@emnapi/wasi-threads": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.0.tgz", - "integrity": "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", "dev": true, "license": "MIT", "optional": true, @@ -691,13 +635,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.23.3", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.3.tgz", - "integrity": "sha512-j+eEWmB6YYLwcNOdlwQ6L2OsptI/LO6lNBuLIqe5R7RetD658HLoF+Mn7LzYmAWWNNzdC6cqP+L6r8ujeYXWLw==", + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^3.0.3", + "@eslint/object-schema": "^3.0.5", "debug": "^4.3.1", "minimatch": "^10.2.4" }, @@ -706,22 +650,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.5.3.tgz", - "integrity": "sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==", + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1" + "@eslint/core": "^1.2.1" }, "engines": { "node": "^20.19.0 || ^22.13.0 || >=24" } }, "node_modules/@eslint/core": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.1.1.tgz", - "integrity": "sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -753,9 +697,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.3.tgz", - "integrity": "sha512-iM869Pugn9Nsxbh/YHRqYiqd23AmIbxJOcpUMOuWCVNdoQJ5ZtwL6h3t0bcZzJUlC3Dq9jCFCESBZnX0GTv7iQ==", + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", "dev": true, "license": "Apache-2.0", "engines": { @@ -763,13 +707,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.6.1.tgz", - "integrity": "sha512-iH1B076HoAshH1mLpHMgwdGeTs0CYwL0SPMkGuSebZrwBp16v415e9NZXg2jtrqPVQjf6IANe2Vtlr5KswtcZQ==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^1.1.1", + "@eslint/core": "^1.2.1", "levn": "^0.4.1" }, "engines": { @@ -897,36 +841,28 @@ } }, "node_modules/@napi-rs/wasm-runtime": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz", - "integrity": "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.4.tgz", + "integrity": "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow==", "dev": true, "license": "MIT", "optional": true, "dependencies": { - "@emnapi/core": "^1.7.1", - "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "funding": { "type": "github", "url": "https://github.com/sponsors/Brooooooklyn" - } - }, - "node_modules/@oxc-project/runtime": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", - "integrity": "sha512-Rg8Wlt5dCbXhQnsXPrkOjL1DTSvXLgb2R/KYfnf1/K+R0k6UMLEmbQXPM+kwrWqSmWA2t0B1EtHy2/3zikQpvQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" } }, "node_modules/@oxc-project/types": { - "version": "0.115.0", - "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.115.0.tgz", - "integrity": "sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==", + "version": "0.132.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.132.0.tgz", + "integrity": "sha512-FESMOxil5Se014ui/Eq8fT5uHJo6nIRwH0PfJrZJXs6Gek3ZVFOrpUv3YIZT20m+extU98Hg1Ym72U58rlsxUQ==", "dev": true, "license": "MIT", "funding": { @@ -934,9 +870,9 @@ } }, "node_modules/@rolldown/binding-android-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-lcJL0bN5hpgJfSIz/8PIf02irmyL43P+j1pTCfbD1DbLkmGRuFIA4DD3B3ZOvGqG0XiVvRznbKtN0COQVaKUTg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.2.tgz", + "integrity": "sha512-ZS4D1JPGn/MYQN/SYDWftIE/nVsM8j/AFOYEzAoOE2O3NktQOZru+/vYXGbR/qtdLdIfGCP0lcoJiYVzsEz+iQ==", "cpu": [ "arm64" ], @@ -951,9 +887,9 @@ } }, "node_modules/@rolldown/binding-darwin-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-J7Zk3kLYFsLtuH6U+F4pS2sYVzac0qkjcO5QxHS7OS7yZu2LRs+IXo+uvJ/mvpyUljDJ3LROZPoQfgBIpCMhdQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.2.tgz", + "integrity": "sha512-vdFA9+C/rekyGce7WqHs/xoT0ioZEWaOFyZLIV1mEeNFaFDUQrPIo8Vs2GvJ6eetb3rzDUtUBgzto3ExpXJB3w==", "cpu": [ "arm64" ], @@ -968,9 +904,9 @@ } }, "node_modules/@rolldown/binding-darwin-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-iwtmmghy8nhfRGeNAIltcNXzD0QMNaaA5U/NyZc1Ia4bxrzFByNMDoppoC+hl7cDiUq5/1CnFthpT9n+UtfFyg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.2.tgz", + "integrity": "sha512-BewSOwTHazv77DTYiAZXSqqKZ4KP/KonFisDMVU7PImxoWfB2aepnPhd2E4SWz3zDzYgDNbs6jBmTdgNnF02GA==", "cpu": [ "x64" ], @@ -985,9 +921,9 @@ } }, "node_modules/@rolldown/binding-freebsd-x64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.0-rc.9.tgz", - "integrity": "sha512-DLFYI78SCiZr5VvdEplsVC2Vx53lnA4/Ga5C65iyldMVaErr86aiqCoNBLl92PXPfDtUYjUh+xFFor40ueNs4Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.2.tgz", + "integrity": "sha512-m41o7M0YWtUdqk61Tb+jnKb2rN++iRdIASlExkUoKfIAH30DOHCB8fVLzSUpbWHHU8esmEioY62PxzexE8MBuA==", "cpu": [ "x64" ], @@ -1002,9 +938,9 @@ } }, "node_modules/@rolldown/binding-linux-arm-gnueabihf": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.0-rc.9.tgz", - "integrity": "sha512-CsjTmTwd0Hri6iTw/DRMK7kOZ7FwAkrO4h8YWKoX/kcj833e4coqo2wzIFywtch/8Eb5enQ/lwLM7w6JX1W5RQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.2.tgz", + "integrity": "sha512-jcojB9H7W/jS29pMKWAK1N+fU99vXodHDTatS3b3y/XSOCiHo0kkA74pL3jJmkoQtYpOCxDvaKs1fo2Ij/1X5w==", "cpu": [ "arm" ], @@ -1019,9 +955,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-2x9O2JbSPxpxMDhP9Z74mahAStibTlrBMW0520+epJH5sac7/LwZW5Bmg/E6CXuEF53JJFW509uP+lSedaUNxg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.2.tgz", + "integrity": "sha512-1jn6qDU5iiOgFgygDzKUuKP0maTi0/f1+sBLgvij/76C77Nm3ts6ufz9Bjg5q5dduxiUIxtq86JIoBvo1xQ4Ig==", "cpu": [ "arm64" ], @@ -1036,9 +972,9 @@ } }, "node_modules/@rolldown/binding-linux-arm64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-JA1QRW31ogheAIRhIg9tjMfsYbglXXYGNPLdPEYrwFxdbkQCAzvpSCSHCDWNl4hTtrol8WeboCSEpjdZK8qrCg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.2.tgz", + "integrity": "sha512-QVLO/czFMdoMFSqlX3bcswcJNm/23r+qoa/jgtmFc/qEp6/jXmIkDjF/XIo8dPfGaiwy1xfQn8o77L79GeXFgw==", "cpu": [ "arm64" ], @@ -1053,9 +989,9 @@ } }, "node_modules/@rolldown/binding-linux-ppc64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-aOKU9dJheda8Kj8Y3w9gnt9QFOO+qKPAl8SWd7JPHP+Cu0EuDAE5wokQubLzIDQWg2myXq2XhTpOVS07qqvT+w==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.2.tgz", + "integrity": "sha512-hgO5Abm0w5UL6FEa2iFnZqo2KlK7TQ5QhV5x09hujBf7t5KzHQ1VmfPuTpqRy/rNlSxua3eWH374xxiVrP+lcA==", "cpu": [ "ppc64" ], @@ -1070,9 +1006,9 @@ } }, "node_modules/@rolldown/binding-linux-s390x-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-OalO94fqj7IWRn3VdXWty75jC5dk4C197AWEuMhIpvVv2lw9fiPhud0+bW2ctCxb3YoBZor71QHbY+9/WToadA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.2.tgz", + "integrity": "sha512-fy8rXxuYEu602abC8MUNaPjYLIFzReOaEIEMKMUa0rFEUxNpVXhs15KSSQ4qlqSaM7B6rcj9rDZgADh/IGDzLQ==", "cpu": [ "s390x" ], @@ -1087,9 +1023,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-gnu": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.0-rc.9.tgz", - "integrity": "sha512-cVEl1vZtBsBZna3YMjGXNvnYYrOJ7RzuWvZU0ffvJUexWkukMaDuGhUXn0rjnV0ptzGVkvc+vW9Yqy6h8YX4pg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.2.tgz", + "integrity": "sha512-0+bOkiQ779+r1WpoHOWHqncvyySci0vKph+myNDYb+im6meJAzHQXay6oEgnkHuUGouM1LKTZwqKpBow6Kj7CQ==", "cpu": [ "x64" ], @@ -1104,9 +1040,9 @@ } }, "node_modules/@rolldown/binding-linux-x64-musl": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.0-rc.9.tgz", - "integrity": "sha512-UzYnKCIIc4heAKgI4PZ3dfBGUZefGCJ1TPDuLHoCzgrMYPb5Rv6TLFuYtyM4rWyHM7hymNdsg5ik2C+UD9VDbA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.2.tgz", + "integrity": "sha512-mjSkrzZK5Qsl0a9d1JgILOiuZOSDTVdKENcSXBoqbzSrspLR/4/IRVDo5wd2GgZjNss/viBFJdeq+j7qH2nypw==", "cpu": [ "x64" ], @@ -1121,9 +1057,9 @@ } }, "node_modules/@rolldown/binding-openharmony-arm64": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.0-rc.9.tgz", - "integrity": "sha512-+6zoiF+RRyf5cdlFQP7nm58mq7+/2PFaY2DNQeD4B87N36JzfF/l9mdBkkmTvSYcYPE8tMh/o3cRlsx1ldLfog==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.2.tgz", + "integrity": "sha512-1v5vHasdfQAZoEHakBV72LIFAC9JjnymsiKxp+GEr/ma3+NJCPSaYK+qavInOovJkgwFrs7GccX2d6IgDA3Z5w==", "cpu": [ "arm64" ], @@ -1138,9 +1074,9 @@ } }, "node_modules/@rolldown/binding-wasm32-wasi": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.0-rc.9.tgz", - "integrity": "sha512-rgFN6sA/dyebil3YTlL2evvi/M+ivhfnyxec7AccTpRPccno/rPoNlqybEZQBkcbZu8Hy+eqNJCqfBR8P7Pg8g==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.2.tgz", + "integrity": "sha512-mb1VobWn6NheziTk5/WEaR6AKVbrwT5sOi6C7zk3gy/pD1qtJfU1j4PgTo2NJnOtbL9Dl3Aeei8w9jJ7qC2jZQ==", "cpu": [ "wasm32" ], @@ -1148,16 +1084,18 @@ "license": "MIT", "optional": true, "dependencies": { - "@napi-rs/wasm-runtime": "^1.1.1" + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" }, "engines": { - "node": ">=14.0.0" + "node": "^20.19.0 || >=22.12.0" } }, "node_modules/@rolldown/binding-win32-arm64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-lHVNUG/8nlF1IQk1C0Ci574qKYyty2goMiPlRqkC5R+3LkXDkL5Dhx8ytbxq35m+pkHVIvIxviD+TWLdfeuadA==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.2.tgz", + "integrity": "sha512-SqKonF56vA/L2yHwHYcEp2P34URpOZ7d1fS635cTkpDnUtEGdUbhI6NzsPdqeSWvAAeGDrxjWjNmibDIdFf9/A==", "cpu": [ "arm64" ], @@ -1172,9 +1110,9 @@ } }, "node_modules/@rolldown/binding-win32-x64-msvc": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.0-rc.9.tgz", - "integrity": "sha512-G0oA4+w1iY5AGi5HcDTxWsoxF509hrFIPB2rduV5aDqS9FtDg1CAfa7V34qImbjfhIcA8C+RekocJZA96EarwQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.2.tgz", + "integrity": "sha512-v7qRI7gXLRINcOGXt+7YmAZ6iFuyZVMIoXAxhd8oP+DR9dLfL9GfNIx7PLMxmhZdvq8waUJBQiWN9EKNy+TRBQ==", "cpu": [ "x64" ], @@ -1189,9 +1127,9 @@ } }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.3", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", - "integrity": "sha512-eybk3TjzzzV97Dlj5c+XrBFW57eTNhzod66y9HrBlzJ6NsCrWCp/2kaPS3K9wJmurBC0Tdw4yPjXKZqlznim3Q==", + "version": "1.0.0-rc.7", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.7.tgz", + "integrity": "sha512-qujRfC8sFVInYSPPMLQByRh7zhwkGFS4+tyMQ83srV1qrxL4g8E2tyxVVyxd0+8QeBM1mIk9KbWxkegRr76XzA==", "dev": true, "license": "MIT" }, @@ -1292,9 +1230,9 @@ } }, "node_modules/@tybys/wasm-util": { - "version": "0.10.1", - "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", - "integrity": "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==", + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", "dev": true, "license": "MIT", "optional": true, @@ -1309,51 +1247,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/babel__core": { - "version": "7.20.5", - "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", - "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.20.7", - "@babel/types": "^7.20.7", - "@types/babel__generator": "*", - "@types/babel__template": "*", - "@types/babel__traverse": "*" - } - }, - "node_modules/@types/babel__generator": { - "version": "7.27.0", - "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", - "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__template": { - "version": "7.4.4", - "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", - "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.1.0", - "@babel/types": "^7.0.0" - } - }, - "node_modules/@types/babel__traverse": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", - "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.28.2" - } - }, "node_modules/@types/chai": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", @@ -1394,9 +1287,9 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.2.14", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", - "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "version": "19.2.16", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.16.tgz", + "integrity": "sha512-esJiCAnl0kfpNdE69f3So4WJUXy95dLZydX0KwK46riIHDzHM7O9Vtf9xCHW0PXIqvgqNrswl522kA/5yx+F4w==", "dev": true, "license": "MIT", "dependencies": { @@ -1414,35 +1307,40 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "5.1.4", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.1.4.tgz", - "integrity": "sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.1.tgz", + "integrity": "sha512-l9X/E3cDb+xY3SWzlG1MOGt2usfEHGMNIaegaUGFsLkb3RCn/k8/TOXBcab+OndDI4TBtktT8/9BwwW8Vi9KUQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.29.0", - "@babel/plugin-transform-react-jsx-self": "^7.27.1", - "@babel/plugin-transform-react-jsx-source": "^7.27.1", - "@rolldown/pluginutils": "1.0.0-rc.3", - "@types/babel__core": "^7.20.5", - "react-refresh": "^0.18.0" + "@rolldown/pluginutils": "1.0.0-rc.7" }, "engines": { "node": "^20.19.0 || >=22.12.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } } }, "node_modules/@vitest/coverage-v8": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.0.tgz", - "integrity": "sha512-nDWulKeik2bL2Va/Wl4x7DLuTKAXa906iRFooIRPR+huHkcvp9QDkPQ2RJdmjOFrqOqvNfoSQLF68deE3xC3CQ==", + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.7.tgz", + "integrity": "sha512-qsYPeXc5Q9dFLd1i8Ap+Bx8sQgcp+rFVQo4R0dDsWNBzl26ldVF1qOO+RL24K7FDrR6pA+50XedRLSoSG24bVQ==", "dev": true, "license": "MIT", "dependencies": { "@bcoe/v8-coverage": "^1.0.2", - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.7", "ast-v8-to-istanbul": "^1.0.0", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", @@ -1450,14 +1348,14 @@ "magicast": "^0.5.2", "obug": "^2.1.1", "std-env": "^4.0.0-rc.1", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" }, "peerDependencies": { - "@vitest/browser": "4.1.0", - "vitest": "4.1.0" + "@vitest/browser": "4.1.7", + "vitest": "4.1.7" }, "peerDependenciesMeta": { "@vitest/browser": { @@ -1465,32 +1363,60 @@ } } }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/pretty-format": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.7.tgz", + "integrity": "sha512-umgCarTOYQWIaDMvGDRZij+6b9oVeLIyJzfN+AS88e0ZOU3QTgNNSTtjQOpcvWr3np1N0j4WgZj+sb3oYBDscw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/coverage-v8/node_modules/@vitest/utils": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.7.tgz", + "integrity": "sha512-T532WBu791cBxJlCl6SO+J14l81DQx6uQHm1bQbmCDY7nqlEIgkza/UFnSBNaUtSf41unldDFjdOBYEQC4b5Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.7", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, "node_modules/@vitest/expect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.0.tgz", - "integrity": "sha512-EIxG7k4wlWweuCLG9Y5InKFwpMEOyrMb6ZJ1ihYu02LVj/bzUwn2VMU+13PinsjRW75XnITeFrQBMH5+dLvCDA==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.5.tgz", + "integrity": "sha512-PWBaRY5JoKuRnHlUHfpV/KohFylaDZTupcXN1H9vYryNLOnitSw60Mw9IAE2r67NbwwzBw/Cc/8q9BK3kIX8Kw==", "dev": true, "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "chai": "^6.2.2", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.0.tgz", - "integrity": "sha512-evxREh+Hork43+Y4IOhTo+h5lGmVRyjqI739Rz4RlUPqwrkFFDF6EMvOOYjTx4E8Tl6gyCLRL8Mu7Ry12a13Tw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.5.tgz", + "integrity": "sha512-/x2EmFC4mT4NNzqvC3fmesuV97w5FC903KPmey4gsnJiMQ3Be1IlDKVaDaG8iqaLFHqJ2FVEkxZk5VmeLjIItw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.1.0", + "@vitest/spy": "4.1.5", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -1499,7 +1425,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -1511,26 +1437,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.0.tgz", - "integrity": "sha512-3RZLZlh88Ib0J7NQTRATfc/3ZPOnSUn2uDBUoGNn5T36+bALixmzphN26OUD3LRXWkJu4H0s5vvUeqBiw+kS0A==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.5.tgz", + "integrity": "sha512-7I3q6l5qr03dVfMX2wCo9FxwSJbPdwKjy2uu/YPpU3wfHvIL4QHwVRp57OfGrDFeUJ8/8QdfBKIV12FTtLn00g==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.0.tgz", - "integrity": "sha512-Duvx2OzQ7d6OjchL+trw+aSrb9idh7pnNfxrklo14p3zmNL4qPCDeIJAK+eBKYjkIwG96Bc6vYuxhqDXQOWpoQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.5.tgz", + "integrity": "sha512-2D+o7Pr82IEO46YPpoA/YU0neeyr6FTerQb5Ro7BUnBuv6NQtT/kmVnczngiMEBhzgqz2UZYl5gArejsyERDSQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.1.0", + "@vitest/utils": "4.1.5", "pathe": "^2.0.3" }, "funding": { @@ -1538,14 +1464,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.0.tgz", - "integrity": "sha512-0Vy9euT1kgsnj1CHttwi9i9o+4rRLEaPRSOJ5gyv579GJkNpgJK+B4HSv/rAWixx2wdAFci1X4CEPjiu2bXIMg==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.5.tgz", + "integrity": "sha512-zypXEt4KH/XgKGPUz4eC2AvErYx0My5hfL8oDb1HzGFpEk1P62bxSohdyOmvz+d9UJwanI68MKwr2EquOaOgMQ==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/pretty-format": "4.1.5", + "@vitest/utils": "4.1.5", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -1554,9 +1480,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.0.tgz", - "integrity": "sha512-pz77k+PgNpyMDv2FV6qmk5ZVau6c3R8HC8v342T2xlFxQKTrSeYw9waIJG8KgV9fFwAtTu4ceRzMivPTH6wSxw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.5.tgz", + "integrity": "sha512-2lNOsh6+R2Idnf1TCZqSwYlKN2E/iDlD8sgU59kYVl+OMDmvldO1VDk39smRfpUNwYpNRVn3w4YfuC7KfbBnkQ==", "dev": true, "license": "MIT", "funding": { @@ -1564,15 +1490,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.0.tgz", - "integrity": "sha512-XfPXT6a8TZY3dcGY8EdwsBulFCIw+BeeX0RZn2x/BtiY/75YGh8FeWGG8QISN/WhaqSrE2OrlDgtF8q5uhOTmw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.5.tgz", + "integrity": "sha512-76wdkrmfXfqGjueGgnb45ITPyUi1ycZ4IHgC2bhPDUfWHklY/q3MdLOAB+TF1e6xfl8NxNY0ZYaPCFNWSsw3Ug==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.1.0", + "@vitest/pretty-format": "4.1.5", "convert-source-map": "^2.0.0", - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -1601,16 +1527,6 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -1698,9 +1614,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1818,32 +1734,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cssstyle": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-6.2.0.tgz", - "integrity": "sha512-Fm5NvhYathRnXNVndkUsCCuR63DCLVVwGOOwQw782coXFi5HhkXdu289l59HlXZBawsyNccXfWRYvLzcDCdDig==", - "dev": true, - "license": "MIT", - "dependencies": { - "@asamuzakjp/css-color": "^5.0.1", - "@csstools/css-syntax-patches-for-csstree": "^1.0.28", - "css-tree": "^3.1.0", - "lru-cache": "^11.2.6" - }, - "engines": { - "node": ">=20" - } - }, - "node_modules/cssstyle/node_modules/lru-cache": { - "version": "11.2.7", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.7.tgz", - "integrity": "sha512-aY/R+aEsRelme17KGQa/1ZSIpLpNYYrhcrepKTZgE+W3WM16YMCaPwOHLHsmopZHELU0Ojin1lPVxKR0MihncA==", - "dev": true, - "license": "BlueOak-1.0.0", - "engines": { - "node": "20 || >=22" - } - }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1932,13 +1822,13 @@ "license": "ISC" }, "node_modules/entities": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", - "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", "dev": true, "license": "BSD-2-Clause", "engines": { - "node": ">=0.12" + "node": ">=20.19.0" }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" @@ -1975,18 +1865,18 @@ } }, "node_modules/eslint": { - "version": "10.0.3", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.0.3.tgz", - "integrity": "sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.2", - "@eslint/config-array": "^0.23.3", - "@eslint/config-helpers": "^0.5.2", - "@eslint/core": "^1.1.1", - "@eslint/plugin-kit": "^0.6.1", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", @@ -1997,7 +1887,7 @@ "escape-string-regexp": "^4.0.0", "eslint-scope": "^9.1.2", "eslint-visitor-keys": "^5.0.1", - "espree": "^11.1.1", + "espree": "^11.2.0", "esquery": "^1.7.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2305,9 +2195,9 @@ } }, "node_modules/globals": { - "version": "17.4.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-17.4.0.tgz", - "integrity": "sha512-hjrNztw/VajQwOLsMNT1cbJiH2muO3OROCHnbehc8eY5JyD2gqz4AcMHPqgaOR59DjgUjYAYLeH699g/eWi2jw==", + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", "dev": true, "license": "MIT", "engines": { @@ -2364,34 +2254,6 @@ "dev": true, "license": "MIT" }, - "node_modules/http-proxy-agent": { - "version": "7.0.2", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", - "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.0", - "debug": "^4.3.4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2506,36 +2368,36 @@ "license": "MIT" }, "node_modules/jsdom": { - "version": "28.1.0", - "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-28.1.0.tgz", - "integrity": "sha512-0+MoQNYyr2rBHqO1xilltfDjV9G7ymYGlAUazgcDLQaUf8JDHbuGwsxN6U9qWaElZ4w1B2r7yEGIL3GdeW3Rug==", + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", "dev": true, "license": "MIT", "dependencies": { - "@acemir/cssom": "^0.9.31", - "@asamuzakjp/dom-selector": "^6.8.1", + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", "@bramus/specificity": "^2.4.2", - "@exodus/bytes": "^1.11.0", - "cssstyle": "^6.0.1", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", - "http-proxy-agent": "^7.0.2", - "https-proxy-agent": "^7.0.6", "is-potential-custom-element-name": "^1.0.1", - "parse5": "^8.0.0", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", - "tough-cookie": "^6.0.0", - "undici": "^7.21.0", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", - "whatwg-url": "^16.0.0", + "whatwg-url": "^16.0.1", "xml-name-validator": "^5.0.0" }, "engines": { - "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" }, "peerDependencies": { "canvas": "^3.0.0" @@ -2546,6 +2408,16 @@ } } }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.3.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.3.5.tgz", + "integrity": "sha512-NxVFwLAnrd9i7KUBxC4DrUhmgjzOs+1Qm50D3oF1/oL+r1NpZ4gA7xvG0/zJ8evR7zIKn4vLf7qTNduWFtCrRw==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/jsesc": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", @@ -2983,13 +2855,13 @@ } }, "node_modules/minimatch": { - "version": "10.2.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.4.tgz", - "integrity": "sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==", + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, "license": "BlueOak-1.0.0", "dependencies": { - "brace-expansion": "^5.0.2" + "brace-expansion": "^5.0.5" }, "engines": { "node": "18 || 20 || >=22" @@ -3006,9 +2878,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", "dev": true, "funding": [ { @@ -3100,13 +2972,13 @@ } }, "node_modules/parse5": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.0.tgz", - "integrity": "sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", "dev": true, "license": "MIT", "dependencies": { - "entities": "^6.0.0" + "entities": "^8.0.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" @@ -3147,9 +3019,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "engines": { @@ -3160,9 +3032,9 @@ } }, "node_modules/postcss": { - "version": "8.5.8", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", - "integrity": "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg==", + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", "dev": true, "funding": [ { @@ -3180,7 +3052,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -3247,24 +3119,24 @@ } }, "node_modules/react": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", - "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, "node_modules/react-dom": { - "version": "19.2.4", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", - "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.4" + "react": "^19.2.7" } }, "node_modules/react-is": { @@ -3274,16 +3146,6 @@ "dev": true, "license": "MIT" }, - "node_modules/react-refresh": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz", - "integrity": "sha512-QgT5//D3jfjJb6Gsjxv0Slpj23ip+HtOpnNgnb2S5zU3CB26G/IDPGoy4RJB42wzFE46DRsstbW6tKHoKbhAxw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/react-window": { "version": "2.2.7", "resolved": "https://registry.npmjs.org/react-window/-/react-window-2.2.7.tgz", @@ -3319,14 +3181,14 @@ } }, "node_modules/rolldown": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.0-rc.9.tgz", - "integrity": "sha512-9EbgWge7ZH+yqb4d2EnELAntgPTWbfL8ajiTW+SyhJEC4qhBbkCKbqFV4Ge4zmu5ziQuVbWxb/XwLZ+RIO7E8Q==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.2.tgz", + "integrity": "sha512-oZx5zVDtVB44AW3eaifgDml1gWRDZGvjcfdxonE4swNPG98PrrXjaO/KrnUjzlMnztCCRVlUueA1kCXhARGk6g==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/types": "=0.115.0", - "@rolldown/pluginutils": "1.0.0-rc.9" + "@oxc-project/types": "=0.132.0", + "@rolldown/pluginutils": "^1.0.0" }, "bin": { "rolldown": "bin/cli.mjs" @@ -3335,27 +3197,27 @@ "node": "^20.19.0 || >=22.12.0" }, "optionalDependencies": { - "@rolldown/binding-android-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-arm64": "1.0.0-rc.9", - "@rolldown/binding-darwin-x64": "1.0.0-rc.9", - "@rolldown/binding-freebsd-x64": "1.0.0-rc.9", - "@rolldown/binding-linux-arm-gnueabihf": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-arm64-musl": "1.0.0-rc.9", - "@rolldown/binding-linux-ppc64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-s390x-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-gnu": "1.0.0-rc.9", - "@rolldown/binding-linux-x64-musl": "1.0.0-rc.9", - "@rolldown/binding-openharmony-arm64": "1.0.0-rc.9", - "@rolldown/binding-wasm32-wasi": "1.0.0-rc.9", - "@rolldown/binding-win32-arm64-msvc": "1.0.0-rc.9", - "@rolldown/binding-win32-x64-msvc": "1.0.0-rc.9" + "@rolldown/binding-android-arm64": "1.0.2", + "@rolldown/binding-darwin-arm64": "1.0.2", + "@rolldown/binding-darwin-x64": "1.0.2", + "@rolldown/binding-freebsd-x64": "1.0.2", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.2", + "@rolldown/binding-linux-arm64-gnu": "1.0.2", + "@rolldown/binding-linux-arm64-musl": "1.0.2", + "@rolldown/binding-linux-ppc64-gnu": "1.0.2", + "@rolldown/binding-linux-s390x-gnu": "1.0.2", + "@rolldown/binding-linux-x64-gnu": "1.0.2", + "@rolldown/binding-linux-x64-musl": "1.0.2", + "@rolldown/binding-openharmony-arm64": "1.0.2", + "@rolldown/binding-wasm32-wasi": "1.0.2", + "@rolldown/binding-win32-arm64-msvc": "1.0.2", + "@rolldown/binding-win32-x64-msvc": "1.0.2" } }, "node_modules/rolldown/node_modules/@rolldown/pluginutils": { - "version": "1.0.0-rc.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.9.tgz", - "integrity": "sha512-w6oiRWgEBl04QkFZgmW+jnU1EC9b57Oihi2ot3HNWIQRqgHp5PnYDia5iZ5FF7rpa4EQdiqMDXjlqKGXBhsoXw==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", "dev": true, "license": "MIT" }, @@ -3493,14 +3355,14 @@ } }, "node_modules/tinyglobby": { - "version": "0.2.15", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", - "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", - "picomatch": "^4.0.3" + "picomatch": "^4.0.4" }, "engines": { "node": ">=12.0.0" @@ -3510,9 +3372,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", "dev": true, "license": "MIT", "engines": { @@ -3585,9 +3447,9 @@ } }, "node_modules/undici": { - "version": "7.24.4", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", - "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", "dev": true, "license": "MIT", "engines": { @@ -3636,18 +3498,17 @@ } }, "node_modules/vite": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.0.tgz", - "integrity": "sha512-fPGaRNj9Zytaf8LEiBhY7Z6ijnFKdzU/+mL8EFBaKr7Vw1/FWcTBAMW0wLPJAGMPX38ZPVCVgLceWiEqeoqL2Q==", + "version": "8.0.14", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.14.tgz", + "integrity": "sha512-s4BJJ+5y1pYL6Otw51FHhVJQhPnuRinKig64g/1+EUNaJsd3gCKdD31IPFvswUgW9/60QT9oFHbZHbQK5imcxw==", "dev": true, "license": "MIT", "dependencies": { - "@oxc-project/runtime": "0.115.0", "lightningcss": "^1.32.0", - "picomatch": "^4.0.3", - "postcss": "^8.5.8", - "rolldown": "1.0.0-rc.9", - "tinyglobby": "^0.2.15" + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.2", + "tinyglobby": "^0.2.16" }, "bin": { "vite": "bin/vite.js" @@ -3663,8 +3524,8 @@ }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", - "@vitejs/devtools": "^0.0.0-alpha.31", - "esbuild": "^0.27.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", "jiti": ">=1.21.0", "less": "^4.0.0", "sass": "^1.70.0", @@ -3715,19 +3576,19 @@ } }, "node_modules/vitest": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.0.tgz", - "integrity": "sha512-YbDrMF9jM2Lqc++2530UourxZHmkKLxrs4+mYhEwqWS97WJ7wOYEkcr+QfRgJ3PW9wz3odRijLZjHEaRLTNbqw==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.5.tgz", + "integrity": "sha512-9Xx1v3/ih3m9hN+SbfkUyy0JAs72ap3r7joc87XL6jwF0jGg6mFBvQ1SrwaX+h8BlkX6Hz9shdd1uo6AF+ZGpg==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.1.0", - "@vitest/mocker": "4.1.0", - "@vitest/pretty-format": "4.1.0", - "@vitest/runner": "4.1.0", - "@vitest/snapshot": "4.1.0", - "@vitest/spy": "4.1.0", - "@vitest/utils": "4.1.0", + "@vitest/expect": "4.1.5", + "@vitest/mocker": "4.1.5", + "@vitest/pretty-format": "4.1.5", + "@vitest/runner": "4.1.5", + "@vitest/snapshot": "4.1.5", + "@vitest/spy": "4.1.5", + "@vitest/utils": "4.1.5", "es-module-lexer": "^2.0.0", "expect-type": "^1.3.0", "magic-string": "^0.30.21", @@ -3738,8 +3599,8 @@ "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -3755,13 +3616,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.1.0", - "@vitest/browser-preview": "4.1.0", - "@vitest/browser-webdriverio": "4.1.0", - "@vitest/ui": "4.1.0", + "@vitest/browser-playwright": "4.1.5", + "@vitest/browser-preview": "4.1.5", + "@vitest/browser-webdriverio": "4.1.5", + "@vitest/coverage-istanbul": "4.1.5", + "@vitest/coverage-v8": "4.1.5", + "@vitest/ui": "4.1.5", "happy-dom": "*", "jsdom": "*", - "vite": "^6.0.0 || ^7.0.0 || ^8.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -3782,6 +3645,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, diff --git a/renderer/package.json b/renderer/package.json index 3b89a9fb..a93a91b2 100644 --- a/renderer/package.json +++ b/renderer/package.json @@ -16,8 +16,8 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", - "react": "^19.2.4", - "react-dom": "^19.2.4", + "react": "^19.2.7", + "react-dom": "^19.2.7", "react-window": "^2.2.7" }, "devDependencies": { @@ -26,16 +26,16 @@ "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", - "@types/react": "^19.2.14", + "@types/react": "^19.2.16", "@types/react-dom": "^19.2.3", - "@vitejs/plugin-react": "^5.1.4", - "@vitest/coverage-v8": "^4.1.0", - "eslint": "^10.0.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.7", + "eslint": "^10.4.0", "eslint-plugin-react-hooks": "^7.0.1", "eslint-plugin-react-refresh": "^0.5.2", - "globals": "^17.4.0", - "jsdom": "^28.1.0", - "vite": "^8.0.0", - "vitest": "^4.0.18" + "globals": "^17.6.0", + "jsdom": "^29.1.1", + "vite": "^8.0.14", + "vitest": "^4.1.5" } } diff --git a/renderer/src/App.css b/renderer/src/App.css index 9fd75901..74f19ee6 100644 --- a/renderer/src/App.css +++ b/renderer/src/App.css @@ -25,43 +25,70 @@ body { display: flex; overflow: hidden; } -.deps-overlay { + +.zoom-indicator { position: fixed; - inset: 0; - background: rgba(0, 0, 0, 0.7); - display: flex; - align-items: center; - justify-content: center; - z-index: 9999; -} -.deps-box { - background: #1e1e2e; - border: 1px solid #3a3a5c; - border-radius: 10px; - padding: 28px 36px; - min-width: 340px; + top: 20px; + left: 14px; + z-index: 999; + background: #2a2a2a; + border: 1px solid #444; + border-radius: 6px; + color: #888; + font-size: 11px; + font-family: monospace; + padding: 0; + cursor: pointer; + white-space: nowrap; + overflow: hidden; display: flex; flex-direction: column; - gap: 12px; + animation: zoom-fadein 0.12s ease; + transition: + background 0.12s, + color 0.12s, + border-color 0.12s; } -.deps-title { - font-size: 16px; - font-weight: 600; - color: #cdd6f4; + +.zoom-indicator:hover { + background: #3a3a3a; + color: #fff; + border-color: #666; } -.deps-msg { - font-size: 13px; - color: #a6adc8; + +.zoom-indicator-label { + padding: 4px 10px; + font-size: 22px; } -.deps-bar-track { - height: 6px; - background: #313244; - border-radius: 3px; - overflow: hidden; + +.zoom-indicator-bar { + display: block; + height: 2px; + background: #5a8f5a; + transform-origin: left center; + animation: zoom-countdown 3s linear forwards; +} + +.zoom-indicator:hover .zoom-indicator-bar { + animation-play-state: paused; } -.deps-bar-fill { - height: 100%; - background: #89b4fa; - border-radius: 3px; - transition: width 0.3s ease; + +@keyframes zoom-fadein { + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes zoom-countdown { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } } diff --git a/renderer/src/App.jsx b/renderer/src/App.jsx index ab11b6e7..b58c440f 100644 --- a/renderer/src/App.jsx +++ b/renderer/src/App.jsx @@ -1,12 +1,18 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useRef } from 'react'; +import { flushSync } from 'react-dom'; import Sidebar from './Sidebar.jsx'; import MusicLibrary from './MusicLibrary.jsx'; import DownloadView from './DownloadView.jsx'; +import TidalDownloadView from './TidalDownloadView.jsx'; +import FileExplorerView from './FileExplorerView.jsx'; import SettingsModal from './SettingsModal.jsx'; import ExportModal from './ExportModal.jsx'; import PlayerBar from './PlayerBar.jsx'; import TopBar from './TopBar.jsx'; import { PlayerProvider } from './PlayerContext.jsx'; +import { DownloadProvider } from './DownloadContext.jsx'; +import { TidalDownloadProvider } from './TidalDownloadContext.jsx'; +import { DepsOverlay } from './DepsOverlay.jsx'; import './App.css'; function App() { @@ -14,6 +20,10 @@ function App() { const [showSettings, setShowSettings] = useState(false); const [exportState, setExportState] = useState(null); // { playlistId, mode } | null const [depsProgress, setDepsProgress] = useState(null); // { msg, pct } or null + const [zoomLevel, setZoomLevel] = useState(null); // shown when != 1.0, null = hidden + const [zoomKey, setZoomKey] = useState(0); // incremented on each zoom change to restart bar animation + const zoomHideTimer = useRef(null); + const ZOOM_HIDE_DELAY = 3000; const [search, setSearch] = useState(''); const handleArtistSearch = (artist) => { @@ -32,59 +42,147 @@ function App() { return unsub; }, []); + // Zoom control: Ctrl+Scroll and Ctrl+=/−/0, persisted to localStorage + useEffect(() => { + const ZOOM_STEP = 0.1; + const ZOOM_MIN = 0.5; + const ZOOM_MAX = 2.0; + const LS_KEY = 'app-zoom-factor'; + + const clamp = (v) => Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, v)); + const round = (v) => Math.round(v * 10) / 10; + + const applyZoom = (factor) => { + const clamped = clamp(round(factor)); + localStorage.setItem(LS_KEY, String(clamped)); + // Flush counter-scale state synchronously BEFORE applying zoom so the + // pill is already at the correct size when the page zooms — no jump. + flushSync(() => { + setZoomLevel(clamped); + setZoomKey((k) => k + 1); + }); + window.api.setZoomFactor(clamped); + clearTimeout(zoomHideTimer.current); + zoomHideTimer.current = setTimeout(() => setZoomLevel(null), ZOOM_HIDE_DELAY); + }; + + // Restore persisted zoom (silently — no indicator on launch) + const saved = parseFloat(localStorage.getItem(LS_KEY)); + if (!isNaN(saved)) { + const clamped = clamp(round(saved)); + window.api.setZoomFactor(clamped); + } + + const onWheel = (e) => { + if (!e.ctrlKey) return; + e.preventDefault(); + const current = window.api.getZoomFactor(); + applyZoom(e.deltaY < 0 ? current + ZOOM_STEP : current - ZOOM_STEP); + }; + + const onKeyDown = (e) => { + if (!e.ctrlKey) return; + if (e.key === '=' || e.key === '+') { + e.preventDefault(); + applyZoom(window.api.getZoomFactor() + ZOOM_STEP); + } else if (e.key === '-') { + e.preventDefault(); + applyZoom(window.api.getZoomFactor() - ZOOM_STEP); + } else if (e.key === '0') { + e.preventDefault(); + applyZoom(1.0); + } + }; + + window.addEventListener('wheel', onWheel, { passive: false }); + window.addEventListener('keydown', onKeyDown); + return () => { + window.removeEventListener('wheel', onWheel); + window.removeEventListener('keydown', onKeyDown); + clearTimeout(zoomHideTimer.current); + }; + }, []); + return ( -
- setShowSettings(true)} - /> -
- - setExportState({ playlistId: id, mode: 'rekordbox' }) - } - onExportPlaylistAll={(id) => setExportState({ playlistId: id, mode: 'all' })} - /> - {selectedPlaylistId === 'download' ? ( - setSelectedPlaylistId('music')} - onGoToPlaylist={(id) => setSelectedPlaylistId(id)} - /> - ) : ( - + +
+ setShowSettings(true)} /> - )} -
-
- - {showSettings && setShowSettings(false)} />} - {exportState != null && ( - setExportState(null)} - /> - )} - {depsProgress && ( -
-
-
First-time setup
-
{depsProgress.msg}
- {depsProgress.pct >= 0 && depsProgress.pct < 100 && ( -
-
-
- )} +
+ + setExportState({ playlistId: id, mode: 'rekordbox' }) + } + onExportPlaylistAll={(id) => setExportState({ playlistId: id, mode: 'all' })} + /> + {/* Always mounted so state persists when switching tabs */} + setSelectedPlaylistId('music')} + onGoToPlaylist={(id) => setSelectedPlaylistId(id)} + /> + setSelectedPlaylistId('music')} + onGoToPlaylist={(id) => setSelectedPlaylistId(id)} + /> + + {selectedPlaylistId !== 'download' && + selectedPlaylistId !== 'tidal' && + selectedPlaylistId !== 'explorer' && ( + + )} +
-
- )} + + {showSettings && setShowSettings(false)} />} + {exportState != null && ( + setExportState(null)} + /> + )} + {zoomLevel !== null && zoomLevel !== 1.0 && ( + + )} + window.api.retryDeps?.()} /> + + ); } diff --git a/renderer/src/BeatGridEditor.css b/renderer/src/BeatGridEditor.css new file mode 100644 index 00000000..8347a57d --- /dev/null +++ b/renderer/src/BeatGridEditor.css @@ -0,0 +1,475 @@ +/* ── Beat Grid Editor modal ──────────────────────────────────────────────── */ + +.bge-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.72); + display: flex; + align-items: center; + justify-content: center; + z-index: 1200; +} + +.bge-modal { + background: #1a1a1a; + border: 1px solid #333; + border-radius: 8px; + width: min(860px, 96vw); + max-height: min(96vh, 900px); + display: flex; + flex-direction: column; + overflow: hidden; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.7); +} + +/* ── Header ─────────────────────────────────────────────────────────────── */ +.bge-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 12px 16px; + border-bottom: 1px solid #2a2a2a; + gap: 12px; + flex-shrink: 0; +} + +.bge-title { + font-size: 13px; + font-weight: 600; + color: #e0e0e0; + display: flex; + align-items: baseline; + gap: 8px; + flex: 1; + min-width: 0; +} + +.bge-track-name { + font-weight: 400; + color: #888; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.bge-close { + background: none; + border: none; + color: #666; + font-size: 16px; + cursor: pointer; + padding: 0 4px; + line-height: 1; + flex-shrink: 0; +} + +.bge-close:hover { + color: #ccc; +} + +/* ── Detail waveform canvas ─────────────────────────────────────────────── */ +.bge-canvas-wrap { + position: relative; + background: #111; + cursor: crosshair; + user-select: none; + border-bottom: 1px solid #1e1e1e; + flex-shrink: 0; +} + +.bge-canvas-wrap:active { + cursor: grabbing; +} + +.bge-canvas { + display: block; + width: 100%; + height: 160px; +} + +.bge-canvas-hint { + position: absolute; + bottom: 4px; + right: 8px; + font-size: 10px; + color: #3a3a3a; + pointer-events: none; +} + +.bge-loading { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + font-size: 11px; + color: #555; + pointer-events: none; + z-index: 2; +} + +/* ── Play/pause overlay button ───────────────────────────────────────────── */ +.bge-play-overlay { + position: absolute; + top: 8px; + left: 8px; + z-index: 10; + background: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + color: #ccc; + font-size: 13px; + width: 28px; + height: 24px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + transition: + background 0.1s, + border-color 0.1s, + color 0.1s; +} + +.bge-play-overlay:hover { + background: rgba(224, 48, 48, 0.3); + border-color: rgba(224, 48, 48, 0.6); + color: #fff; +} + +.bge-play-overlay--playing { + background: rgba(224, 48, 48, 0.2); + border-color: rgba(224, 48, 48, 0.5); + color: #e03030; +} + +.bge-play-overlay--playing:hover { + background: rgba(224, 48, 48, 0.35); +} + +/* ── Overview / navigation waveform ─────────────────────────────────────── */ +.bge-overview-wrap { + background: #0d0d0d; + cursor: pointer; + border-bottom: 1px solid #2a2a2a; + flex-shrink: 0; +} + +.bge-overview-canvas { + display: block; + width: 100%; + height: 44px; +} + +/* ── Controls ───────────────────────────────────────────────────────────── */ +.bge-controls { + padding: 10px 16px; + display: flex; + flex-direction: column; + gap: 8px; + border-bottom: 1px solid #2a2a2a; + flex-shrink: 0; +} + +.bge-row { + display: flex; + align-items: center; + gap: 10px; +} + +.bge-label { + font-size: 12px; + color: #888; + width: 80px; + flex-shrink: 0; +} + +/* ── Nudge group ────────────────────────────────────────────────────────── */ +.bge-nudge-group { + display: flex; + align-items: center; + gap: 4px; + flex-wrap: wrap; +} + +.bge-nudge-btn { + background: #252525; + border: 1px solid #3a3a3a; + border-radius: 4px; + color: #bbb; + font-size: 11px; + padding: 3px 7px; + cursor: pointer; + transition: background 0.08s; + font-variant-numeric: tabular-nums; +} + +.bge-nudge-btn:hover { + background: #333; + color: #fff; +} + +.bge-nudge-reset { + color: #888; + border-color: #333; +} + +.bge-nudge-reset:hover { + color: #f7c07e; + border-color: #f7c07e; +} + +.bge-offset-val { + min-width: 60px; + text-align: center; + font-size: 12px; + color: #7eb8f7; + font-weight: 600; + font-variant-numeric: tabular-nums; + padding: 0 4px; +} + +/* ── Zoom controls (top-right of waveform) ──────────────────────────────── */ +.bge-zoom-controls { + position: absolute; + top: 8px; + right: 8px; + z-index: 10; + display: flex; + align-items: center; + gap: 2px; + background: rgba(0, 0, 0, 0.55); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + padding: 2px 4px; +} + +.bge-zoom-btn { + background: none; + border: none; + color: #aaa; + font-size: 14px; + font-weight: 600; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; + line-height: 1; + border-radius: 2px; + transition: + color 0.08s, + background 0.08s; +} + +.bge-zoom-btn:hover:not(:disabled) { + color: #fff; + background: rgba(255, 255, 255, 0.1); +} + +.bge-zoom-btn:disabled { + color: #444; + cursor: default; +} + +.bge-zoom-label { + font-size: 10px; + color: #666; + min-width: 28px; + text-align: center; + font-variant-numeric: tabular-nums; +} + +/* ── BPM + TAP inline row ───────────────────────────────────────────────── */ +.bge-bpm-row { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; +} + +.bge-bpm-input { + width: 80px; + background: #1a1a1a; + border: 1px solid #444; + border-radius: 4px; + color: #e0e0e0; + font-size: 12px; + padding: 3px 8px; + outline: none; +} + +.bge-bpm-input:focus { + border-color: #7eb8f7; +} + +.bge-bpm-hint { + font-size: 11px; + color: #555; +} + +.bge-bpm-hint--override { + color: #f7c07e; +} + +/* ── TAP tempo ──────────────────────────────────────────────────────────── */ +.bge-tap-group { + display: flex; + align-items: center; + gap: 6px; +} + +.bge-tap-btn { + min-width: 72px; + background: #252525; + border: 1px solid #3a3a3a; + border-radius: 4px; + color: #bbb; + font-size: 12px; + font-weight: 600; + letter-spacing: 0.04em; + padding: 4px 10px; + cursor: pointer; + transition: + background 0.08s, + border-color 0.08s, + color 0.08s; + font-variant-numeric: tabular-nums; +} + +.bge-tap-btn:hover { + background: #333; + color: #fff; + border-color: #555; +} + +.bge-tap-btn--active { + background: #1e3a1e; + border-color: #4caf50; + color: #7ed87e; +} + +.bge-tap-apply-wrap { + display: flex; + align-items: center; + gap: 4px; + /* Hidden by default — shown when there's a tapped BPM */ + opacity: 0; + pointer-events: none; + transform: translateX(-4px); + transition: + opacity 0.15s, + transform 0.15s; +} + +.bge-tap-apply-wrap--visible { + opacity: 1; + pointer-events: auto; + transform: translateX(0); +} + +.bge-tap-arrow { + font-size: 12px; + color: #666; +} + +.bge-tap-apply-btn { + background: #1a4a1a; + border: 1px solid #4caf50; + border-radius: 4px; + color: #7ed87e; + font-size: 12px; + font-weight: 600; + padding: 4px 12px; + cursor: pointer; + transition: + background 0.08s, + box-shadow 0.08s; + font-variant-numeric: tabular-nums; + /* Pulse animation to draw attention */ + animation: bge-tap-pulse 1.2s ease-in-out infinite; +} + +.bge-tap-apply-btn:hover { + background: #1f5c1f; + box-shadow: 0 0 8px rgba(76, 175, 80, 0.4); + animation: none; +} + +@keyframes bge-tap-pulse { + 0%, + 100% { + box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); + } + 50% { + box-shadow: 0 0 6px 2px rgba(76, 175, 80, 0.35); + } +} + +/* ── Cue points section ─────────────────────────────────────────────────── */ +.bge-cue-section { + overflow-y: auto; + max-height: 220px; + border-bottom: 1px solid #2a2a2a; +} + +/* Remove the top border that CuePointsEditor adds since we provide our own */ +.bge-cue-section .cpe { + border-top: none; +} + +/* ── Footer ─────────────────────────────────────────────────────────────── */ +.bge-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px; + flex-shrink: 0; +} + +.bge-btn { + font-size: 12px; + padding: 5px 16px; + border-radius: 4px; + cursor: pointer; + border: 1px solid #444; +} + +.bge-btn--cancel { + background: #252525; + color: #aaa; +} + +.bge-btn--cancel:hover { + background: #333; + color: #e0e0e0; +} + +.bge-btn--apply { + background: #1a4a6a; + border-color: #7eb8f7; + color: #7eb8f7; + font-weight: 600; +} + +.bge-btn--apply:hover { + background: #1e5a80; +} + +/* Cancel confirmation inline row */ +.bge-cancel-confirm { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + flex-wrap: wrap; +} + +.bge-cancel-confirm__msg { + color: #f0a030; + font-size: 13px; + margin-right: 4px; +} diff --git a/renderer/src/BeatGridEditor.jsx b/renderer/src/BeatGridEditor.jsx new file mode 100644 index 00000000..8c216a4d --- /dev/null +++ b/renderer/src/BeatGridEditor.jsx @@ -0,0 +1,934 @@ +import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react'; +import { usePlayer } from './PlayerContext.jsx'; +import CuePointsEditor from './CuePointsEditor.jsx'; +import './BeatGridEditor.css'; + +const COLS_PER_SEC = 150; // must match waveformGenerator.js +const ZOOM_LEVELS = [1000, 2000, 4000, 8000, 16000, 32000]; // ms visible in detail canvas + +/** Compute beat array from beatgrid JSON + bpm + offset (ms). */ +function computeBeats(beatgridJson, bpm, offsetMs = 0) { + let beats = []; + try { + if (beatgridJson) { + const raw = typeof beatgridJson === 'string' ? JSON.parse(beatgridJson) : beatgridJson; + if (Array.isArray(raw) && raw.length > 0) { + beats = + typeof raw[0] === 'number' + ? raw.map((t, i) => ({ time: Math.round(t * 1000), beatNum: (i % 4) + 1 })) + : raw.map((b, i) => ({ + time: Math.round((b.position ?? b.time ?? b.offset ?? 0) * 1000), + beatNum: (i % 4) + 1, + })); + } + } + } catch { + // malformed beatgrid JSON — fall through to BPM-generated grid + } + + if (!beats.length && bpm > 0) { + const intervalMs = (60 / bpm) * 1000; + const count = Math.ceil(600_000 / intervalMs); + beats = Array.from({ length: count }, (_, i) => ({ + time: Math.round(i * intervalMs), + beatNum: (i % 4) + 1, + })); + } + + return beats.map((b) => ({ ...b, time: b.time + offsetMs })).filter((b) => b.time >= 0); +} + +/** + * Draw the scrollable detail waveform. + * viewMs — milliseconds visible in the canvas (zoom level) + */ +function drawDetail(canvas, detail, viewCenter, beats, cuePoints, viewMs) { + const ctx = canvas.getContext('2d'); + const W = canvas.width; + const H = canvas.height; + const pxPerMs = W / viewMs; + const midY = H / 2; + + ctx.clearRect(0, 0, W, H); + + // ── Background ───────────────────────────────────────────────────────────── + ctx.fillStyle = '#111'; + ctx.fillRect(0, 0, W, H); + + // ── Waveform ─────────────────────────────────────────────────────────────── + if (detail && detail.length >= 3) { + const numCols = Math.floor(detail.length / 3); + const totalMs = (numCols / COLS_PER_SEC) * 1000; + + for (let px = 0; px < W; px++) { + const msAtPx = viewCenter - viewMs / 2 + (px / W) * viewMs; + if (msAtPx < 0 || msAtPx > totalMs) continue; + + const col = Math.floor((msAtPx / totalMs) * numCols); + if (col < 0 || col >= numCols) continue; + + const treble = detail[col * 3 + 0]; + const mid = detail[col * 3 + 1]; + const bass = detail[col * 3 + 2]; + + const amplitude = Math.max(treble, mid, bass) / 255; + const halfH = Math.max(1, amplitude * midY * 0.85); + + // Gamma-compress to prevent bass domination (same logic as PlayerBar) + const bassC = Math.pow(bass / 255, 0.55); + const midC = Math.pow(mid / 255, 0.3); + const trebleC = Math.pow(treble / 255, 0.2); + const dominant = Math.max(bassC, midC, trebleC) || 0.001; + const brightness = Math.min(1, amplitude * 2.5); + const r = Math.round((trebleC / dominant) * brightness * 255); + const g = Math.round((midC / dominant) * brightness * 255); + const b = Math.round((bassC / dominant) * brightness * 255); + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(px, midY - halfH, 1, halfH * 2); + } + } else { + const grad = ctx.createLinearGradient(0, 0, 0, H); + grad.addColorStop(0, '#1a1a2e'); + grad.addColorStop(1, '#111'); + ctx.fillStyle = grad; + ctx.fillRect(0, 0, W, H); + } + + // ── Center divider line ──────────────────────────────────────────────────── + ctx.strokeStyle = 'rgba(255,255,255,0.07)'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(0, midY); + ctx.lineTo(W, midY); + ctx.stroke(); + + // ── Time ruler ───────────────────────────────────────────────────────────── + ctx.fillStyle = '#555'; + ctx.font = '10px monospace'; + const secStart = Math.floor((viewCenter - viewMs / 2) / 1000); + const secEnd = Math.ceil((viewCenter + viewMs / 2) / 1000); + for (let s = secStart; s <= secEnd; s++) { + const x = W / 2 + (s * 1000 - viewCenter) * pxPerMs; + ctx.strokeStyle = '#2a2a2a'; + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, 8); + ctx.stroke(); + ctx.fillText(`${s}s`, x + 2, 16); + } + + // ── Beat markers ─────────────────────────────────────────────────────────── + let measureNum = 0; + for (let i = 0; i < beats.length; i++) { + const b = beats[i]; + const x = W / 2 + (b.time - viewCenter) * pxPerMs; + if (x < -4 || x > W + 4) continue; + + if (b.beatNum === 1) measureNum = Math.floor(i / 4) + 1; + + const isBeat1 = b.beatNum === 1; + const lineH = isBeat1 ? H * 0.65 : H * 0.28; + ctx.strokeStyle = isBeat1 ? 'rgba(255,255,255,0.85)' : 'rgba(160,160,160,0.45)'; + ctx.lineWidth = isBeat1 ? 2 : 1; + ctx.beginPath(); + ctx.moveTo(x, midY - lineH / 2); + ctx.lineTo(x, midY + lineH / 2); + ctx.stroke(); + + if (isBeat1) { + ctx.fillStyle = 'rgba(200,200,200,0.7)'; + ctx.font = '10px monospace'; + ctx.fillText(measureNum, x + 3, midY - lineH / 2 - 3); + } + } + + // ── Cue point markers ────────────────────────────────────────────────────── + if (cuePoints && cuePoints.length > 0) { + for (const cue of cuePoints) { + const x = W / 2 + (cue.position_ms - viewCenter) * pxPerMs; + if (x < -10 || x > W + 10) continue; + + const color = cue.color || '#00b4d8'; + ctx.strokeStyle = color; + ctx.lineWidth = 1.5; + ctx.setLineDash([]); + ctx.beginPath(); + ctx.moveTo(x, 0); + ctx.lineTo(x, H); + ctx.stroke(); + + const label = cue.hot_cue_index >= 0 ? 'ABCDEFGH'[cue.hot_cue_index] : '●'; + ctx.fillStyle = color; + ctx.fillRect(x, H - 18, cue.hot_cue_index >= 0 ? 12 : 10, 14); + ctx.fillStyle = '#000'; + ctx.font = 'bold 9px monospace'; + ctx.fillText(label, x + 2, H - 6); + } + } + + // ── Red center playhead (fixed at W/2) ──────────────────────────────────── + ctx.strokeStyle = '#e03030'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(W / 2, 0); + ctx.lineTo(W / 2, H); + ctx.stroke(); + + ctx.fillStyle = '#e03030'; + ctx.beginPath(); + ctx.moveTo(W / 2 - 5, 0); + ctx.lineTo(W / 2 + 5, 0); + ctx.lineTo(W / 2, 7); + ctx.closePath(); + ctx.fill(); + + ctx.beginPath(); + ctx.moveTo(W / 2 - 5, H); + ctx.lineTo(W / 2 + 5, H); + ctx.lineTo(W / 2, H - 7); + ctx.closePath(); + ctx.fill(); +} + +function drawOverview(canvas, overview, viewCenter, durationMs, playheadMs, cuePoints, viewMs) { + const ctx = canvas.getContext('2d'); + const W = canvas.width; + const H = canvas.height; + + ctx.clearRect(0, 0, W, H); + ctx.fillStyle = '#0d0d0d'; + ctx.fillRect(0, 0, W, H); + + const numCols = overview ? Math.floor(overview.length / 4) : 0; + + if (numCols > 0) { + const midY = H / 2; + for (let px = 0; px < W; px++) { + const col = Math.floor((px / W) * numCols); + if (col >= numCols) continue; + + const rms = overview[col * 4 + 0] / 255; + const bass = overview[col * 4 + 1]; + const mid = overview[col * 4 + 2]; + const treble = overview[col * 4 + 3]; + + const amplitude = Math.max(rms, bass / 255, mid / 255, treble / 255); + const halfH = Math.max(1, amplitude * midY * 0.9); + + // Gamma-compress to prevent bass domination (same logic as PlayerBar) + const bassC = Math.pow(bass / 255, 0.55); + const midC = Math.pow(mid / 255, 0.3); + const trebleC = Math.pow(treble / 255, 0.2); + const dominant = Math.max(bassC, midC, trebleC) || 0.001; + const brightness = Math.min(1, rms * 2.5); + const r = Math.round((trebleC / dominant) * brightness * 255); + const g = Math.round((midC / dominant) * brightness * 255); + const b = Math.round((bassC / dominant) * brightness * 255); + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(px, midY - halfH, 1, halfH * 2); + } + } + + // ── Cue markers ──────────────────────────────────────────────────────────── + if (cuePoints && cuePoints.length > 0 && durationMs > 0) { + for (const cue of cuePoints) { + const px = (cue.position_ms / durationMs) * W; + if (px < 0 || px > W) continue; + ctx.strokeStyle = cue.color || '#00b4d8'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(px, 0); + ctx.lineTo(px, H); + ctx.stroke(); + } + } + + // ── Viewport highlight ──────────────────────────────────────────────────── + if (durationMs > 0) { + const viewStart = viewCenter - viewMs / 2; + const viewEnd = viewCenter + viewMs / 2; + const x1 = Math.max(0, (viewStart / durationMs) * W); + const x2 = Math.min(W, (viewEnd / durationMs) * W); + ctx.fillStyle = 'rgba(224,48,48,0.08)'; + ctx.fillRect(x1, 0, x2 - x1, H); + ctx.strokeStyle = 'rgba(224,48,48,0.35)'; + ctx.lineWidth = 1; + ctx.strokeRect(x1, 0, x2 - x1, H); + } + + // ── Playhead ────────────────────────────────────────────────────────────── + if (playheadMs != null && durationMs > 0) { + const px = (playheadMs / durationMs) * W; + ctx.strokeStyle = '#e03030'; + ctx.lineWidth = 1.5; + ctx.beginPath(); + ctx.moveTo(px, 0); + ctx.lineTo(px, H); + ctx.stroke(); + } +} + +// ───────────────────────────────────────────────────────────────────────────── + +export default function BeatGridEditor({ track, onClose, onApply }) { + const { currentTrack, isPlaying, currentTime, duration, togglePlay, play, seek, stop } = + usePlayer(); + + const [offset, setOffset] = useState(track.beatgrid_offset ?? 0); + // bpmInput is the live-preview BPM — drives the beatgrid immediately + const [bpmInput, setBpmInput] = useState(() => { + const bpm = track.bpm_override ?? track.bpm ?? 0; + return bpm > 0 ? String(Math.round(bpm * 10) / 10) : ''; + }); + const [waveformLoading, setWaveformLoading] = useState(true); + const [showCancelConfirm, setShowCancelConfirm] = useState(false); + const initialCuesRef = useRef(null); + const showCancelConfirmRef = useRef(false); + + // Zoom level — index into ZOOM_LEVELS + const [zoomIdx, setZoomIdx] = useState(3); // default: 8000 ms + const viewMsRef = useRef(ZOOM_LEVELS[3]); + + // ── TAP tempo state ──────────────────────────────────────────────────────── + const [tapBpm, setTapBpm] = useState(null); + const tapTimesRef = useRef([]); + const tapResetTimerRef = useRef(null); + + // ── RAF-loop refs ────────────────────────────────────────────────────────── + const detailCanvasRef = useRef(null); + const overviewCanvasRef = useRef(null); + const rafRef = useRef(null); + + const waveformDetailRef = useRef(null); + const waveformOverviewRef = useRef(null); + const beatsRef = useRef([]); + const cuePointsRef = useRef([]); + const viewCenterRef = useRef(0); // track start at playhead on open + const trackDurationMsRef = useRef(0); + const isPlayingRef = useRef(false); + const isThisTrackRef = useRef(false); + const currentTimeSecRef = useRef(0); + const lastTimeUpdateRef = useRef(0); + const userScrollingRef = useRef(false); + const seekRef = useRef(seek); + + const trackDurationMs = (track.duration ?? duration ?? 0) * 1000; + const isThisTrack = currentTrack?.id === track.id; + + // Live preview BPM: use whatever is in the input field (so tapping updates grid immediately) + const previewBpm = (() => { + const p = parseFloat(bpmInput); + return Number.isFinite(p) && p > 0 ? p : (track.bpm_override ?? track.bpm ?? 0); + })(); + + const beats = computeBeats(track.beatgrid, previewBpm, offset); + + // Keep refs in sync with latest render values so RAF callbacks always read + // current state without stale-closure issues. useLayoutEffect runs + // synchronously after every render (before paint) — equivalent to assigning + // during render but avoids the react-compiler lint rule. + useLayoutEffect(() => { + seekRef.current = seek; + beatsRef.current = beats; + viewMsRef.current = ZOOM_LEVELS[zoomIdx]; + trackDurationMsRef.current = trackDurationMs; + isPlayingRef.current = isPlaying; + isThisTrackRef.current = isThisTrack; + showCancelConfirmRef.current = showCancelConfirm; + }); + + useEffect(() => { + currentTimeSecRef.current = currentTime; + lastTimeUpdateRef.current = performance.now(); + }, [currentTime]); + + // Reset the wall-clock reference the moment playback starts so the elapsed + // interpolation doesn't overshoot from a stale timestamp (causes 1-frame glitch). + useEffect(() => { + if (isPlaying) lastTimeUpdateRef.current = performance.now(); + }, [isPlaying]); + + // ── Load waveform ───────────────────────────────────────────────────────── + useEffect(() => { + let alive = true; + window.api + .getEditorWaveform(track.id) + .then((result) => { + if (!alive || !result) return; + waveformDetailRef.current = result.detail ? new Uint8Array(result.detail) : null; + waveformOverviewRef.current = result.overview ? new Uint8Array(result.overview) : null; + }) + .catch(() => {}) + .finally(() => { + if (alive) setWaveformLoading(false); + }); + return () => { + alive = false; + }; + }, [track.id]); + + // ── Load cue points ─────────────────────────────────────────────────────── + useEffect(() => { + let alive = true; + window.api.getCuePoints(track.id).then((pts) => { + if (!alive) return; + const list = pts ?? []; + cuePointsRef.current = list; + if (initialCuesRef.current === null) initialCuesRef.current = list; + }); + return () => { + alive = false; + }; + }, [track.id]); + + // ── RAF loop ────────────────────────────────────────────────────────────── + useEffect(() => { + const loop = () => { + // Only extrapolate forward when actually playing — pausing must freeze the playhead + const elapsed = isPlayingRef.current + ? (performance.now() - lastTimeUpdateRef.current) / 1000 + : 0; + const estTimeSec = currentTimeSecRef.current + elapsed; + const playheadMs = estTimeSec * 1000; + const vms = viewMsRef.current; + + // Auto-scroll: keep the playhead centred — no Min clamp so it works from t=0 + if (isThisTrackRef.current && isPlayingRef.current && !userScrollingRef.current) { + viewCenterRef.current = playheadMs; + } + + const vc = viewCenterRef.current; + const dur = trackDurationMsRef.current; + const ph = isThisTrackRef.current ? playheadMs : null; + const cues = cuePointsRef.current; + + const dc = detailCanvasRef.current; + if (dc) drawDetail(dc, waveformDetailRef.current, vc, beatsRef.current, cues, vms); + + const oc = overviewCanvasRef.current; + if (oc) drawOverview(oc, waveformOverviewRef.current, vc, dur, ph, cues, vms); + + rafRef.current = requestAnimationFrame(loop); + }; + + rafRef.current = requestAnimationFrame(loop); + return () => { + if (rafRef.current) cancelAnimationFrame(rafRef.current); + }; + }, []); + + // ── Resize observer ─────────────────────────────────────────────────────── + useEffect(() => { + const canvases = [detailCanvasRef.current, overviewCanvasRef.current].filter(Boolean); + if (!canvases.length) return; + const ro = new ResizeObserver(() => { + for (const canvas of canvases) { + canvas.width = canvas.offsetWidth * window.devicePixelRatio; + canvas.height = canvas.offsetHeight * window.devicePixelRatio; + } + }); + for (const canvas of canvases) ro.observe(canvas); + return () => ro.disconnect(); + }, []); + + // ── Dirty check ─────────────────────────────────────────────────────────── + const computeIsDirty = useCallback(() => { + const initial = initialCuesRef.current; + if (initial === null) return false; // cues not loaded yet + const pending = cuePointsRef.current; + const initialBpmStr = (() => { + const bpm = track.bpm_override ?? track.bpm ?? 0; + return bpm > 0 ? String(Math.round(bpm * 10) / 10) : ''; + })(); + if (bpmInput !== initialBpmStr || offset !== (track.beatgrid_offset ?? 0)) return true; + if (initial.length !== pending.length) return true; + return initial.some((c, i) => { + const p = pending[i]; + return ( + !p || + c.id !== p.id || + c.position_ms !== p.position_ms || + c.hot_cue_index !== p.hot_cue_index || + c.color !== p.color || + (c.label ?? '') !== (p.label ?? '') + ); + }); + }, [bpmInput, offset, track]); + + // ── Close — show confirmation if there are unsaved changes ──────────────── + const handleClose = useCallback(() => { + if (computeIsDirty()) { + setShowCancelConfirm(true); + return; + } + if (isThisTrackRef.current) stop(); + onClose(); + }, [computeIsDirty, stop, onClose]); + + // Discard: nothing was written to DB, so just close + const handleForceClose = useCallback(() => { + setShowCancelConfirm(false); + if (isThisTrackRef.current) stop(); + onClose(); + }, [stop, onClose]); + + // ── Zoom controls ───────────────────────────────────────────────────────── + const zoomIn = () => + setZoomIdx((i) => { + const next = Math.max(0, i - 1); + viewMsRef.current = ZOOM_LEVELS[next]; + return next; + }); + const zoomOut = () => + setZoomIdx((i) => { + const next = Math.min(ZOOM_LEVELS.length - 1, i + 1); + viewMsRef.current = ZOOM_LEVELS[next]; + return next; + }); + + // ── Detail canvas drag — vinyl-style: grab pauses, drag scrubs, release seeks ─ + const dragRef = useRef(null); + const wasPlayingRef = useRef(false); // remember if track was playing when grabbed + + const onDetailMouseDown = (e) => { + if (e.target !== detailCanvasRef.current) return; + userScrollingRef.current = true; + wasPlayingRef.current = isThisTrackRef.current && isPlayingRef.current; + dragRef.current = { startX: e.clientX, startCenter: viewCenterRef.current, dragged: false }; + + // Pause on grab (vinyl-stop) — no seek, position unchanged + if (wasPlayingRef.current) togglePlay(); + }; + + // Mouse move: update view position visually only — no seek (prevents audio stutter) + const onMouseMove = useCallback((e) => { + if (!dragRef.current) return; + const canvas = detailCanvasRef.current; + if (!canvas) return; + const deltaPx = dragRef.current.startX - e.clientX; + // Only count as a real drag after >4 px to filter out click micro-movement + if (Math.abs(deltaPx) > 4) dragRef.current.dragged = true; + if (!dragRef.current.dragged) return; + const pxPerMs = canvas.offsetWidth / viewMsRef.current; + const deltaMs = deltaPx / pxPerMs; + const maxCenter = trackDurationMsRef.current || 600_000; + viewCenterRef.current = Math.max(0, Math.min(maxCenter, dragRef.current.startCenter + deltaMs)); + }, []); + + // Mouse up: if user dragged, seek to the scrubbed position + const onMouseUp = () => { + if (dragRef.current?.dragged && isThisTrackRef.current) { + const targetSec = viewCenterRef.current / 1000; + seekRef.current(targetSec); + currentTimeSecRef.current = targetSec; + lastTimeUpdateRef.current = performance.now(); + } + dragRef.current = null; + wasPlayingRef.current = false; + userScrollingRef.current = false; + }; + + // ── Wheel to zoom ───────────────────────────────────────────────────────── + const onDetailWheel = useCallback((e) => { + e.preventDefault(); + const delta = e.deltaY || e.deltaX; + if (delta < 0) { + setZoomIdx((i) => { + const next = Math.max(0, i - 1); + viewMsRef.current = ZOOM_LEVELS[next]; + return next; + }); + } else if (delta > 0) { + setZoomIdx((i) => { + const next = Math.min(ZOOM_LEVELS.length - 1, i + 1); + viewMsRef.current = ZOOM_LEVELS[next]; + return next; + }); + } + }, []); + + // ── Overview click-to-jump ──────────────────────────────────────────────── + const onOverviewClick = useCallback( + (e) => { + const canvas = overviewCanvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const frac = (e.clientX - rect.left) / rect.width; + const ms = frac * (trackDurationMsRef.current || 600_000); + const clamped = Math.max( + viewMsRef.current / 2, + Math.min(trackDurationMsRef.current || 600_000, ms) + ); + viewCenterRef.current = clamped; + userScrollingRef.current = false; + if (isThisTrackRef.current) seek(clamped / 1000); + }, + [seek] + ); + + // ── Play / pause ────────────────────────────────────────────────────────── + const handlePlayPause = useCallback(() => { + if (isThisTrack) { + togglePlay(); + } else { + // Start from wherever the user scrolled the waveform to (or 0 if untouched). + const startMs = Math.max(0, viewCenterRef.current); + const startSec = startMs / 1000; + currentTimeSecRef.current = startSec; + lastTimeUpdateRef.current = performance.now(); + play(track, [track], 0, null, null); + // play() resets audio.src, clearing currentTime to 0. Defer the seek by + // one frame so the element has initialised before we set currentTime. + if (startSec > 0) requestAnimationFrame(() => seekRef.current(startSec)); + userScrollingRef.current = false; + } + }, [isThisTrack, togglePlay, play, track]); + + // ── Nudge ───────────────────────────────────────────────────────────────── + const nudge = (deltaMs) => setOffset((prev) => prev + deltaMs); + const resetOffset = () => setOffset(0); + + // ── TAP tempo ───────────────────────────────────────────────────────────── + const handleTap = useCallback(() => { + const now = performance.now(); + const times = tapTimesRef.current; + if (times.length > 0 && now - times[times.length - 1] > 3000) times.length = 0; + times.push(now); + if (times.length > 8) times.splice(0, times.length - 8); + if (times.length >= 2) { + const intervals = []; + for (let i = 1; i < times.length; i++) intervals.push(times[i] - times[i - 1]); + const avgMs = intervals.reduce((a, b) => a + b, 0) / intervals.length; + setTapBpm(Math.round((60000 / avgMs) * 10) / 10); + } + clearTimeout(tapResetTimerRef.current); + tapResetTimerRef.current = setTimeout(() => { + tapTimesRef.current = []; + setTapBpm(null); + }, 3000); + }, []); + + const applyTapBpm = useCallback(() => { + if (tapBpm == null) return; + setBpmInput(String(tapBpm)); + setTapBpm(null); + tapTimesRef.current = []; + clearTimeout(tapResetTimerRef.current); + }, [tapBpm]); + + // ── Apply — commit all pending cue changes, then save BPM/offset ────────── + const handleApply = async () => { + const parsed = parseFloat(bpmInput); + const bpmOverride = Number.isFinite(parsed) && parsed > 0 ? Math.round(parsed * 10) / 10 : null; + + // Diff pending cues (local state) against initial DB snapshot + const initial = initialCuesRef.current ?? []; + const pending = cuePointsRef.current; + const initialMap = new Map(initial.map((c) => [String(c.id), c])); + const pendingIds = new Set(pending.map((c) => String(c.id))); + + // Delete cues removed during this session + for (const c of initial) { + if (!pendingIds.has(String(c.id))) await window.api.deleteCuePoint(c.id); + } + // Add new (temp ID) cues and update modified existing cues + for (const c of pending) { + const sid = String(c.id); + if (sid.startsWith('tmp-')) { + await window.api.addCuePoint({ + trackId: track.id, + positionMs: c.position_ms, + label: c.label ?? '', + color: c.color ?? '#00b4d8', + hotCueIndex: c.hot_cue_index, + }); + } else if (initialMap.has(sid)) { + const orig = initialMap.get(sid); + if ( + orig.color !== c.color || + (orig.label ?? '') !== (c.label ?? '') || + orig.hot_cue_index !== c.hot_cue_index || + orig.enabled !== c.enabled + ) { + await window.api.updateCuePoint(c.id, { + color: c.color, + label: c.label ?? '', + hotCueIndex: c.hot_cue_index, + }); + } + } + } + + onApply(track.id, { beatgrid_offset: offset, bpm_override: bpmOverride }); + onClose(); + }; + + // Keep a ref to handleApply so the keyboard handler always calls the current + // version without needing it in the deps array (it closes over offset/bpmInput + // which are already tracked below). + const handleApplyRef = useRef(handleApply); + useLayoutEffect(() => { + handleApplyRef.current = handleApply; + }); + + // ── Keyboard ────────────────────────────────────────────────────────────── + useEffect(() => { + const handler = (e) => { + // Allow normal typing in inputs, but Space must still work for play/pause + // even when a button has focus — only block in real text inputs. + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + if (e.key === 'Escape') { + if (showCancelConfirmRef.current) { + setShowCancelConfirm(false); + return; + } + handleClose(); + return; + } + if (e.key === 'ArrowLeft') { + e.preventDefault(); + nudge(e.shiftKey ? -10 : -1); + } + if (e.key === 'ArrowRight') { + e.preventDefault(); + nudge(e.shiftKey ? 10 : 1); + } + if (e.key === ' ') { + e.preventDefault(); + // stopPropagation prevents PlayerContext's own Space handler (bubble phase) + // from immediately reversing the play/pause we just triggered. + e.stopPropagation(); + handlePlayPause(); + } + if (e.key === 't' || e.key === 'T') { + e.preventDefault(); + handleTap(); + } + if (e.key === '+' || e.key === '=') zoomIn(); + if (e.key === '-') zoomOut(); + if (e.key === 'Enter') handleApplyRef.current(); + }; + // Capture phase: fires before any focused element (buttons, etc.) so Space + // can't be consumed by a focused Cancel/Apply button before we handle it. + window.addEventListener('keydown', handler, true); + return () => window.removeEventListener('keydown', handler, true); + }, [handlePlayPause, handleClose, handleTap]); + + // ── Cue points callback ─────────────────────────────────────────────────── + // Called by CuePointsEditor whenever its local state changes (deferred mode). + // Keeps cuePointsRef in sync for the RAF renderer, and captures the initial + // snapshot on first call so Apply can diff against it. + const handleCuePointsChange = useCallback((pts) => { + const list = pts ?? []; + cuePointsRef.current = list; + if (initialCuesRef.current === null) initialCuesRef.current = list; + }, []); + + // Called by CuePointsEditor after auto-generate writes to DB — rebase the + // initial snapshot so Cancel after auto-generate doesn't undo it. + const handleCuesRebase = useCallback((pts) => { + initialCuesRef.current = pts ?? []; + }, []); + + const offsetLabel = offset === 0 ? '0 ms' : `${offset > 0 ? '+' : ''}${offset} ms`; + const analyzerBpm = track.bpm_override != null ? null : track.bpm; + const viewMs = ZOOM_LEVELS[zoomIdx]; + + return ( +
+
+ {/* Header */} +
+ + 🎛 Prepare Track + + {track.title} + {track.artist ? ` — ${track.artist}` : ''} + + + +
+ + {/* Detail waveform */} +
+ {waveformLoading &&
Loading waveform…
} + + {/* Zoom controls */} +
+ + + {viewMs >= 1000 ? `${viewMs / 1000}s` : `${viewMs}ms`} + + +
+ +
+ click / drag to seek · scroll to zoom · ← → nudge grid · +/− zoom +
+
+ + {/* Overview */} +
+ +
+ + {/* Controls */} +
+ {/* Grid offset */} +
+ Grid offset +
+ + + + {offsetLabel} + + + + {offset !== 0 && ( + + )} +
+
+ + {/* BPM + TAP inline */} +
+ BPM +
+ setBpmInput(e.target.value)} + placeholder={previewBpm > 0 ? String(Math.round(previewBpm * 10) / 10) : 'e.g. 128'} + onKeyDown={(e) => { + if (e.key === 'Enter') handleApply(); + }} + /> + {/* TAP button inline */} + + {/* Apply tapped BPM — slides in when a tap result is ready */} +
+ +
+ {analyzerBpm != null && analyzer: {analyzerBpm}} + {track.bpm_override != null && ( + override active + )} +
+
+
+ + {/* Cue Points */} +
+ +
+ + {/* Footer */} +
+ {showCancelConfirm ? ( +
+ Discard unsaved changes? + + + +
+ ) : ( + <> + + + + )} +
+
+
+ ); +} diff --git a/renderer/src/CuePointsEditor.css b/renderer/src/CuePointsEditor.css new file mode 100644 index 00000000..67765d9f --- /dev/null +++ b/renderer/src/CuePointsEditor.css @@ -0,0 +1,386 @@ +.cpe { + border-top: 1px solid #2a2a2a; + padding: 10px 12px 6px; +} + +.cpe__header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 8px; +} + +.cpe__title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.06em; + color: #888; +} + +.cpe__vis-toggles { + display: flex; + gap: 3px; + margin-right: 4px; +} + +.cpe__vis-btn { + font-size: 10px; + padding: 1px 6px; + border-radius: 3px; + border: 1px solid #383838; + background: #1a1a1a; + color: #555; + cursor: pointer; + transition: + background 0.12s, + color 0.12s; +} + +.cpe__vis-btn--on { + border-color: #555; + color: #aaa; + background: #2a2a2a; +} + +.cpe__vis-btn:hover { + color: #ccc; + border-color: #666; +} + +.cpe__actions { + display: flex; + gap: 4px; + align-items: center; +} + +/* Add dropdown wrapper */ +.cpe__add-wrap { + position: relative; +} + +.cpe__add-menu { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 200; + background: #1e1e1e; + border: 1px solid #444; + border-radius: 5px; + overflow: hidden; + min-width: 140px; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.6); +} + +.cpe__add-option { + display: block; + width: 100%; + padding: 6px 10px; + font-size: 11px; + text-align: left; + background: none; + border: none; + color: #ccc; + cursor: pointer; + transition: background 0.12s; +} + +.cpe__add-option:hover { + background: #2a2a2a; + color: #fff; +} + +.cpe__btn { + font-size: 11px; + padding: 2px 8px; + border-radius: 4px; + border: 1px solid #444; + background: #222; + color: #ccc; + cursor: pointer; + transition: background 0.15s; +} + +.cpe__btn:hover:not(:disabled) { + background: #333; + color: #fff; +} + +.cpe__btn:disabled { + opacity: 0.4; + cursor: default; +} + +.cpe__btn--add { + border-color: #00b4d8; + color: #00b4d8; +} + +.cpe__btn--add:hover:not(:disabled) { + background: #00b4d822; +} + +.cpe__btn--gen { + border-color: #ff9900; + color: #ff9900; +} + +.cpe__btn--gen:hover:not(:disabled) { + background: #ff990022; +} + +.cpe__empty { + font-size: 11px; + color: #555; + text-align: center; + padding: 8px 0 4px; +} + +.cpe__list { + display: flex; + flex-direction: column; + gap: 4px; +} + +.cpe__row { + display: flex; + align-items: center; + gap: 6px; + min-height: 26px; +} + +.cpe__badge-wrap { + position: relative; + flex-shrink: 0; +} + +.cpe__badge { + width: 20px; + height: 20px; + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; + font-size: 10px; + font-weight: 700; + color: #000; + cursor: pointer; + transition: opacity 0.12s; + user-select: none; +} + +.cpe__badge:hover { + opacity: 0.8; +} + +/* Type picker popup */ +.cpe__type-picker { + position: absolute; + top: calc(100% + 4px); + left: 0; + z-index: 200; + display: flex; + gap: 2px; + background: #1e1e1e; + border: 1px solid #444; + border-radius: 5px; + padding: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.6); +} + +.cpe__type-opt { + font-size: 10px; + font-weight: 700; + width: 20px; + height: 20px; + border-radius: 3px; + border: 1px solid #3a3a3a; + background: #2a2a2a; + color: #aaa; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: background 0.1s; + padding: 0; +} + +.cpe__type-opt:hover { + background: #444; + color: #fff; +} + +.cpe__type-opt--active { + border-color: #fff; +} + +.cpe__type-opt--mem { + color: #888; +} + +.cpe__time { + flex-shrink: 0; + font-size: 11px; + font-family: monospace; + color: #aaa; + background: none; + border: none; + cursor: pointer; + padding: 0 2px; + min-width: 54px; + text-align: left; +} + +.cpe__time:hover { + color: #fff; + text-decoration: underline; +} + +.cpe__label { + flex: 1; + font-size: 11px; + color: #ccc; + background: none; + border: none; + cursor: text; + text-align: left; + padding: 0 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.cpe__label:hover { + color: #fff; +} + +.cpe__label--placeholder { + color: #444; + font-style: italic; +} + +.cpe__label-input { + flex: 1; + font-size: 11px; + background: #1a1a1a; + border: 1px solid #00b4d8; + border-radius: 3px; + color: #fff; + padding: 1px 4px; + outline: none; +} + +.cpe__colors { + display: flex; + gap: 3px; + flex-shrink: 0; +} + +.cpe__color-dot { + width: 12px; + height: 12px; + border-radius: 50%; + border: 1px solid transparent; + padding: 0; + cursor: pointer; + transition: transform 0.1s; +} + +.cpe__color-dot:hover { + transform: scale(1.3); +} + +.cpe__color-dot--active { + border-color: #fff; + transform: scale(1.2); +} + +.cpe__row--disabled .cpe__badge, +.cpe__row--disabled .cpe__time, +.cpe__row--disabled .cpe__label { + opacity: 0.35; +} + +.cpe__export-toggle { + flex-shrink: 0; + font-size: 13px; + color: #3a8a3a; + background: none; + border: none; + cursor: pointer; + padding: 0 2px; + line-height: 1; + transition: color 0.12s; +} + +.cpe__export-toggle:hover { + color: #5cba5c; +} + +.cpe__export-toggle--off { + color: #555; +} + +.cpe__export-toggle--off:hover { + color: #888; +} + +.cpe__del { + flex-shrink: 0; + font-size: 10px; + color: #555; + background: none; + border: none; + cursor: pointer; + padding: 0 2px; + line-height: 1; +} + +.cpe__del:hover { + color: #ff4444; +} + +.cpe__del-confirm { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.cpe__confirm { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 8px; + background: #2a1a00; + border: 1px solid #5a3800; + border-radius: 4px; + margin-bottom: 8px; + font-size: 11px; + color: #ffb347; +} + +.cpe__confirm span { + flex: 1; +} + +.cpe__btn--danger { + border-color: #cc3333; + color: #ff6666; +} + +.cpe__btn--danger:hover:not(:disabled) { + background: #cc333322; + color: #ff4444; +} + +.cpe__btn--danger-subtle { + border-color: #553333; + color: #996666; +} + +.cpe__btn--danger-subtle:hover:not(:disabled) { + border-color: #cc3333; + color: #ff6666; + background: #cc333315; +} diff --git a/renderer/src/CuePointsEditor.jsx b/renderer/src/CuePointsEditor.jsx new file mode 100644 index 00000000..80943fa3 --- /dev/null +++ b/renderer/src/CuePointsEditor.jsx @@ -0,0 +1,562 @@ +import { useState, useEffect, useCallback, useRef } from 'react'; +import { usePlayer } from './PlayerContext.jsx'; +import './CuePointsEditor.css'; + +const COLOR_PALETTE = [ + '#ff6b35', // orange-red (Rekordbox hot cue A) + '#ff0000', // red + '#ff9900', // orange + '#ffff00', // yellow + '#00ff00', // green + '#00b4d8', // cyan (default) + '#0080ff', // blue + '#cc00ff', // violet +]; + +function msToTime(ms) { + if (ms == null) return '0:00.0'; + const totalSec = ms / 1000; + const m = Math.floor(totalSec / 60); + const s = Math.floor(totalSec % 60); + const tenth = Math.floor((totalSec % 1) * 10); + return `${m}:${String(s).padStart(2, '0')}.${tenth}`; +} + +const HOT_CUE_LABELS = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H']; + +// Visibility preference keys in localStorage +const LS_SHOW_HOT = 'cue-show-hot'; +const LS_SHOW_MEM = 'cue-show-mem'; + +function readVis(key) { + try { + return localStorage.getItem(key) !== 'false'; + } catch { + return true; + } +} + +function writeVis(key, val) { + try { + localStorage.setItem(key, String(val)); + window.dispatchEvent(new CustomEvent('cue-visibility-changed', { detail: { key, val } })); + } catch { + /* ignore */ + } +} + +export default function CuePointsEditor({ + trackId, + onCuePointsChange, + deferred = false, + onRebase, +}) { + const { currentTime } = usePlayer() ?? {}; + const [cuePoints, setCuePoints] = useState([]); + const [loading, setLoading] = useState(false); + const [generating, setGenerating] = useState(false); + const [confirmGen, setConfirmGen] = useState(false); + const [confirmDeleteId, setConfirmDeleteId] = useState(null); + const [confirmDeleteAll, setConfirmDeleteAll] = useState(false); + const [editingId, setEditingId] = useState(null); + const [editLabel, setEditLabel] = useState(''); + const [typePickerId, setTypePickerId] = useState(null); // cue id whose type picker is open + const [showAddMenu, setShowAddMenu] = useState(false); + const addMenuRef = useRef(null); + + // Close type picker on outside click + useEffect(() => { + if (typePickerId === null) return; + const close = (e) => { + if (!e.target.closest('.cpe__badge-wrap')) setTypePickerId(null); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [typePickerId]); + + // Close add-type dropdown on outside click + useEffect(() => { + if (!showAddMenu) return; + const close = (e) => { + if (!addMenuRef.current?.contains(e.target)) setShowAddMenu(false); + }; + document.addEventListener('mousedown', close); + return () => document.removeEventListener('mousedown', close); + }, [showAddMenu]); + + // Visibility toggles — persisted in localStorage, shared with PlayerBar via custom event + const [showHot, setShowHot] = useState(() => readVis(LS_SHOW_HOT)); + const [showMem, setShowMem] = useState(() => readVis(LS_SHOW_MEM)); + + // Keep in sync if another component changes visibility + useEffect(() => { + const handler = ({ detail: { key, val } }) => { + if (key === LS_SHOW_HOT) setShowHot(val); + if (key === LS_SHOW_MEM) setShowMem(val); + }; + window.addEventListener('cue-visibility-changed', handler); + return () => window.removeEventListener('cue-visibility-changed', handler); + }, []); + + const toggleShowHot = () => { + const next = !showHot; + setShowHot(next); + writeVis(LS_SHOW_HOT, next); + }; + const toggleShowMem = () => { + const next = !showMem; + setShowMem(next); + writeVis(LS_SHOW_MEM, next); + }; + + const revRef = useRef(0); + const [rev, setRev] = useState(0); + const isLoadedRef = useRef(false); // true once initial DB fetch resolves + const reload = useCallback(() => { + revRef.current += 1; + setRev(revRef.current); + window.dispatchEvent(new CustomEvent('cue-points-updated', { detail: { trackId } })); + }, [trackId]); + + // In deferred mode, notify parent whenever local cue state changes — + // but only after the initial DB load (isLoadedRef prevents a spurious [] + // notification before the real cues arrive). + useEffect(() => { + if (!deferred || !isLoadedRef.current) return; + onCuePointsChange?.(cuePoints); + }, [deferred, cuePoints, onCuePointsChange]); + + // Listen for auto-cue IPC events from main process (e.g. auto-generate on import). + // Skipped in deferred mode — pending state must not be overwritten by DB reads. + useEffect(() => { + if (deferred) return; + const unsub = window.api.onCuePointsUpdated(({ trackId: updatedId }) => { + if (updatedId === trackId) reload(); + }); + return unsub; + }, [trackId, reload, deferred]); + + useEffect(() => { + if (!trackId) return; + let alive = true; + window.api.getCuePoints(trackId).then((pts) => { + if (!alive) return; + isLoadedRef.current = true; + setCuePoints(pts); + // Non-deferred: notify immediately; deferred: the cuePoints useEffect above fires. + if (!deferred) onCuePointsChange?.(pts); + }); + return () => { + alive = false; + }; + }, [trackId, rev, onCuePointsChange, deferred]); + + const handleAddMemoryCue = async () => { + if (!trackId) return; + setShowAddMenu(false); + const posMs = Math.round((currentTime ?? 0) * 1000); + if (deferred) { + setCuePoints((prev) => + [ + ...prev, + { + id: `tmp-${Date.now()}`, + track_id: trackId, + position_ms: posMs, + label: '', + color: '#00b4d8', + hot_cue_index: -1, + enabled: 1, + }, + ].sort((a, b) => a.position_ms - b.position_ms) + ); + } else { + setLoading(true); + await window.api.addCuePoint({ + trackId, + positionMs: posMs, + label: '', + color: '#00b4d8', + hotCueIndex: -1, + }); + reload(); + setLoading(false); + } + }; + + const handleAddHotCue = async () => { + if (!trackId) return; + setShowAddMenu(false); + const usedIndices = new Set( + cuePoints.filter((c) => c.hot_cue_index >= 0).map((c) => c.hot_cue_index) + ); + const nextIndex = [0, 1, 2, 3, 4, 5, 6, 7].find((i) => !usedIndices.has(i)); + if (nextIndex === undefined) return; + const posMs = Math.round((currentTime ?? 0) * 1000); + const color = COLOR_PALETTE[nextIndex % COLOR_PALETTE.length]; + if (deferred) { + setCuePoints((prev) => + [ + ...prev, + { + id: `tmp-${Date.now()}`, + track_id: trackId, + position_ms: posMs, + label: '', + color, + hot_cue_index: nextIndex, + enabled: 1, + }, + ].sort((a, b) => a.position_ms - b.position_ms) + ); + } else { + setLoading(true); + await window.api.addCuePoint({ + trackId, + positionMs: posMs, + label: '', + color, + hotCueIndex: nextIndex, + }); + reload(); + setLoading(false); + } + }; + + const handleGenerateClick = () => { + if (!trackId) return; + if (cuePoints.length > 0) { + setConfirmGen(true); + } else { + handleGenerate(); + } + }; + + const handleGenerate = async () => { + setConfirmGen(false); + if (!trackId) return; + setGenerating(true); + await window.api.generateCuePoints(trackId); + if (deferred) { + // Auto-generate writes to DB; reload into local state and rebase the + // initial snapshot so Cancel after auto-generate keeps the generated cues. + const pts = await window.api.getCuePoints(trackId); + setCuePoints(pts ?? []); + onRebase?.(pts ?? []); + } else { + reload(); + } + setGenerating(false); + }; + + const handleDelete = (id) => { + setConfirmDeleteId(id); + }; + + const confirmDelete = async () => { + if (!confirmDeleteId) return; + if (deferred) { + setCuePoints((prev) => prev.filter((c) => c.id !== confirmDeleteId)); + setConfirmDeleteId(null); + } else { + await window.api.deleteCuePoint(confirmDeleteId); + setConfirmDeleteId(null); + reload(); + } + }; + + const handleDeleteAll = () => setConfirmDeleteAll(true); + + const confirmDeleteAllCues = async () => { + setConfirmDeleteAll(false); + if (deferred) { + setCuePoints([]); + } else { + for (const cue of cuePoints) { + await window.api.deleteCuePoint(cue.id); + } + reload(); + } + }; + + const handleColorChange = async (id, color) => { + if (deferred) { + setCuePoints((prev) => prev.map((c) => (c.id === id ? { ...c, color } : c))); + } else { + await window.api.updateCuePoint(id, { color }); + reload(); + } + }; + + const handleLabelSave = async (id) => { + if (deferred) { + setCuePoints((prev) => prev.map((c) => (c.id === id ? { ...c, label: editLabel } : c))); + setEditingId(null); + } else { + await window.api.updateCuePoint(id, { label: editLabel }); + setEditingId(null); + reload(); + } + }; + + const startEdit = (cue) => { + setEditingId(cue.id); + setEditLabel(cue.label ?? ''); + }; + + const handleToggleEnabled = async (id, currentEnabled) => { + const next = currentEnabled === 0 ? 1 : 0; + if (deferred) { + setCuePoints((prev) => prev.map((c) => (c.id === id ? { ...c, enabled: next } : c))); + } else { + await window.api.updateCuePoint(id, { enabled: next }); + reload(); + } + }; + + // Change a cue's type: -1 = memory, 0-7 = hot cue A-H + const handleTypeChange = async (id, hotCueIndex) => { + setTypePickerId(null); + if (deferred) { + setCuePoints((prev) => + prev.map((c) => (c.id === id ? { ...c, hot_cue_index: hotCueIndex } : c)) + ); + } else { + await window.api.updateCuePoint(id, { hotCueIndex }); + reload(); + } + }; + + const { seek } = usePlayer() ?? {}; + + // Apply visibility filter for the list + const visibleCues = cuePoints.filter((c) => { + if (c.hot_cue_index >= 0) return showHot; + return showMem; + }); + + return ( +
+
+ Cue Points +
+ + +
+
+
+ + {showAddMenu && ( +
+ + +
+ )} +
+ + {cuePoints.length > 0 && ( + + )} +
+
+ + {confirmDeleteAll && ( +
+ + Delete all {cuePoints.length} cue point{cuePoints.length !== 1 ? 's' : ''}? + + + +
+ )} + + {confirmGen && ( +
+ + Replace {cuePoints.length} existing cue point{cuePoints.length !== 1 ? 's' : ''}? + + + +
+ )} + + {cuePoints.length === 0 ? ( +
No cue points — add one or use ⚡ Auto
+ ) : visibleCues.length === 0 ? ( +
All cue points hidden — toggle Hot / Mem above
+ ) : ( +
+ {visibleCues.map((cue) => ( +
+ {/* Type badge — click to open type picker */} +
+
= 0 + ? `Hot cue ${HOT_CUE_LABELS[cue.hot_cue_index]} — click to change type` + : 'Memory cue — click to change type' + } + onClick={() => setTypePickerId(typePickerId === cue.id ? null : cue.id)} + > + {cue.hot_cue_index >= 0 ? HOT_CUE_LABELS[cue.hot_cue_index] : '●'} +
+ + {typePickerId === cue.id && ( +
+ + {HOT_CUE_LABELS.map((label, i) => ( + + ))} +
+ )} +
+ + {/* Time — click to seek */} + + + {/* Label — click to edit */} + {editingId === cue.id ? ( + setEditLabel(e.target.value)} + onBlur={() => handleLabelSave(cue.id)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleLabelSave(cue.id); + if (e.key === 'Escape') setEditingId(null); + }} + /> + ) : ( + + )} + + {/* Color picker */} +
+ {COLOR_PALETTE.map((c) => ( +
+ + {/* Export toggle */} + + + {/* Delete */} + {confirmDeleteId === cue.id ? ( +
+ + +
+ ) : ( + + )} +
+ ))} +
+ )} +
+ ); +} diff --git a/renderer/src/DepsOverlay.css b/renderer/src/DepsOverlay.css new file mode 100644 index 00000000..a12af956 --- /dev/null +++ b/renderer/src/DepsOverlay.css @@ -0,0 +1,117 @@ +.deps-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 9999; +} + +.deps-box { + background: #1e1e2e; + border: 1px solid #3a3a5c; + border-radius: 10px; + padding: 28px 36px; + min-width: 360px; + max-width: 480px; + display: flex; + flex-direction: column; + gap: 12px; +} + +.deps-title { + font-size: 16px; + font-weight: 600; + color: #cdd6f4; +} + +.deps-steps { + display: flex; + flex-direction: column; + gap: 6px; +} + +.deps-step { + display: flex; + align-items: baseline; + gap: 8px; + font-size: 13px; + color: #585b70; + transition: color 0.2s; +} + +.deps-step.active { + color: #cdd6f4; +} + +.deps-step.done { + color: #a6e3a1; +} + +.deps-step-icon { + width: 14px; + text-align: center; + flex-shrink: 0; + font-size: 11px; +} + +.deps-step-label { + flex-shrink: 0; +} + +.deps-step-meta { + font-size: 11px; + color: #6c7086; + margin-left: auto; + white-space: nowrap; +} + +.deps-msg { + font-size: 12px; + color: #6c7086; +} + +.deps-bar-track { + height: 6px; + background: #313244; + border-radius: 3px; + overflow: hidden; +} + +.deps-bar-fill { + height: 100%; + background: #89b4fa; + border-radius: 3px; + transition: width 0.3s ease; +} + +.deps-overall { + font-size: 11px; + color: #585b70; + text-align: right; +} + +.deps-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + font-size: 12px; + color: #f38ba8; +} + +.deps-retry-btn { + background: #313244; + border: 1px solid #45475a; + border-radius: 6px; + color: #cdd6f4; + font-size: 12px; + padding: 4px 12px; + cursor: pointer; + flex-shrink: 0; +} + +.deps-retry-btn:hover { + background: #45475a; +} diff --git a/renderer/src/DepsOverlay.jsx b/renderer/src/DepsOverlay.jsx new file mode 100644 index 00000000..56ea7aa7 --- /dev/null +++ b/renderer/src/DepsOverlay.jsx @@ -0,0 +1,120 @@ +import './DepsOverlay.css'; + +const KNOWN_STEPS = [ + { id: 'ffmpeg', label: 'FFmpeg' }, + { id: 'analyzer', label: 'mixxx-analyzer' }, + { id: 'ytdlp', label: 'yt-dlp' }, + { id: 'tidal', label: 'tidal-dl-ng' }, +]; + +function fmt(bytes) { + if (bytes <= 0) return '0 B'; + const units = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(1024)); + return `${(bytes / Math.pow(1024, i)).toFixed(i > 1 ? 1 : 0)} ${units[i]}`; +} + +function fmtSpeed(bps) { + return bps > 0 ? `${fmt(bps)}/s` : null; +} + +function fmtEta(sec) { + if (sec <= 0) return null; + if (sec < 60) return `~${Math.ceil(sec)}s`; + return `~${Math.ceil(sec / 60)}m`; +} + +export function DepsOverlay({ progress, onRetry }) { + if (!progress) return null; + + const { + stepId, + stepIndex, + stepTotal, + stepPct, + bytesDownloaded, + bytesTotal, + bytesPerSec, + etaSec, + msg, + pct, + error, + } = progress; + + // Build step list from known steps filtered to stepTotal count. + // If stepId is unknown (e.g. old-format payload), fall back to simple view. + const hasSteps = stepTotal > 0 && stepId !== undefined; + + // Derive which known steps are active in this run (stepTotal of them) + const activeSteps = KNOWN_STEPS.filter((s) => { + // Show step if it matches a step we've seen or will see + const idx = KNOWN_STEPS.indexOf(s); + return idx < stepTotal || s.id === stepId; + }).slice(0, stepTotal); + + const currentIdx = activeSteps.findIndex((s) => s.id === stepId); + const isError = pct === -1 || !!error; + const isDone = pct === 100 && !error; + + const speed = fmtSpeed(bytesPerSec); + const eta = fmtEta(etaSec); + const hasBytes = bytesTotal > 0 && bytesDownloaded > 0; + + return ( +
+
+
First-time setup
+ + {hasSteps && ( +
+ {activeSteps.map((s, i) => { + const isActive = s.id === stepId && !isDone; + const isDoneStep = i < currentIdx || isDone; + return ( +
+ {isDoneStep ? '✓' : isActive ? '↓' : '·'} + {s.label} + {isActive && hasBytes && ( + + {fmt(bytesDownloaded)} / {fmt(bytesTotal)} + {speed && ` · ${speed}`} + {eta && ` · ${eta}`} + + )} +
+ ); + })} +
+ )} + +
{msg}
+ + {!isError && (stepPct >= 0 || pct >= 0) && ( +
+
= 0 ? stepPct : pct}%` }} /> +
+ )} + + {hasSteps && stepTotal > 1 && !isDone && !isError && ( +
+ Step {stepIndex} of {stepTotal} +
+ )} + + {isError && ( +
+ {error || msg} + {onRetry && ( + + )} +
+ )} +
+
+ ); +} diff --git a/renderer/src/DownloadContext.jsx b/renderer/src/DownloadContext.jsx new file mode 100644 index 00000000..09e5fce7 --- /dev/null +++ b/renderer/src/DownloadContext.jsx @@ -0,0 +1,194 @@ +import { createContext, useContext, useState, useEffect, useCallback } from 'react'; + +const DownloadContext = createContext(null); + +export function DownloadProvider({ children }) { + // ── shared ────────────────────────────────────────────────────────────────── + const [url, setUrl] = useState(''); + const [downloadHistory, setDownloadHistory] = useState([]); + const [step, setStep] = useState('url'); // 'url' | 'select' | 'download' + + // ── step: url ─────────────────────────────────────────────────────────────── + const [fetching, setFetching] = useState(false); + const [fetchError, setFetchError] = useState(null); + const [checkProgress, setCheckProgress] = useState(null); // { checked, total } | null + + // ── step: select ───────────────────────────────────────────────────────────── + const [playlistInfo, setPlaylistInfo] = useState(null); + const [selectedIndices, setSelectedIndices] = useState(new Set()); + // libraryMap: Map — tracks already in the library + const [libraryMap, setLibraryMap] = useState(new Map()); + // linkIndices: Set — tracks to link (in library, user wants to add to playlist) + const [linkIndices, setLinkIndices] = useState(new Set()); + // playlistMemberUrls: Set — tracks already in the TARGET playlist + const [playlistMemberUrls, setPlaylistMemberUrls] = useState(new Set()); + 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); + const [trackStatuses, setTrackStatuses] = useState([]); + const [result, setResult] = useState(null); + + // Subscribe to IPC events once — context never unmounts + useEffect(() => { + const unsubProgress = window.api.onYtDlpProgress((data) => { + if (data === null) { + setLoading(false); + setProgress(null); + } else { + setProgress(data); + } + }); + + const unsubCheckProgress = window.api.onYtDlpCheckProgress((data) => { + setCheckProgress(data); // null when done + }); + + // Fires once the flat-playlist fetch is done — populate entries before availability check + const unsubEntriesReady = window.api.onYtDlpEntriesReady((entries) => { + setPlaylistInfo((prev) => + prev ? { ...prev, entries } : { type: 'playlist', title: null, entries } + ); + }); + + // Fires after each individual entry is checked — flip unavailable flag in-place + const unsubEntryChecked = window.api.onYtDlpEntryChecked(({ id, unavailable }) => { + setPlaylistInfo((prev) => { + if (!prev?.entries) return prev; + const updated = prev.entries.map((e) => + e.id === id ? { ...e, unavailable, checked: true } : e + ); + return { ...prev, entries: updated }; + }); + }); + + const unsubTrack = window.api.onYtDlpTrackUpdate((update) => { + if (update.type === 'init') { + setTrackStatuses((prev) => { + if (prev.length >= update.total) return prev; + return Array.from({ length: update.total }, (_, i) => ({ + index: i, + title: `Track ${i + 1}`, + url: '', + status: 'pending', + })); + }); + } else if (update.type === 'unavailable') { + setTrackStatuses((prev) => + prev.map((t) => + t.title?.includes(update.videoId) || t.url?.includes(update.videoId) + ? { ...t, status: 'failed', error: update.reason } + : t + ) + ); + } 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}`, url: '', status: 'pending' }); + } + next[i] = { ...next[i], ...update }; + return next; + }); + } + }); + + return () => { + unsubProgress(); + unsubCheckProgress(); + unsubEntriesReady(); + unsubEntryChecked(); + unsubTrack(); + }; + }, []); + + // ── derived ────────────────────────────────────────────────────────────────── + const completedCount = trackStatuses.filter( + (s) => s.status === 'done' || s.status === 'failed' + ).length; + const sbTotal = Math.max(trackStatuses.length, progress?.overallTotal ?? 0, 1); + // Show how many tracks have fully completed (done/failed), starting at 0. + const sbCurrent = completedCount; + const sidebarProgress = loading + ? { + current: sbCurrent, + total: sbTotal, + pct: progress?.pct ?? 0, + msg: progress?.msg ?? 'Downloading…', + } + : null; + + const resetToUrl = useCallback(() => { + setStep('url'); + setPlaylistInfo(null); + setSelectedIndices(new Set()); + setLibraryMap(new Map()); + setLinkIndices(new Set()); + 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 useDownload() { + const ctx = useContext(DownloadContext); + if (!ctx) throw new Error('useDownload must be used inside DownloadProvider'); + return ctx; +} diff --git a/renderer/src/DownloadView.css b/renderer/src/DownloadView.css index f0bbf713..f414bd68 100644 --- a/renderer/src/DownloadView.css +++ b/renderer/src/DownloadView.css @@ -82,6 +82,20 @@ cursor: not-allowed; } +.dl-btn--cancel { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 10px 12px; + color: rgba(255, 255, 255, 0.6); + font-weight: 400; +} + +.dl-btn--cancel:hover:not(:disabled) { + opacity: 1; + color: #fff; + border-color: rgba(255, 255, 255, 0.5); +} + /* ── Progress ────────────────────────────────────────────────────────────── */ .dl-progress { @@ -152,6 +166,12 @@ opacity: 0.9; } +.dl-result-unavailable-note { + font-size: 12px; + color: #e57373; + opacity: 0.9; +} + /* ── History ─────────────────────────────────────────────────────────────── */ .dl-history { @@ -159,6 +179,55 @@ max-width: 640px; } +.dl-checking-list { + margin-top: 20px; + max-width: 640px; +} + +.dl-checking-list-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.08em; + color: var(--text-secondary, #888); + margin-bottom: 8px; +} + +.dl-check-item { + display: grid; + grid-template-columns: 18px 32px 1fr auto; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 13px; + color: var(--text-secondary, #bbb); + border-bottom: 1px solid var(--border, #1e1e1e); + transition: color 0.1s; +} + +.dl-check-item--ok { + color: var(--text-primary, #e0e0e0); +} + +.dl-check-item--unavailable { + color: #888; + text-decoration: line-through; +} + +.dl-check-item-icon { + text-align: center; + font-size: 12px; + color: #666; +} + +.dl-check-item--ok .dl-check-item-icon { + color: #1db954; +} + +.dl-check-item--unavailable .dl-check-item-icon { + color: #e04444; +} + .dl-history-title { font-size: 11px; font-weight: 600; @@ -433,11 +502,37 @@ .dl-select-toolbar { display: flex; align-items: center; - justify-content: space-between; + flex-wrap: wrap; + gap: 6px; padding: 6px 0; border-bottom: 1px solid var(--border, #2a2a2a); } +.dl-select-filter-btns { + display: flex; + gap: 6px; +} + +.dl-filter-btn { + padding: 3px 10px; + border-radius: 4px; + border: 1px solid #444; + background: transparent; + color: #aaa; + font-size: 12px; + cursor: pointer; + transition: + background 0.12s, + color 0.12s, + border-color 0.12s; +} + +.dl-filter-btn:hover { + background: #2a2a2a; + color: #e0e0e0; + border-color: #5865f2; +} + .dl-select-all-label { display: flex; align-items: center; @@ -455,6 +550,7 @@ .dl-select-selected-count { font-size: 12px; color: var(--text-secondary, #666); + margin-left: auto; } .dl-select-list { @@ -468,7 +564,7 @@ .dl-select-item { display: grid; - grid-template-columns: auto 32px 1fr auto; + grid-template-columns: auto 32px 1fr auto auto; align-items: center; gap: 8px; padding: 7px 12px; @@ -514,6 +610,51 @@ flex-shrink: 0; } +.dl-select-item--dupe { + opacity: 0.55; +} + +.dl-select-item--unavailable { + opacity: 0.45; + cursor: default; + pointer-events: none; +} + +.dl-select-item--unavailable .dl-select-item-title { + text-decoration: line-through; + color: var(--text-secondary, #666); +} + +.dl-select-item-dupe-badge { + font-size: 11px; + color: #4caf50; + white-space: nowrap; + flex-shrink: 0; +} + +.dl-select-item-badge--playlist { + color: #7c9fd4; +} + +.dl-select-item-unavailable-badge { + font-size: 11px; + color: #e57373; + white-space: nowrap; + flex-shrink: 0; +} + +.dl-select-unavailable-note { + padding: 8px 12px; + font-size: 12px; + color: #e57373; + opacity: 0.8; + border-top: 1px solid var(--border, #1e1e1e); +} + +.dl-select-count-unavailable { + color: #e57373; +} + .dl-select-footer { padding-top: 4px; display: flex; diff --git a/renderer/src/DownloadView.jsx b/renderer/src/DownloadView.jsx index ba354d8b..57d1f629 100644 --- a/renderer/src/DownloadView.jsx +++ b/renderer/src/DownloadView.jsx @@ -1,4 +1,5 @@ -import { useState, useEffect, useRef, useCallback } from 'react'; +import { useRef, useCallback } from 'react'; +import { useDownload } from './DownloadContext.jsx'; import './DownloadView.css'; const SUPPORTED_SOURCES = [ @@ -23,6 +24,7 @@ const STATUS_ICON = { pending: { icon: '□', label: 'Pending' }, downloading: { icon: '⋯', label: 'Downloading' }, importing: { icon: '↓', label: 'Importing' }, + linking: { icon: '⊟', label: 'Linking to playlist' }, done: { icon: '✓', label: 'Done' }, failed: { icon: '✗', label: 'Failed' }, }; @@ -37,70 +39,52 @@ function fmtDuration(secs) { } export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { - // ── shared state ───────────────────────────────────────────────────────── - const [url, setUrl] = useState(''); - const [history, setHistory] = useState([]); - const [step, setStep] = useState('url'); // 'url' | 'select' | 'download' - - // ── step: url ───────────────────────────────────────────────────────────── - const [fetching, setFetching] = useState(false); - const [fetchError, setFetchError] = useState(null); - const inputRef = useRef(null); - - // ── step: select ────────────────────────────────────────────────────────── - const [playlistInfo, setPlaylistInfo] = useState(null); // { type, title, entries } - const [selectedIndices, setSelectedIndices] = useState(new Set()); - const [playlists, setPlaylists] = useState([]); // existing playlists for combobox - const [targetPlaylistId, setTargetPlaylistId] = useState(null); // null = create new - const [targetPlaylistName, setTargetPlaylistName] = useState(''); - - // ── step: download ──────────────────────────────────────────────────────── - const [loading, setLoading] = useState(false); - const [progress, setProgress] = useState(null); - const [trackStatuses, setTrackStatuses] = useState([]); - const [result, setResult] = useState(null); - - useEffect(() => { - inputRef.current?.focus(); - - const unsubProgress = window.api.onYtDlpProgress((data) => { - if (data === null) { - setLoading(false); - setProgress(null); - } else setProgress(data); - }); + const { + url, + setUrl, + downloadHistory, + setDownloadHistory, + step, + setStep, + fetching, + setFetching, + fetchError, + setFetchError, + checkProgress, + setCheckProgress, + playlistInfo, + setPlaylistInfo, + selectedIndices, + setSelectedIndices, + libraryMap, + setLibraryMap, + linkIndices, + setLinkIndices, + playlistMemberUrls, + setPlaylistMemberUrls, + playlists, + setPlaylists, + targetPlaylistId, + setTargetPlaylistId, + targetPlaylistName, + setTargetPlaylistName, + setLoading, + progress, + setProgress, + trackStatuses, + setTrackStatuses, + result, + setResult, + } = useDownload(); - const unsubTrack = window.api.onYtDlpTrackUpdate((update) => { - if (update.type === 'init') { - // Only use 'init' to populate if the list isn't already pre-populated from step 2 - setTrackStatuses((prev) => { - if (prev.length >= update.total) return prev; - return Array.from({ length: update.total }, (_, i) => ({ - index: i, - title: `Track ${i + 1}`, - url: '', - 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}`, url: '', status: 'pending' }); - } - next[i] = { ...next[i], ...update }; - return next; - }); - } - }); + const inputRef = useRef(null); - return () => { - unsubProgress(); - unsubTrack(); - }; - }, []); + // Cancel an in-progress fetch — the IPC call will still complete but result is ignored + const handleCancelFetch = useCallback(() => { + setFetching(false); + setFetchError(null); + setCheckProgress(null); + }, [setFetching, setFetchError, setCheckProgress]); // ── handlers ────────────────────────────────────────────────────────────── @@ -129,7 +113,10 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { e.preventDefault(); const trimmed = url.trim(); if (!trimmed || fetching) return; - console.log('[DownloadView] handleLoad start, url=', trimmed); + // Auto-prepend https:// if no protocol is present + const normalizedUrl = /^https?:\/\//i.test(trimmed) ? trimmed : `https://${trimmed}`; + console.log('[DownloadView] handleLoad start, url=', normalizedUrl); + if (normalizedUrl !== trimmed) setUrl(normalizedUrl); // update input to show normalised form setFetching(true); setFetchError(null); try { @@ -138,14 +125,44 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { 'ytDlpFetchInfo is not available — please restart the app to load the latest preload changes.' ); } - const res = await window.api.ytDlpFetchInfo(trimmed); + const res = await window.api.ytDlpFetchInfo(normalizedUrl); console.log('[DownloadView] ytDlpFetchInfo result:', res); if (!res.ok) { setFetchError(res.error); return; } setPlaylistInfo(res); - setSelectedIndices(new Set(res.entries.map((_, i) => i))); + + // Check which entries are already in the library + let newLibraryMap = new Map(); + try { + const entryChecks = res.entries + .filter((e) => e.url || e.id) + .map((e) => ({ url: e.url, id: e.id })); + if (entryChecks.length > 0) { + const found = await window.api.checkDuplicateUrls(entryChecks); + // found = [{url, trackId}] + for (const { url: u, trackId } of found) { + if (u) newLibraryMap.set(u, trackId); + } + } + } catch { + // non-fatal + } + setLibraryMap(newLibraryMap); + + // Pre-select non-library entries; pre-link library entries that aren't in the target playlist + setSelectedIndices( + new Set( + res.entries.filter((e) => !e.unavailable && !newLibraryMap.has(e.url)).map((e) => e.index) + ) + ); + setLinkIndices( + new Set( + res.entries.filter((e) => !e.unavailable && newLibraryMap.has(e.url)).map((e) => e.index) + ) + ); + // Fetch existing playlists for the combobox let existingPlaylists = []; try { @@ -159,13 +176,42 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { const match = existingPlaylists.find( (p) => p.name.toLowerCase() === detectedTitle.toLowerCase() ); + let matchedPlaylistId = null; if (match) { + matchedPlaylistId = match.id; setTargetPlaylistId(match.id); setTargetPlaylistName(''); } else { setTargetPlaylistId(null); setTargetPlaylistName(detectedTitle); } + + // Check which library entries are already in the matched playlist + if (matchedPlaylistId && newLibraryMap.size > 0) { + try { + const memberRows = await window.api.getPlaylistSourceUrls(matchedPlaylistId); + const memberTrackIds = new Set(memberRows.map((r) => r.trackId)); + const inPlaylist = new Set( + [...newLibraryMap.entries()] + .filter(([, tid]) => memberTrackIds.has(tid)) + .map(([url]) => url) + ); + setPlaylistMemberUrls(inPlaylist); + // Remove "already in playlist" entries from linkIndices + setLinkIndices((prev) => { + const next = new Set(prev); + for (const entry of res.entries) { + if (inPlaylist.has(entry.url)) next.delete(entry.index); + } + return next; + }); + } catch { + setPlaylistMemberUrls(new Set()); + } + } else { + setPlaylistMemberUrls(new Set()); + } + setStep('select'); } catch (err) { console.error('[DownloadView] handleLoad error:', err); @@ -175,83 +221,220 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { } }; + // When the target playlist changes, re-check which library entries are already in it + const handleTargetPlaylistChange = useCallback( + async (newPlaylistId) => { + setTargetPlaylistId(newPlaylistId); + if (!newPlaylistId || libraryMap.size === 0) { + setPlaylistMemberUrls(new Set()); + // Restore all library entries to linkIndices + if (playlistInfo) { + setLinkIndices( + new Set( + playlistInfo.entries + .filter((e) => !e.unavailable && 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( + [...libraryMap.entries()].filter(([, tid]) => memberTrackIds.has(tid)).map(([url]) => url) + ); + setPlaylistMemberUrls(inPlaylist); + if (playlistInfo) { + setLinkIndices( + new Set( + playlistInfo.entries + .filter((e) => !e.unavailable && libraryMap.has(e.url) && !inPlaylist.has(e.url)) + .map((e) => e.index) + ) + ); + } + } catch { + setPlaylistMemberUrls(new Set()); + } + }, + [libraryMap, playlistInfo, setLinkIndices, setPlaylistMemberUrls, setTargetPlaylistId] + ); + // Step 2 → 1: go back const handleBack = useCallback(() => { setStep('url'); setPlaylistInfo(null); + setLibraryMap(new Map()); + setLinkIndices(new Set()); + setPlaylistMemberUrls(new Set()); setFetchError(null); - }, []); - - // Step 2: toggle a single entry - const handleToggleEntry = useCallback((index) => { - setSelectedIndices((prev) => { - const next = new Set(prev); - if (next.has(index)) next.delete(index); - else next.add(index); - return next; - }); - }, []); + }, [ + setFetchError, + setLibraryMap, + setLinkIndices, + setPlaylistInfo, + setPlaylistMemberUrls, + setStep, + ]); + + // Step 2: toggle a single entry — 3-state cycle for library entries + // library + not-in-playlist: indeterminate (link) → unchecked → indeterminate + // normal (not in library): checked → unchecked → checked + const handleToggleEntry = useCallback( + (index, entry) => { + const isInLibrary = libraryMap.has(entry.url); + const isInPlaylist = playlistMemberUrls.has(entry.url); + if (isInLibrary && !isInPlaylist) { + // 3-state: link ↔ skip + 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, playlistMemberUrls, setLinkIndices, setSelectedIndices] + ); - // Step 2: select / deselect all + // Step 2: select / deselect all — toggles download entries; link entries follow separately const handleToggleAll = useCallback(() => { - setSelectedIndices((prev) => - prev.size === playlistInfo.entries.length - ? new Set() - : new Set(playlistInfo.entries.map((_, i) => i)) + if (!playlistInfo) return; + const downloadable = playlistInfo.entries.filter( + (e) => !e.unavailable && !libraryMap.has(e.url) + ); + const linkable = playlistInfo.entries.filter( + (e) => !e.unavailable && libraryMap.has(e.url) && !playlistMemberUrls.has(e.url) ); - }, [playlistInfo]); + const allDownloadSelected = downloadable.every((e) => selectedIndices.has(e.index)); + const allLinkSelected = linkable.every((e) => linkIndices.has(e.index)); + const allSelected = allDownloadSelected && allLinkSelected; + if (allSelected) { + 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, + ]); // Step 2 → 3: start download const handleDownload = async () => { - if (selectedIndices.size === 0) return; + if (selectedIndices.size === 0 && linkIndices.size === 0) return; - // Pre-populate the track list with real titles from the selection, in playlist order - const selectedEntries = playlistInfo.entries + // Entries to actually download via yt-dlp (not already in library) + const downloadEntries = playlistInfo.entries .filter((e) => selectedIndices.has(e.index)) .sort((a, b) => a.index - b.index); - setStep('download'); - setLoading(true); - setResult(null); - setTrackStatuses( - selectedEntries.map((e, i) => ({ + // Entries to link (already in library, user wants to add to playlist) + const linkEntries = playlistInfo.entries + .filter((e) => linkIndices.has(e.index)) + .sort((a, b) => a.index - b.index); + + // Combined display list: downloads first, then links + const allDisplayEntries = [ + ...downloadEntries.map((e, i) => ({ index: i, title: e.title, url: e.url, status: 'pending', - })) - ); + })), + ...linkEntries.map((e, i) => ({ + index: downloadEntries.length + i, + title: e.title, + url: e.url, + status: 'linking', + })), + ]; + + setStep('download'); + setLoading(true); + setResult(null); + setTrackStatuses(allDisplayEntries); setProgress({ msg: 'Starting download…', pct: 0, trackPct: 0, overallCurrent: 1, - overallTotal: selectedEntries.length, + overallTotal: downloadEntries.length, }); - // Build --playlist-items string (1-based) only when a subset is selected - let playlistItems = null; - if (playlistInfo.type === 'playlist' && selectedIndices.size < playlistInfo.entries.length) { - playlistItems = Array.from(selectedIndices) - .sort((a, b) => a - b) - .map((i) => i + 1) - .join(','); + // Determine effective playlist ID for linking (may be created by the download step) + let effectivePlaylistId = targetPlaylistId; + + if (downloadEntries.length > 0) { + // Always pass --playlist-items when user excluded some tracks or there are unavailable ones + let playlistItems = null; + const downloadOnlyEntries = downloadEntries.length; + const totalAvailable = availableEntries.filter((e) => !libraryMap.has(e.url)).length; + if ( + playlistInfo.type === 'playlist' && + (downloadOnlyEntries < totalAvailable || unavailableCount > 0 || libraryMap.size > 0) + ) { + playlistItems = downloadEntries + .map((e) => e.index + 1) // original 1-based playlist indices + .join(','); + } + + const res = await window.api.ytDlpDownloadUrl({ + url, + playlistItems, + playlistTitle: playlistInfo?.title || null, + existingPlaylistId: playlistInfo?.type === 'playlist' ? targetPlaylistId : null, + newPlaylistName: + playlistInfo?.type === 'playlist' && !targetPlaylistId + ? targetPlaylistName || playlistInfo?.title || 'Imported Playlist' + : null, + }); + + effectivePlaylistId = res.playlistId ?? targetPlaylistId; + setLoading(false); + setProgress(null); + setResult(res); + if (res.ok) setDownloadHistory((prev) => [{ url, at: Date.now() }, ...prev.slice(0, 19)]); } - const res = await window.api.ytDlpDownloadUrl({ - url, - playlistItems, - playlistTitle: playlistInfo?.title || null, - existingPlaylistId: playlistInfo?.type === 'playlist' ? targetPlaylistId : null, - newPlaylistName: - playlistInfo?.type === 'playlist' && !targetPlaylistId - ? targetPlaylistName || playlistInfo?.title || 'Imported Playlist' - : null, - }); - setLoading(false); - setProgress(null); - setResult(res); - if (res.ok) setHistory((prev) => [{ url, at: Date.now() }, ...prev.slice(0, 19)]); + // Link already-downloaded tracks to the playlist (no re-download) + if (linkEntries.length > 0 && effectivePlaylistId) { + const trackIds = linkEntries.map((e) => libraryMap.get(e.url)).filter(Boolean); + if (trackIds.length > 0) { + try { + await window.api.addTracksToPlaylist(effectivePlaylistId, trackIds); + setTrackStatuses((prev) => + prev.map((t) => { + const isLink = linkEntries.some((e) => e.url === t.url); + return isLink ? { ...t, status: 'done' } : t; + }) + ); + } catch (err) { + console.error('[DownloadView] link tracks failed:', err); + } + } + } + + if (downloadEntries.length === 0) { + setLoading(false); + setProgress(null); + setResult({ ok: true, imported: 0 }); + } }; // Step 3 → 1: start fresh @@ -265,6 +448,9 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { setPlaylists([]); setTargetPlaylistId(null); setTargetPlaylistName(''); + setLibraryMap(new Map()); + setLinkIndices(new Set()); + setPlaylistMemberUrls(new Set()); setTimeout(() => inputRef.current?.focus(), 50); }; @@ -275,11 +461,26 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { ).length; // Drive overall counter from trackStatuses (truth source) to avoid yt-dlp reset on retry const overallTotal = trackStatuses.length || (progress?.overallTotal ?? 1); - const overallCurrent = loading ? Math.min(completedCount + 1, overallTotal) : completedCount; + // Show how many tracks have fully completed (done/failed), starting at 0. + const overallCurrent = completedCount; const overallPct = overallTotal > 0 ? Math.round((overallCurrent / overallTotal) * 100) : 0; - const allSelected = playlistInfo && selectedIndices.size === playlistInfo.entries.length; - const someSelected = playlistInfo && selectedIndices.size > 0 && !allSelected; + const availableEntries = playlistInfo ? playlistInfo.entries.filter((e) => !e.unavailable) : []; + const unavailableCount = playlistInfo + ? playlistInfo.entries.filter((e) => e.unavailable).length + : 0; + // "All selected" means: every downloadable AND every linkable entry is active + const downloadableEntries = availableEntries.filter((e) => !libraryMap.has(e.url)); + const linkableEntries = availableEntries.filter( + (e) => libraryMap.has(e.url) && !playlistMemberUrls.has(e.url) + ); + const allSelected = + playlistInfo && + downloadableEntries.every((e) => selectedIndices.has(e.index)) && + linkableEntries.every((e) => linkIndices.has(e.index)) && + downloadableEntries.length + linkableEntries.length > 0; + const someSelected = + playlistInfo && (selectedIndices.size > 0 || linkIndices.size > 0) && !allSelected; // ── render ──────────────────────────────────────────────────────────────── return ( @@ -290,7 +491,7 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { {step === 'url' && 'Paste a URL to preview and choose tracks before downloading.'} {step === 'select' && (playlistInfo?.type === 'playlist' - ? `${playlistInfo.entries.length} tracks found — select what to download.` + ? `${availableEntries.length} track${availableEntries.length !== 1 ? 's' : ''} found${unavailableCount > 0 ? ` (${unavailableCount} unavailable)` : ''} — select what to download.` : 'Ready to download.')} {step === 'download' && 'Downloading and importing to your library…'}

@@ -304,7 +505,7 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { { @@ -335,12 +536,19 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { strokeLinecap="round" /> - Loading… + {checkProgress + ? `Checking ${checkProgress.checked}/${checkProgress.total}…` + : 'Loading…'} ) : ( 'Load →' )} + {fetching && ( + + )}
{fetchError && (
@@ -373,10 +581,36 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) {
- {history.length > 0 && ( + {/* Live track list during availability check */} + {fetching && checkProgress && playlistInfo?.entries?.length > 0 && ( +
+
+ Checking availability… {checkProgress.checked}/{checkProgress.total} +
+
+ {playlistInfo.entries.map((entry) => ( +
+ + {entry.unavailable ? '✗' : entry.checked ? '✓' : '⋯'} + + {entry.index + 1}. + {entry.title} + {entry.duration && ( + {fmtDuration(entry.duration)} + )} +
+ ))} +
+
+ )} + + {downloadHistory.length > 0 && !fetching && (
Session downloads
- {history.map((item, i) => ( + {downloadHistory.map((item, i) => (
{detectIcon(item.url)} {item.url} @@ -400,11 +634,49 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { {playlistInfo.title || (playlistInfo.type === 'playlist' ? 'Playlist' : 'Track')} {playlistInfo.type === 'playlist' && ( - {playlistInfo.entries.length} tracks + + {availableEntries.length} track{availableEntries.length !== 1 ? 's' : ''} + {unavailableCount > 0 && ( + + {' '} + · {unavailableCount} unavailable + + )} + )}
+ {playlistInfo.type === 'playlist' && ( +
+ + + {!targetPlaylistId && ( + setTargetPlaylistName(e.target.value)} + /> + )} +
+ )} + {playlistInfo.type === 'playlist' && (
+
+ {downloadableEntries.length > 0 && ( + + )} + {linkableEntries.length > 0 && ( + + )} +
- {selectedIndices.size} / {playlistInfo.entries.length} selected + {selectedIndices.size + linkIndices.size} / {availableEntries.length} selected
)}
- {playlistInfo.entries.map((entry) => ( - - ))} + {playlistInfo.entries + .filter((entry) => !entry.unavailable) + .map((entry) => { + const isInLibrary = libraryMap.has(entry.url); + const isInPlaylist = playlistMemberUrls.has(entry.url); + const isLink = linkIndices.has(entry.index); + const isSelected = selectedIndices.has(entry.index); + return ( + + ); + })} + {unavailableCount > 0 && ( +
+ {unavailableCount} video{unavailableCount !== 1 ? 's' : ''} unavailable (private, + deleted, or restricted) — not shown +
+ )}
- {playlistInfo.type === 'playlist' && ( -
- - - {!targetPlaylistId && ( - setTargetPlaylistName(e.target.value)} - /> - )} -
- )}
@@ -497,13 +818,14 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) {
+ {progress?.msg && {progress.msg}}
)} - {progress && ( + {!isPlaylist && progress && (
- {isPlaylist ? 'Current track' : 'Download'} + Download {progress.trackPct ?? progress.pct ?? 0}%
@@ -512,7 +834,7 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { style={{ width: `${progress.trackPct ?? progress.pct ?? 0}%` }} />
- {progress.msg} + {progress.msg && {progress.msg}}
)} @@ -526,7 +848,10 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { {trackStatuses.map((t) => (
{t.title} - + {STATUS_ICON[t.status]?.icon ?? '□'}
@@ -536,30 +861,57 @@ export default function DownloadView({ onGoToLibrary, onGoToPlaylist, style }) { )} {result?.ok && ( -
- - {result.trackIds.length === 1 - ? '✓ Track added to your library' - : `✓ ${result.trackIds.length} tracks added to your library`} - -
- {result.playlistId ? ( - - ) : ( - + ) : ( + + )} + - )} -
+ )} + {result.trackIds.length === 0 && ( + -
+ )} )} {result?.error && ( diff --git a/renderer/src/ExportModal.css b/renderer/src/ExportModal.css index aa7d36c2..3d5e484d 100644 --- a/renderer/src/ExportModal.css +++ b/renderer/src/ExportModal.css @@ -246,6 +246,14 @@ margin-top: 4px; } +/* Needs-format buttons are plain text — override the icon-grid layout */ +.export-needs-format-actions .export-option-btn { + display: block; + white-space: nowrap; + text-align: center; + padding: 12px 16px; +} + .export-option-btn--danger { border-color: #6b1f1f; color: #e07070; @@ -288,3 +296,28 @@ transform: rotate(360deg); } } + +.export-normalized-option { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 14px; + font-size: 13px; + color: #aaa; + cursor: pointer; + user-select: none; +} + +.export-normalized-option input[type='checkbox'] { + accent-color: #1db954; + width: 14px; + height: 14px; + cursor: pointer; +} + +.export-confirm-actions { + display: flex; + flex-direction: column; + gap: 10px; + margin-top: 4px; +} diff --git a/renderer/src/ExportModal.jsx b/renderer/src/ExportModal.jsx index 42fa63aa..bea1d7b5 100644 --- a/renderer/src/ExportModal.jsx +++ b/renderer/src/ExportModal.jsx @@ -1,9 +1,10 @@ -import { useState, useEffect, useCallback, useRef } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import FormatConfirmModal from './FormatConfirmModal.jsx'; import './ExportModal.css'; const STEPS = { idle: 'idle', + confirm: 'confirm', pickFolder: 'pickFolder', checkingFormat: 'checkingFormat', needsFormat: 'needsFormat', @@ -22,18 +23,19 @@ function ProgressBar({ pct }) { } function ExportModal({ onClose, playlistId, initialMode }) { - const [step, setStep] = useState(STEPS.idle); - const [mode, setMode] = useState(null); // 'rekordbox' | 'all' + const [step, setStep] = useState(initialMode ? STEPS.confirm : STEPS.idle); + const [mode, setMode] = useState(initialMode ?? null); const [usbInfo, setUsbInfo] = useState(null); const [usbRoot, setUsbRoot] = useState(null); const [progress, setProgress] = useState(null); // { msg, pct } const [formatProgress, setFormatProgress] = useState(null); const [result, setResult] = useState(null); const [error, setError] = useState(null); + const [useNormalized, setUseNormalized] = useState(true); const handleKeyDown = useCallback( (e) => { - if (e.key === 'Escape' && step === STEPS.idle) onClose(); + if (e.key === 'Escape' && (step === STEPS.idle || step === STEPS.confirm)) onClose(); }, [onClose, step] ); @@ -42,17 +44,6 @@ function ExportModal({ onClose, playlistId, initialMode }) { return () => window.removeEventListener('keydown', handleKeyDown); }, [handleKeyDown]); - const autoOpened = useRef(false); - - // If opened with a pre-set mode (from playlist right-click), skip idle and go straight to folder picker - useEffect(() => { - if (initialMode && !autoOpened.current) { - autoOpened.current = true; - pickFolder(initialMode); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - // Progress listeners useEffect(() => { const unsubRekordbox = window.api.onExportRekordboxProgress(setProgress); @@ -96,9 +87,17 @@ function ExportModal({ onClose, playlistId, initialMode }) { setProgress({ msg: 'Starting…', pct: 0 }); let res; if (exportMode === 'rekordbox') { - res = await window.api.exportRekordbox({ usbRoot: dir, playlistId: playlistId ?? null }); + res = await window.api.exportRekordbox({ + usbRoot: dir, + playlistId: playlistId ?? null, + useNormalized, + }); } else { - res = await window.api.exportAll({ usbRoot: dir, playlistId: playlistId ?? null }); + res = await window.api.exportAll({ + usbRoot: dir, + playlistId: playlistId ?? null, + useNormalized, + }); } if (res.ok) { setResult(res); @@ -136,6 +135,14 @@ function ExportModal({ onClose, playlistId, initialMode }) { ? 'Export this playlist to a Pioneer-compatible USB drive for CDJ/XDJ players.' : 'Choose an export format. Rekordbox USB creates a Pioneer-compatible drive you can plug directly into CDJ/XDJ players.'}

+
)} + {step === STEPS.confirm && ( +
+

+ {mode === 'rekordbox' + ? 'Export this playlist to a Pioneer-compatible USB drive for CDJ/XDJ players.' + : 'Export Rekordbox USB + M3U playlists to a folder.'} +

+ +
+ + +
+
+ )} + {step === STEPS.checkingFormat && (
diff --git a/renderer/src/FileExplorerView.css b/renderer/src/FileExplorerView.css new file mode 100644 index 00000000..3458596c --- /dev/null +++ b/renderer/src/FileExplorerView.css @@ -0,0 +1,341 @@ +.explorer-view { + display: flex; + flex-direction: row; + flex: 1; + min-width: 0; + height: 100%; + overflow: hidden; +} + +.explorer-view__main { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + min-width: 0; +} + +.explorer-view--with-panel .explorer-view__main { + border-right: 1px solid #2a2a2a; +} + +/* ── Favourites sidebar ─────────────────────────────────────────────────── */ + +.explorer-favourites { + width: 148px; + flex-shrink: 0; + display: flex; + flex-direction: column; + background: var(--bg-secondary, #1a1a1a); + border-right: 1px solid var(--border, #2a2a2a); + overflow-y: auto; + overflow-x: hidden; +} + +.explorer-favourites__header { + padding: 8px 10px 4px; + font-size: 11px; + font-weight: 600; + color: var(--text-muted, #666); + text-transform: uppercase; + letter-spacing: 0.05em; + user-select: none; + flex-shrink: 0; +} + +.explorer-favourites__empty { + padding: 10px 10px; + font-size: 11px; + color: var(--text-muted, #555); + line-height: 1.5; +} + +.explorer-favourites__item { + display: flex; + align-items: center; + gap: 5px; + padding: 5px 8px; + cursor: pointer; + font-size: 12px; + color: var(--text-secondary, #aaa); + border-radius: 3px; + margin: 1px 4px; + overflow: hidden; + flex-shrink: 0; +} + +.explorer-favourites__item:hover { + background: var(--bg-hover, #2d2d2d); + color: var(--text-primary, #e0e0e0); +} + +.explorer-favourites__item--active { + color: var(--accent, #4a90d9); +} + +.explorer-favourites__icon { + flex-shrink: 0; + font-size: 12px; +} + +.explorer-favourites__name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.explorer-favourites__remove { + background: none; + border: none; + color: var(--text-muted, #555); + cursor: pointer; + padding: 1px 3px; + font-size: 10px; + flex-shrink: 0; + opacity: 0; + line-height: 1; + border-radius: 2px; +} + +.explorer-favourites__item:hover .explorer-favourites__remove { + opacity: 1; +} + +.explorer-favourites__remove:hover { + color: var(--text-primary, #e0e0e0); + background: var(--bg-tertiary, #333); +} + +/* ── Toolbar ────────────────────────────────────────────────────────────── */ + +.explorer-toolbar { + display: flex; + align-items: center; + gap: 4px; + padding: 5px 8px; + background: var(--bg-secondary, #1a1a1a); + border-bottom: 1px solid var(--border, #2a2a2a); + flex-shrink: 0; +} + +.explorer-btn { + background: var(--bg-tertiary, #222); + border: 1px solid var(--border, #333); + color: var(--text-primary, #e0e0e0); + padding: 3px 9px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + white-space: nowrap; + flex-shrink: 0; + line-height: 1.5; +} + +.explorer-btn:hover { + background: var(--bg-hover, #2d2d2d); +} + +.explorer-btn.active { + background: var(--accent, #4a90d9); + border-color: var(--accent, #4a90d9); +} + +.explorer-btn.accent { + background: var(--accent, #4a90d9); + border-color: var(--accent, #4a90d9); + color: #fff; +} + +.explorer-btn.accent:hover { + background: var(--accent-hover, #357abd); +} + +.explorer-breadcrumbs { + display: flex; + align-items: center; + flex: 1; + overflow: hidden; + font-size: 13px; + min-width: 0; +} + +.explorer-crumb { + background: none; + border: none; + color: var(--text-secondary, #aaa); + cursor: pointer; + padding: 2px 3px; + border-radius: 3px; + font-size: 13px; + white-space: nowrap; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; +} + +.explorer-crumb:hover { + color: var(--text-primary, #e0e0e0); + background: var(--bg-hover, #2d2d2d); +} + +.explorer-sep { + color: var(--text-muted, #555); + padding: 0 1px; + user-select: none; + flex-shrink: 0; +} + +.explorer-broken-badge { + font-size: 12px; + color: #f0a; + white-space: nowrap; + flex-shrink: 0; + cursor: default; +} + +.explorer-recursive-banner { + padding: 3px 10px; + font-size: 12px; + background: var(--bg-secondary, #1a1a1a); + border-bottom: 1px solid var(--border, #2a2a2a); + color: var(--text-secondary, #aaa); + flex-shrink: 0; +} + +/* ── List container ─────────────────────────────────────────────────────── */ + +.explorer-list-container { + flex: 1; + min-height: 0; + overflow: hidden; + position: relative; +} + +.explorer-empty { + padding: 24px; + text-align: center; + color: var(--text-muted, #555); + font-size: 14px; +} + +/* ── Explorer-specific row overrides ────────────────────────────────────── */ + +.explorer-dir-row { + color: var(--accent, #6ab0f5); + opacity: 0.9; +} + +.explorer-status-cell { + font-size: 12px; + text-align: center; + padding: 0 2px; + opacity: 0.7; +} + +/* ── Invisible backdrop for closing context menu ─────────────────────────── */ + +.context-backdrop-invisible { + position: fixed; + inset: 0; + z-index: 999; +} + +.context-menu { + z-index: 1000; +} + +/* ── Link-to-library dialog ──────────────────────────────────────────────── */ + +.explorer-dialog-backdrop { + position: fixed; + inset: 0; + z-index: 1100; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: center; + justify-content: center; +} + +.explorer-dialog { + background: var(--bg-secondary, #1e1e1e); + border: 1px solid var(--border, #333); + border-radius: 8px; + padding: 20px 24px; + min-width: 300px; + max-width: 420px; + display: flex; + flex-direction: column; + gap: 10px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); +} + +.explorer-dialog__title { + font-size: 15px; + font-weight: 600; + color: var(--text-primary, #e0e0e0); + margin-bottom: 4px; +} + +.explorer-dialog__option { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: var(--text-primary, #ddd); + cursor: pointer; + user-select: none; +} + +.explorer-dialog__option input[type='radio'] { + cursor: pointer; + accent-color: var(--accent, #4a90d9); +} + +.explorer-dialog__input { + background: var(--bg-tertiary, #2a2a2a); + border: 1px solid var(--border, #444); + color: var(--text-primary, #ddd); + padding: 5px 8px; + border-radius: 4px; + font-size: 13px; + width: 100%; + box-sizing: border-box; +} + +.explorer-dialog__select { + background: var(--bg-tertiary, #2a2a2a); + border: 1px solid var(--border, #444); + color: var(--text-primary, #ddd); + padding: 5px 8px; + border-radius: 4px; + font-size: 13px; + width: 100%; + box-sizing: border-box; +} + +.explorer-dialog__body { + font-size: 13px; + color: var(--text-secondary, #aaa); + margin: 0; + line-height: 1.5; + white-space: pre-line; +} + +.explorer-dialog__warn { + font-size: 12px; + color: var(--text-secondary, #999); + margin: 0; + background: rgba(255, 170, 0, 0.07); + border: 1px solid rgba(255, 170, 0, 0.18); + border-radius: 4px; + padding: 6px 8px; + line-height: 1.45; +} + +.explorer-dialog__actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 6px; +} diff --git a/renderer/src/FileExplorerView.jsx b/renderer/src/FileExplorerView.jsx new file mode 100644 index 00000000..d132d836 --- /dev/null +++ b/renderer/src/FileExplorerView.jsx @@ -0,0 +1,1278 @@ +import { useState, useEffect, useRef, useCallback, useMemo } from 'react'; +import { List } from 'react-window'; +import { usePlayer } from './PlayerContext.jsx'; +import { artworkUrl } from './artworkUrl.js'; +import TrackDetails from './TrackDetails.jsx'; +import BeatGridEditor from './BeatGridEditor.jsx'; +import './MusicLibrary.css'; +import './FileExplorerView.css'; + +// ── Column definitions (matches MusicLibrary) ──────────────────────────────── + +const COLUMNS = [ + { key: 'index', label: '#', width: '40px' }, + { key: 'status', label: '', width: '24px' }, + { key: 'title', label: 'Title', width: 'minmax(120px,2fr)' }, + { key: 'artist', label: 'Artist', width: 'minmax(90px,1.5fr)' }, + { key: 'bpm', label: 'BPM', width: '62px' }, + { key: 'key_camelot', label: 'Key', width: '52px' }, + { key: 'loudness', label: 'Loudness', width: '90px' }, + { key: 'duration', label: 'Duration', width: '65px' }, +]; + +const GRID = COLUMNS.map((c) => c.width).join(' '); +const MIN_WIDTH = 680; +const ROW_HEIGHT = 50; + +function fmtDuration(secs) { + if (secs == null) return '—'; + const m = Math.floor(secs / 60); + const s = Math.floor(secs % 60); + return `${m}:${s.toString().padStart(2, '0')}`; +} + +function basename(p) { + return p.replace(/.*[\\/]/, ''); +} + +function fileToSyntheticTrack(f) { + const name = basename(f.path); + const dot = name.lastIndexOf('.'); + return { + id: `explorer:${f.path}`, + file_path: f.path, + normalized_file_path: null, + title: dot > 0 ? name.slice(0, dot) : name, + artist: null, + album: null, + bpm: null, + bpm_override: null, + key_camelot: null, + loudness: null, + duration: null, + bitrate: null, + has_artwork: 0, + artwork_path: null, + analyzed: 0, + is_linked: 0, + replay_gain: null, + beatgrid_offset: 0, + cue_count: 0, + rating: 0, + genres: '[]', + user_tags: null, + }; +} + +// ── Row component (outside to prevent remounts) ────────────────────────────── + +function ExplorerRow({ + index, + style, + items, + tracksMap, + selectedPaths, + playingFilePath, + onRowClick, + onDoubleClick, + onContextMenu, + mediaPort, +}) { + const item = items[index]; + if (!item) return
; + + const track = + tracksMap.get(item.path) ?? (item.type === 'file' ? fileToSyntheticTrack(item) : null); + const isSelected = selectedPaths.has(item.path); + const isPlaying = item.type === 'file' && item.path === playingFilePath; + const isLinked = track?.is_linked === 1; + const isAnalyzing = isLinked && track?.analyzed === 0; + + if (item.type === 'dir') { + return ( +
onRowClick(e, item)} + onDoubleClick={() => onDoubleClick(item)} + onContextMenu={(e) => onContextMenu(e, item)} + > +
+ 📁 +
+
+
+ 📁 + {item.name} +
+
+
+
+
+
+
+ ); + } + + const bpmVal = track?.bpm_override ?? track?.bpm; + const artSrc = artworkUrl(track?.has_artwork ? track.artwork_path : null, mediaPort); + + return ( +
onRowClick(e, item)} + onDoubleClick={() => onDoubleClick(item)} + onContextMenu={(e) => onContextMenu(e, item)} + > +
+ {index + 1} + +
+
+ {isLinked ? '🔗' : ''} +
+
+ {artSrc ? ( + + ) : ( + + )} + {track?.title ?? item.name} +
+
{track?.artist || '—'}
+
{bpmVal != null ? bpmVal : '—'}
+
{track?.key_camelot ?? '—'}
+
{track?.loudness != null ? track.loudness : '—'}
+
{fmtDuration(track?.duration)}
+
+ ); +} + +// ── Breadcrumbs ────────────────────────────────────────────────────────────── + +function getBreadcrumbs(p) { + if (!p) return []; + if (/^[A-Za-z]:/.test(p)) { + let acc = ''; + return p + .split('\\') + .filter(Boolean) + .map((part) => { + acc = acc ? `${acc}\\${part}` : `${part}\\`; + return { label: part, path: acc }; + }); + } + const parts = p.split('/').filter(Boolean); + const crumbs = [{ label: '/', path: '/' }]; + let acc = ''; + for (const part of parts) { + acc = `${acc}/${part}`; + crumbs.push({ label: part, path: acc }); + } + return crumbs; +} + +// ── Generic confirm dialog ──────────────────────────────────────────────────── + +function ConfirmDialog({ title, body, confirmLabel = 'Confirm', onConfirm, onCancel }) { + return ( +
+
e.stopPropagation()}> +
{title}
+

{body}

+
+ + +
+
+
+ ); +} + +// ── Link-to-library dialog ──────────────────────────────────────────────────── + +function LinkFolderDialog({ description, defaultName, playlists, onConfirm, onCancel }) { + const [mode, setMode] = useState('music'); + const [newName, setNewName] = useState(defaultName); + const [existingId, setExistingId] = useState(playlists[0]?.id ?? ''); + + return ( +
+
e.stopPropagation()}> +
Add to Library
+ {description &&

{description}

} +

+ Metadata and analysis will run for every new file — on large folders this can take a + while. +

+ + + + + {mode === 'new' && ( + setNewName(e.target.value)} + placeholder="Playlist name" + autoFocus + /> + )} + + {playlists.length > 0 && ( + + )} + {mode === 'existing' && playlists.length > 0 && ( + + )} + +
+ + +
+
+
+ ); +} + +// ── Main component ──────────────────────────────────────────────────────────── + +export default function FileExplorerView({ style }) { + const { play, currentTrack, mediaPort, patchCurrentTrack } = usePlayer(); + + const [fsRoot, setFsRoot] = useState(null); + const [homeDir, setHomeDir] = useState(null); + const [currentPath, setCurrentPath] = useState(null); + const [dirEntries, setDirEntries] = useState({ dirs: [], files: [] }); + const [loading, setLoading] = useState(false); + // null = idle; string = path currently being analyzed (persists across navigation) + const [analyzingPath, setAnalyzingPath] = useState(null); + const pendingAnalysisIds = useRef(new Set()); + const [tracksMap, setTracksMap] = useState(new Map()); + const [selectedPaths, setSelectedPaths] = useState(new Set()); + const [playlists, setPlaylists] = useState([]); + const [contextMenu, setContextMenu] = useState(null); + const [detailsTrack, setDetailsTrack] = useState(null); + const [beatGridTrack, setBeatGridTrack] = useState(null); + const [toast, setToast] = useState(null); + const [linkDialog, setLinkDialog] = useState(null); // { defaultName, paths|null, description } + const [confirmDialog, setConfirmDialog] = useState(null); // { title, body, confirmLabel, onConfirm } + const [favourites, setFavourites] = useState([]); // [{ path, name }] + + // Broken links — populated by slow background scan + const [brokenTracks, setBrokenTracks] = useState([]); + const brokenScanRunning = useRef(false); + + // Recursive scan + const [recursiveFiles, setRecursiveFiles] = useState(null); + const [recursiveScanning, setRecursiveScanning] = useState(false); + + const listRef = useRef(); + const containerRef = useRef(); + const [listHeight, setListHeight] = useState(500); + const lastClickIndex = useRef(null); + + const showToast = useCallback((msg, ok = true) => { + setToast({ msg, ok }); + setTimeout(() => setToast(null), 3000); + }, []); + + // ── Init ────────────────────────────────────────────────────────────────── + + useEffect(() => { + window.api.getComputerRoot().then(({ root, home }) => { + setFsRoot(root); + setHomeDir(home); + setCurrentPath(home ?? root); + }); + window.api.getPlaylists().then(setPlaylists); + window.api.getSetting('explorer_favourites', []).then((favs) => { + const parsed = typeof favs === 'string' ? JSON.parse(favs) : favs; + setFavourites(Array.isArray(parsed) ? parsed : []); + }); + const unsub = window.api.onPlaylistsUpdated(() => window.api.getPlaylists().then(setPlaylists)); + return unsub; + }, []); + + const addFavourite = useCallback((path) => { + const name = basename(path) || path; + setFavourites((prev) => { + if (prev.some((f) => f.path === path)) return prev; + const next = [...prev, { path, name }]; + window.api.setSetting('explorer_favourites', next); + return next; + }); + }, []); + + const removeFavourite = useCallback((path) => { + setFavourites((prev) => { + const next = prev.filter((f) => f.path !== path); + window.api.setSetting('explorer_favourites', next); + return next; + }); + }, []); + + // ── Background broken-link scan ────────────────────────────────────────── + + const runBrokenScan = useCallback(async () => { + if (brokenScanRunning.current) return; + brokenScanRunning.current = true; + try { + const linked = await window.api.getLinkedTracksBasic(); + if (!linked.length) return; + const BATCH = 20; + const broken = []; + for (let i = 0; i < linked.length; i += BATCH) { + const batch = linked.slice(i, i + BATCH); + const results = await window.api.checkLinkedTrackStatus(batch.map((t) => t.id)); + for (const r of results) { + if (!r.exists) { + const t = batch.find((b) => b.id === r.id); + if (t) broken.push(t); + } + } + // Yield between batches — keep CPU low + await new Promise((res) => setTimeout(res, 150)); + } + setBrokenTracks(broken); + } finally { + brokenScanRunning.current = false; + } + }, []); + + useEffect(() => { + runBrokenScan(); + }, [runBrokenScan]); + + useEffect(() => { + const unsub = window.api.onLibraryUpdated(() => { + brokenScanRunning.current = false; + setBrokenTracks([]); + runBrokenScan(); + }); + return unsub; + }, [runBrokenScan]); + + // ── Resize observer ────────────────────────────────────────────────────── + + useEffect(() => { + if (!containerRef.current) return; + const obs = new ResizeObserver(([e]) => setListHeight(e.contentRect.height)); + obs.observe(containerRef.current); + return () => obs.disconnect(); + }, []); + + // ── Load directory ──────────────────────────────────────────────────────── + + useEffect(() => { + if (!currentPath || !fsRoot) return; + setLoading(true); + setSelectedPaths(new Set()); + setRecursiveFiles(null); + setRecursiveScanning(false); + window.api.explorerCancelRecursive(); + window.api.browseDirectory(currentPath).then(({ dirs, files }) => { + setDirEntries({ dirs: dirs ?? [], files: files ?? [] }); + const paths = (files ?? []).map((f) => f.path); + if (paths.length) { + window.api.getTracksByPaths(paths).then((tracks) => { + setTracksMap(new Map(tracks.map((t) => [t.file_path, t]))); + }); + } else { + setTracksMap(new Map()); + } + setLoading(false); + }); + }, [currentPath, fsRoot]); + + // Update rows and player bar as analysis results arrive. + // Scan tracksMap inside the state setter (always latest state, no ref race). + useEffect(() => { + const unsub = window.api.onTrackUpdated(({ trackId, analysis }) => { + const merged = { ...analysis, analyzed: analysis.analyzed !== 0 ? 1 : 0 }; + setTracksMap((prev) => { + let filePath = null; + for (const [fp, t] of prev) { + if (t.id === trackId) { + filePath = fp; + break; + } + } + if (!filePath) return prev; + const next = new Map(prev); + next.set(filePath, { ...prev.get(filePath), ...merged }); + return next; + }); + patchCurrentTrack(trackId, merged); + // Decrement pending set; clear analyzingPath when all workers finish + if (pendingAnalysisIds.current.has(trackId)) { + pendingAnalysisIds.current.delete(trackId); + if (pendingAnalysisIds.current.size === 0) { + setAnalyzingPath(null); + } + } + }); + return unsub; + }, [patchCurrentTrack]); + + // ── Recursive scan events ──────────────────────────────────────────────── + + useEffect(() => { + const u1 = window.api.onExplorerRecursiveBatch((batch) => + setRecursiveFiles((p) => [...(p ?? []), ...batch]) + ); + const u2 = window.api.onExplorerRecursiveDone(() => setRecursiveScanning(false)); + return () => { + u1(); + u2(); + }; + }, []); + + // ── Derived state ───────────────────────────────────────────────────────── + + const displayItems = useMemo(() => { + if (recursiveFiles !== null) return recursiveFiles.map((f) => ({ ...f, type: 'file' })); + return [ + ...dirEntries.dirs.map((d) => ({ ...d, type: 'dir' })), + ...dirEntries.files.map((f) => ({ ...f, type: 'file' })), + ]; + }, [dirEntries, recursiveFiles]); + + const brokenByFilename = useMemo(() => { + const m = new Map(); + for (const t of brokenTracks) { + const name = basename(t.file_path); + if (!m.has(name)) m.set(name, t); + } + return m; + }, [brokenTracks]); + + const playingFilePath = currentTrack?.file_path ?? null; + + // ── Navigation ──────────────────────────────────────────────────────────── + + const navigateTo = useCallback((p) => { + setCurrentPath(p); + lastClickIndex.current = null; + }, []); + + // ── Selection ──────────────────────────────────────────────────────────── + + const handleRowClick = useCallback( + (e, item) => { + const idx = displayItems.findIndex((x) => x.path === item.path); + if (e.shiftKey && lastClickIndex.current != null) { + const lo = Math.min(lastClickIndex.current, idx); + const hi = Math.max(lastClickIndex.current, idx); + setSelectedPaths((prev) => { + const next = new Set(prev); + displayItems.slice(lo, hi + 1).forEach((x) => next.add(x.path)); + return next; + }); + } else if (e.ctrlKey || e.metaKey) { + setSelectedPaths((prev) => { + const next = new Set(prev); + next.has(item.path) ? next.delete(item.path) : next.add(item.path); + return next; + }); + lastClickIndex.current = idx; + } else { + setSelectedPaths(new Set([item.path])); + lastClickIndex.current = idx; + } + }, + [displayItems] + ); + + // ── Playback ───────────────────────────────────────────────────────────── + + const handleDoubleClick = useCallback( + (item) => { + if (item.type === 'dir') { + navigateTo(item.path); + return; + } + const fileItems = displayItems.filter((x) => x.type === 'file'); + const idx = fileItems.findIndex((x) => x.path === item.path); + const trackForItem = tracksMap.get(item.path) ?? fileToSyntheticTrack(item); + const queue = fileItems.map((f) => tracksMap.get(f.path) ?? fileToSyntheticTrack(f)); + + // Play immediately — no waiting regardless of link status + play(trackForItem, queue, idx); + + // If unlinked, auto-link in background so analysis starts and player bar updates + if (typeof trackForItem.id === 'string') { + const syntheticId = trackForItem.id; + window.api.linkAudioFiles([item.path], null).then(async (results) => { + if (!results[0]?.id || typeof results[0].id !== 'number') return; + const linked = await window.api.getTracksByPaths([item.path]); + if (!linked[0]) return; + setTracksMap((prev) => { + const next = new Map(prev); + next.set(item.path, linked[0]); + return next; + }); + // Upgrade the synthetic player entry to the real track so analysis + // results (patchCurrentTrack by numeric id) land correctly + patchCurrentTrack(syntheticId, linked[0]); + }); + } + }, + [displayItems, tracksMap, play, navigateTo, patchCurrentTrack] + ); + + // ── Link helpers ────────────────────────────────────────────────────────── + + const linkFiles = useCallback( + async (filePaths, playlistId = null) => { + const results = await window.api.linkAudioFiles(filePaths, playlistId); + const linked = results.filter((r) => !r.duplicate && r.id).length; + showToast(`Linked ${linked} track(s)`); + const tracks = await window.api.getTracksByPaths(filePaths); + setTracksMap((prev) => { + const next = new Map(prev); + tracks.forEach((t) => next.set(t.file_path, t)); + return next; + }); + return results; + }, + [showToast] + ); + + // Refresh tracksMap for all files currently visible — called after any link op + // so onTrackUpdated can find newly linked tracks by their numeric DB id. + const refreshVisibleTracks = useCallback(async (items) => { + const filePaths = items.filter((x) => x.type === 'file').map((x) => x.path); + if (!filePaths.length) return; + const tracks = await window.api.getTracksByPaths(filePaths); + setTracksMap((prev) => { + const next = new Map(prev); + tracks.forEach((t) => next.set(t.file_path, t)); + return next; + }); + }, []); + + const linkDir = useCallback( + async (dirPath, recursive, playlistId = null) => { + const res = await window.api.linkDirectory(dirPath, recursive, playlistId); + showToast(`Linked ${res.linked}/${res.total} tracks`); + }, + [showToast] + ); + + const analyzeFolder = useCallback( + async (recursive = false) => { + if (!currentPath || analyzingPath === currentPath) return; + setAnalyzingPath(currentPath); + pendingAnalysisIds.current = new Set(); + try { + await window.api.linkDirectory(currentPath, recursive, null); + // Refresh map so onTrackUpdated can match track IDs to file paths + const filePaths = displayItems.filter((x) => x.type === 'file').map((x) => x.path); + if (!filePaths.length) { + setAnalyzingPath(null); + return; + } + const tracks = await window.api.getTracksByPaths(filePaths); + setTracksMap((prev) => { + const next = new Map(prev); + tracks.forEach((t) => next.set(t.file_path, t)); + return next; + }); + const unanalyzed = tracks.filter((t) => t.analyzed === 0); + if (unanalyzed.length === 0) { + setAnalyzingPath(null); + } else { + pendingAnalysisIds.current = new Set(unanalyzed.map((t) => t.id)); + showToast(`Analyzing ${unanalyzed.length} track(s)…`); + } + } catch { + setAnalyzingPath(null); + pendingAnalysisIds.current = new Set(); + } + }, + [currentPath, analyzingPath, displayItems, showToast] + ); + + const cancelAnalyzeFolder = useCallback(() => { + pendingAnalysisIds.current = new Set(); + setAnalyzingPath(null); + }, []); + + // ── Context menu ────────────────────────────────────────────────────────── + + const handleContextMenu = useCallback( + (e, item) => { + e.preventDefault(); + if (!selectedPaths.has(item.path)) { + setSelectedPaths(new Set([item.path])); + lastClickIndex.current = displayItems.findIndex((x) => x.path === item.path); + } + const vw = window.innerWidth; + const vh = window.innerHeight; + setContextMenu({ + x: Math.min(e.clientX, vw - 220), + y: Math.min(e.clientY, vh - 16), + item, + flipLeft: e.clientX > vw / 2, + flipUp: e.clientY > vh * 0.5, + }); + }, + [selectedPaths, displayItems] + ); + + const closeMenu = useCallback(() => setContextMenu(null), []); + + // ── Details save ────────────────────────────────────────────────────────── + + const handleDetailsSave = useCallback(async (updatedTrack) => { + await window.api.updateTrack(updatedTrack.id, updatedTrack); + setTracksMap((prev) => { + const next = new Map(prev); + for (const [k, v] of next) { + if (v.id === updatedTrack.id) { + next.set(k, { ...v, ...updatedTrack }); + break; + } + } + return next; + }); + setDetailsTrack(null); + }, []); + + // ── Render helpers ──────────────────────────────────────────────────────── + + const breadcrumbs = useMemo(() => getBreadcrumbs(currentPath), [currentPath]); + + const selectedFileItems = useMemo( + () => displayItems.filter((x) => x.type === 'file' && selectedPaths.has(x.path)), + [displayItems, selectedPaths] + ); + + const rowProps = useMemo( + () => ({ + items: displayItems, + tracksMap, + selectedPaths, + playingFilePath, + onRowClick: handleRowClick, + onDoubleClick: handleDoubleClick, + onContextMenu: handleContextMenu, + mediaPort, + }), + [ + displayItems, + tracksMap, + selectedPaths, + playingFilePath, + handleRowClick, + handleDoubleClick, + handleContextMenu, + mediaPort, + ] + ); + + // Context menu computed values + const menuItem = contextMenu?.item ?? null; + const menuTrack = menuItem ? (tracksMap.get(menuItem.path) ?? null) : null; + const menuIsLinked = menuTrack?.is_linked === 1; + const menuIsDir = menuItem?.type === 'dir'; + const menuFilename = menuItem ? basename(menuItem.path) : ''; + const menuBrokenMatch = + menuItem && !menuIsLinked && !menuIsDir ? (brokenByFilename.get(menuFilename) ?? null) : null; + const menuLinkedBroken = menuIsLinked + ? (brokenTracks.find((b) => b.id === menuTrack?.id) ?? null) + : null; + + return ( +
+ {/* ── Favourites sidebar ────────────────────────────────────────────── */} +
+
Favourites
+ {favourites.length === 0 ? ( +
Right-click a folder to add favourites
+ ) : ( + favourites.map((fav) => ( +
navigateTo(fav.path)} + > + 📁 + {fav.name} + +
+ )) + )} +
+ +
+ {/* ── Toolbar ───────────────────────────────────────────────────────── */} +
+ +
+ {breadcrumbs.map((crumb, i) => ( + + {i > 0 && /} + + + ))} +
+ + + {brokenTracks.length > 0 && ( + + ⚠️ {brokenTracks.length} + + )} + +
+ + {recursiveFiles !== null && ( +
+ Recursive view of {currentPath} + {recursiveScanning ? ' — scanning…' : ` — ${recursiveFiles.length} file(s)`} +
+ )} + + {/* ── Header row ────────────────────────────────────────────────────── */} +
+ {COLUMNS.map((col) => ( +
+ {col.label} +
+ ))} +
+ + {/* ── File list ─────────────────────────────────────────────────────── */} +
+ {loading &&
Loading…
} + {!loading && displayItems.length === 0 && ( +
No audio files here
+ )} + {!loading && displayItems.length > 0 && ( + + )} +
+ + {/* ── Context menu ──────────────────────────────────────────────────── */} + {contextMenu && ( + <> +
+
e.stopPropagation()} + > + {menuIsDir ? ( + <> + {favourites.some((f) => f.path === menuItem.path) ? ( +
{ + closeMenu(); + removeFavourite(menuItem.path); + }} + > + ★ Remove from Favourites +
+ ) : ( +
{ + closeMenu(); + addFavourite(menuItem.path); + }} + > + ⭐ Add to Favourites +
+ )} +
+
{ + closeMenu(); + linkDir(menuItem.path, false); + }} + > + 📁 Import folder (flat) +
+
{ + closeMenu(); + linkDir(menuItem.path, true); + }} + > + 📁 Import folder (recursive) +
+
+
{ + closeMenu(); + const pl = await window.api.createPlaylist(menuItem.name); + linkDir(menuItem.path, false, pl.id); + }} + > + ➕ Create playlist (flat) +
+
{ + closeMenu(); + const pl = await window.api.createPlaylist(menuItem.name); + linkDir(menuItem.path, true, pl.id); + }} + > + ➕ Create playlist (recursive) +
+ {brokenTracks.some((b) => b.file_path.startsWith(menuItem.path)) && ( + <> +
+
{ + closeMenu(); + const r = await window.api.remapFolder(menuItem.path); + showToast(r.ok ? `Remapped ${r.count} track(s)` : 'Remap failed', r.ok); + }} + > + 🔗 Remap broken folder… +
+ + )} + + ) : ( + <> + {/* Add to library — unlinked files only */} + {!menuIsLinked && ( + <> +
{ + closeMenu(); + linkFiles([menuItem.path]); + }} + > + ➕ Add to library +
+
+ + )} + + {/* Add to playlist submenu */} +
+ ➕ Add to playlist +
+
{ + closeMenu(); + const pl = await window.api.createPlaylist(menuFilename); + await linkFiles([menuItem.path], pl.id); + }} + > + ✚ New playlist… +
+ {playlists.length > 0 &&
} + {playlists.map((pl) => ( +
{ + closeMenu(); + let trackId = menuTrack?.id; + if (!menuIsLinked || typeof trackId === 'string') { + const results = await linkFiles([menuItem.path]); + trackId = results[0]?.id ?? null; + } + if (trackId && typeof trackId === 'number') + await window.api.addTracksToPlaylist(pl.id, [trackId]); + showToast(`Added to "${pl.name}"`); + }} + > + {pl.color && } + {pl.name} +
+ ))} +
+
+ +
+ + {/* Play */} +
{ + closeMenu(); + handleDoubleClick(menuItem); + }} + > + ▶ Play +
+ + {/* Edit / Prepare / Analysis — linked tracks only */} + {menuIsLinked && menuTrack && ( + <> +
+
{ + closeMenu(); + setDetailsTrack(menuTrack); + }} + > + ✏️ Edit Details +
+
{ + closeMenu(); + setBeatGridTrack(menuTrack); + }} + > + 🎛 Prepare Track… +
+
+ 🔬 Analysis +
+
{ + closeMenu(); + window.api.reanalyzeTrack(menuTrack.id); + showToast('Re-analysis started'); + }} + > + 🔄 Re-analyze +
+
+
{ + closeMenu(); + window.api.normalizeTracksAudio({ trackIds: [menuTrack.id] }); + showToast('Normalization started'); + }} + > + 🔊 Normalize +
+
+
+ + )} + + {/* Remap — only when broken link detected */} + {(menuBrokenMatch || menuLinkedBroken) && ( + <> +
+ {menuBrokenMatch && ( +
{ + closeMenu(); + const r = await window.api.remapTrack( + menuBrokenMatch.id, + menuItem.path + ); + if (r.ok) { + setBrokenTracks((p) => p.filter((b) => b.id !== menuBrokenMatch.id)); + showToast(`Remapped: ${menuBrokenMatch.title}`); + } else showToast('Remap failed', false); + }} + > + 🔗 Remap “{menuBrokenMatch.title}” to this file +
+ )} + {menuLinkedBroken && ( +
+ ⚠️ Broken link — file missing +
+ )} + + )} + + {/* Remove file */} + {menuIsLinked && ( + <> +
+
{ + const { path: itemPath, id: trackId } = { + path: menuItem.path, + id: menuTrack.id, + }; + closeMenu(); + setConfirmDialog({ + title: '🗑️ Delete file?', + body: `"${basename(itemPath)}" will be permanently deleted from your disk and removed from the library.\n\nThis cannot be undone.`, + confirmLabel: 'Delete file', + onConfirm: async () => { + setConfirmDialog(null); + await window.api.removeLinkedFile(trackId); + setTracksMap((prev) => { + const next = new Map(prev); + next.delete(itemPath); + return next; + }); + showToast(`Deleted: ${basename(itemPath)}`); + }, + }); + }} + > + 🗑️ Remove file +
+ + )} + + )} +
+ + )} + + {/* ── Confirm dialog (recursive scan / analyze) ─────────────────────── */} + {confirmDialog && ( + setConfirmDialog(null)} + /> + )} + + {/* ── Link-to-library dialog ────────────────────────────────────────── */} + {linkDialog && ( + setLinkDialog(null)} + onConfirm={async ({ mode, newName, existingId }) => { + const { paths } = linkDialog; + setLinkDialog(null); + let playlistId = null; + if (mode === 'new') { + const pl = await window.api.createPlaylist(newName || linkDialog.defaultName); + playlistId = pl.id; + } else if (mode === 'existing') { + playlistId = existingId; + } + if (paths) { + await linkFiles(paths, playlistId); + } else { + const res = await window.api.linkDirectory(currentPath, false, playlistId); + showToast(`Linked ${res.linked}/${res.total} tracks`); + await refreshVisibleTracks(displayItems); + } + }} + /> + )} + + {/* ── Beat Grid Editor (fixed overlay — kept inside main so it stays + within the stacking context of the main panel) ──────────────── */} + {beatGridTrack && ( + setBeatGridTrack(null)} + onApply={async (data) => { + await window.api.adjustBpm({ trackId: beatGridTrack.id, ...data }); + setBeatGridTrack(null); + }} + /> + )} + + {/* ── Toast ─────────────────────────────────────────────────────────── */} + {toast && ( +
+ {toast.msg} +
+ )} +
+ {/* end .explorer-view__main */} + + {/* ── Details side panel (sibling to main, same row) ────────────────── */} + {detailsTrack && ( + setDetailsTrack(null)} + /> + )} +
+ ); +} diff --git a/renderer/src/FormatConfirmModal.css b/renderer/src/FormatConfirmModal.css index 9aab937e..c3872b34 100644 --- a/renderer/src/FormatConfirmModal.css +++ b/renderer/src/FormatConfirmModal.css @@ -70,6 +70,7 @@ font-weight: 600; cursor: pointer; border: none; + white-space: nowrap; } .format-confirm-btn--cancel { diff --git a/renderer/src/ImportPlaylistDialog.css b/renderer/src/ImportPlaylistDialog.css new file mode 100644 index 00000000..f7202fcd --- /dev/null +++ b/renderer/src/ImportPlaylistDialog.css @@ -0,0 +1,139 @@ +.ipd-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + z-index: 2000; + display: flex; + align-items: center; + justify-content: center; +} + +.ipd-modal { + background: #1e1e2e; + border: 1px solid #383856; + border-radius: 10px; + padding: 24px 28px; + min-width: 320px; + max-width: 420px; + width: 90%; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.6); + display: flex; + flex-direction: column; + gap: 12px; +} + +.ipd-title { + margin: 0; + font-size: 1.1rem; + font-weight: 600; + color: #e2e2f0; +} + +.ipd-desc { + margin: 0; + font-size: 0.85rem; + color: #888; +} + +.ipd-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 280px; + overflow-y: auto; +} + +.ipd-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + border-radius: 6px; + cursor: pointer; + transition: background 0.15s; + color: #ccc; + font-size: 0.875rem; +} + +.ipd-option:hover { + background: #2a2a3e; +} + +.ipd-option--active { + background: #2a2a3e; + color: #e2e2f0; +} + +.ipd-option input[type='radio'] { + accent-color: #7eb8f7; + flex-shrink: 0; +} + +.ipd-color-dot { + width: 10px; + height: 10px; + border-radius: 50%; + flex-shrink: 0; +} + +.ipd-option-label { + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.ipd-create-input { + background: #12121f; + border: 1px solid #383856; + border-radius: 6px; + color: #e2e2f0; + font-size: 0.875rem; + padding: 8px 10px; + outline: none; + transition: border-color 0.15s; +} + +.ipd-create-input:focus { + border-color: #7eb8f7; +} + +.ipd-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.ipd-btn { + padding: 7px 18px; + border-radius: 6px; + font-size: 0.85rem; + border: none; + cursor: pointer; + font-weight: 500; + transition: background 0.15s; +} + +.ipd-btn--secondary { + background: #2a2a3e; + color: #aaa; +} + +.ipd-btn--secondary:hover { + background: #383856; +} + +.ipd-btn--primary { + background: #4f7cce; + color: #fff; +} + +.ipd-btn--primary:hover:not(:disabled) { + background: #5d8de0; +} + +.ipd-btn--primary:disabled { + opacity: 0.4; + cursor: not-allowed; +} diff --git a/renderer/src/ImportPlaylistDialog.jsx b/renderer/src/ImportPlaylistDialog.jsx new file mode 100644 index 00000000..f741ab05 --- /dev/null +++ b/renderer/src/ImportPlaylistDialog.jsx @@ -0,0 +1,107 @@ +import { useState, useEffect, useRef } from 'react'; +import './ImportPlaylistDialog.css'; + +export default function ImportPlaylistDialog({ playlists, onConfirm, onCancel }) { + const [selected, setSelected] = useState('library'); + const [createName, setCreateName] = useState(''); + const [showCreate, setShowCreate] = useState(false); + const createInputRef = useRef(null); + + useEffect(() => { + if (showCreate) createInputRef.current?.focus(); + }, [showCreate]); + + const handleConfirm = () => { + if (showCreate) { + const name = createName.trim(); + if (!name) return; + onConfirm({ type: 'create', name }); + } else { + onConfirm({ type: selected === 'library' ? 'library' : 'existing', id: selected }); + } + }; + + return ( +
+
e.stopPropagation()}> +

Import to Playlist

+

Choose where to add the imported tracks:

+ +
+ + + {playlists.map((pl) => ( + + ))} + + +
+ + {showCreate && ( + setCreateName(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleConfirm(); + if (e.key === 'Escape') onCancel(); + }} + /> + )} + +
+ + +
+
+
+ ); +} diff --git a/renderer/src/MusicLibrary.css b/renderer/src/MusicLibrary.css index eaf155ae..6249fac8 100644 --- a/renderer/src/MusicLibrary.css +++ b/renderer/src/MusicLibrary.css @@ -13,6 +13,7 @@ flex-direction: column; overflow: hidden; min-width: 0; + position: relative; } /* Search */ @@ -157,6 +158,14 @@ flex-shrink: 0; } +.cell-linked-badge { + font-size: 11px; + flex-shrink: 0; + margin-right: 3px; + opacity: 0.75; + cursor: default; +} + .cell-title-text { overflow: hidden; text-overflow: ellipsis; @@ -209,6 +218,7 @@ padding: 4px 0; z-index: 1000; min-width: 170px; + width: max-content; box-shadow: 0 6px 20px rgba(0, 0, 0, 0.6); user-select: none; max-height: calc(100vh - 16px); @@ -223,6 +233,7 @@ display: flex; align-items: center; gap: 8px; + white-space: nowrap; } .context-menu-item:hover { @@ -274,6 +285,34 @@ background: #1a3a1a !important; } +/* Track is being normalized or re-analyzed */ +.row--analyzing { + opacity: 0.45; +} + +/* New row: space slides open (scaleY), then content fades in */ +@keyframes rowSlideIn { + 0% { + transform: scaleY(0); + transform-origin: top center; + opacity: 0; + } + 45% { + transform: scaleY(1); + transform-origin: top center; + opacity: 0; + } + 100% { + transform: scaleY(1); + transform-origin: top center; + opacity: 1; + } +} + +.row--new { + animation: rowSlideIn 0.45s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards; +} + /* BPM overridden indicator */ .bpm--overridden { color: #7eb8f7; @@ -311,6 +350,77 @@ display: block; } +/* ── Set BPM inline row ─────────────────────────────────────────────────── */ +.context-menu-item--set-bpm { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 10px; + cursor: default; +} + +.context-menu-item--set-bpm:hover { + background: transparent; +} + +.set-bpm-label { + font-size: 12px; + color: #aaa; + white-space: nowrap; +} + +.set-bpm-input { + width: 72px; + background: #1a1a1a; + border: 1px solid #555; + border-radius: 4px; + color: #e0e0e0; + font-size: 12px; + padding: 2px 6px; + outline: none; +} + +.set-bpm-input:focus { + border-color: #7eb8f7; +} + +.set-bpm-apply { + background: #3a3a3a; + border: 1px solid #555; + border-radius: 4px; + color: #7eb8f7; + font-size: 12px; + padding: 2px 6px; + cursor: pointer; +} + +.set-bpm-apply:hover:not(:disabled) { + background: #4a4a4a; +} + +.set-bpm-apply:disabled { + opacity: 0.4; + cursor: default; +} + +/* ── BPM cell grid-shift indicator dot ──────────────────────────────────── */ +.bpm-grid-shift-dot { + display: inline-block; + width: 5px; + height: 5px; + border-radius: 50%; + background: #f7c07e; + margin-left: 3px; + vertical-align: middle; + position: relative; + top: -1px; +} + +.bpm--grid-shifted { + /* orange tint when grid has a non-zero offset */ + color: #f7c07e; +} + /* ── Playlist header bar ───────────────────────────────────────────────── */ .playlist-header-bar { display: flex; @@ -560,3 +670,135 @@ .music-library--with-panel .music-library__main { border-right: 1px solid #2a2a2a; } + +/* ── Toast notification ───────────────────────────────────────────────── */ +.music-library-toast { + position: absolute; + bottom: 16px; + left: 50%; + transform: translateX(-50%); + background: #2a2a2a; + border: 1px solid #1db954; + color: #e0e0e0; + font-size: 13px; + padding: 8px 16px; + border-radius: 6px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + z-index: 2000; + white-space: nowrap; + pointer-events: none; +} + +.music-library-toast--warn { + border-color: #f4a261; + color: #f4a261; +} + +/* ── Add to new playlist inline input ── */ +.ctx-new-playlist-item { + padding: 6px 12px; + cursor: default; +} + +.ctx-new-playlist-form { + display: flex; + flex-direction: column; + gap: 4px; + padding: 6px 12px; +} + +.ctx-new-playlist-input { + background: #1a1a1a; + border: 1px solid #555; + border-radius: 4px; + color: #e0e0e0; + font-size: 12px; + padding: 5px 8px; + outline: none; + width: 100%; + box-sizing: border-box; +} + +.ctx-new-playlist-input:focus { + border-color: #1db954; +} + +.ctx-new-playlist-error { + font-size: 11px; + color: #e06c6c; +} + +/* ── Cue column indicator ───────────────────────────────────────────────── */ +.cell.cue { + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + padding: 0; +} + +.cue-dot { + font-size: 12px; + line-height: 1; + user-select: none; +} + +.cue-dot--has { + color: #ff6b35; +} + +.cue-dot--empty { + color: #444; +} + +.cell.cue:hover .cue-dot--empty { + color: #888; +} + +/* ── Cue points panel ───────────────────────────────────────────────────── */ +.cue-panel { + width: 340px; + min-width: 260px; + max-width: 400px; + display: flex; + flex-direction: column; + background: #1e1e1e; + border-left: 1px solid #2a2a2a; + overflow: hidden; +} + +.cue-panel__header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px 8px; + border-bottom: 1px solid #2a2a2a; + gap: 8px; + flex-shrink: 0; +} + +.cue-panel__title { + font-size: 13px; + font-weight: 600; + color: #e0e0e0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; +} + +.cue-panel__close { + background: none; + border: none; + color: #888; + font-size: 14px; + cursor: pointer; + padding: 2px 4px; + border-radius: 3px; + flex-shrink: 0; +} + +.cue-panel__close:hover { + color: #e0e0e0; + background: #2a2a2a; +} diff --git a/renderer/src/MusicLibrary.jsx b/renderer/src/MusicLibrary.jsx index 3f9c2685..1b2e4965 100644 --- a/renderer/src/MusicLibrary.jsx +++ b/renderer/src/MusicLibrary.jsx @@ -1,4 +1,12 @@ -import { useEffect, useState, useRef, useCallback, useMemo } from 'react'; +import { + useEffect, + useState, + useRef, + useCallback, + useMemo, + createContext, + useContext, +} from 'react'; import { List } from 'react-window'; import { DndContext, @@ -20,6 +28,7 @@ import { artworkUrl } from './artworkUrl.js'; import { parseQuery } from './searchParser.js'; import TrackDetails from './TrackDetails.jsx'; import RatingStars from './RatingStars.jsx'; +import BeatGridEditor from './BeatGridEditor.jsx'; import './MusicLibrary.css'; const PAGE_SIZE = 50; @@ -38,6 +47,7 @@ const ALL_COLUMNS = [ { key: 'bpm', label: 'BPM', width: '62px' }, { key: 'key_camelot', label: 'Key', width: '52px' }, { key: 'loudness', label: 'Loudness (LUFS)', width: '115px' }, + { key: 'cue', label: '◆', width: '28px' }, { key: 'album', label: 'Album', width: 'minmax(80px, 1fr)' }, { key: 'year', label: 'Year', width: '50px' }, { key: 'label', label: 'Label', width: '100px' }, @@ -58,6 +68,7 @@ const DEFAULT_COL_VIS = { bpm: true, key_camelot: true, loudness: true, + cue: true, album: false, year: false, label: false, @@ -104,8 +115,17 @@ function renderCell(t, colKey) { return t.title; case 'artist': return t.artist || 'Unknown'; - case 'bpm': - return bpmValue ?? '...'; + case 'bpm': { + const display = bpmValue ?? '...'; + const hasGridShift = (t.beatgrid_offset ?? 0) !== 0; + if (!hasGridShift) return display; + return ( + 0 ? '+' : ''}${t.beatgrid_offset} ms`}> + {display} + + + ); + } case 'key_camelot': return t.key_camelot ?? '...'; case 'loudness': @@ -123,6 +143,8 @@ function renderCell(t, colKey) { return '—'; } } + case 'cue': + return null; // rendered as icon button in LibraryRow case 'rating': return null; // rendered as interactive RatingStars in LibraryRow case 'user_tags': @@ -141,7 +163,47 @@ function cellClass(colKey, t) { colKey ); const over = colKey === 'bpm' && t.bpm_override != null; - return `cell ${colKey}${numeric ? ' numeric' : ''}${over ? ' bpm--overridden' : ''}`; + const gridShifted = colKey === 'bpm' && (t.beatgrid_offset ?? 0) !== 0; + return `cell ${colKey}${numeric ? ' numeric' : ''}${over ? ' bpm--overridden' : ''}${gridShifted ? ' bpm--grid-shifted' : ''}`; +} + +// ── SubItem context — defined outside MusicLibrary so SubItem's type is stable across +// re-renders, preventing unmount/remount that would kill CSS hover state ────────────── +const SubItemCtx = createContext(null); + +function SubItem({ id, label, children, wide, scrollable }) { + const ctx = useContext(SubItemCtx); + if (!ctx) return null; + const { isOverlay, onDrillDown } = ctx; + if (isOverlay) { + return ( +
{ + e.stopPropagation(); + onDrillDown({ id, label, content: children }); + }} + > + {label} +
+ ); + } + return ( +
+ {label} +
+ {children} +
+
+ ); } // ── LibraryRow — outside MusicLibrary so react-virtualized doesn't remount on re-render ── @@ -155,10 +217,14 @@ function LibraryRow({ onDoubleClick, onContextMenu, onRatingChange, + onCueClick, + onDragStart, visibleColumns, gridTemplate, minScrollWidth, mediaPort, + newTrackIds, + onAnimationEnd, }) { const t = tracks[index]; if (!t) { @@ -173,14 +239,22 @@ function LibraryRow({ } const isSelected = selectedIds.has(t.id); const isPlaying = currentTrackId === t.id; + const isNew = newTrackIds?.has(t.id); return (
onDragStart(e, t)} onClick={(e) => onRowClick(e, t, index)} onDoubleClick={() => onDoubleClick(t, index)} onContextMenu={(e) => onContextMenu(e, t, index)} + onAnimationEnd={isNew ? () => onAnimationEnd?.(t.id) : undefined} > {visibleColumns.map((col) => col.key === 'index' ? ( @@ -198,6 +272,26 @@ function LibraryRow({ ▶
+ ) : col.key === 'cue' ? ( +
{ + e.stopPropagation(); + onCueClick?.(t); + }} + title={ + t.cue_count > 0 + ? `${t.cue_count} cue point(s) — click to edit` + : 'No cue points — click to add' + } + > + {t.cue_count > 0 ? ( + + ) : ( + + )} +
) : col.key === 'rating' ? (
e.stopPropagation()}> onRatingChange(t.id, val)} /> @@ -214,6 +308,11 @@ function LibraryRow({ ) : ( )} + {t.is_linked ? ( + + 🔗 + + ) : null} {t.title}
) : ( @@ -236,10 +335,13 @@ function SortableRow({ onDoubleClick, onContextMenu, onRatingChange, + onCueClick, visibleColumns, gridTemplate, minScrollWidth, mediaPort, + isNew, + onAnimationEnd, }) { const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: t.id, @@ -255,11 +357,16 @@ function SortableRow({
onRowClick(e, t, index)} onDoubleClick={() => onDoubleClick(t, index)} onContextMenu={(e) => onContextMenu(e, t, index)} + onAnimationEnd={isNew ? () => onAnimationEnd?.(t.id) : undefined} > {visibleColumns.map((col) => col.key === 'index' ? ( @@ -277,6 +384,26 @@ function SortableRow({ ▶
+ ) : col.key === 'cue' ? ( +
{ + e.stopPropagation(); + onCueClick?.(t); + }} + title={ + t.cue_count > 0 + ? `${t.cue_count} cue point(s) — click to edit` + : 'No cue points — click to add' + } + > + {t.cue_count > 0 ? ( + + ) : ( + + )} +
) : col.key === 'rating' ? (
e.stopPropagation()}> onRatingChange(t.id, val)} /> @@ -293,6 +420,11 @@ function SortableRow({ ) : ( )} + {t.is_linked ? ( + + 🔗 + + ) : null} {t.title}
) : ( @@ -328,7 +460,16 @@ function SortableColItem({ colKey, label, checked, onToggle }) { function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { const isPlaylistView = selectedPlaylist !== 'music'; - const { play, stop, currentTrack, currentPlaylistId, mediaPort } = usePlayer(); + const { + play, + stop, + currentTrack, + currentPlaylistId, + mediaPort, + patchCurrentTrack, + reloadCurrentTrack, + updateQueue, + } = usePlayer(); // Only highlight a track as "playing" when the source context matches this view. // Library view: only highlight when played from library (currentPlaylistId === null). @@ -343,11 +484,18 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { const [tracks, setTracks] = useState([]); const [hasMore, setHasMore] = useState(true); + const [newTrackIds, setNewTrackIds] = useState(new Set()); // IDs of rows to animate in const [selectedIds, setSelectedIds] = useState(new Set()); const [contextMenu, setContextMenu] = useState(null); // { x, y, targetIds } + const [toast, setToast] = useState(null); // { msg, ok } | null + const toastTimerRef = useRef(null); const [drillStack, setDrillStack] = useState([]); // overlay drill-down stack [{ id, label, content }] const [playlistSubmenu, setPlaylistSubmenu] = useState(null); // [{ id, name, color, is_member }] + const [newPlaylistInputActive, setNewPlaylistInputActive] = useState(false); + const [newPlaylistName, setNewPlaylistName] = useState(''); + const [newPlaylistError, setNewPlaylistError] = useState(''); + const newPlaylistInputRef = useRef(null); const [loadKey, setLoadKey] = useState(0); const [playlistInfo, setPlaylistInfo] = useState(null); // { name, total_duration, track_count } const [activeId, setActiveId] = useState(null); // DnD active drag id @@ -355,8 +503,10 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { const [colVis, setColVis] = useState(loadColVis); const [colOrder, setColOrder] = useState(loadColOrder); const [colMenuAnchor, setColMenuAnchor] = useState(null); // { x, y } | null + const [beatGridEditorTrack, setBeatGridEditorTrack] = useState(null); const [detailsTrack, setDetailsTrack] = useState(null); const [detailsBulkTracks, setDetailsBulkTracks] = useState(null); // array | null + const [bpmEditValue, setBpmEditValue] = useState(''); // value for inline Set BPM input const offsetRef = useRef(0); const loadingRef = useRef(false); @@ -369,6 +519,32 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { const headerRef = useRef(null); const headerScrollRef = useRef(null); // syncs header horizontal scroll to content scroll const dndScrollRef = useRef(null); // ref to playlist DnD scroll container + // When set to true, the next loadTracks call will animate truly-new incoming rows + const animateNextLoadRef = useRef(false); + // Snapshot of IDs already in the list before a reload — used to diff truly-new rows + const preReloadIdsRef = useRef(new Set()); + // Refs that stay in sync so the onLibraryUpdated closure (empty deps) can read current values + const selectedPlaylistRef = useRef(selectedPlaylist); + const searchRef = useRef(search); + const currentPlaylistIdRef = useRef(currentPlaylistId); + const updateQueueRef = useRef(updateQueue); + useEffect(() => { + selectedPlaylistRef.current = selectedPlaylist; + }, [selectedPlaylist]); + useEffect(() => { + searchRef.current = search; + }, [search]); + useEffect(() => { + currentPlaylistIdRef.current = currentPlaylistId; + }, [currentPlaylistId]); + useEffect(() => { + updateQueueRef.current = updateQueue; + }, [updateQueue]); + + // Track previous view identity so the reset effect knows whether the VIEW changed + // (search/playlist switch → clear selection) vs. just a data reload (loadKey bump → keep selection) + const prevSelectedPlaylistRef = useRef(selectedPlaylist); + const prevSearchRef = useRef(search); const visibleColumns = useMemo( () => colOrder.map((k) => COL_BY_KEY[k]).filter((c) => c && colVis[c.key] !== false), @@ -413,7 +589,20 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { if (token !== resetTokenRef.current) return; // stale — reset happened mid-flight - setTracks((prev) => [...prev, ...rows]); + // On first page: replace all tracks atomically (no flash from empty-list state) + if (offsetRef.current === 0) { + // Animate only rows that weren't already in the list before reload + if (animateNextLoadRef.current) { + animateNextLoadRef.current = false; + const truly = new Set( + rows.filter((r) => !preReloadIdsRef.current.has(r.id)).map((r) => r.id) + ); + if (truly.size > 0) setNewTrackIds((prev) => new Set([...prev, ...truly])); + } + setTracks(rows); + } else { + setTracks((prev) => [...prev, ...rows]); + } offsetRef.current += rows.length; if (rows.length < PAGE_SIZE) { @@ -431,7 +620,11 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { // For BPM, prefer the override value const va = sortBy.key === 'bpm' ? (a.bpm_override ?? a.bpm ?? '') : (a[sortBy.key] ?? ''); const vb = sortBy.key === 'bpm' ? (b.bpm_override ?? b.bpm ?? '') : (b[sortBy.key] ?? ''); - if (typeof va === 'string') return sortBy.asc ? va.localeCompare(vb) : vb.localeCompare(va); + if (typeof va === 'string' || typeof vb === 'string') { + const sa = String(va ?? ''); + const sb = String(vb ?? ''); + return sortBy.asc ? sa.localeCompare(sb) : sb.localeCompare(sa); + } if (typeof va === 'number') return sortBy.asc ? va - vb : vb - va; return 0; }); @@ -440,16 +633,28 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { }, [tracks, sortBy]); useEffect(() => { + // Snapshot IDs currently visible so loadTracks can diff truly-new rows + preReloadIdsRef.current = new Set(sortedTracksRef.current.map((t) => t.id)); + + // Only clear selection + reset sort when the VIEW changes (user navigated to a + // different playlist or typed a new search). Pure data reloads (loadKey bumps from + // import/playlist-updated) should preserve selection so the user isn't surprised. + const viewChanged = + prevSelectedPlaylistRef.current !== selectedPlaylist || prevSearchRef.current !== search; + prevSelectedPlaylistRef.current = selectedPlaylist; + prevSearchRef.current = search; + offsetRef.current = 0; loadingRef.current = false; hasMoreRef.current = true; resetTokenRef.current += 1; - setTracks([]); setHasMore(true); - setSelectedIds(new Set()); - lastSelectedIndexRef.current = null; - setSortBy({ key: 'index', asc: true }); // reset sort when switching view/search - setSortSaved(true); + if (viewChanged) { + setSelectedIds(new Set()); + lastSelectedIndexRef.current = null; + setSortBy({ key: 'index', asc: true }); + setSortSaved(true); + } // Use setTimeout so the state updates above are committed before we load. // The cleanup cancels the timer — in StrictMode this means the first @@ -467,19 +672,92 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { // Listen for background analysis updates useEffect(() => { const unsub = window.api.onTrackUpdated(({ trackId, analysis }) => { - setTracks((prev) => - prev.map((t) => (t.id === trackId ? { ...t, ...analysis, analyzed: 1 } : t)) - ); + // analyzed: 0 means an intermediate event (normalization done, re-analysis pending) + // analyzed: undefined or 1 means analysis is complete + const isAnalyzed = analysis.analyzed !== 0; + const merged = { ...analysis, analyzed: isAnalyzed ? 1 : 0 }; + + setTracks((prev) => prev.map((t) => (t.id === trackId ? { ...t, ...merged } : t))); + + // Keep PlayerContext's currentTrack in sync + patchCurrentTrack(trackId, merged); + }); + return unsub; + }, [patchCurrentTrack, reloadCurrentTrack]); + + // Patch cue_count when cue points are added/removed for any track (e.g. library-wide gen) + useEffect(() => { + const unsub = window.api.onCuePointsUpdated(({ trackId, cueCount }) => { + if (cueCount == null) return; // events without a count (e.g. CuePointsEditor) are handled there + setTracks((prev) => prev.map((t) => (t.id === trackId ? { ...t, cue_count: cueCount } : t))); }); return unsub; }, []); // Refresh list when new tracks are imported useEffect(() => { - const unsub = window.api.onLibraryUpdated(() => setLoadKey((k) => k + 1)); + const unsub = window.api.onLibraryUpdated(async () => { + const isDefaultView = selectedPlaylistRef.current === 'music' && !searchRef.current; + if (isDefaultView) { + // getTracks orders by created_at DESC (newest first), so using currentCount as the + // offset would skip the N newest tracks and return the (N+1)th oldest — which is + // already on screen, not the just-imported track (#204). Fetch from offset 0 and + // dedup against what's already loaded instead. + const rows = await window.api.getTracks({ limit: PAGE_SIZE, offset: 0 }); + // Re-check: user may have navigated to a playlist while the fetch was in flight. + // Without this guard the stale all-music rows would be appended on top of the + // playlist's tracks, showing the wrong list. + if (selectedPlaylistRef.current !== 'music' || searchRef.current) return; + if (rows.length > 0) { + const existingIds = new Set(sortedTracksRef.current.map((t) => t.id)); + const newRows = rows.filter((r) => !existingIds.has(r.id)); + if (newRows.length > 0) { + setNewTrackIds((prev) => new Set([...prev, ...newRows.map((r) => r.id)])); + setTracks((prev) => { + const prevIds = new Set(prev.map((t) => t.id)); + const deduped = newRows.filter((r) => !prevIds.has(r.id)); + if (deduped.length === 0) return prev; + const merged = [...prev, ...deduped]; + // Keep the player queue in sync when playing from the music (all-tracks) view. + if (currentPlaylistIdRef.current === null) { + updateQueueRef.current(merged); + } + return merged; + }); + offsetRef.current = sortedTracksRef.current.length + newRows.length; + // If the batch we fetched is smaller than a full page, we've reached the end. + if (rows.length < PAGE_SIZE) { + hasMoreRef.current = false; + setHasMore(false); + } + } + } + } else { + // Filtered / playlist view: full reload (content may have changed meaningfully) + preReloadIdsRef.current = new Set(sortedTracksRef.current.map((t) => t.id)); + animateNextLoadRef.current = true; + setLoadKey((k) => k + 1); + } + }); return unsub; }, []); + // Keep player queue in sync when tracks are added/removed from the current playlist (#213). + // The all-tracks view is handled inside onLibraryUpdated; playlist view needs its own sync + // because it does a full reload (setLoadKey) rather than a soft-append. + useEffect(() => { + if ( + isPlaylistView && + currentPlaylistId !== null && + String(currentPlaylistId) === String(selectedPlaylist) && + sortedTracksRef.current.length > 0 + ) { + updateQueue(sortedTracksRef.current); + } + // Only react to track count changes — sort-order changes should not reshuffle the queue. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [tracks.length, isPlaylistView, selectedPlaylist, currentPlaylistId]); + // Reload playlist info (name, duration) when entering playlist view or tracks change useEffect(() => { if (!isPlaylistView) { @@ -545,6 +823,9 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { const close = () => { setContextMenu(null); setDrillStack([]); + setNewPlaylistInputActive(false); + setNewPlaylistName(''); + setNewPlaylistError(''); }; document.addEventListener('mousedown', close); return () => document.removeEventListener('mousedown', close); @@ -592,6 +873,12 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { setDetailsBulkTracks(null); }, []); + // ── Cue column click — open Prepare Track window ────────────────────────── + + const handleCueClick = useCallback((track) => { + setBeatGridEditorTrack(track); + }, []); + const handleDetailsSave = useCallback((result) => { if (Array.isArray(result)) { // bulk save: update each track in state @@ -746,6 +1033,38 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { for (const id of targetIds) await window.api.reanalyzeTrack(id); }, [contextMenu]); + const showToast = useCallback((msg, ok = true) => { + clearTimeout(toastTimerRef.current); + setToast({ msg, ok }); + toastTimerRef.current = setTimeout(() => setToast(null), 4000); + }, []); + + const handleNormalizeTracks = useCallback(async () => { + const targetIds = contextMenu?.targetIds ?? []; + setContextMenu(null); + + const { normalized, skipped } = await window.api.normalizeTracksAudio({ trackIds: targetIds }); + if (normalized === 0) { + showToast( + skipped > 0 + ? 'No analyzed tracks — analyze tracks first to get loudness data.' + : 'Nothing to normalize.', + false + ); + } else { + showToast(`Gain applied to ${normalized} track${normalized !== 1 ? 's' : ''}.`); + } + // track-updated IPC events carry updated replay_gain values into the track list + }, [contextMenu, showToast]); + + const handleResetNormalization = useCallback(async () => { + const targetIds = contextMenu?.targetIds ?? []; + setContextMenu(null); + await window.api.resetNormalization({ trackIds: targetIds }); + // track-updated IPC events carry replay_gain: null back to the track list + showToast(`Gain reset for ${targetIds.length} track${targetIds.length !== 1 ? 's' : ''}.`); + }, [contextMenu, showToast]); + const handleRemove = useCallback(async () => { const targetIds = contextMenu?.targetIds ?? []; const n = targetIds.length; @@ -788,6 +1107,33 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { } }, []); + const handleAddToNewPlaylist = useCallback( + async (e) => { + e?.preventDefault(); + const name = newPlaylistName.trim(); + if (!name) { + setNewPlaylistInputActive(false); + setNewPlaylistName(''); + return; + } + const targetIds = contextMenu?.targetIds ?? []; + const result = await window.api.createPlaylist(name, null); + if (result?.error === 'duplicate') { + setNewPlaylistError('Name already exists'); + newPlaylistInputRef.current?.focus(); + return; + } + if (result?.id && targetIds.length) { + await window.api.addTracksToPlaylist(result.id, targetIds); + } + setNewPlaylistInputActive(false); + setNewPlaylistName(''); + setNewPlaylistError(''); + setContextMenu(null); + }, + [contextMenu, newPlaylistName] + ); + const handleBpmAdjust = useCallback( async (factor) => { const targetIds = contextMenu?.targetIds ?? []; @@ -817,6 +1163,40 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { [contextMenu] ); + const handleSetBpm = useCallback( + async (rawValue) => { + const targetIds = contextMenu?.targetIds ?? []; + const parsed = parseFloat(rawValue); + if (!Number.isFinite(parsed) || parsed <= 0) return; + const bpmOverride = Math.round(parsed * 10) / 10; + setContextMenu(null); + setBpmEditValue(''); + if (!targetIds.length) return; + + // Optimistic update + setTracks((prev) => + prev.map((t) => (targetIds.includes(t.id) ? { ...t, bpm_override: bpmOverride } : t)) + ); + + // Persist each track + await Promise.all( + targetIds.map((id) => window.api.updateTrack(id, { bpm_override: bpmOverride })) + ); + }, + [contextMenu] + ); + + const handleApplyBeatGrid = useCallback( + async (trackId, { beatgrid_offset, bpm_override }) => { + const update = { beatgrid_offset }; + if (bpm_override != null) update.bpm_override = bpm_override; + setTracks((prev) => prev.map((t) => (t.id === trackId ? { ...t, ...update } : t))); + patchCurrentTrack(trackId, update); + await window.api.updateTrack(trackId, update); + }, + [patchCurrentTrack] + ); + const handleFindSimilar = useCallback( (queryText) => { setContextMenu(null); @@ -848,6 +1228,24 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { [selectedPlaylist] ); + const handleTrackDragStart = useCallback( + (e, track) => { + const ids = selectedIds.has(track.id) ? [...selectedIds] : [track.id]; + e.dataTransfer.effectAllowed = 'copy'; + e.dataTransfer.setData('application/dj-tracks', JSON.stringify(ids)); + }, + [selectedIds] + ); + + const handleRowAnimationEnd = useCallback((trackId) => { + setNewTrackIds((prev) => { + if (!prev.has(trackId)) return prev; + const next = new Set(prev); + next.delete(trackId); + return next; + }); + }, []); + const handleSaveOrder = useCallback(async () => { await window.api.reorderPlaylist( Number(selectedPlaylist), @@ -860,8 +1258,8 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { // ── Misc ─────────────────────────────────────────────────────────────────── const handleItemsRendered = useCallback( - ({ visibleStopIndex }) => { - if (visibleStopIndex >= sortedTracksRef.current.length - PRELOAD_TRIGGER) { + ({ stopIndex }) => { + if (stopIndex >= sortedTracksRef.current.length - PRELOAD_TRIGGER) { loadTracks(); // loadTracks checks hasMoreRef and loadingRef internally } }, @@ -893,40 +1291,14 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { const activeTrack = activeId ? tracks.find((t) => t.id === activeId) : null; - // In normal mode: CSS hover fly-out. - // In overlay mode: clicking pushes children onto drillStack (drill-down navigation). - const SubItem = ({ id, label, children, wide, scrollable }) => { - const isOverlay = contextMenu?.overlayMode; - if (isOverlay) { - return ( -
{ - e.stopPropagation(); - setDrillStack((prev) => [...prev, { id, label, content: children }]); - }} - > - {label} -
- ); - } - return ( -
- {label} -
- {children} -
-
- ); - }; + const subItemCtxValue = useMemo( + () => ({ + isOverlay: !!contextMenu?.overlayMode, + onDrillDown: ({ id, label, content }) => + setDrillStack((prev) => [...prev, { id, label, content }]), + }), + [contextMenu?.overlayMode] + ); return (
No tracks in this playlist.
- Right-click tracks in your library to add them. + Drag tracks from your library here, or right-click to add.
) : ( ))}
@@ -1095,10 +1471,14 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { onDoubleClick: handleDoubleClick, onContextMenu: handleContextMenu, onRatingChange: handleRatingChange, + onCueClick: handleCueClick, + onDragStart: handleTrackDragStart, visibleColumns, gridTemplate, minScrollWidth, mediaPort, + newTrackIds, + onAnimationEnd: handleRowAnimationEnd, }} /> )} @@ -1106,386 +1486,528 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { {/* end .table-scroll-wrap */} {contextMenu && ( - <> - {contextMenu.overlayMode && ( + + <> + {contextMenu.overlayMode && ( +
{ + setContextMenu(null); + setDrillStack([]); + }} + /> + )}
{ - setContextMenu(null); - setDrillStack([]); - }} - /> - )} -
e.stopPropagation()} - > - {/* ── Overlay drill-down view ── */} - {contextMenu.overlayMode && drillStack.length > 0 ? ( - <> -
{ - e.stopPropagation(); - setDrillStack((prev) => prev.slice(0, -1)); - }} - > - ‹ {drillStack.length > 1 ? drillStack[drillStack.length - 2].label : 'Back'} -
-
- {drillStack[drillStack.length - 1].label} -
- {drillStack[drillStack.length - 1].content} - - ) : ( - <> - {/* ── Add to playlist ── */} - {playlistSubmenu !== null && - (playlistSubmenu.length === 0 ? ( -
- ➕ No playlists -
- ) : ( - - {playlistSubmenu.map((pl) => ( -
- !pl.is_member && - handleAddToPlaylist(pl.id, contextMenu?.targetIds ?? []) - } - > - {pl.color && ( - - )} - {pl.is_member ? '✓ ' : ''} - {pl.name} -
- ))} -
- ))} - - {/* ── Find similar ── */} - {contextMenu.targetTracks?.length > 0 && ( - - {contextMenu.targetTracks.length === 1 ? ( - /* ── Single-track options ── */ + className={[ + 'context-menu', + contextMenu.overlayMode ? 'context-menu--overlay' : '', + contextMenu.flipLeft ? 'context-menu--flip-left' : '', + contextMenu.flipUp ? 'context-menu--flip-up' : '', + ] + .filter(Boolean) + .join(' ')} + style={ + contextMenu.overlayMode + ? undefined + : { + top: contextMenu.y, + left: contextMenu.x, + '--submenu-max-h': `${contextMenu.submenuMaxH}px`, + } + } + onMouseDown={(e) => e.stopPropagation()} + > + {/* ── Overlay drill-down view ── */} + {contextMenu.overlayMode && drillStack.length > 0 ? ( + <> +
{ + e.stopPropagation(); + setDrillStack((prev) => prev.slice(0, -1)); + }} + > + ‹ {drillStack.length > 1 ? drillStack[drillStack.length - 2].label : 'Back'} +
+
+ {drillStack[drillStack.length - 1].label} +
+ {drillStack[drillStack.length - 1].content} + + ) : ( + <> + {/* ── Add to playlist ── */} + {playlistSubmenu !== null && + (playlistSubmenu.length === 0 ? ( <> - {contextMenu.track.key_camelot && ( - <> -
- 🎹 Key: {contextMenu.track.key_camelot.toUpperCase()} -
-
- handleFindSimilar( - `KEY is ${contextMenu.track.key_camelot.toUpperCase()}` - ) - } - > - Same key -
-
- handleFindSimilar( - `KEY adjacent ${contextMenu.track.key_camelot.toUpperCase()}` - ) - } - > - Adjacent — energy shift -
-
- handleFindSimilar( - `KEY mode switch ${contextMenu.track.key_camelot.toUpperCase()}` - ) - } - > - Mode switch — minor↔major -
-
- handleFindSimilar( - `KEY matches ${contextMenu.track.key_camelot.toUpperCase()}` - ) - } - > - All compatible keys -
- + {newPlaylistInputActive ? ( +
e.stopPropagation()} + > + { + setNewPlaylistName(e.target.value); + setNewPlaylistError(''); + }} + placeholder="Playlist name" + autoFocus + onBlur={handleAddToNewPlaylist} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setNewPlaylistInputActive(false); + setNewPlaylistName(''); + setNewPlaylistError(''); + } + }} + /> + {newPlaylistError && ( +
{newPlaylistError}
+ )} +
+ ) : ( +
{ + setNewPlaylistInputActive(true); + setTimeout(() => newPlaylistInputRef.current?.focus(), 0); + }} + > + ➕ Add to new playlist… +
)} - {(contextMenu.track.bpm_override ?? contextMenu.track.bpm) != null && - (() => { - const bpm = Math.round( - contextMenu.track.bpm_override ?? contextMenu.track.bpm - ); - return ( - <> - {contextMenu.track.key_camelot && ( -
- )} -
- ♩ BPM: {bpm} -
-
handleFindSimilar(`BPM is ${bpm}`)} - > - Exact BPM -
-
- handleFindSimilar(`BPM in range ${bpm - 5}-${bpm + 5}`) - } - > - Similar BPM (±5) -
-
- handleFindSimilar(`BPM in range ${bpm - 2}-${bpm + 2}`) - } - > - Very similar BPM (±2) -
- - ); - })()} - {contextMenu.track.key_camelot && - (contextMenu.track.bpm_override ?? contextMenu.track.bpm) != null && - (() => { - const bpm = Math.round( - contextMenu.track.bpm_override ?? contextMenu.track.bpm - ); - return ( - <> -
-
- 🎯 Combined -
-
- handleFindSimilar( - `KEY matches ${contextMenu.track.key_camelot.toUpperCase()} AND BPM in range ${bpm - 5}-${bpm + 5}` - ) - } - > - Compatible key + similar BPM -
- - ); - })()} - {(() => { - try { - const genres = JSON.parse(contextMenu.track.genres ?? '[]'); - if (!genres.length) return null; - return ( - <> -
-
- 🏷 Genre -
- {genres.slice(0, 3).map((g) => ( -
handleFindSimilar(`GENRE is ${g}`)} - > - {g} -
- ))} - - ); - } catch { - return null; - } - })()} ) : ( - /* ── Multi-track options only ── */ - (() => { - const tt = contextMenu.targetTracks; - const bpms = tt - .map((t) => t.bpm_override ?? t.bpm) - .filter((b) => b != null) - .map((b) => Math.round(b)); - const keys = tt.map((t) => t.key_camelot).filter(Boolean); - const allGenres = tt.flatMap((t) => { - try { - return JSON.parse(t.genres ?? '[]'); - } catch { - return []; - } - }); - const genreCount = allGenres.reduce((acc, g) => { - acc[g] = (acc[g] ?? 0) + 1; - return acc; - }, {}); - const topGenres = Object.entries(genreCount) - .sort((a, b) => b[1] - a[1]) - .slice(0, 3) - .map(([g]) => g); - const keyCounts = keys.reduce((acc, k) => { - const n = k.toLowerCase(); - acc[n] = (acc[n] ?? 0) + 1; - return acc; - }, {}); - const topKey = Object.entries(keyCounts).sort( - (a, b) => b[1] - a[1] - )[0]?.[0]; - const bpmMin = bpms.length ? Math.min(...bpms) : null; - const bpmMax = bpms.length ? Math.max(...bpms) : null; - return ( - <> -
- 📦 {tt.length} tracks selected -
- {bpms.length > 0 && bpmMin !== bpmMax && ( + +
{ + setNewPlaylistInputActive(true); + setTimeout(() => newPlaylistInputRef.current?.focus(), 0); + }} + > + {newPlaylistInputActive ? ( +
e.stopPropagation()} + > + { + setNewPlaylistName(e.target.value); + setNewPlaylistError(''); + }} + placeholder="Playlist name" + autoFocus + onBlur={handleAddToNewPlaylist} + onKeyDown={(e) => { + if (e.key === 'Escape') { + setNewPlaylistInputActive(false); + setNewPlaylistName(''); + setNewPlaylistError(''); + } + }} + /> + {newPlaylistError && ( +
{newPlaylistError}
+ )} +
+ ) : ( + '✚ New playlist…' + )} +
+
+ {playlistSubmenu.map((pl) => ( +
+ !pl.is_member && + handleAddToPlaylist(pl.id, contextMenu?.targetIds ?? []) + } + > + {pl.color && ( + + )} + {pl.is_member ? '✓ ' : ''} + {pl.name} +
+ ))} + + ))} + + {/* ── Find similar ── */} + {contextMenu.targetTracks?.length > 0 && ( + + {contextMenu.targetTracks.length === 1 ? ( + /* ── Single-track options ── */ + <> + {contextMenu.track.key_camelot && ( + <> +
+ 🎹 Key: {contextMenu.track.key_camelot.toUpperCase()} +
- handleFindSimilar(`BPM in range ${bpmMin}-${bpmMax}`) + handleFindSimilar( + `KEY is ${contextMenu.track.key_camelot.toUpperCase()}` + ) } > - BPM range {bpmMin}–{bpmMax} + Same key
- )} - {bpms.length > 0 && bpmMin === bpmMax && (
handleFindSimilar(`BPM is ${bpmMin}`)} + onClick={() => + handleFindSimilar( + `KEY adjacent ${contextMenu.track.key_camelot.toUpperCase()}` + ) + } > - BPM {bpmMin} (all same) + Adjacent — energy shift
- )} - {topKey && (
- handleFindSimilar(`KEY matches ${topKey.toUpperCase()}`) + handleFindSimilar( + `KEY mode switch ${contextMenu.track.key_camelot.toUpperCase()}` + ) } > - Keys compatible with {topKey.toUpperCase()} + Mode switch — minor↔major
- )} - {topKey && bpms.length > 0 && (
handleFindSimilar( - `KEY matches ${topKey.toUpperCase()} AND BPM in range ${bpmMin}-${bpmMax}` + `KEY matches ${contextMenu.track.key_camelot.toUpperCase()}` ) } > - Compatible key + BPM range + All compatible keys
- )} - {topGenres.map((g) => ( -
handleFindSimilar(`GENRE is ${g}`)} - > - Genre: {g} + + )} + {(contextMenu.track.bpm_override ?? contextMenu.track.bpm) != null && + (() => { + const bpm = Math.round( + contextMenu.track.bpm_override ?? contextMenu.track.bpm + ); + return ( + <> + {contextMenu.track.key_camelot && ( +
+ )} +
+ ♩ BPM: {bpm} +
+
handleFindSimilar(`BPM is ${bpm}`)} + > + Exact BPM +
+
+ handleFindSimilar(`BPM in range ${bpm - 5}-${bpm + 5}`) + } + > + Similar BPM (±5) +
+
+ handleFindSimilar(`BPM in range ${bpm - 2}-${bpm + 2}`) + } + > + Very similar BPM (±2) +
+ + ); + })()} + {contextMenu.track.key_camelot && + (contextMenu.track.bpm_override ?? contextMenu.track.bpm) != null && + (() => { + const bpm = Math.round( + contextMenu.track.bpm_override ?? contextMenu.track.bpm + ); + return ( + <> +
+
+ 🎯 Combined +
+
+ handleFindSimilar( + `KEY matches ${contextMenu.track.key_camelot.toUpperCase()} AND BPM in range ${bpm - 5}-${bpm + 5}` + ) + } + > + Compatible key + similar BPM +
+ + ); + })()} + {(() => { + try { + const genres = JSON.parse(contextMenu.track.genres ?? '[]'); + if (!genres.length) return null; + return ( + <> +
+
+ 🏷 Genre +
+ {genres.slice(0, 3).map((g) => ( +
handleFindSimilar(`GENRE is ${g}`)} + > + {g} +
+ ))} + + ); + } catch { + return null; + } + })()} + + ) : ( + /* ── Multi-track options only ── */ + (() => { + const tt = contextMenu.targetTracks; + const bpms = tt + .map((t) => t.bpm_override ?? t.bpm) + .filter((b) => b != null) + .map((b) => Math.round(b)); + const keys = tt.map((t) => t.key_camelot).filter(Boolean); + const allGenres = tt.flatMap((t) => { + try { + return JSON.parse(t.genres ?? '[]'); + } catch { + return []; + } + }); + const genreCount = allGenres.reduce((acc, g) => { + acc[g] = (acc[g] ?? 0) + 1; + return acc; + }, {}); + const topGenres = Object.entries(genreCount) + .sort((a, b) => b[1] - a[1]) + .slice(0, 3) + .map(([g]) => g); + const keyCounts = keys.reduce((acc, k) => { + const n = k.toLowerCase(); + acc[n] = (acc[n] ?? 0) + 1; + return acc; + }, {}); + const topKey = Object.entries(keyCounts).sort( + (a, b) => b[1] - a[1] + )[0]?.[0]; + const bpmMin = bpms.length ? Math.min(...bpms) : null; + const bpmMax = bpms.length ? Math.max(...bpms) : null; + return ( + <> +
+ 📦 {tt.length} tracks selected
- ))} - - ); - })() - )} - - )} - - {/* ── separator ── */} -
+ {bpms.length > 0 && bpmMin !== bpmMax && ( +
+ handleFindSimilar(`BPM in range ${bpmMin}-${bpmMax}`) + } + > + BPM range {bpmMin}–{bpmMax} +
+ )} + {bpms.length > 0 && bpmMin === bpmMax && ( +
handleFindSimilar(`BPM is ${bpmMin}`)} + > + BPM {bpmMin} (all same) +
+ )} + {topKey && ( +
+ handleFindSimilar(`KEY matches ${topKey.toUpperCase()}`) + } + > + Keys compatible with {topKey.toUpperCase()} +
+ )} + {topKey && bpms.length > 0 && ( +
+ handleFindSimilar( + `KEY matches ${topKey.toUpperCase()} AND BPM in range ${bpmMin}-${bpmMax}` + ) + } + > + Compatible key + BPM range +
+ )} + {topGenres.map((g) => ( +
handleFindSimilar(`GENRE is ${g}`)} + > + Genre: {g} +
+ ))} + + ); + })() + )} + + )} - {/* ── Edit Details ── */} -
{ - const targetTracks = contextMenu?.targetTracks ?? []; - setContextMenu(null); - if (targetTracks.length === 1) { - setDetailsBulkTracks(null); - setDetailsTrack(targetTracks[0]); - setSelectedIds(new Set([targetTracks[0].id])); - } else if (targetTracks.length > 1) { - setDetailsTrack(null); - setDetailsBulkTracks(targetTracks); - } - }} - > - ✏️ Edit Details{selectionLabel} -
+ {/* ── separator ── */} +
- {/* ── Analysis submenu ── */} - -
- 🔄 Re-analyze + {/* ── Edit Details ── */} +
{ + const targetTracks = contextMenu?.targetTracks ?? []; + setContextMenu(null); + if (targetTracks.length === 1) { + setDetailsBulkTracks(null); + setDetailsTrack(targetTracks[0]); + setSelectedIds(new Set([targetTracks[0].id])); + } else if (targetTracks.length > 1) { + setDetailsTrack(null); + setDetailsBulkTracks(targetTracks); + } + }} + > + ✏️ Edit Details{selectionLabel}
-
- -
handleBpmAdjust(2)}> - ✕2 Double BPM -
-
handleBpmAdjust(0.5)}> - ÷2 Halve BPM -
-
- - {/* ── Remove ── */} - {isPlaylistView ? ( - <> + {/* ── Prepare Track ── */} + {contextMenu?.targetTracks?.length === 1 && (
{ + const track = + tracks.find((tr) => tr.id === contextMenu.targetTracks[0]?.id) ?? + contextMenu.targetTracks[0]; + setContextMenu(null); + if (track) setBeatGridEditorTrack(track); + }} > - ➖ Remove from playlist{selectionLabel} + 🎛 Prepare Track… +
+ )} + + {/* ── Analysis submenu ── */} + +
+ 🔄 Re-analyze +
+
+
+ 🔊 Normalize +
+
+ ↩ Reset normalization
+
+ +
handleBpmAdjust(2)}> + ✕2 Double BPM +
+
handleBpmAdjust(0.5)}> + ÷2 Halve BPM +
+
+
e.stopPropagation()} + > + Set BPM: + setBpmEditValue(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') handleSetBpm(bpmEditValue); + if (e.key === 'Escape') { + setBpmEditValue(''); + setContextMenu(null); + } + }} + autoFocus={false} + /> + +
+ + + + {/* ── Remove ── */} + {isPlaylistView ? ( + <> +
+ ➖ Remove from playlist{selectionLabel} +
+
+ 🗑️ Remove from library{selectionLabel} +
+ + ) : (
🗑️ Remove from library{selectionLabel}
- - ) : ( -
- 🗑️ Remove from library{selectionLabel} -
- )} - - )}{' '} - {/* end drill-down conditional */} -
- + )} + + )}{' '} + {/* end drill-down conditional */} +
+ + + )} + {toast && ( +
+ {toast.msg} +
)}
{/* end .music-library__main */} @@ -1512,6 +2034,13 @@ function MusicLibrary({ selectedPlaylist, search, onSearchChange }) { onCancel={handleDetailsClose} /> )} + {beatGridEditorTrack && ( + setBeatGridEditorTrack(null)} + onApply={handleApplyBeatGrid} + /> + )}
); } diff --git a/renderer/src/PlayerBar.jsx b/renderer/src/PlayerBar.jsx index bff116dd..47ff1525 100644 --- a/renderer/src/PlayerBar.jsx +++ b/renderer/src/PlayerBar.jsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { usePlayer } from './PlayerContext.jsx'; import { artworkUrl } from './artworkUrl.js'; import './PlayerBar.css'; +import './PlayerBarCues.css'; function formatTime(s) { if (!s || isNaN(s)) return '0:00'; @@ -33,15 +34,29 @@ export default function PlayerBar({ onNavigateToPlaylist, onArtistSearch }) { setDevice, setVolume, play, + audioRef, } = usePlayer(); const [devices, setDevices] = useState([]); const [showDevices, setShowDevices] = useState(false); const [showHistory, setShowHistory] = useState(false); + const [cuePoints, setCuePoints] = useState([]); + const [showHotCues, setShowHotCues] = useState( + () => localStorage.getItem('cue-show-hot') !== 'false' + ); + const [showMemCues, setShowMemCues] = useState( + () => localStorage.getItem('cue-show-mem') !== 'false' + ); const seekbarRef = useRef(); // uncontrolled range input const seekingRef = useRef(false); // true while user drags const deviceWrapRef = useRef(); const historyWrapRef = useRef(); + const waveCanvasRef = useRef(); + const waveDataRef = useRef(null); // Uint8Array | null + const seekbarBgRef = useRef(); // thin bg line behind waveform + const colorModeRef = useRef('rgb'); + const introFracRef = useRef(0); // 0-1 fraction where intro ends + const outroFracRef = useRef(1); // 0-1 fraction where outro starts useEffect(() => { async function loadDevices() { @@ -52,33 +67,182 @@ export default function PlayerBar({ onNavigateToPlaylist, onArtistSearch }) { loadDevices(); }, []); + // Load cue points whenever the playing track changes + useEffect(() => { + const id = currentTrack?.id; + let alive = true; + Promise.resolve(id ? window.api.getCuePoints(id) : []) + .then((pts) => { + if (alive) setCuePoints(pts); + }) + .catch(() => { + if (alive) setCuePoints([]); + }); + return () => { + alive = false; + }; + }, [currentTrack?.id]); + + // Re-sync cue markers once audio duration is known — fixes the race where the + // SQLite response arrives before durationchange fires, so markers were hidden + // (duration > 0 guard) even though cue points were already in state. + const hasDuration = duration > 0; + useEffect(() => { + const id = currentTrack?.id; + if (!id || !hasDuration) return; + let alive = true; + window.api + .getCuePoints(id) + .then((pts) => { + if (alive) setCuePoints(pts); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, [currentTrack?.id, hasDuration]); + + // Refresh cue markers when cue points are added/edited/deleted elsewhere + useEffect(() => { + const id = currentTrack?.id; + if (!id) return; + const handler = (e) => { + if (e.detail?.trackId === id) { + window.api + .getCuePoints(id) + .then(setCuePoints) + .catch(() => {}); + } + }; + window.addEventListener('cue-points-updated', handler); + return () => window.removeEventListener('cue-points-updated', handler); + }, [currentTrack?.id]); + + // Sync visibility toggles with CuePointsEditor + useEffect(() => { + const handler = ({ detail: { key, val } }) => { + if (key === 'cue-show-hot') setShowHotCues(val); + if (key === 'cue-show-mem') setShowMemCues(val); + }; + window.addEventListener('cue-visibility-changed', handler); + return () => window.removeEventListener('cue-visibility-changed', handler); + }, []); + // Keep seekbar max in sync with duration useEffect(() => { if (seekbarRef.current) seekbarRef.current.max = duration || 0; }, [duration]); - // Paint intro/outro zones on the seekbar track as a CSS gradient + // ── Waveform canvas helpers (declared before the effects that call them) ───── + + function drawWaveform(canvas, data, mode) { + const W = canvas.width; + const H = canvas.height; + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, W, H); + if (!data || data.length < 4) return; + + const numCols = data.length / 4; + const colW = W / numCols; + const midY = H / 2; + + for (let i = 0; i < numCols; i++) { + const rms = data[i * 4] / 255; + const bass = data[i * 4 + 1]; + const mid = data[i * 4 + 2]; + const treble = data[i * 4 + 3]; + + const halfH = Math.max(1, Math.round(rms * midY * 1.8)); + const x = Math.floor(i * colW); + const w = Math.max(1, Math.ceil(colW)); + + // EMA-derived band values have bass >> mid >> treble by ~10-30x, so naive + // normalisation always picks bass as dominant and renders everything blue. + // Gamma-compress each channel independently before normalisation so weaker + // channels (treble, mid) become visually comparable to bass. + const bassC = Math.pow(bass / 255, 0.55); + const midC = Math.pow(mid / 255, 0.3); + const trebleC = Math.pow(treble / 255, 0.2); + + const dominant = Math.max(bassC, midC, trebleC) || 0.001; + const brightness = Math.min(1, rms * 2.5); + + const nb = (bassC / dominant) * brightness; + const ng = (midC / dominant) * brightness; + const nr = (trebleC / dominant) * brightness; + + let r, g, b; + if (mode === 'classic') { + const white = Math.min(1, nr * 2); + r = Math.round(white * 220); + g = Math.round(white * 220); + b = Math.round(55 + nb * 180 + white * 55); + } else if (mode === '3band') { + // Blue=bass, Orange=mid, White=treble + r = Math.min(255, Math.round(nb * 30 + ng * 255 + nr * 255)); + g = Math.min(255, Math.round(nb * 30 + ng * 140 + nr * 255)); + b = Math.min(255, Math.round(nb * 255 + ng * 0 + nr * 255)); + } else { + // RGB: treble→red, mid→green, bass→blue + r = Math.round(nr * 255); + g = Math.round(ng * 255); + b = Math.round(nb * 255); + } + + ctx.fillStyle = `rgb(${r},${g},${b})`; + ctx.fillRect(x, midY - halfH, w, halfH * 2); + } + + // ── Intro / outro amber overlay drawn on the canvas ────────────────────── + const iF = introFracRef.current; + const oF = outroFracRef.current; + if (iF > 0.001) { + ctx.fillStyle = 'rgba(90, 56, 0, 0.52)'; + ctx.fillRect(0, 0, iF * W, H); + } + if (oF < 0.999) { + ctx.fillStyle = 'rgba(90, 56, 0, 0.52)'; + ctx.fillRect(oF * W, 0, (1 - oF) * W, H); + } + } + + function paintWaveform() { + const canvas = waveCanvasRef.current; + if (!canvas || !waveDataRef.current) return; + // rAF ensures the canvas has been laid out and offsetWidth > 0 + requestAnimationFrame(() => { + canvas.width = canvas.offsetWidth || canvas.clientWidth || 400; + canvas.height = canvas.offsetHeight || canvas.clientHeight || 40; + drawWaveform(canvas, waveDataRef.current, colorModeRef.current); + }); + } + + // Recompute intro/outro fracs and redraw waveform when track or duration changes useEffect(() => { - if (!seekbarRef.current || !duration) return; + if (!duration) return; const intro = currentTrack?.intro_secs || 0; const outro = currentTrack?.outro_secs || 0; - const introFrac = Math.min(intro / duration, 1) * 100; - const outroFrac = Math.min(outro / duration, 1) * 100; + introFracRef.current = intro > 0 ? Math.min(intro / duration, 1) : 0; + outroFracRef.current = outro > 0 ? Math.min(outro / duration, 1) : 1; + paintWaveform(); // eslint-disable-line react-hooks/exhaustive-deps + }, [duration, currentTrack]); // eslint-disable-line react-hooks/exhaustive-deps - // No visible zones: intro at very start, outro at very end - if (introFrac <= 0 && outroFrac >= 100) { - seekbarRef.current.style.background = '#333'; - return; - } - // Amber zones for cut-off intro/outro, neutral middle for the mix window - seekbarRef.current.style.background = - `linear-gradient(to right, ` + - `#5a3800 0%, #5a3800 ${introFrac}%, ` + - `#333 ${introFrac}%, #333 ${outroFrac}%, ` + - `#5a3800 ${outroFrac}%, #5a3800 100%)`; - }, [duration, currentTrack]); - - // Advance seekbar during playback — skip when user is dragging + // Advance seekbar at ~60fps via rAF so the position tracks audio smoothly + // instead of jumping every ~250ms from timeupdate events. + useEffect(() => { + if (!isPlaying) return; + let rafId; + const tick = () => { + if (!seekingRef.current && seekbarRef.current && audioRef?.current) { + seekbarRef.current.value = audioRef.current.currentTime; + } + rafId = requestAnimationFrame(tick); + }; + rafId = requestAnimationFrame(tick); + return () => cancelAnimationFrame(rafId); + }, [isPlaying, audioRef]); // eslint-disable-line react-hooks/exhaustive-deps + + // Sync seekbar position on pause / track change (rAF loop stopped) useEffect(() => { if (!seekingRef.current && seekbarRef.current) { seekbarRef.current.value = currentTime; @@ -106,6 +270,62 @@ export default function PlayerBar({ onNavigateToPlaylist, onArtistSearch }) { return () => document.removeEventListener('mousedown', handler); }, [showHistory]); + // ── Waveform color mode — load once, sync live from Settings ──────────────── + const [colorMode, setColorMode] = useState('rgb'); + + useEffect(() => { + window.api.getSetting('waveform_color_mode', 'rgb').then((m) => { + colorModeRef.current = m; + setColorMode(m); + }); + }, []); + + useEffect(() => { + colorModeRef.current = colorMode; + }, [colorMode]); + + useEffect(() => { + const handler = (e) => setColorMode(e.detail); + window.addEventListener('waveform-color-mode-changed', handler); + return () => window.removeEventListener('waveform-color-mode-changed', handler); + }, []); + + // Redraw when color mode changes (data already loaded) + useEffect(() => { + paintWaveform(); // eslint-disable-line react-hooks/exhaustive-deps + }, [colorMode]); // eslint-disable-line react-hooks/exhaustive-deps + + // Fetch waveform data when track changes, then draw + useEffect(() => { + const canvas = waveCanvasRef.current; + if (!currentTrack) { + waveDataRef.current = null; + if (canvas) canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); + return; + } + window.api.getTrackWaveform(currentTrack.id).then((raw) => { + waveDataRef.current = raw ? new Uint8Array(raw) : null; + if (!waveDataRef.current && canvas) { + canvas.getContext('2d').clearRect(0, 0, canvas.width, canvas.height); + } else { + paintWaveform(); + } + }); + }, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + // Reload waveform once the background generator finishes for the current track + useEffect(() => { + const unsub = window.api.onWaveformReady(({ trackId }) => { + if (!currentTrack || trackId !== currentTrack.id) return; + window.api.getTrackWaveform(trackId).then((raw) => { + if (!raw) return; + waveDataRef.current = new Uint8Array(raw); + paintWaveform(); + }); + }); + return unsub; + }, [currentTrack?.id]); // eslint-disable-line react-hooks/exhaustive-deps + const artSrc = artworkUrl( currentTrack?.has_artwork ? currentTrack?.artwork_path : null, mediaPort @@ -179,25 +399,49 @@ export default function PlayerBar({ onNavigateToPlaylist, onArtistSearch }) {
{formatTime(currentTime)} - { - console.log(`[seekbar] pointerDown value=${Number(e.target.value).toFixed(3)}`); - seekingRef.current = true; - }} - onPointerUp={(e) => { - const val = Number(e.target.value); - console.log(`[seekbar] pointerUp value=${val.toFixed(3)}`); - seek(val); - seekingRef.current = false; - }} - /> +
+
+ + { + console.log(`[seekbar] pointerDown value=${Number(e.target.value).toFixed(3)}`); + seekingRef.current = true; + }} + onPointerUp={(e) => { + const val = Number(e.target.value); + console.log(`[seekbar] pointerUp value=${val.toFixed(3)}`); + seek(val); + seekingRef.current = false; + }} + /> + {duration > 0 && + cuePoints + .filter((cue) => (cue.hot_cue_index >= 0 ? showHotCues : showMemCues)) + .map((cue) => { + const pct = Math.min((cue.position_ms / 1000 / duration) * 100, 100); + return ( +
{formatTime(duration)}
diff --git a/renderer/src/PlayerBarCues.css b/renderer/src/PlayerBarCues.css new file mode 100644 index 00000000..65d61a02 --- /dev/null +++ b/renderer/src/PlayerBarCues.css @@ -0,0 +1,73 @@ +.player-seekbar-wrap { + position: relative; + flex: 1; + display: flex; + align-items: center; + height: 40px; +} + +.player-seekbar-bg { + position: absolute; + left: 0; + right: 0; + height: 4px; + top: 50%; + transform: translateY(-50%); + border-radius: 2px; + background: #333; + pointer-events: none; +} + +.player-waveform-canvas { + position: absolute; + left: 0; + top: 0; + width: 100%; + height: 100%; + pointer-events: none; + border-radius: 4px; + opacity: 0.65; +} + +.player-seekbar-wrap .player-seekbar { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + z-index: 1; + background: transparent !important; + cursor: pointer; +} + +/* Center the thumb on its value position (default is left-edge aligned) */ +.player-seekbar-wrap .player-seekbar::-webkit-slider-thumb { + transform: translateX(-50%); +} + +.player-seekbar-wrap .player-seekbar:hover::-webkit-slider-thumb { + transform: translateX(-50%); +} + +.player-cue-marker { + position: absolute; + width: 3px; + height: 14px; + border-radius: 2px; + border: none; + padding: 0; + cursor: pointer; + transform: translateX(-50%); + top: 50%; + margin-top: -7px; + opacity: 0.9; + transition: + opacity 0.1s, + transform 0.1s; + z-index: 2; + pointer-events: auto; +} + +.player-cue-marker:hover { + opacity: 1; + transform: translateX(-50%) scaleY(1.3); +} diff --git a/renderer/src/PlayerContext.jsx b/renderer/src/PlayerContext.jsx index eb5c1424..14414f7b 100644 --- a/renderer/src/PlayerContext.jsx +++ b/renderer/src/PlayerContext.jsx @@ -14,10 +14,22 @@ const HISTORY_MAX = 50; export function PlayerProvider({ children }) { const audioRef = useRef(null); - if (audioRef.current == null) audioRef.current = new Audio(); + if (audioRef.current == null) { + const a = new Audio(); + // Required for Web Audio API (createMediaElementSource) to process audio from + // the local media server — without this Chromium won't send an Origin header + // and CORS is not negotiated, causing the graph to output silence. + a.crossOrigin = 'anonymous'; + audioRef.current = a; + } // eslint-disable-next-line react-hooks/refs const audio = audioRef.current; + // Web Audio graph: MediaElementSource → GainNode → DynamicsCompressor (limiter) → destination + // GainNode has no 1.0 ceiling so positive replay_gain boosts work without clipping. + const audioCtxRef = useRef(null); + const gainNodeRef = useRef(null); + const [currentTrack, setCurrentTrack] = useState(null); const [currentPlaylistId, setCurrentPlaylistId] = useState(null); const [currentPlaylistName, setCurrentPlaylistName] = useState(null); @@ -39,9 +51,65 @@ export function PlayerProvider({ children }) { window.api.getMediaPort().then((port) => { mediaPortRef.current = port; setMediaPort(port); + console.log('[diag] media server port =', port); + // Probe reachability — a 404/403/500 still means the server is up; a network error means blocked + fetch(`http://127.0.0.1:${port}/__diag_probe__`) + .then((r) => console.log('[diag] media server reachable, probe status =', r.status)) + .catch((e) => console.warn('[diag] media server UNREACHABLE:', e.message)); }); + + // Log available audio output devices + if (navigator.mediaDevices?.enumerateDevices) { + navigator.mediaDevices.enumerateDevices().then((devices) => { + const outputs = devices.filter((d) => d.kind === 'audiooutput'); + console.log(`[diag] audio output devices (${outputs.length}):`); + outputs.forEach((d) => + console.log( + `[diag] id=${d.deviceId.slice(0, 16)}… label=${d.label || '(no label — needs permission)'}` + ) + ); + }); + } }, []); + // Web Audio graph is built lazily on first play() call — see buildAudioGraph() below. + // Building it at mount time creates the AudioContext without a user gesture, leaving it + // permanently suspended on Electron/Chrome (autoplay policy), so resume() never works. + + // Build the Web Audio graph on first user interaction so AudioContext is created + // inside a user gesture — the only reliable way to get it into 'running' state on + // Electron/Chrome without an explicit autoplay policy exception. + // createMediaElementSource can only be called ONCE per audio element in Chromium; + // the guard ensures StrictMode's double-invoke doesn't break it. + const buildAudioGraph = useCallback(() => { + if (audioCtxRef.current) return; // already built + try { + const ctx = new AudioContext(); + console.log('[player] AudioContext created, state =', ctx.state); + const source = ctx.createMediaElementSource(audio); + const gain = ctx.createGain(); + gain.gain.value = 1.0; + const limiter = ctx.createDynamicsCompressor(); + limiter.threshold.value = -1.0; + limiter.knee.value = 0; + limiter.ratio.value = 20; + limiter.attack.value = 0.001; + limiter.release.value = 0.1; + source.connect(gain); + gain.connect(limiter); + limiter.connect(ctx.destination); + audioCtxRef.current = ctx; + gainNodeRef.current = gain; + audio.volume = 1.0; + console.log('[player] Web Audio graph built OK'); + } catch (err) { + console.warn( + '[player] Web Audio graph unavailable, falling back to audio.volume:', + err.message + ); + } + }, [audio]); + // Keep mutable refs so event handlers always see latest values const queueRef = useRef(queue); const idxRef = useRef(queueIndex); @@ -82,7 +150,7 @@ export function PlayerProvider({ children }) { // Stable play-at-index — exposed via ref so handleEnded can call it without stale closure const playAtIndexRef = useRef(null); const playAtIndex = useCallback( - (newQueue, index, playlistId = null, playlistName = null) => { + async (newQueue, index, playlistId = null, playlistName = null) => { const track = newQueue[index]; if (!track) return; const gen = ++playGenRef.current; @@ -91,8 +159,9 @@ export function PlayerProvider({ children }) { console.error('[player] media server not ready yet'); return; } + const filePath = track.file_path; // Normalize to forward slashes (Windows paths use backslashes), then encode each segment - const posixPath = track.file_path.replace(/\\/g, '/'); + const posixPath = filePath.replace(/\\/g, '/'); const encodedPath = posixPath .split('/') .map((seg) => encodeURIComponent(seg)) @@ -107,21 +176,51 @@ export function PlayerProvider({ children }) { return next.length > HISTORY_MAX ? next.slice(0, HISTORY_MAX) : next; }); } + console.log('[diag] playAtIndex src =', src); + // Build Web Audio graph on first play (must be inside user gesture so ctx starts running) + buildAudioGraph(); audio.pause(); // cleanly stop current pipeline before swapping source audio.src = src; + // Resume AudioContext — called within the same user gesture, so it works reliably + if (audioCtxRef.current?.state === 'suspended') { + try { + await audioCtxRef.current.resume(); + } catch { + // resume() rejects if context is closed — safe to ignore + } + } + console.log( + '[player] ctx.state after resume =', + audioCtxRef.current?.state ?? 'no ctx (fallback mode)' + ); // Setting src triggers an implicit load; calling audio.load() would race with play() - audio.play().catch((err) => { - // AbortError is expected when we switch tracks before play() resolves - if (gen === playGenRef.current && err.name !== 'AbortError') - console.error('[player] play error:', err.name, err.message); - }); + audio + .play() + .then(() => { + console.log('[diag] play() resolved OK readyState=', audio.readyState); + }) + .catch((err) => { + // AbortError is expected when we switch tracks before play() resolves + if (gen === playGenRef.current && err.name !== 'AbortError') + console.error( + '[diag] play() FAILED:', + err.name, + err.message, + 'readyState=', + audio.readyState, + 'networkState=', + audio.networkState, + 'src=', + audio.src + ); + }); setCurrentTrack(track); setQueue(newQueue); setQueueIndex(index); setCurrentPlaylistId(playlistId); setCurrentPlaylistName(playlistName ?? null); }, - [audio] + [audio, buildAudioGraph] ); useLayoutEffect(() => { playAtIndexRef.current = playAtIndex; @@ -142,7 +241,9 @@ export function PlayerProvider({ children }) { const plName = currentPlaylistNameRef.current; if (rep === 'one') { audio.currentTime = 0; - audio.play().catch(console.error); + audio.play().catch((err) => { + if (err.name !== 'AbortError') console.error(err); + }); return; } if (shuf) { @@ -193,10 +294,23 @@ export function PlayerProvider({ children }) { [playAtIndex] ); - const togglePlay = useCallback(() => { - if (audio.paused) audio.play().catch(console.error); - else audio.pause(); - }, [audio]); + const togglePlay = useCallback(async () => { + if (audio.paused) { + buildAudioGraph(); + if (audioCtxRef.current?.state === 'suspended') { + try { + await audioCtxRef.current.resume(); + } catch { + // resume() rejects if context is closed — safe to ignore + } + } + audio.play().catch((err) => { + if (err.name !== 'AbortError') console.error(err); + }); + } else { + audio.pause(); + } + }, [audio, buildAudioGraph]); const next = useCallback(() => { const q = queueRef.current; @@ -207,6 +321,8 @@ export function PlayerProvider({ children }) { playAtIndexRef.current(q, Math.floor(Math.random() * q.length), plId, plName); } else if (idx < q.length - 1) { playAtIndexRef.current(q, idx + 1, plId, plName); + } else if (repeatRef.current === 'all' && q.length > 0) { + playAtIndexRef.current(q, 0, plId, plName); } }, []); @@ -250,11 +366,18 @@ export function PlayerProvider({ children }) { setVolumeState(clamped); }, []); - // Apply user volume combined with per-track replay_gain + // Apply user volume combined with per-track replay_gain through the GainNode. + // GainNode has no 1.0 ceiling so positive gain (boosting quiet tracks) works correctly. useEffect(() => { const rg = currentTrack?.replay_gain ?? 0; const gainLinear = Math.pow(10, rg / 20); - audio.volume = Math.min(1.0, volume * gainLinear); + const gain = gainNodeRef.current; + if (gain) { + gain.gain.value = gainLinear * volume; + } else { + // Fallback before the Web Audio graph is initialised + audio.volume = Math.min(1.0, gainLinear * volume); + } }, [volume, currentTrack, audio]); const cycleRepeat = useCallback( @@ -265,8 +388,22 @@ export function PlayerProvider({ children }) { const setDevice = useCallback( async (deviceId) => { setOutputDeviceId(deviceId); - if (typeof audio.setSinkId === 'function') { - await audio.setSinkId(deviceId).catch(console.error); + const ctx = audioCtxRef.current; + // When the Web Audio graph is active, audio routes through AudioContext — use + // ctx.setSinkId() to redirect output. Fall back to audio.setSinkId() if the + // graph is not yet initialised (should be rare). + if (ctx && typeof ctx.setSinkId === 'function') { + console.log('[diag] ctx.setSinkId →', deviceId || '(default)'); + await ctx.setSinkId(deviceId || '').catch((err) => { + console.error('[diag] ctx.setSinkId FAILED:', err.name, err.message); + }); + } else if (typeof audio.setSinkId === 'function') { + console.log('[diag] setSinkId (fallback) →', deviceId || '(default)'); + await audio.setSinkId(deviceId).catch((err) => { + console.error('[diag] setSinkId FAILED:', err.name, err.message); + }); + } else { + console.warn('[diag] setSinkId not available'); } }, [audio] @@ -276,7 +413,10 @@ export function PlayerProvider({ children }) { useEffect(() => { if (!navigator.mediaSession) return; navigator.mediaSession.setActionHandler('play', () => { - if (audio.src) audio.play().catch(console.error); + if (audio.src) + audio.play().catch((err) => { + if (err.name !== 'AbortError') console.error(err); + }); }); navigator.mediaSession.setActionHandler('pause', () => audio.pause()); navigator.mediaSession.setActionHandler('nexttrack', () => next()); @@ -307,13 +447,77 @@ export function PlayerProvider({ children }) { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA') return; e.preventDefault(); - if (audio.paused) audio.play().catch(console.error); + if (audio.paused) + audio.play().catch((err) => { + if (err.name !== 'AbortError') console.error(err); + }); else audio.pause(); }; window.addEventListener('keydown', onKeyDown); return () => window.removeEventListener('keydown', onKeyDown); }, [audio]); + const patchCurrentTrack = useCallback( + (id, fields) => setCurrentTrack((prev) => (prev?.id === id ? { ...prev, ...fields } : prev)), + [] + ); + + // Reload audio src for the current track (e.g. after normalization produces a new file). + // Pass the new file path explicitly so we don't race with pending React state updates. + // Seeks back to the position the player was at before the reload. + const reloadCurrentTrack = useCallback( + (newFilePath, shouldPlay = false) => { + const port = mediaPortRef.current; + console.log( + '[reloadCurrentTrack] called with path=', + newFilePath, + 'shouldPlay=', + shouldPlay, + 'port=', + port + ); + if (!port || !newFilePath) return; + const posixPath = newFilePath.replace(/\\/g, '/'); + const encodedPath = posixPath + .split('/') + .map((seg) => encodeURIComponent(seg)) + .join('/'); + const gen = ++playGenRef.current; + const savedTime = audio.currentTime; + const src = `http://127.0.0.1:${port}/${encodedPath.replace(/^\//, '')}?t=${gen}`; + console.log('[reloadCurrentTrack] setting audio.src =', src, 'savedTime=', savedTime); + audio.pause(); + audio.src = src; + audio.addEventListener( + 'canplay', + () => { + console.log( + '[reloadCurrentTrack] canplay fired, seeking to', + savedTime, + 'shouldPlay=', + shouldPlay + ); + audio.currentTime = savedTime; + if (shouldPlay) { + audio.play().catch((err) => { + if (gen === playGenRef.current && err.name !== 'AbortError') + console.error('[player] reloadCurrentTrack play error:', err); + }); + } + }, + { once: true } + ); + }, + [audio] + ); + + // Update the queue in-place without changing the current track or index. + // Called by MusicLibrary when tracks are added to the currently-playing source + // so shuffle picks from the full up-to-date list. + const updateQueue = useCallback((newQueue) => { + setQueue(newQueue); + }, []); + return ( {children} diff --git a/renderer/src/SettingsModal.css b/renderer/src/SettingsModal.css index 8d428f88..11a051b7 100644 --- a/renderer/src/SettingsModal.css +++ b/renderer/src/SettingsModal.css @@ -373,3 +373,60 @@ color: #ffbd2e; border: 1px solid rgba(255, 189, 46, 0.2); } + +.settings-normalize-result { + margin-top: 0.5rem; + font-size: 12.5px; + color: #48c774; + padding: 6px 10px; + background: rgba(72, 199, 116, 0.1); + border: 1px solid rgba(72, 199, 116, 0.22); + border-radius: 6px; +} + +.settings-normalize-progress { + display: flex; + align-items: center; + gap: 10px; + margin-top: 0.5rem; +} + +.settings-normalize-progress-bar { + flex: 1; + height: 6px; + background: #2a2a2a; + border-radius: 3px; + overflow: hidden; +} + +.settings-normalize-progress-fill { + height: 100%; + background: #1db954; + border-radius: 3px; + transition: width 0.2s ease; +} + +.settings-action-count { + color: #888; + font-size: 11px; +} + +.settings-toggle-row { + display: flex; + align-items: center; + gap: 8px; +} + +.settings-toggle-row input[type='checkbox'] { + width: 16px; + height: 16px; + flex-shrink: 0; + cursor: pointer; + accent-color: #1db954; +} + +.settings-toggle-desc { + color: #aaa; + font-size: 12px; + line-height: 1.4; +} diff --git a/renderer/src/SettingsModal.jsx b/renderer/src/SettingsModal.jsx index a18257e0..c393469e 100644 --- a/renderer/src/SettingsModal.jsx +++ b/renderer/src/SettingsModal.jsx @@ -1,7 +1,7 @@ import { useState, useEffect, useCallback } from 'react'; import './SettingsModal.css'; -const DEFAULT_TARGET = -14; +const DEFAULT_TARGET = -9; const COOKIE_BROWSERS = [ { value: '', label: 'None (not logged in)' }, @@ -16,12 +16,30 @@ const COOKIE_BROWSERS = [ function SettingsModal({ onClose }) { const [activeSection, setActiveSection] = useState('library'); const [targetInput, setTargetInput] = useState(String(DEFAULT_TARGET)); + const [autoNormalizeOnImport, setAutoNormalizeOnImport] = useState(false); + const [autoCueOnImport, setAutoCueOnImport] = useState(false); + const [generatingCues, setGeneratingCues] = useState(false); + const [cueGenProgress, setCueGenProgress] = useState(null); // { completed, total } | null + const [cueGenResult, setCueGenResult] = useState(null); // { generated, skipped, total } | null + const [confirmCueGen, setConfirmCueGen] = useState(false); + const [confirmDeleteAllCues, setConfirmDeleteAllCues] = useState(false); + const [deletingAllCues, setDeletingAllCues] = useState(false); + const [deleteAllCuesResult, setDeleteAllCuesResult] = useState(null); const [confirmClear, setConfirmClear] = useState(null); // 'library' | 'userdata' + const [normalizing, setNormalizing] = useState(false); + const [normalizeResult, setNormalizeResult] = useState(null); + const [confirmNormalize, setConfirmNormalize] = useState(false); + const [resettingNorm, setResettingNorm] = useState(false); const [depVersions, setDepVersions] = useState(null); const [updatingAll, setUpdatingAll] = useState(false); const [ytdlpVersionInput, setYtdlpVersionInput] = useState(''); const [ytdlpUpdating, setYtdlpUpdating] = useState(false); + const [tidalUpdating, setTidalUpdating] = useState(false); const [cookiesBrowser, setCookiesBrowser] = useState(''); + const [waveformColorMode, setWaveformColorMode] = useState('rgb'); + const [generatingWaveforms, setGeneratingWaveforms] = useState(false); + const [waveformGenProgress, setWaveformGenProgress] = useState(null); + const [waveformGenResult, setWaveformGenResult] = useState(null); // Library location const [libraryPath, setLibraryPath] = useState(''); @@ -45,6 +63,13 @@ function SettingsModal({ onClose }) { window.api .getSetting('normalize_target_lufs', String(DEFAULT_TARGET)) .then((v) => setTargetInput(v)); + window.api + .getSetting('auto_normalize_on_import', 'false') + .then((v) => setAutoNormalizeOnImport(v === 'true')); + window.api + .getSetting('auto_cue_on_import', 'false') + .then((v) => setAutoCueOnImport(v === 'true')); + window.api.getSetting('waveform_color_mode', 'rgb').then((v) => setWaveformColorMode(v)); }, []); useEffect(() => { @@ -76,14 +101,105 @@ function SettingsModal({ onClose }) { if (tag) setYtdlpVersionInput(''); }; + const handleUpdateTidalDlNg = async () => { + setTidalUpdating(true); + await window.api.updateTidalDlNg(); + const versions = await window.api.getDepVersions(); + setDepVersions(versions); + setTidalUpdating(false); + }; + const handleTargetChange = (raw) => { setTargetInput(raw); + setNormalizeResult(null); const num = Number(raw); if (Number.isFinite(num) && num >= -60 && num <= 0) { window.api.setSetting('normalize_target_lufs', raw); } }; + const handleNormalize = async () => { + setConfirmNormalize(false); + setNormalizing(true); + setNormalizeResult(null); + try { + const { normalized } = await window.api.normalizeLibrary(); + setNormalizeResult({ type: 'normalize', normalized }); + } finally { + setNormalizing(false); + } + }; + + const handleResetAllNormalization = async () => { + setResettingNorm(true); + setNormalizeResult(null); + try { + const { updated } = await window.api.resetNormalization({}); + setNormalizeResult({ type: 'reset', count: updated }); + } finally { + setResettingNorm(false); + } + }; + + const handleAutoNormalizeToggle = (checked) => { + setAutoNormalizeOnImport(checked); + window.api.setSetting('auto_normalize_on_import', String(checked)); + }; + + const handleGenerateCueLibrary = async (overwrite) => { + setConfirmCueGen(false); + setGeneratingCues(true); + setCueGenResult(null); + setCueGenProgress(null); + const unsub = window.api.onCueGenProgress(({ completed, total, done }) => { + setCueGenProgress(done ? null : { completed, total }); + if (done) unsub(); + }); + try { + const result = await window.api.generateCuePointsLibrary({ overwrite }); + setCueGenResult(result); + } finally { + unsub(); + setGeneratingCues(false); + setCueGenProgress(null); + } + }; + + const handleDeleteAllCuePoints = async () => { + setConfirmDeleteAllCues(false); + setDeletingAllCues(true); + setDeleteAllCuesResult(null); + try { + const { deleted } = await window.api.deleteAllCuePointsLibrary(); + setDeleteAllCuesResult(deleted); + } finally { + setDeletingAllCues(false); + } + }; + + const handleAutoCueToggle = (checked) => { + setAutoCueOnImport(checked); + window.api.setSetting('auto_cue_on_import', String(checked)); + }; + + const handleGenerateWaveformsLibrary = async (overwrite) => { + setGeneratingWaveforms(true); + setWaveformGenResult(null); + setWaveformGenProgress(null); + const unsub = window.api.onWaveformGenProgress(({ completed, total, done }) => { + setWaveformGenProgress(done ? null : { completed, total }); + if (done) unsub(); + }); + try { + const result = await window.api.generateWaveformsLibrary({ overwrite }); + setWaveformGenResult(result); + } finally { + unsub(); + setGeneratingWaveforms(false); + setWaveformGenProgress(null); + } + }; + const handleCookiesBrowserChange = (value) => { setCookiesBrowser(value); window.api.setSetting('ytdlp_cookies_browser', value); @@ -124,6 +240,9 @@ function SettingsModal({ onClose }) { const sections = [ { id: 'library', label: 'Library' }, + { id: 'normalization', label: 'Normalization' }, + { id: 'cuepoints', label: 'Cue Points' }, + { id: 'waveform', label: 'Waveform' }, { id: 'downloads', label: 'Downloads' }, { id: 'updates', label: 'Dependencies' }, { id: 'advanced', label: 'Advanced' }, @@ -149,28 +268,6 @@ function SettingsModal({ onClose }) { {activeSection === 'library' && ( <>

Library

-
-
Loudness Normalization
-

- Calculates a gain adjustment for every analyzed track so it hits the target - loudness during playback. Tracks without loudness data are skipped. -

-
- -
- handleTargetChange(e.target.value)} - /> - LUFS -
-
-
-
Library Location

@@ -216,6 +313,339 @@ function SettingsModal({ onClose }) { )} + {activeSection === 'normalization' && ( + <> +

Normalization

+
+

+ Stores a gain value per track so playback volume is automatically matched to the + target loudness. No extra files are written — gain is applied in real time during + playback and to the exported copy on USB. Changing the target takes effect + immediately. +

+
+ +
+ handleTargetChange(e.target.value)} + /> + LUFS +
+
+
+ +
+ handleAutoNormalizeToggle(e.target.checked)} + /> + + Automatically compute and store the gain value for every imported track after + analysis finishes. Off by default. + +
+
+
+
+
Normalize Whole Library
+
+ Computes and stores a gain value for every analyzed track in the library. This + is a fast database operation — no files are written. +
+
+ {confirmNormalize ? ( +
+ Apply to entire library? + + +
+ ) : ( + + )} +
+ {normalizeResult?.type === 'normalize' && ( +
+ {normalizeResult.normalized === 0 + ? 'No analyzed tracks found — analyze tracks first.' + : `Done — gain stored for ${normalizeResult.normalized} track${normalizeResult.normalized !== 1 ? 's' : ''}.`} +
+ )} +
+
+
Reset All Normalization
+
+ Clears stored gain values from every track — playback returns to unmodified + levels. +
+
+ +
+ {normalizeResult?.type === 'reset' && ( +
+ {normalizeResult.count === 0 + ? 'Nothing to reset — no tracks had a normalized file.' + : `Reset — removed normalization from ${normalizeResult.count} track${normalizeResult.count !== 1 ? 's' : ''}.`} +
+ )} +
+ + )} + + {activeSection === 'cuepoints' && ( + <> +

Cue Points

+ +
+
Auto-generate on Import
+
+ +
+ handleAutoCueToggle(e.target.checked)} + /> + + After analysis finishes for a newly imported track (file import, yt-dlp, + TIDAL), automatically run CueGen to place cue points using BPM, intro, and + outro data. Only fires when the track has no existing cue points. Off by + default. + +
+
+
+ +
+
Generate for Whole Library
+
+
+
Generate Cue Points
+
+ Runs CueGen on every analyzed track. Tracks that already have cue points are + skipped unless you choose to overwrite. +
+
+ {confirmCueGen ? ( +
+ Skip tracks with existing cue points? + + + +
+ ) : ( + + )} +
+ {generatingCues && cueGenProgress && ( +
+
+
0 + ? `${Math.round((cueGenProgress.completed / cueGenProgress.total) * 100)}%` + : '0%', + }} + /> +
+ + {cueGenProgress.completed} / {cueGenProgress.total} + +
+ )} + {cueGenResult && ( +
+ {cueGenResult.generated === 0 + ? cueGenResult.total === 0 + ? 'No analyzed tracks found — import and analyze tracks first.' + : `Nothing generated — all ${cueGenResult.skipped} track${cueGenResult.skipped !== 1 ? 's' : ''} already had cue points.` + : `Done — generated cue points for ${cueGenResult.generated} track${cueGenResult.generated !== 1 ? 's' : ''}${cueGenResult.skipped > 0 ? `, skipped ${cueGenResult.skipped}` : ''}.`} +
+ )} +
+ +
+
Danger Zone
+
+
+
Delete All Cue Points
+
+ Permanently removes every cue point from every track in the library. +
+
+ {confirmDeleteAllCues ? ( +
+ Delete all cue points? + + +
+ ) : ( + + )} +
+ {deleteAllCuesResult !== null && ( +
+ {deleteAllCuesResult === 0 + ? 'Nothing to delete — no cue points in the library.' + : `Deleted cue points from ${deleteAllCuesResult} track${deleteAllCuesResult !== 1 ? 's' : ''}.`} +
+ )} +
+ + )} + + {activeSection === 'waveform' && ( + <> +

Waveform

+ +
+
Seek bar color mode
+

+ Choose how the waveform is colored in the seek bar. Waveforms are generated + automatically after each track is analyzed. +

+
+ + +
+
+ +
+
Regenerate waveforms
+

+ Missing waveforms are generated automatically on startup. Use this to + force-rebuild all waveforms (e.g. after changing the color mode you want to + preview). +

+
+ +
+ {generatingWaveforms && waveformGenProgress && ( +
+
+
0 + ? `${Math.round((waveformGenProgress.completed / waveformGenProgress.total) * 100)}%` + : '0%', + }} + /> +
+ + {waveformGenProgress.completed} / {waveformGenProgress.total} + +
+ )} + {waveformGenResult && ( +
+ Generated {waveformGenResult.generated} waveform + {waveformGenResult.generated !== 1 ? 's' : ''}, skipped{' '} + {waveformGenResult.skipped}. +
+ )} +
+ + )} + {activeSection === 'downloads' && ( <>

Downloads

@@ -278,7 +708,8 @@ function SettingsModal({ onClose }) {
Installed Versions

- FFmpeg, mixxx-analyzer, and yt-dlp are downloaded automatically on first launch. + FFmpeg, mixxx-analyzer, yt-dlp, and tidal-dl-ng are downloaded automatically on + first launch.

@@ -293,6 +724,12 @@ function SettingsModal({ onClose }) { yt-dlp {depVersions?.ytDlp?.version ?? '…'}
+
+ tidal-dl-ng + + {depVersions?.tidalDlNg?.version ?? 'not installed'} + +
@@ -302,13 +739,13 @@ function SettingsModal({ onClose }) {
Update All Dependencies
- Re-downloads the latest FFmpeg, mixxx-analyzer, and yt-dlp. + Re-downloads the latest FFmpeg, mixxx-analyzer, yt-dlp, and tidal-dl-ng.
@@ -324,11 +761,27 @@ function SettingsModal({ onClose }) {
+ +
+
+
Update tidal-dl-ng
+
+ Upgrades tidal-dl-ng to the latest version via pip. +
+
+ +
diff --git a/renderer/src/Sidebar.css b/renderer/src/Sidebar.css index cb1cc96a..fd5316b7 100644 --- a/renderer/src/Sidebar.css +++ b/renderer/src/Sidebar.css @@ -60,6 +60,20 @@ background-color: #333; } +.menu-item--disabled { + opacity: 0.4; + cursor: not-allowed; +} + +.menu-item--disabled:hover { + background-color: transparent; +} + +.menu-item-downloading { + margin-left: auto; + font-size: 12px; +} + .menu-icon { font-size: 16px; width: 20px; @@ -91,6 +105,60 @@ margin-bottom: 8px; } +.normalize-progress-wrap { + margin-top: 12px; + margin-bottom: 8px; + padding: 8px 12px; + background-color: #2a2a2a; + border-radius: 4px; + font-size: 13px; +} + +button.normalize-progress-wrap.ytdlp-progress-clickable { + display: block; + width: 100%; + text-align: left; + border: none; + outline: none; + color: inherit; + cursor: pointer; + transition: background-color 0.15s; +} + +button.normalize-progress-wrap.ytdlp-progress-clickable:hover { + background-color: #333; +} + +button.normalize-progress-wrap.ytdlp-progress-clickable:focus-visible { + outline: 2px solid #5865f2; + outline-offset: 1px; +} + +.normalize-progress-label { + display: flex; + justify-content: space-between; + font-weight: 500; + margin-bottom: 6px; +} + +.normalize-progress-bar { + height: 4px; + background-color: #444; + border-radius: 2px; + overflow: hidden; +} + +.normalize-progress-fill { + height: 100%; + background-color: #1db954; + border-radius: 2px; + transition: width 0.2s ease; +} + +.ytdlp-progress-fill { + background-color: #5865f2; +} + .import-button { margin-top: 12px; margin-bottom: 25px; @@ -172,6 +240,12 @@ font-style: italic; } +.playlist-item--drag-over { + background-color: #1db95433 !important; + outline: 1px solid #1db954; + outline-offset: -1px; +} + .playlist-new-form { padding: 2px 4px; } diff --git a/renderer/src/Sidebar.jsx b/renderer/src/Sidebar.jsx index c40077fe..4e969eaf 100644 --- a/renderer/src/Sidebar.jsx +++ b/renderer/src/Sidebar.jsx @@ -1,9 +1,14 @@ import { useState, useEffect, useRef, useCallback } from 'react'; +import { useDownload } from './DownloadContext.jsx'; +import { useTidalDownload } from './TidalDownloadContext.jsx'; import './Sidebar.css'; +import ImportPlaylistDialog from './ImportPlaylistDialog'; const MENU_ITEMS = [ { id: 'music', name: 'Music', icon: '🎵' }, + { id: 'explorer', name: 'Explorer', icon: '📁' }, { id: 'download', name: 'YT-DLP', icon: '⬇️' }, + { id: 'tidal', name: 'TIDAL', icon: '🌊' }, ]; const PRESET_COLORS = [ @@ -23,9 +28,15 @@ function Sidebar({ onExportPlaylistRekordboxUsb, onExportPlaylistAll, }) { + const { sidebarProgress: ytDlpSidebarProgress } = useDownload(); + const { sidebarProgress: tidalSidebarProgress } = useTidalDownload(); const [playlists, setPlaylists] = useState([]); const [importProgress, setImportProgress] = useState({ total: 0, completed: 0 }); + const [normalizeProgress, setNormalizeProgress] = useState(null); // { completed, total } | null + const [analysisProgress, setAnalysisProgress] = useState(null); // { done, total } | null + const [waveformGenProgress, setWaveformGenProgress] = useState(null); // { completed, total } | null const [exportProgress, setExportProgress] = useState(null); // { copied, total, pct } | null + const [ytDlpCheckProgress, setYtDlpCheckProgress] = useState(null); // { checked, total } | null during fetch/check const [newPlaylistName, setNewPlaylistName] = useState(''); const [creatingPlaylist, setCreatingPlaylist] = useState(false); const [createError, setCreateError] = useState(''); @@ -33,6 +44,8 @@ function Sidebar({ const [playlistMenu, setPlaylistMenu] = useState(null); // { id, x, y } const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); + const [dragOverPlaylistId, setDragOverPlaylistId] = useState(null); + const [importDialogFiles, setImportDialogFiles] = useState(null); // pending files waiting for playlist selection const newInputRef = useRef(null); const renameInputRef = useRef(null); @@ -68,9 +81,27 @@ function Sidebar({ const handleImport = async () => { const files = await window.api.selectAudioFiles(); if (!files.length) return; + setImportDialogFiles(files); + }; + + const handleImportConfirm = async (choice) => { + const files = importDialogFiles; + setImportDialogFiles(null); + if (!files?.length) return; + + let playlistId = null; + + if (choice.type === 'create') { + const result = await window.api.createPlaylist(choice.name); + playlistId = result?.id ?? null; + } else if (choice.type === 'existing') { + playlistId = choice.id; + } + setImportProgress({ total: files.length, completed: 0 }); - await window.api.importAudioFiles(files); - setImportProgress({ total: 0, completed: 0 }); + await window.api.importAudioFiles(files, playlistId); + // Small delay so the user sees 100% before the bar disappears + setTimeout(() => setImportProgress({ total: 0, completed: 0 }), 800); }; const handleCreatePlaylist = async (e) => { @@ -109,6 +140,54 @@ function Sidebar({ return unsub; }, []); + useEffect(() => { + const unsub = window.api.onImportProgress(({ completed, total }) => { + setImportProgress({ completed, total }); + }); + return unsub; + }, []); + + useEffect(() => { + const unsub = window.api.onNormalizeProgress((data) => { + if (data.done) { + setTimeout(() => setNormalizeProgress(null), 1500); + } else { + setNormalizeProgress({ completed: data.completed, total: data.total }); + } + }); + return unsub; + }, []); + + useEffect(() => { + if (!window.api.onWaveformGenProgress) return; + const unsub = window.api.onWaveformGenProgress((data) => { + if (data.done) { + setTimeout(() => setWaveformGenProgress(null), 1500); + } else { + setWaveformGenProgress({ completed: data.completed, total: data.total }); + } + }); + return unsub; + }, []); + + useEffect(() => { + const unsub = window.api.onAnalysisProgress((data) => { + if (data.finished) { + setTimeout(() => setAnalysisProgress(null), 1500); + } else { + setAnalysisProgress({ done: data.done, total: data.total }); + } + }); + return unsub; + }, []); + + useEffect(() => { + const unsub = window.api.onYtDlpCheckProgress((data) => { + setYtDlpCheckProgress(data); // null when done + }); + return unsub; + }, []); + const handleExportM3U = async (id) => { setPlaylistMenu(null); const result = await window.api.exportPlaylistAsM3U(id); @@ -142,6 +221,32 @@ function Sidebar({ await window.api.updatePlaylistColor(id, color); }; + const handleDragOver = (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + }; + + const handleDragEnter = (e, playlistId) => { + if (e.dataTransfer.types.includes('application/dj-tracks')) { + setDragOverPlaylistId(playlistId); + } + }; + + const handleDragLeave = (e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setDragOverPlaylistId(null); + } + }; + + const handleDrop = async (e, playlistId) => { + e.preventDefault(); + setDragOverPlaylistId(null); + const raw = e.dataTransfer.getData('application/dj-tracks'); + if (!raw) return; + const trackIds = JSON.parse(raw); + await window.api.addTracksToPlaylist(playlistId, trackIds); + }; + return (
@@ -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}…
)} + {analysisProgress && ( +
+
+ 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 && ( + + )} + {ytDlpSidebarProgress && ( + + )} + {tidalSidebarProgress && ( + + )} {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. +

+ + + )} + {installing && ( + <> +
Installing…
+
+ {installLog.slice(-8).map((line, i) => ( +
+ {line} +
+ ))} + {installLog.length === 0 && ( +
Starting installer…
+ )} +
+ + )} + {installError && ( + <> +
+ Installation failed +
+

{installError}

+ + + )} +
+
+ ); + } + + // ── 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. +

+ + + )} + {loginState === 'waiting' && ( + <> +
+ + Waiting for TIDAL authentication… +
+ {loginUrl ? ( +
+

+ A browser tab was opened. If it didn't open, click the link below: +

+ { + e.preventDefault(); + window.api.openExternal(loginUrl); + }} + > + {loginUrl} + +
+ ) : ( +

Opening browser…

+ )} + + )} + {loginState === 'done' && ( +
✓ Logged in successfully
+ )} + {loginState === 'error' && ( +
+ ✗ Login failed: {loginError} + +
+ )} +
+
+ ); + } + + // ── render: step — url ──────────────────────────────────────────────────── + if (step === 'url') { + return ( +
+
+

TIDAL Download

+

Paste a TIDAL URL to import tracks into your library.

+
+ +
+
+ { + setUrl(e.target.value); + setFetchError(null); + }} + onPaste={(e) => { + const text = e.clipboardData?.getData('text')?.trim(); + if (text) { + e.preventDefault(); + setUrl(text); + setFetchError(null); + } + }} + disabled={fetching} + autoComplete="off" + spellCheck={false} + /> + +
+ {fetchError && ( +
+ ✗ {fetchError} +
+ )} +
+ +
+
Supported URL types
+
+ {TIDAL_URL_TYPES.map((t) => ( +
+ + {t.label} +
+ ))} +
+
+ +
+ +
+
+ ); + } + + // ── 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 +

+
+ +
+ + + {!targetPlaylistId && ( + setTargetPlaylistName(e.target.value)} + /> + )} +
+ +
+
+ + + {totalActive} / {entries.length} selected + +
+
+ {entries.map((entry) => { + const inLibrary = libraryMap.has(entry.url); + const inPlaylist = playlistMemberUrls.has(entry.url); + const checked = inLibrary + ? linkIndices.has(entry.index) + : selectedIndices.has(entry.index); + return ( + + ); + })} +
+
+ +
+ + +
+
+ ); + } + + // ── 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 && ( +
+
+
+
+ Downloading… +
+ )} + + {/* 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 ? ( + + ) : ( + + )} + +
+
+ )} + {!loading && result?.error && ( +
+ ✗ {result.error} + +
+ )} +
+ ); +} 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',