feat(enrich): обогащение треков через Discogs + самохостинг обложек (WebP)
При первом появлении трека подтягиваем жанр/стиль/лейбл/год из Discogs и сохраняем обложку в едином формате WebP 500x500 у себя (/covers). Дальше пользователю отдаём только из своей БД — внешние сервисы в рантайме не дёргаем. - Track: +genre/styles/label/year/discogsId/enrichStatus (миграция) - EnrichModule: DiscogsService (поиск), CoverStorageService (sharp->webp), EnrichmentService (очередь с троттлингом + бэкафилл-крон каждые 10 мин) - charts: фильтр чартов по жанру (?genre=), GET /charts/genres, жанр/стиль/лейбл/год в выдаче чарта и детальной странице - main: раздача /covers статикой; docker: volume covers_data + env DISCOGS_TOKEN/PUBLIC_BASE_URL/COVERS_DIR - убран MusicBrainz-фолбэк (заменён Discogs) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -19,6 +19,13 @@ MAIL_FROM=radiOLA <noreply@example.com>
|
|||||||
FRONTEND_URL=https://radiola.app
|
FRONTEND_URL=https://radiola.app
|
||||||
PORT=3000
|
PORT=3000
|
||||||
|
|
||||||
|
# Обогащение треков (Discogs): личный токен из discogs.com → Settings → Developers
|
||||||
|
DISCOGS_TOKEN=
|
||||||
|
# Базовый публичный URL бэкенда — для абсолютных ссылок на обложки (/covers/*.webp)
|
||||||
|
PUBLIC_BASE_URL=http://121.127.37.212:3000
|
||||||
|
# Каталог для сохранённых обложек (в docker — volume /data/covers)
|
||||||
|
COVERS_DIR=/data/covers
|
||||||
|
|
||||||
# Postgres (for docker-compose)
|
# Postgres (for docker-compose)
|
||||||
POSTGRES_USER=radiola
|
POSTGRES_USER=radiola
|
||||||
POSTGRES_PASSWORD=radiola_pass
|
POSTGRES_PASSWORD=radiola_pass
|
||||||
|
|||||||
@@ -20,6 +20,12 @@ services:
|
|||||||
- SMTP_PASS=${SMTP_PASS}
|
- SMTP_PASS=${SMTP_PASS}
|
||||||
- MAIL_FROM=${MAIL_FROM:-noreply@radiola.app}
|
- MAIL_FROM=${MAIL_FROM:-noreply@radiola.app}
|
||||||
- FRONTEND_URL=${FRONTEND_URL:-https://radiola.app}
|
- FRONTEND_URL=${FRONTEND_URL:-https://radiola.app}
|
||||||
|
# Обогащение треков
|
||||||
|
- DISCOGS_TOKEN=${DISCOGS_TOKEN}
|
||||||
|
- COVERS_DIR=/data/covers
|
||||||
|
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://121.127.37.212:3000}
|
||||||
|
volumes:
|
||||||
|
- covers_data:/data/covers
|
||||||
depends_on:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
@@ -62,6 +68,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
postgres_data:
|
postgres_data:
|
||||||
redis_data:
|
redis_data:
|
||||||
|
covers_data:
|
||||||
|
|
||||||
networks:
|
networks:
|
||||||
radiola:
|
radiola:
|
||||||
|
|||||||
445
package-lock.json
generated
445
package-lock.json
generated
@@ -29,7 +29,8 @@
|
|||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.33.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
@@ -781,7 +782,6 @@
|
|||||||
"version": "1.10.0",
|
"version": "1.10.0",
|
||||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"optional": true,
|
"optional": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1022,6 +1022,367 @@
|
|||||||
"url": "https://github.com/sponsors/nzakas"
|
"url": "https://github.com/sponsors/nzakas"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-darwin-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-libvips-darwin-arm64": {
|
||||||
|
"version": "1.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
|
||||||
|
"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.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
|
||||||
|
"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.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
|
||||||
|
"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.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"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.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
|
||||||
|
"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.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
|
||||||
|
"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.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
|
||||||
|
"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.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
|
||||||
|
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
|
||||||
|
"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.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
|
||||||
|
"cpu": [
|
||||||
|
"arm"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-s390x": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
|
||||||
|
"cpu": [
|
||||||
|
"s390x"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linux-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-arm64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-linuxmusl-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-wasm32": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
|
||||||
|
"cpu": [
|
||||||
|
"wasm32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
|
||||||
|
"optional": true,
|
||||||
|
"dependencies": {
|
||||||
|
"@emnapi/runtime": "^1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-ia32": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
|
||||||
|
"cpu": [
|
||||||
|
"ia32"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@img/sharp-win32-x64": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "Apache-2.0 AND LGPL-3.0-or-later",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"win32"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@inquirer/ansi": {
|
"node_modules/@inquirer/ansi": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
||||||
@@ -5096,11 +5457,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color": {
|
||||||
|
"version": "4.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
|
||||||
|
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-convert": "^2.0.1",
|
||||||
|
"color-string": "^1.9.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.5.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"color-name": "~1.1.4"
|
"color-name": "~1.1.4"
|
||||||
@@ -5113,9 +5486,18 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/color-string": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"color-name": "^1.0.0",
|
||||||
|
"simple-swizzle": "^0.2.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/combined-stream": {
|
"node_modules/combined-stream": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||||
@@ -5462,7 +5844,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"optional": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
@@ -9739,6 +10120,45 @@
|
|||||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/sharp": {
|
||||||
|
"version": "0.33.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
|
||||||
|
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"color": "^4.2.3",
|
||||||
|
"detect-libc": "^2.0.3",
|
||||||
|
"semver": "^7.6.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://opencollective.com/libvips"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"@img/sharp-darwin-arm64": "0.33.5",
|
||||||
|
"@img/sharp-darwin-x64": "0.33.5",
|
||||||
|
"@img/sharp-libvips-darwin-arm64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-darwin-x64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linux-arm": "1.0.5",
|
||||||
|
"@img/sharp-libvips-linux-arm64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linux-s390x": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linux-x64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
|
||||||
|
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
|
||||||
|
"@img/sharp-linux-arm": "0.33.5",
|
||||||
|
"@img/sharp-linux-arm64": "0.33.5",
|
||||||
|
"@img/sharp-linux-s390x": "0.33.5",
|
||||||
|
"@img/sharp-linux-x64": "0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-arm64": "0.33.5",
|
||||||
|
"@img/sharp-linuxmusl-x64": "0.33.5",
|
||||||
|
"@img/sharp-wasm32": "0.33.5",
|
||||||
|
"@img/sharp-win32-ia32": "0.33.5",
|
||||||
|
"@img/sharp-win32-x64": "0.33.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"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",
|
||||||
@@ -9847,6 +10267,21 @@
|
|||||||
"url": "https://github.com/sponsors/isaacs"
|
"url": "https://github.com/sponsors/isaacs"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-swizzle": {
|
||||||
|
"version": "0.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
|
||||||
|
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-arrayish": "^0.3.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/simple-swizzle/node_modules/is-arrayish": {
|
||||||
|
"version": "0.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
|
||||||
|
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/slash": {
|
"node_modules/slash": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||||
|
|||||||
@@ -44,7 +44,8 @@
|
|||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1",
|
||||||
|
"sharp": "^0.33.5"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/eslintrc": "^3.2.0",
|
"@eslint/eslintrc": "^3.2.0",
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
-- Обогащение треков (Discogs): жанр/стили/лейбл/год + состояние обогащения
|
||||||
|
ALTER TABLE "tracks" ADD COLUMN "genre" TEXT;
|
||||||
|
ALTER TABLE "tracks" ADD COLUMN "styles" TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[];
|
||||||
|
ALTER TABLE "tracks" ADD COLUMN "label" TEXT;
|
||||||
|
ALTER TABLE "tracks" ADD COLUMN "year" INTEGER;
|
||||||
|
ALTER TABLE "tracks" ADD COLUMN "discogs_id" INTEGER;
|
||||||
|
-- Все треки стартуют как pending: бэкафилл добавит жанр/стиль/лейбл из Discogs
|
||||||
|
-- и сохранит обложку в WebP (в т.ч. накопленным ранее трекам)
|
||||||
|
ALTER TABLE "tracks" ADD COLUMN "enrich_status" TEXT NOT NULL DEFAULT 'pending';
|
||||||
|
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "tracks_genre_idx" ON "tracks"("genre");
|
||||||
@@ -124,12 +124,21 @@ model Track {
|
|||||||
coverUrl String? @map("cover_url")
|
coverUrl String? @map("cover_url")
|
||||||
album String?
|
album String?
|
||||||
releaseDate DateTime? @map("release_date")
|
releaseDate DateTime? @map("release_date")
|
||||||
|
// Обогащение через Discogs (своя БД — в рантайме к Discogs не ходим)
|
||||||
|
genre String?
|
||||||
|
styles String[] @default([])
|
||||||
|
label String?
|
||||||
|
year Int?
|
||||||
|
discogsId Int? @map("discogs_id")
|
||||||
|
// Состояние обогащения: pending | done | failed
|
||||||
|
enrichStatus String @default("pending") @map("enrich_status")
|
||||||
firstSeenAt DateTime @default(now()) @map("first_seen_at")
|
firstSeenAt DateTime @default(now()) @map("first_seen_at")
|
||||||
enrichedAt DateTime? @map("enriched_at")
|
enrichedAt DateTime? @map("enriched_at")
|
||||||
|
|
||||||
plays TrackPlay[]
|
plays TrackPlay[]
|
||||||
likes TrackLike[]
|
likes TrackLike[]
|
||||||
|
|
||||||
|
@@index([genre])
|
||||||
@@map("tracks")
|
@@map("tracks")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,13 +26,21 @@ export class ChartsController {
|
|||||||
async getTopTracks(
|
async getTopTracks(
|
||||||
@Query('period') period: string = 'week',
|
@Query('period') period: string = 'week',
|
||||||
@Query('limit') limit: string = '100',
|
@Query('limit') limit: string = '100',
|
||||||
|
@Query('genre') genre?: string,
|
||||||
) {
|
) {
|
||||||
const validPeriod: ChartPeriod =
|
const validPeriod: ChartPeriod =
|
||||||
period === 'day' || period === 'week' || period === 'month' || period === 'all'
|
period === 'day' || period === 'week' || period === 'month' || period === 'all'
|
||||||
? (period as ChartPeriod)
|
? (period as ChartPeriod)
|
||||||
: 'week';
|
: 'week';
|
||||||
const parsedLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 200);
|
const parsedLimit = Math.min(Math.max(parseInt(limit, 10) || 100, 1), 200);
|
||||||
return this.chartsService.getTopTracks(validPeriod, parsedLimit);
|
const genreFilter = genre?.trim() ? genre.trim() : undefined;
|
||||||
|
return this.chartsService.getTopTracks(validPeriod, parsedLimit, genreFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('genres')
|
||||||
|
@ApiOperation({ summary: 'Список доступных жанров для фильтра' })
|
||||||
|
async getGenres() {
|
||||||
|
return this.chartsService.getGenres();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Get('tracks/:trackId')
|
@Get('tracks/:trackId')
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import { Module } from '@nestjs/common';
|
|||||||
import { ChartsController } from './charts.controller';
|
import { ChartsController } from './charts.controller';
|
||||||
import { ChartsService } from './charts.service';
|
import { ChartsService } from './charts.service';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { EnrichModule } from '../enrich/enrich.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [AuthModule],
|
imports: [AuthModule, EnrichModule],
|
||||||
controllers: [ChartsController],
|
controllers: [ChartsController],
|
||||||
providers: [ChartsService],
|
providers: [ChartsService],
|
||||||
exports: [ChartsService],
|
exports: [ChartsService],
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { EnrichmentService } from '../enrich/enrichment.service';
|
||||||
|
|
||||||
// Период чарта
|
// Период чарта
|
||||||
export type ChartPeriod = 'day' | 'week' | 'month' | 'all';
|
export type ChartPeriod = 'day' | 'week' | 'month' | 'all';
|
||||||
@@ -13,6 +14,10 @@ export interface ChartEntry {
|
|||||||
artist: string;
|
artist: string;
|
||||||
song: string;
|
song: string;
|
||||||
coverUrl: string | null;
|
coverUrl: string | null;
|
||||||
|
genre: string | null;
|
||||||
|
styles: string[];
|
||||||
|
label: string | null;
|
||||||
|
year: number | null;
|
||||||
plays: number;
|
plays: number;
|
||||||
stationsCount: number;
|
stationsCount: number;
|
||||||
likes: number;
|
likes: number;
|
||||||
@@ -53,7 +58,10 @@ interface RawStationRow {
|
|||||||
export class ChartsService {
|
export class ChartsService {
|
||||||
private readonly logger = new Logger(ChartsService.name);
|
private readonly logger = new Logger(ChartsService.name);
|
||||||
|
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly enrichment: EnrichmentService,
|
||||||
|
) {}
|
||||||
|
|
||||||
// Возвращает метку начала периода
|
// Возвращает метку начала периода
|
||||||
private periodStart(period: ChartPeriod): Date {
|
private periodStart(period: ChartPeriod): Date {
|
||||||
@@ -125,21 +133,30 @@ export class ChartsService {
|
|||||||
'|' +
|
'|' +
|
||||||
song.toLowerCase().replace(/\s+/g, ' ');
|
song.toLowerCase().replace(/\s+/g, ' ');
|
||||||
|
|
||||||
|
// Не перетираем уже сохранённую (self-hosted) обложку сырым Record-URL
|
||||||
const track = await this.prisma.track.upsert({
|
const track = await this.prisma.track.upsert({
|
||||||
where: { normKey },
|
where: { normKey },
|
||||||
create: { normKey, artist, song, coverUrl: coverUrl ?? null },
|
create: { normKey, artist, song, coverUrl: coverUrl ?? null },
|
||||||
update: { coverUrl: coverUrl ?? null },
|
update: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Если у трека ещё нет обложки, а Record прислал — подставим как стартовую
|
||||||
|
if (!track.coverUrl && coverUrl) {
|
||||||
|
await this.prisma.track.update({
|
||||||
|
where: { id: track.id },
|
||||||
|
data: { coverUrl },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.trackPlay.create({
|
await this.prisma.trackPlay.create({
|
||||||
data: { trackId: track.id, stationId: stationDbId },
|
data: { trackId: track.id, stationId: stationDbId },
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.debug(`Записан трек: "${artist} — ${song}"`);
|
this.logger.debug(`Записан трек: "${artist} — ${song}"`);
|
||||||
|
|
||||||
// Асинхронное обогащение нового трека (fire-and-forget)
|
// Асинхронное обогащение нового трека (Discogs + WebP-обложка, fire-and-forget)
|
||||||
if (!track.enrichedAt) {
|
if (track.enrichStatus !== 'done') {
|
||||||
void this.enrichTrack(track.id, artist, song);
|
this.enrichment.enqueue(track.id);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
// Ошибка сбора не должна ронять поллер
|
// Ошибка сбора не должна ронять поллер
|
||||||
@@ -147,65 +164,34 @@ export class ChartsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обогащение трека через MusicBrainz (fire-and-forget, best-effort)
|
// Чарт треков за период (с опциональным фильтром по жанру)
|
||||||
private async enrichTrack(
|
async getTopTracks(
|
||||||
trackId: string,
|
period: ChartPeriod,
|
||||||
artist: string,
|
limit: number,
|
||||||
song: string,
|
genre?: string,
|
||||||
): Promise<void> {
|
): Promise<{ items: ChartEntry[] }> {
|
||||||
try {
|
|
||||||
const query = encodeURIComponent(`recording:"${song}" AND artist:"${artist}"`);
|
|
||||||
const url = `https://musicbrainz.org/ws/2/recording/?query=${query}&fmt=json&limit=1`;
|
|
||||||
const res = await fetch(url, {
|
|
||||||
headers: {
|
|
||||||
'User-Agent': 'radiOLA/1.0 ( blinnafeg@gmail.com )',
|
|
||||||
Accept: 'application/json',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!res.ok) return;
|
|
||||||
|
|
||||||
const data = (await res.json()) as {
|
|
||||||
recordings?: Array<{
|
|
||||||
releases?: Array<{
|
|
||||||
title: string;
|
|
||||||
date?: string;
|
|
||||||
}>;
|
|
||||||
}>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const recording = data.recordings?.[0];
|
|
||||||
if (!recording) return;
|
|
||||||
|
|
||||||
const release = recording.releases?.[0];
|
|
||||||
const album = release?.title ?? null;
|
|
||||||
const releaseDate = release?.date ? new Date(release.date) : null;
|
|
||||||
|
|
||||||
// Проверяем, что дата валидна
|
|
||||||
const validDate =
|
|
||||||
releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate : null;
|
|
||||||
|
|
||||||
await this.prisma.track.update({
|
|
||||||
where: { id: trackId },
|
|
||||||
data: { album, releaseDate: validDate, enrichedAt: new Date() },
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.debug(`Трек ${trackId} обогащён: альбом="${album}"`);
|
|
||||||
} catch (error) {
|
|
||||||
// Игнорируем ошибки обогащения — не критично
|
|
||||||
this.logger.debug(`Обогащение трека ${trackId} не удалось: ${error.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Чарт треков за период
|
|
||||||
async getTopTracks(period: ChartPeriod, limit: number): Promise<{ items: ChartEntry[] }> {
|
|
||||||
const since = this.periodStart(period);
|
const since = this.periodStart(period);
|
||||||
const duration = this.periodDuration(period);
|
const duration = this.periodDuration(period);
|
||||||
const prevSince = new Date(since.getTime() - duration);
|
const prevSince = new Date(since.getTime() - duration);
|
||||||
|
|
||||||
|
// Фильтр по жанру: ограничиваем набор треков
|
||||||
|
let genreTrackIds: string[] | undefined;
|
||||||
|
if (genre) {
|
||||||
|
const matched = await this.prisma.track.findMany({
|
||||||
|
where: { genre: { equals: genre, mode: 'insensitive' } },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
genreTrackIds = matched.map((t) => t.id);
|
||||||
|
if (genreTrackIds.length === 0) return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
// Топ текущего периода: группировка по trackId
|
// Топ текущего периода: группировка по trackId
|
||||||
const currentGroups = await this.prisma.trackPlay.groupBy({
|
const currentGroups = await this.prisma.trackPlay.groupBy({
|
||||||
by: ['trackId'],
|
by: ['trackId'],
|
||||||
where: { playedAt: { gte: since } },
|
where: {
|
||||||
|
playedAt: { gte: since },
|
||||||
|
...(genreTrackIds ? { trackId: { in: genreTrackIds } } : {}),
|
||||||
|
},
|
||||||
_count: { id: true },
|
_count: { id: true },
|
||||||
orderBy: { _count: { id: 'desc' } },
|
orderBy: { _count: { id: 'desc' } },
|
||||||
take: limit,
|
take: limit,
|
||||||
@@ -265,7 +251,16 @@ export class ChartsService {
|
|||||||
// Получаем данные треков
|
// Получаем данные треков
|
||||||
const tracks = await this.prisma.track.findMany({
|
const tracks = await this.prisma.track.findMany({
|
||||||
where: { id: { in: trackIds } },
|
where: { id: { in: trackIds } },
|
||||||
select: { id: true, artist: true, song: true, coverUrl: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
artist: true,
|
||||||
|
song: true,
|
||||||
|
coverUrl: true,
|
||||||
|
genre: true,
|
||||||
|
styles: true,
|
||||||
|
label: true,
|
||||||
|
year: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
const tracksMap = new Map(tracks.map((t) => [t.id, t]));
|
const tracksMap = new Map(tracks.map((t) => [t.id, t]));
|
||||||
|
|
||||||
@@ -290,6 +285,10 @@ export class ChartsService {
|
|||||||
artist: track?.artist ?? '',
|
artist: track?.artist ?? '',
|
||||||
song: track?.song ?? '',
|
song: track?.song ?? '',
|
||||||
coverUrl: track?.coverUrl ?? null,
|
coverUrl: track?.coverUrl ?? null,
|
||||||
|
genre: track?.genre ?? null,
|
||||||
|
styles: track?.styles ?? [],
|
||||||
|
label: track?.label ?? null,
|
||||||
|
year: track?.year ?? null,
|
||||||
plays: g._count.id,
|
plays: g._count.id,
|
||||||
stationsCount: stationsMap.get(g.trackId) ?? 0,
|
stationsCount: stationsMap.get(g.trackId) ?? 0,
|
||||||
likes: likesMap.get(g.trackId) ?? 0,
|
likes: likesMap.get(g.trackId) ?? 0,
|
||||||
@@ -311,6 +310,10 @@ export class ChartsService {
|
|||||||
song: string;
|
song: string;
|
||||||
album: string | null;
|
album: string | null;
|
||||||
coverUrl: string | null;
|
coverUrl: string | null;
|
||||||
|
genre: string | null;
|
||||||
|
styles: string[];
|
||||||
|
label: string | null;
|
||||||
|
year: number | null;
|
||||||
releaseDate: string | null;
|
releaseDate: string | null;
|
||||||
firstSeen: string | null;
|
firstSeen: string | null;
|
||||||
totalPlays: number;
|
totalPlays: number;
|
||||||
@@ -403,6 +406,10 @@ export class ChartsService {
|
|||||||
song: track.song,
|
song: track.song,
|
||||||
album: track.album ?? null,
|
album: track.album ?? null,
|
||||||
coverUrl: track.coverUrl ?? null,
|
coverUrl: track.coverUrl ?? null,
|
||||||
|
genre: track.genre ?? null,
|
||||||
|
styles: track.styles ?? [],
|
||||||
|
label: track.label ?? null,
|
||||||
|
year: track.year ?? null,
|
||||||
releaseDate: track.releaseDate ? track.releaseDate.toISOString() : null,
|
releaseDate: track.releaseDate ? track.releaseDate.toISOString() : null,
|
||||||
firstSeen: track.firstSeenAt ? track.firstSeenAt.toISOString() : null,
|
firstSeen: track.firstSeenAt ? track.firstSeenAt.toISOString() : null,
|
||||||
totalPlays: totalPlaysResult,
|
totalPlays: totalPlaysResult,
|
||||||
@@ -438,4 +445,15 @@ export class ChartsService {
|
|||||||
});
|
});
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Список доступных жанров (для фильтра в чартах)
|
||||||
|
async getGenres(): Promise<{ genres: string[] }> {
|
||||||
|
const rows = await this.prisma.track.findMany({
|
||||||
|
where: { genre: { not: null } },
|
||||||
|
select: { genre: true },
|
||||||
|
distinct: ['genre'],
|
||||||
|
orderBy: { genre: 'asc' },
|
||||||
|
});
|
||||||
|
return { genres: rows.map((r) => r.genre).filter((g): g is string => !!g) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
72
src/enrich/cover-storage.service.ts
Normal file
72
src/enrich/cover-storage.service.ts
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { createHash } from 'crypto';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import sharp from 'sharp';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Хранилище обложек треков.
|
||||||
|
* Скачивает картинку из любого источника, приводит к единому формату —
|
||||||
|
* WebP фиксированного размера (качество без видимых потерь, малый вес) —
|
||||||
|
* и сохраняет локально. В рантайме отдаём со своего домена, не зависим от чужих CDN.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class CoverStorageService {
|
||||||
|
private readonly logger = new Logger(CoverStorageService.name);
|
||||||
|
private readonly dir =
|
||||||
|
process.env.COVERS_DIR || join(process.cwd(), 'data', 'covers');
|
||||||
|
private readonly publicBase = (process.env.PUBLIC_BASE_URL || '').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
private readonly size = 500; // квадрат 500×500 — хватает и карточке, и детальной
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Скачивает и сохраняет обложку как WebP.
|
||||||
|
* key — стабильный ключ (normKey трека), чтобы имя файла было детерминированным.
|
||||||
|
* Возвращает публичный URL обложки или null.
|
||||||
|
*/
|
||||||
|
async store(sourceUrl: string, key: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const hash = createHash('sha1').update(key).digest('hex').slice(0, 16);
|
||||||
|
const fileName = `${hash}.webp`;
|
||||||
|
const filePath = join(this.dir, fileName);
|
||||||
|
const publicPath = `/covers/${fileName}`;
|
||||||
|
|
||||||
|
// Уже сохранена — повторно не качаем
|
||||||
|
try {
|
||||||
|
await fs.access(filePath);
|
||||||
|
return this.toPublicUrl(publicPath);
|
||||||
|
} catch {
|
||||||
|
// файла нет — продолжаем
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(sourceUrl, {
|
||||||
|
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
|
||||||
|
});
|
||||||
|
if (!res.ok) return null;
|
||||||
|
|
||||||
|
const ctype = res.headers.get('content-type') ?? '';
|
||||||
|
if (!ctype.startsWith('image/')) return null;
|
||||||
|
|
||||||
|
const buf = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (buf.length === 0 || buf.length > 8 * 1024 * 1024) return null;
|
||||||
|
|
||||||
|
await fs.mkdir(this.dir, { recursive: true });
|
||||||
|
await sharp(buf)
|
||||||
|
.resize(this.size, this.size, { fit: 'cover', position: 'centre' })
|
||||||
|
.webp({ quality: 80 })
|
||||||
|
.toFile(filePath);
|
||||||
|
|
||||||
|
return this.toPublicUrl(publicPath);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug(`Не удалось сохранить обложку: ${(e as Error).message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Если задан PUBLIC_BASE_URL — отдаём абсолютный URL, иначе относительный путь
|
||||||
|
private toPublicUrl(path: string): string {
|
||||||
|
return this.publicBase ? `${this.publicBase}${path}` : path;
|
||||||
|
}
|
||||||
|
}
|
||||||
84
src/enrich/discogs.service.ts
Normal file
84
src/enrich/discogs.service.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
// Результат обогащения из Discogs
|
||||||
|
export interface DiscogsResult {
|
||||||
|
discogsId: number | null;
|
||||||
|
genre: string | null;
|
||||||
|
styles: string[];
|
||||||
|
label: string | null;
|
||||||
|
year: number | null;
|
||||||
|
coverImageUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Сырой результат поиска Discogs (нужные поля)
|
||||||
|
interface DiscogsSearchItem {
|
||||||
|
id?: number;
|
||||||
|
genre?: string[];
|
||||||
|
style?: string[];
|
||||||
|
label?: string[];
|
||||||
|
year?: string | number;
|
||||||
|
cover_image?: string;
|
||||||
|
thumb?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клиент Discogs Database Search.
|
||||||
|
* Один запрос поиска уже отдаёт genre/style/label/year/cover_image —
|
||||||
|
* детальный запрос релиза не нужен.
|
||||||
|
* Токен берётся из env DISCOGS_TOKEN (личный токен из Settings → Developers).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class DiscogsService {
|
||||||
|
private readonly logger = new Logger(DiscogsService.name);
|
||||||
|
private readonly token = process.env.DISCOGS_TOKEN ?? '';
|
||||||
|
private readonly userAgent = 'radiOLA/1.0 +https://radiola.app';
|
||||||
|
|
||||||
|
// Без токена обогащение жанрами не работает (поиск требует авторизации)
|
||||||
|
get enabled(): boolean {
|
||||||
|
return this.token.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async lookup(artist: string, song: string): Promise<DiscogsResult | null> {
|
||||||
|
if (!this.enabled) return null;
|
||||||
|
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
artist,
|
||||||
|
track: song,
|
||||||
|
type: 'release',
|
||||||
|
per_page: '5',
|
||||||
|
token: this.token,
|
||||||
|
});
|
||||||
|
const url = `https://api.discogs.com/database/search?${params.toString()}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: { 'User-Agent': this.userAgent, Accept: 'application/json' },
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
this.logger.debug(`Discogs ${res.status} для "${artist} — ${song}"`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as { results?: DiscogsSearchItem[] };
|
||||||
|
const item = data.results?.[0];
|
||||||
|
if (!item) return null;
|
||||||
|
|
||||||
|
const cover =
|
||||||
|
item.cover_image && !item.cover_image.includes('spacer.gif')
|
||||||
|
? item.cover_image
|
||||||
|
: item.thumb && !item.thumb.includes('spacer.gif')
|
||||||
|
? item.thumb
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const yearNum =
|
||||||
|
item.year != null ? parseInt(String(item.year), 10) || null : null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
discogsId: typeof item.id === 'number' ? item.id : null,
|
||||||
|
genre: item.genre?.[0] ?? null,
|
||||||
|
styles: Array.isArray(item.style) ? item.style.slice(0, 6) : [],
|
||||||
|
label: item.label?.[0] ?? null,
|
||||||
|
year: yearNum,
|
||||||
|
coverImageUrl: cover,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/enrich/enrich.module.ts
Normal file
10
src/enrich/enrich.module.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { DiscogsService } from './discogs.service';
|
||||||
|
import { CoverStorageService } from './cover-storage.service';
|
||||||
|
import { EnrichmentService } from './enrichment.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [DiscogsService, CoverStorageService, EnrichmentService],
|
||||||
|
exports: [EnrichmentService],
|
||||||
|
})
|
||||||
|
export class EnrichModule {}
|
||||||
122
src/enrich/enrichment.service.ts
Normal file
122
src/enrich/enrichment.service.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { DiscogsService } from './discogs.service';
|
||||||
|
import { CoverStorageService } from './cover-storage.service';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Оркестратор обогащения трека: при первом появлении трека подтягиваем
|
||||||
|
* жанр/стиль/лейбл/год из Discogs и сохраняем обложку в едином формате (WebP)
|
||||||
|
* у себя. Дальше пользователю отдаём только из своей БД — внешние сервисы
|
||||||
|
* в рантайме не дёргаем.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class EnrichmentService {
|
||||||
|
private readonly logger = new Logger(EnrichmentService.name);
|
||||||
|
|
||||||
|
// Очередь обогащения с троттлингом (Discogs ~60 запросов/мин с токеном)
|
||||||
|
private readonly queue: string[] = [];
|
||||||
|
private running = false;
|
||||||
|
private readonly throttleMs = 1200;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly discogs: DiscogsService,
|
||||||
|
private readonly covers: CoverStorageService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// Поставить трек в очередь (fire-and-forget из recordPlay)
|
||||||
|
enqueue(trackId: string): void {
|
||||||
|
if (this.queue.includes(trackId)) return;
|
||||||
|
this.queue.push(trackId);
|
||||||
|
void this.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Периодически добираем не обогащённые треки (в т.ч. накопленные ранее)
|
||||||
|
@Cron(CronExpression.EVERY_10_MINUTES)
|
||||||
|
async backfill(): Promise<void> {
|
||||||
|
if (!this.discogs.enabled) return; // без токена смысла нет — не крутим вхолостую
|
||||||
|
const pending = await this.prisma.track.findMany({
|
||||||
|
where: { enrichStatus: 'pending' },
|
||||||
|
select: { id: true },
|
||||||
|
orderBy: { firstSeenAt: 'desc' },
|
||||||
|
take: 30,
|
||||||
|
});
|
||||||
|
for (const t of pending) this.enqueue(t.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async drain(): Promise<void> {
|
||||||
|
if (this.running) return;
|
||||||
|
this.running = true;
|
||||||
|
try {
|
||||||
|
while (this.queue.length > 0) {
|
||||||
|
const id = this.queue.shift();
|
||||||
|
if (!id) continue;
|
||||||
|
await this.enrichOne(id);
|
||||||
|
await this.sleep(this.throttleMs);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
this.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async enrichOne(trackId: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
const track = await this.prisma.track.findUnique({
|
||||||
|
where: { id: trackId },
|
||||||
|
});
|
||||||
|
if (!track || track.enrichStatus === 'done') return;
|
||||||
|
|
||||||
|
const data = this.discogs.enabled
|
||||||
|
? await this.discogs.lookup(track.artist, track.song)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
// Обложку приводим к WebP и кладём к себе (если ещё не наша)
|
||||||
|
let coverUrl = track.coverUrl;
|
||||||
|
const candidate = data?.coverImageUrl ?? track.coverUrl;
|
||||||
|
if (candidate && !this.isSelfHosted(candidate)) {
|
||||||
|
const stored = await this.covers.store(candidate, track.normKey);
|
||||||
|
if (stored) coverUrl = stored;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Без токена Discogs жанры не получим — оставляем статус pending,
|
||||||
|
// чтобы добрать позже (когда токен появится), но обложку уже сохранили.
|
||||||
|
const enriched = this.discogs.enabled;
|
||||||
|
|
||||||
|
await this.prisma.track.update({
|
||||||
|
where: { id: trackId },
|
||||||
|
data: {
|
||||||
|
genre: data?.genre ?? track.genre,
|
||||||
|
styles: data?.styles?.length ? data.styles : track.styles,
|
||||||
|
label: data?.label ?? track.label,
|
||||||
|
year: data?.year ?? track.year,
|
||||||
|
discogsId: data?.discogsId ?? track.discogsId,
|
||||||
|
coverUrl,
|
||||||
|
releaseDate:
|
||||||
|
!track.releaseDate && data?.year
|
||||||
|
? new Date(Date.UTC(data.year, 0, 1))
|
||||||
|
: track.releaseDate,
|
||||||
|
enrichStatus: enriched ? 'done' : 'pending',
|
||||||
|
enrichedAt: enriched ? new Date() : track.enrichedAt,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.debug(
|
||||||
|
`Обогащён "${track.artist} — ${track.song}": genre=${data?.genre ?? '—'}, label=${data?.label ?? '—'}`,
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
this.logger.debug(`Обогащение ${trackId} не удалось: ${(e as Error).message}`);
|
||||||
|
await this.prisma.track
|
||||||
|
.update({ where: { id: trackId }, data: { enrichStatus: 'failed' } })
|
||||||
|
.catch(() => undefined);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private isSelfHosted(url: string): boolean {
|
||||||
|
return url.includes('/covers/');
|
||||||
|
}
|
||||||
|
|
||||||
|
private sleep(ms: number): Promise<void> {
|
||||||
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
}
|
||||||
12
src/main.ts
12
src/main.ts
@@ -1,10 +1,20 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
|
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||||
|
import { join } from 'path';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create<NestExpressApplication>(AppModule);
|
||||||
|
|
||||||
|
// Раздача сохранённых обложек треков (/covers/*.webp) — свой CDN
|
||||||
|
const coversDir = process.env.COVERS_DIR || join(process.cwd(), 'data', 'covers');
|
||||||
|
app.useStaticAssets(coversDir, {
|
||||||
|
prefix: '/covers/',
|
||||||
|
maxAge: '30d',
|
||||||
|
immutable: true,
|
||||||
|
});
|
||||||
|
|
||||||
app.useGlobalPipes(
|
app.useGlobalPipes(
|
||||||
new ValidationPipe({
|
new ValidationPipe({
|
||||||
|
|||||||
Reference in New Issue
Block a user