Compare commits
71 Commits
feat/chart
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bceb8bd1 | ||
|
|
0dd52ddc3b | ||
|
|
791156f814 | ||
|
|
059ebc9c45 | ||
|
|
1616c231b7 | ||
|
|
05e3796b85 | ||
|
|
4aa3b55b5e | ||
|
|
944ec63df0 | ||
|
|
a3434ed894 | ||
|
|
924a4a0ab1 | ||
|
|
0084177d15 | ||
|
|
38e380a59f | ||
|
|
cc30422d8d | ||
|
|
c4c475544a | ||
|
|
3c4f349f71 | ||
|
|
c87a0caa5c | ||
|
|
426fd0e197 | ||
|
|
d5f30cd05d | ||
|
|
d8b6a6024f | ||
|
|
cb0e401854 | ||
|
|
fd26e4df57 | ||
|
|
a06a9b2a2b | ||
|
|
4d9fd24074 | ||
|
|
1f67e01ac8 | ||
|
|
52c8c3f69f | ||
|
|
28487a7911 | ||
|
|
59aa23ff77 | ||
|
|
ba9b4054e8 | ||
|
|
35f9a2b7cc | ||
|
|
c2e941f1c3 | ||
|
|
326bbbc0ee | ||
|
|
87cc67072c | ||
|
|
3c6dbed659 | ||
|
|
51576f7198 | ||
|
|
d6b8be124e | ||
|
|
7457498f5b | ||
|
|
e982fde730 | ||
|
|
dfdfb7e4ab | ||
|
|
94e7f46b39 | ||
|
|
ed94bd73d7 | ||
|
|
36043c32b0 | ||
|
|
5164843824 | ||
|
|
588857a73e | ||
|
|
d46020bd37 | ||
|
|
bd2cd36f1e | ||
|
|
68c919c8ba | ||
|
|
fa7742d06e | ||
|
|
338f189f33 | ||
|
|
40a9f3968f | ||
|
|
c2f638e1a1 | ||
|
|
7ff48fff29 | ||
|
|
8f0ec8a5b8 | ||
|
|
db09274060 | ||
|
|
982c42cdf2 | ||
|
|
7e6b0c8dc6 | ||
|
|
3215dd5a4e | ||
|
|
3049b1ec89 | ||
|
|
499863744f | ||
|
|
38b2aee26d | ||
|
|
dcc2f599f9 | ||
|
|
5bd7bfb923 | ||
|
|
554c1730a3 | ||
|
|
bb74d631c1 | ||
|
|
916fc301e4 | ||
|
|
96fabac7f5 | ||
|
|
f379110975 | ||
|
|
149421740f | ||
|
|
0efba7c691 | ||
|
|
24ed44e8ab | ||
|
|
df20e0fac6 | ||
|
|
e0990540b9 |
11
.env.example
11
.env.example
@@ -19,6 +19,17 @@ MAIL_FROM=radiOLA <noreply@example.com>
|
||||
FRONTEND_URL=https://radiola.app
|
||||
PORT=3000
|
||||
|
||||
# Обогащение треков (Discogs): личный токен из discogs.com → Settings → Developers
|
||||
DISCOGS_TOKEN=
|
||||
# Распознавание треков (shazam-api.com): ключ из ЛК (Authorization: Bearer)
|
||||
SHAZAM_API_KEY=
|
||||
# База API Shazam (необязательно, по умолчанию https://shazam-api.com/api)
|
||||
# SHAZAM_API_URL=https://shazam-api.com/api
|
||||
# Базовый публичный 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_USER=radiola
|
||||
POSTGRES_PASSWORD=radiola_pass
|
||||
|
||||
10
app-version.example.json
Normal file
10
app-version.example.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"android": {
|
||||
"version_name": "1.1",
|
||||
"version_code": 2,
|
||||
"download_url": "http://121.127.37.212:3000/downloads/radiola-latest.apk",
|
||||
"force_update": false,
|
||||
"sha256": "",
|
||||
"notes": "Тёмные цветовые темы, фикс таймера сна, авто-обновление."
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,22 @@ services:
|
||||
- SMTP_PASS=${SMTP_PASS}
|
||||
- MAIL_FROM=${MAIL_FROM:-noreply@radiola.app}
|
||||
- FRONTEND_URL=${FRONTEND_URL:-https://radiola.app}
|
||||
# Обогащение треков
|
||||
- DISCOGS_TOKEN=${DISCOGS_TOKEN}
|
||||
- DISCOGS_TOKEN2=${DISCOGS_TOKEN2}
|
||||
- DISCOGS_TOKEN3=${DISCOGS_TOKEN3}
|
||||
- DISCOGS_PROXY=${DISCOGS_PROXY}
|
||||
# Распознавание треков (shazam-api.com). Ключ — только в .env на сервере.
|
||||
- SHAZAM_API_KEY=${SHAZAM_API_KEY}
|
||||
- COVERS_DIR=/data/covers
|
||||
- PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://121.127.37.212:3000}
|
||||
# Авто-обновление APK: манифест версии + каталог раздачи APK
|
||||
# (хост-bind /opt/radiola/appdist смонтирован как /data/dist)
|
||||
- APP_VERSION_FILE=/data/dist/app-version.json
|
||||
- DOWNLOADS_DIR=/data/dist/downloads
|
||||
volumes:
|
||||
- covers_data:/data/covers
|
||||
- /opt/radiola/appdist:/data/dist
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
@@ -62,6 +78,7 @@ services:
|
||||
volumes:
|
||||
postgres_data:
|
||||
redis_data:
|
||||
covers_data:
|
||||
|
||||
networks:
|
||||
radiola:
|
||||
|
||||
455
package-lock.json
generated
455
package-lock.json
generated
@@ -29,7 +29,9 @@
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"undici": "^7.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/eslintrc": "^3.2.0",
|
||||
@@ -781,7 +783,6 @@
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
@@ -1022,6 +1023,367 @@
|
||||
"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": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz",
|
||||
@@ -5096,11 +5458,23 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
@@ -5113,9 +5487,18 @@
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -5462,7 +5845,6 @@
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
@@ -9739,6 +10121,45 @@
|
||||
"integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
|
||||
"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": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
@@ -9847,6 +10268,21 @@
|
||||
"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": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz",
|
||||
@@ -10903,6 +11339,15 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.27.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.27.0.tgz",
|
||||
"integrity": "sha512-+t2Z/GwkZQDtu00813aP66ygViGtPHKhhoFZpQKpKrE+9jIgES+Zw+mFNaDWOVRKiuJjuqKHzD3B1sfGg8+ZOQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20.18.1"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.16.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||
|
||||
@@ -44,7 +44,9 @@
|
||||
"passport": "^0.7.0",
|
||||
"passport-jwt": "^4.0.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"undici": "^7.27.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@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");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Покрывающий индекс под агрегацию чартов (идемпотентно — на проде уже создан CONCURRENTLY).
|
||||
CREATE INDEX IF NOT EXISTS "track_plays_window_idx" ON "track_plays" ("played_at", "track_id", "station_id");
|
||||
|
||||
-- Частичный индекс под бэкфилл обогащения (Prisma не выражает partial WHERE в схеме).
|
||||
CREATE INDEX IF NOT EXISTS "tracks_enrich_pending_idx" ON "tracks" ("first_seen_at" DESC) WHERE "enrich_status" = 'pending';
|
||||
@@ -124,12 +124,23 @@ model Track {
|
||||
coverUrl String? @map("cover_url")
|
||||
album String?
|
||||
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")
|
||||
enrichedAt DateTime? @map("enriched_at")
|
||||
|
||||
plays TrackPlay[]
|
||||
likes TrackLike[]
|
||||
|
||||
@@index([genre])
|
||||
// Частичный индекс под бэкфилл обогащения создаётся миграцией (Prisma не умеет
|
||||
// partial WHERE): tracks_enrich_pending_idx (first_seen_at DESC) WHERE enrich_status='pending'.
|
||||
@@map("tracks")
|
||||
}
|
||||
|
||||
@@ -145,6 +156,8 @@ model TrackPlay {
|
||||
@@index([trackId, playedAt])
|
||||
@@index([playedAt])
|
||||
@@index([stationId])
|
||||
// Покрывающий индекс под агрегацию чартов (WHERE played_at>=X → GROUP BY track_id, COUNT DISTINCT station_id)
|
||||
@@index([playedAt, trackId, stationId], map: "track_plays_window_idx")
|
||||
@@map("track_plays")
|
||||
}
|
||||
|
||||
|
||||
11422
prisma/stations.json
Normal file
11422
prisma/stations.json
Normal file
File diff suppressed because it is too large
Load Diff
190
scripts/probe-qualities.mjs
Normal file
190
scripts/probe-qualities.mjs
Normal file
@@ -0,0 +1,190 @@
|
||||
// Пробер качества потоков: обогащает app/src/main/assets/stations.json полем `qualities`.
|
||||
//
|
||||
// Идея: у многих станций маунт потока кодирует битрейт в конце имени
|
||||
// (rr_main96.aacp, dfm32.aacp, live128.aac, pop256k, Chan_8_192.mp3).
|
||||
// Подставляем соседние битрейты из белого списка, проверяем живость потока
|
||||
// (только заголовки: статус 200/206 + content-type audio/*), и записываем
|
||||
// список реально работающих качеств. Пропускаем HLS (emgsound, *.m3u8),
|
||||
// Love Radio (n340.com — UID-привязка) и станции без распознанного битрейта.
|
||||
//
|
||||
// Запуск: node backend/scripts/probe-qualities.mjs [--dry]
|
||||
|
||||
import { readFileSync, writeFileSync } from 'node:fs';
|
||||
import http from 'node:http';
|
||||
import https from 'node:https';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { dirname, resolve } from 'node:path';
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const STATIONS_PATH = resolve(__dirname, '../../app/src/main/assets/stations.json');
|
||||
const DRY = process.argv.includes('--dry');
|
||||
|
||||
// Стандартные битрейты Icecast/SHOUTcast (kbps). От высокого к низкому.
|
||||
const BITRATES = [320, 256, 192, 160, 128, 112, 96, 64, 48, 32];
|
||||
const CONCURRENCY = 24;
|
||||
const TIMEOUT_MS = 7000;
|
||||
|
||||
// Хосты/форматы, которые НЕ переключаем по битрейту:
|
||||
// - emgsound.ru / *.m3u8 — HLS, адаптивный сам по себе
|
||||
// - n340.com — Love Radio, поток привязан к сессионному UID
|
||||
const SKIP_HOST = (host) =>
|
||||
host.includes('emgsound.ru') || host.includes('n340.com');
|
||||
|
||||
/** Разобрать URL потока и выделить «битрейтный» хвост маунта.
|
||||
* Возвращает {build(bitrate)->url, currentBitrate, type} или null. */
|
||||
function parseStream(streamUrl) {
|
||||
let u;
|
||||
try { u = new URL(streamUrl); } catch { return null; }
|
||||
if (!/^https?:$/.test(u.protocol)) return null;
|
||||
if (SKIP_HOST(u.hostname)) return null;
|
||||
|
||||
const path = u.pathname;
|
||||
if (/\.(m3u8|m3u|pls)$/i.test(path)) return null; // плейлисты/HLS — пропуск
|
||||
|
||||
// Последний сегмент пути — имя маунта (rr_main96.aacp, live128.aac, pop256k)
|
||||
const lastSlash = path.lastIndexOf('/');
|
||||
const prefixPath = path.slice(0, lastSlash + 1);
|
||||
const seg = path.slice(lastSlash + 1);
|
||||
|
||||
// Отделяем расширение
|
||||
const extMatch = seg.match(/\.(aacp|aac|mp3|ogg)$/i);
|
||||
const ext = extMatch ? extMatch[0] : '';
|
||||
const core = ext ? seg.slice(0, -ext.length) : seg;
|
||||
|
||||
// Хвостовая группа цифр + возможный нецифровой суффикс (pop256[k], ..._192[kbps])
|
||||
const m = core.match(/(\d+)(\D*)$/);
|
||||
if (!m) return null;
|
||||
const digitRun = m[1];
|
||||
const tailLetters = m[2]; // напр. "k", "kbps"
|
||||
const head = core.slice(0, core.length - digitRun.length - tailLetters.length);
|
||||
|
||||
// Битрейт = самый длинный элемент белого списка, являющийся суффиксом digitRun
|
||||
// (отсекает приклеенные к бренду цифры: studio2196 → 96, dancegold9096 → 96)
|
||||
let bitrate = null;
|
||||
for (const b of BITRATES) {
|
||||
const bs = String(b);
|
||||
if (digitRun.endsWith(bs) && (bitrate === null || bs.length > String(bitrate).length)) {
|
||||
bitrate = b;
|
||||
}
|
||||
}
|
||||
if (bitrate === null) return null;
|
||||
|
||||
const brandDigits = digitRun.slice(0, digitRun.length - String(bitrate).length);
|
||||
|
||||
const type = /mp3/i.test(ext) ? 'mp3' : /aac/i.test(ext) ? 'aac' : null;
|
||||
const build = (b) =>
|
||||
`${u.protocol}//${u.host}${prefixPath}${head}${brandDigits}${b}${tailLetters}${ext}${u.search}`;
|
||||
|
||||
return { build, currentBitrate: bitrate, type, origin: streamUrl };
|
||||
}
|
||||
|
||||
/** Жив ли поток: пришли заголовки 200/206 с аудийным content-type. */
|
||||
function checkAlive(url) {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (val) => { if (!done) { done = true; resolve(val); } };
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
let req;
|
||||
try {
|
||||
req = lib.get(url, {
|
||||
timeout: TIMEOUT_MS,
|
||||
headers: { 'Icy-MetaData': '1', 'User-Agent': 'radiOLA-probe/1.0' },
|
||||
}, (res) => {
|
||||
const ct = String(res.headers['content-type'] || '').toLowerCase();
|
||||
const ok = (res.statusCode === 200 || res.statusCode === 206) &&
|
||||
(ct.startsWith('audio') || ct.includes('aac') || ct.includes('mpeg') || ct.includes('ogg'));
|
||||
res.destroy();
|
||||
req.destroy();
|
||||
finish(ok);
|
||||
});
|
||||
} catch { return finish(false); }
|
||||
req.on('error', () => finish(false));
|
||||
req.on('timeout', () => { req.destroy(); finish(false); });
|
||||
});
|
||||
}
|
||||
|
||||
async function pool(items, worker, concurrency) {
|
||||
const results = new Array(items.length);
|
||||
let idx = 0;
|
||||
const runners = Array.from({ length: concurrency }, async () => {
|
||||
while (idx < items.length) {
|
||||
const i = idx++;
|
||||
results[i] = await worker(items[i], i);
|
||||
}
|
||||
});
|
||||
await Promise.all(runners);
|
||||
return results;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const raw = readFileSync(STATIONS_PATH, 'utf-8');
|
||||
const data = JSON.parse(raw);
|
||||
const stations = data.stations.filter(
|
||||
(s) => s.enabled && !s.notWorked && s.stream,
|
||||
);
|
||||
|
||||
console.log(`Рабочих станций: ${stations.length}`);
|
||||
|
||||
let multi = 0, single = 0, skipped = 0;
|
||||
const probedSlots = [];
|
||||
|
||||
// Собираем все (станция × кандидат) для проверки
|
||||
const tasks = [];
|
||||
for (const s of stations) {
|
||||
const parsed = parseStream(s.stream);
|
||||
if (!parsed) { skipped++; s.__skip = true; continue; }
|
||||
s.__parsed = parsed;
|
||||
const seen = new Set();
|
||||
for (const b of BITRATES) {
|
||||
const url = parsed.build(b);
|
||||
if (seen.has(b)) continue;
|
||||
seen.add(b);
|
||||
tasks.push({ s, b, url, type: parsed.type });
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`Проб кандидатов: ${tasks.length} (пропущено станций: ${skipped})`);
|
||||
|
||||
const alive = await pool(tasks, async (t) => (await checkAlive(t.url)) ? t : null, CONCURRENCY);
|
||||
|
||||
// Группируем живые качества по станции
|
||||
const byStation = new Map();
|
||||
for (const t of alive) {
|
||||
if (!t) continue;
|
||||
if (!byStation.has(t.s.id)) byStation.set(t.s.id, []);
|
||||
byStation.get(t.s.id).push({ bitrate: t.b, url: t.url, type: t.type || 'aac' });
|
||||
}
|
||||
|
||||
for (const s of stations) {
|
||||
delete s.__parsed;
|
||||
delete s.__skip;
|
||||
const list = (byStation.get(s.id) || []).sort((a, b) => b.bitrate - a.bitrate);
|
||||
if (list.length >= 2) {
|
||||
s.qualities = list;
|
||||
multi++;
|
||||
} else {
|
||||
// Один (или ноль — поток мог не ответить на пробу) → без переключателя
|
||||
if (s.qualities) delete s.qualities;
|
||||
single++;
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`С переключателем (>=2 качества): ${multi}`);
|
||||
console.log(`Одно качество / без вариантов: ${single}`);
|
||||
|
||||
// Топ распределения числа качеств
|
||||
const dist = {};
|
||||
for (const [, list] of byStation) {
|
||||
if (list.length >= 2) dist[list.length] = (dist[list.length] || 0) + 1;
|
||||
}
|
||||
console.log('Распределение (кол-во качеств → станций):', dist);
|
||||
|
||||
if (DRY) {
|
||||
console.log('--dry: файл не изменён');
|
||||
return;
|
||||
}
|
||||
writeFileSync(STATIONS_PATH, JSON.stringify(data, null, 2) + '\n', 'utf-8');
|
||||
console.log(`Записано: ${STATIONS_PATH}`);
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1); });
|
||||
49
src/app-version/app-version.controller.ts
Normal file
49
src/app-version/app-version.controller.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Controller, Get, Logger } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
interface PlatformVersion {
|
||||
version_name: string;
|
||||
version_code: number;
|
||||
download_url: string;
|
||||
force_update: boolean;
|
||||
sha256?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Манифест последней версии приложения для авто-обновления (как в nkVPN).
|
||||
* Читается с диска при каждом запросе, поэтому релиз = заменить APK в /downloads
|
||||
* и отредактировать data/app-version.json — без пересборки бэкенда.
|
||||
* Путь к файлу — env APP_VERSION_FILE, иначе data/app-version.json.
|
||||
*/
|
||||
@ApiTags('app-version')
|
||||
@Controller('app-version')
|
||||
export class AppVersionController {
|
||||
private readonly logger = new Logger(AppVersionController.name);
|
||||
private readonly file =
|
||||
process.env.APP_VERSION_FILE ||
|
||||
path.join(process.cwd(), 'data', 'app-version.json');
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Манифест последней версии приложения' })
|
||||
getVersion(): { android: PlatformVersion } {
|
||||
try {
|
||||
const raw = fs.readFileSync(this.file, 'utf-8');
|
||||
return JSON.parse(raw) as { android: PlatformVersion };
|
||||
} catch (e) {
|
||||
this.logger.warn(`app-version.json недоступен: ${(e as Error).message}`);
|
||||
// Безопасный фолбэк: version_code 0 ⇒ установленное приложение (code ≥ 1)
|
||||
// никогда не увидит «обновление», т.е. при сбое файла апдейт не навязываем.
|
||||
return {
|
||||
android: {
|
||||
version_name: '0',
|
||||
version_code: 0,
|
||||
download_url: '',
|
||||
force_update: false,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
7
src/app-version/app-version.module.ts
Normal file
7
src/app-version/app-version.module.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AppVersionController } from './app-version.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [AppVersionController],
|
||||
})
|
||||
export class AppVersionModule {}
|
||||
@@ -8,6 +8,9 @@ import { UsersModule } from './users/users.module';
|
||||
import { NowPlayingModule } from './now-playing/now-playing.module';
|
||||
import { HealthCheckModule } from './health-check/health-check.module';
|
||||
import { ChartsModule } from './charts/charts.module';
|
||||
import { AppVersionModule } from './app-version/app-version.module';
|
||||
import { ShazamModule } from './shazam/shazam.module';
|
||||
import { PrivacyModule } from './privacy/privacy.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -20,6 +23,9 @@ import { ChartsModule } from './charts/charts.module';
|
||||
NowPlayingModule,
|
||||
HealthCheckModule,
|
||||
ChartsModule,
|
||||
AppVersionModule,
|
||||
ShazamModule,
|
||||
PrivacyModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
|
||||
@@ -26,13 +26,21 @@ export class ChartsController {
|
||||
async getTopTracks(
|
||||
@Query('period') period: string = 'week',
|
||||
@Query('limit') limit: string = '100',
|
||||
@Query('genre') genre?: string,
|
||||
) {
|
||||
const validPeriod: ChartPeriod =
|
||||
period === 'day' || period === 'week' || period === 'month' || period === 'all'
|
||||
? (period as ChartPeriod)
|
||||
: 'week';
|
||||
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')
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ChartsController } from './charts.controller';
|
||||
import { ChartsService } from './charts.service';
|
||||
import { MaintenanceService } from './maintenance.service';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { EnrichModule } from '../enrich/enrich.module';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule],
|
||||
imports: [AuthModule, EnrichModule],
|
||||
controllers: [ChartsController],
|
||||
providers: [ChartsService],
|
||||
providers: [ChartsService, MaintenanceService],
|
||||
exports: [ChartsService],
|
||||
})
|
||||
export class ChartsModule {}
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { EnrichmentService } from '../enrich/enrichment.service';
|
||||
import { NON_MUSIC_GENRES } from '../common/station-classification';
|
||||
|
||||
// Жанры, исключённые из чарта: разговорные/шуточные/без названий треков.
|
||||
// Их «треки» — это названия передач/реприз/спектаклей, не музыка.
|
||||
// Единый список — в station-classification.ts (там же используется для флага
|
||||
// `musical` станции, по которому клиент показывает кнопку распознавания).
|
||||
const EXCLUDED_CHART_GENRES: string[] = [...NON_MUSIC_GENRES];
|
||||
|
||||
// Период чарта
|
||||
export type ChartPeriod = 'day' | 'week' | 'month' | 'all';
|
||||
@@ -13,6 +21,10 @@ export interface ChartEntry {
|
||||
artist: string;
|
||||
song: string;
|
||||
coverUrl: string | null;
|
||||
genre: string | null;
|
||||
styles: string[];
|
||||
label: string | null;
|
||||
year: number | null;
|
||||
plays: number;
|
||||
stationsCount: number;
|
||||
likes: number;
|
||||
@@ -53,7 +65,10 @@ interface RawStationRow {
|
||||
export class ChartsService {
|
||||
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 {
|
||||
@@ -85,31 +100,108 @@ export class ChartsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Кэш названий станций — чтобы отсеивать джинглы/шоу из чарта
|
||||
private stationNames = new Set<string>();
|
||||
private stationNamesAt = 0;
|
||||
|
||||
private async getStationNames(): Promise<Set<string>> {
|
||||
const now = Date.now();
|
||||
if (now - this.stationNamesAt > 10 * 60 * 1000 || this.stationNames.size === 0) {
|
||||
const rows = await this.prisma.station.findMany({ select: { name: true } });
|
||||
this.stationNames = new Set(
|
||||
rows.map((r) => r.name.trim().toLowerCase()).filter((n) => n.length > 0),
|
||||
);
|
||||
this.stationNamesAt = now;
|
||||
}
|
||||
return this.stationNames;
|
||||
}
|
||||
|
||||
// Кэш id станций исключённых жанров (разговорные/шуточные — не в чарт)
|
||||
private excludedStationIds = new Set<string>();
|
||||
private excludedStationIdsAt = 0;
|
||||
|
||||
private async getExcludedStationIds(): Promise<Set<string>> {
|
||||
const now = Date.now();
|
||||
if (now - this.excludedStationIdsAt > 10 * 60 * 1000 || this.excludedStationIds.size === 0) {
|
||||
const rows = await this.prisma.station.findMany({
|
||||
where: { genre: { in: EXCLUDED_CHART_GENRES } },
|
||||
select: { id: true },
|
||||
});
|
||||
this.excludedStationIds = new Set(rows.map((r) => r.id));
|
||||
this.excludedStationIdsAt = now;
|
||||
}
|
||||
return this.excludedStationIds;
|
||||
}
|
||||
|
||||
// Мусорный «трек»: хекс-плейсхолдер, URL, числовой код, или artist == song
|
||||
// (целый тайтл без нормального разбиения на исполнителя/название).
|
||||
private isJunkTrack(artist: string, song: string): boolean {
|
||||
const a = artist.trim();
|
||||
const s = song.trim();
|
||||
if (a.toLowerCase() === s.toLowerCase()) return true;
|
||||
// Известные заглушки эфира / джинглы-свиперы.
|
||||
if (a.toLowerCase() === 'online' && s.toLowerCase() === 'radio') return true;
|
||||
if (/^fx$/i.test(a) && /^fx-?\d+$/i.test(s)) return true;
|
||||
const hex = (v: string) => /^[0-9a-f]{6,}$/i.test(v) && /[0-9]/.test(v);
|
||||
const code = (v: string) => /^[0-9]+-[0-9]+/.test(v);
|
||||
const url = (v: string) => /\.(ru|fm|by|com|ua)$/i.test(v) || /^https?:/i.test(v);
|
||||
return [a, s].some((v) => hex(v) || code(v) || url(v));
|
||||
}
|
||||
|
||||
// Записывает факт смены трека на станции (вызывается из NowPlayingService)
|
||||
async recordPlay(params: RecordPlayParams): Promise<void> {
|
||||
try {
|
||||
const { artist, song, coverUrl, stationDbId } = params;
|
||||
const artist = (params.artist ?? '').trim();
|
||||
const song = (params.song ?? '').trim();
|
||||
const { coverUrl, stationDbId } = params;
|
||||
|
||||
// Отсекаем не-музыкальные записи: пустые поля, либо когда артист/песня
|
||||
// совпадает с названием станции (это джинглы, шоу, сетевые промо).
|
||||
if (!artist || !song) return;
|
||||
// Разговорные/шуточные станции — не в чарт.
|
||||
const excluded = await this.getExcludedStationIds();
|
||||
if (excluded.has(stationDbId)) return;
|
||||
// Мусорные названия (хекс/URL/код/artist==song) — не в чарт.
|
||||
if (this.isJunkTrack(artist, song)) return;
|
||||
const stationNames = await this.getStationNames();
|
||||
if (
|
||||
stationNames.has(artist.toLowerCase()) ||
|
||||
stationNames.has(song.toLowerCase())
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Нормализованный ключ: нижний регистр, схлопнуть пробелы
|
||||
const normKey =
|
||||
artist.trim().toLowerCase().replace(/\s+/g, ' ') +
|
||||
artist.toLowerCase().replace(/\s+/g, ' ') +
|
||||
'|' +
|
||||
song.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
song.toLowerCase().replace(/\s+/g, ' ');
|
||||
|
||||
// Не перетираем уже сохранённую (self-hosted) обложку сырым Record-URL
|
||||
const track = await this.prisma.track.upsert({
|
||||
where: { normKey },
|
||||
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({
|
||||
data: { trackId: track.id, stationId: stationDbId },
|
||||
});
|
||||
|
||||
this.logger.debug(`Записан трек: "${artist} — ${song}"`);
|
||||
|
||||
// Асинхронное обогащение нового трека (fire-and-forget)
|
||||
if (!track.enrichedAt) {
|
||||
void this.enrichTrack(track.id, artist, song);
|
||||
// Асинхронное обогащение (iTunes/Discogs + WebP-обложка, fire-and-forget).
|
||||
// priority — трек играет прямо сейчас, обложка нужна быстро.
|
||||
if (track.enrichStatus !== 'done') {
|
||||
this.enrichment.enqueue(track.id, { priority: true });
|
||||
}
|
||||
} catch (error) {
|
||||
// Ошибка сбора не должна ронять поллер
|
||||
@@ -117,72 +209,58 @@ export class ChartsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Обогащение трека через MusicBrainz (fire-and-forget, best-effort)
|
||||
private async enrichTrack(
|
||||
trackId: string,
|
||||
artist: string,
|
||||
song: string,
|
||||
): Promise<void> {
|
||||
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;
|
||||
// In-memory TTL-кэш чартов: чарт меняется медленно, а агрегации тяжёлые.
|
||||
// Один пересчёт на (period, genre, limit) раз в CHART_TTL.
|
||||
private chartCache = new Map<string, { at: number; data: { items: ChartEntry[] } }>();
|
||||
private static readonly CHART_TTL = 90_000;
|
||||
|
||||
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,
|
||||
genre?: string,
|
||||
): Promise<{ items: ChartEntry[] }> {
|
||||
const cacheKey = `${period}|${genre ?? ''}|${limit}`;
|
||||
const cached = this.chartCache.get(cacheKey);
|
||||
if (cached && Date.now() - cached.at < ChartsService.CHART_TTL) {
|
||||
return cached.data;
|
||||
}
|
||||
}
|
||||
|
||||
// Чарт треков за период
|
||||
async getTopTracks(period: ChartPeriod, limit: number): Promise<{ items: ChartEntry[] }> {
|
||||
const since = this.periodStart(period);
|
||||
const duration = this.periodDuration(period);
|
||||
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) {
|
||||
const empty = { items: [] };
|
||||
this.chartCache.set(cacheKey, { at: Date.now(), data: empty });
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
// Топ текущего периода: группировка по trackId
|
||||
const currentGroups = await this.prisma.trackPlay.groupBy({
|
||||
by: ['trackId'],
|
||||
where: { playedAt: { gte: since } },
|
||||
where: {
|
||||
playedAt: { gte: since },
|
||||
...(genreTrackIds ? { trackId: { in: genreTrackIds } } : {}),
|
||||
},
|
||||
_count: { id: true },
|
||||
orderBy: { _count: { id: 'desc' } },
|
||||
take: limit,
|
||||
});
|
||||
|
||||
if (currentGroups.length === 0) {
|
||||
return { items: [] };
|
||||
const empty = { items: [] };
|
||||
this.chartCache.set(cacheKey, { at: Date.now(), data: empty });
|
||||
return empty;
|
||||
}
|
||||
|
||||
const trackIds = currentGroups.map((g) => g.trackId);
|
||||
@@ -235,7 +313,16 @@ export class ChartsService {
|
||||
// Получаем данные треков
|
||||
const tracks = await this.prisma.track.findMany({
|
||||
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]));
|
||||
|
||||
@@ -260,6 +347,10 @@ export class ChartsService {
|
||||
artist: track?.artist ?? '',
|
||||
song: track?.song ?? '',
|
||||
coverUrl: track?.coverUrl ?? null,
|
||||
genre: track?.genre ?? null,
|
||||
styles: track?.styles ?? [],
|
||||
label: track?.label ?? null,
|
||||
year: track?.year ?? null,
|
||||
plays: g._count.id,
|
||||
stationsCount: stationsMap.get(g.trackId) ?? 0,
|
||||
likes: likesMap.get(g.trackId) ?? 0,
|
||||
@@ -268,7 +359,9 @@ export class ChartsService {
|
||||
};
|
||||
});
|
||||
|
||||
return { items };
|
||||
const result = { items };
|
||||
this.chartCache.set(cacheKey, { at: Date.now(), data: result });
|
||||
return result;
|
||||
}
|
||||
|
||||
// Детальная страница трека
|
||||
@@ -281,6 +374,10 @@ export class ChartsService {
|
||||
song: string;
|
||||
album: string | null;
|
||||
coverUrl: string | null;
|
||||
genre: string | null;
|
||||
styles: string[];
|
||||
label: string | null;
|
||||
year: number | null;
|
||||
releaseDate: string | null;
|
||||
firstSeen: string | null;
|
||||
totalPlays: number;
|
||||
@@ -373,6 +470,10 @@ export class ChartsService {
|
||||
song: track.song,
|
||||
album: track.album ?? 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,
|
||||
firstSeen: track.firstSeenAt ? track.firstSeenAt.toISOString() : null,
|
||||
totalPlays: totalPlaysResult,
|
||||
@@ -408,4 +509,15 @@ export class ChartsService {
|
||||
});
|
||||
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) };
|
||||
}
|
||||
}
|
||||
|
||||
87
src/charts/maintenance.service.ts
Normal file
87
src/charts/maintenance.service.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
|
||||
/**
|
||||
* Фоновое обслуживание данных чартов. track_plays растёт ~100k строк/сутки
|
||||
* (≈700 станций × смены треков), поэтому без ретенции таблица разрастается
|
||||
* безгранично. Раз в сутки:
|
||||
* 1) удаляем проигрывания старше RETENTION_DAYS (чанками, чтобы не лочить таблицу);
|
||||
* 2) подчищаем «осиротевшие» треки (без проигрываний/лайков/обложки, старые).
|
||||
*
|
||||
* ⚠️ RETENTION_DAYS ограничивает и чарт period='all' (он становится «за последние
|
||||
* N дней») и totalPlays трека на детальной странице. 180 дней — компромисс между
|
||||
* осмысленной историей и размером таблицы (~18M строк потолок). Меняется здесь.
|
||||
*/
|
||||
@Injectable()
|
||||
export class MaintenanceService {
|
||||
private readonly logger = new Logger(MaintenanceService.name);
|
||||
|
||||
// Сколько дней храним факты проигрывания (см. предупреждение выше).
|
||||
private static readonly RETENTION_DAYS = 180;
|
||||
// Возраст «осиротевшего» трека для удаления.
|
||||
private static readonly ORPHAN_DAYS = 30;
|
||||
// Размер чанка удаления (чтобы один DELETE не держал долгий лок).
|
||||
private static readonly CHUNK = 20_000;
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
@Cron(CronExpression.EVERY_DAY_AT_4AM)
|
||||
async runMaintenance(): Promise<void> {
|
||||
await this.pruneOldPlays();
|
||||
await this.pruneOrphanTracks();
|
||||
}
|
||||
|
||||
/** Удаляет проигрывания старше RETENTION_DAYS чанками. */
|
||||
private async pruneOldPlays(): Promise<void> {
|
||||
const cutoff = new Date(
|
||||
Date.now() - MaintenanceService.RETENTION_DAYS * 86_400_000,
|
||||
);
|
||||
try {
|
||||
let total = 0;
|
||||
let removed: number;
|
||||
do {
|
||||
// ctid + LIMIT — удаляем порциями, не лочим всю таблицу разом.
|
||||
removed = await this.prisma.$executeRaw`
|
||||
DELETE FROM track_plays
|
||||
WHERE ctid IN (
|
||||
SELECT ctid FROM track_plays
|
||||
WHERE played_at < ${cutoff}
|
||||
LIMIT ${MaintenanceService.CHUNK}
|
||||
)`;
|
||||
total += removed;
|
||||
} while (removed > 0);
|
||||
if (total > 0) {
|
||||
this.logger.log(
|
||||
`Ретенция: удалено ${total} track_plays старше ${MaintenanceService.RETENTION_DAYS}д`,
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`Ретенция track_plays упала: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Удаляет треки без следов использования: нет проигрываний, нет лайков, нет
|
||||
* обложки и созданы давно (artefact-строки, которые иначе копятся навсегда).
|
||||
* Каскад снимает зависимые plays (их и так нет).
|
||||
*/
|
||||
private async pruneOrphanTracks(): Promise<void> {
|
||||
const cutoff = new Date(
|
||||
Date.now() - MaintenanceService.ORPHAN_DAYS * 86_400_000,
|
||||
);
|
||||
try {
|
||||
const removed = await this.prisma.$executeRaw`
|
||||
DELETE FROM tracks t
|
||||
WHERE t.first_seen_at < ${cutoff}
|
||||
AND t.cover_url IS NULL
|
||||
AND NOT EXISTS (SELECT 1 FROM track_plays p WHERE p.track_id = t.id)
|
||||
AND NOT EXISTS (SELECT 1 FROM track_likes l WHERE l.track_id = t.id)`;
|
||||
if (removed > 0) {
|
||||
this.logger.log(`Прун сирот: удалено ${removed} tracks`);
|
||||
}
|
||||
} catch (e) {
|
||||
this.logger.error(`Прун сирот упал: ${(e as Error).message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
28
src/common/station-classification.ts
Normal file
28
src/common/station-classification.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Единый признак «музыкальная ли станция». Используется в двух местах:
|
||||
* • ChartsService — НЕ засчитывать «треки» разговорных станций в чарт;
|
||||
* • StationsService — выставить флаг `musical` в ответе /stations, по которому
|
||||
* клиент показывает кнопку «Распознать трек» (Shazam) только для музыки.
|
||||
*
|
||||
* Жанры разговорных/юмористических/новостных станций: их «треки» — это названия
|
||||
* передач/реприз/спектаклей, не музыка, распознавать там нечего.
|
||||
*
|
||||
* ⚠️ Добавил разговорную станцию — впиши её genre сюда (одно место на весь проект).
|
||||
*/
|
||||
export const NON_MUSIC_GENRES = [
|
||||
'Станция Кассиопея',
|
||||
'Юмор ФМ',
|
||||
'Рассказы',
|
||||
'Радио Вера',
|
||||
'Comedy Radio',
|
||||
'ВГТРК',
|
||||
'Старое радио',
|
||||
] as const;
|
||||
|
||||
const NON_MUSIC_SET = new Set<string>(NON_MUSIC_GENRES);
|
||||
|
||||
/** true — на станции играет музыка (а не разговор/юмор/новости). */
|
||||
export function isMusicStation(genre?: string | null): boolean {
|
||||
if (!genre) return true; // без жанра считаем музыкальной (консервативно)
|
||||
return !NON_MUSIC_SET.has(genre.trim());
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
32
src/enrich/covers.controller.ts
Normal file
32
src/enrich/covers.controller.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { IsString, MaxLength } from 'class-validator';
|
||||
import { EnrichmentService } from './enrichment.service';
|
||||
|
||||
class SubmitCoverDto {
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
artist!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(300)
|
||||
song!: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(1000)
|
||||
artworkUrl!: string;
|
||||
}
|
||||
|
||||
@ApiTags('covers')
|
||||
@Controller('covers')
|
||||
export class CoversController {
|
||||
constructor(private readonly enrichment: EnrichmentService) {}
|
||||
|
||||
// Клиент прислал ссылку на найденную им (со своего IP) обложку iTunes.
|
||||
// Сервер скачивает её и кладёт WebP к себе; возвращает наш coverUrl.
|
||||
@Post('submit')
|
||||
@ApiOperation({ summary: 'Принять обложку, найденную клиентом' })
|
||||
async submit(@Body() dto: SubmitCoverDto) {
|
||||
return this.enrichment.submitCover(dto.artist, dto.song, dto.artworkUrl);
|
||||
}
|
||||
}
|
||||
121
src/enrich/discogs.service.ts
Normal file
121
src/enrich/discogs.service.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ProxyAgent, Agent } from 'undici';
|
||||
|
||||
// Результат обогащения из 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 userAgent = 'radiOLA/1.0 +https://radiola.app';
|
||||
|
||||
// Discogs троттлит ПО IP. Делаем несколько маршрутов с РАЗНЫМИ IP, каждый со
|
||||
// своим токеном и слотом ~54/мин → суммарно N×54/мин без 429:
|
||||
// • token1 — напрямую (RU, IPv6 по умолчанию)
|
||||
// • token2 — через DE-прокси (выход с IP DE)
|
||||
// • token3 — напрямую, но форсируем IPv4 (RU, другой IP, чем IPv6)
|
||||
private readonly minIntervalMs = 1100;
|
||||
private readonly routeList = this.buildRoutes();
|
||||
private readonly slots: number[] = this.routeList.map(() => 0);
|
||||
|
||||
private buildRoutes(): { token: string; dispatcher: unknown }[] {
|
||||
const routes: { token: string; dispatcher: unknown }[] = [];
|
||||
const t1 = process.env.DISCOGS_TOKEN ?? '';
|
||||
const t2 = process.env.DISCOGS_TOKEN2 ?? '';
|
||||
const t3 = process.env.DISCOGS_TOKEN3 ?? '';
|
||||
const proxy = process.env.DISCOGS_PROXY ?? '';
|
||||
if (t1) routes.push({ token: t1, dispatcher: undefined });
|
||||
if (t2 && proxy) routes.push({ token: t2, dispatcher: new ProxyAgent(proxy) });
|
||||
if (t3) routes.push({ token: t3, dispatcher: new Agent({ connect: { family: 4 } }) });
|
||||
return routes;
|
||||
}
|
||||
|
||||
// Без токена обогащение жанрами не работает (поиск требует авторизации)
|
||||
get enabled(): boolean {
|
||||
return this.routeList.length > 0;
|
||||
}
|
||||
|
||||
// Резервирует слот наименее загруженного маршрута, возвращает токен + dispatcher
|
||||
private async pickRoute(): Promise<{ token: string; dispatcher: unknown }> {
|
||||
let idx = 0;
|
||||
for (let i = 1; i < this.slots.length; i++) {
|
||||
if (this.slots[i] < this.slots[idx]) idx = i;
|
||||
}
|
||||
const now = Date.now();
|
||||
const start = Math.max(now, this.slots[idx]);
|
||||
this.slots[idx] = start + this.minIntervalMs;
|
||||
if (start > now) await new Promise((res) => setTimeout(res, start - now));
|
||||
return this.routeList[idx];
|
||||
}
|
||||
|
||||
async lookup(artist: string, song: string): Promise<DiscogsResult | null> {
|
||||
if (!this.enabled) return null;
|
||||
const { token, dispatcher } = await this.pickRoute();
|
||||
|
||||
const params = new URLSearchParams({
|
||||
artist,
|
||||
track: song,
|
||||
type: 'release',
|
||||
per_page: '5',
|
||||
token,
|
||||
});
|
||||
const url = `https://api.discogs.com/database/search?${params.toString()}`;
|
||||
|
||||
const init: Record<string, unknown> = {
|
||||
headers: { 'User-Agent': this.userAgent, Accept: 'application/json' },
|
||||
};
|
||||
if (dispatcher) init.dispatcher = dispatcher;
|
||||
const res = await fetch(url, init);
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
12
src/enrich/enrich.module.ts
Normal file
12
src/enrich/enrich.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { DiscogsService } from './discogs.service';
|
||||
import { CoverStorageService } from './cover-storage.service';
|
||||
import { EnrichmentService } from './enrichment.service';
|
||||
import { CoversController } from './covers.controller';
|
||||
|
||||
@Module({
|
||||
controllers: [CoversController],
|
||||
providers: [DiscogsService, CoverStorageService, EnrichmentService],
|
||||
exports: [EnrichmentService],
|
||||
})
|
||||
export class EnrichModule {}
|
||||
475
src/enrich/enrichment.service.ts
Normal file
475
src/enrich/enrichment.service.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { ProxyAgent } from 'undici';
|
||||
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/iTunes)
|
||||
private readonly queue: string[] = [];
|
||||
private running = false;
|
||||
// Discogs сам себя лимитирует (rate-limiter в DiscogsService), поэтому можно
|
||||
// выше параллельность: обложки (iTunes, без лимита) льются быстрее.
|
||||
private readonly throttleMs = 150;
|
||||
private readonly concurrency = 12;
|
||||
|
||||
// RU-IP сервера забанен Apple (429) и Deezer из РФ отдаёт пустой каталог —
|
||||
// поэтому iTunes/Deezer ходят через тот же DE-прокси, что и Discogs.
|
||||
private readonly proxyDispatcher = process.env.DISCOGS_PROXY
|
||||
? new ProxyAgent(process.env.DISCOGS_PROXY)
|
||||
: undefined;
|
||||
|
||||
// iTunes лимитирует ПО IP (~20/мин) и легко банит общий DE-IP (его делит
|
||||
// Discogs) — сериализуем запросы к iTunes с интервалом.
|
||||
private itunesGate: Promise<void> = Promise.resolve();
|
||||
private readonly itunesMinIntervalMs = 3500;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly discogs: DiscogsService,
|
||||
private readonly covers: CoverStorageService,
|
||||
) {}
|
||||
|
||||
// Поставить трек в очередь. priority — играющие сейчас треки (в начало очереди),
|
||||
// чтобы обложка успела появиться, пока трек звучит.
|
||||
enqueue(trackId: string, opts?: { priority?: boolean }): void {
|
||||
const idx = this.queue.indexOf(trackId);
|
||||
if (idx !== -1) {
|
||||
if (opts?.priority && idx > 0) {
|
||||
this.queue.splice(idx, 1);
|
||||
this.queue.unshift(trackId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (opts?.priority) this.queue.unshift(trackId);
|
||||
else this.queue.push(trackId);
|
||||
void this.drain();
|
||||
}
|
||||
|
||||
// Непрерывно добираем холодный бэклог: когда очередь почти пуста — подкидываем
|
||||
// батч pending (не-приоритетно, играющие треки всё равно идут вперёд).
|
||||
// Раз в минуту, чтобы конвейер не простаивал между всплесками now-playing.
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async backfill(): Promise<void> {
|
||||
if (!this.discogs.enabled) return; // без токена смысла нет — не крутим вхолостую
|
||||
if (this.queue.length > this.concurrency) return; // ещё есть что жевать
|
||||
const pending = await this.prisma.track.findMany({
|
||||
where: { enrichStatus: 'pending' },
|
||||
select: { id: true },
|
||||
orderBy: { firstSeenAt: 'desc' },
|
||||
take: 100,
|
||||
});
|
||||
for (const t of pending) this.enqueue(t.id);
|
||||
}
|
||||
|
||||
// Раз в минуту обеспечиваем ОБЛОЖКУ у играющих СЕЙЧАС треков — быстрый проход
|
||||
// ТОЛЬКО через iTunes (без Discogs, который лимитирован 54/мин и тормозил бы
|
||||
// обложки). Полное обогащение (жанр/стили) идёт фоном через backfill/enqueue.
|
||||
private nowPlayingRunning = false;
|
||||
|
||||
@Cron(CronExpression.EVERY_MINUTE)
|
||||
async enrichNowPlaying(): Promise<void> {
|
||||
if (this.nowPlayingRunning) return; // не накладываем проходы
|
||||
this.nowPlayingRunning = true;
|
||||
try {
|
||||
await this.runEnrichNowPlaying();
|
||||
} finally {
|
||||
this.nowPlayingRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async runEnrichNowPlaying(): Promise<void> {
|
||||
const rows = await this.prisma.nowPlaying.findMany({
|
||||
select: { artist: true, song: true },
|
||||
});
|
||||
// Треки уже созданы в ChartsService.recordPlay — не upsert'им построчно (был
|
||||
// N+1 на ~300 строк/мин), а читаем пачкой по normKey. Мусор/исключённые
|
||||
// станции трек не создавали → их и не обогащаем (это правильно).
|
||||
const normKeys = new Set<string>();
|
||||
for (const r of rows) {
|
||||
const artist = (r.artist ?? '').trim();
|
||||
const song = (r.song ?? '').trim();
|
||||
if (artist && song) normKeys.add(this.buildNormKey(artist, song));
|
||||
}
|
||||
if (normKeys.size === 0) return;
|
||||
|
||||
const tracks = await this.prisma.track.findMany({
|
||||
where: { normKey: { in: [...normKeys] } },
|
||||
select: {
|
||||
id: true,
|
||||
artist: true,
|
||||
song: true,
|
||||
normKey: true,
|
||||
coverUrl: true,
|
||||
enrichStatus: true,
|
||||
},
|
||||
});
|
||||
|
||||
const todo: { id: string; artist: string; song: string; normKey: string }[] = [];
|
||||
for (const track of tracks) {
|
||||
if (!track.coverUrl) {
|
||||
todo.push({
|
||||
id: track.id,
|
||||
artist: track.artist,
|
||||
song: track.song,
|
||||
normKey: track.normKey,
|
||||
});
|
||||
}
|
||||
// полное обогащение (жанр) — в общую очередь, если ещё не сделано
|
||||
if (track.enrichStatus !== 'done') this.enqueue(track.id);
|
||||
}
|
||||
// Быстрый cover-only проход, по 8 параллельно — чтобы успевать за сменой
|
||||
// треков по всем сетям (~120/мин)
|
||||
for (let i = 0; i < todo.length; i += 8) {
|
||||
await Promise.all(todo.slice(i, i + 8).map((t) => this.coverFast(t)));
|
||||
}
|
||||
}
|
||||
|
||||
// Только обложка — для быстрого покрытия эфира. Чтобы не множить нагрузку на
|
||||
// iTunes (лимит ~20/мин на 170+ играющих треков), делаем ОДИН запрос iTunes по
|
||||
// очищенному названию (он чаще матчит ремиксы/«(Original Mix)»), а на промахе/
|
||||
// лимите идём в Deezer (отдельный лимит, хорошее покрытие электроники).
|
||||
private async coverFast(t: {
|
||||
id: string;
|
||||
artist: string;
|
||||
song: string;
|
||||
normKey: string;
|
||||
}): Promise<void> {
|
||||
try {
|
||||
const cover = await this.fetchCover(t.artist, t.song);
|
||||
if (!cover?.coverUrl) return;
|
||||
const stored = await this.covers.store(cover.coverUrl, t.normKey);
|
||||
if (!stored) return;
|
||||
await this.prisma.track.update({
|
||||
where: { id: t.id },
|
||||
data: {
|
||||
coverUrl: stored,
|
||||
genre: cover.genre ?? undefined,
|
||||
album: cover.album ?? undefined,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// сбой — добёрём на следующем тике
|
||||
}
|
||||
}
|
||||
|
||||
/** Только обложка для быстрого now-playing-прохода — ТОЛЬКО Deezer (через
|
||||
* DE-прокси, высокий лимит, параллельно). iTunes здесь НЕ дёргаем: его жёсткий
|
||||
* троттлинг (3.5с) затыкал бы проход. Промахи Deezer добирает фоновый
|
||||
* enrichOne (там iTunes через прокси с троттлингом). */
|
||||
private async fetchCover(
|
||||
artist: string,
|
||||
song: string,
|
||||
): Promise<{ coverUrl: string | null; genre: string | null; album: string | null } | null> {
|
||||
const dz = await this.fetchDeezerCover(artist, song);
|
||||
if (dz) return { coverUrl: dz, genre: null, album: null };
|
||||
return null;
|
||||
}
|
||||
|
||||
// ===== Клиентский сабмит обложки =====
|
||||
// Клиент (со своего IP) делает iTunes-поиск (наш серверный IP забанен Apple)
|
||||
// и присылает ССЫЛКУ на арт. Сервер качает её (CDN из РФ доступен) и кладёт
|
||||
// WebP к себе. SSRF-защита: только доверенные CDN. Идемпотентно (first-write-wins).
|
||||
private static readonly COVER_HOST_ALLOW = ['mzstatic.com', 'dzcdn.net'];
|
||||
private submitInFlight = 0;
|
||||
private readonly submitMaxInFlight = 6;
|
||||
|
||||
async submitCover(
|
||||
artist: string,
|
||||
song: string,
|
||||
artworkUrl: string,
|
||||
): Promise<{ coverUrl: string | null }> {
|
||||
const a = (artist ?? '').trim();
|
||||
const s = (song ?? '').trim();
|
||||
if (!a || !s || !artworkUrl) return { coverUrl: null };
|
||||
|
||||
let host = '';
|
||||
try {
|
||||
host = new URL(artworkUrl).hostname.toLowerCase();
|
||||
} catch {
|
||||
return { coverUrl: null };
|
||||
}
|
||||
const allowed = EnrichmentService.COVER_HOST_ALLOW.some(
|
||||
(h) => host === h || host.endsWith('.' + h),
|
||||
);
|
||||
if (!allowed) return { coverUrl: null };
|
||||
|
||||
const normKey = this.buildNormKey(a, s);
|
||||
// Уже есть — отдаём существующую (не качаем повторно, защита от перезаписи).
|
||||
const existing = await this.prisma.track.findUnique({
|
||||
where: { normKey },
|
||||
select: { coverUrl: true },
|
||||
});
|
||||
if (existing?.coverUrl) return { coverUrl: existing.coverUrl };
|
||||
|
||||
if (this.submitInFlight >= this.submitMaxInFlight) return { coverUrl: null };
|
||||
this.submitInFlight++;
|
||||
try {
|
||||
const stored = await this.covers.store(artworkUrl, normKey);
|
||||
if (!stored) return { coverUrl: null };
|
||||
await this.prisma.track.upsert({
|
||||
where: { normKey },
|
||||
create: { normKey, artist: a, song: s, coverUrl: stored },
|
||||
update: { coverUrl: stored },
|
||||
});
|
||||
return { coverUrl: stored };
|
||||
} finally {
|
||||
this.submitInFlight--;
|
||||
}
|
||||
}
|
||||
|
||||
// Нормализованный ключ — как в ChartsService.recordPlay
|
||||
private buildNormKey(artist: string, song: string): string {
|
||||
return (
|
||||
artist.toLowerCase().replace(/\s+/g, ' ') +
|
||||
'|' +
|
||||
song.toLowerCase().replace(/\s+/g, ' ')
|
||||
);
|
||||
}
|
||||
|
||||
private async drain(): Promise<void> {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
try {
|
||||
while (this.queue.length > 0) {
|
||||
const batch = this.queue.splice(0, this.concurrency);
|
||||
await Promise.all(batch.map((id) => 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;
|
||||
|
||||
// iTunes: обложка (покрытие почти как у Record) + альбом/год/жанр как
|
||||
// фолбэк к Discogs. Гибрид: стили и лейбл — только Discogs.
|
||||
// Отличаем сбой запроса (ретраить) от чистого «не найдено» (done).
|
||||
let itunes: Awaited<ReturnType<typeof this.fetchItunes>> = null;
|
||||
let itunesFailed = false;
|
||||
try {
|
||||
itunes = await this.fetchItunes(track.artist, track.song);
|
||||
} catch {
|
||||
itunesFailed = true;
|
||||
}
|
||||
|
||||
// Обложка → WebP к себе (если ещё не наша)
|
||||
let coverUrl = track.coverUrl;
|
||||
const candidate = itunes?.coverUrl ?? data?.coverImageUrl ?? track.coverUrl;
|
||||
if (candidate && !this.isSelfHosted(candidate)) {
|
||||
const stored = await this.covers.store(candidate, track.normKey);
|
||||
if (stored) coverUrl = stored;
|
||||
}
|
||||
|
||||
// Жанр: Discogs приоритетнее (тонкий), затем iTunes (грубый фолбэк)
|
||||
const genre = data?.genre ?? itunes?.genre ?? track.genre;
|
||||
const year = data?.year ?? itunes?.year ?? track.year;
|
||||
const album = track.album ?? itunes?.album ?? null;
|
||||
const releaseDate =
|
||||
track.releaseDate ??
|
||||
itunes?.releaseDate ??
|
||||
(data?.year ? new Date(Date.UTC(data.year, 0, 1)) : null);
|
||||
|
||||
// Помечаем done, если обогатились. НЕ помечаем (оставляем pending для
|
||||
// ретрая), если: нет токена Discogs, ИЛИ запрос к iTunes упал И обложку
|
||||
// так и не получили (транзиентный сбой — промах не должен застывать).
|
||||
const enriched = this.discogs.enabled && !(itunesFailed && !coverUrl);
|
||||
|
||||
await this.prisma.track.update({
|
||||
where: { id: trackId },
|
||||
data: {
|
||||
genre,
|
||||
styles: data?.styles?.length ? data.styles : track.styles,
|
||||
label: data?.label ?? track.label,
|
||||
year,
|
||||
album,
|
||||
discogsId: data?.discogsId ?? track.discogsId,
|
||||
coverUrl,
|
||||
releaseDate,
|
||||
enrichStatus: enriched ? 'done' : 'pending',
|
||||
enrichedAt: enriched ? new Date() : track.enrichedAt,
|
||||
},
|
||||
});
|
||||
|
||||
this.logger.debug(
|
||||
`Обогащён "${track.artist} — ${track.song}": genre=${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);
|
||||
}
|
||||
}
|
||||
|
||||
// iTunes Search API (без ключа, высокое покрытие): обложка (600×600) +
|
||||
// альбом/год/жанр/дата релиза.
|
||||
private async fetchItunes(
|
||||
artist: string,
|
||||
song: string,
|
||||
): Promise<{
|
||||
coverUrl: string | null;
|
||||
album: string | null;
|
||||
year: number | null;
|
||||
releaseDate: Date | null;
|
||||
genre: string | null;
|
||||
} | null> {
|
||||
// Попытка 1: как есть. Многие треки несут суффиксы «(Original Mix)»,
|
||||
// «(SEA)», «[... Dub]», «feat. X» — они ломают точный матч iTunes (limit=1).
|
||||
let r = await this.itunesSearch(`${artist} ${song}`);
|
||||
|
||||
// Попытка 2: очищенный запрос (без скобок/квадратных/feat) — даёт обложку
|
||||
// базового трека, когда точный ремикс не нашёлся.
|
||||
if (!r?.coverUrl) {
|
||||
const cleaned = `${this.stripNoise(artist)} ${this.stripNoise(song)}`
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
const original = `${artist} ${song}`.toLowerCase();
|
||||
if (cleaned && cleaned.toLowerCase() !== original) {
|
||||
const r2 = await this.itunesSearch(cleaned);
|
||||
if (r2?.coverUrl) r = r2;
|
||||
}
|
||||
}
|
||||
|
||||
// Попытка 3: Deezer (публичный API, без ключа) — у него хорошее покрытие
|
||||
// электроники/ремиксов/лаунжа, которых нет в iTunes. Берём только обложку.
|
||||
if (!r?.coverUrl) {
|
||||
const dz = await this.fetchDeezerCover(artist, song);
|
||||
if (dz) {
|
||||
r = {
|
||||
coverUrl: dz,
|
||||
album: r?.album ?? null,
|
||||
year: r?.year ?? null,
|
||||
releaseDate: r?.releaseDate ?? null,
|
||||
genre: r?.genre ?? null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return r;
|
||||
}
|
||||
|
||||
/** Убирает «шумовые» суффиксы названия, мешающие матчу обложки. */
|
||||
private stripNoise(s: string): string {
|
||||
return s
|
||||
.replace(/\([^)]*\)/g, ' ') // (Original Mix), (SEA), (feat. X)
|
||||
.replace(/\[[^\]]*\]/g, ' ') // [Luxar Brooklyn Dub]
|
||||
.replace(/\b(?:feat|ft|featuring)\.?\s+.*$/gi, ' ') // feat. X …
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/** Сериализует запросы к iTunes с минимальным интервалом (защита от 429/бана). */
|
||||
private async itunesThrottle(): Promise<void> {
|
||||
const prev = this.itunesGate;
|
||||
let release!: () => void;
|
||||
this.itunesGate = new Promise<void>((r) => (release = r));
|
||||
await prev;
|
||||
setTimeout(release, this.itunesMinIntervalMs);
|
||||
}
|
||||
|
||||
/** Один поиск в iTunes по уже собранному запросу. Бросает при сбое сети/HTTP
|
||||
* (отличаем сбой от чистого «не найдено» → null). */
|
||||
private async itunesSearch(rawTerm: string): Promise<{
|
||||
coverUrl: string | null;
|
||||
album: string | null;
|
||||
year: number | null;
|
||||
releaseDate: Date | null;
|
||||
genre: string | null;
|
||||
} | null> {
|
||||
const clean = rawTerm
|
||||
.replace(/[^\p{L}\p{N}]+/gu, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!clean) return null;
|
||||
const term = encodeURIComponent(clean);
|
||||
const url = `https://itunes.apple.com/search?term=${term}&entity=song&limit=1`;
|
||||
await this.itunesThrottle();
|
||||
const init: RequestInit & { dispatcher?: unknown } = {
|
||||
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
|
||||
};
|
||||
if (this.proxyDispatcher) init.dispatcher = this.proxyDispatcher;
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) throw new Error(`iTunes ${res.status}`);
|
||||
const data = (await res.json()) as {
|
||||
results?: Array<{
|
||||
artworkUrl100?: string;
|
||||
collectionName?: string;
|
||||
releaseDate?: string;
|
||||
primaryGenreName?: string;
|
||||
}>;
|
||||
};
|
||||
const r = data.results?.[0];
|
||||
if (!r) return null;
|
||||
|
||||
const cover = r.artworkUrl100
|
||||
? r.artworkUrl100.replace(/\/\d+x\d+bb\./, '/600x600bb.')
|
||||
: null;
|
||||
const rd = r.releaseDate ? new Date(r.releaseDate) : null;
|
||||
const validDate = rd && !isNaN(rd.getTime()) ? rd : null;
|
||||
|
||||
return {
|
||||
coverUrl: cover,
|
||||
album: r.collectionName ?? null,
|
||||
year: validDate ? validDate.getUTCFullYear() : null,
|
||||
releaseDate: validDate,
|
||||
genre: r.primaryGenreName ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Обложка из Deezer (фолбэк). Best-effort: при любой ошибке → null. */
|
||||
private async fetchDeezerCover(
|
||||
artist: string,
|
||||
song: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const q = `${this.stripNoise(artist)} ${this.stripNoise(song)}`
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
if (!q) return null;
|
||||
const url = `https://api.deezer.com/search?limit=1&q=${encodeURIComponent(q)}`;
|
||||
const init: RequestInit & { dispatcher?: unknown } = {
|
||||
headers: { 'User-Agent': 'radiOLA/1.0 +https://radiola.app' },
|
||||
};
|
||||
if (this.proxyDispatcher) init.dispatcher = this.proxyDispatcher;
|
||||
const res = await fetch(url, init);
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as {
|
||||
data?: Array<{ album?: { cover_xl?: string; cover_big?: string } }>;
|
||||
};
|
||||
const al = data.data?.[0]?.album;
|
||||
return al?.cover_xl ?? al?.cover_big ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private isSelfHosted(url: string): boolean {
|
||||
return url.includes('/covers/');
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
}
|
||||
@@ -1,72 +1,104 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
||||
import { Cron, CronExpression } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
||||
@Injectable()
|
||||
export class HealthCheckService {
|
||||
export class HealthCheckService implements OnModuleInit {
|
||||
private readonly logger = new Logger(HealthCheckService.name);
|
||||
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
// Один прогон вскоре после старта, чтобы isOnline был актуален после деплоя
|
||||
async onModuleInit() {
|
||||
setTimeout(() => {
|
||||
void this.checkAllStations();
|
||||
}, 15000);
|
||||
}
|
||||
|
||||
@Cron(CronExpression.EVERY_HOUR)
|
||||
async checkAllStations() {
|
||||
this.logger.log('Starting hourly station health check...');
|
||||
const stations = await this.prisma.station.findMany();
|
||||
let onlineCount = 0;
|
||||
let offlineCount = 0;
|
||||
this.logger.log('Проверка доступности станций...');
|
||||
const stations = await this.prisma.station.findMany({
|
||||
select: { id: true, streamUrl: true, isOnline: true },
|
||||
});
|
||||
|
||||
for (const station of stations) {
|
||||
try {
|
||||
const isOnline = await this.checkStation(station.streamUrl);
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline, lastCheckAt: new Date() },
|
||||
});
|
||||
if (isOnline) onlineCount++;
|
||||
else offlineCount++;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to check station ${station.name}: ${error.message}`,
|
||||
);
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: false, lastCheckAt: new Date() },
|
||||
});
|
||||
offlineCount++;
|
||||
}
|
||||
let online = 0;
|
||||
let offline = 0;
|
||||
const CONC = 24;
|
||||
|
||||
for (let i = 0; i < stations.length; i += CONC) {
|
||||
const batch = stations.slice(i, i + CONC);
|
||||
await Promise.all(
|
||||
batch.map(async (s) => {
|
||||
const isOnline = await this.isAlive(s.streamUrl);
|
||||
if (isOnline) online++;
|
||||
else offline++;
|
||||
// Пишем только при изменении статуса — меньше нагрузка на БД
|
||||
if (isOnline !== s.isOnline) {
|
||||
await this.prisma.station
|
||||
.update({
|
||||
where: { id: s.id },
|
||||
data: { isOnline, lastCheckAt: new Date() },
|
||||
})
|
||||
.catch(() => undefined);
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`Health check complete. Online: ${onlineCount}, Offline: ${offlineCount}`,
|
||||
);
|
||||
this.logger.log(`Проверка завершена. Online: ${online}, Offline: ${offline}`);
|
||||
}
|
||||
|
||||
private async checkStation(url: string): Promise<boolean> {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 10000);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: 'HEAD',
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
return response.status >= 200 && response.status < 400;
|
||||
} catch {
|
||||
clearTimeout(timeout);
|
||||
// Fallback to GET if HEAD fails
|
||||
try {
|
||||
const controller2 = new AbortController();
|
||||
const timeout2 = setTimeout(() => controller2.abort(), 10000);
|
||||
const response = await fetch(url, {
|
||||
method: 'GET',
|
||||
signal: controller2.signal,
|
||||
});
|
||||
clearTimeout(timeout2);
|
||||
return response.status >= 200 && response.status < 400;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
/**
|
||||
* Живость потока: живой = пришли заголовки со статусом 200–399.
|
||||
* Аудиопоток отдаёт тело бесконечно, поэтому сразу после заголовков рвём
|
||||
* соединение (req.destroy). Ошибка/4xx/5xx/таймаут = мёртв. 2 попытки.
|
||||
*/
|
||||
private async isAlive(url: string): Promise<boolean> {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
if (await this.probe(url)) return true;
|
||||
await this.sleep(300);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private probe(url: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (v: boolean) => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve(v);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
const req = lib.get(
|
||||
url,
|
||||
{
|
||||
timeout: 8000,
|
||||
headers: { 'User-Agent': 'Mozilla/5.0', 'Icy-MetaData': '1' },
|
||||
},
|
||||
(res) => {
|
||||
const code = res.statusCode ?? 0;
|
||||
req.destroy();
|
||||
finish(code >= 200 && code < 400);
|
||||
},
|
||||
);
|
||||
req.on('error', () => finish(false));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
finish(false);
|
||||
});
|
||||
} catch {
|
||||
finish(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
}
|
||||
|
||||
17
src/main.ts
17
src/main.ts
@@ -1,10 +1,25 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestExpressApplication } from '@nestjs/platform-express';
|
||||
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
|
||||
import { join } from 'path';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
// Раздача APK приложения для авто-обновления (/downloads/radiola-latest.apk).
|
||||
const downloadsDir =
|
||||
process.env.DOWNLOADS_DIR || join(process.cwd(), 'data', 'downloads');
|
||||
app.useStaticAssets(downloadsDir, { prefix: '/downloads/' });
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
|
||||
36
src/now-playing/dedicated-sources.ts
Normal file
36
src/now-playing/dedicated-sources.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Единый реестр станций, у которых ЕСТЬ выделенный now-playing-сервис (берёт трек
|
||||
* из их API или особого ICY). Общий ICY-поллер (IcyNowPlayingService) обязан их
|
||||
* ПРОПУСКАТЬ — иначе двойной опрос и перезапись точных данных «сырым» ICY.
|
||||
*
|
||||
* ⚠️ Добавил новый dedicated-сервис — впиши сюда его признак:
|
||||
* • host — если сервис выбирает станции по `streamUrl.contains(host)`
|
||||
* • genre — если сервис выбирает станции по `genre`
|
||||
* Список один-в-один повторяет селекторы сервисов, поэтому исключение в ICY
|
||||
* всегда согласовано с тем, что реально обрабатывает выделенный сервис
|
||||
* (раньше это был ручной genre-список, который легко забывали обновить).
|
||||
*/
|
||||
|
||||
// Сервисы, выбирающие станции по хосту потока (streamUrl.contains)
|
||||
export const DEDICATED_STREAM_HOSTS = [
|
||||
'emgsound.ru', // EmgNowPlayingService
|
||||
'radio7.hostingradio.ru', // EmgNowPlayingService (старые мейны Radio 7)
|
||||
'unistar.by', // UnistarNowPlayingService
|
||||
'abs.zaycev.fm', // ZaycevNowPlayingService
|
||||
'radiogoose.ru', // GooseNowPlayingService
|
||||
'novoeradio.by', // NovoeByNowPlayingService
|
||||
'radio.orpheus.ru', // OrpheusNowPlayingService
|
||||
'.101.ru', // Radio101NowPlayingService (Comedy Radio, Радио Energy)
|
||||
'amgradio.ru', // VolnaNowPlayingService (Русская Волна)
|
||||
'piterfm.cdnvideo.ru', // SpbRadioNowPlayingService (Питер ФМ)
|
||||
'radiovanya.cdnvideo.ru', // SpbRadioNowPlayingService (Радио Ваня)
|
||||
] as const;
|
||||
|
||||
// Сервисы, выбирающие станции по жанру (genre)
|
||||
export const DEDICATED_GENRES = [
|
||||
'DFM', // DfmNowPlayingService
|
||||
'MAXIMUM', // DfmNowPlayingService
|
||||
'Radio Monte Carlo', // DfmNowPlayingService
|
||||
'Love Radio', // LoveNowPlayingService
|
||||
'Radio ROKS', // RoksNowPlayingService
|
||||
] as const;
|
||||
149
src/now-playing/dfm-now-playing.service.ts
Normal file
149
src/now-playing/dfm-now-playing.service.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
interface DfmCurrent {
|
||||
artist?: string;
|
||||
title?: string;
|
||||
cover?: string;
|
||||
genres?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Now-playing для станций Крутой Медиа (DFM и все его сабканалы — Skrillex,
|
||||
* Daft Punk, K-Pop, Игромания и т.д.). Единый веб-API dfm.ru/api/n/current
|
||||
* отдаёт текущий трек + WebP-обложку по всем ~147 каналам (ключ — slug).
|
||||
* Сопоставляем наши станции (группа DFM) по нормализованному имени.
|
||||
*/
|
||||
@Injectable()
|
||||
export class DfmNowPlayingService {
|
||||
private readonly logger = new Logger(DfmNowPlayingService.name);
|
||||
private readonly headers = {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Referer: 'https://dfm.ru/',
|
||||
};
|
||||
|
||||
// Стойкие случаи: нормализованное имя нашей станции -> slug в API
|
||||
private readonly alias: Record<string, string> = {
|
||||
'dance-gold-00-s': 'dance-gold-2000s',
|
||||
'dance-gold-10-s': 'dance-gold-2010s',
|
||||
'dance-gold-20-s': 'dance-gold-2020s',
|
||||
'dance-gold-90-s': 'dance-gold-1990s',
|
||||
'pop-gold-00-s': 'pop-gold-2000s',
|
||||
'pop-gold-10-s': 'pop-gold-2010s',
|
||||
'pop-gold-20-s': 'pop-gold2020s',
|
||||
'pop-gold-90-s': 'pop-gold-1990s',
|
||||
'festival-gold': 'festivals-gold',
|
||||
pioneer: '59-dfm-pioneer',
|
||||
игромания: '61-igromaniq',
|
||||
'vocal-trance': 'trance',
|
||||
'disco-90th': 'diskach-90h',
|
||||
// MAXIMUM (тот же Крутой Медиа, тот же /api/n/current)
|
||||
britpop: '130-maxbritpop',
|
||||
covers: '129-maxcover',
|
||||
'heavy-80-s': '131-max80',
|
||||
'heavy-monday': '141-heavymonday',
|
||||
'maximum-90th': '145-maximum90',
|
||||
millenium: '140-millenium',
|
||||
'new-russians': '125-maxnewrussians',
|
||||
punk: '132-maxpunk',
|
||||
rhcp: '123-maxrhcp',
|
||||
'rock-hits': '144-rockhits',
|
||||
rugby: '139-rugby',
|
||||
'russian-rock': '90-russkijrok',
|
||||
soft: '127-maxsoft',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollDfmNowPlaying() {
|
||||
// DFM, MAXIMUM и Радио Монте-Карло — все сети Крутой Медиа, общий API
|
||||
// dfm.ru/api/n/current
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { genre: { in: ['DFM', 'MAXIMUM', 'Radio Monte Carlo'] } },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
const res = await fetch('https://dfm.ru/api/n/current', {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`DFM api/n/current вернул ${res.status}`);
|
||||
return;
|
||||
}
|
||||
const json = (await res.json()) as {
|
||||
result?: { data?: Record<string, { current?: DfmCurrent }> };
|
||||
};
|
||||
const data = json.result?.data;
|
||||
if (!data) return;
|
||||
|
||||
// Индекс: slug и его варианты (без дефисов, без числового префикса) -> current
|
||||
const idx = new Map<string, DfmCurrent>();
|
||||
for (const slug of Object.keys(data)) {
|
||||
const cur = data[slug].current;
|
||||
if (!cur?.artist || !cur?.title) continue;
|
||||
const base = slug.replace(/^\d+-/, '');
|
||||
for (const key of [slug, slug.replace(/-/g, ''), base, base.replace(/-/g, '')]) {
|
||||
if (!idx.has(key)) idx.set(key, cur);
|
||||
}
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
for (const station of stations) {
|
||||
const n = this.norm(station.name);
|
||||
const aliasSlug = this.alias[n];
|
||||
// Слаг из маута потока (basename без битрейта) — основной ключ для
|
||||
// Монте-Карло (имя станции не совпадает с API-слагом, а маут совпадает:
|
||||
// `mccovers96.aacp` → `mccovers`, `blues96.aacp` → `blues`).
|
||||
const mount = this.mountSlug(station.streamUrl);
|
||||
const cur =
|
||||
idx.get(n) ??
|
||||
idx.get(n.replace(/-/g, '')) ??
|
||||
(mount ? idx.get(mount) : undefined) ??
|
||||
(aliasSlug ? data[aliasSlug]?.current : undefined);
|
||||
if (!cur?.artist || !cur?.title) continue;
|
||||
|
||||
const cover = cur.cover
|
||||
? cur.cover.startsWith('http')
|
||||
? cur.cover
|
||||
: `https://dfm.ru${cur.cover}`
|
||||
: null;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist: cur.artist.trim(),
|
||||
song: cur.title.trim(),
|
||||
coverUrl: cover,
|
||||
});
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
|
||||
this.logger.log(`Krutoy poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
private norm(s: string): string {
|
||||
return s
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9а-я]+/gi, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
}
|
||||
|
||||
// Слаг из маута потока: basename пути без расширения и хвостового битрейта.
|
||||
// `http://mc-blues.hostingradio.ru/blues96.aacp` → `blues`.
|
||||
private mountSlug(streamUrl: string): string | null {
|
||||
const m = streamUrl.match(/\/([a-z0-9_-]+?)\d*\.(?:aacp|aac|mp3|m3u8)/i);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
}
|
||||
134
src/now-playing/emg-now-playing.service.ts
Normal file
134
src/now-playing/emg-now-playing.service.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
// Элемент истории meta.hostingradio.ru (нужные поля)
|
||||
interface EmgHistoryItem {
|
||||
artist?: string;
|
||||
title?: string;
|
||||
type?: number;
|
||||
coverImageWebpUrl600?: string;
|
||||
coverImageUrl600?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Now-playing для станций группы ЕМГ (Европа Плюс, Ретро FM, Дорожное, Радио 7,
|
||||
* Studio 21, Эльдорадио и их сабканалы). Все они вещают через emgsound.ru, а текущий
|
||||
* трек с обложкой отдаёт единый сервис meta.hostingradio.ru/emg/{slug}/history.
|
||||
* slug берём из хоста потока: hls-NN-{slug}.emgsound.ru. order=desc → первый = сейчас.
|
||||
*/
|
||||
@Injectable()
|
||||
export class EmgNowPlayingService {
|
||||
private readonly logger = new Logger(EmgNowPlayingService.name);
|
||||
private readonly headers = {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Origin: 'https://europaplus.ru',
|
||||
Referer: 'https://europaplus.ru/',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollEmgNowPlaying() {
|
||||
// Не фильтруем по isOnline: health-check ошибочно метит HLS-потоки offline.
|
||||
// Radio 7 — тоже ЕМГ, но на старых мейнах radio7.hostingradio.ru (slug в meta).
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: {
|
||||
OR: [
|
||||
{ streamUrl: { contains: 'emgsound.ru' } },
|
||||
{ streamUrl: { contains: 'radio7.hostingradio.ru' } },
|
||||
],
|
||||
},
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
const { date, from, to } = this.mskWindow();
|
||||
let updated = 0;
|
||||
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
const slug = this.extractSlug(station.streamUrl);
|
||||
if (!slug) return;
|
||||
|
||||
// Slug из хоста потока не всегда = slug в meta (напр. hls-01-fresh →
|
||||
// в meta это europaplus-fresh). Если по основному пусто — пробуем префикс.
|
||||
const candidates = [slug];
|
||||
if (!slug.startsWith('europaplus') && slug !== 'dfm') {
|
||||
candidates.push(`europaplus-${slug}`);
|
||||
}
|
||||
|
||||
let cur: EmgHistoryItem | null = null;
|
||||
for (const c of candidates) {
|
||||
const url =
|
||||
`https://meta.hostingradio.ru/emg/${c}/history` +
|
||||
`?format=native&types=3&order=desc&date=${date}&from=${from}&to=${to}`;
|
||||
const res = await fetch(url, { headers: this.headers });
|
||||
if (!res.ok) continue;
|
||||
const items = (await res.json()) as EmgHistoryItem[] | unknown;
|
||||
const first = Array.isArray(items) ? (items[0] as EmgHistoryItem) : null;
|
||||
if (first?.artist && first?.title) {
|
||||
cur = first;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!cur?.artist || !cur?.title) return;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist: cur.artist.trim(),
|
||||
song: cur.title.trim(),
|
||||
coverUrl: cur.coverImageWebpUrl600 ?? cur.coverImageUrl600 ?? null,
|
||||
});
|
||||
// Станция явно в эфире — поправим ошибочный offline-флаг
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`EMG poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
// hls-01-europaplus-kpop.emgsound.ru → europaplus-kpop
|
||||
// radio7.hostingradio.ru:8040/radio7_love64.mp3 → radio7-love (strip bitrate, _→-)
|
||||
private extractSlug(streamUrl: string): string | null {
|
||||
const emg = streamUrl.match(/hls-\d+-([a-z0-9-]+)\.emgsound\.ru/i);
|
||||
if (emg) return emg[1].toLowerCase();
|
||||
// Маунты radio7: radio7{br}, radio7_love{br}, radio7_happiness{br} (br=64/128/256).
|
||||
// ВАЖНО: нельзя резать цифры с конца «\d+$» — у «radio7128» это съест «7» (→radio).
|
||||
// Берём «radio7» + опциональный «_слово» (бренд), отбрасывая битрейт.
|
||||
const r7 = streamUrl.match(/radio7\.hostingradio\.ru[:0-9]*\/(radio7(?:_[a-z]+)?)/i);
|
||||
if (r7) return r7[1].toLowerCase().replace(/_/g, '-');
|
||||
return null;
|
||||
}
|
||||
|
||||
// Дата и окно времени по Москве (контейнер может быть в UTC)
|
||||
private mskWindow(): { date: string; from: string; to: string } {
|
||||
const fmt = (d: Date) => {
|
||||
const parts = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: 'Europe/Moscow',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hourCycle: 'h23',
|
||||
}).formatToParts(d);
|
||||
const g = (t: string) => parts.find((p) => p.type === t)?.value ?? '00';
|
||||
return { date: `${g('year')}-${g('month')}-${g('day')}`, time: `${g('hour')}:${g('minute')}` };
|
||||
};
|
||||
const now = new Date();
|
||||
const cur = fmt(now);
|
||||
const start = fmt(new Date(now.getTime() - 120 * 60 * 1000));
|
||||
return { date: cur.date, from: start.time, to: cur.time };
|
||||
}
|
||||
}
|
||||
101
src/now-playing/goose-now-playing.service.ts
Normal file
101
src/now-playing/goose-now-playing.service.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
// Ответ AzuraCast https://radiogoose.ru/api/nowplaying/{slug}
|
||||
interface AzuraNowPlaying {
|
||||
is_online?: boolean;
|
||||
now_playing?: {
|
||||
song?: {
|
||||
artist?: string;
|
||||
title?: string;
|
||||
art?: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Now-playing для сети ГУСЬ (radiogoose.ru) — это AzuraCast, у него штатный
|
||||
* публичный API текущего трека с обложкой:
|
||||
* GET https://radiogoose.ru/api/nowplaying/{slug} → now_playing.song {artist,title,art}.
|
||||
* slug = сегмент потока /listen/{slug}/play. is_online из ответа поправляет
|
||||
* ошибочный offline-флаг (health-check ранее спотыкался о многострочный URL).
|
||||
*/
|
||||
@Injectable()
|
||||
export class GooseNowPlayingService {
|
||||
private readonly logger = new Logger(GooseNowPlayingService.name);
|
||||
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollGooseNowPlaying() {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { streamUrl: { contains: 'radiogoose.ru' } },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
let updated = 0;
|
||||
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
const slug = this.extractSlug(station.streamUrl);
|
||||
if (!slug) return;
|
||||
|
||||
const res = await fetch(
|
||||
`https://radiogoose.ru/api/nowplaying/${slug}`,
|
||||
{ headers: this.headers },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = (await res.json()) as AzuraNowPlaying;
|
||||
if (data.is_online === false) return;
|
||||
|
||||
const song = data.now_playing?.song;
|
||||
const artist = (song?.artist ?? '').trim();
|
||||
const title = (song?.title ?? '').trim();
|
||||
if (!artist || !title) return;
|
||||
|
||||
// ВНИМАНИЕ: art-URL AzuraCast (`song.art`) на radiogoose.ru отдаёт 404
|
||||
// (route обложек на сервере не включён) — НЕ используем его, иначе на
|
||||
// карточке битая картинка. Отдаём null → обложку подтянет наше обогащение
|
||||
// (iTunes/Deezer по normKey), как у обычных ICY-станций.
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist,
|
||||
song: title,
|
||||
coverUrl: null,
|
||||
});
|
||||
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Goose poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
// У большинства каналов mount потока == ключ now-playing API. Исключение:
|
||||
// Технорейв — поток /listen/harddance/, а в API он /api/nowplaying/technorave.
|
||||
private readonly slugAliases: Record<string, string> = {
|
||||
harddance: 'technorave',
|
||||
};
|
||||
|
||||
// https://radiogoose.ru/listen/bigroom/play → bigroom
|
||||
private extractSlug(streamUrl: string): string | null {
|
||||
const m = streamUrl.match(/\/listen\/([a-z0-9]+)\/play/i);
|
||||
if (!m) return null;
|
||||
const slug = m[1].toLowerCase();
|
||||
return this.slugAliases[slug] ?? slug;
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,53 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingGateway } from './now-playing.gateway';
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
import { readIcyStreamTitle } from './icy-reader';
|
||||
import { DEDICATED_GENRES, DEDICATED_STREAM_HOSTS } from './dedicated-sources';
|
||||
|
||||
/**
|
||||
* Сбор now-playing для не-Record станций (DFM и др.) через ICY-метаданные потока.
|
||||
* Станций много (сотни), поэтому за один тик опрашиваем окно и сдвигаем курсор —
|
||||
* за несколько минут проходим все по кругу. Обложку и зачёт в чарты/обогащение
|
||||
* берёт на себя NowPlayingService.ingest (обложка подтянется из нашей БД).
|
||||
*/
|
||||
@Injectable()
|
||||
export class IcyNowPlayingService {
|
||||
private readonly logger = new Logger(IcyNowPlayingService.name);
|
||||
private cursor = 0;
|
||||
private readonly windowSize = 70;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly gateway: NowPlayingGateway,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(60000)
|
||||
async pollIcyNowPlaying() {
|
||||
this.logger.log('Starting ICY now playing poll...');
|
||||
// Станции с выделенным now-playing-сервисом (через их API) исключаем из ICY,
|
||||
// чтобы не тратить слоты впустую и не перезаписывать точные данные сырым ICY.
|
||||
// Источник исключений — единый реестр dedicated-sources (host + genre),
|
||||
// согласованный с селекторами самих сервисов.
|
||||
const where = {
|
||||
recordStationId: null,
|
||||
isOnline: true,
|
||||
genre: { notIn: [...DEDICATED_GENRES] },
|
||||
AND: DEDICATED_STREAM_HOSTS.map((host) => ({
|
||||
NOT: { streamUrl: { contains: host } },
|
||||
})),
|
||||
};
|
||||
const total = await this.prisma.station.count({ where });
|
||||
if (total === 0) return;
|
||||
if (this.cursor >= total) this.cursor = 0;
|
||||
const offset = this.cursor;
|
||||
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { recordStationId: null, isOnline: true },
|
||||
take: 50,
|
||||
where,
|
||||
orderBy: { stationId: 'asc' },
|
||||
skip: offset,
|
||||
take: this.windowSize,
|
||||
});
|
||||
this.cursor += this.windowSize;
|
||||
|
||||
let successCount = 0;
|
||||
|
||||
@@ -28,146 +55,42 @@ export class IcyNowPlayingService {
|
||||
const batch = stations.slice(i, i + 10);
|
||||
const results = await Promise.allSettled(
|
||||
batch.map(async (station) => {
|
||||
const track = await this.parseIcyMetadata(station.streamUrl);
|
||||
const track = await this.parseIcyTrack(station.streamUrl);
|
||||
if (!track) return null;
|
||||
|
||||
const updated = await this.prisma.nowPlaying.upsert({
|
||||
where: { stationId: station.id },
|
||||
create: {
|
||||
stationId: station.id,
|
||||
song: track.song,
|
||||
artist: track.artist,
|
||||
coverUrl: null,
|
||||
},
|
||||
update: {
|
||||
song: track.song,
|
||||
artist: track.artist,
|
||||
coverUrl: null,
|
||||
},
|
||||
});
|
||||
|
||||
this.gateway.broadcastNowPlaying(station.stationId.toString(), {
|
||||
song: track.song,
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist: track.artist,
|
||||
song: track.song,
|
||||
coverUrl: null,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
return track;
|
||||
}),
|
||||
);
|
||||
|
||||
for (let j = 0; j < results.length; j++) {
|
||||
const result = results[j];
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
successCount++;
|
||||
} else if (result.status === 'rejected') {
|
||||
this.logger.warn(
|
||||
`ICY failed for ${batch[j].name}: ${result.reason?.message || result.reason}`,
|
||||
);
|
||||
}
|
||||
for (const result of results) {
|
||||
if (result.status === 'fulfilled' && result.value) successCount++;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`ICY poll complete: ${successCount}/${stations.length} stations updated`,
|
||||
`ICY poll: ${successCount}/${stations.length} updated (offset ${offset}/${total})`,
|
||||
);
|
||||
}
|
||||
|
||||
private async parseIcyMetadata(
|
||||
/** Читает StreamTitle через общий ICY-ридер и разбирает «Артист - Песня». */
|
||||
private async parseIcyTrack(
|
||||
url: string,
|
||||
): Promise<{ artist: string; song: string } | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const client = url.startsWith('https') ? https : http;
|
||||
const req = client.get(
|
||||
url,
|
||||
{ headers: { 'Icy-MetaData': '1' }, timeout: 5000 },
|
||||
(res) => {
|
||||
const metaint = parseInt(
|
||||
(res.headers['icy-metaint'] as string) || '0',
|
||||
);
|
||||
if (!metaint) {
|
||||
req.destroy();
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let audioBytesRead = 0;
|
||||
let metaLength = 0;
|
||||
let metaBytesRead = 0;
|
||||
let metaBuffer = Buffer.alloc(0);
|
||||
let state: 'audio' | 'meta-length' | 'meta' = 'audio';
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) {
|
||||
if (state === 'audio') {
|
||||
const need = metaint - audioBytesRead;
|
||||
const available = chunk.length - offset;
|
||||
const take = Math.min(need, available);
|
||||
audioBytesRead += take;
|
||||
offset += take;
|
||||
if (audioBytesRead >= metaint) {
|
||||
state = 'meta-length';
|
||||
}
|
||||
} else if (state === 'meta-length') {
|
||||
metaLength = chunk[offset] * 16;
|
||||
offset++;
|
||||
if (metaLength === 0) {
|
||||
audioBytesRead = 0;
|
||||
state = 'audio';
|
||||
} else {
|
||||
metaBuffer = Buffer.alloc(0);
|
||||
metaBytesRead = 0;
|
||||
state = 'meta';
|
||||
}
|
||||
} else if (state === 'meta') {
|
||||
const need = metaLength - metaBytesRead;
|
||||
const available = chunk.length - offset;
|
||||
const take = Math.min(need, available);
|
||||
metaBuffer = Buffer.concat([
|
||||
metaBuffer,
|
||||
chunk.slice(offset, offset + take),
|
||||
]);
|
||||
metaBytesRead += take;
|
||||
offset += take;
|
||||
if (metaBytesRead >= metaLength) {
|
||||
const metaStr = metaBuffer
|
||||
.toString('utf-8')
|
||||
.replace(/\x00/g, '');
|
||||
const match = metaStr.match(/StreamTitle='([^']+)'/);
|
||||
req.destroy();
|
||||
if (!match) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
const parts = match[1].split(' - ', 2);
|
||||
if (parts.length < 2) {
|
||||
resolve({ artist: match[1], song: match[1] });
|
||||
} else {
|
||||
resolve({
|
||||
artist: parts[0].trim(),
|
||||
song: parts[1].trim(),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
res.on('error', (err) => {
|
||||
req.destroy();
|
||||
reject(err);
|
||||
});
|
||||
res.on('end', () => resolve(null));
|
||||
},
|
||||
);
|
||||
|
||||
req.on('error', (err) => reject(err));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
reject(new Error('Timeout'));
|
||||
});
|
||||
});
|
||||
const raw = await readIcyStreamTitle(url, { timeoutMs: 5000 });
|
||||
if (!raw) return null;
|
||||
// Некоторые потоки (101.ru и др.) шлют в StreamTitle JSON-статус, а не трек.
|
||||
if (raw.startsWith('{') || raw.startsWith('[')) return null;
|
||||
const parts = raw.split(' - ', 2);
|
||||
const artist = parts.length < 2 ? raw : parts[0].trim();
|
||||
const song = parts.length < 2 ? raw : parts[1].trim();
|
||||
if (!artist || !song) return null;
|
||||
return { artist, song };
|
||||
}
|
||||
}
|
||||
|
||||
135
src/now-playing/icy-reader.ts
Normal file
135
src/now-playing/icy-reader.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as http from 'http';
|
||||
import * as https from 'https';
|
||||
|
||||
export interface IcyReadOptions {
|
||||
/** Таймаут сокета, мс (по умолчанию 8000). */
|
||||
timeoutMs?: number;
|
||||
/**
|
||||
* Декодирование StreamTitle:
|
||||
* - 'utf8' — как есть (по умолчанию);
|
||||
* - 'auto-1251' — при битом UTF-8 (символ <20>) перечитать байты как windows-1251
|
||||
* (нужно потокам с кириллицей в cp1251, напр. «Новое Радио BY»).
|
||||
*/
|
||||
decode?: 'utf8' | 'auto-1251';
|
||||
/** Доп. заголовки запроса (User-Agent и т.п.). */
|
||||
headers?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Единая реализация чтения первого StreamTitle из ICY-метаданных потока.
|
||||
* Раньше один и тот же state-machine разбора icy-metaint был скопирован в трёх
|
||||
* сервисах (icy/novoeby/love) — теперь источник один.
|
||||
*
|
||||
* Возвращает очищенный заголовок (без \x00, trim) либо null. Никогда не реджектит
|
||||
* (ошибки сети/таймаут → null), чтобы вызов был безопасен в Promise.allSettled.
|
||||
*/
|
||||
export function readIcyStreamTitle(
|
||||
url: string,
|
||||
opts: IcyReadOptions = {},
|
||||
): Promise<string | null> {
|
||||
const timeoutMs = opts.timeoutMs ?? 8000;
|
||||
const decode = opts.decode ?? 'utf8';
|
||||
return new Promise((resolve) => {
|
||||
let done = false;
|
||||
const finish = (v: string | null) => {
|
||||
if (!done) {
|
||||
done = true;
|
||||
resolve(v);
|
||||
}
|
||||
};
|
||||
try {
|
||||
const lib = url.startsWith('https') ? https : http;
|
||||
const req = lib.get(
|
||||
url,
|
||||
{
|
||||
headers: { 'Icy-MetaData': '1', ...(opts.headers ?? {}) },
|
||||
timeout: timeoutMs,
|
||||
},
|
||||
(res) => {
|
||||
const metaint = parseInt(
|
||||
(res.headers['icy-metaint'] as string) || '0',
|
||||
);
|
||||
if (!metaint) {
|
||||
req.destroy();
|
||||
finish(null);
|
||||
return;
|
||||
}
|
||||
let audio = 0;
|
||||
let metaLen = 0;
|
||||
let metaBuf = Buffer.alloc(0);
|
||||
let state: 'audio' | 'len' | 'meta' = 'audio';
|
||||
|
||||
res.on('data', (chunk: Buffer) => {
|
||||
let off = 0;
|
||||
while (off < chunk.length) {
|
||||
if (state === 'audio') {
|
||||
const take = Math.min(metaint - audio, chunk.length - off);
|
||||
audio += take;
|
||||
off += take;
|
||||
if (audio >= metaint) state = 'len';
|
||||
} else if (state === 'len') {
|
||||
metaLen = chunk[off] * 16;
|
||||
off++;
|
||||
if (metaLen === 0) {
|
||||
audio = 0;
|
||||
state = 'audio';
|
||||
} else {
|
||||
metaBuf = Buffer.alloc(0);
|
||||
state = 'meta';
|
||||
}
|
||||
} else {
|
||||
const take = Math.min(
|
||||
metaLen - metaBuf.length,
|
||||
chunk.length - off,
|
||||
);
|
||||
metaBuf = Buffer.concat([
|
||||
metaBuf,
|
||||
chunk.slice(off, off + take),
|
||||
]);
|
||||
off += take;
|
||||
if (metaBuf.length >= metaLen) {
|
||||
req.destroy();
|
||||
finish(extractTitle(metaBuf, decode));
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
res.on('error', () => finish(null));
|
||||
res.on('end', () => finish(null));
|
||||
},
|
||||
);
|
||||
req.on('error', () => finish(null));
|
||||
req.on('timeout', () => {
|
||||
req.destroy();
|
||||
finish(null);
|
||||
});
|
||||
} catch {
|
||||
finish(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Достаёт StreamTitle из блока ICY-метаданных. Границы ищем побайтово (latin1),
|
||||
* чтобы мультибайтовая кириллица не сбила смещения; терминатор — `';` (а не первый
|
||||
* апостроф — иначе названия с апострофом, напр. «Song's Name», обрезались бы).
|
||||
*/
|
||||
function extractTitle(buf: Buffer, decode: 'utf8' | 'auto-1251'): string | null {
|
||||
const latin = buf.toString('latin1');
|
||||
const start = latin.indexOf("StreamTitle='");
|
||||
if (start < 0) return null;
|
||||
const from = start + "StreamTitle='".length;
|
||||
const end = latin.indexOf("';", from);
|
||||
if (end < 0) return null;
|
||||
const titleBytes = buf.slice(from, end);
|
||||
|
||||
const utf8 = titleBytes.toString('utf8');
|
||||
// <20> (<28>) — признак невалидного UTF-8 → перечитываем как windows-1251.
|
||||
const decoded =
|
||||
decode === 'auto-1251' && utf8.includes('<27>')
|
||||
? new TextDecoder('windows-1251').decode(titleBytes)
|
||||
: utf8;
|
||||
const clean = decoded.replace(/\x00/g, '').trim();
|
||||
return clean || null;
|
||||
}
|
||||
112
src/now-playing/love-now-playing.service.ts
Normal file
112
src/now-playing/love-now-playing.service.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
import { readIcyStreamTitle } from './icy-reader';
|
||||
|
||||
/**
|
||||
* Now-playing для Love Radio. Их API (player/online) кэширует один трек на все
|
||||
* каналы (игнорит musicStreamId), поэтому берём метаданные из САМИХ потоков:
|
||||
* каждый n340-поток физически разный и несёт свой трек в ICY StreamTitle.
|
||||
* Потоки защищены — нужен сессионный UID из player/config (привязан к IP сервера,
|
||||
* бэкенд и читает поток со своего IP). Обложек нет — их даёт обогащение.
|
||||
*/
|
||||
@Injectable()
|
||||
export class LoveNowPlayingService {
|
||||
private readonly logger = new Logger(LoveNowPlayingService.name);
|
||||
private uid: string | null = null;
|
||||
|
||||
// Имя нашей станции -> mount потока на n340
|
||||
private readonly mount: Record<string, string> = {
|
||||
'Love Radio': '12_love_128',
|
||||
'Love RnB': '6_rnb_24',
|
||||
'Love Top40': '9_top40_24',
|
||||
'Love Dance': '7_dance_24',
|
||||
'Love Gold': '3_gold_56',
|
||||
'Love Russian': '8_russian_24',
|
||||
'Love KPOP': '11_kpop_28',
|
||||
'Love Power': '15_power_24',
|
||||
'Love Chill': '4_chill_24',
|
||||
'Love Summer': '5_summer_24',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollLoveNowPlaying() {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { genre: 'Love Radio' },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
const uid = await this.getUid();
|
||||
if (!uid) return;
|
||||
|
||||
let updated = 0;
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
const m = this.mount[station.name];
|
||||
if (!m) return;
|
||||
const url = `https://stream2.n340.com/${m}?type=aac&UID=${uid}`;
|
||||
const title = await readIcyStreamTitle(url, {
|
||||
headers: { 'User-Agent': 'Mozilla/5.0' },
|
||||
});
|
||||
if (!title) return;
|
||||
// «onlinestop56k» = заглушка (UID протух) — сбросим, добёрём на след. цикле
|
||||
if (title === 'onlinestop56k') {
|
||||
this.uid = null;
|
||||
return;
|
||||
}
|
||||
const parts = title.split(' - ');
|
||||
if (parts.length < 2) return;
|
||||
const artist = parts[0].trim();
|
||||
const song = parts.slice(1).join(' - ').trim();
|
||||
if (!artist || !song) return;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist,
|
||||
song,
|
||||
coverUrl: null,
|
||||
});
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Love poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
// Сессионный UID из player/config (кэшируем; сбрасываем при заглушке)
|
||||
private async getUid(): Promise<string | null> {
|
||||
if (this.uid) return this.uid;
|
||||
try {
|
||||
const res = await fetch(
|
||||
'https://api.loveradio.ru/api/v1/love-radio/player/config',
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Referer: 'https://www.loveradio.ru/',
|
||||
Origin: 'https://www.loveradio.ru',
|
||||
},
|
||||
},
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const json = (await res.json()) as { data?: { uid?: string } };
|
||||
this.uid = json.data?.uid ?? null;
|
||||
return this.uid;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
65
src/now-playing/novoeby-now-playing.service.ts
Normal file
65
src/now-playing/novoeby-now-playing.service.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
import { readIcyStreamTitle } from './icy-reader';
|
||||
|
||||
/**
|
||||
* Now-playing для «Новое Радио BY» (Беларусь, live.novoeradio.by). Их Icecast-мейны
|
||||
* отдают ICY StreamTitle, НО кириллица в нём — windows-1251 (общий ICY-поллер читает
|
||||
* как UTF-8 → каша «<><C2AB><EFBFBD><EFBFBD>»). Поэтому отдельный сервис: читаем ICY и декодируем UTF-8,
|
||||
* а при «битых» байтах — windows-1251. Опрос 30с (общий поллер крутит всё по кругу
|
||||
* ~9 мин — для now-playing слишком лениво). Обложку даёт обогащение (iTunes/Deezer).
|
||||
*/
|
||||
@Injectable()
|
||||
export class NovoeByNowPlayingService {
|
||||
private readonly logger = new Logger(NovoeByNowPlayingService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollNovoeByNowPlaying() {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { streamUrl: { contains: 'novoeradio.by' } },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
let updated = 0;
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
// Кириллица в их ICY — windows-1251 (decode: auto-1251).
|
||||
const title = await readIcyStreamTitle(station.streamUrl, {
|
||||
decode: 'auto-1251',
|
||||
});
|
||||
if (!title || title.startsWith('{') || title.startsWith('[')) return;
|
||||
|
||||
// Джинглы/заставки без трека («NOVOE RADIO MEGAMIX» и т.п.) — пропускаем.
|
||||
const sep = title.indexOf(' - ');
|
||||
if (sep < 0) return;
|
||||
const artist = title.slice(0, sep).trim();
|
||||
const song = title.slice(sep + 3).trim();
|
||||
if (!artist || !song) return;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist,
|
||||
song,
|
||||
coverUrl: null,
|
||||
});
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`NovoeBY poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
}
|
||||
23
src/now-playing/now-playing.controller.ts
Normal file
23
src/now-playing/now-playing.controller.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
@ApiTags('now-playing')
|
||||
@Controller('now-playing')
|
||||
export class NowPlayingController {
|
||||
constructor(private readonly nowPlayingService: NowPlayingService) {}
|
||||
|
||||
// Текущие треки по всем станциям, ключ — числовой id станции (как в каталоге).
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'Текущие треки по всем станциям' })
|
||||
async getAll() {
|
||||
const list = await this.nowPlayingService.getAllNowPlaying();
|
||||
return list.map((np) => ({
|
||||
stationId: np.station.stationId,
|
||||
name: np.station.name,
|
||||
song: np.song,
|
||||
artist: np.artist,
|
||||
coverUrl: np.coverUrl,
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -1,17 +1,43 @@
|
||||
import { Module, forwardRef } from '@nestjs/common';
|
||||
import { NowPlayingGateway } from './now-playing.gateway';
|
||||
import { NowPlayingController } from './now-playing.controller';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
import { RecordStationSyncService } from './record-station-sync.service';
|
||||
import { IcyNowPlayingService } from './icy-now-playing.service';
|
||||
import { EmgNowPlayingService } from './emg-now-playing.service';
|
||||
import { DfmNowPlayingService } from './dfm-now-playing.service';
|
||||
import { LoveNowPlayingService } from './love-now-playing.service';
|
||||
import { RoksNowPlayingService } from './roks-now-playing.service';
|
||||
import { UnistarNowPlayingService } from './unistar-now-playing.service';
|
||||
import { ZaycevNowPlayingService } from './zaycev-now-playing.service';
|
||||
import { GooseNowPlayingService } from './goose-now-playing.service';
|
||||
import { NovoeByNowPlayingService } from './novoeby-now-playing.service';
|
||||
import { SpbRadioNowPlayingService } from './spb-radio-now-playing.service';
|
||||
import { VolnaNowPlayingService } from './volna-now-playing.service';
|
||||
import { Radio101NowPlayingService } from './radio101-now-playing.service';
|
||||
import { OrpheusNowPlayingService } from './orpheus-now-playing.service';
|
||||
import { ChartsModule } from '../charts/charts.module';
|
||||
|
||||
@Module({
|
||||
imports: [forwardRef(() => ChartsModule)],
|
||||
controllers: [NowPlayingController],
|
||||
providers: [
|
||||
NowPlayingGateway,
|
||||
NowPlayingService,
|
||||
RecordStationSyncService,
|
||||
IcyNowPlayingService,
|
||||
EmgNowPlayingService,
|
||||
DfmNowPlayingService,
|
||||
LoveNowPlayingService,
|
||||
RoksNowPlayingService,
|
||||
UnistarNowPlayingService,
|
||||
ZaycevNowPlayingService,
|
||||
GooseNowPlayingService,
|
||||
NovoeByNowPlayingService,
|
||||
SpbRadioNowPlayingService,
|
||||
VolnaNowPlayingService,
|
||||
Radio101NowPlayingService,
|
||||
OrpheusNowPlayingService,
|
||||
],
|
||||
exports: [NowPlayingService],
|
||||
})
|
||||
|
||||
@@ -56,49 +56,17 @@ export class NowPlayingService {
|
||||
const mapping = this.recordSync.getStationByNowPlayingId(np.id);
|
||||
if (!mapping) continue;
|
||||
|
||||
const coverUrl = np.track.image600 ?? np.track.image200 ?? np.track.image100;
|
||||
const coverUrl =
|
||||
np.track.image600 ?? np.track.image200 ?? np.track.image100;
|
||||
|
||||
// Получаем текущее состояние до апдейта, чтобы определить смену трека
|
||||
const prev = await this.prisma.nowPlaying.findUnique({
|
||||
where: { stationId: mapping.dbId },
|
||||
});
|
||||
|
||||
const updated = await this.prisma.nowPlaying.upsert({
|
||||
where: { stationId: mapping.dbId },
|
||||
create: {
|
||||
stationId: mapping.dbId,
|
||||
song: np.track.song,
|
||||
artist: np.track.artist,
|
||||
coverUrl,
|
||||
},
|
||||
update: {
|
||||
song: np.track.song,
|
||||
artist: np.track.artist,
|
||||
coverUrl,
|
||||
},
|
||||
});
|
||||
|
||||
this.gateway.broadcastNowPlaying(mapping.stationId.toString(), {
|
||||
song: np.track.song,
|
||||
await this.ingest({
|
||||
stationDbId: mapping.dbId,
|
||||
stationNumericId: mapping.stationId,
|
||||
artist: np.track.artist,
|
||||
song: np.track.song,
|
||||
coverUrl,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
updatedCount++;
|
||||
|
||||
// Засчитываем проигрывание только при смене трека
|
||||
const trackChanged =
|
||||
!prev ||
|
||||
prev.song !== np.track.song ||
|
||||
prev.artist !== np.track.artist;
|
||||
if (trackChanged) {
|
||||
void this.chartsService.recordPlay({
|
||||
artist: np.track.artist,
|
||||
song: np.track.song,
|
||||
coverUrl,
|
||||
stationDbId: mapping.dbId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
@@ -113,6 +81,12 @@ export class NowPlayingService {
|
||||
stationId: string,
|
||||
data: { song: string; artist: string; coverUrl?: string },
|
||||
) {
|
||||
const coverUrl = await this.resolveCover(
|
||||
data.artist,
|
||||
data.song,
|
||||
data.coverUrl,
|
||||
);
|
||||
|
||||
// Получаем текущее состояние до апдейта, чтобы определить смену трека
|
||||
const prev = await this.prisma.nowPlaying.findUnique({
|
||||
where: { stationId },
|
||||
@@ -120,23 +94,14 @@ export class NowPlayingService {
|
||||
|
||||
const nowPlaying = await this.prisma.nowPlaying.upsert({
|
||||
where: { stationId },
|
||||
create: {
|
||||
stationId,
|
||||
song: data.song,
|
||||
artist: data.artist,
|
||||
coverUrl: data.coverUrl,
|
||||
},
|
||||
update: {
|
||||
song: data.song,
|
||||
artist: data.artist,
|
||||
coverUrl: data.coverUrl,
|
||||
},
|
||||
create: { stationId, song: data.song, artist: data.artist, coverUrl },
|
||||
update: { song: data.song, artist: data.artist, coverUrl },
|
||||
});
|
||||
|
||||
this.gateway.broadcastNowPlaying(stationId, {
|
||||
song: data.song,
|
||||
artist: data.artist,
|
||||
coverUrl: data.coverUrl,
|
||||
coverUrl,
|
||||
updatedAt: nowPlaying.updatedAt,
|
||||
});
|
||||
|
||||
@@ -147,7 +112,7 @@ export class NowPlayingService {
|
||||
void this.chartsService.recordPlay({
|
||||
artist: data.artist,
|
||||
song: data.song,
|
||||
coverUrl: data.coverUrl,
|
||||
coverUrl,
|
||||
stationDbId: stationId,
|
||||
});
|
||||
}
|
||||
@@ -155,6 +120,96 @@ export class NowPlayingService {
|
||||
return nowPlaying;
|
||||
}
|
||||
|
||||
/**
|
||||
* Универсальный приём now-playing из любого источника (Record / ICY).
|
||||
* Если источник не дал обложку — подставляем обложку обогащённого трека
|
||||
* из нашей БД (по normKey). Обновляет now_playing, шлёт сокет, засчитывает
|
||||
* проигрывание при смене трека (что запускает обогащение через Discogs).
|
||||
*/
|
||||
async ingest(params: {
|
||||
stationDbId: string;
|
||||
stationNumericId: number;
|
||||
artist: string;
|
||||
song: string;
|
||||
coverUrl?: string | null;
|
||||
}): Promise<void> {
|
||||
const { stationDbId, stationNumericId, artist, song } = params;
|
||||
|
||||
// Отсекаем мусор: пустое или JSON-статус в полях (некоторые потоки шлют
|
||||
// в метаданных {"status":...} вместо трека).
|
||||
const a = (artist ?? '').trim();
|
||||
const s = (song ?? '').trim();
|
||||
if (!a || !s || a.startsWith('{') || a.startsWith('[') || s.startsWith('{')) {
|
||||
return;
|
||||
}
|
||||
|
||||
const coverUrl = await this.resolveCover(artist, song, params.coverUrl);
|
||||
|
||||
const prev = await this.prisma.nowPlaying.findUnique({
|
||||
where: { stationId: stationDbId },
|
||||
});
|
||||
|
||||
// Ничего не изменилось (станцию опросили, трек тот же) — не пишем в БД и не
|
||||
// шлём сокет: иначе ~20k бесполезных upsert/час и лишний churn индексов.
|
||||
if (
|
||||
prev &&
|
||||
prev.song === song &&
|
||||
prev.artist === artist &&
|
||||
prev.coverUrl === coverUrl
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await this.prisma.nowPlaying.upsert({
|
||||
where: { stationId: stationDbId },
|
||||
create: { stationId: stationDbId, song, artist, coverUrl },
|
||||
update: { song, artist, coverUrl },
|
||||
});
|
||||
|
||||
this.gateway.broadcastNowPlaying(stationNumericId.toString(), {
|
||||
song,
|
||||
artist,
|
||||
coverUrl,
|
||||
updatedAt: updated.updatedAt,
|
||||
});
|
||||
|
||||
const trackChanged = !prev || prev.song !== song || prev.artist !== artist;
|
||||
if (trackChanged) {
|
||||
void this.chartsService.recordPlay({
|
||||
artist,
|
||||
song,
|
||||
coverUrl,
|
||||
stationDbId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Нормализованный ключ трека — совпадает с ChartsService.recordPlay
|
||||
private buildNormKey(artist: string, song: string): string {
|
||||
return (
|
||||
artist.trim().toLowerCase().replace(/\s+/g, ' ') +
|
||||
'|' +
|
||||
song.trim().toLowerCase().replace(/\s+/g, ' ')
|
||||
);
|
||||
}
|
||||
|
||||
// Обложка: если источник дал — берём её, иначе обложку из обогащённого трека
|
||||
private async resolveCover(
|
||||
artist: string,
|
||||
song: string,
|
||||
provided?: string | null,
|
||||
): Promise<string | null> {
|
||||
if (provided) return provided;
|
||||
const a = (artist ?? '').trim();
|
||||
const s = (song ?? '').trim();
|
||||
if (!a || !s) return null;
|
||||
const track = await this.prisma.track.findUnique({
|
||||
where: { normKey: this.buildNormKey(a, s) },
|
||||
select: { coverUrl: true },
|
||||
});
|
||||
return track?.coverUrl ?? null;
|
||||
}
|
||||
|
||||
async getNowPlaying(stationId: string) {
|
||||
return this.prisma.nowPlaying.findUnique({
|
||||
where: { stationId },
|
||||
@@ -162,8 +217,38 @@ export class NowPlayingService {
|
||||
}
|
||||
|
||||
async getAllNowPlaying() {
|
||||
return this.prisma.nowPlaying.findMany({
|
||||
include: { station: true },
|
||||
// Проекция: контроллеру нужны только stationId+name станции и трек —
|
||||
// не тянем всю строку Station (streamUrl, tags[], даты и т.д.) на каждый ряд.
|
||||
const list = await this.prisma.nowPlaying.findMany({
|
||||
select: {
|
||||
artist: true,
|
||||
song: true,
|
||||
coverUrl: true,
|
||||
station: { select: { stationId: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
// Для записей без своей обложки (ICY-станции типа DFM) подтягиваем обложку
|
||||
// обогащённого трека из нашей БД по normKey — на чтении, чтобы она появлялась
|
||||
// сразу после обогащения, не дожидаясь следующего опроса станции.
|
||||
const missing = list.filter((np) => !np.coverUrl && np.artist && np.song);
|
||||
if (missing.length > 0) {
|
||||
const keys = [
|
||||
...new Set(missing.map((np) => this.buildNormKey(np.artist, np.song))),
|
||||
];
|
||||
const tracks = await this.prisma.track.findMany({
|
||||
where: { normKey: { in: keys }, coverUrl: { not: null } },
|
||||
select: { normKey: true, coverUrl: true },
|
||||
});
|
||||
const coverByKey = new Map(tracks.map((t) => [t.normKey, t.coverUrl]));
|
||||
for (const np of list) {
|
||||
if (!np.coverUrl && np.artist && np.song) {
|
||||
const cover = coverByKey.get(this.buildNormKey(np.artist, np.song));
|
||||
if (cover) np.coverUrl = cover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
140
src/now-playing/orpheus-now-playing.service.ts
Normal file
140
src/now-playing/orpheus-now-playing.service.ts
Normal file
@@ -0,0 +1,140 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
interface IceSource {
|
||||
listenurl?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Now-playing для «Орфей» (классика, radio.orpheus.ru). ICY в потоке часто пуст или
|
||||
* с хекс-плейсхолдером, но Icecast `status-json.xsl` отдаёт title по всем маунтам.
|
||||
* Качество разное: у части каналов нормальный «Композитор — Произведение», у части
|
||||
* мусор (hex/URL/undefined) — его пропускаем. Обложки у классики в iTunes почти нет,
|
||||
* поэтому coverUrl=null (что найдётся — подтянет обогащение). Каналы на
|
||||
* orfeyfm.hostingradio.ru (главный FM) трека не дают — остаются без подписи.
|
||||
*/
|
||||
@Injectable()
|
||||
export class OrpheusNowPlayingService {
|
||||
private readonly logger = new Logger(OrpheusNowPlayingService.name);
|
||||
private readonly statusUrl = 'https://radio.orpheus.ru:8000/status-json.xsl';
|
||||
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollOrpheusNowPlaying() {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { streamUrl: { contains: 'radio.orpheus.ru' } },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
const byMount = await this.loadStatus();
|
||||
if (!byMount) return;
|
||||
|
||||
let updated = 0;
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
const mount = this.extractMount(station.streamUrl);
|
||||
const title = mount ? byMount[mount] : undefined;
|
||||
const parsed = this.parseTitle(title);
|
||||
if (!parsed) return;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist: parsed.artist,
|
||||
song: parsed.song,
|
||||
coverUrl: null,
|
||||
});
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Orpheus poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
private async loadStatus(): Promise<Record<string, string> | null> {
|
||||
try {
|
||||
const res = await fetch(this.statusUrl, { headers: this.headers });
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { icestats?: { source?: IceSource | IceSource[] } };
|
||||
const src = data.icestats?.source;
|
||||
const arr = Array.isArray(src) ? src : src ? [src] : [];
|
||||
const map: Record<string, string> = {};
|
||||
for (const s of arr) {
|
||||
const mount = (s.listenurl ?? '').split('/').pop();
|
||||
if (mount && typeof s.title === 'string') map[mount] = s.title;
|
||||
}
|
||||
return map;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// https://radio.orpheus.ru:8000/Chan_8_192.mp3 → Chan_8_192.mp3
|
||||
private extractMount(streamUrl: string): string | null {
|
||||
const m = streamUrl.match(/\/([A-Za-z0-9_]+\.(?:mp3|aac))(?:$|\?)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Чинит кодировку: реальную кириллицу (U+0400-04FF) не трогаем; «двойную
|
||||
* мойибейк» (cp1251-байты, прочитанные как latin1 и завёрнутые в UTF-8 —
|
||||
* признак латиницы-1 À-ÿ) восстанавливаем latin1→windows-1251.
|
||||
*/
|
||||
private fixEncoding(s: string): string {
|
||||
if (/[Ѐ-ӿ]/.test(s)) return s; // уже корректная кириллица
|
||||
if (/[À-ÿ]/.test(s)) {
|
||||
try {
|
||||
return new TextDecoder('windows-1251').decode(Buffer.from(s, 'latin1'));
|
||||
} catch {
|
||||
return s;
|
||||
}
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
/** Разбирает title «Артист — Произведение», чиня кодировку и отсекая мусор/служебные id. */
|
||||
private parseTitle(title?: string): { artist: string; song: string } | null {
|
||||
if (!title) return null;
|
||||
let t = this.fixEncoding(title).replace(/\s+/g, ' ').trim();
|
||||
if (!t || t.toLowerCase() === 'undefined') return null;
|
||||
// Срезаем хвостовой служебный id трека: « - f0098627», « - 147-1-10», « - 263-2-01»
|
||||
t = t
|
||||
.replace(/\s*[-—]\s*[0-9a-f]{6,}\s*$/i, '')
|
||||
.replace(/\s*[-—]\s*\d+-\d+(?:-\d+)?\s*$/, '')
|
||||
.trim();
|
||||
if (!t) return null;
|
||||
// Целиком мусор: hex-плейсхолдер, числовой код, URL, JSON
|
||||
if (
|
||||
/^[0-9a-f]{4,}$/i.test(t) ||
|
||||
/^\d+-\d+/.test(t) ||
|
||||
t.startsWith('http') ||
|
||||
t.startsWith('{')
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
// Разделитель — длинное тире или дефис с пробелами (оба длиной 3 символа: « X »)
|
||||
const emIdx = t.indexOf(' — ');
|
||||
const idx = emIdx >= 0 ? emIdx : t.indexOf(' - ');
|
||||
if (idx < 0) return null;
|
||||
const artist = t.slice(0, idx).trim();
|
||||
const song = t.slice(idx + 3).trim();
|
||||
if (!artist || !song) return null;
|
||||
// Часть после чистки всё ещё мусорная
|
||||
if (/^[0-9a-f]{4,}$/i.test(song) || /^[0-9a-f]{4,}$/i.test(artist)) return null;
|
||||
return { artist, song };
|
||||
}
|
||||
}
|
||||
84
src/now-playing/radio101-now-playing.service.ts
Normal file
84
src/now-playing/radio101-now-playing.service.ts
Normal file
@@ -0,0 +1,84 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
interface Radio101Resp {
|
||||
result?: {
|
||||
short?: {
|
||||
titleTrack?: string;
|
||||
titleExecutorFull?: string;
|
||||
cover?: { coverOriginal?: string };
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Now-playing для станций на платформе 101.ru (Comedy Radio, Радио Energy/NRJ).
|
||||
* Потоки pub*.101.ru/stream/.../{channelId} — id канала = последний сегмент.
|
||||
* Трек: `https://101.ru/api/channel/getTrackOnAir/{id}/?dataFormat=json&idcity=1`
|
||||
* → result.short {titleExecutorFull, titleTrack, cover.coverOriginal}. Обложка —
|
||||
* cdn0.101.ru + coverOriginal. (Comedy: «комик - реприза» — это их «в эфире».)
|
||||
*/
|
||||
@Injectable()
|
||||
export class Radio101NowPlayingService {
|
||||
private readonly logger = new Logger(Radio101NowPlayingService.name);
|
||||
private readonly coverBase = 'https://cdn0.101.ru';
|
||||
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollRadio101NowPlaying() {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { streamUrl: { contains: '.101.ru' } },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
let updated = 0;
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
const id = this.extractChannelId(station.streamUrl);
|
||||
if (!id) return;
|
||||
|
||||
const res = await fetch(
|
||||
`https://101.ru/api/channel/getTrackOnAir/${id}/?dataFormat=json&idcity=1`,
|
||||
{ headers: this.headers, redirect: 'follow' },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
const short = ((await res.json()) as Radio101Resp).result?.short;
|
||||
const artist = (short?.titleExecutorFull ?? '').trim();
|
||||
const song = (short?.titleTrack ?? '').trim();
|
||||
if (!artist || !song) return;
|
||||
|
||||
const coverPath = short?.cover?.coverOriginal;
|
||||
const coverUrl = coverPath ? this.coverBase + coverPath : null;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist,
|
||||
song,
|
||||
coverUrl,
|
||||
});
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Radio101 poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
// https://pub0302.101.ru:8000/stream/pro/aac/64/446 → 446
|
||||
private extractChannelId(streamUrl: string): string | null {
|
||||
const m = streamUrl.match(/\/(\d+)\/?$/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
}
|
||||
97
src/now-playing/roks-now-playing.service.ts
Normal file
97
src/now-playing/roks-now-playing.service.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
interface TavrTrack {
|
||||
singer?: string;
|
||||
song?: string;
|
||||
cover?: string;
|
||||
type?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Now-playing для Radio ROKS (TavR Media). Главный канал не отдаёт трек по ICY
|
||||
* (StreamTitle пустой), а сабканалы — без обложек. У TavR есть JSON-API, который
|
||||
* даёт и трек, и обложку (static.radioroks.ua, 500×500):
|
||||
* • https://o.tavr.media/roks — главный канал, [0] = текущий трек
|
||||
* • https://o.tavr.media/roks4songs — сабканалы, по полю type (ukr/bal/new/har)
|
||||
*/
|
||||
@Injectable()
|
||||
export class RoksNowPlayingService {
|
||||
private readonly logger = new Logger(RoksNowPlayingService.name);
|
||||
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
|
||||
|
||||
// Подстрока в имени нашей станции -> type в roks4songs.
|
||||
// HD-варианты ловятся теми же правилами (Ballads HD, New Rock HD и т.д.).
|
||||
private readonly typeByName: { match: RegExp; type: string }[] = [
|
||||
{ match: /ballads/i, type: 'bal' },
|
||||
{ match: /hard/i, type: 'har' },
|
||||
{ match: /new\s*rock/i, type: 'new' },
|
||||
{ match: /ukrai|укра/i, type: 'ukr' },
|
||||
];
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollRoks() {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { genre: 'Radio ROKS' },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
const [main, subs] = await Promise.all([
|
||||
this.fetchTavr('https://o.tavr.media/roks'),
|
||||
this.fetchTavr('https://o.tavr.media/roks4songs'),
|
||||
]);
|
||||
if (!main && !subs) return;
|
||||
|
||||
const mainCur = main?.[0];
|
||||
// type -> текущий трек сабканала (первый по времени = играющий сейчас)
|
||||
const subByType = new Map<string, TavrTrack>();
|
||||
for (const s of subs ?? []) {
|
||||
if (s.type && !subByType.has(s.type)) subByType.set(s.type, s);
|
||||
}
|
||||
|
||||
let updated = 0;
|
||||
for (const station of stations) {
|
||||
const rule = this.typeByName.find((r) => r.match.test(station.name));
|
||||
const cur = rule ? subByType.get(rule.type) : mainCur;
|
||||
if (!cur?.singer || !cur?.song) continue;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist: cur.singer.trim(),
|
||||
song: cur.song.trim(),
|
||||
coverUrl: cur.cover || null,
|
||||
});
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}
|
||||
|
||||
this.logger.log(`ROKS poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
private async fetchTavr(url: string): Promise<TavrTrack[] | null> {
|
||||
try {
|
||||
const res = await fetch(url, { headers: this.headers });
|
||||
if (!res.ok) {
|
||||
this.logger.warn(`${url} вернул ${res.status}`);
|
||||
return null;
|
||||
}
|
||||
return (await res.json()) as TavrTrack[];
|
||||
} catch (e) {
|
||||
this.logger.warn(`${url}: ${(e as Error).message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
117
src/now-playing/spb-radio-now-playing.service.ts
Normal file
117
src/now-playing/spb-radio-now-playing.service.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
interface SpbStream {
|
||||
id: number;
|
||||
link?: string;
|
||||
}
|
||||
interface SpbPlaylist {
|
||||
items?: Array<{
|
||||
track?: {
|
||||
name?: string;
|
||||
imglarge?: string;
|
||||
imgsmall?: string;
|
||||
artist?: { name?: string };
|
||||
};
|
||||
}>;
|
||||
}
|
||||
|
||||
// Сети на одном движке (radiopiterfm.ru / radiovanya.ru — один разработчик, СПб):
|
||||
// API `/api/v1/streams/` (link↔id) + `/api/v5/playlists/{id}/` → items[0].track.
|
||||
const NETWORKS = [
|
||||
{ base: 'https://radiopiterfm.ru', host: 'piterfm.cdnvideo.ru', label: 'PiterFM' },
|
||||
{ base: 'https://radiovanya.ru', host: 'radiovanya.cdnvideo.ru', label: 'RadioVanya' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Now-playing для Питер ФМ и Радио Ваня. Потоки (cdnvideo Icecast) НЕ дают трек по ICY
|
||||
* (пусто / URL сайта), но у сайтов общий API. Матчим станцию по МАУНТУ из поля `link`
|
||||
* стрима (у Вани slug≠маунт, поэтому не по slug). Обложки готовые (iTunes mzstatic).
|
||||
*/
|
||||
@Injectable()
|
||||
export class SpbRadioNowPlayingService {
|
||||
private readonly logger = new Logger(SpbRadioNowPlayingService.name);
|
||||
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollSpbNowPlaying() {
|
||||
for (const net of NETWORKS) {
|
||||
await this.pollNetwork(net).catch((e) =>
|
||||
this.logger.warn(`${net.label}: ${e?.message ?? e}`),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private async pollNetwork(net: (typeof NETWORKS)[number]) {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { streamUrl: { contains: net.host } },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
const mountToId = await this.loadStreamMap(net.base);
|
||||
if (!mountToId) return;
|
||||
|
||||
let updated = 0;
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
const mount = this.extractMount(station.streamUrl);
|
||||
const id = mount ? mountToId[mount.toLowerCase()] : undefined;
|
||||
if (id === undefined) return;
|
||||
|
||||
const res = await fetch(`${net.base}/api/v5/playlists/${id}/`, {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
const t = ((await res.json()) as SpbPlaylist).items?.[0]?.track;
|
||||
const artist = (t?.artist?.name ?? '').trim();
|
||||
const song = (t?.name ?? '').trim();
|
||||
if (!artist || !song) return;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist,
|
||||
song,
|
||||
coverUrl: t?.imglarge ?? t?.imgsmall ?? null,
|
||||
});
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
this.logger.log(`${net.label} poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
private async loadStreamMap(base: string): Promise<Record<string, number> | null> {
|
||||
try {
|
||||
const res = await fetch(`${base}/api/v1/streams/`, { headers: this.headers });
|
||||
if (!res.ok) return null;
|
||||
const data = (await res.json()) as { items?: SpbStream[] };
|
||||
const map: Record<string, number> = {};
|
||||
for (const s of data.items ?? []) {
|
||||
const mount = this.extractMount(s.link ?? '');
|
||||
if (mount) map[mount.toLowerCase()] = s.id;
|
||||
}
|
||||
return map;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// .../cdnvideo.ru/pfm_ddt → pfm_ddt ; .../radiovanya → radiovanya
|
||||
private extractMount(url: string): string | null {
|
||||
const m = url.match(/cdnvideo\.ru\/([A-Za-z0-9_]+)/i);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
}
|
||||
106
src/now-playing/unistar-now-playing.service.ts
Normal file
106
src/now-playing/unistar-now-playing.service.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
// Ответ https://api3.unistar.by/client/latest/{slug}
|
||||
interface UnistarLatest {
|
||||
latest: {
|
||||
channel_name?: string;
|
||||
element_data?: {
|
||||
Type?: string; // Music | Commercial | Program | Jingle | News...
|
||||
Title?: string;
|
||||
Artist?: string;
|
||||
PictureFile?: string;
|
||||
FullPictureUrl?: string;
|
||||
};
|
||||
} | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Now-playing для каналов Unistar (Беларусь). Все 8 каналов вещают через HLS
|
||||
* (usp.unistar.by) — ICY-метаданных нет, поэтому трек берём из их API:
|
||||
* GET https://api3.unistar.by/client/latest/{slug}, где slug = alt_name канала
|
||||
* (unistar_main, unistar_top, ...), он же сегмент пути потока /hls/{slug}/master.m3u8.
|
||||
* API отдаёт исполнителя, название и имя файла обложки (своя картинка трека).
|
||||
*/
|
||||
@Injectable()
|
||||
export class UnistarNowPlayingService {
|
||||
private readonly logger = new Logger(UnistarNowPlayingService.name);
|
||||
// База для имён файлов обложек (pics_path из appData плеера Unistar)
|
||||
private readonly picsBase = 'https://unistar.by/upload/music/photos/';
|
||||
private readonly headers = {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Origin: 'https://unistar.by',
|
||||
Referer: 'https://unistar.by/',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollUnistarNowPlaying() {
|
||||
// Не фильтруем по isOnline: health-check ошибочно метит HLS-потоки offline.
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { streamUrl: { contains: 'unistar.by' } },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
let updated = 0;
|
||||
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
const slug = this.extractSlug(station.streamUrl);
|
||||
if (!slug) return;
|
||||
|
||||
const res = await fetch(
|
||||
`https://api3.unistar.by/client/latest/${slug}`,
|
||||
{ headers: this.headers },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = (await res.json()) as UnistarLatest;
|
||||
const el = data.latest?.element_data;
|
||||
// Только музыка: рекламу/программы/джинглы не показываем как трек.
|
||||
if (!el || el.Type !== 'Music') return;
|
||||
|
||||
const artist = (el.Artist ?? '').trim();
|
||||
const song = (el.Title ?? '').trim();
|
||||
if (!artist || !song) return;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist,
|
||||
song,
|
||||
coverUrl: this.buildCoverUrl(el.PictureFile ?? el.FullPictureUrl),
|
||||
});
|
||||
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Unistar poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
// http://edge1.usp.unistar.by/hls/unistar_top/master.m3u8 → unistar_top
|
||||
private extractSlug(streamUrl: string): string | null {
|
||||
const m = streamUrl.match(/\/hls\/([a-z0-9_]+)\//i);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
|
||||
// Имя файла обложки → полный URL (или абсолютный URL как есть)
|
||||
private buildCoverUrl(pic?: string): string | null {
|
||||
const p = (pic ?? '').trim();
|
||||
if (!p) return null;
|
||||
return p.startsWith('http') ? p : this.picsBase + p;
|
||||
}
|
||||
}
|
||||
99
src/now-playing/volna-now-playing.service.ts
Normal file
99
src/now-playing/volna-now-playing.service.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
/**
|
||||
* Now-playing для «Русская Волна» (сеть Волна / amgradio.ru, ~27 каналов). Потоки
|
||||
* mp3.amgradio.ru НЕ отдают ICY. Единый JSON со всеми каналами:
|
||||
* `https://info.volna.top/radio.json` → поля `{prefix}_title` = «АРТИСТ - ПЕСНЯ».
|
||||
* Маунт нашего потока (RusRock128, ChillaFM128…) приводим к префиксу volna
|
||||
* (rusrock, chilla — с fallback на отброс «fm»). Обложек у них нет (covers 404) →
|
||||
* подтянет обогащение (iTunes/Deezer).
|
||||
*/
|
||||
@Injectable()
|
||||
export class VolnaNowPlayingService {
|
||||
private readonly logger = new Logger(VolnaNowPlayingService.name);
|
||||
private readonly headers = { 'User-Agent': 'Mozilla/5.0' };
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollVolnaNowPlaying() {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { streamUrl: { contains: 'amgradio.ru' } },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
let data: Record<string, unknown> | null = null;
|
||||
try {
|
||||
const res = await fetch('https://info.volna.top/radio.json', {
|
||||
headers: this.headers,
|
||||
});
|
||||
if (res.ok) data = (await res.json()) as Record<string, unknown>;
|
||||
} catch {
|
||||
data = null;
|
||||
}
|
||||
if (!data) return;
|
||||
|
||||
const prefixes = new Set(
|
||||
Object.keys(data)
|
||||
.filter((k) => k.endsWith('_title'))
|
||||
.map((k) => k.slice(0, -'_title'.length)),
|
||||
);
|
||||
|
||||
let updated = 0;
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
const prefix = this.resolvePrefix(station.streamUrl, prefixes);
|
||||
if (!prefix) return;
|
||||
const raw = data[`${prefix}_title`];
|
||||
const parsed = this.parseTitle(typeof raw === 'string' ? raw : undefined);
|
||||
if (!parsed) return;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist: parsed.artist,
|
||||
song: parsed.song,
|
||||
coverUrl: null,
|
||||
});
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
this.logger.log(`Volna poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
// mp3.amgradio.ru/RusRock128 → rusrock ; ChillaFM128 → chilla (fallback -fm)
|
||||
private resolvePrefix(streamUrl: string, prefixes: Set<string>): string | null {
|
||||
const m = streamUrl.match(/amgradio\.ru\/([A-Za-z0-9_]+)/i);
|
||||
if (!m) return null;
|
||||
const norm = m[1].replace(/\d+$/, '').toLowerCase();
|
||||
if (prefixes.has(norm)) return norm;
|
||||
if (norm.endsWith('fm') && prefixes.has(norm.slice(0, -2))) {
|
||||
return norm.slice(0, -2);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private parseTitle(title?: string): { artist: string; song: string } | null {
|
||||
if (!title) return null;
|
||||
const t = title.trim();
|
||||
if (!t || /listen radio|^https?:/i.test(t)) return null;
|
||||
const idx = t.indexOf(' - ');
|
||||
if (idx < 0) return null;
|
||||
const artist = t.slice(0, idx).trim();
|
||||
const song = t.slice(idx + 3).trim();
|
||||
if (!artist || !song) return null;
|
||||
return { artist, song };
|
||||
}
|
||||
}
|
||||
100
src/now-playing/zaycev-now-playing.service.ts
Normal file
100
src/now-playing/zaycev-now-playing.service.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Interval } from '@nestjs/schedule';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { NowPlayingService } from './now-playing.service';
|
||||
|
||||
// Ответ https://www.zaycev.fm/api/v1/recent?channel={slug}&limit=1
|
||||
interface ZaycevRecentItem {
|
||||
track?: {
|
||||
artist?: string;
|
||||
title?: string;
|
||||
is_music?: boolean;
|
||||
img?: string;
|
||||
images?: {
|
||||
medium?: string;
|
||||
large?: string;
|
||||
extralarge?: string;
|
||||
};
|
||||
};
|
||||
station_alias?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Now-playing для каналов Зайцев ФМ. Их MP3-потоки (abs.zaycev.fm/{slug}256k)
|
||||
* НЕ отдают ICY-метаданных, поэтому трек берём из API сайта:
|
||||
* GET https://www.zaycev.fm/api/v1/recent?channel={slug}&limit=1 — массив, [0] =
|
||||
* текущий трек с artist/title и готовыми обложками (radio2.zaycev.fm/artistimages).
|
||||
* slug = буквенная часть имени потока (pop256k → pop), совпадает со station_alias.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ZaycevNowPlayingService {
|
||||
private readonly logger = new Logger(ZaycevNowPlayingService.name);
|
||||
private readonly headers = {
|
||||
'User-Agent': 'Mozilla/5.0',
|
||||
Referer: 'https://www.zaycev.fm/',
|
||||
};
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly nowPlayingService: NowPlayingService,
|
||||
) {}
|
||||
|
||||
@Interval(30000)
|
||||
async pollZaycevNowPlaying() {
|
||||
const stations = await this.prisma.station.findMany({
|
||||
where: { streamUrl: { contains: 'abs.zaycev.fm' } },
|
||||
});
|
||||
if (stations.length === 0) return;
|
||||
|
||||
let updated = 0;
|
||||
|
||||
await Promise.allSettled(
|
||||
stations.map(async (station) => {
|
||||
const slug = this.extractSlug(station.streamUrl);
|
||||
if (!slug) return;
|
||||
|
||||
const res = await fetch(
|
||||
`https://www.zaycev.fm/api/v1/recent?channel=${slug}&limit=1`,
|
||||
{ headers: this.headers },
|
||||
);
|
||||
if (!res.ok) return;
|
||||
|
||||
const arr = (await res.json()) as ZaycevRecentItem[] | unknown;
|
||||
const cur = Array.isArray(arr) ? arr[0] : null;
|
||||
const t = cur?.track;
|
||||
if (!t || t.is_music === false) return;
|
||||
|
||||
const artist = (t.artist ?? '').trim();
|
||||
const song = (t.title ?? '').trim();
|
||||
if (!artist || !song) return;
|
||||
|
||||
const coverUrl =
|
||||
t.images?.large ?? t.images?.extralarge ?? t.images?.medium ?? t.img ?? null;
|
||||
|
||||
await this.nowPlayingService.ingest({
|
||||
stationDbId: station.id,
|
||||
stationNumericId: station.stationId,
|
||||
artist,
|
||||
song,
|
||||
coverUrl,
|
||||
});
|
||||
|
||||
if (!station.isOnline) {
|
||||
await this.prisma.station.update({
|
||||
where: { id: station.id },
|
||||
data: { isOnline: true },
|
||||
});
|
||||
}
|
||||
updated++;
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.log(`Zaycev poll: ${updated}/${stations.length} обновлено`);
|
||||
}
|
||||
|
||||
// http://abs.zaycev.fm/pop256k → pop ; rurock256k → rurock
|
||||
private extractSlug(streamUrl: string): string | null {
|
||||
const m = streamUrl.match(/abs\.zaycev\.fm\/([a-z]+)\d/i);
|
||||
return m ? m[1].toLowerCase() : null;
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,29 @@
|
||||
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
/**
|
||||
* Добавляет connection_limit в URL, если он не задан явно. По умолчанию Prisma
|
||||
* берёт num_cpu*2+1 (на мелком VPS ~5), а у нас ~16 now-playing-поллеров + чарты
|
||||
* шлют запросы конкурентно — пул из 20 устойчивее под всплески. Идемпотентно:
|
||||
* если параметр уже есть в DATABASE_URL — не трогаем.
|
||||
*/
|
||||
function withConnectionLimit(url?: string): string | undefined {
|
||||
if (!url || /[?&]connection_limit=/.test(url)) return url;
|
||||
const sep = url.includes('?') ? '&' : '?';
|
||||
return `${url}${sep}connection_limit=20`;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
constructor() {
|
||||
super({
|
||||
datasources: { db: { url: withConnectionLimit(process.env.DATABASE_URL) } },
|
||||
});
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
88
src/privacy/privacy.controller.ts
Normal file
88
src/privacy/privacy.controller.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
import { Controller, Get, Header } from '@nestjs/common';
|
||||
|
||||
// Статическая страница политики конфиденциальности (для карточки RuStore).
|
||||
// HTML хранится константой — без внешних файлов, чтобы не зависеть от копирования
|
||||
// ассетов в Docker-образ.
|
||||
const PRIVACY_HTML = `<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Политика конфиденциальности radiOLA</title>
|
||||
<style>
|
||||
body{font:16px/1.6 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;max-width:760px;margin:40px auto;padding:0 18px;color:#1a1a1a;background:#fff}
|
||||
h1{font-size:1.6rem;margin-bottom:.2rem}
|
||||
h2{font-size:1.15rem;margin-top:2rem}
|
||||
.date{color:#666;margin-top:0}
|
||||
ul{padding-left:1.2rem}
|
||||
a{color:#2b7a2b}
|
||||
footer{margin-top:3rem;color:#888;font-size:.9rem}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Политика конфиденциальности приложения radiOLA</h1>
|
||||
<p class="date">Дата вступления в силу: 8 июня 2026 г.</p>
|
||||
|
||||
<p>Настоящая Политика описывает, какие данные обрабатывает мобильное приложение
|
||||
radiOLA («Приложение»), с какой целью и как вы можете ими управлять. Используя
|
||||
Приложение, вы соглашаетесь с условиями настоящей Политики.</p>
|
||||
|
||||
<h2>1. Какие данные мы обрабатываем</h2>
|
||||
<ul>
|
||||
<li><b>Адрес электронной почты</b> — только если вы добровольно входите в аккаунт.
|
||||
Вход не обязателен и нужен лишь для синхронизации избранного и истории между
|
||||
устройствами. Авторизация выполняется по одноразовой ссылке (magic-link); пароль
|
||||
мы не храним.</li>
|
||||
<li><b>История прослушиваний и распознанных треков</b> — сохраняется локально на
|
||||
вашем устройстве. При входе в аккаунт история станций также сохраняется на сервере
|
||||
для синхронизации.</li>
|
||||
<li><b>Технические данные об ошибках</b> — при сбоях Приложение может отправлять
|
||||
обезличенный отчёт об ошибке (тип устройства, версия приложения, стек вызовов) в
|
||||
нашу систему мониторинга для исправления проблем.</li>
|
||||
</ul>
|
||||
<p>Приложение <b>не</b> собирает ваши контакты, геолокацию, СМС и не отслеживает вас
|
||||
в других приложениях.</p>
|
||||
|
||||
<h2>2. Сторонние сервисы</h2>
|
||||
<ul>
|
||||
<li><b>shazam-api.com</b> — при нажатии кнопки «Распознать трек» короткий фрагмент
|
||||
аудио текущего радиопотока передаётся сервису распознавания музыки для определения
|
||||
исполнителя и названия. Фрагмент не содержит ваших персональных данных.</li>
|
||||
<li><b>Discogs</b> — для получения обложек и сведений о треках мы передаём название
|
||||
и исполнителя композиции.</li>
|
||||
<li><b>Радиостанции третьих лиц</b> — воспроизведение ведётся напрямую с серверов
|
||||
вещателей; на их потоки распространяются их собственные условия.</li>
|
||||
</ul>
|
||||
|
||||
<h2>3. Цели обработки</h2>
|
||||
<p>Данные обрабатываются исключительно для работы функций Приложения: воспроизведение
|
||||
радио, синхронизация избранного и истории, распознавание треков, отображение обложек и
|
||||
исправление технических ошибок. Мы не продаём и не передаём ваши данные третьим лицам
|
||||
в рекламных целях.</p>
|
||||
|
||||
<h2>4. Хранение и удаление</h2>
|
||||
<p>Локальные данные (история, настройки) хранятся на вашем устройстве и удаляются при
|
||||
удалении Приложения или очистке его данных. Данные аккаунта на сервере хранятся, пока
|
||||
существует аккаунт. Вы можете запросить удаление аккаунта и связанных данных, написав
|
||||
нам (см. контакты ниже).</p>
|
||||
|
||||
<h2>5. Изменения политики</h2>
|
||||
<p>Мы можем обновлять настоящую Политику. Актуальная версия всегда доступна по этому
|
||||
адресу.</p>
|
||||
|
||||
<h2>6. Контакты</h2>
|
||||
<p>По вопросам обработки персональных данных и для запроса на их удаление:
|
||||
<a href="mailto:blinnafeg@gmail.com">blinnafeg@gmail.com</a>.</p>
|
||||
|
||||
<footer>radiOLA — интернет-радио. © 2026</footer>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
@Controller()
|
||||
export class PrivacyController {
|
||||
@Get('privacy')
|
||||
@Header('Content-Type', 'text/html; charset=utf-8')
|
||||
getPrivacy(): string {
|
||||
return PRIVACY_HTML;
|
||||
}
|
||||
}
|
||||
5
src/privacy/privacy.module.ts
Normal file
5
src/privacy/privacy.module.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrivacyController } from './privacy.controller';
|
||||
|
||||
@Module({ controllers: [PrivacyController] })
|
||||
export class PrivacyModule {}
|
||||
113
src/shazam/shazam.client.ts
Normal file
113
src/shazam/shazam.client.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export interface RecognitionResult {
|
||||
artist: string;
|
||||
title: string;
|
||||
coverUrl: string | null;
|
||||
album: string | null;
|
||||
}
|
||||
|
||||
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
/**
|
||||
* Адаптер к shazam-api.com. API асинхронный (две стадии):
|
||||
* 1) POST {base}/recognize (multipart: file) → { uuid, status:"processing" }
|
||||
* 2) POST {base}/results/{uuid} — поллим, пока status != "completed";
|
||||
* результат: results[0].track.{title, subtitle(=исполнитель)} (обложки нет).
|
||||
*
|
||||
* Авторизация: заголовок `Authorization: Bearer <key>`.
|
||||
* Настройки через env:
|
||||
* SHAZAM_API_KEY — ключ (ОБЯЗАТЕЛЬНО; в git НЕ коммитим, только env на сервере)
|
||||
* SHAZAM_API_URL — база API (необязательно, по умолч. https://shazam-api.com/api)
|
||||
*/
|
||||
@Injectable()
|
||||
export class ShazamClient {
|
||||
private readonly logger = new Logger(ShazamClient.name);
|
||||
private readonly DEFAULT_BASE = 'https://shazam-api.com/api';
|
||||
// Бюджет поллинга: ~12 попыток × 1.2с ≈ до 15с ожидания распознавания.
|
||||
private readonly POLL_ATTEMPTS = 12;
|
||||
private readonly POLL_INTERVAL_MS = 1200;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
isConfigured(): boolean {
|
||||
return Boolean(this.config.get<string>('SHAZAM_API_KEY'));
|
||||
}
|
||||
|
||||
private base(): string {
|
||||
return this.config.get<string>('SHAZAM_API_URL') ?? this.DEFAULT_BASE;
|
||||
}
|
||||
|
||||
private authHeader(): Record<string, string> {
|
||||
const key = this.config.get<string>('SHAZAM_API_KEY');
|
||||
if (!key) throw new Error('Shazam API key is not configured');
|
||||
return { Authorization: `Bearer ${key}` };
|
||||
}
|
||||
|
||||
/**
|
||||
* Распознать трек по аудио-фрагменту. null — сервис ничего не нашёл
|
||||
* (тишина/реклама/джингл) или не успел за бюджет поллинга. Бросает при
|
||||
* сетевой ошибке / отказе API (401/403/4xx-5xx).
|
||||
*/
|
||||
async recognize(
|
||||
audio: Buffer,
|
||||
contentType = 'audio/mpeg',
|
||||
): Promise<RecognitionResult | null> {
|
||||
const uuid = await this.submit(audio, contentType);
|
||||
return this.pollResult(uuid);
|
||||
}
|
||||
|
||||
/** Стадия 1: загрузка аудио, получение uuid задачи. */
|
||||
private async submit(audio: Buffer, contentType: string): Promise<string> {
|
||||
const form = new FormData();
|
||||
const blob = new Blob([new Uint8Array(audio)], { type: contentType });
|
||||
form.append('file', blob, 'sample.mp3');
|
||||
|
||||
const res = await fetch(`${this.base()}/recognize`, {
|
||||
method: 'POST',
|
||||
headers: this.authHeader(),
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Shazam recognize ${res.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
const data = (await res.json()) as { uuid?: string };
|
||||
if (!data?.uuid) throw new Error('Shazam recognize: нет uuid в ответе');
|
||||
return data.uuid;
|
||||
}
|
||||
|
||||
/** Стадия 2: поллинг результата по uuid до status="completed". */
|
||||
private async pollResult(uuid: string): Promise<RecognitionResult | null> {
|
||||
for (let i = 0; i < this.POLL_ATTEMPTS; i++) {
|
||||
await delay(this.POLL_INTERVAL_MS);
|
||||
|
||||
const res = await fetch(`${this.base()}/results/${uuid}`, {
|
||||
method: 'POST',
|
||||
headers: this.authHeader(),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const body = await res.text().catch(() => '');
|
||||
throw new Error(`Shazam results ${res.status}: ${body.slice(0, 200)}`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as {
|
||||
status?: string;
|
||||
results?: Array<{ track?: { title?: string; subtitle?: string } }>;
|
||||
};
|
||||
|
||||
if (data.status === 'completed') {
|
||||
const track = data.results?.[0]?.track;
|
||||
const title = track?.title?.trim();
|
||||
const artist = track?.subtitle?.trim();
|
||||
if (!title || !artist) return null; // completed, но матча нет
|
||||
return { artist, title, coverUrl: null, album: null };
|
||||
}
|
||||
// status "processing" — ждём следующую попытку
|
||||
}
|
||||
// Не успели за бюджет — считаем, что не распознали.
|
||||
this.logger.warn(`Shazam: поллинг ${uuid} истёк без результата`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
17
src/shazam/shazam.controller.ts
Normal file
17
src/shazam/shazam.controller.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Controller, Post, Param, ParseIntPipe } from '@nestjs/common';
|
||||
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
||||
import { ShazamService } from './shazam.service';
|
||||
|
||||
@ApiTags('shazam')
|
||||
@Controller('shazam')
|
||||
export class ShazamController {
|
||||
constructor(private readonly shazamService: ShazamService) {}
|
||||
|
||||
@Post('recognize/:stationId')
|
||||
@ApiOperation({
|
||||
summary: 'Распознать играющий сейчас трек на станции (по station_id)',
|
||||
})
|
||||
async recognize(@Param('stationId', ParseIntPipe) stationId: number) {
|
||||
return this.shazamService.recognize(stationId);
|
||||
}
|
||||
}
|
||||
12
src/shazam/shazam.module.ts
Normal file
12
src/shazam/shazam.module.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ShazamController } from './shazam.controller';
|
||||
import { ShazamService } from './shazam.service';
|
||||
import { ShazamClient } from './shazam.client';
|
||||
import { PrismaModule } from '../prisma/prisma.module';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [ShazamController],
|
||||
providers: [ShazamService, ShazamClient],
|
||||
})
|
||||
export class ShazamModule {}
|
||||
129
src/shazam/shazam.service.ts
Normal file
129
src/shazam/shazam.service.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import {
|
||||
Injectable,
|
||||
Logger,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ServiceUnavailableException,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { ShazamClient } from './shazam.client';
|
||||
import { fetchStreamChunk } from './stream-audio';
|
||||
import { isMusicStation } from '../common/station-classification';
|
||||
|
||||
export interface RecognizeResponse {
|
||||
matched: boolean;
|
||||
artist?: string;
|
||||
song?: string;
|
||||
coverUrl?: string | null;
|
||||
album?: string | null;
|
||||
}
|
||||
|
||||
interface CacheEntry {
|
||||
at: number;
|
||||
result: RecognizeResponse;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ShazamService {
|
||||
private readonly logger = new Logger(ShazamService.name);
|
||||
// Кэш последнего результата по станции: троттлинг + экономия платных вызовов,
|
||||
// когда несколько клиентов распознают одну станцию почти одновременно.
|
||||
private readonly cache = new Map<number, CacheEntry>();
|
||||
private readonly CACHE_TTL_MS = 15000;
|
||||
// Глобальный лимит реальных вызовов Shazam (платные коины) — защита баланса
|
||||
// от перебора станций. Кэш-хиты сюда не считаются.
|
||||
private readonly recentCalls: number[] = [];
|
||||
private readonly MAX_PER_MIN = 30;
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly shazam: ShazamClient,
|
||||
) {}
|
||||
|
||||
async recognize(
|
||||
stationId: number,
|
||||
now: number = Date.now(),
|
||||
): Promise<RecognizeResponse> {
|
||||
if (!this.shazam.isConfigured()) {
|
||||
throw new ServiceUnavailableException('Распознавание временно недоступно');
|
||||
}
|
||||
|
||||
const cached = this.cache.get(stationId);
|
||||
if (cached && now - cached.at < this.CACHE_TTL_MS) {
|
||||
return cached.result;
|
||||
}
|
||||
|
||||
const station = await this.prisma.station.findUnique({
|
||||
where: { stationId },
|
||||
select: { streamUrl: true, genre: true, name: true },
|
||||
});
|
||||
if (!station) throw new NotFoundException('Станция не найдена');
|
||||
if (!isMusicStation(station.genre)) {
|
||||
throw new BadRequestException('На этой станции нет музыки');
|
||||
}
|
||||
|
||||
this.checkRateLimit(now);
|
||||
|
||||
let result: RecognizeResponse;
|
||||
try {
|
||||
const audio = await fetchStreamChunk(station.streamUrl);
|
||||
const match = await this.shazam.recognize(audio);
|
||||
if (!match) {
|
||||
result = { matched: false };
|
||||
} else {
|
||||
const coverUrl =
|
||||
match.coverUrl ??
|
||||
(await this.resolveCover(match.artist, match.title));
|
||||
result = {
|
||||
matched: true,
|
||||
artist: match.artist,
|
||||
song: match.title,
|
||||
coverUrl,
|
||||
album: match.album,
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(
|
||||
`Распознавание «${station.name}» не удалось: ${(err as Error).message}`,
|
||||
);
|
||||
throw new ServiceUnavailableException('Не удалось распознать трек');
|
||||
}
|
||||
|
||||
this.cache.set(stationId, { at: now, result });
|
||||
return result;
|
||||
}
|
||||
|
||||
// Скользящее окно 60с по реальным (не кэшированным) вызовам Shazam.
|
||||
private checkRateLimit(now: number): void {
|
||||
const cutoff = now - 60000;
|
||||
while (this.recentCalls.length && this.recentCalls[0] < cutoff) {
|
||||
this.recentCalls.shift();
|
||||
}
|
||||
if (this.recentCalls.length >= this.MAX_PER_MIN) {
|
||||
throw new HttpException(
|
||||
'Слишком много запросов на распознавание, попробуйте позже',
|
||||
HttpStatus.TOO_MANY_REQUESTS,
|
||||
);
|
||||
}
|
||||
this.recentCalls.push(now);
|
||||
}
|
||||
|
||||
// Если у распознанного трека нет обложки от Shazam — пробуем взять обложку
|
||||
// уже обогащённого трека из нашей БД (по тому же normKey, что и чарты).
|
||||
private async resolveCover(
|
||||
artist: string,
|
||||
song: string,
|
||||
): Promise<string | null> {
|
||||
const normKey =
|
||||
artist.trim().toLowerCase().replace(/\s+/g, ' ') +
|
||||
'|' +
|
||||
song.trim().toLowerCase().replace(/\s+/g, ' ');
|
||||
const track = await this.prisma.track.findUnique({
|
||||
where: { normKey },
|
||||
select: { coverUrl: true },
|
||||
});
|
||||
return track?.coverUrl ?? null;
|
||||
}
|
||||
}
|
||||
57
src/shazam/stream-audio.ts
Normal file
57
src/shazam/stream-audio.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Захват короткого аудио-фрагмента из вещательного потока (icecast/shoutcast,
|
||||
* mp3/aac). Открываем поток, копим байты до целевого размера или таймаута, затем
|
||||
* обрываем соединение. Этого фрагмента хватает Shazam для построения отпечатка.
|
||||
*
|
||||
* Размер по умолчанию рассчитан на ~6 сек при 128 kbps (≈96 KB). Точная
|
||||
* длительность не важна — алгоритму распознавания достаточно нескольких секунд.
|
||||
*/
|
||||
export async function fetchStreamChunk(
|
||||
streamUrl: string,
|
||||
opts: { bytes?: number; timeoutMs?: number } = {},
|
||||
): Promise<Buffer> {
|
||||
const targetBytes = opts.bytes ?? 96 * 1024;
|
||||
const timeoutMs = opts.timeoutMs ?? 12000;
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
|
||||
try {
|
||||
const res = await fetch(streamUrl, {
|
||||
signal: controller.signal,
|
||||
headers: {
|
||||
// Часть icecast-серверов без User-Agent отдаёт 403/плейлист.
|
||||
'User-Agent': 'radiOLA/1.0 (track recognition)',
|
||||
// Просим НЕ слать ICY-метаданные — нам нужно чистое аудио.
|
||||
'Icy-MetaData': '0',
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok || !res.body) {
|
||||
throw new Error(`Stream responded ${res.status}`);
|
||||
}
|
||||
|
||||
const reader = res.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let collected = 0;
|
||||
|
||||
while (collected < targetBytes) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
if (value) {
|
||||
chunks.push(value);
|
||||
collected += value.length;
|
||||
}
|
||||
}
|
||||
|
||||
// Обрываем чтение — больше байт не нужно.
|
||||
await reader.cancel().catch(() => undefined);
|
||||
|
||||
if (collected === 0) {
|
||||
throw new Error('Stream returned no audio');
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,12 @@ export class StationsController {
|
||||
});
|
||||
}
|
||||
|
||||
@Get('offline-ids')
|
||||
@ApiOperation({ summary: 'station_id оффлайн-станций (для скрытия в клиенте)' })
|
||||
async offlineIds() {
|
||||
return this.stationsService.getOfflineStationIds();
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get station by ID' })
|
||||
async findOne(@Param('id') id: string) {
|
||||
|
||||
@@ -35,6 +35,15 @@ export class StationsService {
|
||||
});
|
||||
}
|
||||
|
||||
// station_id оффлайн-станций — для скрытия мёртвых плиток в клиенте
|
||||
async getOfflineStationIds(): Promise<number[]> {
|
||||
const rows = await this.prisma.station.findMany({
|
||||
where: { isOnline: false },
|
||||
select: { stationId: true },
|
||||
});
|
||||
return rows.map((r) => r.stationId);
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const station = await this.prisma.station.findUnique({
|
||||
where: { id },
|
||||
|
||||
Reference in New Issue
Block a user