diff --git a/backend/package.json b/backend/package.json index b10864d..a8c4792 100644 --- a/backend/package.json +++ b/backend/package.json @@ -22,7 +22,9 @@ "consola": "^2.15.3", "cors": "^2.8.5", "express": "^4.17.2", + "got": "11", "md5": "^2.3.0", + "node-schedule": "^2.1.1", "uuid": "^8.3.2" }, "devDependencies": { diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index c5fbbb8..3656d93 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -6,7 +6,9 @@ specifiers: consola: ^2.15.3 cors: ^2.8.5 express: ^4.17.2 + got: '11' md5: ^2.3.0 + node-schedule: ^2.1.1 nodemon: ^2.0.15 uuid: ^8.3.2 @@ -16,7 +18,9 @@ dependencies: consola: registry.nlark.com/consola/2.15.3 cors: 2.8.5 express: registry.npmmirror.com/express/4.17.2 + got: 11.8.6 md5: 2.3.0 + node-schedule: 2.1.1 uuid: 8.3.2 devDependencies: @@ -29,6 +33,11 @@ packages: engines: {node: '>=6'} dev: true + /@sindresorhus/is/4.6.0: + resolution: {integrity: sha512-t09vSN3MdfsyCHoFcTRCH/iUtG7OJ0CsjzB8cjAmKc/va/kIgeDI/TxsigdncE/4be734m0cvIYwNaV4i2XqAw==} + engines: {node: '>=10'} + dev: false + /@szmarczak/http-timer/1.1.2: resolution: {integrity: sha512-XIB2XbzHTN6ieIjfIMV9hlVcfPU26s2vafYWQcZHWXHOxiaRZYEDKEwdl129Zyg50+foYV2jCgtrqSA6qNuNSA==} engines: {node: '>=6'} @@ -36,6 +45,13 @@ packages: defer-to-connect: 1.1.3 dev: true + /@szmarczak/http-timer/4.0.6: + resolution: {integrity: sha512-4BAffykYOgO+5nzBWYwE3W90sBgLJoUPRWWcL8wlyiM8IB8ipJz3UMJ9KXQd1RKQXpKp8Tutn80HZtWsu2u76w==} + engines: {node: '>=10'} + dependencies: + defer-to-connect: 2.0.1 + dev: false + /@tokenizer/token/0.3.0: resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} dev: false @@ -45,21 +61,31 @@ packages: engines: {node: '>= 6'} dev: false + /@types/cacheable-request/6.0.3: + resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==} + dependencies: + '@types/http-cache-semantics': 4.0.4 + '@types/keyv': 3.1.4 + '@types/node': 18.0.6 + '@types/responselike': 1.0.0 + dev: false + + /@types/http-cache-semantics/4.0.4: + resolution: {integrity: sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==} + dev: false + /@types/keyv/3.1.4: resolution: {integrity: sha512-BQ5aZNSCpj7D6K2ksrRCTmKRLEpnPvWDiLPfoGyhZ++8YtiK9d/3DBKPJgry359X/P1PfruyYwvnvwFjuEiEIg==} dependencies: '@types/node': 18.0.6 - dev: true /@types/node/18.0.6: resolution: {integrity: sha512-/xUq6H2aQm261exT6iZTMifUySEt4GR5KX8eYyY+C4MSNPqSh9oNIP7tz2GLKTlFaiBbgZNxffoR3CVRG+cljw==} - dev: true /@types/responselike/1.0.0: resolution: {integrity: sha512-85Y2BjiufFzaMIlvJDvTTB8Fxl2xfLo4HgmHzVBz08w4wDePCTjYw66PdrolO0kzli3yam/YCgRufyo1DdQVTA==} dependencies: '@types/node': 18.0.6 - dev: true /NeteaseCloudMusicApi/4.6.7: resolution: {integrity: sha512-Qw+r3ti2O5vgrqeOxOvlu+DNsTdzfxJ+nC6C5rjbwU2d4VMZZynqm8Sb+692WGQvZAjH5aTbXLnGAR8hBFJdEQ==} @@ -223,19 +249,37 @@ packages: engines: {node: '>= 0.8'} dev: false + /cacheable-lookup/5.0.4: + resolution: {integrity: sha512-2/kNscPhpcxrOigMZzbiWF7dz8ilhb/nIHU3EyZiXWXpeq/au8qJ8VhdftMkty3n7Gj6HIGalQG8oiBNB3AJgA==} + engines: {node: '>=10.6.0'} + dev: false + /cacheable-request/6.1.0: resolution: {integrity: sha512-Oj3cAGPCqOZX7Rz64Uny2GYAZNliQSqfbePrgAQ1wKAihYmCUnraBtJtKcGR4xz7wF+LoJC+ssFZvv5BgF9Igg==} engines: {node: '>=8'} dependencies: clone-response: 1.0.2 get-stream: 5.2.0 - http-cache-semantics: 4.1.0 + http-cache-semantics: 4.1.1 keyv: 3.1.0 lowercase-keys: 2.0.0 normalize-url: 4.5.1 responselike: 1.0.2 dev: true + /cacheable-request/7.0.4: + resolution: {integrity: sha512-v+p6ongsrp0yTGbJXjgxPow2+DL93DASP4kXCDKb8/bwRtt9OEF3whggkkDkGNzgcWy2XaF4a8nZglC7uElscg==} + engines: {node: '>=8'} + dependencies: + clone-response: 1.0.2 + get-stream: 5.2.0 + http-cache-semantics: 4.1.1 + keyv: 4.5.4 + lowercase-keys: 2.0.0 + normalize-url: 6.1.0 + responselike: 2.0.1 + dev: false + /camelcase/5.3.1: resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} engines: {node: '>=6'} @@ -299,10 +343,9 @@ packages: dev: false /clone-response/1.0.2: - resolution: {integrity: sha1-0dyXOSAxTfZ/vrlCI7TuNQI56Ws=} + resolution: {integrity: sha512-yjLXh88P599UOyPTFX0POsd7WxnbsVsGohcwzHOLspIhhpalPw1BcqED8NblyZLKcGrL8dTgMlcaZxV2jAD41Q==} dependencies: mimic-response: 1.0.1 - dev: true /color-convert/2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} @@ -362,6 +405,13 @@ packages: vary: 1.1.2 dev: false + /cron-parser/4.9.0: + resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} + engines: {node: '>=12.0.0'} + dependencies: + luxon: 3.5.0 + dev: false + /crypt/0.0.2: resolution: {integrity: sha1-iNf/fsDfuG9xPch7u0LQRNPmxBs=} dev: false @@ -417,12 +467,19 @@ packages: dev: false /decompress-response/3.3.0: - resolution: {integrity: sha1-gKTdMjdIOEv6JICDYirt7Jgq3/M=} + resolution: {integrity: sha512-BzRPQuY1ip+qDonAOz42gRm/pg9F768C+npV/4JOsxRC2sq+Rlk+Q4ZCAsOhnIaMrgarILY+RMUIvMmmX1qAEA==} engines: {node: '>=4'} dependencies: mimic-response: 1.0.1 dev: true + /decompress-response/6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + dependencies: + mimic-response: 3.1.0 + dev: false + /deep-extend/0.6.0: resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} engines: {node: '>=4.0.0'} @@ -436,6 +493,11 @@ packages: resolution: {integrity: sha512-0ISdNousHvZT2EiFlZeZAHBUvSxmKswVCEf8hW7KWgG4a8MVEu/3Vb6uWYozkjylyCxe0JBIiRB1jV45S70WVQ==} dev: true + /defer-to-connect/2.0.1: + resolution: {integrity: sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==} + engines: {node: '>=10'} + dev: false + /degenerator/3.0.2: resolution: {integrity: sha512-c0mef3SNQo56t6urUU6tdQAs+ThoD0o9B9MJ8HEt7NQcGEILCRFqQb7ZbP9JAv+QF1Ky5plydhMR/IrqWDm+TQ==} engines: {node: '>= 6'} @@ -490,7 +552,6 @@ packages: resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} dependencies: once: 1.4.0 - dev: true /escalade/3.1.1: resolution: {integrity: sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw==} @@ -695,7 +756,6 @@ packages: engines: {node: '>=8'} dependencies: pump: 3.0.0 - dev: true /get-uri/3.0.2: resolution: {integrity: sha512-+5s0SJbGoyiJTZZ2JTpFPLMPSch72KEqGOTvQsBqg0RBWvwhWUSYZFAtz3TPW0GXJuLBJPts1E241iHg+VRfhg==} @@ -725,6 +785,23 @@ packages: ini: 2.0.0 dev: true + /got/11.8.6: + resolution: {integrity: sha512-6tfZ91bOr7bOXnK7PRDCGBLa1H4U080YHNaAQ2KsMGlLEzRbk44nsZF2E1IeRc3vtJHPVbKCYgdFbaGO2ljd8g==} + engines: {node: '>=10.19.0'} + dependencies: + '@sindresorhus/is': 4.6.0 + '@szmarczak/http-timer': 4.0.6 + '@types/cacheable-request': 6.0.3 + '@types/responselike': 1.0.0 + cacheable-lookup: 5.0.4 + cacheable-request: 7.0.4 + decompress-response: 6.0.0 + http2-wrapper: 1.0.3 + lowercase-keys: 2.0.0 + p-cancelable: 2.1.1 + responselike: 2.0.1 + dev: false + /got/9.6.0: resolution: {integrity: sha512-R7eWptXuGYxwijs0eV+v3o6+XH1IqVK8dJOEecQfTmkncw9AV4dcw/Dhxi8MdlqPthxxpZyizMzyg8RTmEsG+Q==} engines: {node: '>=8.6'} @@ -762,9 +839,8 @@ packages: engines: {node: '>=8'} dev: true - /http-cache-semantics/4.1.0: - resolution: {integrity: sha512-carPklcUh7ROWRK7Cv27RPtdhYhUsela/ue5/jKzjegVvXDqM2ILE9Q2BGn9JZJh1g87cp56su/FgQSzcWS8cQ==} - dev: true + /http-cache-semantics/4.1.1: + resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} /http-errors/1.8.1: resolution: {integrity: sha512-Kpk9Sm7NmI+RHhnj6OIWDI1d6fIoFAtFt9RLaTMRlg/8w49juAStsrBgp0Dp4OdxdVbRIeKhtCUvoi/RuAhO4g==} @@ -788,6 +864,14 @@ packages: - supports-color dev: false + /http2-wrapper/1.0.3: + resolution: {integrity: sha512-V+23sDMr12Wnz7iTcDeJr3O6AIxlnvT/bmaAAAP/Xda35C90p9599p0F1eHR/N1KILWSoWVAiOMFjBBXaXSMxg==} + engines: {node: '>=10.19.0'} + dependencies: + quick-lru: 5.1.1 + resolve-alpn: 1.2.1 + dev: false + /https-proxy-agent/5.0.1: resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==} engines: {node: '>= 6'} @@ -924,9 +1008,13 @@ packages: dev: false /json-buffer/3.0.0: - resolution: {integrity: sha1-Wx85evx11ne96Lz8Dkfh+aPZqJg=} + resolution: {integrity: sha512-CuUqjv0FUZIdXkHPI8MezCnFCdaTAacej1TZYulLoAg1h/PhwkdXFN4V/gzY4g+fMBCOV2xF+rp7t2XD2ns/NQ==} dev: true + /json-buffer/3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + dev: false + /jsonfile/4.0.0: resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} optionalDependencies: @@ -939,6 +1027,12 @@ packages: json-buffer: 3.0.0 dev: true + /keyv/4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + dependencies: + json-buffer: 3.0.1 + dev: false + /latest-version/5.1.0: resolution: {integrity: sha512-weT+r0kTkRQdCdYCNtkMwWXQTMEswKrFBkm4ckQOMVhhqhIMI1UT2hMj+1iigIhgSZm5gTmrRXBNoGUgaTY1xA==} engines: {node: '>=8'} @@ -961,6 +1055,10 @@ packages: p-locate: 4.1.0 dev: false + /long-timeout/0.1.1: + resolution: {integrity: sha512-BFRuQUqc7x2NWxfJBCyUrN8iYUYznzL9JROmRz1gZ6KlOIgmoD+njPVbb+VNn2nGMKggMsK79iUNErillsrx7w==} + dev: false + /lowercase-keys/1.0.1: resolution: {integrity: sha512-G2Lj61tXDnVFFOi8VZds+SoQjtQC3dgokKdDG2mTm1tx4m50NUHBOZSBwQQHyy0V12A0JTG4icfZQH+xPyh8VA==} engines: {node: '>=0.10.0'} @@ -969,7 +1067,6 @@ packages: /lowercase-keys/2.0.0: resolution: {integrity: sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA==} engines: {node: '>=8'} - dev: true /lru-cache/6.0.0: resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} @@ -978,6 +1075,11 @@ packages: yallist: 4.0.0 dev: true + /luxon/3.5.0: + resolution: {integrity: sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==} + engines: {node: '>=12'} + dev: false + /make-dir/3.1.0: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} @@ -1033,7 +1135,11 @@ packages: /mimic-response/1.0.1: resolution: {integrity: sha512-j5EctnkH7amfV/q5Hgmoal1g2QHFJRraOtmx0JpIqkxhBhI/lJSl1nMpQ45hVarwNETOoWEimndZ4QK0RHxuxQ==} engines: {node: '>=4'} - dev: true + + /mimic-response/3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + dev: false /minimatch/3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} @@ -1081,6 +1187,15 @@ packages: engines: {node: '>= 0.4.0'} dev: false + /node-schedule/2.1.1: + resolution: {integrity: sha512-OXdegQq03OmXEjt2hZP33W2YPs/E5BcFQks46+G2gAxs4gHOIVD1u7EqlYLYSKsaIpyKCK9Gbk0ta1/gjRSMRQ==} + engines: {node: '>=6'} + dependencies: + cron-parser: 4.9.0 + long-timeout: 0.1.1 + sorted-array-functions: 1.3.0 + dev: false + /nodemon/2.0.15: resolution: {integrity: sha512-gdHMNx47Gw7b3kWxJV64NI+Q5nfl0y5DgDbiVtShiwa7Z0IZ07Ll4RLFo6AjrhzMtoEZn5PDE3/c2AbVsiCkpA==} engines: {node: '>=8.10.0'} @@ -1116,6 +1231,11 @@ packages: engines: {node: '>=8'} dev: true + /normalize-url/6.1.0: + resolution: {integrity: sha512-DlL+XwOy3NxAQ8xuC0okPgK46iuVNAK01YN7RueYBqqFeGsBjV9XmCAzAdgt+667bCl5kPh9EqKKDwnaPG1I7A==} + engines: {node: '>=10'} + dev: false + /object-assign/4.1.1: resolution: {integrity: sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=} engines: {node: '>=0.10.0'} @@ -1129,10 +1249,9 @@ packages: dev: false /once/1.4.0: - resolution: {integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=} + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} dependencies: wrappy: 1.0.2 - dev: true /optionator/0.8.3: resolution: {integrity: sha512-+IW9pACdk3XWmmTXG8m3upGUJst5XRGzxMRjXzAuJ1XnIFNvfhjjIuYkDvysnPQ7qzqVzLt78BCruntqRhWQbA==} @@ -1151,6 +1270,11 @@ packages: engines: {node: '>=6'} dev: true + /p-cancelable/2.1.1: + resolution: {integrity: sha512-BZOr3nRQHOntUjTrH8+Lh54smKHoHyur8We1V8DSMVrl5A2malOOwuJRnKRDjSnkoeBh4at6BwEnb5I7Jl31wg==} + engines: {node: '>=8'} + dev: false + /p-limit/2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -1262,7 +1386,6 @@ packages: dependencies: end-of-stream: 1.4.4 once: 1.4.0 - dev: true /pupa/2.1.1: resolution: {integrity: sha512-l1jNAspIBSFqbT+y+5FosojNpVpF94nlI+wDUpqP9enwOTfHx9f0gh5nB96vl+6yTpsJsypeNrwfzPrKuHB41A==} @@ -1287,6 +1410,11 @@ packages: engines: {node: '>=0.6'} dev: false + /quick-lru/5.1.1: + resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==} + engines: {node: '>=10'} + dev: false + /range-parser/1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -1367,12 +1495,22 @@ packages: resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} dev: false + /resolve-alpn/1.2.1: + resolution: {integrity: sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==} + dev: false + /responselike/1.0.2: - resolution: {integrity: sha1-kYcg7ztjHFZCvgaPFa3lpG9Loec=} + resolution: {integrity: sha512-/Fpe5guzJk1gPqdJLJR5u7eG/gNY4nImjbRDaVWVMRhne55TCmj2i9Q+54PBRfatRC8v/rIiv9BN0pMd9OV5EQ==} dependencies: lowercase-keys: 1.0.1 dev: true + /responselike/2.0.1: + resolution: {integrity: sha512-4gl03wn3hj1HP3yzgdI7d3lCkF95F21Pz4BPGvKHinyQzALR5CapwC8yIi0Rh58DEMQ/SguC03wFj2k0M/mHhw==} + dependencies: + lowercase-keys: 2.0.0 + dev: false + /safe-buffer/5.2.1: resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} dev: false @@ -1479,6 +1617,10 @@ packages: smart-buffer: 4.2.0 dev: false + /sorted-array-functions/1.3.0: + resolution: {integrity: sha512-2sqgzeFlid6N4Z2fUQ1cvFmTOLRi/sEDzSQ0OKYchqgoPmQBVyM3959qYx3fpS6Esef80KjmpgPeEr028dP3OA==} + dev: false + /source-map/0.6.1: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} @@ -1724,8 +1866,7 @@ packages: strip-ansi: 6.0.1 /wrappy/1.0.2: - resolution: {integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=} - dev: true + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} /write-file-atomic/3.0.3: resolution: {integrity: sha512-AvHcyZ5JnSfq3ioSyjrBkH9yW4m7Ayk8/9My/DD9onKeu/94fwrMocemO2QAJFAlnnDN+ZDS+ZjAR5ua1/PV/Q==} diff --git a/backend/src/consts/business_code.js b/backend/src/consts/business_code.js index e3aa1e2..a8a1ff3 100644 --- a/backend/src/consts/business_code.js +++ b/backend/src/consts/business_code.js @@ -1,3 +1,5 @@ module.exports = { StatusJobAlreadyExisted: 40010, -} \ No newline at end of file + StatusJobNoNeedToCreate: 40011, + StatusNoNeedToSync: 40012, +} diff --git a/backend/src/consts/job_type.js b/backend/src/consts/job_type.js index f78e279..9eabaa8 100644 --- a/backend/src/consts/job_type.js +++ b/backend/src/consts/job_type.js @@ -2,4 +2,6 @@ module.exports = { UnblockedPlaylist: "UnblockedPlaylist", UnblockedSong: "UnblockedSong", SyncSongFromUrl: "SyncSongFromUrl", + DownloadSongFromUrl: "DownloadSongFromUrl", + SyncThePlaylistToLocalService: "SyncThePlaylistToLocalService", } \ No newline at end of file diff --git a/backend/src/consts/sound_quality.js b/backend/src/consts/sound_quality.js new file mode 100644 index 0000000..199e1a9 --- /dev/null +++ b/backend/src/consts/sound_quality.js @@ -0,0 +1,4 @@ +module.exports = { + High: "high", + Lossless: "lossless", +} \ No newline at end of file diff --git a/backend/src/handler/account.js b/backend/src/handler/account.js index f6c031f..50762f2 100644 --- a/backend/src/handler/account.js +++ b/backend/src/handler/account.js @@ -16,16 +16,21 @@ async function set(req, res) { const accountName = req.body.account; const password = req.body.password; const countryCode = req.body.countryCode; - if (!accountName || !password) { - res.status(422).send({ - status: 1, - message: 'account or password is empty', - data: {} - }); - return; + const config = req.body.config; + const name = req.body.name; + + if (name) { + // check if the name is already used by other accounts + const allAccounts = await AccountService.getAllAccountsWithoutSensitiveInfo(); + for (const account of Object.values(allAccounts)) { + if (account.name === name && account.uid !== req.account.uid) { + res.status(412).send({ status: 1, message: '昵称已被占用啦,请换一个试试吧', data: {} }); + return; + } + } } - const ret = await AccountService.setAccount(req.account.uid, loginType, accountName, password, countryCode); + const ret = await AccountService.setAccount(req.account.uid, loginType, accountName, password, countryCode, config, name); res.send({ status: ret ? 0 : 1, data: { @@ -74,7 +79,7 @@ async function qrLoginCheck(req, res) { req.account.account = account.wyAccount.userId; storeCookie(req.account.uid, req.account, loginCheckRet.cookie); - AccountService.setAccount(req.account.uid, 'qrcode', account.wyAccount.userId, ''); + AccountService.setAccount(req.account.uid, 'qrcode', account.wyAccount.userId, '', null); account = await getWyAccountInfo(req.account.uid); } res.send({ @@ -86,9 +91,18 @@ async function qrLoginCheck(req, res) { }); } +async function getAllAccounts(req, res) { + const data = await AccountService.getAllAccountsWithoutSensitiveInfo(); + res.send({ + status: 0, + data: data + }); +} + module.exports = { get: get, set: set, qrLoginCreate, qrLoginCheck, + getAllAccounts, } \ No newline at end of file diff --git a/backend/src/handler/proxy.js b/backend/src/handler/proxy.js new file mode 100644 index 0000000..e51288e --- /dev/null +++ b/backend/src/handler/proxy.js @@ -0,0 +1,46 @@ +const logger = require('consola'); +const got = require('got'); + +async function proxyAudio(req, res) { + const url = req.query.url; + const source = req.query.source; + const referer = req.query.referer; + + if (!url || !source) { + res.status(400).send({ + status: 1, + message: "url and source are required" + }); + return; + } + + // 只允许 bilibili 源 + if (source !== 'bilibili') { + res.status(403).send({ + status: 1, + message: "only bilibili source is allowed" + }); + return; + } + + try { + const stream = got.stream(url, { + headers: { + 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)', + 'Referer': referer || 'https://www.bilibili.com' + } + }); + + stream.pipe(res); + } catch (err) { + logger.error('proxy audio error:', err); + res.status(500).send({ + status: 1, + message: "proxy failed" + }); + } +} + +module.exports = { + proxyAudio +}; \ No newline at end of file diff --git a/backend/src/handler/scheduler.js b/backend/src/handler/scheduler.js new file mode 100644 index 0000000..d9dafda --- /dev/null +++ b/backend/src/handler/scheduler.js @@ -0,0 +1,27 @@ +const schedulerService = require('../service/scheduler'); +const AccountService = require('../service/account'); + +async function getNextRun(req, res) { + const localNextRun = schedulerService.getLocalSyncNextRun(); + const accounts = await AccountService.getAllAccounts(); + + const cloudNextRuns = {}; + for (const uid in accounts) { + const nextRun = schedulerService.getCloudSyncNextRun(uid); + if (nextRun) { + cloudNextRuns[uid] = nextRun; + } + } + + res.send({ + status: 0, + data: { + localNextRun, + cloudNextRuns + } + }); +} + +module.exports = { + getNextRun +}; diff --git a/backend/src/handler/sync_jobs.js b/backend/src/handler/sync_jobs.js index 9749572..55f07dd 100644 --- a/backend/src/handler/sync_jobs.js +++ b/backend/src/handler/sync_jobs.js @@ -3,7 +3,7 @@ const { unblockMusicInPlaylist, unblockMusicWithSongId } = require('../service/s const JobType = require('../consts/job_type'); const Source = require('../consts/source').consts; const { matchUrlFromStr } = require('../utils/regex'); -const { syncSingleSongWithUrl } = require('../service/sync_music'); +const { syncSingleSongWithUrl, syncPlaylist } = require('../service/sync_music'); const findTheBestMatchFromWyCloud = require('../service/search_songs/find_the_best_match_from_wycloud'); const JobManager = require('../service/job_manager'); const JobStatus = require('../consts/job_status'); @@ -15,38 +15,47 @@ async function createJob(req, res) { const request = req.body; const jobType = request.jobType; + const options = request.options; let jobId = 0; - if (jobType === JobType.UnblockedPlaylist) { + if (jobType === JobType.UnblockedPlaylist || jobType === JobType.SyncThePlaylistToLocalService) { const source = request.playlist && request.playlist.source; const playlistId = request.playlist && request.playlist.id; if (source !== Source.Netease.code || !playlistId) { - res.status(429).send({ + res.status(412).send({ status: 1, message: "source or id is invalid", }); return; } - jobId = await unblockMusicInPlaylist(uid, source, playlistId) + if (jobType === JobType.UnblockedPlaylist) { + jobId = await unblockMusicInPlaylist(uid, source, playlistId, { + syncWySong: options.syncWySong, + syncNotWySong: options.syncNotWySong, + asyncExecute: true, + }); + } else { + jobId = await syncPlaylist(uid, source, playlistId) + } } else if (jobType === JobType.UnblockedSong) { const source = request.source; const songId = request.songId; if (source !== Source.Netease.code || !songId) { - res.status(429).send({ + res.status(412).send({ status: 1, message: "source or id is invalid", }); return; } jobId = await unblockMusicWithSongId(uid, source, songId) - } else if (jobType === JobType.SyncSongFromUrl) { + } else if (jobType === JobType.SyncSongFromUrl || jobType === JobType.DownloadSongFromUrl) { const request = req.body; const url = request.urlJob && matchUrlFromStr(request.urlJob.url); if (!url) { - res.status(429).send({ + res.status(412).send({ status: 1, message: "url is invalid", }); @@ -73,7 +82,7 @@ async function createJob(req, res) { }); if (!songFromWyCloud) { logger.error(`song not found in wycloud`); - res.status(429).send({ + res.status(412).send({ status: 1, message: "can not find song in wycloud with your songId", }); @@ -83,33 +92,34 @@ async function createJob(req, res) { } // create job - const args = `SyncSongFromUrl: {"url":${url}}`; + const args = `${jobType}: {"url":${url}}`; if (await JobManager.findActiveJobByArgs(uid, args)) { - logger.info(`SyncSongFromUrl job is already running.`); + logger.info(`${jobType} job is already running.`); jobId = BusinessCode.StatusJobAlreadyExisted; } else { + const operation = jobType === JobType.SyncSongFromUrl ? "上传" : "下载"; jobId = await JobManager.createJob(uid, { - name: `上传歌曲:${meta.songName ? meta.songName : url}`, + name: `${operation}歌曲:${meta.songName ? meta.songName : url}`, args, - type: JobType.SyncSongFromUrl, + type: jobType, status: JobStatus.Pending, desc: `歌曲:${meta.songName ? meta.songName : url}`, progress: 0, - tip: "等待上传", + tip: `等待${operation}`, createdAt: Date.now() }); // async job - syncSingleSongWithUrl(req.account.uid, url, meta, jobId).then(async ret => { + syncSingleSongWithUrl(req.account.uid, url, meta, jobId, jobType).then(async ret => { await JobManager.updateJob(uid, jobId, { - status: ret ? JobStatus.Finished : JobStatus.Failed, + status: ret === true ? JobStatus.Finished : JobStatus.Failed, progress: 1, - tip: ret ? "上传成功" : "上传失败", + tip: ret === true ? `${operation}成功` : `${operation}失败`, }); }) } } else { - res.status(429).send({ + res.status(412).send({ status: 1, message: "jobType is not supported", }); @@ -118,7 +128,7 @@ async function createJob(req, res) { if (jobId === false) { logger.error(`create job failed, uid: ${uid}`); - res.status(429).send({ + res.status(412).send({ status: 1, message: "create job failed", }); @@ -126,12 +136,19 @@ async function createJob(req, res) { } if (jobId === BusinessCode.StatusJobAlreadyExisted) { - res.status(429).send({ + res.status(412).send({ status: BusinessCode.StatusJobAlreadyExisted, message: "你的任务已经在跑啦,等等吧", }); return; } + if (jobId === BusinessCode.StatusJobNoNeedToCreate) { + res.status(412).send({ + status: BusinessCode.StatusJobAlreadyExisted, + message: "你的任务无需被创建,可能是因为没有需要 sync 的歌曲", + }); + return; + } res.status(201).send({ status: jobId ? 0 : 1, diff --git a/backend/src/index.js b/backend/src/index.js index 86566a8..61c290b 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -11,18 +11,24 @@ const bodyParser = require('body-parser'); const app = express(); const port = 5566; +const schedulerService = require('./service/scheduler'); require('./init_app')().then(() => { const middlewareHandleError = require('./middleware/handle_error'); const middlewareAuth = require('./middleware/auth'); - - app.use('/api', middlewareAuth); + const proxy = require('./handler/proxy'); app.use(bodyParser.json()); app.use(cors({ origin: true, credentials: true, })); + + // 先注册代理路由,跳过 auth 验证 + app.get('/api/proxy/audio', proxy.proxyAudio); + + // 其他 API 路由需要 auth + app.use('/api', middlewareAuth); app.use('/', require('./router')); app.use(middlewareHandleError); @@ -33,4 +39,6 @@ require('./init_app')().then(() => { const port = server.address().port logger.info(`Express server is listening on ${host}:${port}!`) }) -}); \ No newline at end of file +}); + +schedulerService.start(); \ No newline at end of file diff --git a/backend/src/init_app.js b/backend/src/init_app.js index 36b2a34..77438dd 100644 --- a/backend/src/init_app.js +++ b/backend/src/init_app.js @@ -1,6 +1,10 @@ const logger = require('consola'); const fs = require('fs'); const process = require('process'); + +initDir(); +initAccountFileIfNotExisted(); + const mediaGet = require('./service/media_fetcher/media_get'); function initDir() { @@ -29,10 +33,6 @@ function initAccountFileIfNotExisted() { } module.exports = async function() { - initDir(); - - initAccountFileIfNotExisted(); - // check if media-get is installed const mediaGetInfo = await mediaGet.getMediaGetInfo(); if (mediaGetInfo === false) { diff --git a/backend/src/router.js b/backend/src/router.js index 9fc130e..2ff3740 100644 --- a/backend/src/router.js +++ b/backend/src/router.js @@ -7,7 +7,7 @@ const Playlists = require('./handler/playlists'); const Account = require('./handler/account'); const MediaFetcherLib = require('./handler/media_fetcher_lib'); const Config = require('./handler/config'); - +const Scheduler = require('./handler/scheduler'); const asyncWrapper = (cb) => { return (req, res, next) => cb(req, res, next).catch(next); }; @@ -26,6 +26,7 @@ router.get('/api/playlists/:source/:id/songs', asyncWrapper(Playlists.listSongsF router.get('/api/account', asyncWrapper(Account.get)); router.post('/api/account', asyncWrapper(Account.set)); +router.get('/api/accounts', asyncWrapper(Account.getAllAccounts)); router.get('/api/account/qrlogin-create', asyncWrapper(Account.qrLoginCreate)); router.get('/api/account/qrlogin-check', asyncWrapper(Account.qrLoginCheck)); @@ -35,4 +36,7 @@ router.post('/api/media-fetcher-lib/update', asyncWrapper(MediaFetcherLib.downlo router.get('/api/config/global', asyncWrapper(Config.getGlobalConfig)); router.post('/api/config/global', asyncWrapper(Config.setGlobalConfig)); -module.exports = router; \ No newline at end of file +router.get('/api/scheduler/next-run', asyncWrapper(Scheduler.getNextRun)); + +module.exports = router; + \ No newline at end of file diff --git a/backend/src/service/account.js b/backend/src/service/account.js index 7998560..671f8fb 100644 --- a/backend/src/service/account.js +++ b/backend/src/service/account.js @@ -4,23 +4,47 @@ let AccountMap = require(AccountPath); const logger = require('consola'); const locker = require('../utils/simple_locker'); const fs = require('fs'); +const SoundQuality = require('../consts/sound_quality'); +const schedulerService = require('../service/scheduler'); + module.exports = { getAccount: getAccount, setAccount: setAccount, + getAllAccounts: getAllAccounts, + getAllAccountsWithoutSensitiveInfo: getAllAccountsWithoutSensitiveInfo, } +const defaultConfig = { + playlistSyncToWyCloudDisk: { + autoSync: { + enable: false, + frequency: 1, + frequencyUnit: "day", + }, + syncWySong: true, + syncNotWySong: false, + soundQualityPreference: SoundQuality.High, + }, +}; + function getAccount(uid) { const account = AccountMap[uid]; if (!account) { logger.error(`the uid(${uid}) does not existed`); return false; } + if (!account.config) { + account.config = defaultConfig; + } + if (!account.config.playlistSyncToWyCloudDisk) { + account.config.playlistSyncToWyCloudDisk = defaultConfig.playlistSyncToWyCloudDisk; + } account.uid = uid; return account; } -async function setAccount(uid, loginType, account, password, countryCode = '') { +async function setAccount(uid, loginType, account, password, countryCode = '', config, name) { const lockKey = 'setAccount'; await locker.lock(lockKey, 5); @@ -31,14 +55,21 @@ async function setAccount(uid, loginType, account, password, countryCode = '') { locker.unlock(lockKey); return false; } - if (userAccount.loginType == loginType && userAccount.account == account && userAccount.password == password && userAccount.countryCode == countryCode) { - locker.unlock(lockKey); - return true; - } + + const oldAccount = userAccount; + userAccount.loginType = loginType; userAccount.account = account; userAccount.password = password; userAccount.countryCode = countryCode; + if (name) { + userAccount.name = name; + } + + if (config) { + userAccount.config = config; + } + AccountMap[uid] = userAccount; storeAccount(AccountMap); @@ -49,6 +80,12 @@ async function setAccount(uid, loginType, account, password, countryCode = '') { fs.unlinkSync(CookiePath + uid); } catch(_){} + // 重启调度器以应用新的账号配置 + if (config && JSON.stringify(oldAccount?.config?.playlistSyncToWyCloudDisk) !== + JSON.stringify(config.playlistSyncToWyCloudDisk)) { + await schedulerService.updateCloudSyncJob(uid); + } + return true; } @@ -60,4 +97,21 @@ function storeAccount(account) { fs.writeFileSync(AccountPath, JSON.stringify(account, null, 4)); } +async function getAllAccounts() { + refreshAccountFromFile(); + return AccountMap; +} + +async function getAllAccountsWithoutSensitiveInfo() { + refreshAccountFromFile(); + const filteredAccounts = {}; + for (const [uid, account] of Object.entries(AccountMap)) { + filteredAccounts[uid] = { + name: account.name || uid, + uid: account.uid + }; + } + return filteredAccounts; +} + diff --git a/backend/src/service/config_manager/index.js b/backend/src/service/config_manager/index.js index 6fec71b..ed9a670 100644 --- a/backend/src/service/config_manager/index.js +++ b/backend/src/service/config_manager/index.js @@ -1,26 +1,48 @@ +const sound_quality = require('../../consts/sound_quality'); const asyncFs = require('../../utils/fs'); const DataPath = `${__dirname}/../../../.profile/data`; const ConfigPath = `${DataPath}/config`; const GlobalConfig = `${ConfigPath}/global.json`; const sourceConsts = require('../../consts/source').consts; +const libPath = require('path'); async function init() { if (!await asyncFs.asyncFileExisted(ConfigPath)) { - await asyncFs.asyncMkdir(ConfigPath); + await asyncFs.asyncMkdir(ConfigPath, { recursive: true }); } } init(); const GlobalDefaultConfig = { - localDownloadPath: '', + downloadPath: '', + filenameFormat: '{songName}-{artist}', + downloadPathExisted: false, // don't search youtube by default sources: Object.values(sourceConsts).map(i => i.code).filter(s => s !== sourceConsts.Youtube.code), sourceConsts, + playlistSyncToLocal: { + autoSync: { + enable: false, + frequency: 1, + frequencyUnit: "day", + }, + deleteLocalFile: false, + filenameFormat: `{playlistName}${libPath.sep}{songName}-{artist}`, + soundQualityPreference: sound_quality.High, + syncAccounts: [], + }, }; async function setGlobalConfig(config) { + const oldConfig = await getGlobalConfig(); await asyncFs.asyncWriteFile(GlobalConfig, JSON.stringify(config)); + + // 只在本地同步配置发生变化时更新调度器 + if (JSON.stringify(oldConfig.playlistSyncToLocal) !== JSON.stringify(config.playlistSyncToLocal)) { + const schedulerService = require('../scheduler'); + await schedulerService.updateLocalSyncJob(); + } } async function getGlobalConfig() { @@ -32,6 +54,27 @@ async function getGlobalConfig() { config.sources = GlobalDefaultConfig.sources; } config.sourceConsts = GlobalDefaultConfig.sourceConsts; + config.downloadPathExisted = false; + if (config.downloadPath) { + config.downloadPathExisted = await asyncFs.asyncFileExisted(config.downloadPath); + } + + if (!config.filenameFormat) { + config.filenameFormat = GlobalDefaultConfig.filenameFormat; + } + + if (!config.playlistSyncToLocal) { + config.playlistSyncToLocal = GlobalDefaultConfig.playlistSyncToLocal; + } + if (!config.playlistSyncToLocal.filenameFormat) { + config.playlistSyncToLocal.filenameFormat = GlobalDefaultConfig.playlistSyncToLocal.filenameFormat; + } + if (!config.playlistSyncToLocal.soundQualityPreference) { + config.playlistSyncToLocal.soundQualityPreference = GlobalDefaultConfig.playlistSyncToLocal.soundQualityPreference; + } + if (!config.playlistSyncToLocal.syncAccounts) { + config.playlistSyncToLocal.syncAccounts = GlobalDefaultConfig.playlistSyncToLocal.syncAccounts; + } return config; } diff --git a/backend/src/service/cronjob/index.js b/backend/src/service/cronjob/index.js new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/service/job_manager/index.js b/backend/src/service/job_manager/index.js index 3899b09..fcf6b56 100644 --- a/backend/src/service/job_manager/index.js +++ b/backend/src/service/job_manager/index.js @@ -7,6 +7,8 @@ const JobStatus = require('../../consts/job_status'); const DataPath = `${__dirname}/../../../.profile/data`; const JobDataPath = `${DataPath}/jobs`; +const JobManagerInitTime = Date.now(); + async function listJobs(uid) { const list = []; const jobs = await getUserJobs(uid); @@ -157,6 +159,14 @@ async function findActiveJobByArgs(uid, args) { const jobs = await listJobs(uid); return jobs.find(job => { if (job['args'] === args && job['status'] !== JobStatus.Failed && job['status'] !== JobStatus.Finished) { + // 如果创建时间 早于 组件 init 时间,那么认为是无效的 job(意味着服务重启了,而目前 job 不支持重启服务后继续 run) + if (job['createdAt'] < JobManagerInitTime) { + return false; + } + // 超过 1 小时也认为超时 + if (Date.now() - job['createdAt'] > 1000 * 60 * 60) { + return false; + } return job; } }); diff --git a/backend/src/service/kv/index.js b/backend/src/service/kv/index.js new file mode 100644 index 0000000..85a2711 --- /dev/null +++ b/backend/src/service/kv/index.js @@ -0,0 +1,88 @@ +const { lock, unlock } = require('../../utils/simple_locker'); +const asyncFs = require('../../utils/fs'); +const logger = require('consola'); + +const DbPath = `${__dirname}/../../../.profile/data/kv-db`; + +async function init() { + if (!await asyncFs.asyncFileExisted(DbPath)) { + await asyncFs.asyncMkdir(DbPath); + } +} +init(); + +async function set(table, key, value) { + if (!await lock(table, 5)) { + logger.error(`get table locker failed, table: ${table}, key: ${key}, value: ${value}`); + return false; + } + const filePath = `${DbPath}/${table}.json`; + let data = {}; + if (await asyncFs.asyncFileExisted(filePath)) { + try { + data = JSON.parse(await asyncFs.asyncReadFile(filePath)); + } catch (err) { + logger.error(`parse ${filePath} failed`, err); + return false; + } + } + data[key] = value; + try { + await asyncFs.asyncWriteFile(filePath, JSON.stringify(data)); + } catch (err) { + logger.error(`write ${filePath} failed`, err); + return false; + } + unlock(table); + return true; +} + +async function get(table, key) { + const filePath = `${DbPath}/${table}.json`; + let data = {}; + if (await asyncFs.asyncFileExisted(filePath)) { + try { + data = JSON.parse(await asyncFs.asyncReadFile(filePath)); + } catch (err) { + logger.error(`parse ${filePath} failed`, err); + return false; + } + } + return data[key]; +} + +module.exports = { + set, + get, + fileSyncMeta: { + set: async function (source, sourceID, value) { + const key = `${source}-${sourceID}`; + return await set('fileSyncMeta', key, JSON.stringify(value)); + }, + get: async function (source, sourceID) { + const key = `${source}-${sourceID}`; + const ret = await get('fileSyncMeta', key); + if (!ret) { + return false; + } + return JSON.parse(ret); + }, + setPlaylistMeta: async function(playlistID, meta) { + const key = `playlist-${playlistID}`; + return await set('fileSyncMeta', key, JSON.stringify({ + songIDs: meta.songIDs || [], + // 预留其他字段 + })); + }, + getPlaylistMeta: async function(playlistID) { + const key = `playlist-${playlistID}`; + const ret = await get('fileSyncMeta', key); + if (!ret) { + return { + songIDs: [] + }; + } + return JSON.parse(ret); + } + } +}; \ No newline at end of file diff --git a/backend/src/service/media_fetcher/index.js b/backend/src/service/media_fetcher/index.js index bcc0580..d8fd2b9 100644 --- a/backend/src/service/media_fetcher/index.js +++ b/backend/src/service/media_fetcher/index.js @@ -5,6 +5,7 @@ const path = require('path'); const cmd = require('../../utils/cmd'); const fs = require('fs'); const configManager = require('../config_manager') +const downloadFile = require('../../utils/download'); const { getBinPath } = require('./media_get'); @@ -15,6 +16,29 @@ if (!fs.existsSync(basePath)) { } logger.info(`[tmp path] use ${basePath}`) + +async function downloadViaSourceUrl(url) { + logger.info(`downloadViaSourceUrl params: url: ${url}`); + + const requestHash = md5(url); + const downloadPath = `${basePath}/${requestHash}.mp3`; + logger.info(`start download from ${url}`); + + + const isSucceed = await downloadFile(url, downloadPath); + if (!isSucceed) { + logger.error(`download failed with ${url}`); + return false; + } + + if (!fs.existsSync(downloadPath)) { + logger.error(`download failed with ${url}, the file not exists ${downloadPath}`); + return false; + } + logger.info(`download success, path: ${downloadPath}`); + return downloadPath; +} + async function fetchWithUrl(url, { songName = "", addMediaTag = false, @@ -32,6 +56,7 @@ async function fetchWithUrl(url, { return false; } + addMediaTag = false; // todo: 等到 media-get fix 偶现的 添加 addMediaTag 后 panic 的问题,再移除这行代码 const downloadPath = `${fileBasePath}/${songName ? songName : requestHash}.mp3`; logger.info(`start parse and download from ${url}`); @@ -142,6 +167,7 @@ async function searchSongFromAllPlatform({ } module.exports = { + downloadViaSourceUrl: downloadViaSourceUrl, fetchWithUrl: fetchWithUrl, getMetaWithUrl: getMetaWithUrl, searchSongFromAllPlatform: searchSongFromAllPlatform, diff --git a/backend/src/service/media_fetcher/media_get.js b/backend/src/service/media_fetcher/media_get.js index 24bc9d6..0a48fab 100644 --- a/backend/src/service/media_fetcher/media_get.js +++ b/backend/src/service/media_fetcher/media_get.js @@ -31,8 +31,9 @@ async function getMediaGetInfo(isTempBin = false) { async function getLatestMediaGetVersion() { const remoteConfig = await RemoteConfig.getRemoteConfig(); - const latestVerisonUrl = `${remoteConfig.githubProxy}/https://raw.githubusercontent.com/foamzou/media-get/main/LATEST_VERSION`; + const latestVerisonUrl = `${remoteConfig.bestGithubProxy}https://raw.githubusercontent.com/foamzou/media-get/main/LATEST_VERSION`; console.log('start to get latest version from: ' + latestVerisonUrl); + const latestVersion = await httpsGet(latestVerisonUrl); console.log('latest version: ' + latestVersion); if (latestVersion === null || (latestVersion || "").split('.').length !== 3) { @@ -44,18 +45,73 @@ async function getLatestMediaGetVersion() { async function downloadFile(url, filename) { return new Promise((resolve) => { - const fileStream = fs.createWriteStream(filename); - https.get(url, res => { + let fileStream = fs.createWriteStream(filename); + let receivedBytes = 0; + + const handleResponse = (res) => { + // Handle redirects + if (res.statusCode === 301 || res.statusCode === 302) { + logger.info('Following redirect'); + fileStream.end(); + fileStream = fs.createWriteStream(filename); + if (res.headers.location) { + https.get(res.headers.location, handleResponse) + .on('error', handleError); + } + return; + } + + // Check for successful status code + if (res.statusCode !== 200) { + handleError(new Error(`HTTP Error: ${res.statusCode}`)); + return; + } + + const totalBytes = parseInt(res.headers['content-length'], 10); + + res.on('error', handleError); + fileStream.on('error', handleError); + res.pipe(fileStream); + + res.on('data', (chunk) => { + receivedBytes += chunk.length; + }); + fileStream.on('finish', () => { - fileStream.close(() => resolve(true)); // Successfully downloaded and saved, resolve with true + fileStream.close(() => { + if (receivedBytes === 0) { + fs.unlink(filename, () => { + logger.error('Download failed: Empty file received'); + resolve(false); + }); + } else if (totalBytes && receivedBytes < totalBytes) { + fs.unlink(filename, () => { + logger.error(`Download incomplete: ${receivedBytes}/${totalBytes} bytes`); + resolve(false); + }); + } else { + resolve(true); + } + }); }); - }) - .on('error', (error) => { - console.error('Download error:', error); + }; + + const handleError = (error) => { fileStream.destroy(); - fs.unlink(filename, () => resolve(false)); // On error, delete the file and resolve with false - }); + fs.unlink(filename, () => { + logger.error('Download error:', error); + resolve(false); + }); + }; + + const req = https.get(url, handleResponse) + .on('error', handleError) + .setTimeout(60000, () => { + handleError(new Error('Download timeout')); + }); + + req.on('error', handleError); }); } @@ -71,7 +127,7 @@ async function getMediaGetRemoteFilename(latestVersion) { suffix += '-arm64'; } const remoteConfig = await RemoteConfig.getRemoteConfig(); - return `${remoteConfig.githubProxy}/https://github.com/foamzou/media-get/releases/download/v${latestVersion}/media-get-${latestVersion}-${suffix}`; + return `${remoteConfig.bestGithubProxy}https://github.com/foamzou/media-get/releases/download/v${latestVersion}/media-get-${latestVersion}-${suffix}`; } const renameFile = (oldName, newName) => { diff --git a/backend/src/service/music_platform/wycloud/index.js b/backend/src/service/music_platform/wycloud/index.js index 88e7953..f6f4e24 100644 --- a/backend/src/service/music_platform/wycloud/index.js +++ b/backend/src/service/music_platform/wycloud/index.js @@ -115,9 +115,10 @@ async function getSongInfo(uid, id) { }; } -async function getPlayUrl(uid, id) { +async function getPlayUrl(uid, id, isLossless = false) { const response = await safeRequest(uid, song_url, { id, + br: isLossless ? 999000 : 320000 }); if (response === false) { return ''; @@ -172,6 +173,8 @@ async function getSongsFromPlaylist(uid, source, playlistId) { logger.error(`uid(${uid}) playlist(${playlistId}) has no songs.`, detailResponse, songsResponse); return false; } + // console.log(JSON.stringify(songsResponse, null, 4)); + // ddd if (songsResponse.songs.length >= 1000) { const songsPage2Response = await safeRequest(uid, playlist_track_all, { id: playlistId, @@ -201,11 +204,16 @@ async function getSongsFromPlaylist(uid, source, playlistId) { return false; } + // 收费歌曲 if (song.fee === 1) { + if (song.realPayed === 1 || song.payed === 1) { + return false; + } return true; } - // blocked or need to pay: subp == 0 && realpayed !== 1 - if (song.subp != 0 || song.realPayed === 1) { + // subp 或 cp === 1 可能都表示有版权 + // 免费歌曲 + if (song.subp === 1) { return false; } @@ -224,11 +232,12 @@ async function getSongsFromPlaylist(uid, source, playlistId) { songId: songInfo.id, songName: songInfo.name, artists: songInfo.ar.map(artist => artist.name), + artist: songInfo.ar.length > 0 ? songInfo.ar[0].name : '', duration: songInfo.dt / 1000, album: songInfo.al.name, cover: songInfo.al.picUrl, pageUrl: `https://music.163.com/song?id=${songInfo.id}`, - playUrl: !isBlocked && !isCloud ? `http://music.163.com/song/media/outer/url?id=${songInfo.id}.mp3` : '', + playUrl: !isBlocked && !isCloud ? `http://music.163.com/song/media/outer/url?id=${songInfo.id}.mp3` : '', // 不再建议使用这个 url,建议每次都 Call API 获取 isBlocked, isCloud, }); @@ -274,6 +283,11 @@ async function qrLoginCheck(uid, qrKey) { }; } +async function verifyAccountStatus(uid) { + const account = await getMyAccount(uid); + return account !== false; +} + async function safeRequest(uid, moduleFunc, params, cookieRequired = true) { try { const response = await requestApi(uid, moduleFunc, params, cookieRequired); @@ -300,4 +314,5 @@ module.exports = { getPlayUrl: getPlayUrl, qrLoginCreate: qrLoginCreate, qrLoginCheck: qrLoginCheck, + verifyAccountStatus: verifyAccountStatus, } \ No newline at end of file diff --git a/backend/src/service/remote_config/index.js b/backend/src/service/remote_config/index.js index 2736e6c..19786dd 100644 --- a/backend/src/service/remote_config/index.js +++ b/backend/src/service/remote_config/index.js @@ -1,23 +1,69 @@ const httpsGet = require('../../utils/network').asyncHttpsGet; const logger = require('consola'); +const configManager = require('../config_manager'); +// Store best proxy in memory for performance +let cachedBestProxy = ''; + +async function validateGithubAccess(proxy = '') { + try { + const testUrl = proxy ? `${proxy}https://api.github.com/zen` : 'https://api.github.com/zen'; + const response = await httpsGet(testUrl); + return response !== null; + } catch (err) { + return false; + } +} + +async function findBestProxy(proxyList) { + // Always try direct access first + if (await validateGithubAccess()) { + cachedBestProxy = ''; + return ''; + } + + // Try cached proxy if available + if (cachedBestProxy && await validateGithubAccess(cachedBestProxy)) { + return cachedBestProxy; + } + + // Test each proxy in the list + for (const proxy of proxyList) { + if (proxy && await validateGithubAccess(proxy)) { + cachedBestProxy = proxy; + return proxy; + } + } + + logger.warn('No working GitHub access found, either direct or via proxy'); + return ''; // Return empty string if no working access found +} async function getRemoteConfig() { const fallbackConfig = { - githubProxy: 'https://mirror.ghproxy.com/', + githubProxy: ['', 'https://ghp.ci/'], } - const remoteConfigUrl = 'https://foamzou.com/tools/melody-config.php'; + + const remoteConfigUrl = 'https://foamzou.com/tools/melody-config.php?v=2'; const remoteConfig = await httpsGet(remoteConfigUrl); + + let config = {}; if (remoteConfig === null) { - logger.error('get remote config failed, use fallback config'); - return fallbackConfig; + config = fallbackConfig; + } else { + config = JSON.parse(remoteConfig); } - const config = JSON.parse(remoteConfig); + + let bestGithubProxy = await findBestProxy(config.githubProxy); + if (bestGithubProxy !== '' && !bestGithubProxy.endsWith('/')) { + bestGithubProxy = bestGithubProxy + '/'; + } + return { - githubProxy: config.githubProxy, + bestGithubProxy, } } module.exports = { - getRemoteConfig: getRemoteConfig, + getRemoteConfig, } \ No newline at end of file diff --git a/backend/src/service/scheduler/index.js b/backend/src/service/scheduler/index.js new file mode 100644 index 0000000..ceede96 --- /dev/null +++ b/backend/src/service/scheduler/index.js @@ -0,0 +1,217 @@ +const schedule = require('node-schedule'); +const logger = require('consola'); +const configManager = require('../config_manager'); +const AccountService = require('../account'); +const syncPlaylist = require('../sync_music/sync_playlist'); +const unblockMusicInPlaylist = require('../sync_music/unblock_music_in_playlist'); +const { consts: sourceConsts } = require('../../consts/source'); +const { getUserAllPlaylist, verifyAccountStatus } = require('../music_platform/wycloud'); + +class SchedulerService { + constructor() { + this.jobs = new Map(); + } + + async start() { + await this.scheduleLocalSyncJobs(); + await this.scheduleCloudSyncJobs(); + + // Log initial schedule info + const localNextRun = this.getLocalSyncNextRun(); + if (localNextRun) { + logger.info(`Next local sync scheduled at: ${localNextRun.nextRunTime}, in ${Math.round(localNextRun.remainingMs / 1000 / 60)} minutes`); + } + + const accounts = await AccountService.getAllAccounts(); + for (const uid in accounts) { + const cloudNextRun = this.getCloudSyncNextRun(uid); + if (cloudNextRun) { + logger.info(`Next cloud sync for account ${uid} scheduled at: ${cloudNextRun.nextRunTime}, in ${Math.round(cloudNextRun.remainingMs / 1000 / 60)} minutes`); + } + } + + logger.info('Scheduler service started'); + } + + async scheduleLocalSyncJobs() { + // 系统级别的本地同步任务 + const config = await configManager.getGlobalConfig(); + const syncAccounts = config.playlistSyncToLocal.syncAccounts || []; + if (!config.playlistSyncToLocal.autoSync.enable || syncAccounts.length === 0) { + return; + } + + const frequency = config.playlistSyncToLocal.autoSync.frequency; + const unit = config.playlistSyncToLocal.autoSync.frequencyUnit; + + const rule = this.buildScheduleRule(frequency, unit); + const jobKey = 'localSync'; + + const job = schedule.scheduleJob(rule, async () => { + logger.info('Start auto sync playlist to local'); + for (const uid of syncAccounts) { + const isActive = await verifyAccountStatus(uid); + if (!isActive) { + logger.warn(`Account ${uid} is not active, skip local sync`); + continue; + } + const playlists = await getUserAllPlaylist(uid); + for (const playlist of playlists) { + logger.info(`Start sync playlist ${playlist.id} to local for account ${uid}`); + await syncPlaylist(uid, sourceConsts.Netease.code, playlist.id); + } + } + }); + + this.jobs.set(jobKey, job); + + logger.info(`Schedule local sync job success, rule: ${this.formatScheduleRule(rule)}`); + } + + async scheduleCloudSyncJobs() { + // 账号级别的云盘同步任务 + const accounts = await AccountService.getAllAccounts(); + for (const uid in accounts) { + const account = accounts[uid]; + await this.scheduleCloudSyncJob(uid, account); + } + } + + async scheduleCloudSyncJob(uid, account) { + if (!account.config?.playlistSyncToWyCloudDisk?.autoSync?.enable) { + return; + } + + const isActive = await verifyAccountStatus(uid); + if (!isActive) { + logger.warn(`Account ${uid} is not active, skip cloud sync`); + return; + } + + const frequency = account.config.playlistSyncToWyCloudDisk.autoSync.frequency; + const unit = account.config.playlistSyncToWyCloudDisk.autoSync.frequencyUnit; + const jobKey = `cloudSync_${uid}`; + + const rule = this.buildScheduleRule(frequency, unit); + + this.jobs.set(jobKey, schedule.scheduleJob(rule, async () => { + logger.info(`Start cloud sync for account ${uid}`); + const playlists = await getUserAllPlaylist(uid); + for (const playlist of playlists) { + logger.info(`Start sync playlist ${playlist.id} to cloud for account ${uid}`); + await unblockMusicInPlaylist(uid, sourceConsts.Netease.code, playlist.id, { + syncWySong: account.config.playlistSyncToWyCloudDisk.syncWySong, + syncNotWySong: account.config.playlistSyncToWyCloudDisk.syncNotWySong + }); + } + })); + logger.info(`Schedule cloud sync job for account ${uid} success, rule: ${this.formatScheduleRule(rule)}`); + } + + buildScheduleRule(frequency, unit) { + if (unit === 'minute') { + return `0 */${frequency} * * * *`; + } else if (unit === 'hour') { + return `0 0 */${frequency} * * *`; + } else { + return `0 0 0 */${frequency} * *`; + } + } + + formatScheduleRule(rule) { + if (typeof rule === 'string') { + return rule; + } + return `每天 ${rule.hour.map(h => h.toString().padStart(2, '0') + ':00').join(', ')} 执行`; + } + + async updateLocalSyncJob() { + logger.info('Update local sync job'); + // 添加调试日志 + logger.info('Before update:'); + logger.info(`- Jobs map size: ${this.jobs.size}`); + logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`); + + const localJob = this.jobs.get('localSync'); + if (localJob) { + localJob.cancel(); + this.jobs.delete('localSync'); + } + + // 添加调试日志 + logger.info('After cancel:'); + logger.info(`- Jobs map size: ${this.jobs.size}`); + logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`); + + await this.scheduleLocalSyncJobs(); + + // 添加调试日志 + logger.info('After reschedule:'); + logger.info(`- Jobs map size: ${this.jobs.size}`); + logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`); + const newJob = this.jobs.get('localSync'); + logger.info(`- New job created: ${!!newJob}`); + if (newJob) { + logger.info(`- New job next run: ${newJob.nextInvocation()}`); + } + + logger.info('Update local sync job success'); + } + + async updateCloudSyncJob(uid) { + logger.info(`Update cloud sync job for account ${uid}`); + // 取消指定账号的云盘同步任务 + const cloudJobKey = `cloudSync_${uid}`; + const cloudJob = this.jobs.get(cloudJobKey); + if (cloudJob) { + cloudJob.cancel(); + this.jobs.delete(cloudJobKey); + logger.info(`Cancel cloud sync job for account ${uid}`); + } + // 重新调度指定账号的云盘同步任务 + const account = (await AccountService.getAllAccounts())[uid]; + if (account) { + await this.scheduleCloudSyncJob(uid, account); + } + logger.info(`Update cloud sync job for account ${uid} success`); + } + + getNextRunInfo(job) { + if (!job) return null; + + const nextRun = job.nextInvocation(); + if (!nextRun) return null; + + const now = new Date(); + const remainingMs = nextRun.getTime() - now.getTime(); + + return { + nextRunTime: nextRun, + remainingMs: remainingMs + }; + } + + getLocalSyncNextRun() { + const job = this.jobs.get('localSync'); + + // 添加调试日志 + logger.info('Debug getLocalSyncNextRun:'); + logger.info(`- Has job: ${!!job}`); + logger.info(`- Jobs map size: ${this.jobs.size}`); + logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`); + if (job) { + logger.info(`- Job next invocation: ${job.nextInvocation()}`); + logger.info(`- Job scheduling info:`, job.scheduledJobs); + } + + return this.getNextRunInfo(job); + } + + getCloudSyncNextRun(uid) { + const job = this.jobs.get(`cloudSync_${uid}`); + return this.getNextRunInfo(job); + } +} + +const schedulerService = new SchedulerService(); +module.exports = schedulerService; \ No newline at end of file diff --git a/backend/src/service/sync_music/download_to_local.js b/backend/src/service/sync_music/download_to_local.js new file mode 100644 index 0000000..9cdc2ad --- /dev/null +++ b/backend/src/service/sync_music/download_to_local.js @@ -0,0 +1,64 @@ +const { fetchWithUrl, getMetaWithUrl } = require('../media_fetcher'); +const { uploadSong, searchSong, matchAndFixCloudSong } = require('../music_platform/wycloud'); +const logger = require('consola'); +const sleep = require('../../utils/sleep'); +const configManager = require('../config_manager'); +const fs = require('fs'); +const libPath = require('path'); +const utilFs = require('../../utils/fs'); + + +module.exports = { + downloadFromLocalTmpPath: downloadFromLocalTmpPath, + buildDestFilename: buildDestFilename, +} + +async function downloadFromLocalTmpPath(tmpPath, songInfo = { + songName: "", + artist: "", + album: "", +}, playlistName = '', collectResponse) { + const globalConfig = (await configManager.getGlobalConfig()); + const downloadPath = globalConfig.downloadPath; + if (!downloadPath) { + logger.error(`download path not set`); + return "IOFailed"; + } + const destPathAndFilename = buildDestFilename(globalConfig, songInfo, playlistName); + const destPath = libPath.dirname(destPathAndFilename); + // make sure the path is exist + await utilFs.asyncMkdir(destPath, {recursive: true}); + try { + if (await utilFs.asyncFileExisted(destPathAndFilename)) { + logger.info(`file already exists, remove it: ${destPathAndFilename}`); + await utilFs.asyncUnlinkFile(destPathAndFilename) + } + await utilFs.asyncMoveFile(tmpPath, destPathAndFilename); + } catch (err) { + logger.error(`move file failed, ${tmpPath} -> ${destPathAndFilename}`, err); + return "IOFailed"; + } + if (collectResponse !== undefined) { + try { + const md5Value = await utilFs.asyncMd5(destPathAndFilename); + collectResponse['md5Value'] = md5Value; + } catch (err) { + logger.error(`md5 failed, ${destPathAndFilename}`, err); + // don't return false, just log it + } + } + logger.info(`download song success, path: ${destPathAndFilename}`); + return true; +} + +function buildDestFilename(globalConfig, songInfo, playlistName) { + const downloadPath = globalConfig.downloadPath; + let filename = (playlistName ? globalConfig.playlistSyncToLocal?.filenameFormat : globalConfig.filenameFormat) + .replace(/{artist}/g, songInfo.artist ? songInfo.artist : 'Unknown') + .replace(/{songName}/g, songInfo.songName ? songInfo.songName : 'Unknown') + .replace(/{playlistName}/g, playlistName ? playlistName : 'UnknownPlayList') + .replace(/{album}/g, songInfo.album ? songInfo.album : 'Unknown'); + // remove the head / and \ in filename + filename = filename.replace(/^[\/\\]+/, '') + '.mp3'; + return `${downloadPath}${libPath.sep}${filename}` +} \ No newline at end of file diff --git a/backend/src/service/sync_music/index.js b/backend/src/service/sync_music/index.js index a1bdd2e..c7c3f5a 100644 --- a/backend/src/service/sync_music/index.js +++ b/backend/src/service/sync_music/index.js @@ -1,9 +1,11 @@ const syncSingleSongWithUrl = require('./sync_single_song_with_url'); const unblockMusicInPlaylist = require('./unblock_music_in_playlist'); const unblockMusicWithSongId = require('./unblock_music_with_song_id'); +const syncPlaylist = require('./sync_playlist'); module.exports = { syncSingleSongWithUrl: syncSingleSongWithUrl, unblockMusicInPlaylist: unblockMusicInPlaylist, unblockMusicWithSongId: unblockMusicWithSongId, + syncPlaylist: syncPlaylist, }; \ No newline at end of file diff --git a/backend/src/service/sync_music/sync_playlist.js b/backend/src/service/sync_music/sync_playlist.js new file mode 100644 index 0000000..82fde10 --- /dev/null +++ b/backend/src/service/sync_music/sync_playlist.js @@ -0,0 +1,382 @@ +const { + getSongsFromPlaylist, + getPlayUrl, +} = require("../music_platform/wycloud"); +const syncSingleSongWithUrl = require("./sync_single_song_with_url"); +const logger = require("consola"); +const { + findTheBestMatchFromWyCloud, + searchSongsWithSongMeta, +} = require("../search_songs"); +const JobManager = require("../job_manager"); +const JobType = require("../../consts/job_type"); +const JobStatus = require("../../consts/job_status"); +const BusinessCode = require("../../consts/business_code"); +const { downloadViaSourceUrl } = require("../media_fetcher/index"); +const { + downloadFromLocalTmpPath, + buildDestFilename, +} = require("./download_to_local"); +const KV = require("../kv"); +const utilFs = require("../../utils/fs"); +const configManager = require("../config_manager"); +const path = require("path"); +const { consts } = require("../../consts/source"); +const soundQualityConst = require("../../consts/sound_quality"); + +module.exports = async function syncPlaylist(uid, source, playlistId) { + // step 1. get all the songs + const playlistInfo = await getSongsFromPlaylist(uid, source, playlistId); + if (playlistInfo === false) { + return false; + } + + // calc the songs need to be synced + const songsNeedToSync = []; + const cacheForCalcSongsNeedToSync = {}; + + // 如果开启了删除功能,先执行删除 + const globalConfig = await configManager.getGlobalConfig(); + if (globalConfig.playlistSyncToLocal.deleteLocalFile) { + const currentSongIDs = playlistInfo.songs.map(song => song.songId); + await syncDeleteFiles(playlistId, currentSongIDs); + } + + for (const song of playlistInfo.songs) { + const needToSync = await isNeedToSyncFile(playlistId, song.songId, cacheForCalcSongsNeedToSync); + if (!needToSync) { + continue; + } + songsNeedToSync.push(song); + } + + if (songsNeedToSync.length === 0) { + logger.info(`[No need] all the songs in the playlist are already downloaded.`); + return BusinessCode.StatusJobNoNeedToCreate; + } + + // create job + const args = `syncPlaylist: {"source":${source},"playlistId":${playlistId}}`; + if (await JobManager.findActiveJobByArgs(uid, args)) { + logger.info(`syncPlaylist job is already running.`); + return BusinessCode.StatusJobAlreadyExisted; + } + const jobId = await JobManager.createJob(uid, { + name: `下载歌单到本地服务器:${playlistInfo.name}`, + args, + type: JobType.SyncThePlaylistToLocalService, + status: JobStatus.Pending, + desc: `有${songsNeedToSync.length}首歌曲需要下载`, + progress: 0, + tip: "等待下载", + createdAt: Date.now(), + }); + + // async do the job + (async () => { + const songs = songsNeedToSync; + logger.info(`${jobId}: try to sync songs: ${playlistInfo.name}`); + await JobManager.updateJob(uid, jobId, { + status: JobStatus.InProgress, + }); + const succeedList = []; + const failedList = []; + // step 2. download the songs + for (const song of songs) { + let tip = `[${succeedList.length + failedList.length + 1}/${ + songs.length + }] 正在下载歌曲:${song.songName}`; + await JobManager.updateJob(uid, jobId, { + tip, + }); + const syncSucceed = await syncSingleSong(uid, song, playlistInfo); + if (syncSucceed) { + await JobManager.updateJob(uid, jobId, { + log: song.songName + ": 下载成功", + }); + succeedList.push({ songName: song.songName, artist: song.artists[0] }); + } else { + await JobManager.updateJob(uid, jobId, { + log: song.songName + ": 下载失败", + }); + failedList.push({ songName: song.songName, artist: song.artists[0] }); + } + await JobManager.updateJob(uid, jobId, { + progress: (succeedList.length + failedList.length) / songs.length, + }); + } + + let tip = `任务完成,成功${succeedList.length}首,失败${failedList.length}首`; + await JobManager.updateJob(uid, jobId, { + progress: 1, + status: succeedList.length > 0 ? JobStatus.Finished : JobStatus.Failed, + tip, + data: { + succeedList, + failedList, + }, + }); + })().catch(async (e) => { + logger.error(`${jobId}: ${e}`); + let tip = "遇到不可思议的错误了哦,任务终止"; + await JobManager.updateJob(uid, jobId, { + status: JobStatus.Failed, + tip, + }); + }); + + return jobId; +}; + +async function syncSingleSong(uid, wySongMeta, playlistInfo) { + const playlistName = playlistInfo.name; + const playlistID = playlistInfo.id; + logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`); + const globalConfig = await configManager.getGlobalConfig(); + let isLossless = false; + if (globalConfig.playlistSyncToLocal.soundQualityPreference === soundQualityConst.Lossless) { + isLossless = true; + } + // 优先使用官方资源下载 + const playUrl = await getPlayUrl(uid, wySongMeta.songId, isLossless); + if (playUrl) { + const tmpPath = await downloadViaSourceUrl(playUrl); + if (tmpPath) { + const collectRet = {}; + const ret = await downloadFromLocalTmpPath( + tmpPath, + wySongMeta, + playlistName, + collectRet + ); + if (ret === true) { + logger.info(`download from official succeed`, wySongMeta); + if (collectRet.md5Value) { + await recordSongIndex(playlistID, wySongMeta.songId, wySongMeta, playlistName, collectRet.md5Value); + } + return true; + } + } + } + + // 从公开资源获取 + logger.info( + `download from official failed, try to download from public resources`, + wySongMeta + ); + + const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, { + songName: wySongMeta.songName, + artist: wySongMeta.artists[0], + album: wySongMeta.album, + musicPlatformSongId: wySongMeta.songId, + }); + // search songs with the meta + const searchListfilttered = await searchSongsWithSongMeta( + { + songName: wySongMeta.songName, + artist: wySongMeta.artists[0], + album: wySongMeta.album, + duration: wySongMeta.duration, + }, + { + expectArtistAkas: songFromWyCloud.artists ? songFromWyCloud.artists : [], + allowSongsJustMatchDuration: false, + allowSongsNotMatchMeta: false, + } + ); + + logger.info( + `use the searchListfilttered: ${JSON.stringify(searchListfilttered)}` + ); + if (searchListfilttered === false) { + return false; + } + + // find the best match song + for (const searchItem of searchListfilttered) { + logger.info(`try to the search item: ${JSON.stringify(searchItem)}`); + + const collectRet = {}; + const isSucceed = await syncSingleSongWithUrl( + uid, + searchItem.url, + { + songName: wySongMeta.songName, + artist: wySongMeta.artists[0], + album: wySongMeta.album, + songFromWyCloud, + }, + 0, + JobType.SyncThePlaylistToLocalService, + playlistName, + collectRet + ); + if (isSucceed === "IOFailed") { + logger.error(`not try others due to upload failed.`); + return false; + } + if (isSucceed) { + if (collectRet.md5Value) { + await recordSongIndex(playlistID, wySongMeta.songId, wySongMeta, playlistName, collectRet.md5Value); + } + return true; + } + } + return false; +} + +const SourceWYPlaylist = "wycloudPlaylist"; +function recordFileIndex(playlistID, songID, songInfo, playlistName, md5Value) { + const sourceID = `${playlistID}_${songID}`; + // no need to await to save time + KV.fileSyncMeta.set(SourceWYPlaylist, sourceID, { + songInfo, + playlistName, + md5Value, + createTime: Date.now(), + }); +} + +async function getRecordFileIndex(playlistID, songID) { + const sourceID = `${playlistID}_${songID}`; + return await KV.fileSyncMeta.get(SourceWYPlaylist, sourceID); +} + +async function isNeedToSyncFile(playlistID, songID, cache) { + const record = await getRecordFileIndex(playlistID, songID); + if (!record) { + // logger.info(`no record for ${playlistID}_${songID}, need to sync`); + return true; + } + + // use the latest setting to rebuild the dest filename + // then check: + // 1. if the file exists(don't check the md5), skip + // 2. if the file not exists, check if there is a same file under the destFile's dir with the same md5, skip + + const globalConfig = await configManager.getGlobalConfig(); + const destFilename = buildDestFilename( + globalConfig, + record.songInfo, + record.playlistName + ); + + if (await utilFs.asyncFileExisted(destFilename)) { + // logger.info(`file already exists, skip: ${destFilename}`); + return false; + } + + try { + const dir = path.dirname(destFilename); + const files = await (async () => { + if (cache[`dir_files_${dir}`]) { + return cache[`dir_files_${dir}`]; + } + const files = await utilFs.asyncReadDir(dir); + cache[`dir_files_${dir}`] = files; + return files; + })(); + for (const file of files) { + const filename = path.join(dir, file); + const md5Value = await (async () => { + if (cache[`md5_${filename}`]) { + return cache[`md5_${filename}`]; + } + const md5Value = await utilFs.asyncMd5(filename); + cache[`md5_${filename}`] = md5Value; + return md5Value; + })() + if (md5Value === record.md5Value) { + // logger.info(`file already exists with the same md5, skip: ${filename}`); + return false; + } + } + // logger.info(`no file with the same md5, need to sync: ${destFilename}`); + return true; + } catch (e) { + logger.error(e); + // logger.info(`error when check the file, need to sync: ${destFilename}`); + return true; + } +} + +async function recordSongIndex(playlistID, songID, songInfo, playlistName, md5Value) { + try { + // 记录单曲信息 + recordFileIndex(playlistID, songID, songInfo, playlistName, md5Value); + + // 更新歌单元数据 + const playlistMeta = await KV.fileSyncMeta.getPlaylistMeta(playlistID); + const songIDs = new Set(playlistMeta.songIDs || []); + songIDs.add(songID); + await KV.fileSyncMeta.setPlaylistMeta(playlistID, { + songIDs: Array.from(songIDs) + }); + + logger.info(`Updated playlist meta for song: ${songInfo.songName}`); + } catch (err) { + logger.error(`Failed to record song index: ${songInfo.songName}`, err); + // 不抛出错误,避免影响主流程 + } +} + +async function syncDeleteFiles(playlistId, currentSongIDs) { + try { + // 获取本地已下载的歌曲记录 + const playlistMeta = await KV.fileSyncMeta.getPlaylistMeta(playlistId); + const localSongIDs = playlistMeta.songIDs || []; + + // 找出需要删除的歌曲(在本地但不在云端的) + const needDeleteSongIDs = localSongIDs.filter(id => !currentSongIDs.includes(id)); + + if (needDeleteSongIDs.length === 0) { + logger.info(`No songs need to be deleted for playlist: ${playlistId}`); + return; + } + + logger.info(`Found ${needDeleteSongIDs.length} songs to delete for playlist: ${playlistId}`); + + // 记录删除结果 + const deletedSongIDs = new Set(); + + for (const songID of needDeleteSongIDs) { + const record = await getRecordFileIndex(playlistId, songID); + if (!record) { + logger.warn(`No record found for song: ${songID}`); + continue; + } + + const globalConfig = await configManager.getGlobalConfig(); + const destFilename = buildDestFilename(globalConfig, record.songInfo, record.playlistName); + + try { + if (await utilFs.asyncFileExisted(destFilename)) { + await utilFs.asyncUnlinkFile(destFilename); + logger.info(`Deleted file: ${destFilename}`); + } else { + logger.warn(`File not found: ${destFilename}`); + } + + // 删除单曲记录 + const sourceID = `${playlistId}_${songID}`; + await KV.fileSyncMeta.set(SourceWYPlaylist, sourceID, null); + + deletedSongIDs.add(songID); + logger.info(`Deleted record for song: ${record.songInfo.songName}`); + } catch (err) { + logger.error(`Failed to delete song: ${record.songInfo.songName}`, err); + } + } + + // 更新歌单元数据 + const remainingSongIDs = localSongIDs.filter(id => !deletedSongIDs.has(id)); + await KV.fileSyncMeta.setPlaylistMeta(playlistId, { + songIDs: remainingSongIDs + }); + + logger.info(`Successfully deleted ${deletedSongIDs.size} songs from playlist: ${playlistId}`); + } catch (err) { + logger.error(`Failed to sync delete files for playlist: ${playlistId}`, err); + } +} diff --git a/backend/src/service/sync_music/sync_single_song_with_url.js b/backend/src/service/sync_music/sync_single_song_with_url.js index b9e0d62..60d7d2f 100644 --- a/backend/src/service/sync_music/sync_single_song_with_url.js +++ b/backend/src/service/sync_music/sync_single_song_with_url.js @@ -1,18 +1,23 @@ const { fetchWithUrl, getMetaWithUrl } = require('../media_fetcher'); -const { uploadSong, searchSong, matchAndFixCloudSong } = require('../music_platform/wycloud'); const logger = require('consola'); const sleep = require('../../utils/sleep'); const findTheBestMatchFromWyCloud = require('../search_songs/find_the_best_match_from_wycloud'); const JobManager = require('../job_manager'); const JobStatus = require('../../consts/job_status'); +const JobType = require('../../consts/job_type'); +const configManager = require('../config_manager'); const fs = require('fs'); +const libPath = require('path'); +const utilFs = require('../../utils/fs'); +const { downloadFromLocalTmpPath } = require('./download_to_local'); +const uploadWithRetryThenMatch = require('./upload_to_wycloud_disk_with_retry_then_match'); module.exports = async function syncSingleSongWithUrl(uid, url, { songName = "", artist = "", album = "", songFromWyCloud = null -} = {}, jobId = 0) { +} = {}, jobId = 0, jobType = JobType.SyncSongFromUrl, playlistName = "", collectRet) { // step 1. fetch song info const songInfo = await getMetaWithUrl(url); logger.info(songInfo); @@ -21,7 +26,7 @@ module.exports = async function syncSingleSongWithUrl(uid, url, { return false; } - await updateJobIfNeed(uid, jobId, songInfo); + await updateJobIfNeed(uid, jobId, songInfo, jobType); // step 2. find the best match from wycloud if (songFromWyCloud === null) { @@ -54,62 +59,25 @@ module.exports = async function syncSingleSongWithUrl(uid, url, { return false; } - // step 4. upload - logger.info(`upload song start: ${path}`); - let isUploadSucceed = false; - let uploadResult; - const startTime = new Date(); - for (let tryCount = 0; tryCount < 5; tryCount++) { - if (tryCount !== 0) { - logger.info(`upload song failed, try again: ${path}`); - } - uploadResult = await uploadSong(uid, path); - if (uploadResult === false) { - logger.error(`upload song failed, uid: ${uid}, path: ${path}`); - await sleep(3000); - continue; - } else { - isUploadSucceed = true; - break; - } - } - - // del file async - fs.unlink(path, () => {}); - - if (!isUploadSucceed) { - logger.error(`upload song failed, uid: ${uid}, path: ${path}`); - return "uploadFailed"; - } - - const costSeconds = (new Date() - startTime) / 1000; - logger.info(`upload song success, uid: ${uid}, path: ${path}, cost: ${costSeconds}s`); + // step 4. upload or download + logger.info(`handle song start: ${path}`); - if (uploadResult.matched) { - logger.info(`matched song already, uid: ${uid}, songId: ${uploadResult.songId}. ignore.`); - return true; - } - - // step 5. fix match manually IF not matched in music platform - if (!songFromWyCloud) { - logger.info(`would not try to match from wycloud!!! uid: ${uid}, ${JSON.stringify(songInfo)}`); - return true; + if (jobType === JobType.DownloadSongFromUrl || jobType === JobType.SyncThePlaylistToLocalService) { + return await downloadFromLocalTmpPath(path, songInfo, playlistName, collectRet); + } else { + return await uploadWithRetryThenMatch(uid, path, songInfo, songFromWyCloud); } - const matchResult = await matchAndFixCloudSong(uid, uploadResult.songId, songFromWyCloud.songId); - logger.info(`match song ${matchResult ? 'success' : 'failed'}, uid: ${uid}, songId: ${uploadResult.songId}, wySongId: ${songFromWyCloud.songId}`); - - return true; } -async function updateJobIfNeed(uid, jobId, songInfo) { +async function updateJobIfNeed(uid, jobId, songInfo, jobType) { if (!jobId) { return; } + const operation = jobType === JobType.SyncSongFromUrl ? "上传" : "下载"; await JobManager.updateJob(uid, jobId, { - name: `上传歌曲:${songInfo.songName}`, + name: `${operation}歌曲:${songInfo.songName}`, status: JobStatus.InProgress, desc: `歌曲: ${songInfo.songName}`, tip: "任务开始", }); -} - +} \ No newline at end of file diff --git a/backend/src/service/sync_music/unblock_music_in_playlist.js b/backend/src/service/sync_music/unblock_music_in_playlist.js index 42ef108..3acccf0 100644 --- a/backend/src/service/sync_music/unblock_music_in_playlist.js +++ b/backend/src/service/sync_music/unblock_music_in_playlist.js @@ -1,23 +1,56 @@ -const { getBlockedSongsFromPlaylist } = require('../music_platform/wycloud'); +const { getSongsFromPlaylist, getPlayUrl } = require('../music_platform/wycloud'); const syncSingleSongWithUrl = require('./sync_single_song_with_url'); const logger = require('consola'); const { findTheBestMatchFromWyCloud, searchSongsWithSongMeta } = require('../search_songs'); const JobManager = require('../job_manager'); const JobType = require('../../consts/job_type'); const JobStatus = require('../../consts/job_status'); +const SoundQuality = require('../../consts/sound_quality'); const BusinessCode = require('../../consts/business_code'); +const AccountService = require('../account'); +const { downloadViaSourceUrl } = require("../media_fetcher/index"); +const uploadWithRetryThenMatch = require('./upload_to_wycloud_disk_with_retry_then_match'); +const asyncFS = require('../../utils/fs'); -module.exports = async function unblockMusicInPlaylist(uid, source, playlistId) { - // step 1. get blocked songs - const songsInfo = await getBlockedSongsFromPlaylist(uid, source, playlistId); +// scope: +// 1. for not wy song: download from network then upload to cloud disk +// 2. for wy song: download from wy then upload to cloud disk. (i.e. backup wy song to cloud disk) +module.exports = async function unblockMusicInPlaylist(uid, source, playlistId, options = { + syncWySong: false, + syncNotWySong: false, + asyncExecute: true, +}) { + // step 1. get songs + const songsInfo = await getSongsFromPlaylist(uid, source, playlistId); if (songsInfo === false) { return false; } - if (songsInfo.blockedSongs.length === 0) { + if (songsInfo.songs.length === 0) { return false; } + const songsNeedToSync = []; + songsInfo.songs.forEach(song => { + if (song.isCloud) { + return + } + // block song + if (song.isBlocked) { + if (options.syncNotWySong) { + songsNeedToSync.push(song); + } + } else { + // wy song + if (options.syncWySong) { + songsNeedToSync.push(song); + } + } + }); + if (songsNeedToSync.length === 0) { + return BusinessCode.StatusNoNeedToSync; + } + // create job const args = `unblockMusicInPlaylist: {"source":${source},"playlistId":${playlistId}}`; if (await JobManager.findActiveJobByArgs(uid, args)) { @@ -29,15 +62,15 @@ module.exports = async function unblockMusicInPlaylist(uid, source, playlistId) args, type: JobType.UnblockedPlaylist, status: JobStatus.Pending, - desc: `有${songsInfo.blockedSongs.length}首歌曲需要解锁`, + desc: `有${songsNeedToSync.length}首歌曲需要解锁`, progress: 0, tip: "等待解锁", createdAt: Date.now() }); - + // async do the job - (async () => { - const songs = songsInfo.blockedSongs; + const job = (async () => { + const songs = songsNeedToSync; logger.info(`${jobId}: try to unblock songs: ${JSON.stringify(songs)}`); await JobManager.updateJob(uid, jobId, { status: JobStatus.InProgress, @@ -84,20 +117,56 @@ module.exports = async function unblockMusicInPlaylist(uid, source, playlistId) status: JobStatus.Failed, tip, }); - }) + }); + + // For sync execution, wait for job completion + if (!options.asyncExecute) { + await job; + } return jobId; } async function syncSingleSongWithMeta(uid, wySongMeta) { logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`); + // 获取 wycloud 的歌曲信息,有 id 就直接 get,没有就 search meta 选一个最匹配的 const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, { songName: wySongMeta.songName, artist: wySongMeta.artists[0], album: wySongMeta.album, musicPlatformSongId: wySongMeta.songId, }); - // search songs with the meta + + // Case 1: download the song from wy + if (!wySongMeta.isBlocked) { + const account = AccountService.getAccount(uid); + const playUrl = await getPlayUrl(uid, wySongMeta.songId, account.config.playlistSyncToWyCloudDisk.soundQualityPreference === SoundQuality.Lossless); + // if the playUrl is empty, we think the song is block as well. go through the search process + if (playUrl) { + const tmpPath = await downloadViaSourceUrl(playUrl); + // if download failed, we think due to network issue, just return false. It will retry in the next time + if (tmpPath === false) { + return false; + } + + // add some magic + try { + await asyncFS.asyncAppendFile(tmpPath, '00000'); + } catch (e) { + logger.error(`append file failed: ${tmpPath}`); + // 追加失败,可以继续 + } + + const isSucceed = await uploadWithRetryThenMatch(uid, tmpPath, null, songFromWyCloud); + + if (isSucceed === true) { + return true; + } + return false; + } + } + + // Case 2: search songs with the meta in the internet then upload to cloud const searchListfilttered = await searchSongsWithSongMeta({ songName: wySongMeta.songName, artist: wySongMeta.artists[0], @@ -124,7 +193,7 @@ async function syncSingleSongWithMeta(uid, wySongMeta) { album: wySongMeta.album, songFromWyCloud, }); - if (isUploadSucceed === "uploadFailed") { + if (isUploadSucceed === "IOFailed") { logger.error(`not try others due to upload failed.`); return false; } diff --git a/backend/src/service/sync_music/unblock_music_with_song_id.js b/backend/src/service/sync_music/unblock_music_with_song_id.js index 927cd2f..887b03c 100644 --- a/backend/src/service/sync_music/unblock_music_with_song_id.js +++ b/backend/src/service/sync_music/unblock_music_with_song_id.js @@ -90,7 +90,7 @@ async function syncSingleSongWithMeta(uid, wySongMeta) { album: wySongMeta.album, songFromWyCloud, }); - if (isUploadSucceed === "uploadFailed") { + if (isUploadSucceed === "IOFailed") { logger.error(`not try others due to upload failed.`); return false; } diff --git a/backend/src/service/sync_music/upload_to_wycloud_disk_with_retry_then_match.js b/backend/src/service/sync_music/upload_to_wycloud_disk_with_retry_then_match.js new file mode 100644 index 0000000..700aab7 --- /dev/null +++ b/backend/src/service/sync_music/upload_to_wycloud_disk_with_retry_then_match.js @@ -0,0 +1,50 @@ +const { uploadSong, matchAndFixCloudSong } = require('../music_platform/wycloud'); +const logger = require('consola'); +const fs = require('fs'); +const sleep = require('../../utils/sleep'); + +module.exports = async function uploadWithRetryThenMatch(uid, path, songInfo, songFromWyCloud) { + const startTime = new Date(); + let isHandleSucceed = false; + let uploadResult; + + for (let tryCount = 0; tryCount < 5; tryCount++) { + if (tryCount !== 0) { + logger.info(`upload song failed, try again: ${path}`); + } + uploadResult = await uploadSong(uid, path); + if (uploadResult === false) { + logger.error(`upload song failed, uid: ${uid}, path: ${path}`); + await sleep(3000); + continue; + } else { + isHandleSucceed = true; + break; + } + } + + // del file async + fs.unlink(path, () => {}); + + if (!isHandleSucceed) { + logger.error(`upload song failed, uid: ${uid}, path: ${path}`); + return "IOFailed"; + } + + const costSeconds = (new Date() - startTime) / 1000; + logger.info(`upload song success, uid: ${uid}, path: ${path}, cost: ${costSeconds}s`); + + if (uploadResult.matched) { + logger.info(`matched song already, uid: ${uid}, songId: ${uploadResult.songId}. ignore.`); + return true; + } + + // fix match manually IF not matched in music platform + if (!songFromWyCloud) { + logger.info(`would not try to match from wycloud!!! uid: ${uid}, ${JSON.stringify(songInfo)}`); + return true; + } + const matchResult = await matchAndFixCloudSong(uid, uploadResult.songId, songFromWyCloud.songId); + logger.info(`match song ${matchResult ? 'success' : 'failed'}, uid: ${uid}, songId: ${uploadResult.songId}, wySongId: ${songFromWyCloud.songId}`); + return true; +} \ No newline at end of file diff --git a/backend/src/utils/download.js b/backend/src/utils/download.js new file mode 100644 index 0000000..36245e2 --- /dev/null +++ b/backend/src/utils/download.js @@ -0,0 +1,20 @@ +const got = require('got'); +const fs = require('fs'); +const pipeline = require('stream').pipeline; +const { promisify } = require('util'); +const streamPipeline = promisify(pipeline); + + +module.exports = async function downloadFile(url, destination) { + try { + await streamPipeline( + got.stream(url), + fs.createWriteStream(destination) + ); + return true; + } catch (error) { + console.error('download failed:', error); + return false; + } + +} \ No newline at end of file diff --git a/backend/src/utils/fs.js b/backend/src/utils/fs.js index c5552bc..bc2e8e8 100644 --- a/backend/src/utils/fs.js +++ b/backend/src/utils/fs.js @@ -1,4 +1,5 @@ const fs = require('fs'); +const crypto = require('crypto'); function asyncReadFile(filePath) { return new Promise((resolve, reject) => { @@ -60,9 +61,64 @@ function asyncUnlinkFile(filePath) { }); } +const fsPromise = fs.promises; +async function asyncMoveFile(oldPath, newPath) { + await fsPromise.copyFile(oldPath, newPath) + await fsPromise.unlink(oldPath); +} + +function asyncReadDir(dirPath) { + return new Promise((resolve, reject) => { + fs.readdir(dirPath, (err, files) => { + if (err) { + reject(err); + return; + } + resolve(files); + } + )}); +} + +async function asyncMd5(filePath) { + return new Promise((resolve, reject) => { + const hash = crypto.createHash('md5'); + const stream = fs.createReadStream(filePath); + + stream.on('data', (data) => { + hash.update(data); + }); + + stream.on('end', () => { + resolve(hash.digest('hex')); + }); + + stream.on('error', (error) => { + reject(error); + }); + }); +} + +async function asyncAppendFile(filePath, str) { + return new Promise((resolve, reject) => { + fs.appendFile(filePath, str, (err) => { + if (err) { + reject(err); + return; + } + resolve(); + }); + }); +} + + module.exports = { asyncReadFile, asyncWriteFile, asyncFileExisted, asyncMkdir, + asyncUnlinkFile, + asyncMoveFile, + asyncReadDir, + asyncMd5, + asyncAppendFile, }; \ No newline at end of file diff --git a/backend/src/utils/network.js b/backend/src/utils/network.js index 54c1385..481fad6 100644 --- a/backend/src/utils/network.js +++ b/backend/src/utils/network.js @@ -3,13 +3,19 @@ const https = require('https'); function asyncHttpsGet(url) { return new Promise((resolve) => { https.get(url, res => { - res.on('data', data => { + let data = ''; + + res.on('data', chunk => { + data += chunk; + }); + + res.on('end', () => { resolve(data.toString()); - }) - res.on('error', err => { - l(err); - resolve(null); - }) + }); + + }).on('error', err => { + console.error(err); + resolve(null); }); }); } diff --git a/frontend/src/App.vue b/frontend/src/App.vue index c0499e8..77ec23a 100644 --- a/frontend/src/App.vue +++ b/frontend/src/App.vue @@ -57,18 +57,20 @@ - - - 搜索 - - - 我的歌单 - - - 我的音乐账号 - - - 设置 + + + @@ -85,43 +87,40 @@ -
- - - - - - - {{ playerSongInfo.songName }} - - - {{ playerSongInfo.artist }} - +
+ + + +
+ +
+
+
{{ playerSongInfo.songName }}
+
{{ playerSongInfo.artist }}
+
- + + + + class="audio-player" + /> - + + + - - - + + + - - - + +
@@ -171,6 +166,7 @@ import { getPlayUrl, getSongsMeta, createSyncSongFromUrlJob } from "./api"; import { startTaskListener } from "./components/TaskNotification"; import storage from "./utils/storage"; +import { getProperPlayUrl } from "./utils/audio"; export default { data: () => { @@ -184,14 +180,22 @@ export default { suggestMatchSongId: "", }, wyAccount: null, + navItems: [ + { label: "搜索", path: "/", icon: "bi bi-search" }, + { label: "我的歌单", path: "/playlist", icon: "bi bi-music-note-list" }, + { label: "我的音乐账号", path: "/account", icon: "bi bi-person" }, + { label: "设置", path: "/setting", icon: "bi bi-gear" }, + ], + currentPath: "/", }; }, mounted() { this.wyAccount = storage.get("wyAccount"); }, watch: { - $route(to, from) { + $route(to) { this.wyAccount = storage.get("wyAccount"); + this.currentPath = to.path; }, }, methods: { @@ -229,7 +233,12 @@ export default { const resourceForbidden = info.resourceForbidden; const songUrl = info.audios[0].url; console.log("play: ", songUrl); - this.playerSongInfo.playUrl = songUrl; + this.playerSongInfo.playUrl = getProperPlayUrl( + info.source, + songUrl, + pageUrl || info.pageUrl + ); + this.playerSongInfo.coverUrl = info.coverUrl; this.playerSongInfo.songName = info.songName; this.playerSongInfo.artist = info.artist; @@ -239,13 +248,16 @@ export default { async playTheSongWithPlayUrl(playOption) { if (!playOption.playUrl) { const playUrlRet = await getPlayUrl(playOption.songId); - if (!playUrlRet.data.playUrl) { - return false; + if (playUrlRet.data.playUrl) { + playOption.playUrl = playUrlRet.data.playUrl; } - playOption.playUrl = playUrlRet.data.playUrl; } - this.playerSongInfo.playUrl = playOption.playUrl; + this.playerSongInfo.playUrl = getProperPlayUrl( + playOption.source, + playOption.playUrl, + playOption.pageUrl + ); this.playerSongInfo.coverUrl = playOption.coverUrl; this.playerSongInfo.songName = playOption.songName; this.playerSongInfo.artist = playOption.artist; @@ -255,6 +267,9 @@ export default { abortTheSong() { this.playerSongInfo.playUrl = ""; }, + navigate(path) { + this.$router.push(path); + }, }, }; @@ -302,4 +317,135 @@ export default { animation: octocat-wave 560ms ease-in-out; } } + + diff --git a/frontend/src/Mobile.vue b/frontend/src/Mobile.vue index 4d56b26..3f60c58 100644 --- a/frontend/src/Mobile.vue +++ b/frontend/src/Mobile.vue @@ -108,6 +108,7 @@ import { startTaskListener } from "./components/TaskNotification"; import Player from "./components/Player.vue"; import storage from "./utils/storage"; import { Notify } from "vant"; +import { getProperPlayUrl } from "./utils/audio"; export default { setup() { @@ -156,12 +157,18 @@ export default { console.log(ret); } - const resourceForbidden = info.resourceForbidden; const songUrl = info.audios[0].url; console.log("play: ", songUrl); + // 处理播放 URL + const processedPlayUrl = getProperPlayUrl( + info.source, + songUrl, + pageUrl || info.pageUrl + ); + this.songInfos.push({ - playUrl: songUrl, + playUrl: processedPlayUrl, coverUrl: info.coverUrl, songName: info.songName, artist: info.artist, diff --git a/frontend/src/api/axios.js b/frontend/src/api/axios.js index cceebb7..bf25b98 100644 --- a/frontend/src/api/axios.js +++ b/frontend/src/api/axios.js @@ -8,7 +8,7 @@ axiosApiInstance.defaults.baseURL = import.meta.env.VITE_APP_API_URL //允许跨域携带cookie信息 axiosApiInstance.defaults.withCredentials = true; //设置超时 -axiosApiInstance.defaults.timeout = 10000; +axiosApiInstance.defaults.timeout = 12000; axiosApiInstance.interceptors.request.use( config => { @@ -24,45 +24,51 @@ axiosApiInstance.interceptors.request.use( axiosApiInstance.interceptors.response.use( response => { - if (response.status >= 200 && response.status < 300) { - return Promise.resolve(response); - } else { - return Promise.reject(response); - } + return Promise.resolve(response); }, error => { - console.log('error in response', error); - Promise.reject(error); + // 返回错误响应中的数据 + if (error.response && error.response.data) { + return Promise.resolve(error.response); + } + // 如果没有response.data,返回一个统一的错误格式 + return Promise.resolve({ + data: { + code: -1, + message: error.message || '网络错误' + } + }); } ); - export const post = (url, data) => { - return new Promise((resolve, reject) => { - axiosApiInstance({ - method: 'post', - url, - data, - }) - .then(res => { - resolve(res ? res.data : false) - }) - .catch(err => { - reject(err) - }); - }) - }; - export const get = (url, data) => { - return new Promise((resolve, reject) => { - axiosApiInstance({ - method: 'get', - url, - params: data, - }) - .then(res => { - resolve(res ? res.data : false) - }) - .catch(err => { - reject(err) - }) - }) - } \ No newline at end of file +export const post = (url, data) => { + return new Promise((resolve, reject) => { + axiosApiInstance({ + method: 'post', + url, + data, + }) + .then(res => { + resolve(res ? res.data : false) + }) + .catch(err => { + reject(err.data) + }); + }) +}; + +export const get = (url, data) => { + return new Promise((resolve, reject) => { + axiosApiInstance({ + method: 'get', + url, + params: data, + }) + .then(res => { + resolve(res ? res.data : false) + }) + .catch(err => { + reject(err) + }) + }) +} \ No newline at end of file diff --git a/frontend/src/api/index.js b/frontend/src/api/index.js index d9b746f..2d16d41 100644 --- a/frontend/src/api/index.js +++ b/frontend/src/api/index.js @@ -23,9 +23,30 @@ export const createSyncSongFromUrlJob = (url, songId = "") => { } }); }; -export const createSyncSongFromPlaylistJob = (playlistId) => { +export const createDownloadSongFromUrlJob = (url, songId = "") => { + return post("/sync-jobs", { + "jobType": "DownloadSongFromUrl", + "urlJob": { + "url": url, + "meta": { + "songId": songId + } + } + }); +}; +export const createSyncSongFromPlaylistJob = (playlistId, options) => { return post("/sync-jobs", { "jobType": "UnblockedPlaylist", + "playlist": { + "id": playlistId, + "source": "netease" + }, + "options": options + }); +}; +export const createSyncThePlaylistToLocalServiceJob = (playlistId) => { + return post("/sync-jobs", { + "jobType": "SyncThePlaylistToLocalService", "playlist": { "id": playlistId, "source": "netease" @@ -50,4 +71,8 @@ export const updateMediaFetcherLib = (version) => { export const getGlobalConfig = _ => get("/config/global", {}); export const setGlobalConfig = (config) => { return post("/config/global", config); -}; \ No newline at end of file +}; + +export const getAllAccounts = _ => get("/accounts", {}); + +export const getNextRunInfo = () => get("/scheduler/next-run", {}); diff --git a/frontend/src/components/Player.vue b/frontend/src/components/Player.vue index 90e917e..c06d018 100644 --- a/frontend/src/components/Player.vue +++ b/frontend/src/components/Player.vue @@ -87,7 +87,7 @@ import { nextTick, ref } from "vue"; import * as Howler from "howler"; import { secondDurationToDisplayDuration, sleep } from "../utils"; -import { createSyncSongFromUrlJob } from "../api"; +import { createSyncSongFromUrlJob, createDownloadSongFromUrlJob } from "../api"; import { startTaskListener } from "./TaskNotificationForMobile"; let playerCtl; @@ -96,6 +96,7 @@ let currentPlayId; const ActionUpload = 0; const ActionDownload = 1; const ActionOpenRef = 2; +const ActionDownloadToLocalService = 3; export default { data() { @@ -213,11 +214,29 @@ export default { startTaskListener(ret.data.jobId); } }, + async downloadToLocalService() { + if (!this.currentSong) { + return; + } + console.log(this.currentSong); + const ret = await createDownloadSongFromUrlJob( + this.currentSong.pageUrl, + this.currentSong.suggestMatchSongId ?? 0 + ); + console.log(ret); + + if (ret.data && ret.data.jobId) { + startTaskListener(ret.data.jobId); + } + }, async onSelect(actionItem) { switch (actionItem.action) { case ActionUpload: this.uploadToCloud(); break; + case ActionDownloadToLocalService: + this.downloadToLocalService(); + break; case ActionDownload: const a = document.createElement("a"); a.target = "_blank"; @@ -244,7 +263,8 @@ export default { const showPopover = ref(false); const actions = [ { text: "上传到云盘", icon: "upgrade", action: ActionUpload }, - { text: "下载到本地", icon: "down", action: ActionDownload }, + { text: "下载到浏览器本地", icon: "down", action: ActionDownload }, + { text: "下载到服务器", icon: "down", action: ActionDownloadToLocalService }, { text: "打开源站", icon: "share", action: ActionOpenRef }, ]; diff --git a/frontend/src/components/SearchResultListForMobile.vue b/frontend/src/components/SearchResultListForMobile.vue index d2f9426..016fc16 100644 --- a/frontend/src/components/SearchResultListForMobile.vue +++ b/frontend/src/components/SearchResultListForMobile.vue @@ -51,11 +51,17 @@ songIndex: i, }, { - text: '下载到本地', + text: '下载到浏览器本地', icon: 'down', action: ActionDownload, songIndex: i, }, + { + text: '下载到服务器本地', + icon: 'down', + action: ActionDownloadToLocalService, + songIndex: i, + }, { text: '打开源站', icon: 'share', @@ -83,7 +89,11 @@ diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 389e71f..2840196 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -40,7 +40,7 @@ router.beforeEach((to, from, next) => { if (!mk) { next("/account"); } - if ([PathPlaylist, PathSetting].includes(to.path) && !wyAccount) { + if ([PathPlaylist].includes(to.path) && !wyAccount) { next("/account"); return; } diff --git a/frontend/src/utils/audio.js b/frontend/src/utils/audio.js new file mode 100644 index 0000000..5e8624d --- /dev/null +++ b/frontend/src/utils/audio.js @@ -0,0 +1,22 @@ +/** + * Get the proper play URL based on source + * @param {string} source - The source platform (e.g., 'bilibili', 'netease') + * @param {string} url - The original audio URL + * @param {string} referer - The referer URL + * @returns {string} The processed play URL + */ +export function getProperPlayUrl(source, url, referer) { + console.log("--------getProperPlayUrl----------------"); + console.log(source); + console.log(url); + console.log(referer); + if (source === "bilibili") { + const params = new URLSearchParams({ + url: url, + source: 'bilibili', + referer: referer + }); + return `${import.meta.env.VITE_APP_API_URL}/proxy/audio?${params}`; + } + return url; +} \ No newline at end of file diff --git a/frontend/src/views/mobile/Home.vue b/frontend/src/views/mobile/Home.vue index 2cd64a1..4eff0e3 100644 --- a/frontend/src/views/mobile/Home.vue +++ b/frontend/src/views/mobile/Home.vue @@ -146,6 +146,8 @@ import { } from "../../utils"; import { startTaskListener } from "../../components/TaskNotification"; import storage from "../../utils/storage"; +import { getProperPlayUrl } from "../../utils/audio"; + export default { data: () => { return { diff --git a/frontend/src/views/mobile/Playlist.vue b/frontend/src/views/mobile/Playlist.vue index e414db8..67f63c3 100644 --- a/frontend/src/views/mobile/Playlist.vue +++ b/frontend/src/views/mobile/Playlist.vue @@ -237,6 +237,7 @@ import SearchResultList from "../../components/SearchResultListForMobile.vue"; import { startTaskListener } from "../../components/TaskNotificationForMobile"; import { Notify, Dialog } from "vant"; import { ref } from "vue"; +import { getProperPlayUrl } from "../../utils/audio"; export default { data: () => { @@ -285,6 +286,16 @@ export default { this.searchTheSong(playOption.pageUrl); return; } + + // 处理 playUrl + if (playOption.playUrl) { + playOption.playUrl = getProperPlayUrl( + playOption.source, + playOption.playUrl, + playOption.pageUrl + ); + } + if (await this.playTheSongWithPlayUrl(playOption)) { this.currentSongUrl = playOption.pageUrl; } @@ -301,7 +312,11 @@ export default { type: "primary", duration: 1000, }); - const ret = await createSyncSongFromPlaylistJob(playlistId); + const ret = await createSyncSongFromPlaylistJob(playlistId, { + // TODO 先 hard code,后面 mobile 端再做配置 + syncWySong: false, + syncNotWySong: true, + }); console.log(ret); if (ret.data && ret.data.jobId) { diff --git a/frontend/src/views/pc/Account.vue b/frontend/src/views/pc/Account.vue index b864f13..25ff868 100644 --- a/frontend/src/views/pc/Account.vue +++ b/frontend/src/views/pc/Account.vue @@ -1,188 +1,644 @@ + + diff --git a/frontend/src/views/pc/Home.vue b/frontend/src/views/pc/Home.vue index aa1d3c0..6dfa6af 100644 --- a/frontend/src/views/pc/Home.vue +++ b/frontend/src/views/pc/Home.vue @@ -1,188 +1,246 @@ + + diff --git a/frontend/src/views/pc/Playlist.vue b/frontend/src/views/pc/Playlist.vue index 58db576..65b660a 100644 --- a/frontend/src/views/pc/Playlist.vue +++ b/frontend/src/views/pc/Playlist.vue @@ -65,6 +65,32 @@ (实验性功能) + + + + 备份到网易云云盘 + + + + + + 同步到服务器本地 + + @@ -205,6 +256,7 @@ import { getPlaylistDetail, createSyncSongFromPlaylistJob, createSyncSongWithSongIdJob, + createSyncThePlaylistToLocalServiceJob, } from "../../api"; import { secondDurationToDisplayDuration, sourceCodeToName } from "../../utils"; import SearchResultTable from "../../components/SearchResultTable.vue"; @@ -273,7 +325,7 @@ export default { methods: { async unblockThePlaylist(playlistId) { ElMessageBox.confirm( - "【智能解锁全部】是一个实验性功能,会根据歌曲名和歌手尝试寻找最合适的来源,但也可能会有货不对版的情况,请谨慎使用。你也可以点击下边单首歌曲的搜索图标进入搜索页面,进行手动解锁", + "【智能解锁全部】是一个实验性功能,会根据歌曲名��歌手尝试寻找最合适的来源,但也可能会有货不对版的情况,请谨慎使用。你也可以点击下边单首歌曲的搜索图标进入搜索页面,进行手动解锁", "Warning", { confirmButtonText: "解锁全部", @@ -287,7 +339,10 @@ export default { type: "info", duration: 1000, }); - const ret = await createSyncSongFromPlaylistJob(playlistId); + const ret = await createSyncSongFromPlaylistJob(playlistId, { + syncWySong: false, + syncNotWySong: true, + }); console.log(ret); if (ret.data && ret.data.jobId) { @@ -295,6 +350,30 @@ export default { } }); }, + async unblockThePlaylistForWySong(playlistId) { + ElMessage({ + message: "开始将歌单中的歌曲备份到网易云云盘", + type: "info", + duration: 1000, + }); + const ret = await createSyncSongFromPlaylistJob(playlistId, { + syncWySong: true, + syncNotWySong: false, + }); + console.log(ret); + + if (ret.data && ret.data.jobId) { + startTaskListener(ret.data.jobId); + } + }, + async syncThePlaylistToLocalService(playlistId) { + const ret = await createSyncThePlaylistToLocalServiceJob(playlistId); + console.log(ret); + + if (ret.data && ret.data.jobId) { + startTaskListener(ret.data.jobId); + } + }, async unblockTheSong(songId) { const ret = await createSyncSongWithSongIdJob(songId); console.log(ret); diff --git a/frontend/src/views/pc/Setting.vue b/frontend/src/views/pc/Setting.vue index b21736e..1775981 100644 --- a/frontend/src/views/pc/Setting.vue +++ b/frontend/src/views/pc/Setting.vue @@ -1,78 +1,369 @@ + + diff --git a/scripts/setup.js b/scripts/setup.js index 8df9890..10c9595 100644 --- a/scripts/setup.js +++ b/scripts/setup.js @@ -32,7 +32,7 @@ const runCmd = (cmd, shouldOutput = true, cwd = null) => { const runCmdAndExitWhenFailed = async (cmd, msg, shouldOutput = true, cwd = null) => { const ret = await runCmd(cmd, shouldOutput, cwd); if (ret.code !== 0) { - l(msg); + l(msg, 'should exit'); process.exit(1); } return ret;