Main Upload
This is the start upload I figured this is a way for me to easily tweak with radio DJ and get the album art
This commit is contained in:
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
data
|
||||
.git
|
||||
.gitignore
|
||||
.dockerignore
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
*.md
|
||||
*.log
|
||||
.env
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
data/
|
||||
*.log
|
||||
.env
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
|
||||
# ---- Dependencies (better-sqlite3 uses prebuilt binaries on linux-x64 glibc) ----
|
||||
FROM node:20-slim AS deps
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json* ./
|
||||
# Use `npm ci` when a lockfile is present, otherwise fall back to `npm install`.
|
||||
RUN if [ -f package-lock.json ]; then npm ci --omit=dev; else npm install --omit=dev; fi
|
||||
|
||||
# ---- Runtime ----
|
||||
FROM node:20-slim
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
DATA_DIR=/app/data
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY package.json ./
|
||||
COPY server.js ./
|
||||
COPY public ./public
|
||||
|
||||
# Persistent SQLite storage lives in /app/data (map a volume or ./data here).
|
||||
RUN mkdir -p /app/data && chown -R node:node /app
|
||||
USER node
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3000)+'/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,18 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 Smartcraft-Media-Tech
|
||||
Copyright (c) 2026 rdj-art-app contributors
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
@@ -1,2 +1,101 @@
|
||||
# RadioDJ-Album-art-downloader
|
||||
# RDJ Art Manager 🎵
|
||||
|
||||
A production-ready, open-source web app to find, review, and apply **missing album artwork** for a [RadioDJ](https://www.radiodj.ro/) MySQL database.
|
||||
|
||||
- 🔍 **Free artwork lookup** — iTunes Search API first, MusicBrainz + Cover Art Archive fallback. No API keys, no credit cards.
|
||||
- 🗂 **Multi-user with roles** — embedded SQLite (better-sqlite3) stores accounts and per-user RadioDJ connection profiles. First registered user becomes the **admin**.
|
||||
- 🔐 **Secure by default** — scrypt password hashing, AES-256-GCM encrypted DB passwords, HttpOnly session cookies, rate-limited auth endpoints.
|
||||
- 🎛 **Spreadsheet-style review grid** — preview artwork candidates per track, pick the right cover, filter, auto-search.
|
||||
- 📦 **ZIP export** — images renamed to the base audio filename (`01 Track.mp3` → `01 Track.jpg`), plus an `export-report.json` manifest.
|
||||
- 🗄 **Optional DB update** — sets `songs.image` in the RadioDJ database to the new file name.
|
||||
- 🐳 **Docker** — one `docker compose up` with `./data` persisted.
|
||||
|
||||
## Quick start
|
||||
|
||||
### Local (Node.js ≥ 20)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm start
|
||||
# open http://localhost:3000
|
||||
```
|
||||
|
||||
### Docker
|
||||
|
||||
```bash
|
||||
docker compose up -d --build
|
||||
# open http://localhost:3000
|
||||
```
|
||||
|
||||
SQLite data is persisted in `./data` (mapped to `/app/data` in the container).
|
||||
|
||||
## Configuration
|
||||
|
||||
| Env var | Default | Description |
|
||||
| --------------- | ------------ | --------------------------------------------------------------------------- |
|
||||
| `PORT` | `3000` | HTTP port. |
|
||||
| `DATA_DIR` | `./data` | Where the SQLite database lives. |
|
||||
| `APP_SECRET` | *(auto)* | **Set this in production** (min 16 chars). Encrypts stored RadioDJ DB passwords. If unset, a random secret is generated at `data/.app-secret`. Changing it makes stored DB passwords unreadable. |
|
||||
| `COOKIE_SECURE` | `0` | Set to `1` when serving over HTTPS so session cookies get the `Secure` flag. |
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Register** — the first account becomes the `admin`.
|
||||
2. Open **Profiles** and add a RadioDJ MySQL connection (host, port, database, user, password). Use **Test** to verify connectivity — it also reports how many tracks are missing artwork.
|
||||
3. Pick the station in the header dropdown and click **Load tracks missing artwork**. The query matches tracks where `song_type = 0` and `image` is `NULL`, empty, **or a RadioDJ default placeholder** (`%no_cover%`, `%no_image%`, `%no-cover%` — case-insensitive). Placeholder rows are flagged in the grid with an amber “placeholder art” badge.
|
||||
4. Pick a **primary art source** in the header (Auto chains iTunes → Deezer → MusicBrainz, or pin a single API), use the grid **pagination bar** (page size 10/25/30/50/all, prev/next, numbered pages), then click **Search artwork** per row (or **Fetch Art for Current Page** for the whole page) to get artwork candidates. Hover a thumbnail to see its source (native tooltip), click it to open the **full-size preview overlay** (title, artist, source, “View Original Image”, and **✓ Confirm & Select This Art** — close with backdrop click or `Esc`). A row only becomes exportable once you tick its checkbox; fetching art never checks the box for you.
|
||||
5. Click **Download ZIP & Update Database**:
|
||||
- Uncheck **Update RadioDJ DB** to only download the ZIP.
|
||||
- When checked, the app runs `UPDATE songs SET image = '<new file name>' WHERE id = <song id>` for every exported item.
|
||||
6. Copy the images from the ZIP into the folder configured in RadioDJ under *Options → Options → Album Art*, so RadioDJ picks them up.
|
||||
|
||||
> **Note:** RadioDJ's `songs.image` column stores just the artwork file name (relative to its album-art folder). That is exactly what this app writes.
|
||||
|
||||
### Admin
|
||||
|
||||
The admin header button opens the administration panel:
|
||||
|
||||
- List all users and change roles (`user` ⇄ `admin`). You can't change your own role or demote the last admin.
|
||||
- Copy (clone) all station profiles from one user to another — encrypted passwords carry over.
|
||||
|
||||
## API overview
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
| ------ | --------------------------------- | ------------------------------------------------------- |
|
||||
| POST | `/api/auth/register` | Register (first user becomes admin). |
|
||||
| POST | `/api/auth/login` / `logout` | Session auth (HttpOnly cookie). |
|
||||
| GET | `/api/auth/me` | Current session user. |
|
||||
| GET/POST | `/api/profiles` | List / create RadioDJ connection profiles. |
|
||||
| PUT/DELETE | `/api/profiles/:id` | Update (blank password keeps stored one) / delete. |
|
||||
| POST | `/api/profiles/:id/test` | Test MySQL connectivity + count missing artwork. |
|
||||
| GET | `/api/tracks?profileId=&page=&limit=` | Tracks missing artwork (`song_type=0` and `image` NULL/empty or a `no_cover`/`no_image`/`no-cover` placeholder); `limit=all` disables paging. Returns `{tracks,totalTracks,page,totalPages}`. |
|
||||
| POST | `/api/artwork/search` | `{artist,title,album,source}` — source `auto` chains iTunes → Deezer → MusicBrainz/CAA; or `itunes`/`deezer`/`musicbrainz` only. |
|
||||
| POST | `/api/export` | `{profileId,updateDb,items[]}` → ZIP download + optional DB update. |
|
||||
| GET | `/api/admin/users` | (admin) List users. |
|
||||
| PUT | `/api/admin/users/:id/role` | (admin) Change role. |
|
||||
| POST | `/api/admin/clone-profiles` | (admin) Copy all profiles between users. |
|
||||
| GET | `/api/health` | Health check (used by Docker HEALTHCHECK). |
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
├── server.js # Express backend: auth, profiles, MySQL pools, artwork search, ZIP export
|
||||
├── public/index.html # Single-page Tailwind dashboard (vanilla JS)
|
||||
├── data/ # SQLite storage (git-ignored, volume-mapped in Docker)
|
||||
├── Dockerfile
|
||||
├── docker-compose.yml
|
||||
├── smoke-test.ps1 # Optional API smoke test (run while the server is up)
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Security notes
|
||||
|
||||
- Passwords: scrypt (N=16384) + constant-time comparison.
|
||||
- RadioDJ DB passwords: AES-256-GCM encrypted at rest; never returned by the API.
|
||||
- Sessions: random 256-bit tokens in SQLite, HttpOnly + SameSite=Lax cookies, 7-day expiry.
|
||||
- Auth endpoints are rate-limited; all API routes require authentication; admin routes require the `admin` role.
|
||||
- SQL access is parameterized; the RadioDJ connection user only needs `SELECT`/`UPDATE` on the `songs` table.
|
||||
|
||||
## License
|
||||
|
||||
MIT — see [LICENSE](LICENSE).
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
rdj-art-app:
|
||||
build: .
|
||||
container_name: rdj-art-app
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
# IMPORTANT: set a strong, stable secret in production (see .env / compose overrides).
|
||||
# It encrypts the stored RadioDJ MySQL passwords. Changing it makes existing
|
||||
# stored passwords unreadable.
|
||||
APP_SECRET: ${APP_SECRET:-change-me-to-a-long-random-string}
|
||||
PORT: 3000
|
||||
volumes:
|
||||
- ./data:/app/data
|
||||
restart: unless-stopped
|
||||
Generated
+2320
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "rdj-art-app",
|
||||
"version": "1.0.0",
|
||||
"description": "Manage missing album artwork for a RadioDJ MySQL database: free artwork lookup (iTunes / MusicBrainz), ZIP export renamed to audio filenames, and optional database update.",
|
||||
"main": "server.js",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
"scripts": {
|
||||
"start": "node server.js",
|
||||
"dev": "node --watch server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"archiver": "^7.0.1",
|
||||
"better-sqlite3": "^11.10.0",
|
||||
"express": "^4.21.2",
|
||||
"mysql2": "^3.14.0"
|
||||
},
|
||||
"overrides": {
|
||||
"qs": "^6.16.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" class="h-full">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>RDJ Art Manager</title>
|
||||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>🎵</text></svg>">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<style type="text/tailwindcss">
|
||||
.btn-primary { @apply bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-sm font-semibold px-3 py-2 rounded-lg transition whitespace-nowrap; }
|
||||
.btn-ghost { @apply bg-slate-700/60 hover:bg-slate-600/60 disabled:opacity-50 text-slate-200 text-sm font-medium px-3 py-2 rounded-lg transition whitespace-nowrap; }
|
||||
.btn-mini { @apply bg-slate-700 hover:bg-slate-600 disabled:opacity-50 text-slate-100 text-xs font-medium px-2 py-1 rounded transition; }
|
||||
.btn-danger { @apply bg-rose-600/80 hover:bg-rose-500 text-white text-xs font-medium px-2 py-1 rounded transition; }
|
||||
.inp { @apply bg-slate-900/70 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-100 placeholder-slate-500 focus:outline-none focus:ring-2 focus:ring-indigo-500 w-full; }
|
||||
.modal { @apply fixed inset-0 z-50 items-start justify-center bg-black/60 p-4 overflow-y-auto; }
|
||||
.modal-card { @apply bg-slate-800 border border-slate-700 rounded-xl p-6 w-full mt-10 shadow-2xl; }
|
||||
.lbl { @apply block text-xs font-medium text-slate-400 mb-1; }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-slate-900 text-slate-200 min-h-full">
|
||||
|
||||
<!-- ============================== Header ============================== -->
|
||||
<header class="sticky top-0 z-40 bg-slate-900/90 backdrop-blur border-b border-slate-700/60">
|
||||
<div class="max-w-7xl mx-auto px-4 h-14 flex items-center gap-3">
|
||||
<div class="flex items-center gap-2 font-bold text-lg text-white select-none">
|
||||
<span class="text-2xl">🎵</span> RDJ Art Manager
|
||||
</div>
|
||||
<div id="navArea" class="ml-auto hidden items-center gap-2">
|
||||
<label class="text-xs text-slate-400 hidden sm:block">Station</label>
|
||||
<select id="stationSelect" class="inp !w-auto !py-1.5 max-w-[240px]"></select>
|
||||
<label class="text-xs text-slate-400 hidden sm:block">Source</label>
|
||||
<select id="apiSourceSelect" title="Primary artwork source" class="bg-slate-900 border border-slate-600 rounded px-3 py-1.5 text-sm text-slate-200">
|
||||
<option value="auto">Auto (iTunes → Deezer → MusicBrainz)</option>
|
||||
<option value="itunes">iTunes API Only</option>
|
||||
<option value="deezer">Deezer API Only</option>
|
||||
<option value="musicbrainz">MusicBrainz / CoverArtArchive Only</option>
|
||||
</select>
|
||||
<button id="btnStations" class="btn-ghost">Profiles</button>
|
||||
<button id="btnAdmin" class="btn-ghost hidden">Admin</button>
|
||||
<span id="userChip" class="text-xs px-2 py-1.5 rounded-lg bg-slate-700/80 text-slate-200"></span>
|
||||
<button id="btnLogout" class="btn-ghost">Log out</button>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- ============================== Auth ============================== -->
|
||||
<section id="authView" class="max-w-md mx-auto mt-16 px-4 hidden">
|
||||
<div class="bg-slate-800/70 border border-slate-700 rounded-xl p-6 shadow-xl">
|
||||
<h1 class="text-xl font-bold text-white mb-1">Welcome</h1>
|
||||
<p class="text-sm text-slate-400 mb-5">Sign in to manage RadioDJ album artwork.</p>
|
||||
<div class="flex mb-6 rounded-lg overflow-hidden border border-slate-600">
|
||||
<button id="tabLogin" type="button" class="flex-1 py-2 text-sm font-semibold bg-indigo-600 text-white transition">Sign in</button>
|
||||
<button id="tabRegister" type="button" class="flex-1 py-2 text-sm font-semibold text-slate-300 hover:bg-slate-700/50 transition">Register</button>
|
||||
</div>
|
||||
<form id="authForm" class="space-y-4">
|
||||
<div>
|
||||
<label class="lbl" for="authUsername">Username</label>
|
||||
<input id="authUsername" class="inp" autocomplete="username" required minlength="3" maxlength="32">
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl" for="authPassword">Password</label>
|
||||
<input id="authPassword" type="password" class="inp" autocomplete="current-password" required minlength="8">
|
||||
</div>
|
||||
<p id="authHint" class="text-xs text-slate-500">The first registered account becomes the administrator.</p>
|
||||
<button id="authSubmit" type="submit" class="btn-primary w-full">Sign in</button>
|
||||
<p id="authError" class="text-sm text-rose-400 hidden"></p>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- ============================== App ============================== -->
|
||||
<main id="appView" class="max-w-7xl mx-auto px-4 py-6 hidden">
|
||||
<div class="flex flex-wrap items-center gap-2 mb-4">
|
||||
<button id="btnLoadTracks" class="btn-primary">Load tracks missing artwork</button>
|
||||
<button id="btnAutoSearch" class="btn-ghost" disabled>Fetch Art for Current Page</button>
|
||||
<input id="filterInput" placeholder="Filter title / artist / file…" class="inp !w-56">
|
||||
<span id="trackStats" class="text-xs text-slate-400"></span>
|
||||
<div class="ml-auto flex items-center gap-3">
|
||||
<label class="flex items-center gap-1.5 text-sm text-slate-300 select-none" title="When enabled, the export also runs UPDATE songs SET image = '<new file name>' on the selected station database.">
|
||||
<input type="checkbox" id="chkUpdateDb" class="accent-indigo-500 w-4 h-4" checked>
|
||||
Update RadioDJ DB
|
||||
</label>
|
||||
<button id="btnExport" class="btn-primary" disabled title="Only tracks whose checkbox is ticked are exported">Download ZIP & Update Database</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-slate-800/60 border border-slate-700 rounded-xl overflow-hidden">
|
||||
<div class="overflow-auto max-h-[72vh]">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="sticky top-0 bg-slate-800 z-10 shadow">
|
||||
<tr class="text-left text-xs uppercase tracking-wide text-slate-400">
|
||||
<th class="p-3 w-10 text-center"><input type="checkbox" id="checkAll" class="accent-indigo-500 w-4 h-4"></th>
|
||||
<th class="p-3 w-16">Artwork</th>
|
||||
<th class="p-3">Title</th>
|
||||
<th class="p-3">Artist</th>
|
||||
<th class="p-3">Audio Filename</th>
|
||||
<th class="p-3 w-80">Search & Results</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="tracksBody"></tbody>
|
||||
</table>
|
||||
<div id="paginationBar" class="hidden flex flex-wrap items-center gap-2 px-3 py-2 border-t border-slate-700/60 text-sm"></div>
|
||||
<div id="emptyState" class="p-10 text-center text-slate-500 text-sm">
|
||||
Select a station profile, then click “Load tracks missing artwork”.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-xs text-slate-500 mt-3">
|
||||
Artwork sources: iTunes Search API, MusicBrainz + Cover Art Archive — 100% free, no API keys.
|
||||
Hover any thumbnail to see its source; click it to inspect the full-size image. Exported images are
|
||||
renamed to the audio file’s base name (e.g. <span class="font-mono">01 Track.mp3 → 01 Track.jpg</span>).
|
||||
Nothing is exported until you tick the track’s checkbox to confirm its artwork.
|
||||
</p>
|
||||
</main>
|
||||
|
||||
<!-- ====================== Artwork preview overlay ===================== -->
|
||||
<div id="artModal" class="modal hidden">
|
||||
<div class="relative flex flex-col items-center justify-center min-h-[calc(100vh-2rem)] w-full">
|
||||
<button id="artClose" class="absolute top-2 right-2 btn-ghost !text-lg" title="Close (Esc)">✕</button>
|
||||
<img id="artImg" src="" alt="High resolution artwork"
|
||||
class="max-h-[70vh] max-w-[90vw] rounded-xl shadow-2xl object-contain bg-slate-800 cursor-pointer"
|
||||
title="Click to view the original image in a new tab">
|
||||
<div class="mt-4 text-center">
|
||||
<div id="artTitle" class="text-lg font-semibold text-white"></div>
|
||||
<div id="artArtist" class="text-sm text-slate-400"></div>
|
||||
<div id="artSource" class="text-xs text-slate-500 mt-1"></div>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-3">
|
||||
<a id="artOriginal" href="#" target="_blank" rel="noopener noreferrer" class="btn-ghost">View Original Image ↗</a>
|
||||
<button id="artConfirm" class="btn-primary">✓ Confirm & Select This Art</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================= Profiles modal ========================= -->
|
||||
<div id="profilesModal" class="modal hidden">
|
||||
<div class="modal-card max-w-3xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Station Profiles — RadioDJ MySQL</h2>
|
||||
<button class="btn-ghost" data-close="profilesModal">✕</button>
|
||||
</div>
|
||||
<div id="profilesList" class="space-y-2 mb-6"></div>
|
||||
<h3 id="profileFormTitle" class="text-sm font-semibold text-slate-300 mb-2">Add profile</h3>
|
||||
<form id="profileForm" class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<input type="hidden" id="pfId">
|
||||
<input id="pfName" class="inp" placeholder="Profile name (e.g. Main FM)" required maxlength="64">
|
||||
<div class="flex gap-2">
|
||||
<input id="pfHost" class="inp flex-1" placeholder="Host (e.g. 192.168.1.10)" required>
|
||||
<input id="pfPort" class="inp !w-24" placeholder="3306" type="number" min="1" max="65535">
|
||||
</div>
|
||||
<input id="pfDb" class="inp" placeholder="Database name (e.g. radiodj)" required>
|
||||
<input id="pfUser" class="inp" placeholder="DB user" required>
|
||||
<input id="pfPass" class="inp sm:col-span-2" placeholder="DB password" type="password" autocomplete="new-password">
|
||||
<div class="sm:col-span-2 flex items-center gap-2">
|
||||
<button type="submit" class="btn-primary">Save profile</button>
|
||||
<button type="button" id="pfCancel" class="btn-ghost hidden">Cancel edit</button>
|
||||
<span id="pfMsg" class="text-xs"></span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ========================== Admin modal =========================== -->
|
||||
<div id="adminModal" class="modal hidden">
|
||||
<div class="modal-card max-w-2xl">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-lg font-semibold text-white">Administration</h2>
|
||||
<button class="btn-ghost" data-close="adminModal">✕</button>
|
||||
</div>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Users</h3>
|
||||
<div id="adminUsers" class="space-y-2 mb-6"></div>
|
||||
<h3 class="text-sm font-semibold text-slate-300 mb-2">Copy station profiles between users</h3>
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<select id="cloneFrom" class="inp !w-auto"></select>
|
||||
<span class="text-slate-400">→</span>
|
||||
<select id="cloneTo" class="inp !w-auto"></select>
|
||||
<button id="btnClone" class="btn-primary">Copy all profiles</button>
|
||||
</div>
|
||||
<p id="adminMsg" class="text-xs mt-3"></p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="toasts" class="fixed bottom-4 right-4 z-[60] space-y-2 max-w-sm"></div>
|
||||
|
||||
<script>
|
||||
'use strict';
|
||||
// ------------------------------ helpers -----------------------------------
|
||||
const $ = id => document.getElementById(id);
|
||||
const esc = s => String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
|
||||
const state = {
|
||||
user: null,
|
||||
profiles: [],
|
||||
activeProfileId: null,
|
||||
tracks: [],
|
||||
selections: new Map(), // trackId -> { imageUrl, thumb, sourceLabel }
|
||||
candidates: new Map(), // trackId -> [{ full, thumb, source, sourceLabel, artist, album }] — persists across filter re-renders
|
||||
artModal: { trackId: null, full: '' },
|
||||
authMode: 'login',
|
||||
};
|
||||
let currentPage = 1;
|
||||
let currentLimit = 25;
|
||||
let totalTracks = 0;
|
||||
|
||||
async function api(url, opts = {}) {
|
||||
const res = await fetch(url, {
|
||||
credentials: 'same-origin',
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) },
|
||||
body: opts.body && typeof opts.body !== 'string' ? JSON.stringify(opts.body) : opts.body,
|
||||
});
|
||||
if (res.status === 401 && !url.includes('/auth/')) { showAuth(); throw new Error('Session expired — please sign in again.'); }
|
||||
let data = null;
|
||||
if ((res.headers.get('content-type') || '').includes('application/json')) data = await res.json();
|
||||
if (!res.ok) throw new Error((data && data.error) || `Request failed (${res.status})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
function toast(msg, type = 'info') {
|
||||
const colors = { info: 'bg-slate-700 text-slate-100', success: 'bg-emerald-600 text-white', error: 'bg-rose-600 text-white' };
|
||||
const el = document.createElement('div');
|
||||
el.className = `px-4 py-2.5 rounded-lg shadow-lg text-sm ${colors[type] || colors.info}`;
|
||||
el.textContent = msg;
|
||||
$('toasts').appendChild(el);
|
||||
setTimeout(() => el.remove(), 4500);
|
||||
}
|
||||
|
||||
function openModal(id) { $(id).classList.remove('hidden'); $(id).classList.add('flex'); }
|
||||
function closeModal(id) { $(id).classList.add('hidden'); $(id).classList.remove('flex'); }
|
||||
|
||||
// ------------------------------ views -------------------------------------
|
||||
function showAuth() {
|
||||
state.user = null;
|
||||
$('authView').classList.remove('hidden');
|
||||
$('appView').classList.add('hidden');
|
||||
$('navArea').classList.add('hidden');
|
||||
$('navArea').classList.remove('flex');
|
||||
}
|
||||
|
||||
function showApp() {
|
||||
$('authView').classList.add('hidden');
|
||||
$('appView').classList.remove('hidden');
|
||||
$('navArea').classList.remove('hidden');
|
||||
$('navArea').classList.add('flex');
|
||||
$('userChip').textContent = `${state.user.username} · ${state.user.role}`;
|
||||
$('btnAdmin').classList.toggle('hidden', state.user.role !== 'admin');
|
||||
}
|
||||
|
||||
function setAuthMode(mode) {
|
||||
state.authMode = mode;
|
||||
const login = mode === 'login';
|
||||
$('tabLogin').className = 'flex-1 py-2 text-sm font-semibold transition ' + (login ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700/50');
|
||||
$('tabRegister').className = 'flex-1 py-2 text-sm font-semibold transition ' + (!login ? 'bg-indigo-600 text-white' : 'text-slate-300 hover:bg-slate-700/50');
|
||||
$('authSubmit').textContent = login ? 'Sign in' : 'Create account';
|
||||
$('authHint').classList.toggle('hidden', login);
|
||||
$('authError').classList.add('hidden');
|
||||
}
|
||||
|
||||
// ------------------------------ auth --------------------------------------
|
||||
async function submitAuth(e) {
|
||||
e.preventDefault();
|
||||
$('authError').classList.add('hidden');
|
||||
const username = $('authUsername').value.trim();
|
||||
const password = $('authPassword').value;
|
||||
try {
|
||||
const data = await api(`/api/auth/${state.authMode}`, { method: 'POST', body: { username, password } });
|
||||
state.user = data.user;
|
||||
showApp();
|
||||
await loadProfiles();
|
||||
toast(`Welcome, ${data.user.username}${data.user.role === 'admin' ? ' (admin)' : ''}.`, 'success');
|
||||
} catch (err) {
|
||||
$('authError').textContent = err.message;
|
||||
$('authError').classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
try { await api('/api/auth/logout', { method: 'POST' }); } catch { /* ignore */ }
|
||||
location.reload();
|
||||
}
|
||||
|
||||
// ---------------------------- profiles ------------------------------------
|
||||
async function loadProfiles() {
|
||||
const data = await api('/api/profiles');
|
||||
state.profiles = data.profiles;
|
||||
if (state.activeProfileId && !state.profiles.some(p => p.id === state.activeProfileId)) state.activeProfileId = null;
|
||||
if (!state.activeProfileId && state.profiles.length) state.activeProfileId = state.profiles[0].id;
|
||||
renderStationSelect();
|
||||
renderProfilesList();
|
||||
}
|
||||
|
||||
function renderStationSelect() {
|
||||
const sel = $('stationSelect');
|
||||
sel.innerHTML = state.profiles.length
|
||||
? state.profiles.map(p => `<option value="${p.id}">${esc(p.name)} — ${esc(p.host)}/${esc(p.database)}</option>`).join('')
|
||||
: '<option value="">No profiles — open Profiles</option>';
|
||||
sel.value = state.activeProfileId ?? '';
|
||||
}
|
||||
|
||||
function renderProfilesList() {
|
||||
const box = $('profilesList');
|
||||
if (!state.profiles.length) {
|
||||
box.innerHTML = '<p class="text-sm text-slate-500">No station profiles yet — add one below.</p>';
|
||||
return;
|
||||
}
|
||||
box.innerHTML = state.profiles.map(p => `
|
||||
<div class="flex flex-wrap items-center gap-2 bg-slate-900/50 border ${p.id === state.activeProfileId ? 'border-indigo-500/60' : 'border-slate-700'} rounded-lg px-3 py-2" data-pid="${p.id}">
|
||||
<div class="min-w-0">
|
||||
<div class="font-semibold text-slate-100">${esc(p.name)} ${p.id === state.activeProfileId ? '<span class="text-xs text-indigo-400">(active)</span>' : ''}</div>
|
||||
<div class="text-xs text-slate-400 font-mono">${esc(p.dbUser)}@${esc(p.host)}:${p.port}/${esc(p.database)}</div>
|
||||
</div>
|
||||
<div class="ml-auto flex gap-1.5">
|
||||
<button type="button" class="btn-mini act-use">Set active</button>
|
||||
<button type="button" class="btn-mini act-test">Test</button>
|
||||
<button type="button" class="btn-mini act-edit">Edit</button>
|
||||
<button type="button" class="btn-danger act-del">Delete</button>
|
||||
</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function resetProfileForm() {
|
||||
$('pfId').value = '';
|
||||
$('profileForm').reset();
|
||||
$('pfPort').value = '';
|
||||
$('pfPass').placeholder = 'DB password';
|
||||
$('profileFormTitle').textContent = 'Add profile';
|
||||
$('pfCancel').classList.add('hidden');
|
||||
$('pfMsg').textContent = '';
|
||||
}
|
||||
|
||||
async function submitProfile(e) {
|
||||
e.preventDefault();
|
||||
const id = $('pfId').value;
|
||||
const body = {
|
||||
name: $('pfName').value.trim(),
|
||||
host: $('pfHost').value.trim(),
|
||||
port: $('pfPort').value || '3306',
|
||||
database: $('pfDb').value.trim(),
|
||||
dbUser: $('pfUser').value.trim(),
|
||||
dbPassword: $('pfPass').value,
|
||||
};
|
||||
try {
|
||||
if (id) await api(`/api/profiles/${id}`, { method: 'PUT', body });
|
||||
else await api('/api/profiles', { method: 'POST', body });
|
||||
resetProfileForm();
|
||||
await loadProfiles();
|
||||
toast('Profile saved.', 'success');
|
||||
} catch (err) {
|
||||
$('pfMsg').textContent = err.message;
|
||||
$('pfMsg').className = 'text-xs text-rose-400';
|
||||
}
|
||||
}
|
||||
|
||||
async function handleProfileAction(e) {
|
||||
const btn = e.target.closest('button');
|
||||
if (!btn) return;
|
||||
const wrap = e.target.closest('[data-pid]');
|
||||
if (!wrap) return;
|
||||
const pid = Number(wrap.dataset.pid);
|
||||
const profile = state.profiles.find(p => p.id === pid);
|
||||
if (!profile) return;
|
||||
|
||||
if (btn.classList.contains('act-use')) {
|
||||
state.activeProfileId = pid;
|
||||
renderStationSelect();
|
||||
renderProfilesList();
|
||||
toast(`Active station: ${profile.name}`, 'success');
|
||||
} else if (btn.classList.contains('act-test')) {
|
||||
btn.disabled = true; btn.textContent = '…';
|
||||
try {
|
||||
const r = await api(`/api/profiles/${pid}/test`, { method: 'POST' });
|
||||
toast(`Connected — MySQL ${r.version}, ${r.missingArtwork} track(s) missing artwork.`, 'success');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Test';
|
||||
}
|
||||
} else if (btn.classList.contains('act-edit')) {
|
||||
$('pfId').value = profile.id;
|
||||
$('pfName').value = profile.name;
|
||||
$('pfHost').value = profile.host;
|
||||
$('pfPort').value = profile.port;
|
||||
$('pfDb').value = profile.database;
|
||||
$('pfUser').value = profile.dbUser;
|
||||
$('pfPass').value = '';
|
||||
$('pfPass').placeholder = 'Leave blank to keep current password';
|
||||
$('profileFormTitle').textContent = `Edit profile: ${profile.name}`;
|
||||
$('pfCancel').classList.remove('hidden');
|
||||
} else if (btn.classList.contains('act-del')) {
|
||||
if (!confirm(`Delete profile "${profile.name}"?`)) return;
|
||||
try {
|
||||
await api(`/api/profiles/${pid}`, { method: 'DELETE' });
|
||||
if (state.activeProfileId === pid) state.activeProfileId = null;
|
||||
await loadProfiles();
|
||||
toast('Profile deleted.', 'success');
|
||||
} catch (err) { toast(err.message, 'error'); }
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------- tracks -------------------------------------
|
||||
function sourceLabel(r) {
|
||||
return r.sourceLabel || ({ itunes: 'iTunes API', deezer: 'Deezer', musicbrainz: 'Cover Art Archive / MusicBrainz' }[r.source] || r.source || 'Unknown');
|
||||
}
|
||||
|
||||
function thumbHtml(r, selectedUrl) {
|
||||
return `<img class="thumb w-14 h-14 rounded object-cover cursor-zoom-in border-2 ${selectedUrl === r.full ? 'border-indigo-500' : 'border-transparent hover:border-slate-500'}"
|
||||
src="${esc(r.thumb)}" data-full="${esc(r.full)}" data-thumb="${esc(r.thumb)}" data-source-label="${esc(sourceLabel(r))}"
|
||||
title="Source: ${esc(sourceLabel(r))} — click to preview full size" loading="lazy" alt="artwork option">`;
|
||||
}
|
||||
|
||||
function trackRowHtml(t) {
|
||||
const sel = state.selections.get(t.id);
|
||||
return `<tr data-id="${t.id}" class="border-b border-slate-700/50 hover:bg-slate-700/20">
|
||||
<td class="p-3 text-center"><input type="checkbox" class="row-check accent-indigo-500 w-4 h-4" ${sel ? 'checked' : ''} title="Confirm this artwork for export"></td>
|
||||
<td class="p-3"><div class="preview-slot w-14 h-14 rounded overflow-hidden bg-slate-700/60 flex items-center justify-center text-slate-500 text-xs">
|
||||
${sel
|
||||
? `<img src="${esc(sel.thumb)}" data-full="${esc(sel.imageUrl)}" data-source-label="${esc(sel.sourceLabel || '')}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(sel.sourceLabel || 'Unknown')} — click to preview full size" alt="artwork">`
|
||||
: t.placeholder
|
||||
? `<span class="text-amber-400 text-[10px] leading-tight text-center px-0.5" title="RadioDJ placeholder: ${esc(t.image)}">placeholder art</span>`
|
||||
: '<span class="text-slate-500">missing</span>'}</div></td>
|
||||
<td class="p-3 font-medium text-slate-100">${esc(t.title)}</td>
|
||||
<td class="p-3 text-slate-300">${esc(t.artist)}</td>
|
||||
<td class="p-3 font-mono text-xs text-slate-400 break-all">${esc(t.audioBase)}</td>
|
||||
<td class="p-3">
|
||||
<button type="button" class="search-btn btn-mini" title="Search iTunes / Cover Art Archive for artwork — review results, then tick the checkbox to confirm">Search artwork</button>
|
||||
<div class="thumbs flex flex-wrap gap-1.5 mt-2">${(state.candidates.get(t.id) || []).map(r => thumbHtml(r, sel?.imageUrl)).join('')}</div>
|
||||
</td>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
function renderTracks() {
|
||||
const f = $('filterInput').value.trim().toLowerCase();
|
||||
const rows = state.tracks.filter(t => !f || [t.title, t.artist, t.audioBase].join(' ').toLowerCase().includes(f));
|
||||
$('tracksBody').innerHTML = rows.map(trackRowHtml).join('');
|
||||
const empty = $('emptyState');
|
||||
empty.style.display = rows.length ? 'none' : '';
|
||||
empty.textContent = state.tracks.length
|
||||
? 'No tracks match the filter.'
|
||||
: 'No tracks loaded. Select a station profile, then click “Load tracks missing artwork”.';
|
||||
$('checkAll').checked = false;
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function updateStats() {
|
||||
const checked = document.querySelectorAll('#tracksBody .row-check:checked').length;
|
||||
$('trackStats').textContent = state.tracks.length
|
||||
? `${state.tracks.length} track(s) on page (${totalTracks} total) · ${state.selections.size} with artwork · ${checked} selected`
|
||||
: '';
|
||||
$('btnExport').disabled = checked === 0;
|
||||
$('btnAutoSearch').disabled = state.tracks.length === 0;
|
||||
}
|
||||
|
||||
// --------------------------- pagination ------------------------------------
|
||||
function renderPagination(totalPages) {
|
||||
const bar = $('paginationBar');
|
||||
if (!totalTracks) { bar.classList.add('hidden'); bar.innerHTML = ''; return; }
|
||||
bar.classList.remove('hidden');
|
||||
const cur = String(currentLimit);
|
||||
let pages = '';
|
||||
if (totalPages > 1) {
|
||||
const lo = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
|
||||
const hi = Math.min(totalPages, Math.max(currentPage + 2, 5));
|
||||
let html = '';
|
||||
if (lo > 1) html += `<button type="button" data-page="1" class="btn-mini">1</button>` + (lo > 2 ? '<span class="px-1 text-slate-500">…</span>' : '');
|
||||
for (let p = lo; p <= hi; p++) {
|
||||
html += `<button type="button" data-page="${p}" class="btn-mini ${p === currentPage ? '!bg-indigo-600 !text-white' : ''}">${p}</button>`;
|
||||
}
|
||||
if (hi < totalPages) html += (hi < totalPages - 1 ? '<span class="px-1 text-slate-500">…</span>' : '') + `<button type="button" data-page="${totalPages}" class="btn-mini">${totalPages}</button>`;
|
||||
pages = `<span class="flex items-center gap-1">${html}</span>`;
|
||||
} else {
|
||||
pages = '<span class="text-slate-400">Page 1 of 1</span>';
|
||||
}
|
||||
bar.innerHTML = `
|
||||
<label class="text-slate-400">Per page</label>
|
||||
<select id="limitSelect" class="inp !w-auto !py-1">
|
||||
<option value="10" ${cur === '10' ? 'selected' : ''}>10</option>
|
||||
<option value="25" ${cur === '25' ? 'selected' : ''}>25</option>
|
||||
<option value="30" ${cur === '30' ? 'selected' : ''}>30</option>
|
||||
<option value="50" ${cur === '50' ? 'selected' : ''}>50</option>
|
||||
<option value="all" ${cur === 'all' ? 'selected' : ''}>all</option>
|
||||
</select>
|
||||
<button type="button" id="btnPrev" class="btn-mini" ${currentPage <= 1 ? 'disabled' : ''}>← Prev</button>
|
||||
${pages}
|
||||
<button type="button" id="btnNext" class="btn-mini" ${currentPage >= totalPages ? 'disabled' : ''}>Next →</button>`;
|
||||
}
|
||||
|
||||
async function loadTracks() {
|
||||
if (!state.activeProfileId) return toast('Add and select a station profile first (Profiles button).', 'error');
|
||||
const btn = $('btnLoadTracks');
|
||||
btn.disabled = true; btn.textContent = 'Loading…';
|
||||
try {
|
||||
const data = await api(`/api/tracks?profileId=${state.activeProfileId}&page=${currentPage}&limit=${currentLimit}`);
|
||||
state.tracks = data.tracks;
|
||||
totalTracks = data.totalTracks || 0;
|
||||
currentPage = data.page || 1;
|
||||
renderPagination(data.totalPages || 1);
|
||||
state.selections.clear();
|
||||
state.candidates.clear();
|
||||
renderTracks();
|
||||
toast(`${data.tracks.length} track(s) missing artwork loaded.`, 'success');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Load tracks missing artwork';
|
||||
}
|
||||
}
|
||||
|
||||
async function searchForRow(tr, track) {
|
||||
const btn = tr.querySelector('.search-btn');
|
||||
const box = tr.querySelector('.thumbs');
|
||||
btn.disabled = true; btn.textContent = 'Searching…';
|
||||
try {
|
||||
const data = await api('/api/artwork/search', { method: 'POST', body: { artist: track.artist, title: track.title, album: track.album, source: $('apiSourceSelect').value } });
|
||||
if (!data.results.length) {
|
||||
box.innerHTML = '<span class="text-xs text-slate-500">No artwork found.</span>';
|
||||
return;
|
||||
}
|
||||
state.candidates.set(track.id, data.results);
|
||||
const sel = state.selections.get(track.id);
|
||||
box.innerHTML = data.results.map(r => thumbHtml(r, sel?.imageUrl)).join('');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Search artwork';
|
||||
}
|
||||
}
|
||||
|
||||
function setTrackSelection(tr, track, img) {
|
||||
state.selections.set(track.id, { imageUrl: img.dataset.full, thumb: img.dataset.thumb, sourceLabel: img.dataset.sourceLabel || '' });
|
||||
tr.querySelectorAll('.thumb').forEach(t => { t.classList.remove('border-indigo-500'); t.classList.add('border-transparent'); });
|
||||
tr.querySelectorAll(`.thumb[data-full="${CSS.escape(img.dataset.full)}"]`).forEach(t => { t.classList.remove('border-transparent'); t.classList.add('border-indigo-500'); });
|
||||
tr.querySelector('.preview-slot').innerHTML = `<img src="${esc(img.dataset.thumb)}" data-full="${esc(img.dataset.full)}" data-source-label="${esc(img.dataset.sourceLabel || '')}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(img.dataset.sourceLabel || 'Unknown')} — click to preview full size" alt="selected artwork">`;
|
||||
updateStats();
|
||||
}
|
||||
|
||||
function selectThumb(tr, track, img) {
|
||||
setTrackSelection(tr, track, img);
|
||||
tr.querySelector('.row-check').checked = true;
|
||||
updateStats();
|
||||
}
|
||||
|
||||
// --------------------------- preview overlay -------------------------------
|
||||
function openArtModal(trackId, imgData) {
|
||||
const t = state.tracks.find(x => x.id === trackId);
|
||||
if (!t) return;
|
||||
state.artModal = { trackId, full: imgData.full, thumb: imgData.thumb || imgData.full, sourceLabel: imgData.sourceLabel || '' };
|
||||
$('artImg').src = imgData.thumb || imgData.full; // thumb first, then upgrade to hi-res
|
||||
$('artImg').src = imgData.full;
|
||||
$('artTitle').textContent = t.title || '(untitled)';
|
||||
$('artArtist').textContent = t.artist || '';
|
||||
$('artSource').textContent = imgData.sourceLabel ? `Source: ${imgData.sourceLabel}` : '';
|
||||
$('artOriginal').href = imgData.full;
|
||||
openModal('artModal');
|
||||
}
|
||||
|
||||
function confirmArtSelection() {
|
||||
const { trackId, full, thumb, sourceLabel } = state.artModal;
|
||||
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
|
||||
const track = state.tracks.find(x => x.id === trackId);
|
||||
if (tr && track) {
|
||||
setTrackSelection(tr, track, { dataset: { full, thumb, sourceLabel } });
|
||||
tr.querySelector('.row-check').checked = true;
|
||||
updateStats();
|
||||
toast('Artwork confirmed for export.', 'success');
|
||||
}
|
||||
closeModal('artModal');
|
||||
}
|
||||
|
||||
function fillRowFromBest(tr, track, best) {
|
||||
// Suggest the top hit as a visual preview WITHOUT checking the confirmation box —
|
||||
// the user must review it and tick the box manually before export.
|
||||
state.candidates.set(track.id, [best]);
|
||||
tr.querySelector('.thumbs').innerHTML = thumbHtml(best, null);
|
||||
tr.querySelector('.preview-slot').innerHTML = `<img src="${esc(best.thumb)}" data-full="${esc(best.full)}" data-source-label="${esc(sourceLabel(best))}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(sourceLabel(best))} — suggested, click to preview">`;
|
||||
}
|
||||
|
||||
async function autoSearch() {
|
||||
const pending = state.tracks.filter(t => !state.selections.has(t.id));
|
||||
if (!pending.length) return toast('Every loaded track already has artwork selected.');
|
||||
const btn = $('btnAutoSearch');
|
||||
btn.disabled = true;
|
||||
let done = 0, found = 0;
|
||||
for (const t of pending) {
|
||||
done += 1;
|
||||
btn.textContent = `Searching ${done}/${pending.length}…`;
|
||||
try {
|
||||
const data = await api('/api/artwork/search', { method: 'POST', body: { artist: t.artist, title: t.title, album: t.album, source: $('apiSourceSelect').value } });
|
||||
if (data.results.length) {
|
||||
found += 1;
|
||||
const tr = document.querySelector(`tr[data-id="${t.id}"]`);
|
||||
if (tr) fillRowFromBest(tr, t, data.results[0]);
|
||||
}
|
||||
} catch { /* keep going */ }
|
||||
await sleep(250); // be polite to the free APIs
|
||||
}
|
||||
btn.textContent = 'Fetch Art for Current Page';
|
||||
btn.disabled = false;
|
||||
updateStats();
|
||||
toast(`Fetched artwork suggestions for ${found}/${pending.length} track(s) — review each one and tick its checkbox to confirm.`, found ? 'success' : 'info');
|
||||
}
|
||||
|
||||
// ----------------------------- export -------------------------------------
|
||||
async function exportSelected() {
|
||||
const items = [];
|
||||
document.querySelectorAll('#tracksBody .row-check:checked').forEach(cb => {
|
||||
const id = Number(cb.closest('tr').dataset.id);
|
||||
const t = state.tracks.find(x => x.id === id);
|
||||
const sel = state.selections.get(id);
|
||||
if (t && sel) items.push({ id: t.id, path: t.path, audioBase: t.audioBase, imageUrl: sel.imageUrl });
|
||||
});
|
||||
if (!items.length) return toast('Nothing to export — select artwork for at least one track.', 'error');
|
||||
|
||||
const updateDb = $('chkUpdateDb').checked;
|
||||
const btn = $('btnExport');
|
||||
btn.disabled = true; btn.textContent = 'Working…';
|
||||
try {
|
||||
const res = await fetch('/api/export', {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ profileId: state.activeProfileId, updateDb, items }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let msg = `Export failed (${res.status})`;
|
||||
try { msg = (await res.json()).error || msg; } catch { /* ignore */ }
|
||||
throw new Error(msg);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `rdj-artwork-${new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-')}.zip`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
URL.revokeObjectURL(a.href);
|
||||
toast(`Exported ${items.length} artwork file(s)${updateDb ? ' and updated the RadioDJ database' : ''}. See export-report.json in the ZIP.`, 'success');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
} finally {
|
||||
btn.disabled = false; btn.textContent = 'Download ZIP & Update Database';
|
||||
updateStats();
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------ admin -------------------------------------
|
||||
async function loadAdmin() {
|
||||
const data = await api('/api/admin/users');
|
||||
$('adminUsers').innerHTML = data.users.map(u => `
|
||||
<div class="flex items-center gap-2 bg-slate-900/50 border border-slate-700 rounded-lg px-3 py-2" data-uid="${u.id}">
|
||||
<span class="font-medium text-slate-100">${esc(u.username)}</span>
|
||||
<span class="text-xs text-slate-500">${u.profileCount} profile(s) · joined ${esc(String(u.createdAt).slice(0, 10))}</span>
|
||||
<select class="role-sel inp !w-auto !py-1 ml-auto" ${u.id === state.user.id ? 'disabled title="You cannot change your own role"' : ''}>
|
||||
<option value="user" ${u.role === 'user' ? 'selected' : ''}>user</option>
|
||||
<option value="admin" ${u.role === 'admin' ? 'selected' : ''}>admin</option>
|
||||
</select>
|
||||
</div>`).join('');
|
||||
const opts = data.users.map(u => `<option value="${u.id}">${esc(u.username)}</option>`).join('');
|
||||
$('cloneFrom').innerHTML = opts;
|
||||
$('cloneTo').innerHTML = opts;
|
||||
if (data.users.length > 1) $('cloneTo').selectedIndex = 1;
|
||||
}
|
||||
|
||||
async function handleRoleChange(e) {
|
||||
if (!e.target.classList.contains('role-sel')) return;
|
||||
const uid = Number(e.target.closest('[data-uid]').dataset.uid);
|
||||
try {
|
||||
await api(`/api/admin/users/${uid}/role`, { method: 'PUT', body: { role: e.target.value } });
|
||||
toast('Role updated.', 'success');
|
||||
} catch (err) {
|
||||
toast(err.message, 'error');
|
||||
await loadAdmin();
|
||||
}
|
||||
}
|
||||
|
||||
async function cloneProfiles() {
|
||||
const fromUserId = Number($('cloneFrom').value);
|
||||
const toUserId = Number($('cloneTo').value);
|
||||
$('adminMsg').textContent = '';
|
||||
try {
|
||||
const r = await api('/api/admin/clone-profiles', { method: 'POST', body: { fromUserId, toUserId } });
|
||||
$('adminMsg').className = 'text-xs mt-3 text-emerald-400';
|
||||
$('adminMsg').textContent = `Copied ${r.cloned} profile(s).`;
|
||||
await loadAdmin();
|
||||
} catch (err) {
|
||||
$('adminMsg').className = 'text-xs mt-3 text-rose-400';
|
||||
$('adminMsg').textContent = err.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------ events ------------------------------------
|
||||
function bindEvents() {
|
||||
$('tabLogin').addEventListener('click', () => setAuthMode('login'));
|
||||
$('tabRegister').addEventListener('click', () => setAuthMode('register'));
|
||||
$('authForm').addEventListener('submit', submitAuth);
|
||||
$('btnLogout').addEventListener('click', logout);
|
||||
|
||||
$('stationSelect').addEventListener('change', e => {
|
||||
state.activeProfileId = Number(e.target.value) || null;
|
||||
currentPage = 1;
|
||||
renderProfilesList();
|
||||
});
|
||||
|
||||
$('btnStations').addEventListener('click', () => { renderProfilesList(); openModal('profilesModal'); });
|
||||
$('btnAdmin').addEventListener('click', async () => {
|
||||
openModal('adminModal');
|
||||
try { await loadAdmin(); } catch (err) { toast(err.message, 'error'); }
|
||||
});
|
||||
document.querySelectorAll('[data-close]').forEach(b =>
|
||||
b.addEventListener('click', () => closeModal(b.dataset.close)));
|
||||
document.querySelectorAll('.modal').forEach(m =>
|
||||
m.addEventListener('click', e => { if (e.target === m) closeModal(m.id); }));
|
||||
|
||||
$('profileForm').addEventListener('submit', submitProfile);
|
||||
$('pfCancel').addEventListener('click', resetProfileForm);
|
||||
$('profilesList').addEventListener('click', handleProfileAction);
|
||||
|
||||
$('btnLoadTracks').addEventListener('click', loadTracks);
|
||||
$('btnAutoSearch').addEventListener('click', autoSearch);
|
||||
$('filterInput').addEventListener('input', renderTracks);
|
||||
$('btnExport').addEventListener('click', exportSelected);
|
||||
|
||||
$('checkAll').addEventListener('change', e => {
|
||||
const on = e.target.checked;
|
||||
document.querySelectorAll('#tracksBody .row-check').forEach(cb => {
|
||||
const id = Number(cb.closest('tr').dataset.id);
|
||||
cb.checked = on ? state.selections.has(id) : false;
|
||||
});
|
||||
updateStats();
|
||||
});
|
||||
|
||||
$('tracksBody').addEventListener('click', async e => {
|
||||
const tr = e.target.closest('tr[data-id]');
|
||||
if (!tr) return;
|
||||
const track = state.tracks.find(t => t.id === Number(tr.dataset.id));
|
||||
if (!track) return;
|
||||
if (e.target.classList.contains('search-btn')) await searchForRow(tr, track);
|
||||
else if (e.target.classList.contains('thumb') || e.target.classList.contains('preview-img')) {
|
||||
openArtModal(track.id, {
|
||||
full: e.target.dataset.full || e.target.src,
|
||||
thumb: e.target.dataset.thumb || e.target.src,
|
||||
sourceLabel: e.target.dataset.sourceLabel || '',
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
$('tracksBody').addEventListener('change', e => {
|
||||
if (!e.target.classList.contains('row-check')) return;
|
||||
const id = Number(e.target.closest('tr').dataset.id);
|
||||
if (e.target.checked && !state.selections.has(id)) {
|
||||
e.target.checked = false;
|
||||
toast('Choose an artwork result for this track first.', 'error');
|
||||
}
|
||||
updateStats();
|
||||
});
|
||||
|
||||
$('adminUsers').addEventListener('change', handleRoleChange);
|
||||
$('btnClone').addEventListener('click', cloneProfiles);
|
||||
|
||||
// Pagination bar (delegated — contents re-render on every load)
|
||||
$('paginationBar').addEventListener('change', e => {
|
||||
if (e.target.id !== 'limitSelect') return;
|
||||
currentLimit = e.target.value === 'all' ? 'all' : parseInt(e.target.value, 10);
|
||||
currentPage = 1;
|
||||
loadTracks();
|
||||
});
|
||||
$('paginationBar').addEventListener('click', e => {
|
||||
const b = e.target.closest('button');
|
||||
if (!b || b.disabled) return;
|
||||
if (b.id === 'btnPrev') currentPage -= 1;
|
||||
else if (b.id === 'btnNext') currentPage += 1;
|
||||
else if (b.dataset.page) currentPage = parseInt(b.dataset.page, 10);
|
||||
else return;
|
||||
loadTracks();
|
||||
});
|
||||
|
||||
// Artwork preview overlay
|
||||
$('artClose').addEventListener('click', () => closeModal('artModal'));
|
||||
$('artImg').addEventListener('click', () => { if (state.artModal.full) window.open(state.artModal.full, '_blank', 'noopener'); });
|
||||
$('artConfirm').addEventListener('click', confirmArtSelection);
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === 'Escape' && !$('artModal').classList.contains('hidden')) closeModal('artModal');
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------- init -------------------------------------
|
||||
(async function init() {
|
||||
bindEvents();
|
||||
setAuthMode('login');
|
||||
try {
|
||||
const { user } = await api('/api/auth/me');
|
||||
state.user = user;
|
||||
showApp();
|
||||
await loadProfiles();
|
||||
} catch {
|
||||
showAuth();
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,759 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* rdj-art-app — Album artwork manager for RadioDJ MySQL databases.
|
||||
*
|
||||
* - Local accounts + RadioDJ connection profiles stored in embedded SQLite.
|
||||
* - First registered user becomes 'admin' (user management + profile cloning).
|
||||
* - Artwork lookup via 100% free APIs: iTunes Search, fallback MusicBrainz +
|
||||
* Cover Art Archive. No API keys required.
|
||||
* - Confirmed artwork is downloaded, renamed to the base audio filename
|
||||
* ("01 Track.mp3" -> "01 Track.jpg") and streamed back as a ZIP, with an
|
||||
* optional UPDATE of the RadioDJ `songs.image` column.
|
||||
*/
|
||||
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const express = require('express');
|
||||
const Database = require('better-sqlite3');
|
||||
const mysql = require('mysql2/promise');
|
||||
const archiver = require('archiver');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
const PORT = Number.parseInt(process.env.PORT || '3000', 10);
|
||||
const DATA_DIR = process.env.DATA_DIR || path.join(__dirname, 'data');
|
||||
const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
const USER_AGENT = 'rdj-art-app/1.0 (open-source; contact: local-admin)';
|
||||
const FETCH_TIMEOUT_MS = 12000;
|
||||
const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
|
||||
/**
|
||||
* APP_SECRET encrypts stored RadioDJ MySQL passwords. In production always set
|
||||
* it via the environment. For convenience a random secret is generated and
|
||||
* persisted in the data directory on first boot (never commit that file).
|
||||
*/
|
||||
function loadSecret() {
|
||||
if (process.env.APP_SECRET && process.env.APP_SECRET.length >= 16) {
|
||||
return process.env.APP_SECRET;
|
||||
}
|
||||
const secretFile = path.join(DATA_DIR, '.app-secret');
|
||||
try {
|
||||
const existing = fs.readFileSync(secretFile, 'utf8').trim();
|
||||
if (existing) {
|
||||
console.warn('[security] APP_SECRET not set (or too short) — reusing secret from data/.app-secret');
|
||||
return existing;
|
||||
}
|
||||
} catch { /* first boot */ }
|
||||
const generated = crypto.randomBytes(32).toString('hex');
|
||||
fs.writeFileSync(secretFile, generated, { mode: 0o600 });
|
||||
console.warn('[security] APP_SECRET not set — generated a random secret at data/.app-secret. Set APP_SECRET in production!');
|
||||
return generated;
|
||||
}
|
||||
const APP_SECRET = loadSecret();
|
||||
const ENC_KEY = crypto.scryptSync(APP_SECRET, 'rdj-art-app/enc/v1', 32);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Crypto helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
function encryptSecret(plain) {
|
||||
const iv = crypto.randomBytes(12);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', ENC_KEY, iv);
|
||||
const data = Buffer.concat([cipher.update(String(plain), 'utf8'), cipher.final()]);
|
||||
return JSON.stringify({
|
||||
iv: iv.toString('base64'),
|
||||
tag: cipher.getAuthTag().toString('base64'),
|
||||
data: data.toString('base64'),
|
||||
});
|
||||
}
|
||||
|
||||
function decryptSecret(payload) {
|
||||
const { iv, tag, data } = JSON.parse(payload);
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', ENC_KEY, Buffer.from(iv, 'base64'));
|
||||
decipher.setAuthTag(Buffer.from(tag, 'base64'));
|
||||
return Buffer.concat([decipher.update(Buffer.from(data, 'base64')), decipher.final()]).toString('utf8');
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
const salt = crypto.randomBytes(16);
|
||||
const hash = crypto.scryptSync(password, salt, 64, { N: 16384, r: 8, p: 1 });
|
||||
return ['scrypt', 16384, 8, 1, salt.toString('base64'), hash.toString('base64')].join('$');
|
||||
}
|
||||
|
||||
function verifyPassword(password, stored) {
|
||||
try {
|
||||
const [algo, N, r, p, saltB64, hashB64] = String(stored).split('$');
|
||||
if (algo !== 'scrypt') return false;
|
||||
const expected = Buffer.from(hashB64, 'base64');
|
||||
const actual = crypto.scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, {
|
||||
N: Number(N), r: Number(r), p: Number(p),
|
||||
});
|
||||
return crypto.timingSafeEqual(expected, actual);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite (users, profiles, sessions)
|
||||
// ---------------------------------------------------------------------------
|
||||
const db = new Database(path.join(DATA_DIR, 'app.db'));
|
||||
db.pragma('journal_mode = WAL');
|
||||
db.pragma('foreign_keys = ON');
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
password_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'user' CHECK (role IN ('admin','user')),
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS profiles (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
name TEXT NOT NULL,
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 3306,
|
||||
db_name TEXT NOT NULL,
|
||||
db_user TEXT NOT NULL,
|
||||
db_pass_enc TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created_at INTEGER NOT NULL,
|
||||
expires_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_user ON profiles(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id);
|
||||
`);
|
||||
|
||||
// Purge expired sessions hourly.
|
||||
setInterval(() => {
|
||||
try { db.prepare('DELETE FROM sessions WHERE expires_at < ?').run(Date.now()); } catch { /* ignore */ }
|
||||
}, 60 * 60 * 1000).unref();
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Small utilities
|
||||
// ---------------------------------------------------------------------------
|
||||
const asyncH = fn => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next);
|
||||
|
||||
function parseCookies(req) {
|
||||
const out = {};
|
||||
for (const part of (req.headers.cookie || '').split(';')) {
|
||||
const i = part.indexOf('=');
|
||||
if (i > -1) out[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function publicUser(u) {
|
||||
return { id: u.id, username: u.username, role: u.role, createdAt: u.created_at };
|
||||
}
|
||||
|
||||
/** Base name of an audio file without extension; handles / and \ separators. */
|
||||
function audioBaseName(p) {
|
||||
if (!p) return '';
|
||||
const norm = String(p).replace(/\\/g, '/');
|
||||
const base = norm.slice(norm.lastIndexOf('/') + 1);
|
||||
const dot = base.lastIndexOf('.');
|
||||
return dot > 0 ? base.slice(0, dot) : base;
|
||||
}
|
||||
|
||||
/** Strip characters that are illegal in file names on Windows/POSIX. */
|
||||
function sanitizeFilename(name) {
|
||||
const cleaned = String(name)
|
||||
// eslint-disable-next-line no-control-regex
|
||||
.replace(/[<>:"/\\|?*\x00-\x1f]/g, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 150);
|
||||
return cleaned || 'track';
|
||||
}
|
||||
|
||||
async function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
return await fetch(url, { ...options, signal: controller.signal, redirect: 'follow' });
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchJson(url, headers = {}) {
|
||||
const res = await fetchWithTimeout(url, { headers });
|
||||
if (!res.ok) throw new Error(`HTTP ${res.status} from ${new URL(url).host}`);
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function downloadImage(url) {
|
||||
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) throw new Error('Invalid image URL.');
|
||||
const res = await fetchWithTimeout(
|
||||
url,
|
||||
{ headers: { 'User-Agent': USER_AGENT, Accept: 'image/*' } },
|
||||
20000,
|
||||
);
|
||||
if (!res.ok) throw new Error(`Download failed (HTTP ${res.status}).`);
|
||||
const type = (res.headers.get('content-type') || '').toLowerCase();
|
||||
if (!type.startsWith('image/')) throw new Error(`URL did not return an image (${type || 'unknown content-type'}).`);
|
||||
const ext = type.includes('png') ? '.png' : type.includes('webp') ? '.webp' : '.jpg';
|
||||
const buffer = Buffer.from(await res.arrayBuffer());
|
||||
if (buffer.length === 0) throw new Error('Empty image.');
|
||||
if (buffer.length > MAX_IMAGE_BYTES) throw new Error('Image exceeds 15 MB limit.');
|
||||
return { buffer, ext };
|
||||
}
|
||||
|
||||
/** Run async fn over items with bounded concurrency, preserving order. */
|
||||
async function mapLimit(items, limit, fn) {
|
||||
const out = new Array(items.length);
|
||||
let i = 0;
|
||||
await Promise.all(
|
||||
Array.from({ length: Math.min(limit, items.length) }, async () => {
|
||||
while (i < items.length) {
|
||||
const idx = i++;
|
||||
out[idx] = await fn(items[idx], idx);
|
||||
}
|
||||
}),
|
||||
);
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sessions / auth middleware
|
||||
// ---------------------------------------------------------------------------
|
||||
function getSessionUser(req) {
|
||||
const token = parseCookies(req).rdj_session;
|
||||
if (!token) return null;
|
||||
const row = db.prepare(`
|
||||
SELECT u.id, u.username, u.role, u.created_at, s.expires_at AS exp
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ?`).get(token);
|
||||
if (!row) return null;
|
||||
if (row.exp < Date.now()) {
|
||||
db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
return null;
|
||||
}
|
||||
return { id: row.id, username: row.username, role: row.role, created_at: row.created_at };
|
||||
}
|
||||
|
||||
function createSession(res, userId) {
|
||||
const token = crypto.randomBytes(32).toString('base64url');
|
||||
const now = Date.now();
|
||||
db.prepare('INSERT INTO sessions (token, user_id, created_at, expires_at) VALUES (?,?,?,?)')
|
||||
.run(token, userId, now, now + SESSION_TTL_MS);
|
||||
const secure = process.env.COOKIE_SECURE === '1' ? '; Secure' : '';
|
||||
res.setHeader(
|
||||
'Set-Cookie',
|
||||
`rdj_session=${token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}${secure}`,
|
||||
);
|
||||
}
|
||||
|
||||
function requireAuth(req, res, next) {
|
||||
const user = getSessionUser(req);
|
||||
if (!user) return res.status(401).json({ error: 'Authentication required.' });
|
||||
req.user = user;
|
||||
next();
|
||||
}
|
||||
|
||||
function requireAdmin(req, res, next) {
|
||||
if (!req.user || req.user.role !== 'admin') return res.status(403).json({ error: 'Admin role required.' });
|
||||
next();
|
||||
}
|
||||
|
||||
// Naive in-memory rate limiting for auth endpoints (20 attempts / 5 min / IP).
|
||||
const authAttempts = new Map();
|
||||
function authRateLimit(req, res, next) {
|
||||
const key = req.ip || 'unknown';
|
||||
const now = Date.now();
|
||||
const rec = authAttempts.get(key) || { count: 0, reset: now + 5 * 60 * 1000 };
|
||||
if (now > rec.reset) { rec.count = 0; rec.reset = now + 5 * 60 * 1000; }
|
||||
rec.count += 1;
|
||||
authAttempts.set(key, rec);
|
||||
if (rec.count > 20) return res.status(429).json({ error: 'Too many attempts. Try again later.' });
|
||||
next();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// RadioDJ profiles / dynamic MySQL pools
|
||||
// ---------------------------------------------------------------------------
|
||||
function validateProfileInput(body) {
|
||||
const errors = [];
|
||||
const name = String(body.name || '').trim();
|
||||
const host = String(body.host || '').trim();
|
||||
const port = Number.parseInt(body.port ?? '3306', 10);
|
||||
const dbName = String(body.database || '').trim();
|
||||
const dbUser = String(body.dbUser || '').trim();
|
||||
const dbPass = body.dbPassword != null ? String(body.dbPassword) : null;
|
||||
if (!name || name.length > 64) errors.push('Profile name is required (max 64 chars).');
|
||||
if (!host || host.length > 255) errors.push('Host is required.');
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) errors.push('Port must be between 1 and 65535.');
|
||||
if (!dbName) errors.push('Database name is required.');
|
||||
if (!dbUser) errors.push('Database user is required.');
|
||||
return { errors, value: { name, host, port, dbName, dbUser, dbPass } };
|
||||
}
|
||||
|
||||
function getOwnedProfile(userId, profileId) {
|
||||
return db.prepare('SELECT * FROM profiles WHERE id = ? AND user_id = ?').get(profileId, userId);
|
||||
}
|
||||
|
||||
function profileToJson(p) {
|
||||
return { id: p.id, name: p.name, host: p.host, port: p.port, database: p.db_name, dbUser: p.db_user, createdAt: p.created_at };
|
||||
}
|
||||
|
||||
/** Create a temporary MySQL pool for the selected profile and always close it. */
|
||||
async function withProfilePool(profile, fn) {
|
||||
const pool = mysql.createPool({
|
||||
host: profile.host,
|
||||
port: profile.port,
|
||||
user: profile.db_user,
|
||||
password: decryptSecret(profile.db_pass_enc),
|
||||
database: profile.db_name,
|
||||
waitForConnections: true,
|
||||
connectionLimit: 4,
|
||||
connectTimeout: 8000,
|
||||
charset: 'utf8mb4',
|
||||
});
|
||||
try {
|
||||
return await fn(pool);
|
||||
} finally {
|
||||
await pool.end().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* RadioDJ tracks that need artwork. Besides NULL/empty values, RadioDJ assigns
|
||||
* default placeholder file names (e.g. 'no_cover_image.jpg', 'no_image.jpg',
|
||||
* 'no-cover.jpg') to songs imported without artwork — treat those as missing.
|
||||
*/
|
||||
const MISSING_ARTWORK_WHERE = `
|
||||
song_type = 0
|
||||
AND (
|
||||
image IS NULL
|
||||
OR image = ''
|
||||
OR LOWER(image) LIKE '%no_cover%'
|
||||
OR LOWER(image) LIKE '%no_image%'
|
||||
OR LOWER(image) LIKE '%no-cover%'
|
||||
)`;
|
||||
|
||||
/** True when a RadioDJ `image` value is a known default placeholder. */
|
||||
function isPlaceholderImage(image) {
|
||||
const v = String(image || '').toLowerCase();
|
||||
return v.includes('no_cover') || v.includes('no_image') || v.includes('no-cover');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Express app
|
||||
// ---------------------------------------------------------------------------
|
||||
const app = express();
|
||||
app.disable('x-powered-by');
|
||||
app.set('trust proxy', true);
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
app.use((req, res, next) => {
|
||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||
res.setHeader('X-Frame-Options', 'DENY');
|
||||
res.setHeader('Referrer-Policy', 'same-origin');
|
||||
next();
|
||||
});
|
||||
app.use(express.static(path.join(__dirname, 'public'), {
|
||||
maxAge: '1h',
|
||||
index: 'index.html',
|
||||
setHeaders: (res, filePath) => {
|
||||
// Never serve stale HTML — always revalidate (ETag keeps it cheap).
|
||||
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache');
|
||||
},
|
||||
}));
|
||||
|
||||
// ------------------------------ Auth --------------------------------------
|
||||
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,32}$/;
|
||||
|
||||
app.post('/api/auth/register', authRateLimit, asyncH(async (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
if (!USERNAME_RE.test(String(username || ''))) {
|
||||
return res.status(400).json({ error: 'Username must be 3-32 chars (letters, numbers, _ . -).' });
|
||||
}
|
||||
if (typeof password !== 'string' || password.length < 8) {
|
||||
return res.status(400).json({ error: 'Password must be at least 8 characters.' });
|
||||
}
|
||||
const userCount = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
const role = userCount === 0 ? 'admin' : 'user';
|
||||
try {
|
||||
const info = db.prepare('INSERT INTO users (username, password_hash, role) VALUES (?,?,?)')
|
||||
.run(String(username).trim(), hashPassword(password), role);
|
||||
const user = db.prepare('SELECT * FROM users WHERE id = ?').get(info.lastInsertRowid);
|
||||
createSession(res, user.id);
|
||||
res.status(201).json({ user: publicUser(user) });
|
||||
} catch (e) {
|
||||
if (String(e.message).includes('UNIQUE')) return res.status(409).json({ error: 'Username already taken.' });
|
||||
throw e;
|
||||
}
|
||||
}));
|
||||
|
||||
app.post('/api/auth/login', authRateLimit, asyncH(async (req, res) => {
|
||||
const { username, password } = req.body || {};
|
||||
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(String(username || '').trim());
|
||||
if (!user || !verifyPassword(String(password || ''), user.password_hash)) {
|
||||
return res.status(401).json({ error: 'Invalid username or password.' });
|
||||
}
|
||||
createSession(res, user.id);
|
||||
res.json({ user: publicUser(user) });
|
||||
}));
|
||||
|
||||
app.post('/api/auth/logout', (req, res) => {
|
||||
const token = parseCookies(req).rdj_session;
|
||||
if (token) db.prepare('DELETE FROM sessions WHERE token = ?').run(token);
|
||||
res.setHeader('Set-Cookie', 'rdj_session=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.get('/api/auth/me', (req, res) => {
|
||||
const user = getSessionUser(req);
|
||||
if (!user) return res.status(401).json({ error: 'Not signed in.' });
|
||||
res.json({ user: publicUser(user) });
|
||||
});
|
||||
|
||||
// ---------------------------- Profiles ------------------------------------
|
||||
app.get('/api/profiles', requireAuth, (req, res) => {
|
||||
const rows = db.prepare('SELECT * FROM profiles WHERE user_id = ? ORDER BY name').all(req.user.id);
|
||||
res.json({ profiles: rows.map(profileToJson) });
|
||||
});
|
||||
|
||||
app.post('/api/profiles', requireAuth, asyncH(async (req, res) => {
|
||||
const { errors, value } = validateProfileInput(req.body || {});
|
||||
if (!value.dbPass) errors.push('Database password is required.');
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||
const info = db.prepare(`
|
||||
INSERT INTO profiles (user_id, name, host, port, db_name, db_user, db_pass_enc)
|
||||
VALUES (?,?,?,?,?,?,?)`)
|
||||
.run(req.user.id, value.name, value.host, value.port, value.dbName, value.dbUser, encryptSecret(value.dbPass));
|
||||
res.status(201).json({ profile: profileToJson(db.prepare('SELECT * FROM profiles WHERE id = ?').get(info.lastInsertRowid)) });
|
||||
}));
|
||||
|
||||
app.put('/api/profiles/:id', requireAuth, asyncH(async (req, res) => {
|
||||
const existing = getOwnedProfile(req.user.id, req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Profile not found.' });
|
||||
const { errors, value } = validateProfileInput(req.body || {});
|
||||
if (errors.length) return res.status(400).json({ error: errors.join(' ') });
|
||||
// Blank password keeps the stored one.
|
||||
const enc = value.dbPass ? encryptSecret(value.dbPass) : existing.db_pass_enc;
|
||||
db.prepare('UPDATE profiles SET name=?, host=?, port=?, db_name=?, db_user=?, db_pass_enc=? WHERE id=?')
|
||||
.run(value.name, value.host, value.port, value.dbName, value.dbUser, enc, existing.id);
|
||||
res.json({ profile: profileToJson(db.prepare('SELECT * FROM profiles WHERE id = ?').get(existing.id)) });
|
||||
}));
|
||||
|
||||
app.delete('/api/profiles/:id', requireAuth, (req, res) => {
|
||||
const existing = getOwnedProfile(req.user.id, req.params.id);
|
||||
if (!existing) return res.status(404).json({ error: 'Profile not found.' });
|
||||
db.prepare('DELETE FROM profiles WHERE id = ?').run(existing.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/profiles/:id/test', requireAuth, asyncH(async (req, res) => {
|
||||
const profile = getOwnedProfile(req.user.id, req.params.id);
|
||||
if (!profile) return res.status(404).json({ error: 'Profile not found.' });
|
||||
try {
|
||||
const result = await withProfilePool(profile, async pool => {
|
||||
const [[ver]] = await pool.query('SELECT VERSION() AS version');
|
||||
const [[cnt]] = await pool.query(
|
||||
`SELECT COUNT(*) AS c FROM songs WHERE ${MISSING_ARTWORK_WHERE}`,
|
||||
);
|
||||
return { version: ver.version, missingArtwork: Number(cnt.c) };
|
||||
});
|
||||
res.json({ ok: true, ...result });
|
||||
} catch (e) {
|
||||
res.status(502).json({ ok: false, error: `Connection failed: ${e.message}` });
|
||||
}
|
||||
}));
|
||||
|
||||
// ----------------------------- Tracks -------------------------------------
|
||||
app.get('/api/tracks', requireAuth, asyncH(async (req, res) => {
|
||||
const profile = getOwnedProfile(req.user.id, req.query.profileId);
|
||||
if (!profile) return res.status(404).json({ error: 'Profile not found.' });
|
||||
const rawLimit = String(req.query.limit || '25');
|
||||
const fetchAll = rawLimit === 'all';
|
||||
const limit = fetchAll ? null : Math.min(Math.max(Number.parseInt(rawLimit, 10) || 25, 1), 1000);
|
||||
try {
|
||||
const payload = await withProfilePool(profile, async pool => {
|
||||
const [[cnt]] = await pool.query(`SELECT COUNT(*) AS c FROM songs WHERE ${MISSING_ARTWORK_WHERE}`);
|
||||
const totalTracks = Number(cnt.c);
|
||||
const totalPages = fetchAll ? 1 : Math.max(1, Math.ceil(totalTracks / limit));
|
||||
const page = fetchAll ? 1 : Math.min(Math.max(Number.parseInt(req.query.page || '1', 10) || 1, 1), totalPages);
|
||||
const offset = fetchAll ? 0 : (page - 1) * limit;
|
||||
const sql = `SELECT id, artist, title, album, path, image
|
||||
FROM songs
|
||||
WHERE ${MISSING_ARTWORK_WHERE}
|
||||
ORDER BY artist ASC, title ASC` + (fetchAll ? '' : '\n LIMIT ? OFFSET ?');
|
||||
const rows = await pool.query(sql, fetchAll ? [] : [limit, offset]).then(([r]) => r);
|
||||
return { rows, totalTracks, page, totalPages };
|
||||
});
|
||||
res.json({
|
||||
tracks: payload.rows.map(r => ({
|
||||
id: r.id,
|
||||
artist: r.artist || '',
|
||||
title: r.title || '',
|
||||
album: r.album || '',
|
||||
path: r.path || '',
|
||||
audioBase: audioBaseName(r.path),
|
||||
image: r.image || '',
|
||||
placeholder: isPlaceholderImage(r.image),
|
||||
})),
|
||||
totalTracks: payload.totalTracks,
|
||||
page: payload.page,
|
||||
totalPages: payload.totalPages,
|
||||
});
|
||||
} catch (e) {
|
||||
res.status(502).json({ error: `Database error: ${e.message}` });
|
||||
}
|
||||
}));
|
||||
|
||||
// ------------------------ Artwork search (free) ----------------------------
|
||||
function dedupeResults(list) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const r of list) {
|
||||
if (r.full && !seen.has(r.full)) { seen.add(r.full); out.push(r); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function escapeLucene(s) {
|
||||
return String(s).replace(/[+\-!(){}[\]^"~*?:\\/]|&&|\|\|/g, ' ').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
app.post('/api/artwork/search', requireAuth, asyncH(async (req, res) => {
|
||||
const artist = String(req.body?.artist || '').trim();
|
||||
const title = String(req.body?.title || '').trim();
|
||||
const album = String(req.body?.album || '').trim();
|
||||
const source = ['auto', 'itunes', 'deezer', 'musicbrainz'].includes(req.body?.source) ? req.body.source : 'auto';
|
||||
if (!artist && !title && !album) return res.status(400).json({ error: 'Provide an artist and/or title.' });
|
||||
const results = [];
|
||||
|
||||
// 1) iTunes Search API — free, no key.
|
||||
if (source === 'auto' || source === 'itunes') {
|
||||
try {
|
||||
const term = [artist, album || title].filter(Boolean).join(' ');
|
||||
const data = await fetchJson(
|
||||
`https://itunes.apple.com/search?media=music&entity=album&limit=10&term=${encodeURIComponent(term)}`,
|
||||
);
|
||||
for (const item of data.results || []) {
|
||||
if (!item.artworkUrl100) continue;
|
||||
results.push({
|
||||
source: 'itunes',
|
||||
sourceLabel: 'iTunes API',
|
||||
artist: item.artistName || artist,
|
||||
album: item.collectionName || '',
|
||||
thumb: item.artworkUrl100.replace('100x100bb', '200x200bb'),
|
||||
full: item.artworkUrl100.replace('100x100bb', '1200x1200bb'),
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[artwork] iTunes search failed:', e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Deezer API — free, no key.
|
||||
if (source === 'auto' || source === 'deezer') {
|
||||
if (source === 'deezer' || results.length < 3) {
|
||||
try {
|
||||
const q = [artist, album || title].filter(Boolean).join(' ');
|
||||
const dz = await fetchJson(`https://api.deezer.com/search/album?limit=10&q=${encodeURIComponent(q)}`);
|
||||
for (const d of dz.data || []) {
|
||||
const cover = d.cover_xl || d.cover_big || d.cover_medium;
|
||||
if (!cover) continue;
|
||||
results.push({
|
||||
source: 'deezer',
|
||||
sourceLabel: 'Deezer',
|
||||
artist: d.artist?.name || artist,
|
||||
album: d.title || '',
|
||||
thumb: d.cover_medium || cover,
|
||||
full: cover,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[artwork] Deezer search failed:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3) MusicBrainz + Cover Art Archive fallback — free, no key.
|
||||
if (source === 'auto' || source === 'musicbrainz') {
|
||||
if (source === 'musicbrainz' || results.length < 3) {
|
||||
try {
|
||||
const clauses = [];
|
||||
if (album) clauses.push(`release:"${escapeLucene(album)}"`);
|
||||
else if (title) clauses.push(`release:"${escapeLucene(title)}"`);
|
||||
if (artist) clauses.push(`artist:"${escapeLucene(artist)}"`);
|
||||
const q = clauses.join(' AND ') || escapeLucene(`${artist} ${title}`);
|
||||
const mb = await fetchJson(
|
||||
`https://musicbrainz.org/ws/2/release/?fmt=json&limit=5&query=${encodeURIComponent(q)}`,
|
||||
{ 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||
);
|
||||
for (const rel of (mb.releases || []).slice(0, 4)) {
|
||||
try {
|
||||
const caa = await fetchJson(
|
||||
`https://coverartarchive.org/release/${rel.id}`,
|
||||
{ 'User-Agent': USER_AGENT, Accept: 'application/json' },
|
||||
);
|
||||
const front = (caa.images || []).find(i => i.front) || (caa.images || [])[0];
|
||||
if (front && front.image) {
|
||||
results.push({
|
||||
source: 'musicbrainz',
|
||||
sourceLabel: 'Cover Art Archive / MusicBrainz',
|
||||
artist: rel['artist-credit']?.[0]?.name || artist,
|
||||
album: rel.title || '',
|
||||
thumb: front.thumbnails?.small || front.thumbnails?.['250'] || front.image,
|
||||
full: front.image,
|
||||
});
|
||||
}
|
||||
} catch { /* release has no cover art */ }
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[artwork] MusicBrainz search failed:', e.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({ results: dedupeResults(results).slice(0, 12) });
|
||||
}));
|
||||
|
||||
// ------------------------ ZIP export + DB update ---------------------------
|
||||
app.post('/api/export', requireAuth, asyncH(async (req, res) => {
|
||||
const { profileId, updateDb = false, items = [] } = req.body || {};
|
||||
if (!Array.isArray(items) || items.length === 0) return res.status(400).json({ error: 'No items provided.' });
|
||||
if (items.length > 500) return res.status(400).json({ error: 'Too many items (max 500 per export).' });
|
||||
|
||||
const report = { generatedAt: new Date().toISOString(), downloaded: [], updated: [], errors: [] };
|
||||
|
||||
// Download artwork (bounded concurrency), rename to base audio filename.
|
||||
const results = await mapLimit(items, 4, async item => {
|
||||
const base = sanitizeFilename(item.audioBase || audioBaseName(item.path) || `track-${item.id ?? 'x'}`);
|
||||
try {
|
||||
const { buffer, ext } = await downloadImage(String(item.imageUrl || ''));
|
||||
return { ok: true, base, buffer, ext, songId: Number.isFinite(+item.id) ? +item.id : null };
|
||||
} catch (e) {
|
||||
return { ok: false, base, id: item.id ?? null, error: e.message };
|
||||
}
|
||||
});
|
||||
|
||||
const usedNames = new Set();
|
||||
const files = [];
|
||||
for (const r of results) {
|
||||
if (!r.ok) {
|
||||
report.errors.push({ file: r.base, id: r.id, error: r.error });
|
||||
continue;
|
||||
}
|
||||
let name = `${r.base}${r.ext}`;
|
||||
let n = 2;
|
||||
while (usedNames.has(name.toLowerCase())) name = `${r.base} (${n++})${r.ext}`;
|
||||
usedNames.add(name.toLowerCase());
|
||||
files.push({ name, buffer: r.buffer, songId: r.songId });
|
||||
report.downloaded.push({ name });
|
||||
}
|
||||
|
||||
// Optional: UPDATE the RadioDJ `songs.image` column to the new file name.
|
||||
if (updateDb) {
|
||||
const profile = profileId ? getOwnedProfile(req.user.id, profileId) : null;
|
||||
if (!profile) return res.status(400).json({ error: 'updateDb requires a valid profileId.' });
|
||||
try {
|
||||
await withProfilePool(profile, async pool => {
|
||||
for (const f of files) {
|
||||
if (f.songId == null) continue;
|
||||
try {
|
||||
await pool.query('UPDATE songs SET image = ? WHERE id = ?', [f.name, f.songId]);
|
||||
report.updated.push({ id: f.songId, image: f.name });
|
||||
} catch (e) {
|
||||
report.errors.push({ id: f.songId, image: f.name, error: `DB update failed: ${e.message}` });
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
return res.status(502).json({ error: `Database error: ${e.message}` });
|
||||
}
|
||||
}
|
||||
|
||||
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
|
||||
res.setHeader('Content-Type', 'application/zip');
|
||||
res.setHeader('Content-Disposition', `attachment; filename="rdj-artwork-${stamp}.zip"`);
|
||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||
archive.on('error', err => {
|
||||
console.error('[export] archive error:', err);
|
||||
res.destroy(err);
|
||||
});
|
||||
archive.pipe(res);
|
||||
for (const f of files) archive.append(f.buffer, { name: f.name });
|
||||
archive.append(JSON.stringify(report, null, 2), { name: 'export-report.json' });
|
||||
await archive.finalize();
|
||||
}));
|
||||
|
||||
// ------------------------------ Admin --------------------------------------
|
||||
app.get('/api/admin/users', requireAuth, requireAdmin, (req, res) => {
|
||||
const users = db.prepare(`
|
||||
SELECT u.id, u.username, u.role, u.created_at,
|
||||
(SELECT COUNT(*) FROM profiles p WHERE p.user_id = u.id) AS profileCount
|
||||
FROM users u ORDER BY u.id`).all();
|
||||
res.json({
|
||||
users: users.map(u => ({
|
||||
id: u.id, username: u.username, role: u.role, createdAt: u.created_at, profileCount: u.profileCount,
|
||||
})),
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/admin/users/:id/role', requireAuth, requireAdmin, (req, res) => {
|
||||
const target = db.prepare('SELECT * FROM users WHERE id = ?').get(req.params.id);
|
||||
if (!target) return res.status(404).json({ error: 'User not found.' });
|
||||
const role = req.body?.role;
|
||||
if (!['admin', 'user'].includes(role)) return res.status(400).json({ error: 'Role must be "admin" or "user".' });
|
||||
if (target.id === req.user.id) return res.status(400).json({ error: 'You cannot change your own role.' });
|
||||
if (target.role === 'admin' && role === 'user') {
|
||||
const admins = db.prepare("SELECT COUNT(*) AS c FROM users WHERE role = 'admin'").get().c;
|
||||
if (admins <= 1) return res.status(400).json({ error: 'Cannot demote the last admin.' });
|
||||
}
|
||||
db.prepare('UPDATE users SET role = ? WHERE id = ?').run(role, target.id);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
app.post('/api/admin/clone-profiles', requireAuth, requireAdmin, (req, res) => {
|
||||
const from = db.prepare('SELECT * FROM users WHERE id = ?').get(req.body?.fromUserId);
|
||||
const to = db.prepare('SELECT * FROM users WHERE id = ?').get(req.body?.toUserId);
|
||||
if (!from || !to) return res.status(404).json({ error: 'User not found.' });
|
||||
if (from.id === to.id) return res.status(400).json({ error: 'Source and target user must differ.' });
|
||||
const rows = db.prepare('SELECT * FROM profiles WHERE user_id = ?').all(from.id);
|
||||
if (rows.length === 0) return res.status(400).json({ error: `User "${from.username}" has no profiles to copy.` });
|
||||
const insert = db.prepare(`
|
||||
INSERT INTO profiles (user_id, name, host, port, db_name, db_user, db_pass_enc)
|
||||
VALUES (?,?,?,?,?,?,?)`);
|
||||
const tx = db.transaction(() => {
|
||||
for (const p of rows) insert.run(to.id, p.name, p.host, p.port, p.db_name, p.db_user, p.db_pass_enc);
|
||||
});
|
||||
tx();
|
||||
res.json({ ok: true, cloned: rows.length });
|
||||
});
|
||||
|
||||
// ------------------------------ Misc ---------------------------------------
|
||||
app.get('/api/health', (req, res) => {
|
||||
const users = db.prepare('SELECT COUNT(*) AS c FROM users').get().c;
|
||||
res.json({ ok: true, users });
|
||||
});
|
||||
|
||||
app.use('/api', (req, res) => res.status(404).json({ error: 'Not found.' }));
|
||||
|
||||
// SPA fallback.
|
||||
app.get('*', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
||||
|
||||
// Central error handler.
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('[error]', err);
|
||||
if (res.headersSent) return;
|
||||
res.status(err.status || 500).json({ error: err.expose ? err.message : 'Internal server error.' });
|
||||
});
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`rdj-art-app listening on http://0.0.0.0:${PORT}`);
|
||||
console.log(`Data directory: ${DATA_DIR}`);
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$base = 'http://localhost:3000'
|
||||
$out = @()
|
||||
|
||||
# 1. Health
|
||||
$h = Invoke-RestMethod "$base/api/health"
|
||||
$out += "health: ok=$($h.ok) users=$($h.users)"
|
||||
|
||||
# 2. Register first user (should become admin); log in if it already exists
|
||||
$s = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
||||
try {
|
||||
$r = Invoke-RestMethod -Uri "$base/api/auth/register" -Method Post -ContentType 'application/json' -Body (@{username='admin1';password='password123'} | ConvertTo-Json) -WebSession $s
|
||||
$out += "register admin1: role=$($r.user.role)"
|
||||
} catch {
|
||||
$r = Invoke-RestMethod -Uri "$base/api/auth/login" -Method Post -ContentType 'application/json' -Body (@{username='admin1';password='password123'} | ConvertTo-Json) -WebSession $s
|
||||
$out += "login admin1 (already existed): role=$($r.user.role)"
|
||||
}
|
||||
|
||||
# 3. Session check
|
||||
$m = Invoke-RestMethod -Uri "$base/api/auth/me" -WebSession $s
|
||||
$out += "me: $($m.user.username) ($($m.user.role))"
|
||||
|
||||
# 4. Second user (should be plain user); log in if it already exists
|
||||
$s2 = New-Object Microsoft.PowerShell.Commands.WebRequestSession
|
||||
try {
|
||||
$r2 = Invoke-RestMethod -Uri "$base/api/auth/register" -Method Post -ContentType 'application/json' -Body (@{username='djbob';password='password456'} | ConvertTo-Json) -WebSession $s2
|
||||
$out += "register djbob: role=$($r2.user.role)"
|
||||
} catch {
|
||||
$r2 = Invoke-RestMethod -Uri "$base/api/auth/login" -Method Post -ContentType 'application/json' -Body (@{username='djbob';password='password456'} | ConvertTo-Json) -WebSession $s2
|
||||
$out += "login djbob (already existed): role=$($r2.user.role)"
|
||||
}
|
||||
|
||||
# 5. Admin: list users
|
||||
$users = Invoke-RestMethod -Uri "$base/api/admin/users" -WebSession $s
|
||||
$out += "admin users list: $($users.users.Count) user(s)"
|
||||
|
||||
# 6. Admin: create a profile, then clone to djbob
|
||||
$p = Invoke-RestMethod -Uri "$base/api/profiles" -Method Post -ContentType 'application/json' -Body (@{name=('Main FM ' + [guid]::NewGuid().ToString('N').Substring(0,6));host='127.0.0.1';port='3306';database='radiodj';dbUser='rdj';dbPassword='secret'} | ConvertTo-Json) -WebSession $s
|
||||
$out += "profile created: id=$($p.profile.id) name=$($p.profile.name)"
|
||||
$uid1 = ($users.users | Where-Object username -eq 'admin1').id
|
||||
$uid2 = ($users.users | Where-Object username -eq 'djbob').id
|
||||
$before = (Invoke-RestMethod -Uri "$base/api/profiles" -WebSession $s2).profiles.Count
|
||||
$c = Invoke-RestMethod -Uri "$base/api/admin/clone-profiles" -Method Post -ContentType 'application/json' -Body (@{fromUserId=$uid1;toUserId=$uid2} | ConvertTo-Json) -WebSession $s
|
||||
$out += "clone profiles: cloned=$($c.cloned)"
|
||||
$bobProfiles = Invoke-RestMethod -Uri "$base/api/profiles" -WebSession $s2
|
||||
$out += "djbob profiles: $before -> $($bobProfiles.profiles.Count) after clone"
|
||||
|
||||
# 7. Artwork search (iTunes, live)
|
||||
$search = Invoke-RestMethod -Uri "$base/api/artwork/search" -Method Post -ContentType 'application/json' -Body (@{artist='Adele';title='Hello'} | ConvertTo-Json) -WebSession $s
|
||||
$out += "artwork search: $($search.results.Count) result(s), first from $($search.results[0].source)"
|
||||
|
||||
# 8. Export ZIP (no DB update) with the found artwork
|
||||
$exportBody = @{ profileId = $p.profile.id; updateDb = $false; items = @(@{ id = 999; path = 'D:\Music\Adele\01 Hello.mp3'; audioBase = '01 Hello'; imageUrl = $search.results[0].full }) } | ConvertTo-Json -Depth 5
|
||||
Invoke-WebRequest -Uri "$base/api/export" -Method Post -ContentType 'application/json' -Body $exportBody -WebSession $s -OutFile "$PSScriptRoot\test-export.zip"
|
||||
Add-Type -AssemblyName System.IO.Compression.FileSystem
|
||||
$zip = [System.IO.Compression.ZipFile]::OpenRead("$PSScriptRoot\test-export.zip")
|
||||
$names = ($zip.Entries | ForEach-Object { $_.FullName }) -join ', '
|
||||
$zip.Dispose()
|
||||
$out += "export zip entries: $names"
|
||||
|
||||
# 9. Unauthenticated request should 401
|
||||
try {
|
||||
Invoke-RestMethod -Uri "$base/api/profiles" -WebSession (New-Object Microsoft.PowerShell.Commands.WebRequestSession)
|
||||
$out += "UNAUTH CHECK FAILED"
|
||||
} catch {
|
||||
$out += "unauth /api/profiles correctly rejected ($($_.Exception.Response.StatusCode.value__))"
|
||||
}
|
||||
|
||||
$out | ForEach-Object { Write-Host $_ }
|
||||
Write-Host "ALL SMOKE TESTS DONE"
|
||||
Reference in New Issue
Block a user