small tweeks

This commit is contained in:
minster586
2026-11-02 05:46:56 -05:00
parent b9a2cc584b
commit 86ab23a814
8 changed files with 1076 additions and 54 deletions
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

+1
View File
@@ -17,6 +17,7 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package.json ./
COPY server.js ./
COPY Album-Art.png ./
COPY public ./public
# Persistent SQLite storage lives in /app/data (map a volume or ./data here).
+9 -4
View File
@@ -37,19 +37,23 @@ SQLite data is persisted in `./data` (mapped to `/app/data` in the container).
| `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. |
| `LOG_LEVEL` | `info` | `error`, `warn`, `info`, or `debug`. Selection and export summaries are logged at `info`; per-track export details and conversions are logged at `debug`. Logs go to stdout (terminal and Docker logs). |
## 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.
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).
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), and correct the editable **Title**, **Artist**, or **Album** fields if needed before searching. These corrections are temporary, stay available while paging through the current load, and are used only for artwork searches; reloading tracks or refreshing the page discards them. They are never written to RadioDJ. Click **Search artwork** per row (or **Fetch Art for Current Page** for the whole page) to get artwork candidates. The final upload-icon option on each row lets you add a local PNG/JPEG/WebP image (up to 15 MB) or provide a direct image URL. Custom artwork goes through the same full-size preview and confirmation flow as search results. 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. Exports support up to 20 local uploads at a time. Confirmed artwork selections are logged with track ID and source; set `LOG_LEVEL=debug` for per-track export details.
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.
- When checked, the app runs `UPDATE songs SET image = '<new file name>' WHERE id = <song id>` for every successfully exported item. Exported images are always RadioDJ-compatible `.jpg` or `.png`; WebP sources (including URLs and uploads) are converted to JPEG.
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.
> Artwork logs do not include image URLs or file contents. Unsupported formats are reported as export errors rather than added to the ZIP.
Tracks without selected artwork display the bundled `Album-Art.png` in the web interface. It is only a visual fallback and is not selected, exported, or written to RadioDJ automatically.
### Admin
@@ -70,7 +74,7 @@ The admin header button opens the administration panel:
| 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. |
| POST | `/api/export` | JSON for URL artwork, or multipart form data with a JSON `payload` and uploaded artwork files → 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. |
@@ -81,6 +85,7 @@ The admin header button opens the administration panel:
```
├── server.js # Express backend: auth, profiles, MySQL pools, artwork search, ZIP export
├── public/index.html # Single-page Tailwind dashboard (vanilla JS)
├── Album-Art.png # Web-interface fallback for tracks without selected artwork
├── data/ # SQLite storage (git-ignored, volume-mapped in Docker)
├── Dockerfile
├── docker-compose.yml
+1
View File
@@ -10,6 +10,7 @@ services:
# stored passwords unreadable.
APP_SECRET: ${APP_SECRET:-change-me-to-a-long-random-string}
PORT: 3000
LOG_LEVEL: ${LOG_LEVEL:-info}
volumes:
- ./data:/app/data
restart: unless-stopped
+612 -1
View File
@@ -12,12 +12,524 @@
"archiver": "^7.0.1",
"better-sqlite3": "^11.10.0",
"express": "^4.21.2",
"mysql2": "^3.14.0"
"multer": "^2.5.0",
"mysql2": "^3.14.0",
"sharp": "^0.35.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@isaacs/cliui": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@@ -104,6 +616,12 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/append-field": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
"integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
"license": "MIT"
},
"node_modules/archiver": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
@@ -416,6 +934,17 @@
"node": ">=8.0.0"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
"integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
"dependencies": {
"streamsearch": "^1.1.0"
},
"engines": {
"node": ">=10.16.0"
}
},
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@ -1335,6 +1864,24 @@
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
"node_modules/multer": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/multer/-/multer-2.5.0.tgz",
"integrity": "sha512-BsqOzYGVq49JcW2IzoSAxeKYRevb+dGGPPWleOR8Lrlk4igwxu1/9pwSXlByhCb7MVZDrNJFC0IBP5/O5nLYRw==",
"license": "MIT",
"dependencies": {
"append-field": "^1.0.0",
"busboy": "^1.6.0",
"type-is": "^1.6.18"
},
"engines": {
"node": ">= 10.16.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/mysql2": {
"version": "3.24.4",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
@@ -1745,6 +2292,55 @@
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
"license": "ISC"
},
"node_modules/sharp": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.5",
"@img/sharp-darwin-x64": "0.35.5",
"@img/sharp-freebsd-wasm32": "0.35.5",
"@img/sharp-libvips-darwin-arm64": "1.3.4",
"@img/sharp-libvips-darwin-x64": "1.3.4",
"@img/sharp-libvips-linux-arm": "1.3.4",
"@img/sharp-libvips-linux-arm64": "1.3.4",
"@img/sharp-libvips-linux-ppc64": "1.3.4",
"@img/sharp-libvips-linux-riscv64": "1.3.4",
"@img/sharp-libvips-linux-s390x": "1.3.4",
"@img/sharp-libvips-linux-x64": "1.3.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
"@img/sharp-linux-arm": "0.35.5",
"@img/sharp-linux-arm64": "0.35.5",
"@img/sharp-linux-ppc64": "0.35.5",
"@img/sharp-linux-riscv64": "0.35.5",
"@img/sharp-linux-s390x": "0.35.5",
"@img/sharp-linux-x64": "0.35.5",
"@img/sharp-linuxmusl-arm64": "0.35.5",
"@img/sharp-linuxmusl-x64": "0.35.5",
"@img/sharp-webcontainers-wasm32": "0.35.5",
"@img/sharp-win32-arm64": "0.35.5",
"@img/sharp-win32-ia32": "0.35.5",
"@img/sharp-win32-x64": "0.35.5"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -1919,6 +2515,14 @@
"node": ">= 0.8"
}
},
"node_modules/streamsearch": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
"integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/streamx": {
"version": "2.28.1",
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
@@ -2125,6 +2729,13 @@
"node": ">=0.6"
}
},
"node_modules/tslib": {
"version": "2.9.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.9.1.tgz",
"integrity": "sha512-a4Ux9VKuIMHsOS7ENbEq6hK2ZGYIT27Rtl0E4eD9KqwxhKiQeMZE/9xb3IdYwWaSOyIBQLExhQgXDTYoiCxnWA==",
"license": "0BSD",
"optional": true
},
"node_modules/tunnel-agent": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+3 -1
View File
@@ -15,7 +15,9 @@
"archiver": "^7.0.1",
"better-sqlite3": "^11.10.0",
"express": "^4.21.2",
"mysql2": "^3.14.0"
"multer": "^2.5.0",
"mysql2": "^3.14.0",
"sharp": "^0.35.5"
},
"overrides": {
"qs": "^6.16.0"
+207 -28
View File
@@ -93,6 +93,7 @@
<th class="p-3 w-16">Artwork</th>
<th class="p-3">Title</th>
<th class="p-3">Artist</th>
<th class="p-3">Album</th>
<th class="p-3">Audio Filename</th>
<th class="p-3 w-80">Search &amp; Results</th>
</tr>
@@ -107,7 +108,8 @@
</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
Edit title, artist, or album in the grid to adjust artwork searches. These edits are temporary and do not update RadioDJ metadata.
The last option in each row lets you upload custom artwork or use a direct image URL. 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>
@@ -132,6 +134,30 @@
</div>
</div>
<!-- ======================= Custom artwork modal ======================= -->
<div id="customArtModal" class="modal hidden">
<div class="modal-card max-w-xl">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-white">Use custom artwork</h2>
<button type="button" class="btn-ghost" data-close="customArtModal">✕</button>
</div>
<p class="text-sm text-slate-400 mb-4">Choose an image file or provide a direct image URL. The image will be previewed for confirmation before export.</p>
<div class="space-y-4">
<label class="block text-sm text-slate-300">
Upload an image (PNG, JPEG, or WebP; up to 15 MB)
<input id="customArtFile" type="file" accept="image/png,image/jpeg,image/webp" class="inp mt-2">
</label>
<div>
<label for="customArtUrl" class="block text-sm text-slate-300">Or enter a direct image URL</label>
<div class="flex gap-2 mt-2">
<input id="customArtUrl" type="url" class="inp" placeholder="https://example.com/album-art.jpg">
<button id="btnUseArtUrl" type="button" class="btn-primary shrink-0">Use URL</button>
</div>
</div>
</div>
</div>
</div>
<!-- ========================= Profiles modal ========================= -->
<div id="profilesModal" class="modal hidden">
<div class="modal-card max-w-3xl">
@@ -194,9 +220,11 @@ const state = {
profiles: [],
activeProfileId: null,
tracks: [],
metadataEdits: new Map(), // trackId -> edited artist/title/album for the current load
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: '' },
customArtTrackId: null,
authMode: 'login',
};
let currentPage = 1;
@@ -409,29 +437,40 @@ function thumbHtml(r, selectedUrl) {
title="Source: ${esc(sourceLabel(r))} — click to preview full size" loading="lazy" alt="artwork option">`;
}
function candidateOptionsHtml(trackId, results, selectedUrl) {
const candidates = results.map(r => thumbHtml(r, selectedUrl)).join('');
return `${candidates}
<button type="button" class="upload-art-btn w-14 h-14 rounded border-2 border-dashed border-slate-500 hover:border-indigo-400 hover:text-indigo-300 text-slate-300 flex items-center justify-center"
title="Upload an image or use a direct image URL" aria-label="Add custom artwork" data-track-id="${trackId}">
<svg aria-hidden="true" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" class="w-6 h-6">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 16V4m0 0L7 9m5-5 5 5M5 14v4a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2v-4"/>
</svg>
</button>`;
}
function trackRowHtml(t) {
const sel = state.selections.get(t.id);
const candidates = state.candidates.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>
: `<img src="/Album-Art.png" class="w-full h-full object-contain" alt="Default album artwork">`}</div></td>
<td class="p-3"><input class="metadata-input inp !w-full !min-w-36 !py-1 font-medium text-slate-100" data-field="title" aria-label="Title" value="${esc(t.title)}"></td>
<td class="p-3"><input class="metadata-input inp !w-full !min-w-36 !py-1 text-slate-300" data-field="artist" aria-label="Artist" value="${esc(t.artist)}"></td>
<td class="p-3"><input class="metadata-input inp !w-full !min-w-36 !py-1 text-slate-300" data-field="album" aria-label="Album" value="${esc(t.album)}"></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>
<div class="thumbs flex flex-wrap gap-1.5 mt-2">${candidateOptionsHtml(t.id, candidates, sel?.imageUrl)}</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));
const rows = state.tracks.filter(t => !f || [t.title, t.artist, t.album, t.audioBase].join(' ').toLowerCase().includes(f));
$('tracksBody').innerHTML = rows.map(trackRowHtml).join('');
const empty = $('emptyState');
empty.style.display = rows.length ? 'none' : '';
@@ -442,6 +481,21 @@ function renderTracks() {
updateStats();
}
function clearArtworkChoices() {
const objectUrls = new Set();
for (const candidates of state.candidates.values()) {
for (const candidate of candidates) {
if (candidate.file && candidate.full.startsWith('blob:')) objectUrls.add(candidate.full);
}
}
for (const selection of state.selections.values()) {
if (selection.file && selection.imageUrl.startsWith('blob:')) objectUrls.add(selection.imageUrl);
}
for (const url of objectUrls) URL.revokeObjectURL(url);
state.selections.clear();
state.candidates.clear();
}
function updateStats() {
const checked = document.querySelectorAll('#tracksBody .row-check:checked').length;
$('trackStats').textContent = state.tracks.length
@@ -485,18 +539,21 @@ function renderPagination(totalPages) {
<button type="button" id="btnNext" class="btn-mini" ${currentPage >= totalPages ? 'disabled' : ''}>Next →</button>`;
}
async function loadTracks() {
async function loadTracks({ resetMetadataEdits = false } = {}) {
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;
if (resetMetadataEdits) state.metadataEdits.clear();
state.tracks = data.tracks.map(track => ({
...track,
...(state.metadataEdits.get(track.id) || {}),
}));
totalTracks = data.totalTracks || 0;
currentPage = data.page || 1;
renderPagination(data.totalPages || 1);
state.selections.clear();
state.candidates.clear();
clearArtworkChoices();
renderTracks();
toast(`${data.tracks.length} track(s) missing artwork loaded.`, 'success');
} catch (err) {
@@ -512,13 +569,10 @@ async function searchForRow(tr, track) {
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('');
box.innerHTML = candidateOptionsHtml(track.id, data.results, sel?.imageUrl);
if (!data.results.length) box.insertAdjacentHTML('afterbegin', '<span class="text-xs text-slate-500">No artwork found.</span>');
} catch (err) {
toast(err.message, 'error');
} finally {
@@ -527,7 +581,13 @@ async function searchForRow(tr, track) {
}
function setTrackSelection(tr, track, img) {
state.selections.set(track.id, { imageUrl: img.dataset.full, thumb: img.dataset.thumb, sourceLabel: img.dataset.sourceLabel || '' });
const candidate = (state.candidates.get(track.id) || []).find(item => item.full === img.dataset.full);
state.selections.set(track.id, {
imageUrl: img.dataset.full,
thumb: img.dataset.thumb,
sourceLabel: img.dataset.sourceLabel || '',
file: candidate?.file || null,
});
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">`;
@@ -544,7 +604,13 @@ function selectThumb(tr, track, img) {
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 || '' };
state.artModal = {
trackId,
full: imgData.full,
thumb: imgData.thumb || imgData.full,
sourceLabel: imgData.sourceLabel || '',
file: imgData.file || null,
};
$('artImg').src = imgData.thumb || imgData.full; // thumb first, then upgrade to hi-res
$('artImg').src = imgData.full;
$('artTitle').textContent = t.title || '(untitled)';
@@ -554,24 +620,90 @@ function openArtModal(trackId, imgData) {
openModal('artModal');
}
function confirmArtSelection() {
const { trackId, full, thumb, sourceLabel } = state.artModal;
async function confirmArtSelection() {
const { trackId, full, thumb, sourceLabel, file } = state.artModal;
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
const track = state.tracks.find(x => x.id === trackId);
if (tr && track) {
const candidate = (state.candidates.get(trackId) || []).find(item => item.full === full);
const selectedFile = candidate?.file || file || null;
if (selectedFile) {
state.selections.set(track.id, { imageUrl: full, thumb, sourceLabel, file: selectedFile });
tr.querySelector('.preview-slot').innerHTML = `<img src="${esc(thumb)}" data-full="${esc(full)}" data-source-label="${esc(sourceLabel)}" class="preview-img w-full h-full object-cover cursor-zoom-in" title="Source: ${esc(sourceLabel || 'Unknown')} — click to preview full size" alt="selected artwork">`;
updateStats();
} else {
setTrackSelection(tr, track, { dataset: { full, thumb, sourceLabel } });
}
tr.querySelector('.row-check').checked = true;
updateStats();
toast('Artwork confirmed for export.', 'success');
let source = 'unknown';
if (selectedFile) source = 'manual_upload';
else if (candidate?.source && ['itunes', 'deezer', 'musicbrainz'].includes(candidate.source)) source = candidate.source;
else if (sourceLabel === 'Custom image URL') source = 'url_upload';
try {
await api('/api/artwork/selection', { method: 'POST', body: { trackId: track.id, source } });
} catch (err) {
toast(`Artwork was selected, but selection logging failed: ${err.message}`, 'error');
}
}
closeModal('artModal');
}
function openCustomArtModal(trackId) {
state.customArtTrackId = trackId;
$('customArtFile').value = '';
$('customArtUrl').value = '';
openModal('customArtModal');
}
function addCustomArtworkCandidate({ full, thumb = full, sourceLabel, file = null }) {
const trackId = state.customArtTrackId;
const track = state.tracks.find(item => item.id === trackId);
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
if (!track || !tr) return toast('Track is no longer visible. Reload it and try again.', 'error');
const candidate = { full, thumb, sourceLabel, artist: track.artist, album: track.album, file };
const candidates = state.candidates.get(trackId) || [];
candidates.push(candidate);
state.candidates.set(trackId, candidates);
tr.querySelector('.thumbs').innerHTML = candidateOptionsHtml(trackId, candidates, state.selections.get(trackId)?.imageUrl);
closeModal('customArtModal');
openArtModal(trackId, candidate);
}
function useCustomArtFile() {
const file = $('customArtFile').files[0];
if (!file) return toast('Choose an image file first.', 'error');
if (file.size > 15 * 1024 * 1024) return toast('Image exceeds 15 MB limit.', 'error');
if (!['image/png', 'image/jpeg', 'image/webp'].includes(file.type)) {
return toast('Choose a PNG, JPEG, or WebP image.', 'error');
}
addCustomArtworkCandidate({
full: URL.createObjectURL(file),
sourceLabel: `Uploaded image: ${file.name}`,
file,
});
}
function useCustomArtUrl() {
const value = $('customArtUrl').value.trim();
let url;
try {
url = new URL(value);
} catch {
return toast('Enter a valid direct image URL.', 'error');
}
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
return toast('Image URL must be a public HTTP or HTTPS URL.', 'error');
}
addCustomArtworkCandidate({ full: url.href, sourceLabel: 'Custom image URL' });
}
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('.thumbs').innerHTML = candidateOptionsHtml(track.id, [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">`;
}
@@ -603,23 +735,36 @@ async function autoSearch() {
// ----------------------------- export -------------------------------------
async function exportSelected() {
const items = [];
const uploadedFiles = [];
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 (t && sel) {
items.push({
id: t.id,
path: t.path,
audioBase: t.audioBase,
imageUrl: sel.file ? '' : sel.imageUrl,
upload: Boolean(sel.file),
});
if (sel.file) uploadedFiles.push({ id: t.id, file: sel.file });
}
});
if (!items.length) return toast('Nothing to export — select artwork for at least one track.', 'error');
if (uploadedFiles.length > 20) return toast('You can export up to 20 uploaded images at a time.', 'error');
const updateDb = $('chkUpdateDb').checked;
const btn = $('btnExport');
btn.disabled = true; btn.textContent = 'Working…';
try {
const formData = new FormData();
formData.append('payload', JSON.stringify({ profileId: state.activeProfileId, updateDb, items }));
for (const { id, file } of uploadedFiles) formData.append(`upload-${id}`, file, file.name);
const res = await fetch('/api/export', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ profileId: state.activeProfileId, updateDb, items }),
body: formData,
});
if (!res.ok) {
let msg = `Export failed (${res.status})`;
@@ -634,7 +779,14 @@ async function exportSelected() {
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');
const downloaded = Number(res.headers.get('X-Export-Downloaded') || 0);
const errors = Number(res.headers.get('X-Export-Errors') || 0);
toast(
errors
? `Exported ${downloaded} artwork file(s); ${errors} item(s) had errors. See export-report.json in the ZIP.`
: `Exported ${downloaded} artwork file(s)${updateDb ? ' and updated the RadioDJ database' : ''}.`,
errors ? 'error' : 'success',
);
} catch (err) {
toast(err.message, 'error');
} finally {
@@ -698,6 +850,12 @@ function bindEvents() {
$('stationSelect').addEventListener('change', e => {
state.activeProfileId = Number(e.target.value) || null;
currentPage = 1;
state.metadataEdits.clear();
state.tracks = [];
clearArtworkChoices();
totalTracks = 0;
renderPagination(0);
renderTracks();
renderProfilesList();
});
@@ -715,7 +873,7 @@ function bindEvents() {
$('pfCancel').addEventListener('click', resetProfileForm);
$('profilesList').addEventListener('click', handleProfileAction);
$('btnLoadTracks').addEventListener('click', loadTracks);
$('btnLoadTracks').addEventListener('click', () => loadTracks({ resetMetadataEdits: true }));
$('btnAutoSearch').addEventListener('click', autoSearch);
$('filterInput').addEventListener('input', renderTracks);
$('btnExport').addEventListener('click', exportSelected);
@@ -734,12 +892,16 @@ function bindEvents() {
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);
if (e.target.closest('.upload-art-btn')) openCustomArtModal(track.id);
else if (e.target.classList.contains('search-btn')) await searchForRow(tr, track);
else if (e.target.classList.contains('thumb') || e.target.classList.contains('preview-img')) {
const candidate = (state.candidates.get(track.id) || []).find(item => item.full === (e.target.dataset.full || e.target.src));
const selected = state.selections.get(track.id);
openArtModal(track.id, {
full: e.target.dataset.full || e.target.src,
thumb: e.target.dataset.thumb || e.target.src,
sourceLabel: e.target.dataset.sourceLabel || '',
file: candidate?.file || (selected?.imageUrl === (e.target.dataset.full || e.target.src) ? selected.file : null),
});
}
});
@@ -754,6 +916,21 @@ function bindEvents() {
updateStats();
});
$('tracksBody').addEventListener('input', e => {
if (!e.target.classList.contains('metadata-input')) return;
const tr = e.target.closest('tr[data-id]');
const track = state.tracks.find(t => t.id === Number(tr.dataset.id));
const field = e.target.dataset.field;
if (track && ['title', 'artist', 'album'].includes(field)) {
track[field] = e.target.value;
state.metadataEdits.set(track.id, {
title: track.title,
artist: track.artist,
album: track.album,
});
}
});
$('adminUsers').addEventListener('change', handleRoleChange);
$('btnClone').addEventListener('click', cloneProfiles);
@@ -778,6 +955,8 @@ function bindEvents() {
$('artClose').addEventListener('click', () => closeModal('artModal'));
$('artImg').addEventListener('click', () => { if (state.artModal.full) window.open(state.artModal.full, '_blank', 'noopener'); });
$('artConfirm').addEventListener('click', confirmArtSelection);
$('customArtFile').addEventListener('change', useCustomArtFile);
$('btnUseArtUrl').addEventListener('click', useCustomArtUrl);
document.addEventListener('keydown', e => {
if (e.key === 'Escape' && !$('artModal').classList.contains('hidden')) closeModal('artModal');
});
+241 -18
View File
@@ -15,10 +15,17 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const http = require('http');
const https = require('https');
const dns = require('dns').promises;
const net = require('net');
const os = require('os');
const express = require('express');
const Database = require('better-sqlite3');
const mysql = require('mysql2/promise');
const archiver = require('archiver');
const multer = require('multer');
const sharp = require('sharp');
// ---------------------------------------------------------------------------
// Configuration
@@ -29,9 +36,34 @@ 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;
const MAX_UPLOAD_FILES = 20;
const LOG_LEVELS = ['error', 'warn', 'info', 'debug'];
const LOG_LEVEL = String(process.env.LOG_LEVEL || 'info').toLowerCase();
if (!LOG_LEVELS.includes(LOG_LEVEL)) {
throw new Error(`Invalid LOG_LEVEL "${LOG_LEVEL}". Use error, warn, info, or debug.`);
}
const LOG_THRESHOLD = LOG_LEVELS.indexOf(LOG_LEVEL);
const privateAddressRanges = { ipv4: new net.BlockList(), ipv6: new net.BlockList() };
for (const range of [
['0.0.0.0', 8], ['10.0.0.0', 8], ['100.64.0.0', 10], ['127.0.0.0', 8],
['169.254.0.0', 16], ['172.16.0.0', 12], ['192.0.0.0', 24], ['192.0.2.0', 24],
['192.168.0.0', 16], ['198.18.0.0', 15], ['198.51.100.0', 24],
['203.0.113.0', 24], ['224.0.0.0', 4], ['240.0.0.0', 4],
]) privateAddressRanges.ipv4.addSubnet(range[0], range[1], 'ipv4');
for (const range of [
['::', 128], ['::1', 128], ['::ffff:0:0', 96], ['64:ff9b::', 96],
['100::', 64], ['2001:db8::', 32], ['2001:10::', 28], ['2001:20::', 28],
['fc00::', 7], ['fe80::', 10], ['ff00::', 8],
]) privateAddressRanges.ipv6.addSubnet(range[0], range[1], 'ipv6');
fs.mkdirSync(DATA_DIR, { recursive: true });
function log(level, event, details = {}) {
if (LOG_LEVELS.indexOf(level) > LOG_THRESHOLD) return;
const write = level === 'error' ? console.error : level === 'warn' ? console.warn : console.log;
write(`[${level}] ${event} ${JSON.stringify(details)}`);
}
/**
* APP_SECRET encrypts stored RadioDJ MySQL passwords. In production always set
* it via the environment. For convenience a random secret is generated and
@@ -192,21 +224,120 @@ async function fetchJson(url, headers = {}) {
return res.json();
}
function imageExtensionFromBuffer(buffer) {
if (buffer.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return '.png';
if (buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) return '.jpg';
if (
buffer.toString('ascii', 0, 4) === 'RIFF'
&& buffer.toString('ascii', 8, 12) === 'WEBP'
) return '.webp';
return null;
}
async function normalizeArtworkForRadioDj(buffer) {
const ext = imageExtensionFromBuffer(buffer);
if (ext === '.webp') {
const converted = await sharp(buffer).jpeg({ quality: 90 }).toBuffer();
if (converted.length > MAX_IMAGE_BYTES) {
throw new Error('Converted image exceeds 15 MB limit.');
}
return { buffer: converted, ext: '.jpg', convertedFrom: '.webp' };
}
if (ext !== '.jpg' && ext !== '.png') {
throw new Error('Artwork must be a PNG, JPEG, or WebP image.');
}
return { buffer, ext, convertedFrom: null };
}
async function getPublicImageAddresses(url) {
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) {
throw new Error('Image URL must be a public HTTP or HTTPS URL.');
}
const hostname = url.hostname.replace(/^\[|\]$/g, '');
const addresses = net.isIP(hostname)
? [{ address: hostname, family: net.isIP(hostname) }]
: await dns.lookup(hostname, { all: true, verbatim: true });
const publicAddresses = addresses.filter(({ address, family }) =>
!privateAddressRanges[family === 6 ? 'ipv6' : 'ipv4'].check(address));
if (!publicAddresses.length) {
throw new Error('Image URL must resolve only to public IP addresses.');
}
return publicAddresses;
}
function requestImageFromAddress(url, address) {
return new Promise((resolve, reject) => {
const client = url.protocol === 'https:' ? https : http;
const request = client.request(url, {
headers: { 'User-Agent': USER_AGENT, Accept: 'image/*' },
lookup: (hostname, options, callback) => {
if (options?.all) callback(null, [address]);
else callback(null, address.address, address.family);
},
}, resolve);
request.setTimeout(20000, () => request.destroy(new Error('Image download timed out.')));
request.once('error', reject);
request.end();
});
}
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.');
let currentUrl;
try {
currentUrl = new URL(url);
} catch {
throw new Error('Invalid image URL.');
}
for (let redirects = 0; redirects <= 5; redirects += 1) {
const addresses = await getPublicImageAddresses(currentUrl);
let res;
let lastError;
for (const address of addresses) {
try {
res = await requestImageFromAddress(currentUrl, address);
break;
} catch (error) {
lastError = error;
}
}
if (!res) throw new Error(`Image download failed: ${lastError?.message || 'No public address was reachable.'}`);
try {
if ([301, 302, 303, 307, 308].includes(res.statusCode)) {
const location = res.headers.location;
if (!location) throw new Error('Image URL redirect did not include a destination.');
if (redirects === 5) throw new Error('Image URL redirected too many times.');
res.destroy();
currentUrl = new URL(location, currentUrl);
continue;
}
if (res.statusCode < 200 || res.statusCode >= 300) {
throw new Error(`Download failed (HTTP ${res.statusCode}).`);
}
const type = (res.headers['content-type'] || '').toLowerCase();
if (!type.startsWith('image/')) {
throw new Error(`URL did not return an image (${type || 'unknown content-type'}).`);
}
const chunks = [];
let size = 0;
for await (const chunk of res) {
size += chunk.length;
if (size > MAX_IMAGE_BYTES) {
res.destroy();
throw new Error('Image exceeds 15 MB limit.');
}
chunks.push(chunk);
}
const buffer = Buffer.concat(chunks);
if (!buffer.length) throw new Error('Empty image.');
const ext = imageExtensionFromBuffer(buffer);
if (!ext) throw new Error('URL did not return a supported PNG, JPEG, or WebP image.');
return { buffer, ext };
} finally {
if (!res.complete) res.destroy();
}
}
throw new Error('Image URL redirected too many times.');
}
/** Run async fn over items with bounded concurrency, preserving order. */
@@ -351,6 +482,13 @@ function isPlaceholderImage(image) {
// Express app
// ---------------------------------------------------------------------------
const app = express();
const receiveExportUploads = multer({
storage: multer.diskStorage({
destination: os.tmpdir(),
filename: (req, file, callback) => callback(null, `rdj-art-${crypto.randomBytes(16).toString('hex')}`),
}),
limits: { fileSize: MAX_IMAGE_BYTES, files: MAX_UPLOAD_FILES, fields: 1, fieldSize: 2 * 1024 * 1024 },
}).any();
app.disable('x-powered-by');
app.set('trust proxy', true);
app.use(express.json({ limit: '2mb' }));
@@ -368,6 +506,11 @@ app.use(express.static(path.join(__dirname, 'public'), {
if (filePath.endsWith('.html')) res.setHeader('Cache-Control', 'no-cache');
},
}));
app.get('/Album-Art.png', (req, res, next) => {
res.sendFile(path.join(__dirname, 'Album-Art.png'), err => {
if (err) next(err);
});
});
// ------------------------------ Auth --------------------------------------
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,32}$/;
@@ -621,21 +764,94 @@ app.post('/api/artwork/search', requireAuth, asyncH(async (req, res) => {
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 || {};
app.post('/api/artwork/selection', requireAuth, (req, res) => {
const trackId = Number(req.body?.trackId);
const source = String(req.body?.source || '');
const validSources = ['itunes', 'deezer', 'musicbrainz', 'manual_upload', 'url_upload', 'unknown'];
if (!Number.isSafeInteger(trackId) || trackId <= 0 || !validSources.includes(source)) {
return res.status(400).json({ error: 'Invalid artwork selection details.' });
}
log('info', 'artwork.selected', { trackId, source });
res.json({ ok: true });
});
// ------------------------ ZIP export + DB update ---------------------------
app.post('/api/export', requireAuth, (req, res, next) => {
receiveExportUploads(req, res, err => {
let cleaned = false;
const cleanup = () => {
if (cleaned) return;
cleaned = true;
Promise.all((req.files || []).map(file => fs.promises.unlink(file.path)))
.catch(error => console.error('[export] temporary upload cleanup failed:', error));
};
if (err) {
cleanup();
const tooLarge = err.code === 'LIMIT_FILE_SIZE';
return res.status(400).json({
error: tooLarge
? 'Uploaded images must be 15 MB or smaller.'
: `Upload failed: ${err.message}`,
});
}
res.once('finish', cleanup);
res.once('close', cleanup);
next();
});
}, asyncH(async (req, res) => {
let body = req.body || {};
if (req.is('multipart/form-data')) {
try {
body = JSON.parse(req.body?.payload || '{}');
} catch {
return res.status(400).json({ error: 'Invalid export payload.' });
}
}
const { profileId, updateDb = false, items = [] } = 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).' });
if ((req.files || []).length > MAX_UPLOAD_FILES) {
return res.status(400).json({ error: `Too many uploaded images (max ${MAX_UPLOAD_FILES} 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'}`);
const trackId = Number.isSafeInteger(Number(item.id)) ? Number(item.id) : null;
const source = item.upload === true ? 'manual_upload' : /^https?:\/\//i.test(String(item.imageUrl || '')) ? 'url_upload' : 'provider';
log('debug', 'artwork.export_item_started', { trackId, source });
try {
const { buffer, ext } = await downloadImage(String(item.imageUrl || ''));
return { ok: true, base, buffer, ext, songId: Number.isFinite(+item.id) ? +item.id : null };
let downloaded;
if (item.upload === true) {
const uploaded = (req.files || []).find(file => file.fieldname === `upload-${item.id}`);
if (!uploaded) throw new Error('Uploaded artwork is missing.');
const buffer = await fs.promises.readFile(uploaded.path);
const ext = imageExtensionFromBuffer(buffer);
if (!ext) throw new Error('Uploaded file must be a PNG, JPEG, or WebP image.');
downloaded = { buffer, ext };
} else {
downloaded = await downloadImage(String(item.imageUrl || ''));
}
const normalized = await normalizeArtworkForRadioDj(downloaded.buffer);
if (normalized.convertedFrom) {
log('debug', 'artwork.converted', {
trackId,
from: normalized.convertedFrom,
to: normalized.ext,
});
}
log('debug', 'artwork.export_item_downloaded', { trackId, format: normalized.ext });
return {
ok: true,
base,
buffer: normalized.buffer,
ext: normalized.ext,
songId: Number.isFinite(+item.id) ? +item.id : null,
};
} catch (e) {
log('warn', 'artwork.export_item_failed', { trackId, source, error: e.message });
return { ok: false, base, id: item.id ?? null, error: e.message };
}
});
@@ -676,7 +892,14 @@ app.post('/api/export', requireAuth, asyncH(async (req, res) => {
}
}
log('info', 'artwork.export_completed', {
downloaded: report.downloaded.length,
errors: report.errors.length,
databaseUpdated: report.updated.length,
});
const stamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
res.setHeader('X-Export-Downloaded', String(report.downloaded.length));
res.setHeader('X-Export-Errors', String(report.errors.length));
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="rdj-artwork-${stamp}.zip"`);
const archive = archiver('zip', { zlib: { level: 9 } });