small tweeks
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@@ -17,6 +17,7 @@ WORKDIR /app
|
|||||||
COPY --from=deps /app/node_modules ./node_modules
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
COPY package.json ./
|
COPY package.json ./
|
||||||
COPY server.js ./
|
COPY server.js ./
|
||||||
|
COPY Album-Art.png ./
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
# Persistent SQLite storage lives in /app/data (map a volume or ./data here).
|
# Persistent SQLite storage lives in /app/data (map a volume or ./data here).
|
||||||
|
|||||||
@@ -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. |
|
| `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. |
|
| `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. |
|
| `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
|
## Usage
|
||||||
|
|
||||||
1. **Register** — the first account becomes the `admin`.
|
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.
|
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.
|
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), 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.
|
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**:
|
5. Click **Download ZIP & Update Database**:
|
||||||
- Uncheck **Update RadioDJ DB** to only download the ZIP.
|
- 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.
|
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.
|
> **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
|
### Admin
|
||||||
|
|
||||||
@@ -70,7 +74,7 @@ The admin header button opens the administration panel:
|
|||||||
| POST | `/api/profiles/:id/test` | Test MySQL connectivity + count missing artwork. |
|
| 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}`. |
|
| 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/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. |
|
| GET | `/api/admin/users` | (admin) List users. |
|
||||||
| PUT | `/api/admin/users/:id/role` | (admin) Change role. |
|
| PUT | `/api/admin/users/:id/role` | (admin) Change role. |
|
||||||
| POST | `/api/admin/clone-profiles` | (admin) Copy all profiles between users. |
|
| 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
|
├── server.js # Express backend: auth, profiles, MySQL pools, artwork search, ZIP export
|
||||||
├── public/index.html # Single-page Tailwind dashboard (vanilla JS)
|
├── 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)
|
├── data/ # SQLite storage (git-ignored, volume-mapped in Docker)
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── docker-compose.yml
|
├── docker-compose.yml
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ services:
|
|||||||
# stored passwords unreadable.
|
# stored passwords unreadable.
|
||||||
APP_SECRET: ${APP_SECRET:-change-me-to-a-long-random-string}
|
APP_SECRET: ${APP_SECRET:-change-me-to-a-long-random-string}
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
|
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||||
volumes:
|
volumes:
|
||||||
- ./data:/app/data
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
Generated
+612
-1
@@ -12,12 +12,524 @@
|
|||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"mysql2": "^3.14.0"
|
"multer": "^2.5.0",
|
||||||
|
"mysql2": "^3.14.0",
|
||||||
|
"sharp": "^0.35.5"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"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": {
|
"node_modules/@isaacs/cliui": {
|
||||||
"version": "8.0.2",
|
"version": "8.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
|
"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"
|
"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": {
|
"node_modules/archiver": {
|
||||||
"version": "7.0.1",
|
"version": "7.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz",
|
||||||
@@ -416,6 +934,17 @@
|
|||||||
"node": ">=8.0.0"
|
"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": {
|
"node_modules/bytes": {
|
||||||
"version": "3.1.2",
|
"version": "3.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
|
||||||
@@ -1335,6 +1864,24 @@
|
|||||||
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/mysql2": {
|
||||||
"version": "3.24.4",
|
"version": "3.24.4",
|
||||||
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
|
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.4.tgz",
|
||||||
@@ -1745,6 +2292,55 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/shebang-command": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||||
@@ -1919,6 +2515,14 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/streamx": {
|
||||||
"version": "2.28.1",
|
"version": "2.28.1",
|
||||||
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
|
"resolved": "https://registry.npmjs.org/streamx/-/streamx-2.28.1.tgz",
|
||||||
@@ -2125,6 +2729,13 @@
|
|||||||
"node": ">=0.6"
|
"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": {
|
"node_modules/tunnel-agent": {
|
||||||
"version": "0.6.0",
|
"version": "0.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
|
||||||
|
|||||||
+3
-1
@@ -15,7 +15,9 @@
|
|||||||
"archiver": "^7.0.1",
|
"archiver": "^7.0.1",
|
||||||
"better-sqlite3": "^11.10.0",
|
"better-sqlite3": "^11.10.0",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"mysql2": "^3.14.0"
|
"multer": "^2.5.0",
|
||||||
|
"mysql2": "^3.14.0",
|
||||||
|
"sharp": "^0.35.5"
|
||||||
},
|
},
|
||||||
"overrides": {
|
"overrides": {
|
||||||
"qs": "^6.16.0"
|
"qs": "^6.16.0"
|
||||||
|
|||||||
+207
-28
@@ -93,6 +93,7 @@
|
|||||||
<th class="p-3 w-16">Artwork</th>
|
<th class="p-3 w-16">Artwork</th>
|
||||||
<th class="p-3">Title</th>
|
<th class="p-3">Title</th>
|
||||||
<th class="p-3">Artist</th>
|
<th class="p-3">Artist</th>
|
||||||
|
<th class="p-3">Album</th>
|
||||||
<th class="p-3">Audio Filename</th>
|
<th class="p-3">Audio Filename</th>
|
||||||
<th class="p-3 w-80">Search & Results</th>
|
<th class="p-3 w-80">Search & Results</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -107,7 +108,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-slate-500 mt-3">
|
<p class="text-xs text-slate-500 mt-3">
|
||||||
Artwork sources: iTunes Search API, MusicBrainz + Cover Art Archive — 100% free, no API keys.
|
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>).
|
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.
|
Nothing is exported until you tick the track’s checkbox to confirm its artwork.
|
||||||
</p>
|
</p>
|
||||||
@@ -132,6 +134,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ========================= -->
|
<!-- ========================= Profiles modal ========================= -->
|
||||||
<div id="profilesModal" class="modal hidden">
|
<div id="profilesModal" class="modal hidden">
|
||||||
<div class="modal-card max-w-3xl">
|
<div class="modal-card max-w-3xl">
|
||||||
@@ -194,9 +220,11 @@ const state = {
|
|||||||
profiles: [],
|
profiles: [],
|
||||||
activeProfileId: null,
|
activeProfileId: null,
|
||||||
tracks: [],
|
tracks: [],
|
||||||
|
metadataEdits: new Map(), // trackId -> edited artist/title/album for the current load
|
||||||
selections: new Map(), // trackId -> { imageUrl, thumb, sourceLabel }
|
selections: new Map(), // trackId -> { imageUrl, thumb, sourceLabel }
|
||||||
candidates: new Map(), // trackId -> [{ full, thumb, source, sourceLabel, artist, album }] — persists across filter re-renders
|
candidates: new Map(), // trackId -> [{ full, thumb, source, sourceLabel, artist, album }] — persists across filter re-renders
|
||||||
artModal: { trackId: null, full: '' },
|
artModal: { trackId: null, full: '' },
|
||||||
|
customArtTrackId: null,
|
||||||
authMode: 'login',
|
authMode: 'login',
|
||||||
};
|
};
|
||||||
let currentPage = 1;
|
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">`;
|
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) {
|
function trackRowHtml(t) {
|
||||||
const sel = state.selections.get(t.id);
|
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">
|
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 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">
|
<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
|
${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">`
|
? `<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
|
: `<img src="/Album-Art.png" class="w-full h-full object-contain" alt="Default album artwork">`}</div></td>
|
||||||
? `<span class="text-amber-400 text-[10px] leading-tight text-center px-0.5" title="RadioDJ placeholder: ${esc(t.image)}">placeholder art</span>`
|
<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>
|
||||||
: '<span class="text-slate-500">missing</span>'}</div></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 font-medium text-slate-100">${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="album" aria-label="Album" value="${esc(t.album)}"></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 font-mono text-xs text-slate-400 break-all">${esc(t.audioBase)}</td>
|
||||||
<td class="p-3">
|
<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>
|
<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>
|
</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderTracks() {
|
function renderTracks() {
|
||||||
const f = $('filterInput').value.trim().toLowerCase();
|
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('');
|
$('tracksBody').innerHTML = rows.map(trackRowHtml).join('');
|
||||||
const empty = $('emptyState');
|
const empty = $('emptyState');
|
||||||
empty.style.display = rows.length ? 'none' : '';
|
empty.style.display = rows.length ? 'none' : '';
|
||||||
@@ -442,6 +481,21 @@ function renderTracks() {
|
|||||||
updateStats();
|
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() {
|
function updateStats() {
|
||||||
const checked = document.querySelectorAll('#tracksBody .row-check:checked').length;
|
const checked = document.querySelectorAll('#tracksBody .row-check:checked').length;
|
||||||
$('trackStats').textContent = state.tracks.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>`;
|
<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');
|
if (!state.activeProfileId) return toast('Add and select a station profile first (Profiles button).', 'error');
|
||||||
const btn = $('btnLoadTracks');
|
const btn = $('btnLoadTracks');
|
||||||
btn.disabled = true; btn.textContent = 'Loading…';
|
btn.disabled = true; btn.textContent = 'Loading…';
|
||||||
try {
|
try {
|
||||||
const data = await api(`/api/tracks?profileId=${state.activeProfileId}&page=${currentPage}&limit=${currentLimit}`);
|
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;
|
totalTracks = data.totalTracks || 0;
|
||||||
currentPage = data.page || 1;
|
currentPage = data.page || 1;
|
||||||
renderPagination(data.totalPages || 1);
|
renderPagination(data.totalPages || 1);
|
||||||
state.selections.clear();
|
clearArtworkChoices();
|
||||||
state.candidates.clear();
|
|
||||||
renderTracks();
|
renderTracks();
|
||||||
toast(`${data.tracks.length} track(s) missing artwork loaded.`, 'success');
|
toast(`${data.tracks.length} track(s) missing artwork loaded.`, 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -512,13 +569,10 @@ async function searchForRow(tr, track) {
|
|||||||
btn.disabled = true; btn.textContent = 'Searching…';
|
btn.disabled = true; btn.textContent = 'Searching…';
|
||||||
try {
|
try {
|
||||||
const data = await api('/api/artwork/search', { method: 'POST', body: { artist: track.artist, title: track.title, album: track.album, source: $('apiSourceSelect').value } });
|
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);
|
state.candidates.set(track.id, data.results);
|
||||||
const sel = state.selections.get(track.id);
|
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) {
|
} catch (err) {
|
||||||
toast(err.message, 'error');
|
toast(err.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -527,7 +581,13 @@ async function searchForRow(tr, track) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setTrackSelection(tr, track, img) {
|
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').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.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">`;
|
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) {
|
function openArtModal(trackId, imgData) {
|
||||||
const t = state.tracks.find(x => x.id === trackId);
|
const t = state.tracks.find(x => x.id === trackId);
|
||||||
if (!t) return;
|
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.thumb || imgData.full; // thumb first, then upgrade to hi-res
|
||||||
$('artImg').src = imgData.full;
|
$('artImg').src = imgData.full;
|
||||||
$('artTitle').textContent = t.title || '(untitled)';
|
$('artTitle').textContent = t.title || '(untitled)';
|
||||||
@@ -554,24 +620,90 @@ function openArtModal(trackId, imgData) {
|
|||||||
openModal('artModal');
|
openModal('artModal');
|
||||||
}
|
}
|
||||||
|
|
||||||
function confirmArtSelection() {
|
async function confirmArtSelection() {
|
||||||
const { trackId, full, thumb, sourceLabel } = state.artModal;
|
const { trackId, full, thumb, sourceLabel, file } = state.artModal;
|
||||||
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
|
const tr = document.querySelector(`tr[data-id="${trackId}"]`);
|
||||||
const track = state.tracks.find(x => x.id === trackId);
|
const track = state.tracks.find(x => x.id === trackId);
|
||||||
if (tr && track) {
|
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 } });
|
setTrackSelection(tr, track, { dataset: { full, thumb, sourceLabel } });
|
||||||
|
}
|
||||||
tr.querySelector('.row-check').checked = true;
|
tr.querySelector('.row-check').checked = true;
|
||||||
updateStats();
|
updateStats();
|
||||||
toast('Artwork confirmed for export.', 'success');
|
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');
|
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) {
|
function fillRowFromBest(tr, track, best) {
|
||||||
// Suggest the top hit as a visual preview WITHOUT checking the confirmation box —
|
// 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.
|
// the user must review it and tick the box manually before export.
|
||||||
state.candidates.set(track.id, [best]);
|
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">`;
|
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 -------------------------------------
|
// ----------------------------- export -------------------------------------
|
||||||
async function exportSelected() {
|
async function exportSelected() {
|
||||||
const items = [];
|
const items = [];
|
||||||
|
const uploadedFiles = [];
|
||||||
document.querySelectorAll('#tracksBody .row-check:checked').forEach(cb => {
|
document.querySelectorAll('#tracksBody .row-check:checked').forEach(cb => {
|
||||||
const id = Number(cb.closest('tr').dataset.id);
|
const id = Number(cb.closest('tr').dataset.id);
|
||||||
const t = state.tracks.find(x => x.id === id);
|
const t = state.tracks.find(x => x.id === id);
|
||||||
const sel = state.selections.get(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 (!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 updateDb = $('chkUpdateDb').checked;
|
||||||
const btn = $('btnExport');
|
const btn = $('btnExport');
|
||||||
btn.disabled = true; btn.textContent = 'Working…';
|
btn.disabled = true; btn.textContent = 'Working…';
|
||||||
try {
|
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', {
|
const res = await fetch('/api/export', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
credentials: 'same-origin',
|
credentials: 'same-origin',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: formData,
|
||||||
body: JSON.stringify({ profileId: state.activeProfileId, updateDb, items }),
|
|
||||||
});
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
let msg = `Export failed (${res.status})`;
|
let msg = `Export failed (${res.status})`;
|
||||||
@@ -634,7 +779,14 @@ async function exportSelected() {
|
|||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
URL.revokeObjectURL(a.href);
|
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) {
|
} catch (err) {
|
||||||
toast(err.message, 'error');
|
toast(err.message, 'error');
|
||||||
} finally {
|
} finally {
|
||||||
@@ -698,6 +850,12 @@ function bindEvents() {
|
|||||||
$('stationSelect').addEventListener('change', e => {
|
$('stationSelect').addEventListener('change', e => {
|
||||||
state.activeProfileId = Number(e.target.value) || null;
|
state.activeProfileId = Number(e.target.value) || null;
|
||||||
currentPage = 1;
|
currentPage = 1;
|
||||||
|
state.metadataEdits.clear();
|
||||||
|
state.tracks = [];
|
||||||
|
clearArtworkChoices();
|
||||||
|
totalTracks = 0;
|
||||||
|
renderPagination(0);
|
||||||
|
renderTracks();
|
||||||
renderProfilesList();
|
renderProfilesList();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -715,7 +873,7 @@ function bindEvents() {
|
|||||||
$('pfCancel').addEventListener('click', resetProfileForm);
|
$('pfCancel').addEventListener('click', resetProfileForm);
|
||||||
$('profilesList').addEventListener('click', handleProfileAction);
|
$('profilesList').addEventListener('click', handleProfileAction);
|
||||||
|
|
||||||
$('btnLoadTracks').addEventListener('click', loadTracks);
|
$('btnLoadTracks').addEventListener('click', () => loadTracks({ resetMetadataEdits: true }));
|
||||||
$('btnAutoSearch').addEventListener('click', autoSearch);
|
$('btnAutoSearch').addEventListener('click', autoSearch);
|
||||||
$('filterInput').addEventListener('input', renderTracks);
|
$('filterInput').addEventListener('input', renderTracks);
|
||||||
$('btnExport').addEventListener('click', exportSelected);
|
$('btnExport').addEventListener('click', exportSelected);
|
||||||
@@ -734,12 +892,16 @@ function bindEvents() {
|
|||||||
if (!tr) return;
|
if (!tr) return;
|
||||||
const track = state.tracks.find(t => t.id === Number(tr.dataset.id));
|
const track = state.tracks.find(t => t.id === Number(tr.dataset.id));
|
||||||
if (!track) return;
|
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')) {
|
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, {
|
openArtModal(track.id, {
|
||||||
full: e.target.dataset.full || e.target.src,
|
full: e.target.dataset.full || e.target.src,
|
||||||
thumb: e.target.dataset.thumb || e.target.src,
|
thumb: e.target.dataset.thumb || e.target.src,
|
||||||
sourceLabel: e.target.dataset.sourceLabel || '',
|
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();
|
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);
|
$('adminUsers').addEventListener('change', handleRoleChange);
|
||||||
$('btnClone').addEventListener('click', cloneProfiles);
|
$('btnClone').addEventListener('click', cloneProfiles);
|
||||||
|
|
||||||
@@ -778,6 +955,8 @@ function bindEvents() {
|
|||||||
$('artClose').addEventListener('click', () => closeModal('artModal'));
|
$('artClose').addEventListener('click', () => closeModal('artModal'));
|
||||||
$('artImg').addEventListener('click', () => { if (state.artModal.full) window.open(state.artModal.full, '_blank', 'noopener'); });
|
$('artImg').addEventListener('click', () => { if (state.artModal.full) window.open(state.artModal.full, '_blank', 'noopener'); });
|
||||||
$('artConfirm').addEventListener('click', confirmArtSelection);
|
$('artConfirm').addEventListener('click', confirmArtSelection);
|
||||||
|
$('customArtFile').addEventListener('change', useCustomArtFile);
|
||||||
|
$('btnUseArtUrl').addEventListener('click', useCustomArtUrl);
|
||||||
document.addEventListener('keydown', e => {
|
document.addEventListener('keydown', e => {
|
||||||
if (e.key === 'Escape' && !$('artModal').classList.contains('hidden')) closeModal('artModal');
|
if (e.key === 'Escape' && !$('artModal').classList.contains('hidden')) closeModal('artModal');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,10 +15,17 @@
|
|||||||
const path = require('path');
|
const path = require('path');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const crypto = require('crypto');
|
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 express = require('express');
|
||||||
const Database = require('better-sqlite3');
|
const Database = require('better-sqlite3');
|
||||||
const mysql = require('mysql2/promise');
|
const mysql = require('mysql2/promise');
|
||||||
const archiver = require('archiver');
|
const archiver = require('archiver');
|
||||||
|
const multer = require('multer');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Configuration
|
// 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 USER_AGENT = 'rdj-art-app/1.0 (open-source; contact: local-admin)';
|
||||||
const FETCH_TIMEOUT_MS = 12000;
|
const FETCH_TIMEOUT_MS = 12000;
|
||||||
const MAX_IMAGE_BYTES = 15 * 1024 * 1024;
|
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 });
|
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
|
* APP_SECRET encrypts stored RadioDJ MySQL passwords. In production always set
|
||||||
* it via the environment. For convenience a random secret is generated and
|
* it via the environment. For convenience a random secret is generated and
|
||||||
@@ -192,21 +224,120 @@ async function fetchJson(url, headers = {}) {
|
|||||||
return res.json();
|
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) {
|
async function downloadImage(url) {
|
||||||
if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) throw new Error('Invalid image URL.');
|
let currentUrl;
|
||||||
const res = await fetchWithTimeout(
|
try {
|
||||||
url,
|
currentUrl = new URL(url);
|
||||||
{ headers: { 'User-Agent': USER_AGENT, Accept: 'image/*' } },
|
} catch {
|
||||||
20000,
|
throw new Error('Invalid image URL.');
|
||||||
);
|
}
|
||||||
if (!res.ok) throw new Error(`Download failed (HTTP ${res.status}).`);
|
|
||||||
const type = (res.headers.get('content-type') || '').toLowerCase();
|
for (let redirects = 0; redirects <= 5; redirects += 1) {
|
||||||
if (!type.startsWith('image/')) throw new Error(`URL did not return an image (${type || 'unknown content-type'}).`);
|
const addresses = await getPublicImageAddresses(currentUrl);
|
||||||
const ext = type.includes('png') ? '.png' : type.includes('webp') ? '.webp' : '.jpg';
|
let res;
|
||||||
const buffer = Buffer.from(await res.arrayBuffer());
|
let lastError;
|
||||||
if (buffer.length === 0) throw new Error('Empty image.');
|
for (const address of addresses) {
|
||||||
if (buffer.length > MAX_IMAGE_BYTES) throw new Error('Image exceeds 15 MB limit.');
|
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 };
|
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. */
|
/** Run async fn over items with bounded concurrency, preserving order. */
|
||||||
@@ -351,6 +482,13 @@ function isPlaceholderImage(image) {
|
|||||||
// Express app
|
// Express app
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
const app = express();
|
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.disable('x-powered-by');
|
||||||
app.set('trust proxy', true);
|
app.set('trust proxy', true);
|
||||||
app.use(express.json({ limit: '2mb' }));
|
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');
|
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 --------------------------------------
|
// ------------------------------ Auth --------------------------------------
|
||||||
const USERNAME_RE = /^[A-Za-z0-9_.-]{3,32}$/;
|
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) });
|
res.json({ results: dedupeResults(results).slice(0, 12) });
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// ------------------------ ZIP export + DB update ---------------------------
|
app.post('/api/artwork/selection', requireAuth, (req, res) => {
|
||||||
app.post('/api/export', requireAuth, asyncH(async (req, res) => {
|
const trackId = Number(req.body?.trackId);
|
||||||
const { profileId, updateDb = false, items = [] } = req.body || {};
|
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 (!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 (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: [] };
|
const report = { generatedAt: new Date().toISOString(), downloaded: [], updated: [], errors: [] };
|
||||||
|
|
||||||
// Download artwork (bounded concurrency), rename to base audio filename.
|
// Download artwork (bounded concurrency), rename to base audio filename.
|
||||||
const results = await mapLimit(items, 4, async item => {
|
const results = await mapLimit(items, 4, async item => {
|
||||||
const base = sanitizeFilename(item.audioBase || audioBaseName(item.path) || `track-${item.id ?? 'x'}`);
|
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 {
|
try {
|
||||||
const { buffer, ext } = await downloadImage(String(item.imageUrl || ''));
|
let downloaded;
|
||||||
return { ok: true, base, buffer, ext, songId: Number.isFinite(+item.id) ? +item.id : null };
|
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) {
|
} catch (e) {
|
||||||
|
log('warn', 'artwork.export_item_failed', { trackId, source, error: e.message });
|
||||||
return { ok: false, base, id: item.id ?? null, 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);
|
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-Type', 'application/zip');
|
||||||
res.setHeader('Content-Disposition', `attachment; filename="rdj-artwork-${stamp}.zip"`);
|
res.setHeader('Content-Disposition', `attachment; filename="rdj-artwork-${stamp}.zip"`);
|
||||||
const archive = archiver('zip', { zlib: { level: 9 } });
|
const archive = archiver('zip', { zlib: { level: 9 } });
|
||||||
|
|||||||
Reference in New Issue
Block a user