diff --git a/.env.example b/.env.example index e176f01..f2925ce 100644 --- a/.env.example +++ b/.env.example @@ -19,6 +19,13 @@ MAIL_FROM=radiOLA FRONTEND_URL=https://radiola.app PORT=3000 +# Обогащение треков (Discogs): личный токен из discogs.com → Settings → Developers +DISCOGS_TOKEN= +# Базовый публичный URL бэкенда — для абсолютных ссылок на обложки (/covers/*.webp) +PUBLIC_BASE_URL=http://121.127.37.212:3000 +# Каталог для сохранённых обложек (в docker — volume /data/covers) +COVERS_DIR=/data/covers + # Postgres (for docker-compose) POSTGRES_USER=radiola POSTGRES_PASSWORD=radiola_pass diff --git a/docker-compose.yml b/docker-compose.yml index f39637e..771f043 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,6 +20,12 @@ services: - SMTP_PASS=${SMTP_PASS} - MAIL_FROM=${MAIL_FROM:-noreply@radiola.app} - FRONTEND_URL=${FRONTEND_URL:-https://radiola.app} + # Обогащение треков + - DISCOGS_TOKEN=${DISCOGS_TOKEN} + - COVERS_DIR=/data/covers + - PUBLIC_BASE_URL=${PUBLIC_BASE_URL:-http://121.127.37.212:3000} + volumes: + - covers_data:/data/covers depends_on: postgres: condition: service_healthy @@ -62,6 +68,7 @@ services: volumes: postgres_data: redis_data: + covers_data: networks: radiola: diff --git a/package-lock.json b/package-lock.json index 91e3a9b..183060f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "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" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", @@ -781,7 +782,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 +1022,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 +5457,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 +5486,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 +5844,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 +10120,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 +10267,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", diff --git a/package.json b/package.json index d42886f..56a1942 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,8 @@ "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" }, "devDependencies": { "@eslint/eslintrc": "^3.2.0", diff --git a/prisma/migrations/20250603120000_track_enrichment/migration.sql b/prisma/migrations/20250603120000_track_enrichment/migration.sql new file mode 100644 index 0000000..920e371 --- /dev/null +++ b/prisma/migrations/20250603120000_track_enrichment/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7b069f6..e407446 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -124,12 +124,21 @@ 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]) @@map("tracks") } diff --git a/src/charts/charts.controller.ts b/src/charts/charts.controller.ts index 71dd1e2..3d7e08d 100644 --- a/src/charts/charts.controller.ts +++ b/src/charts/charts.controller.ts @@ -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') diff --git a/src/charts/charts.module.ts b/src/charts/charts.module.ts index 497eeb0..69629f3 100644 --- a/src/charts/charts.module.ts +++ b/src/charts/charts.module.ts @@ -2,9 +2,10 @@ import { Module } from '@nestjs/common'; import { ChartsController } from './charts.controller'; import { ChartsService } from './charts.service'; import { AuthModule } from '../auth/auth.module'; +import { EnrichModule } from '../enrich/enrich.module'; @Module({ - imports: [AuthModule], + imports: [AuthModule, EnrichModule], controllers: [ChartsController], providers: [ChartsService], exports: [ChartsService], diff --git a/src/charts/charts.service.ts b/src/charts/charts.service.ts index 5c7cfa9..dd0b56e 100644 --- a/src/charts/charts.service.ts +++ b/src/charts/charts.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +import { EnrichmentService } from '../enrich/enrichment.service'; // Период чарта export type ChartPeriod = 'day' | 'week' | 'month' | 'all'; @@ -13,6 +14,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 +58,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 { @@ -125,21 +133,30 @@ export class ChartsService { '|' + 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); + // Асинхронное обогащение нового трека (Discogs + WebP-обложка, fire-and-forget) + if (track.enrichStatus !== 'done') { + this.enrichment.enqueue(track.id); } } catch (error) { // Ошибка сбора не должна ронять поллер @@ -147,65 +164,34 @@ export class ChartsService { } } - // Обогащение трека через MusicBrainz (fire-and-forget, best-effort) - private async enrichTrack( - trackId: string, - artist: string, - song: string, - ): Promise { - try { - const query = encodeURIComponent(`recording:"${song}" AND artist:"${artist}"`); - const url = `https://musicbrainz.org/ws/2/recording/?query=${query}&fmt=json&limit=1`; - const res = await fetch(url, { - headers: { - 'User-Agent': 'radiOLA/1.0 ( blinnafeg@gmail.com )', - Accept: 'application/json', - }, - }); - if (!res.ok) return; - - const data = (await res.json()) as { - recordings?: Array<{ - releases?: Array<{ - title: string; - date?: string; - }>; - }>; - }; - - const recording = data.recordings?.[0]; - if (!recording) return; - - const release = recording.releases?.[0]; - const album = release?.title ?? null; - const releaseDate = release?.date ? new Date(release.date) : null; - - // Проверяем, что дата валидна - const validDate = - releaseDate && !isNaN(releaseDate.getTime()) ? releaseDate : null; - - await this.prisma.track.update({ - where: { id: trackId }, - data: { album, releaseDate: validDate, enrichedAt: new Date() }, - }); - - this.logger.debug(`Трек ${trackId} обогащён: альбом="${album}"`); - } catch (error) { - // Игнорируем ошибки обогащения — не критично - this.logger.debug(`Обогащение трека ${trackId} не удалось: ${error.message}`); - } - } - - // Чарт треков за период - async getTopTracks(period: ChartPeriod, limit: number): Promise<{ items: ChartEntry[] }> { + // Чарт треков за период (с опциональным фильтром по жанру) + async getTopTracks( + period: ChartPeriod, + limit: number, + genre?: string, + ): 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) return { items: [] }; + } + // Топ текущего периода: группировка по 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, @@ -265,7 +251,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])); @@ -290,6 +285,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, @@ -311,6 +310,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; @@ -403,6 +406,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, @@ -438,4 +445,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) }; + } } diff --git a/src/enrich/cover-storage.service.ts b/src/enrich/cover-storage.service.ts new file mode 100644 index 0000000..795b927 --- /dev/null +++ b/src/enrich/cover-storage.service.ts @@ -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 { + 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; + } +} diff --git a/src/enrich/discogs.service.ts b/src/enrich/discogs.service.ts new file mode 100644 index 0000000..1a60b0e --- /dev/null +++ b/src/enrich/discogs.service.ts @@ -0,0 +1,84 @@ +import { Injectable, Logger } from '@nestjs/common'; + +// Результат обогащения из Discogs +export interface DiscogsResult { + discogsId: number | null; + genre: string | null; + styles: string[]; + label: string | null; + year: number | null; + coverImageUrl: string | null; +} + +// Сырой результат поиска Discogs (нужные поля) +interface DiscogsSearchItem { + id?: number; + genre?: string[]; + style?: string[]; + label?: string[]; + year?: string | number; + cover_image?: string; + thumb?: string; +} + +/** + * Клиент Discogs Database Search. + * Один запрос поиска уже отдаёт genre/style/label/year/cover_image — + * детальный запрос релиза не нужен. + * Токен берётся из env DISCOGS_TOKEN (личный токен из Settings → Developers). + */ +@Injectable() +export class DiscogsService { + private readonly logger = new Logger(DiscogsService.name); + private readonly token = process.env.DISCOGS_TOKEN ?? ''; + private readonly userAgent = 'radiOLA/1.0 +https://radiola.app'; + + // Без токена обогащение жанрами не работает (поиск требует авторизации) + get enabled(): boolean { + return this.token.length > 0; + } + + async lookup(artist: string, song: string): Promise { + if (!this.enabled) return null; + + const params = new URLSearchParams({ + artist, + track: song, + type: 'release', + per_page: '5', + token: this.token, + }); + const url = `https://api.discogs.com/database/search?${params.toString()}`; + + const res = await fetch(url, { + headers: { 'User-Agent': this.userAgent, Accept: 'application/json' }, + }); + if (!res.ok) { + this.logger.debug(`Discogs ${res.status} для "${artist} — ${song}"`); + return null; + } + + const data = (await res.json()) as { results?: DiscogsSearchItem[] }; + const item = data.results?.[0]; + if (!item) return null; + + const cover = + item.cover_image && !item.cover_image.includes('spacer.gif') + ? item.cover_image + : item.thumb && !item.thumb.includes('spacer.gif') + ? item.thumb + : null; + + const yearNum = + item.year != null ? parseInt(String(item.year), 10) || null : null; + + return { + discogsId: typeof item.id === 'number' ? item.id : null, + genre: item.genre?.[0] ?? null, + styles: Array.isArray(item.style) ? item.style.slice(0, 6) : [], + label: item.label?.[0] ?? null, + year: yearNum, + coverImageUrl: cover, + }; + } +} diff --git a/src/enrich/enrich.module.ts b/src/enrich/enrich.module.ts new file mode 100644 index 0000000..7c6d8a1 --- /dev/null +++ b/src/enrich/enrich.module.ts @@ -0,0 +1,10 @@ +import { Module } from '@nestjs/common'; +import { DiscogsService } from './discogs.service'; +import { CoverStorageService } from './cover-storage.service'; +import { EnrichmentService } from './enrichment.service'; + +@Module({ + providers: [DiscogsService, CoverStorageService, EnrichmentService], + exports: [EnrichmentService], +}) +export class EnrichModule {} diff --git a/src/enrich/enrichment.service.ts b/src/enrich/enrichment.service.ts new file mode 100644 index 0000000..573ebe3 --- /dev/null +++ b/src/enrich/enrichment.service.ts @@ -0,0 +1,122 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { Cron, CronExpression } from '@nestjs/schedule'; +import { PrismaService } from '../prisma/prisma.service'; +import { DiscogsService } from './discogs.service'; +import { CoverStorageService } from './cover-storage.service'; + +/** + * Оркестратор обогащения трека: при первом появлении трека подтягиваем + * жанр/стиль/лейбл/год из Discogs и сохраняем обложку в едином формате (WebP) + * у себя. Дальше пользователю отдаём только из своей БД — внешние сервисы + * в рантайме не дёргаем. + */ +@Injectable() +export class EnrichmentService { + private readonly logger = new Logger(EnrichmentService.name); + + // Очередь обогащения с троттлингом (Discogs ~60 запросов/мин с токеном) + private readonly queue: string[] = []; + private running = false; + private readonly throttleMs = 1200; + + constructor( + private readonly prisma: PrismaService, + private readonly discogs: DiscogsService, + private readonly covers: CoverStorageService, + ) {} + + // Поставить трек в очередь (fire-and-forget из recordPlay) + enqueue(trackId: string): void { + if (this.queue.includes(trackId)) return; + this.queue.push(trackId); + void this.drain(); + } + + // Периодически добираем не обогащённые треки (в т.ч. накопленные ранее) + @Cron(CronExpression.EVERY_10_MINUTES) + async backfill(): Promise { + if (!this.discogs.enabled) return; // без токена смысла нет — не крутим вхолостую + const pending = await this.prisma.track.findMany({ + where: { enrichStatus: 'pending' }, + select: { id: true }, + orderBy: { firstSeenAt: 'desc' }, + take: 30, + }); + for (const t of pending) this.enqueue(t.id); + } + + private async drain(): Promise { + if (this.running) return; + this.running = true; + try { + while (this.queue.length > 0) { + const id = this.queue.shift(); + if (!id) continue; + await this.enrichOne(id); + await this.sleep(this.throttleMs); + } + } finally { + this.running = false; + } + } + + private async enrichOne(trackId: string): Promise { + try { + const track = await this.prisma.track.findUnique({ + where: { id: trackId }, + }); + if (!track || track.enrichStatus === 'done') return; + + const data = this.discogs.enabled + ? await this.discogs.lookup(track.artist, track.song) + : null; + + // Обложку приводим к WebP и кладём к себе (если ещё не наша) + let coverUrl = track.coverUrl; + const candidate = data?.coverImageUrl ?? track.coverUrl; + if (candidate && !this.isSelfHosted(candidate)) { + const stored = await this.covers.store(candidate, track.normKey); + if (stored) coverUrl = stored; + } + + // Без токена Discogs жанры не получим — оставляем статус pending, + // чтобы добрать позже (когда токен появится), но обложку уже сохранили. + const enriched = this.discogs.enabled; + + await this.prisma.track.update({ + where: { id: trackId }, + data: { + genre: data?.genre ?? track.genre, + styles: data?.styles?.length ? data.styles : track.styles, + label: data?.label ?? track.label, + year: data?.year ?? track.year, + discogsId: data?.discogsId ?? track.discogsId, + coverUrl, + releaseDate: + !track.releaseDate && data?.year + ? new Date(Date.UTC(data.year, 0, 1)) + : track.releaseDate, + enrichStatus: enriched ? 'done' : 'pending', + enrichedAt: enriched ? new Date() : track.enrichedAt, + }, + }); + + this.logger.debug( + `Обогащён "${track.artist} — ${track.song}": genre=${data?.genre ?? '—'}, label=${data?.label ?? '—'}`, + ); + } catch (e) { + this.logger.debug(`Обогащение ${trackId} не удалось: ${(e as Error).message}`); + await this.prisma.track + .update({ where: { id: trackId }, data: { enrichStatus: 'failed' } }) + .catch(() => undefined); + } + } + + private isSelfHosted(url: string): boolean { + return url.includes('/covers/'); + } + + private sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); + } +} diff --git a/src/main.ts b/src/main.ts index d708688..44d296e 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,10 +1,20 @@ 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(AppModule); + + // Раздача сохранённых обложек треков (/covers/*.webp) — свой CDN + const coversDir = process.env.COVERS_DIR || join(process.cwd(), 'data', 'covers'); + app.useStaticAssets(coversDir, { + prefix: '/covers/', + maxAge: '30d', + immutable: true, + }); app.useGlobalPipes( new ValidationPipe({