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:
minster586
2026-09-09 05:03:17 -04:00
parent 84c241f661
commit b9a2cc584b
11 changed files with 4151 additions and 14 deletions
+10
View File
@@ -0,0 +1,10 @@
node_modules
data
.git
.gitignore
.dockerignore
Dockerfile
docker-compose.yml
*.md
*.log
.env
+6
View File
@@ -0,0 +1,6 @@
node_modules/
data/
*.log
.env
.DS_Store
Thumbs.db
+31
View File
@@ -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"]
+16 -13
View File
@@ -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.
+100 -1
View File
@@ -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).
+15
View File
@@ -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
+2320
View File
File diff suppressed because it is too large Load Diff
+23
View File
@@ -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"
}
}
+801
View File
@@ -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 &amp; 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 &amp; 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 files base name (e.g. <span class="font-mono">01 Track.mp3 → 01 Track.jpg</span>).
Nothing is exported until you tick the tracks 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 &amp; 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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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>
+759
View File
@@ -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}`);
});
+70
View File
@@ -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"