├── .dockerignore ├── .github └── ISSUE_TEMPLATE │ ├── ----------.md │ └── -------bug---.md ├── .gitignore ├── Dockerfile ├── LICENSE ├── README.md ├── backend ├── .nvmrc ├── accounts.sample.json ├── bin │ └── .gitkeep ├── package.json ├── pnpm-lock.yaml └── src │ ├── consts │ ├── business_code.js │ ├── job_status.js │ ├── job_type.js │ ├── sound_quality.js │ └── source.js │ ├── errors │ └── account_not_existed.js │ ├── handler │ ├── account.js │ ├── config.js │ ├── media_fetcher_lib.js │ ├── playlists.js │ ├── proxy.js │ ├── scheduler.js │ ├── song_meta.js │ ├── songs.js │ └── sync_jobs.js │ ├── index.js │ ├── init_app.js │ ├── middleware │ ├── auth.js │ └── handle_error.js │ ├── router.js │ ├── service │ ├── account.js │ ├── config_manager │ │ └── index.js │ ├── cronjob │ │ └── index.js │ ├── job_manager │ │ └── index.js │ ├── kv │ │ └── index.js │ ├── media_fetcher │ │ ├── index.js │ │ └── media_get.js │ ├── music_platform │ │ └── wycloud │ │ │ ├── index.js │ │ │ └── transport.js │ ├── remote_config │ │ └── index.js │ ├── scheduler │ │ └── index.js │ ├── search_songs │ │ ├── find_the_best_match_from_wycloud.js │ │ ├── index.js │ │ └── search_songs_with_song_meta.js │ ├── songs_info │ │ └── index.js │ └── sync_music │ │ ├── download_to_local.js │ │ ├── index.js │ │ ├── sync_playlist.js │ │ ├── sync_single_song_with_url.js │ │ ├── unblock_music_in_playlist.js │ │ ├── unblock_music_with_song_id.js │ │ └── upload_to_wycloud_disk_with_retry_then_match.js │ └── utils │ ├── cmd.js │ ├── download.js │ ├── fs.js │ ├── network.js │ ├── regex.js │ ├── simple_locker.js │ ├── sleep.js │ └── uuid.js ├── frontend ├── .env.development ├── .env.production ├── .nvmrc ├── index.html ├── mobile.html ├── package.json ├── pnpm-lock.yaml ├── public │ ├── favicon.ico │ ├── github-logo.png │ ├── melody-192x192.png │ ├── melody-512x512.png │ └── melody.png ├── src │ ├── App.vue │ ├── Mobile.vue │ ├── api │ │ ├── axios.js │ │ └── index.js │ ├── assets │ │ └── logo.png │ ├── components │ │ ├── Player.vue │ │ ├── SearchResultListForMobile.vue │ │ ├── SearchResultTable.vue │ │ ├── TaskNotification.js │ │ └── TaskNotificationForMobile.js │ ├── main.js │ ├── mobile.js │ ├── router │ │ ├── index.js │ │ └── mobile.js │ ├── utils │ │ ├── audio.js │ │ ├── index.js │ │ ├── pwa.js │ │ └── storage.js │ └── views │ │ ├── mobile │ │ ├── Account.vue │ │ ├── Home.vue │ │ └── Playlist.vue │ │ └── pc │ │ ├── Account.vue │ │ ├── Home.vue │ │ ├── Playlist.vue │ │ └── Setting.vue └── vite.config.js ├── imgs ├── 1-home-search-keyword.png ├── 2-home-search-url.png ├── 3-playlist.png ├── 4-playlist-search.png ├── billing.jpeg ├── melody.png ├── mobile-1.png ├── mobile-2.png ├── mobile-3.png └── mobile-4.png ├── package.json └── scripts ├── setup-for-build-docker.js └── setup.js /.dockerignore: -------------------------------------------------------------------------------- 1 | npm-debug.log 2 | Dockerfile* 3 | docker-compose* 4 | .dockerignore 5 | .git 6 | .github 7 | .gitignore 8 | README.md 9 | LICENSE 10 | dist 11 | imgs 12 | 13 | backend/.vscode 14 | backend/.data 15 | backend/.profile/ 16 | backend/foamzou 17 | backend/node_modules 18 | backend/bin/* 19 | 20 | frontend/node_modules 21 | frontend/.vscode 22 | 23 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/----------.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 我想提需求 / 功能 3 | about: 最好是适用于大家的需求 4 | title: "[Feature Request]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **功能描述** 11 | 尽可能简单扼要地描述清楚需求 12 | 13 | ** 为什么需要该功能 ** 14 | 尽可能介绍清楚上下文,在什么场景下需要该功能 15 | 16 | **你能想到的解决方案** 17 | 为实现该功能,你有什么建议吗 18 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/-------bug---.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: 使用故障 / bug 报告 3 | about: 使用过程中出现问题,或者发现 bug , 请使用该模板 4 | title: "[BUG]" 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | ## 请至少提供以下信息,帮助排查问题 11 | 1. 后端日志 12 | 2. F12 前端网络请求报文 13 | 3. F12 console 信息 14 | 15 | ## 复现步骤 16 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # repo file 2 | /backend/.profile 3 | /backend/.data 4 | /backend/bin/media-get* 5 | /backend/public 6 | 7 | .vscode 8 | 9 | # Logs 10 | logs 11 | *.log 12 | npm-debug.log* 13 | yarn-debug.log* 14 | yarn-error.log* 15 | lerna-debug.log* 16 | 17 | # Diagnostic reports (https://nodejs.org/api/report.html) 18 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 19 | 20 | # Runtime data 21 | pids 22 | *.pid 23 | *.seed 24 | *.pid.lock 25 | 26 | # Directory for instrumented libs generated by jscoverage/JSCover 27 | lib-cov 28 | 29 | # Coverage directory used by tools like istanbul 30 | coverage 31 | *.lcov 32 | 33 | # nyc test coverage 34 | .nyc_output 35 | 36 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 37 | .grunt 38 | 39 | # Bower dependency directory (https://bower.io/) 40 | bower_components 41 | 42 | # node-waf configuration 43 | .lock-wscript 44 | 45 | # Compiled binary addons (https://nodejs.org/api/addons.html) 46 | build/Release 47 | 48 | # Dependency directories 49 | node_modules/ 50 | jspm_packages/ 51 | 52 | # TypeScript v1 declaration files 53 | typings/ 54 | 55 | # TypeScript cache 56 | *.tsbuildinfo 57 | 58 | # Optional npm cache directory 59 | .npm 60 | 61 | # Optional eslint cache 62 | .eslintcache 63 | 64 | # Microbundle cache 65 | .rpt2_cache/ 66 | .rts2_cache_cjs/ 67 | .rts2_cache_es/ 68 | .rts2_cache_umd/ 69 | 70 | # Optional REPL history 71 | .node_repl_history 72 | 73 | # Output of 'npm pack' 74 | *.tgz 75 | 76 | # Yarn Integrity file 77 | .yarn-integrity 78 | 79 | # dotenv environment variables file 80 | .env 81 | .env.test 82 | 83 | # parcel-bundler cache (https://parceljs.org/) 84 | .cache 85 | 86 | # Next.js build output 87 | .next 88 | 89 | # Nuxt.js build / generate output 90 | .nuxt 91 | dist 92 | 93 | # Gatsby files 94 | .cache/ 95 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 96 | # https://nextjs.org/blog/next-9-1#public-directory-support 97 | # public 98 | 99 | # vuepress build output 100 | .vuepress/dist 101 | 102 | # Serverless directories 103 | .serverless/ 104 | 105 | # FuseBox cache 106 | .fusebox/ 107 | 108 | # DynamoDB Local files 109 | .dynamodb/ 110 | 111 | # TernJS port file 112 | .tern-port 113 | 114 | 115 | # Logs 116 | logs 117 | *.log 118 | npm-debug.log* 119 | yarn-debug.log* 120 | yarn-error.log* 121 | pnpm-debug.log* 122 | lerna-debug.log* 123 | 124 | node_modules 125 | dist 126 | dist-ssr 127 | *.local 128 | 129 | # Editor directories and files 130 | .vscode/* 131 | !.vscode/extensions.json 132 | .idea 133 | .DS_Store 134 | *.suo 135 | *.ntvs* 136 | *.njsproj 137 | *.sln 138 | *.sw? 139 | 140 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # stage: build frontend 2 | FROM surnet/alpine-node-opencv:16.13.0-4.5.1 AS FRONTEND_BUILD 3 | 4 | WORKDIR /app 5 | RUN apk add --no-cache git && \ 6 | npm install -g pnpm@7.8.0 7 | 8 | ENV NODE_OPTIONS="--max-old-space-size=4096" 9 | 10 | COPY frontend frontend/ 11 | WORKDIR /app/frontend 12 | RUN pnpm install --verbose && \ 13 | pnpm run build 14 | 15 | # stage: build backend 16 | FROM surnet/alpine-node-opencv:16.13.0-4.5.1 AS BACKEND_BUILD 17 | 18 | WORKDIR /app 19 | RUN apk add --no-cache git && \ 20 | npm install -g pnpm@7.8.0 21 | 22 | ENV CROSS_COMPILING=1 23 | ENV NODE_OPTIONS="--max-old-space-size=4096" 24 | 25 | COPY backend backend/ 26 | COPY scripts scripts/ 27 | COPY package.json ./ 28 | 29 | WORKDIR /app/backend 30 | RUN pnpm install --production --verbose 31 | 32 | # Download media-get 33 | RUN mkdir -p /app/backend/bin && \ 34 | node /app/scripts/setup-for-build-docker.js 35 | 36 | # stage: final 37 | FROM iamccc/alpine-node:16.20 38 | 39 | WORKDIR /app 40 | 41 | # Install FFmpeg 42 | COPY --from=pldin601/static-ffmpeg:22.04.061404-87ac0d7 /ffmpeg /ffprobe /usr/local/bin/ 43 | RUN chmod +x /usr/local/bin/ffmpeg /usr/local/bin/ffprobe 44 | 45 | ENV PATH="/usr/local/bin:${PATH}" 46 | ENV NODE_ENV=production 47 | 48 | # Copy backend with media-get 49 | COPY --from=BACKEND_BUILD /app/backend ./backend 50 | COPY --from=FRONTEND_BUILD /app/frontend/dist ./backend/public 51 | 52 | # Ensure media-get is executable 53 | RUN chmod +x /app/backend/bin/media-get 54 | 55 | EXPOSE 5566 56 | 57 | CMD ["node", "backend/src/index.js"] -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Melody 2 | 3 | ## 项目介绍 4 | 5 | 6 | 7 | 大家好,我叫 Melody,你的音乐精灵,旨在帮助你更好地管理音乐。目前的主要能力是帮助你将喜欢的歌曲或者音频上传到音乐平台的云盘。 8 | 9 | ## 免责声明 10 | - 本项目所搜索的歌曲均来源于音乐平台的免费公开资源。请确保在使用本项目时遵守相关音乐平台的服务条款和版权规定。 11 | - 本项目仅供技术学习和交流使用,使用者仅限于个人学习,不得用于任何商业目的。使用者应自行承担因使用本项目而可能产生的法律责任。 12 | 13 | 为了避免不必要的纠纷和账号安全问题,本项目不会以任何形式提供在线 demo 服务 14 | 15 | 16 | ## Feature 17 | 18 | - 支持在各大音乐和视频网站检索歌曲。目前支持 咪咕、网易云、QQ 音乐、酷狗、bilibili、抖音等站点。详情可以在我的 [media-get](https://github.com/foamzou/media-get#%E6%94%AF%E6%8C%81%E7%9A%84%E7%BD%91%E7%AB%99) 项目中查看 19 | - 支持一键下载到本地,一键上传到云盘 20 | - 支持定时上传网易云歌单歌曲到网易云云盘 21 | - 支持定时同步网易云歌单歌曲到本地 22 | - 用链接搜索歌曲(例如使用 b站或抖音的视频链接进行搜索,可以将对应的音频自动上传到音乐云盘) 23 | - 一键“解锁”无法播放的歌曲(一键检测变灰的歌曲,自动从公共资源搜索最佳资源,自动上传到云盘,自动匹配歌曲信息。代替繁琐的人工操作,实现可播放)(实验性功能,目前仅支持网易云) 24 | - PC 端、移动端适配良好(支持 PWA) 25 | - 部署简单,支持 docker 26 | 27 | ## 安装和启动 28 | 29 | ### 方式一:Docker 安装 30 | 31 | 1. 在你的宿主机创建一个目录,例如: `~/melody-profile` 32 | 2. 创建镜像,有两种方式选择(注意修改下面的宿主机目录为你实际的): 33 | - 从 hub.docker.com 拉取 34 | ``` 35 | docker run -d -p 5566:5566 -v ~/melody-profile:/app/backend/.profile -v /你宿主机的路径:/app/melody-data foamzou/melody:latest 36 | ``` 37 | - 从代码编译镜像(若你的 docker 不支持 DOCKER_BUILDKIT,则去掉) 38 | ``` 39 | DOCKER_BUILDKIT=1 docker build -t melody . 40 | docker run -d -p 5566:5566 -v ~/melody-profile:/app/backend/.profile -v /你宿主机的路径:/app/melody-data melody 41 | ``` 42 | 3. 后续更新(以从 hub.docker.com 更新为例) 43 | ``` 44 | docker pull docker.io/foamzou/melody:latest 45 | docker kill 46 | docker run -d -p 5566:5566 -v ~/melody-profile:/app/backend/.profile -v /你宿主机的路径:/app/melody-data foamzou/melody:latest 47 | ``` 48 | 49 | ### 方式二:源码安装 50 | 51 | 1. 依赖 52 | 53 | 确保以下两个依赖是安装好的 54 | 55 | 1. node.js ([官网下载](https://nodejs.org/zh-cn/download/)) 56 | 2. FFmpeg ([windows 安装介绍](https://zhuanlan.zhihu.com/p/400952501)) 57 | 58 | 2. 下载源码、初始化服务、运行服务 59 | 60 | ``` 61 | git clone https://github.com/foamzou/melody.git 62 | cd melody && npm run init && npm run app 63 | ``` 64 | 65 | 3. 若后面代码有更新,下次执行该命令即可更新 66 | ``` 67 | npm run update && npm run app 68 | ``` 69 | 70 | ### 方式三:通过第三方部署 71 | 72 | 通过宝塔面板一键部署 73 | 74 | #### 前提 75 | 76 | * 仅适用于宝塔面板 9.2.0 及以上版本 77 | * 安装宝塔面板,前往[宝塔面板](https://www.bt.cn/new/download.html)官网,选择正式版的脚本下载安装 78 | 79 | #### 部署 80 | 81 | 1. 登录宝塔面板,在左侧菜单栏中点击 `Docker` 82 | 2. 首次会提示安装`Docker`和`Docker Compose`服务,点击立即安装,若已安装请忽略。 83 | 3. 安装完成后在`Docker-应用商店-实用工具`中找到 `Melody`,点击`安装`,也可以在搜索框直接搜索`melody`。 84 | 4. 设置域名等基本信息,点击`确定` 85 | * 说明: 86 | * 名称:应用名称,默认`melody_随机字符` 87 | * 版本选择:默认`latest` 88 | * 域名:如您需要通过域名访问,请在此处填写您的域名 89 | * 允许外部访问:如您需通过`IP+Port`直接访问,请勾选,如您已经设置了域名,请不要勾选此处 90 | * 端口:默认`5568`,可自行修改 91 | * CPU 限制:0 为不限制,根据实际需要设置 92 | * 内存限制:0 为不限制,根据实际需要设置 93 | 5. 提交后面板会自动进行应用初始化,大概需要`1-3`分钟,初始化完成后即可访问。 94 | 95 | 96 | ### 配置你的账号(可选) 97 | 98 | 默认的 melody key 为: `melody`,若你的服务部署在私有网络,则可以不用修改(网易云账号、密码可以在 web 页面设置)。 99 | 100 | 若需要修改或添加新账号,则编辑 `backend/.profile/accounts.json` (安装方式为 docker 的则为:`你的宿主机目录/accounts.json` ) 。 101 | 102 | 1. 该 JSON 中的 key 是 `Melody Key`,是你在网页访问该服务的唯一凭证 103 | 2. 网易云账号信息: `account` 和 `password` 可以后续在网页修改 104 | 3. 该 JSON 是个数组,支持配置多个账号 105 | 106 | Q: 更新了 accounts.json 如何生效? 107 | 108 | A: 两种方式。1: 重启服务。2: 网页端 `我的音乐账号` tab 下,随便修改点账号,密码,然后点击 `更新账号密码`,这样会从 accounts.json 更新信息到内存(我后面优化下这块) 109 | 110 | ### 浏览器访问 111 | 112 | 最后,在浏览器访问 http://127.0.0.1:5566 就可以使用啦~ 113 | 114 | ## 功能介绍 115 | 116 | ### 关键词搜索歌曲 117 | 118 | 如果试听后是你想要的,点击上传按钮会将该歌曲上传到你的网易云音乐云盘 119 | 120 | 121 | ### 链接搜索 122 | 123 | 有时候我们在 b 站 听到好听的歌,也可以上传到云盘 124 | 125 | 126 | ### 一键解锁歌单 127 | 128 | 点击 `解锁全部`(实验性功能) 后,服务会自动匹配每首歌,并把歌曲上传到云盘,最后做个 match,以保证你还能看到歌词、评论 129 | 130 | 131 | ### 手动搜索匹配 132 | 133 | 当某首歌自动解锁失败后,还可以手动点击搜索按钮,找到符合的歌曲后,手动点击上传按钮 134 | 135 | 136 | ### 移动端适配 137 | 138 | 139 | 140 | 141 | 142 | 143 | 144 | 145 | 146 | ## Roadmap 147 | 148 | 计划在后面支持以下功能 149 | 150 | - [x] 页面适配移动端 151 | - [ ] 浏览器油猴脚本 152 | - [ ] 云盘歌曲 match 手动纠错 153 | - [ ] 支持播放列表 154 | - [x] 支持播放云盘的歌曲 155 | - [x] 支持 docker 部署 156 | - [ ] 支持 youtube-dl,you-dl 等工具作为输入源 157 | - [ ] 支持 酷狗、qq 音乐等音乐平台的云盘作为输出 158 | - [ ] 偏好设置 159 | - [ ] 版本更新提示 160 | 161 | ## Q & A 162 | 1. Q:移动端版本,为什么点击下载歌曲,会跳新的页面? 163 | 164 | A:有的浏览器不支持嗅探的,会有这个问题。因为外部资源文件都不允许跨域,无法用常规下载方式 save as。考虑后续 hack 165 | 2. Q:移动端版本,为什么在数据网络无法播放歌曲? 166 | 167 | A:发现某些网络下,没有触发 `canplaythrough` 事件,wifi 环境下一般是没有问题的。 168 | 3. Q:为什么移动端 PWA,点击跳转到其他页面时,无法返回到原来页面? 169 | 170 | A:PWA 在移动端不支持使用外部浏览器打开外链,只能在应用内打开,因此会有各种奇怪问题。此时,只能先杀死应用。 171 | 172 | 4. Q:为什么我部署的服务,PWA 始终出不了? 173 | 174 | A:PWA 要求服务必须是 HTTPS。 175 | 176 | 5. Q: 为什么更新 media-get 组件后,搜索报错 177 | 178 | A: 目前存在 bug,更新完 media-get 组件之后,请务必重启 docker 容器或服务,否则将无法继续使用 179 | 180 | ## Change log 181 | 见 [Release](https://github.com/foamzou/melody/releases) 182 | 183 | ## 致谢 184 | 185 | - [NeteaseCloudMusicApi](https://github.com/Binaryify/NeteaseCloudMusicApi) 的网易云 API 186 | - [MakeGirlsMoe](https://make.girls.moe/) 生成的 Melody 虚拟形象图片 187 | - [Media Get](https://github.com/foamzou/media-get) 我的开源项目 188 | -------------------------------------------------------------------------------- /backend/.nvmrc: -------------------------------------------------------------------------------- 1 | v16.13.0 -------------------------------------------------------------------------------- /backend/accounts.sample.json: -------------------------------------------------------------------------------- 1 | { 2 | "Melody Key,建议随机生成 UUID": { 3 | "loginType": "固定为:phone,目前仅支持手机号+密码登录。下面为示例", 4 | "account": "填写手机号。如:18888888888", 5 | "password": "填写密码", 6 | "platform": "固定为:wy,目前仅支持网易云。" 7 | }, 8 | "melody": { 9 | "loginType": "phone", 10 | "account": "", 11 | "password": "", 12 | "platform": "wy" 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /backend/bin/.gitkeep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/backend/bin/.gitkeep -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "melody-backend", 3 | "version": "0.1.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "dev": "nodemon node src/index.js", 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "repository": { 10 | "type": "git", 11 | "url": "git+https://github.com/foamzou/personal-music-assistant.git" 12 | }, 13 | "author": "foamzou", 14 | "license": "ISC", 15 | "bugs": { 16 | "url": "https://github.com/foamzou/personal-music-assistant/issues" 17 | }, 18 | "homepage": "https://github.com/foamzou/personal-music-assistant#readme", 19 | "dependencies": { 20 | "NeteaseCloudMusicApi": "4.6.7", 21 | "body-parser": "^1.19.1", 22 | "consola": "^2.15.3", 23 | "cors": "^2.8.5", 24 | "express": "^4.17.2", 25 | "got": "11", 26 | "md5": "^2.3.0", 27 | "node-schedule": "^2.1.1", 28 | "uuid": "^8.3.2" 29 | }, 30 | "devDependencies": { 31 | "nodemon": "^2.0.15" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/src/consts/business_code.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | StatusJobAlreadyExisted: 40010, 3 | StatusJobNoNeedToCreate: 40011, 4 | StatusNoNeedToSync: 40012, 5 | } 6 | -------------------------------------------------------------------------------- /backend/src/consts/job_status.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | Pending: "待开始", 3 | InProgress: "进行中", 4 | Failed: "失败", 5 | Finished: "已完成", 6 | } -------------------------------------------------------------------------------- /backend/src/consts/job_type.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | UnblockedPlaylist: "UnblockedPlaylist", 3 | UnblockedSong: "UnblockedSong", 4 | SyncSongFromUrl: "SyncSongFromUrl", 5 | DownloadSongFromUrl: "DownloadSongFromUrl", 6 | SyncThePlaylistToLocalService: "SyncThePlaylistToLocalService", 7 | } -------------------------------------------------------------------------------- /backend/src/consts/sound_quality.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | High: "high", 3 | Lossless: "lossless", 4 | } -------------------------------------------------------------------------------- /backend/src/consts/source.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | consts: { 3 | Netease : { 4 | code: 'netease', 5 | label: '网易云', 6 | }, 7 | Bilibili : { 8 | code: 'bilibili', 9 | label: '哔哩哔哩', 10 | }, 11 | Douyin : { 12 | code: 'douyin', 13 | label: '抖音', 14 | }, 15 | Kugou : { 16 | code: 'kugou', 17 | label: '酷狗', 18 | }, 19 | Kuwo : { 20 | code: 'kuwo', 21 | label: '酷我', 22 | }, 23 | Migu : { 24 | code: 'migu', 25 | label: '咪咕', 26 | }, 27 | QQ : { 28 | code: 'qq', 29 | label: 'QQ', 30 | }, 31 | Youtube : { 32 | code: 'youtube', 33 | label: 'Youtube', 34 | }, 35 | Qmkg : { 36 | code: 'qmkg', 37 | label: '全民K歌', 38 | }, 39 | }, 40 | } -------------------------------------------------------------------------------- /backend/src/errors/account_not_existed.js: -------------------------------------------------------------------------------- 1 | module.exports = class AccountNotExisted extends Error { 2 | constructor(message) { 3 | super(message); 4 | this.name = 'AccountNotExisted'; 5 | } 6 | } -------------------------------------------------------------------------------- /backend/src/handler/account.js: -------------------------------------------------------------------------------- 1 | const AccountService = require('../service/account'); 2 | const WYAPI = require('../service/music_platform/wycloud'); 3 | const { storeCookie } = require('../service/music_platform/wycloud/transport.js'); 4 | 5 | async function get(req, res) { 6 | res.send({ 7 | status: 0, 8 | data: { 9 | account: await getWyAccountInfo(req.account.uid) 10 | } 11 | }); 12 | } 13 | 14 | async function set(req, res) { 15 | const loginType = req.body.loginType; 16 | const accountName = req.body.account; 17 | const password = req.body.password; 18 | const countryCode = req.body.countryCode; 19 | const config = req.body.config; 20 | const name = req.body.name; 21 | 22 | if (name) { 23 | // check if the name is already used by other accounts 24 | const allAccounts = await AccountService.getAllAccountsWithoutSensitiveInfo(); 25 | for (const account of Object.values(allAccounts)) { 26 | if (account.name === name && account.uid !== req.account.uid) { 27 | res.status(412).send({ status: 1, message: '昵称已被占用啦,请换一个试试吧', data: {} }); 28 | return; 29 | } 30 | } 31 | } 32 | 33 | const ret = await AccountService.setAccount(req.account.uid, loginType, accountName, password, countryCode, config, name); 34 | res.send({ 35 | status: ret ? 0 : 1, 36 | data: { 37 | account: await getWyAccountInfo(req.account.uid) 38 | } 39 | }); 40 | } 41 | 42 | async function getWyAccountInfo(uid) { 43 | const account = AccountService.getAccount(uid) 44 | const wyInfo = await WYAPI.getMyAccount(uid); 45 | account.wyAccount = wyInfo; 46 | return account; 47 | } 48 | 49 | async function qrLoginCreate(req, res) { 50 | const qrData = await WYAPI.qrLoginCreate(req.account.uid); 51 | if (qrData === false) { 52 | res.status(500).send({ 53 | status: 1, 54 | message: 'qr login create failed', 55 | data: {} 56 | }); 57 | return; 58 | } 59 | res.send({ 60 | status: 0, 61 | data: { 62 | qrKey: qrData.qrKey, 63 | qrCode: qrData.qrCode, 64 | } 65 | }); 66 | } 67 | async function qrLoginCheck(req, res) { 68 | // 800 为二维码过期; 801 为等待扫码; 802 为待确认; 803 为授权登录成功 69 | const loginCheckRet = await WYAPI.qrLoginCheck(req.account.uid, req.query.qrKey); 70 | let account = false; 71 | if (loginCheckRet.code == 803) { 72 | // it's a bad design to export the transport function here. Let's refactor it at a good time. 73 | // should be put the cookie method to a cookie manager service 74 | req.account.loginType = 'qrcode'; 75 | req.account.account = 'temp'; 76 | storeCookie(req.account.uid, req.account, loginCheckRet.cookie); 77 | 78 | account = await getWyAccountInfo(req.account.uid); 79 | req.account.account = account.wyAccount.userId; 80 | storeCookie(req.account.uid, req.account, loginCheckRet.cookie); 81 | 82 | AccountService.setAccount(req.account.uid, 'qrcode', account.wyAccount.userId, '', null); 83 | account = await getWyAccountInfo(req.account.uid); 84 | } 85 | res.send({ 86 | status: loginCheckRet ? 0 : 1, 87 | data: { 88 | wyQrStatus: loginCheckRet.code, 89 | account 90 | } 91 | }); 92 | } 93 | 94 | async function getAllAccounts(req, res) { 95 | const data = await AccountService.getAllAccountsWithoutSensitiveInfo(); 96 | res.send({ 97 | status: 0, 98 | data: data 99 | }); 100 | } 101 | 102 | module.exports = { 103 | get: get, 104 | set: set, 105 | qrLoginCreate, 106 | qrLoginCheck, 107 | getAllAccounts, 108 | } -------------------------------------------------------------------------------- /backend/src/handler/config.js: -------------------------------------------------------------------------------- 1 | const ConfigService = require('../service/config_manager'); 2 | 3 | async function getGlobalConfig(req, res) { 4 | const config = await ConfigService.getGlobalConfig(); 5 | res.send({ 6 | status: 0, 7 | data: config 8 | }); 9 | } 10 | 11 | async function setGlobalConfig(req, res) { 12 | const config = req.body; 13 | await ConfigService.setGlobalConfig(config); 14 | res.send({ 15 | status: 0, 16 | data: config 17 | }); 18 | } 19 | 20 | module.exports = { 21 | getGlobalConfig, 22 | setGlobalConfig, 23 | } -------------------------------------------------------------------------------- /backend/src/handler/media_fetcher_lib.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const { getMediaGetInfo, getLatestMediaGetVersion, downloadTheLatestMediaGet } = require('../service/media_fetcher/media_get'); 3 | 4 | async function checkLibVersion(req, res) { 5 | const query = req.query; 6 | 7 | if (!['mediaGet'].includes(query.lib)) { 8 | res.send({ 9 | status: 1, 10 | message: "lib name is invalid", 11 | }); 12 | return; 13 | } 14 | 15 | const latestVersion = await getLatestMediaGetVersion(); 16 | const mediaGetInfo = await getMediaGetInfo(); 17 | 18 | res.send({ 19 | status: 0, 20 | data: { 21 | mediaGetInfo, 22 | latestVersion, 23 | } 24 | }); 25 | } 26 | 27 | async function downloadTheLatestLib(req, res) { 28 | const {version} = req.body; 29 | 30 | const succeed = await downloadTheLatestMediaGet(version); 31 | 32 | res.send({ 33 | status: succeed ? 0 : 1, 34 | data: {} 35 | }); 36 | } 37 | 38 | module.exports = { 39 | checkLibVersion: checkLibVersion, 40 | downloadTheLatestLib: downloadTheLatestLib, 41 | } -------------------------------------------------------------------------------- /backend/src/handler/playlists.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const { getUserAllPlaylist, getSongsFromPlaylist } = require('../service/music_platform/wycloud'); 3 | const Source = require('../consts/source').consts; 4 | 5 | async function listAllPlaylists(req, res) { 6 | const uid = req.account.uid; 7 | const playlists = await getUserAllPlaylist(uid); 8 | if (playlists === false) { 9 | logger.error(`get user all playlist failed, uid: ${uid}`); 10 | } 11 | 12 | res.send({ 13 | status: playlists ? 0 : 1, 14 | data: { 15 | playlists, 16 | } 17 | }); 18 | } 19 | 20 | async function listSongsFromPlaylist(req, res) { 21 | const uid = req.account.uid; 22 | const source = req.params.source; 23 | const playlistId = req.params.id; 24 | 25 | if (source !== Source.Netease.code || !playlistId) { 26 | res.send({ 27 | status: 1, 28 | message: "source or id is invalid", 29 | }); 30 | return; 31 | } 32 | const playlists = await getSongsFromPlaylist(uid, source, playlistId); 33 | if (playlists === false) { 34 | logger.error(`get user all playlist failed, uid: ${uid}`); 35 | } 36 | 37 | res.send({ 38 | status: playlists ? 0 : 1, 39 | data: { 40 | playlists: playlists ? playlists : [], 41 | } 42 | }); 43 | } 44 | 45 | module.exports = { 46 | listAllPlaylists: listAllPlaylists, 47 | listSongsFromPlaylist: listSongsFromPlaylist, 48 | } -------------------------------------------------------------------------------- /backend/src/handler/proxy.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const got = require('got'); 3 | 4 | async function proxyAudio(req, res) { 5 | const url = req.query.url; 6 | const source = req.query.source; 7 | const referer = req.query.referer; 8 | 9 | if (!url || !source) { 10 | res.status(400).send({ 11 | status: 1, 12 | message: "url and source are required" 13 | }); 14 | return; 15 | } 16 | 17 | // 只允许 bilibili 源 18 | if (source !== 'bilibili') { 19 | res.status(403).send({ 20 | status: 1, 21 | message: "only bilibili source is allowed" 22 | }); 23 | return; 24 | } 25 | 26 | try { 27 | const stream = got.stream(url, { 28 | headers: { 29 | 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko)', 30 | 'Referer': referer || 'https://www.bilibili.com' 31 | } 32 | }); 33 | 34 | stream.pipe(res); 35 | } catch (err) { 36 | logger.error('proxy audio error:', err); 37 | res.status(500).send({ 38 | status: 1, 39 | message: "proxy failed" 40 | }); 41 | } 42 | } 43 | 44 | module.exports = { 45 | proxyAudio 46 | }; -------------------------------------------------------------------------------- /backend/src/handler/scheduler.js: -------------------------------------------------------------------------------- 1 | const schedulerService = require('../service/scheduler'); 2 | const AccountService = require('../service/account'); 3 | 4 | async function getNextRun(req, res) { 5 | const localNextRun = schedulerService.getLocalSyncNextRun(); 6 | const accounts = await AccountService.getAllAccounts(); 7 | 8 | const cloudNextRuns = {}; 9 | for (const uid in accounts) { 10 | const nextRun = schedulerService.getCloudSyncNextRun(uid); 11 | if (nextRun) { 12 | cloudNextRuns[uid] = nextRun; 13 | } 14 | } 15 | 16 | res.send({ 17 | status: 0, 18 | data: { 19 | localNextRun, 20 | cloudNextRuns 21 | } 22 | }); 23 | } 24 | 25 | module.exports = { 26 | getNextRun 27 | }; 28 | -------------------------------------------------------------------------------- /backend/src/handler/song_meta.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const { getMetaWithUrl } = require('../service/media_fetcher'); 3 | const { matchUrlFromStr } = require('../utils/regex'); 4 | 5 | async function getMeta(req, res) { 6 | const query = req.query; 7 | 8 | const url = matchUrlFromStr(query.url); 9 | 10 | if (!url) { 11 | res.send({ 12 | status: 1, 13 | message: "url is invalid", 14 | }); 15 | return; 16 | } 17 | 18 | const songMeta = await getMetaWithUrl(url); 19 | songMeta && (songMeta.pageUrl = url); 20 | 21 | res.send({ 22 | status: songMeta ? 0 : 1, 23 | data: { 24 | songMeta, 25 | } 26 | }); 27 | } 28 | 29 | module.exports = { 30 | getMeta: getMeta 31 | } -------------------------------------------------------------------------------- /backend/src/handler/songs.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const { searchSongsWithKeyword, searchSongsWithSongMeta } = require('../service/search_songs'); 3 | const { getPlayUrlWithOptions } = require('../service/songs_info'); 4 | const { getMetaWithUrl } = require('../service/media_fetcher'); 5 | const { matchUrlFromStr } = require('../utils/regex'); 6 | 7 | async function search(req, res) { 8 | const query = req.query; 9 | 10 | const keywordOrUrl = query.keyword; 11 | 12 | if (!keywordOrUrl) { 13 | res.send({ 14 | status: 1, 15 | message: "keyword is required", 16 | }); 17 | return; 18 | } 19 | let songs = []; 20 | const url = matchUrlFromStr(keywordOrUrl); 21 | if (!url) { 22 | songs = await searchSongsWithKeyword(keywordOrUrl); 23 | } else { 24 | const songMeta = await getMetaWithUrl(url); 25 | if (!songMeta) { 26 | res.send({ 27 | status: 2, 28 | message: "can not get song meta with this url", 29 | }); 30 | return; 31 | } 32 | songs = await searchSongsWithSongMeta({ 33 | songName: songMeta.songName, 34 | artist: songMeta.artist, 35 | album: songMeta.album, 36 | duration: songMeta.duration, 37 | }, { 38 | expectArtistAkas: [], 39 | allowSongsJustMatchDuration: true, 40 | allowSongsNotMatchMeta: true, 41 | }); 42 | } 43 | 44 | res.send({ 45 | status: 0, 46 | data: { 47 | songs: songs ? songs : [], 48 | } 49 | }); 50 | } 51 | 52 | async function getPlayUrl(req, res) { 53 | const source = req.params.source; 54 | const songId = req.params.id; 55 | 56 | if (!source || !songId) { 57 | res.send({ 58 | status: 1, 59 | message: "source and songId is required", 60 | }); 61 | return; 62 | } 63 | const playUrl = await getPlayUrlWithOptions(req.account.uid, source, songId); 64 | 65 | res.send({ 66 | status: 0, 67 | data: { 68 | playUrl, 69 | } 70 | }); 71 | } 72 | 73 | module.exports = { 74 | search: search, 75 | getPlayUrl: getPlayUrl 76 | } -------------------------------------------------------------------------------- /backend/src/handler/sync_jobs.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const { unblockMusicInPlaylist, unblockMusicWithSongId } = require('../service/sync_music'); 3 | const JobType = require('../consts/job_type'); 4 | const Source = require('../consts/source').consts; 5 | const { matchUrlFromStr } = require('../utils/regex'); 6 | const { syncSingleSongWithUrl, syncPlaylist } = require('../service/sync_music'); 7 | const findTheBestMatchFromWyCloud = require('../service/search_songs/find_the_best_match_from_wycloud'); 8 | const JobManager = require('../service/job_manager'); 9 | const JobStatus = require('../consts/job_status'); 10 | const BusinessCode = require('../consts/business_code'); 11 | 12 | 13 | async function createJob(req, res) { 14 | const uid = req.account.uid; 15 | const request = req.body; 16 | 17 | const jobType = request.jobType; 18 | const options = request.options; 19 | let jobId = 0; 20 | 21 | if (jobType === JobType.UnblockedPlaylist || jobType === JobType.SyncThePlaylistToLocalService) { 22 | const source = request.playlist && request.playlist.source; 23 | const playlistId = request.playlist && request.playlist.id; 24 | 25 | if (source !== Source.Netease.code || !playlistId) { 26 | res.status(412).send({ 27 | status: 1, 28 | message: "source or id is invalid", 29 | }); 30 | return; 31 | } 32 | if (jobType === JobType.UnblockedPlaylist) { 33 | jobId = await unblockMusicInPlaylist(uid, source, playlistId, { 34 | syncWySong: options.syncWySong, 35 | syncNotWySong: options.syncNotWySong, 36 | asyncExecute: true, 37 | }); 38 | } else { 39 | jobId = await syncPlaylist(uid, source, playlistId) 40 | } 41 | } else if (jobType === JobType.UnblockedSong) { 42 | const source = request.source; 43 | const songId = request.songId; 44 | 45 | if (source !== Source.Netease.code || !songId) { 46 | res.status(412).send({ 47 | status: 1, 48 | message: "source or id is invalid", 49 | }); 50 | return; 51 | } 52 | jobId = await unblockMusicWithSongId(uid, source, songId) 53 | } else if (jobType === JobType.SyncSongFromUrl || jobType === JobType.DownloadSongFromUrl) { 54 | const request = req.body; 55 | const url = request.urlJob && matchUrlFromStr(request.urlJob.url); 56 | 57 | if (!url) { 58 | res.status(412).send({ 59 | status: 1, 60 | message: "url is invalid", 61 | }); 62 | return; 63 | } 64 | 65 | let meta = {}; 66 | const songId = request.urlJob && request.urlJob.meta.songId ? request.urlJob.meta.songId : ""; 67 | 68 | if (request.urlJob.meta && (request.urlJob.meta.songName !== "" && request.urlJob.meta.artist !== "")) { 69 | meta = { 70 | songName: request.urlJob.meta.songName, 71 | artist: request.urlJob.meta.artist, 72 | album : request.urlJob.meta.album ? request.urlJob.meta.album : "", 73 | }; 74 | } 75 | 76 | if (songId) { 77 | const songFromWyCloud = await findTheBestMatchFromWyCloud(req.account.uid, { 78 | songName: meta.songName, 79 | artist: meta.artist, 80 | album: meta.album, 81 | musicPlatformSongId: songId, 82 | }); 83 | if (!songFromWyCloud) { 84 | logger.error(`song not found in wycloud`); 85 | res.status(412).send({ 86 | status: 1, 87 | message: "can not find song in wycloud with your songId", 88 | }); 89 | return; 90 | } 91 | meta.songFromWyCloud = songFromWyCloud; 92 | } 93 | 94 | // create job 95 | const args = `${jobType}: {"url":${url}}`; 96 | if (await JobManager.findActiveJobByArgs(uid, args)) { 97 | logger.info(`${jobType} job is already running.`); 98 | jobId = BusinessCode.StatusJobAlreadyExisted; 99 | } else { 100 | const operation = jobType === JobType.SyncSongFromUrl ? "上传" : "下载"; 101 | jobId = await JobManager.createJob(uid, { 102 | name: `${operation}歌曲:${meta.songName ? meta.songName : url}`, 103 | args, 104 | type: jobType, 105 | status: JobStatus.Pending, 106 | desc: `歌曲:${meta.songName ? meta.songName : url}`, 107 | progress: 0, 108 | tip: `等待${operation}`, 109 | createdAt: Date.now() 110 | }); 111 | 112 | // async job 113 | syncSingleSongWithUrl(req.account.uid, url, meta, jobId, jobType).then(async ret => { 114 | await JobManager.updateJob(uid, jobId, { 115 | status: ret === true ? JobStatus.Finished : JobStatus.Failed, 116 | progress: 1, 117 | tip: ret === true ? `${operation}成功` : `${operation}失败`, 118 | }); 119 | }) 120 | } 121 | } else { 122 | res.status(412).send({ 123 | status: 1, 124 | message: "jobType is not supported", 125 | }); 126 | return; 127 | } 128 | 129 | if (jobId === false) { 130 | logger.error(`create job failed, uid: ${uid}`); 131 | res.status(412).send({ 132 | status: 1, 133 | message: "create job failed", 134 | }); 135 | return; 136 | } 137 | 138 | if (jobId === BusinessCode.StatusJobAlreadyExisted) { 139 | res.status(412).send({ 140 | status: BusinessCode.StatusJobAlreadyExisted, 141 | message: "你的任务已经在跑啦,等等吧", 142 | }); 143 | return; 144 | } 145 | if (jobId === BusinessCode.StatusJobNoNeedToCreate) { 146 | res.status(412).send({ 147 | status: BusinessCode.StatusJobAlreadyExisted, 148 | message: "你的任务无需被创建,可能是因为没有需要 sync 的歌曲", 149 | }); 150 | return; 151 | } 152 | 153 | res.status(201).send({ 154 | status: jobId ? 0 : 1, 155 | data: { 156 | jobId, 157 | } 158 | }); 159 | } 160 | 161 | async function listAllJobs(req, res) { 162 | res.send({ 163 | status: 0, 164 | data: { 165 | jobs: await JobManager.listJobs(req.account.uid), 166 | } 167 | }); 168 | } 169 | 170 | async function getJob(req, res) { 171 | res.send({ 172 | status: 0, 173 | data: { 174 | jobs: await JobManager.getJob(req.account.uid, req.params.id), 175 | } 176 | }); 177 | } 178 | 179 | module.exports = { 180 | createJob: createJob, 181 | listAllJobs: listAllJobs, 182 | getJob: getJob, 183 | } -------------------------------------------------------------------------------- /backend/src/index.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const logger = require('consola'); 3 | const cors = require('cors'); 4 | const express = require('express'); 5 | const bodyParser = require('body-parser'); 6 | const app = express(); 7 | const port = 5566; 8 | 9 | 10 | require('./init_app')().then(() => { 11 | const middlewareHandleError = require('./middleware/handle_error'); 12 | const middlewareAuth = require('./middleware/auth'); 13 | const proxy = require('./handler/proxy'); 14 | 15 | app.use(bodyParser.json()); 16 | app.use(cors({ 17 | origin: true, 18 | credentials: true, 19 | })); 20 | 21 | // 先注册代理路由,跳过 auth 验证 22 | app.get('/api/proxy/audio', proxy.proxyAudio); 23 | 24 | // 其他 API 路由需要 auth 25 | app.use('/api', middlewareAuth); 26 | app.use('/', require('./router')); 27 | app.use(middlewareHandleError); 28 | 29 | app.use(express.static(path.resolve(__dirname, '../public'))); 30 | 31 | const server = app.listen(port, () => { 32 | const host = server.address().address 33 | const port = server.address().port 34 | logger.info(`Express server is listening on ${host}:${port}!`) 35 | }) 36 | 37 | const schedulerService = require('./service/scheduler'); 38 | schedulerService.start(); 39 | }); 40 | -------------------------------------------------------------------------------- /backend/src/init_app.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const fs = require('fs'); 3 | const process = require('process'); 4 | 5 | initDir(); 6 | initAccountFileIfNotExisted(); 7 | 8 | const mediaGet = require('./service/media_fetcher/media_get'); 9 | 10 | function initDir() { 11 | // make sure all dir has been created 12 | const dirList = [ 13 | __dirname + '/../.profile', 14 | __dirname + '/../.profile/cookie', 15 | __dirname + '/../.profile/data', 16 | __dirname + '/../.profile/data/jobs', 17 | ]; 18 | 19 | dirList.forEach(dir => { 20 | if (!fs.existsSync(dir)) { 21 | fs.mkdirSync(dir); 22 | } 23 | }); 24 | } 25 | 26 | function initAccountFileIfNotExisted() { 27 | const targetFile = __dirname + '/../.profile/accounts.json'; 28 | const sampleFile = __dirname + '/../accounts.sample.json'; 29 | if (!fs.existsSync(targetFile)) { 30 | fs.copyFileSync(sampleFile, targetFile); 31 | logger.info('初始化 accounts.json 文件成功, 默认的 melody key 为: melody'); 32 | } 33 | } 34 | 35 | module.exports = async function() { 36 | // check if media-get is installed 37 | const mediaGetInfo = await mediaGet.getMediaGetInfo(); 38 | if (mediaGetInfo === false) { 39 | process.exit(-1); 40 | } 41 | if (!mediaGetInfo.hasInstallFFmpeg) { 42 | logger.error('please install FFmpeg and FFprobe first'); 43 | process.exit(-1); 44 | } 45 | logger.info(`[media-get] Version: ${mediaGetInfo.versionInfo}`); 46 | 47 | // TODO check media-get latest version 48 | } -------------------------------------------------------------------------------- /backend/src/middleware/auth.js: -------------------------------------------------------------------------------- 1 | const AccountService = require('../service/account'); 2 | const AccountNotExisted = require('../errors/account_not_existed'); 3 | const logger = require('consola'); 4 | 5 | module.exports = (req, res, next) => { 6 | if (req.method === 'OPTIONS') { 7 | return next(); 8 | } 9 | if (!req.headers['mk']) { 10 | throw new AccountNotExisted; 11 | } 12 | const account = AccountService.getAccount(req.headers['mk']) 13 | if (!account) { 14 | throw new AccountNotExisted; 15 | } 16 | //logger.info('user access', account); 17 | req.account = account 18 | next() 19 | } -------------------------------------------------------------------------------- /backend/src/middleware/handle_error.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const AccountNotExisted = require('../errors/account_not_existed'); 3 | 4 | module.exports = async (error, req, res, next) => { 5 | logger.error('catch error', error); 6 | 7 | if (error instanceof AccountNotExisted) { 8 | res.status(403).send({ 9 | status: 1, 10 | message: "account not existed", 11 | }); 12 | return; 13 | } 14 | 15 | res.status(500).send({ 16 | status: 1, 17 | message: "Internal Server Error", 18 | }); 19 | } -------------------------------------------------------------------------------- /backend/src/router.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router(); 2 | 3 | const SyncJob = require('./handler/sync_jobs'); 4 | const Songs = require('./handler/songs'); 5 | const SongMeta = require('./handler/song_meta'); 6 | const Playlists = require('./handler/playlists'); 7 | const Account = require('./handler/account'); 8 | const MediaFetcherLib = require('./handler/media_fetcher_lib'); 9 | const Config = require('./handler/config'); 10 | const Scheduler = require('./handler/scheduler'); 11 | const asyncWrapper = (cb) => { 12 | return (req, res, next) => cb(req, res, next).catch(next); 13 | }; 14 | 15 | router.post('/api/sync-jobs', asyncWrapper(SyncJob.createJob)); 16 | router.get('/api/sync-jobs', asyncWrapper(SyncJob.listAllJobs)); 17 | router.get('/api/sync-jobs/:id', asyncWrapper(SyncJob.getJob)); 18 | 19 | router.get('/api/songs', asyncWrapper(Songs.search)); 20 | router.get('/api/songs/:source/:id/playUrl', asyncWrapper(Songs.getPlayUrl)); 21 | 22 | router.get('/api/songs-meta', asyncWrapper(SongMeta.getMeta)); 23 | 24 | router.get('/api/playlists', asyncWrapper(Playlists.listAllPlaylists)); 25 | router.get('/api/playlists/:source/:id/songs', asyncWrapper(Playlists.listSongsFromPlaylist)); 26 | 27 | router.get('/api/account', asyncWrapper(Account.get)); 28 | router.post('/api/account', asyncWrapper(Account.set)); 29 | router.get('/api/accounts', asyncWrapper(Account.getAllAccounts)); 30 | router.get('/api/account/qrlogin-create', asyncWrapper(Account.qrLoginCreate)); 31 | router.get('/api/account/qrlogin-check', asyncWrapper(Account.qrLoginCheck)); 32 | 33 | router.get('/api/media-fetcher-lib/version-check', asyncWrapper(MediaFetcherLib.checkLibVersion)); 34 | router.post('/api/media-fetcher-lib/update', asyncWrapper(MediaFetcherLib.downloadTheLatestLib)); 35 | 36 | router.get('/api/config/global', asyncWrapper(Config.getGlobalConfig)); 37 | router.post('/api/config/global', asyncWrapper(Config.setGlobalConfig)); 38 | 39 | router.get('/api/scheduler/next-run', asyncWrapper(Scheduler.getNextRun)); 40 | 41 | module.exports = router; 42 | -------------------------------------------------------------------------------- /backend/src/service/account.js: -------------------------------------------------------------------------------- 1 | const AccountPath = __dirname + '/../../.profile/accounts.json'; 2 | const CookiePath = __dirname + '/../../.profile/cookie/'; 3 | let AccountMap = require(AccountPath); 4 | const logger = require('consola'); 5 | const locker = require('../utils/simple_locker'); 6 | const fs = require('fs'); 7 | const SoundQuality = require('../consts/sound_quality'); 8 | 9 | module.exports = { 10 | getAccount: getAccount, 11 | setAccount: setAccount, 12 | getAllAccounts: getAllAccounts, 13 | getAllAccountsWithoutSensitiveInfo: getAllAccountsWithoutSensitiveInfo, 14 | } 15 | 16 | const defaultConfig = { 17 | playlistSyncToWyCloudDisk: { 18 | autoSync: { 19 | enable: false, 20 | frequency: 1, 21 | frequencyUnit: "day", 22 | }, 23 | syncWySong: true, 24 | syncNotWySong: false, 25 | soundQualityPreference: SoundQuality.High, 26 | }, 27 | }; 28 | 29 | function getAccount(uid) { 30 | const account = AccountMap[uid]; 31 | if (!account) { 32 | logger.error(`the uid(${uid}) does not existed`); 33 | return false; 34 | } 35 | if (!account.config) { 36 | account.config = defaultConfig; 37 | } 38 | if (!account.config.playlistSyncToWyCloudDisk) { 39 | account.config.playlistSyncToWyCloudDisk = defaultConfig.playlistSyncToWyCloudDisk; 40 | } 41 | account.uid = uid; 42 | return account; 43 | } 44 | 45 | async function setAccount(uid, loginType, account, password, countryCode = '', config, name) { 46 | const lockKey = 'setAccount'; 47 | await locker.lock(lockKey, 5); 48 | 49 | refreshAccountFromFile(); 50 | 51 | const userAccount = getAccount(uid); 52 | if (!userAccount) { 53 | locker.unlock(lockKey); 54 | return false; 55 | } 56 | 57 | const oldAccount = userAccount; 58 | 59 | userAccount.loginType = loginType; 60 | userAccount.account = account; 61 | userAccount.password = password; 62 | userAccount.countryCode = countryCode; 63 | if (name) { 64 | userAccount.name = name; 65 | } 66 | 67 | if (config) { 68 | userAccount.config = config; 69 | } 70 | 71 | AccountMap[uid] = userAccount; 72 | 73 | storeAccount(AccountMap); 74 | locker.unlock(lockKey); 75 | 76 | // clear cookie 77 | try { 78 | fs.unlinkSync(CookiePath + uid); 79 | } catch(_){} 80 | 81 | // 重启调度器以应用新的账号配置 82 | if (config && JSON.stringify(oldAccount?.config?.playlistSyncToWyCloudDisk) !== 83 | JSON.stringify(config.playlistSyncToWyCloudDisk)) { 84 | const schedulerService = require('../service/scheduler'); 85 | await schedulerService.updateCloudSyncJob(uid); 86 | } 87 | 88 | return true; 89 | } 90 | 91 | function refreshAccountFromFile() { 92 | AccountMap = JSON.parse(fs.readFileSync(AccountPath).toString()); 93 | } 94 | 95 | function storeAccount(account) { 96 | fs.writeFileSync(AccountPath, JSON.stringify(account, null, 4)); 97 | } 98 | 99 | async function getAllAccounts() { 100 | refreshAccountFromFile(); 101 | return AccountMap; 102 | } 103 | 104 | async function getAllAccountsWithoutSensitiveInfo() { 105 | refreshAccountFromFile(); 106 | const filteredAccounts = {}; 107 | for (const [uid, account] of Object.entries(AccountMap)) { 108 | filteredAccounts[uid] = { 109 | name: account.name || uid, 110 | uid: account.uid 111 | }; 112 | } 113 | return filteredAccounts; 114 | } 115 | 116 | 117 | -------------------------------------------------------------------------------- /backend/src/service/config_manager/index.js: -------------------------------------------------------------------------------- 1 | const sound_quality = require('../../consts/sound_quality'); 2 | const asyncFs = require('../../utils/fs'); 3 | 4 | const DataPath = `${__dirname}/../../../.profile/data`; 5 | const ConfigPath = `${DataPath}/config`; 6 | const GlobalConfig = `${ConfigPath}/global.json`; 7 | const sourceConsts = require('../../consts/source').consts; 8 | const libPath = require('path'); 9 | 10 | async function init() { 11 | if (!await asyncFs.asyncFileExisted(ConfigPath)) { 12 | await asyncFs.asyncMkdir(ConfigPath, { recursive: true }); 13 | } 14 | } 15 | init(); 16 | 17 | const GlobalDefaultConfig = { 18 | downloadPath: '', 19 | filenameFormat: '{songName}-{artist}', 20 | downloadPathExisted: false, 21 | // don't search youtube by default 22 | sources: Object.values(sourceConsts).map(i => i.code).filter(s => s !== sourceConsts.Youtube.code), 23 | sourceConsts, 24 | playlistSyncToLocal: { 25 | autoSync: { 26 | enable: false, 27 | frequency: 1, 28 | frequencyUnit: "day", 29 | }, 30 | deleteLocalFile: false, 31 | filenameFormat: `{playlistName}${libPath.sep}{songName}-{artist}`, 32 | soundQualityPreference: sound_quality.High, 33 | syncAccounts: [], 34 | }, 35 | }; 36 | 37 | async function setGlobalConfig(config) { 38 | const oldConfig = await getGlobalConfig(); 39 | await asyncFs.asyncWriteFile(GlobalConfig, JSON.stringify(config)); 40 | 41 | // 只在本地同步配置发生变化时更新调度器 42 | if (JSON.stringify(oldConfig.playlistSyncToLocal) !== JSON.stringify(config.playlistSyncToLocal)) { 43 | const schedulerService = require('../scheduler'); 44 | await schedulerService.updateLocalSyncJob(); 45 | } 46 | } 47 | 48 | async function getGlobalConfig() { 49 | if (!await asyncFs.asyncFileExisted(GlobalConfig)) { 50 | return GlobalDefaultConfig; 51 | } 52 | const config = JSON.parse(await asyncFs.asyncReadFile(GlobalConfig)); 53 | if (!config.sources) { 54 | config.sources = GlobalDefaultConfig.sources; 55 | } 56 | config.sourceConsts = GlobalDefaultConfig.sourceConsts; 57 | config.downloadPathExisted = false; 58 | if (config.downloadPath) { 59 | config.downloadPathExisted = await asyncFs.asyncFileExisted(config.downloadPath); 60 | } 61 | 62 | if (!config.filenameFormat) { 63 | config.filenameFormat = GlobalDefaultConfig.filenameFormat; 64 | } 65 | 66 | if (!config.playlistSyncToLocal) { 67 | config.playlistSyncToLocal = GlobalDefaultConfig.playlistSyncToLocal; 68 | } 69 | if (!config.playlistSyncToLocal.filenameFormat) { 70 | config.playlistSyncToLocal.filenameFormat = GlobalDefaultConfig.playlistSyncToLocal.filenameFormat; 71 | } 72 | if (!config.playlistSyncToLocal.soundQualityPreference) { 73 | config.playlistSyncToLocal.soundQualityPreference = GlobalDefaultConfig.playlistSyncToLocal.soundQualityPreference; 74 | } 75 | if (!config.playlistSyncToLocal.syncAccounts) { 76 | config.playlistSyncToLocal.syncAccounts = GlobalDefaultConfig.playlistSyncToLocal.syncAccounts; 77 | } 78 | return config; 79 | } 80 | 81 | 82 | module.exports = { 83 | setGlobalConfig, 84 | getGlobalConfig, 85 | } -------------------------------------------------------------------------------- /backend/src/service/cronjob/index.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/backend/src/service/cronjob/index.js -------------------------------------------------------------------------------- /backend/src/service/job_manager/index.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const asyncFs = require('../../utils/fs'); 3 | const genUUID = require('../../utils/uuid'); 4 | const { lock, unlock } = require('../../utils/simple_locker'); 5 | const JobStatus = require('../../consts/job_status'); 6 | 7 | const DataPath = `${__dirname}/../../../.profile/data`; 8 | const JobDataPath = `${DataPath}/jobs`; 9 | 10 | const JobManagerInitTime = Date.now(); 11 | 12 | async function listJobs(uid) { 13 | const list = []; 14 | const jobs = await getUserJobs(uid); 15 | for (const jobId in jobs) { 16 | const job = await getJob(uid, jobId); 17 | if (!job) { 18 | continue; 19 | } 20 | job.id = jobId; 21 | list.push(job); 22 | } 23 | return list.sort((a, b) => b.createdAt - a.createdAt); 24 | } 25 | 26 | async function getJob(uid, jobId) { 27 | const jobFile = await getJobFilePath(uid, jobId, false); 28 | if (!await asyncFs.asyncFileExisted(jobFile)) { 29 | return null; 30 | } 31 | const fileText = await asyncFs.asyncReadFile(jobFile); 32 | if (fileText == "") { 33 | return null; 34 | } 35 | return JSON.parse(fileText); 36 | } 37 | 38 | async function updateJob(uid, jobId, info) { 39 | const lockKey = getJobLockKey(jobId); 40 | if (!await lock(lockKey, 5)) { 41 | logger.error(`get job locker failed, uid: ${uid}, job: ${jobId}`); 42 | return false; 43 | } 44 | const job = await getJob(uid, jobId); 45 | if (info.desc) { 46 | job.desc = info.desc; 47 | } 48 | if (info.progress) { 49 | job.progress = info.progress; 50 | } 51 | if (info.status) { 52 | job.status = info.status; 53 | } 54 | if (info.tip) { 55 | job.tip = info.tip; 56 | if (!info.log) { 57 | info.log = info.tip 58 | } 59 | } 60 | if (info.log) { 61 | if (!job.logs) { 62 | job.logs = []; 63 | } 64 | job.logs.push({ 65 | time: Date.now(), 66 | info: info.log 67 | }); 68 | } 69 | if (info.data) { 70 | job.data = info.data; 71 | } 72 | const jobFile = await getJobFilePath(uid, jobId); 73 | await asyncFs.asyncWriteFile(jobFile, JSON.stringify(job)); 74 | 75 | unlock(lockKey); 76 | } 77 | 78 | async function createJob(uid, job = { 79 | name: '', 80 | type: '', 81 | desc: '', 82 | progress: 0, 83 | tip: '', 84 | status: '', 85 | logs: [], 86 | data: {}, 87 | createdAt: Date.now(), 88 | }) { 89 | const jobId = genUUID(); 90 | const jobFile = await getJobFilePath(uid, jobId); 91 | await asyncFs.asyncWriteFile(jobFile, JSON.stringify(job)); 92 | 93 | await addJobIdToUserJobList(uid, jobId); 94 | return jobId; 95 | } 96 | 97 | async function deleteJob(uid, jobId) { 98 | await removeJobIdFromUserJobList(uid, jobId); 99 | await asyncFs.asyncUnlinkFile(await getJobFilePath(uid, jobId, false)); 100 | } 101 | 102 | async function addJobIdToUserJobList(uid, jobId) { 103 | const lockKey = getJobListLockKey(uid); 104 | if (!await lock(lockKey, 5)) { 105 | logger.error(`get job_list locker failed, uid: ${uid}`); 106 | return false; 107 | } 108 | const jobs = await getUserJobs(uid); 109 | jobs[jobId] = { 110 | createdAt: Date.now(), 111 | }; 112 | await asyncFs.asyncWriteFile(await getJobListFilePath(uid), JSON.stringify(jobs)); 113 | unlock(lockKey); 114 | } 115 | 116 | async function removeJobIdFromUserJobList(uid, jobId) { 117 | const lockKey = getJobListLockKey(uid); 118 | if (!await lock(lockKey, 5)) { 119 | logger.error(`get job_list locker failed, uid: ${uid}`); 120 | return false; 121 | } 122 | const jobs = await getUserJobs(uid); 123 | delete jobs[jobId]; 124 | await asyncFs.asyncWriteFile(await getJobListFilePath(uid), JSON.stringify(jobs)); 125 | unlock(lockKey); 126 | } 127 | 128 | function getJobListLockKey(uid) { 129 | return `job_list_${uid}`; 130 | } 131 | 132 | function getJobLockKey(jobId) { 133 | return `job_${jobId}`; 134 | } 135 | 136 | async function getUserJobs(uid) { 137 | const jobListFile = await getJobListFilePath(uid); 138 | return JSON.parse(await asyncFs.asyncReadFile(jobListFile)); 139 | } 140 | 141 | async function getJobFilePath(uid, jobId, createIfNotExist = true) { 142 | const path = `${await getUserJobPath(uid)}/${jobId}`; 143 | if (createIfNotExist && !await asyncFs.asyncFileExisted(path)) { 144 | await asyncFs.asyncWriteFile(path, '{}'); 145 | } 146 | return path; 147 | } 148 | 149 | async function getJobListFilePath(uid, createIfNotExist = true) { 150 | const path = `${await getUserJobPath(uid)}/list`; 151 | if (createIfNotExist && !await asyncFs.asyncFileExisted(path)) { 152 | await asyncFs.asyncWriteFile(path, '{}'); 153 | } 154 | return path; 155 | } 156 | 157 | async function getUserJobPath(uid, createIfNotExist = true) { 158 | const path = `${JobDataPath}/${uid}`; 159 | if (createIfNotExist && !await asyncFs.asyncFileExisted(path)) { 160 | await asyncFs.asyncMkdir(path, { recursive: true }); 161 | } 162 | return path; 163 | } 164 | 165 | async function findActiveJobByArgs(uid, args) { 166 | const jobs = await listJobs(uid); 167 | return jobs.find(job => { 168 | if (job['args'] === args && job['status'] !== JobStatus.Failed && job['status'] !== JobStatus.Finished) { 169 | // 如果创建时间 早于 组件 init 时间,那么认为是无效的 job(意味着服务重启了,而目前 job 不支持重启服务后继续 run) 170 | if (job['createdAt'] < JobManagerInitTime) { 171 | return false; 172 | } 173 | // 超过 1 小时也认为超时 174 | if (Date.now() - job['createdAt'] > 1000 * 60 * 60) { 175 | return false; 176 | } 177 | return job; 178 | } 179 | }); 180 | } 181 | 182 | module.exports = { 183 | listJobs: listJobs, 184 | createJob: createJob, 185 | findActiveJobByArgs: findActiveJobByArgs, 186 | deleteJob: deleteJob, 187 | getJob: getJob, 188 | updateJob: updateJob, 189 | } -------------------------------------------------------------------------------- /backend/src/service/kv/index.js: -------------------------------------------------------------------------------- 1 | const { lock, unlock } = require('../../utils/simple_locker'); 2 | const asyncFs = require('../../utils/fs'); 3 | const logger = require('consola'); 4 | 5 | const DbPath = `${__dirname}/../../../.profile/data/kv-db`; 6 | 7 | async function init() { 8 | if (!await asyncFs.asyncFileExisted(DbPath)) { 9 | await asyncFs.asyncMkdir(DbPath); 10 | } 11 | } 12 | init(); 13 | 14 | async function set(table, key, value) { 15 | if (!await lock(table, 5)) { 16 | logger.error(`get table locker failed, table: ${table}, key: ${key}, value: ${value}`); 17 | return false; 18 | } 19 | const filePath = `${DbPath}/${table}.json`; 20 | let data = {}; 21 | if (await asyncFs.asyncFileExisted(filePath)) { 22 | try { 23 | data = JSON.parse(await asyncFs.asyncReadFile(filePath)); 24 | } catch (err) { 25 | logger.error(`parse ${filePath} failed`, err); 26 | return false; 27 | } 28 | } 29 | data[key] = value; 30 | try { 31 | await asyncFs.asyncWriteFile(filePath, JSON.stringify(data)); 32 | } catch (err) { 33 | logger.error(`write ${filePath} failed`, err); 34 | return false; 35 | } 36 | unlock(table); 37 | return true; 38 | } 39 | 40 | async function get(table, key) { 41 | const filePath = `${DbPath}/${table}.json`; 42 | let data = {}; 43 | if (await asyncFs.asyncFileExisted(filePath)) { 44 | try { 45 | data = JSON.parse(await asyncFs.asyncReadFile(filePath)); 46 | } catch (err) { 47 | logger.error(`parse ${filePath} failed`, err); 48 | return false; 49 | } 50 | } 51 | return data[key]; 52 | } 53 | 54 | module.exports = { 55 | set, 56 | get, 57 | fileSyncMeta: { 58 | set: async function (source, sourceID, value) { 59 | const key = `${source}-${sourceID}`; 60 | return await set('fileSyncMeta', key, JSON.stringify(value)); 61 | }, 62 | get: async function (source, sourceID) { 63 | const key = `${source}-${sourceID}`; 64 | const ret = await get('fileSyncMeta', key); 65 | if (!ret) { 66 | return false; 67 | } 68 | return JSON.parse(ret); 69 | }, 70 | setPlaylistMeta: async function(playlistID, meta) { 71 | const key = `playlist-${playlistID}`; 72 | return await set('fileSyncMeta', key, JSON.stringify({ 73 | songIDs: meta.songIDs || [], 74 | // 预留其他字段 75 | })); 76 | }, 77 | getPlaylistMeta: async function(playlistID) { 78 | const key = `playlist-${playlistID}`; 79 | const ret = await get('fileSyncMeta', key); 80 | if (!ret) { 81 | return { 82 | songIDs: [] 83 | }; 84 | } 85 | return JSON.parse(ret); 86 | } 87 | } 88 | }; -------------------------------------------------------------------------------- /backend/src/service/media_fetcher/index.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const os = require('os'); 3 | const md5 = require('md5'); 4 | const path = require('path'); 5 | const cmd = require('../../utils/cmd'); 6 | const fs = require('fs'); 7 | const configManager = require('../config_manager') 8 | const downloadFile = require('../../utils/download'); 9 | 10 | const { getBinPath } = require('./media_get'); 11 | 12 | const basePath = path.join(os.tmpdir(), 'melody-tmp-songs'); 13 | // create path if not exists 14 | if (!fs.existsSync(basePath)) { 15 | fs.mkdirSync(basePath); 16 | } 17 | logger.info(`[tmp path] use ${basePath}`) 18 | 19 | 20 | async function downloadViaSourceUrl(url) { 21 | logger.info(`downloadViaSourceUrl params: url: ${url}`); 22 | 23 | const requestHash = md5(url); 24 | const downloadPath = `${basePath}/${requestHash}.mp3`; 25 | logger.info(`start download from ${url}`); 26 | 27 | 28 | const isSucceed = await downloadFile(url, downloadPath); 29 | if (!isSucceed) { 30 | logger.error(`download failed with ${url}`); 31 | return false; 32 | } 33 | 34 | if (!fs.existsSync(downloadPath)) { 35 | logger.error(`download failed with ${url}, the file not exists ${downloadPath}`); 36 | return false; 37 | } 38 | logger.info(`download success, path: ${downloadPath}`); 39 | return downloadPath; 40 | } 41 | 42 | async function fetchWithUrl(url, { 43 | songName = "", 44 | addMediaTag = false, 45 | }) { 46 | logger.info(`fetchWithUrl params: ${JSON.stringify(arguments)}`); 47 | if (songName) { 48 | songName = songName.replace(/ /g, '').replace(/\./g, '').replace(/\//g, '').replace(/"/g, ''); 49 | } 50 | const requestHash = md5(`${url}${songName}${addMediaTag}`); 51 | const fileBasePath = `${basePath}/${requestHash}`; 52 | try { 53 | fs.mkdirSync(fileBasePath, { recursive: true }); 54 | } catch (err) { 55 | logger.error('create dir failed', err); 56 | return false; 57 | } 58 | 59 | addMediaTag = false; // todo: 等到 media-get fix 偶现的 添加 addMediaTag 后 panic 的问题,再移除这行代码 60 | const downloadPath = `${fileBasePath}/${songName ? songName : requestHash}.mp3`; 61 | logger.info(`start parse and download from ${url}`); 62 | 63 | let args = ['-u', `"${url}"`, '--out', `${downloadPath}`, '-t', 'audio', `${addMediaTag ? '--addMediaTag' : ''}`]; 64 | 65 | logger.info(`${getBinPath()} ${args.join(' ')}`); 66 | 67 | const {code, message} = await cmd(getBinPath(), args); 68 | logger.info('-------') 69 | logger.info(code); 70 | logger.info(message); 71 | logger.info('-------') 72 | if (code != 0) { 73 | return false; 74 | } 75 | 76 | if (!fs.existsSync(downloadPath)) { 77 | return false; 78 | } 79 | return downloadPath; 80 | } 81 | 82 | async function getMetaWithUrl(url) { 83 | logger.info(`getMetaWithUrl from ${url}`); 84 | 85 | let args = ['-u', `"${url}"`, '-m', '--infoFormat=json']; 86 | 87 | const {code, message} = await cmd(getBinPath(), args); 88 | logger.info('-------') 89 | logger.info(code); 90 | // logger.info(message); 91 | logger.info('-------') 92 | if (code != 0) { 93 | logger.error(`getMetaWithUrl failed with ${url}, err: ${message}`); 94 | return false; 95 | } 96 | 97 | const meta = JSON.parse(message); 98 | 99 | return { 100 | songName: meta.title, 101 | artist: meta.artist, 102 | album: meta.album, 103 | duration: meta.duration, 104 | coverUrl: meta.cover_url, 105 | publicTime: meta.public_time, 106 | isTrial: meta.is_trial, 107 | resourceType: meta.resource_type, 108 | audios: meta.audios, 109 | fromMusicPlatform: meta.from_music_platform, 110 | resourceForbidden: meta.resource_forbidden, 111 | source: meta.source 112 | } 113 | } 114 | 115 | async function searchSongFromAllPlatform({ 116 | keyword, 117 | songName, artist, album 118 | }) { 119 | logger.info(`searchSong with ${JSON.stringify(arguments)}`); 120 | 121 | const globalConfig = await configManager.getGlobalConfig(); 122 | 123 | let searchParams = keyword 124 | ? ['-k', `"${keyword}"`] 125 | : ['--searchSongName', `"${songName}"`, '--searchArtist', `"${artist}"`, '--searchAlbum', `"${album}"`]; 126 | searchParams = searchParams.concat([ 127 | '--searchType="song"', 128 | '-m', 129 | `--sources=${globalConfig.sources.join(',')}`, 130 | '--infoFormat=json', 131 | '-l', 'silence' 132 | ]); 133 | 134 | logger.info(`cmdStr: ${getBinPath()} ${searchParams.join(' ')}`); 135 | 136 | const {code, message} = await cmd(getBinPath(), searchParams); 137 | logger.info('-------') 138 | logger.info(code); 139 | // logger.info(message); 140 | logger.info('-------') 141 | if (code != 0) { 142 | logger.error(`searchSong failed with ${arguments}, err: ${message}`); 143 | return false; 144 | } 145 | 146 | let jsonResponse; 147 | try { 148 | jsonResponse = JSON.parse(message); 149 | } catch (e) { 150 | logger.error(e) 151 | return false; 152 | } 153 | 154 | return jsonResponse.map(searchItem => { 155 | return { 156 | songName: searchItem.Name, 157 | artist: searchItem.Artist, 158 | album: searchItem.Album, 159 | duration: searchItem.Duration, 160 | url: searchItem.Url, 161 | resourceForbidden: searchItem.ResourceForbidden, 162 | source: searchItem.Source, 163 | fromMusicPlatform: searchItem.FromMusicPlatform, 164 | score: searchItem.Score, 165 | } 166 | }) 167 | } 168 | 169 | module.exports = { 170 | downloadViaSourceUrl: downloadViaSourceUrl, 171 | fetchWithUrl: fetchWithUrl, 172 | getMetaWithUrl: getMetaWithUrl, 173 | searchSongFromAllPlatform: searchSongFromAllPlatform, 174 | } -------------------------------------------------------------------------------- /backend/src/service/media_fetcher/media_get.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const https = require('https'); 3 | const cmd = require('../../utils/cmd'); 4 | var isWin = require('os').platform().indexOf('win32') > -1; 5 | const isLinux = require('os').platform().indexOf('linux') > -1; 6 | const isDarwin = require('os').platform().indexOf('darwin') > -1; 7 | const httpsGet = require('../../utils/network').asyncHttpsGet; 8 | const RemoteConfig = require('../remote_config'); 9 | const fs = require('fs'); 10 | 11 | function getBinPath(isTemp = false) { 12 | return `${__dirname}/../../../bin/media-get` + (isTemp ? '-tmp-' : '') + (isWin ? '.exe' : ''); 13 | } 14 | 15 | async function getMediaGetInfo(isTempBin = false) { 16 | try { 17 | const {code, message, error} = await cmd(getBinPath(isTempBin), ['-h']); 18 | logger.info('Command execution result:', { 19 | code, 20 | error, 21 | binPath: getBinPath(isTempBin) 22 | }); 23 | 24 | if (code != 0) { 25 | logger.error(`Failed to execute media-get:`, { 26 | code, 27 | error, 28 | message 29 | }); 30 | return false; 31 | } 32 | 33 | const hasInstallFFmpeg = message.indexOf('FFmpeg,FFprobe: installed') > -1; 34 | const versionInfo = message.match(/Version:(.+?)\n/); 35 | 36 | return { 37 | hasInstallFFmpeg, 38 | versionInfo: versionInfo ? versionInfo[1].trim() : '', 39 | fullMessage: message, 40 | } 41 | } catch (err) { 42 | logger.error('Exception while executing media-get:', err); 43 | return false; 44 | } 45 | } 46 | 47 | async function getLatestMediaGetVersion() { 48 | const remoteConfig = await RemoteConfig.getRemoteConfig(); 49 | const latestVerisonUrl = `${remoteConfig.bestGithubProxy}https://raw.githubusercontent.com/foamzou/media-get/main/LATEST_VERSION`; 50 | console.log('start to get latest version from: ' + latestVerisonUrl); 51 | 52 | const latestVersion = await httpsGet(latestVerisonUrl); 53 | console.log('latest version: ' + latestVersion); 54 | if (latestVersion === null || (latestVersion || "").split('.').length !== 3) { 55 | logger.error('获取 media-get 最新版本号失败, got: ' + latestVersion); 56 | return false; 57 | } 58 | return latestVersion; 59 | } 60 | 61 | async function downloadFile(url, filename) { 62 | return new Promise((resolve) => { 63 | let fileStream = fs.createWriteStream(filename); 64 | let receivedBytes = 0; 65 | 66 | const handleResponse = (res) => { 67 | // Handle redirects 68 | if (res.statusCode === 301 || res.statusCode === 302) { 69 | logger.info('Following redirect'); 70 | fileStream.end(); 71 | fileStream = fs.createWriteStream(filename); 72 | if (res.headers.location) { 73 | https.get(res.headers.location, handleResponse) 74 | .on('error', handleError); 75 | } 76 | return; 77 | } 78 | 79 | // Check for successful status code 80 | if (res.statusCode !== 200) { 81 | handleError(new Error(`HTTP Error: ${res.statusCode}`)); 82 | return; 83 | } 84 | 85 | const totalBytes = parseInt(res.headers['content-length'], 10); 86 | 87 | res.on('error', handleError); 88 | fileStream.on('error', handleError); 89 | 90 | res.pipe(fileStream); 91 | 92 | res.on('data', (chunk) => { 93 | receivedBytes += chunk.length; 94 | }); 95 | 96 | fileStream.on('finish', () => { 97 | fileStream.close(() => { 98 | if (receivedBytes === 0) { 99 | fs.unlink(filename, () => { 100 | logger.error('Download failed: Empty file received'); 101 | resolve(false); 102 | }); 103 | } else if (totalBytes && receivedBytes < totalBytes) { 104 | fs.unlink(filename, () => { 105 | logger.error(`Download incomplete: ${receivedBytes}/${totalBytes} bytes`); 106 | resolve(false); 107 | }); 108 | } else { 109 | resolve(true); 110 | } 111 | }); 112 | }); 113 | }; 114 | 115 | const handleError = (error) => { 116 | fileStream.destroy(); 117 | fs.unlink(filename, () => { 118 | logger.error('Download error:', error); 119 | resolve(false); 120 | }); 121 | }; 122 | 123 | const req = https.get(url, handleResponse) 124 | .on('error', handleError) 125 | .setTimeout(60000, () => { 126 | handleError(new Error('Download timeout')); 127 | }); 128 | 129 | req.on('error', handleError); 130 | }); 131 | } 132 | 133 | async function getMediaGetRemoteFilename(latestVersion) { 134 | let suffix = 'win.exe'; 135 | if (isLinux) { 136 | suffix = 'linux'; 137 | } 138 | if (isDarwin) { 139 | suffix = 'darwin'; 140 | } 141 | if (process.arch === 'arm64') { 142 | suffix += '-arm64'; 143 | } 144 | const remoteConfig = await RemoteConfig.getRemoteConfig(); 145 | return `${remoteConfig.bestGithubProxy}https://github.com/foamzou/media-get/releases/download/v${latestVersion}/media-get-${latestVersion}-${suffix}`; 146 | } 147 | 148 | const renameFile = (oldName, newName) => { 149 | return new Promise((resolve, reject) => { 150 | fs.rename(oldName, newName, (err) => { 151 | if (err) { 152 | logger.error(err) 153 | resolve(false); 154 | } else { 155 | resolve(true); 156 | } 157 | }); 158 | }); 159 | }; 160 | 161 | async function downloadTheLatestMediaGet(version) { 162 | const remoteFile = await getMediaGetRemoteFilename(version); 163 | logger.info('start to download media-get: ' + remoteFile); 164 | const ret = await downloadFile(remoteFile, getBinPath(true)); 165 | if (ret === false) { 166 | logger.error('download failed'); 167 | return false; 168 | } 169 | fs.chmodSync(getBinPath(true), '755'); 170 | logger.info('download finished'); 171 | 172 | // Add debug logs for binary file and validate 173 | try { 174 | const stats = fs.statSync(getBinPath(true)); 175 | logger.info(`Binary file stats: size=${stats.size}, mode=${stats.mode.toString(8)}`); 176 | 177 | // Check minimum file size (should be at least 2MB) 178 | const minSize = 2 * 1024 * 1024; // 2MB 179 | if (stats.size < minSize) { 180 | logger.error(`Invalid binary file size: ${stats.size} bytes. Expected at least ${minSize} bytes`); 181 | return false; 182 | } 183 | 184 | // Check file permissions (should be executable) 185 | const executableMode = 0o755; 186 | if ((stats.mode & 0o777) !== executableMode) { 187 | logger.error(`Invalid binary file permissions: ${stats.mode.toString(8)}. Expected: ${executableMode.toString(8)}`); 188 | return false; 189 | } 190 | 191 | // Skip validation when cross compiling 192 | if (!process.env.CROSS_COMPILING) { 193 | const temBinInfo = await getMediaGetInfo(true); 194 | logger.info('Execution result:', { 195 | binPath: getBinPath(true), 196 | arch: process.arch, 197 | platform: process.platform, 198 | temBinInfo 199 | }); 200 | 201 | if (!temBinInfo || temBinInfo.versionInfo === "") { 202 | logger.error('testing new bin failed. Details:', { 203 | binExists: fs.existsSync(getBinPath(true)), 204 | binPath: getBinPath(true), 205 | error: temBinInfo === false ? 'Execution failed' : 'No version info' 206 | }); 207 | return false; 208 | } 209 | } 210 | 211 | const renameRet = await renameFile(getBinPath(true), getBinPath()); 212 | if (!renameRet) { 213 | logger.error('rename failed'); 214 | return false; 215 | } 216 | return true; 217 | } catch (err) { 218 | logger.error('Failed to get binary stats:', err); 219 | return false; 220 | } 221 | } 222 | 223 | module.exports = { 224 | getBinPath: getBinPath, 225 | getMediaGetInfo: getMediaGetInfo, 226 | getLatestMediaGetVersion: getLatestMediaGetVersion, 227 | downloadTheLatestMediaGet: downloadTheLatestMediaGet, 228 | } -------------------------------------------------------------------------------- /backend/src/service/music_platform/wycloud/index.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const { 3 | cloud, cloudsearch, cloud_match, song_detail, song_url, 4 | user_playlist, playlist_detail, user_account, playlist_track_all, 5 | login_qr_check, login_qr_create, login_qr_key, 6 | } = require('NeteaseCloudMusicApi'); 7 | const fs = require('fs'); 8 | const path = require('path'); 9 | const {requestApi} = require('./transport'); 10 | 11 | async function uploadSong(uid, filePath) { 12 | const response = await safeRequest(uid, cloud, { 13 | songFile: { 14 | name: path.basename(filePath), 15 | data: fs.readFileSync(filePath), 16 | }, 17 | }); 18 | if (response === false) { 19 | return false; 20 | } 21 | logger.debug('uploadSong\'s resonse: ', response) 22 | if (!response.privateCloud) { 23 | return false; 24 | } 25 | const songInfo = response.privateCloud.simpleSong; 26 | 27 | return { 28 | songId: songInfo.id, 29 | matched: songInfo.ar[0].id !== 0 && songInfo.al.id !== 0, // It's matched the song on wyMusic if singer and album has info 30 | }; 31 | } 32 | 33 | 34 | async function searchSong(uid, songName, artist) { 35 | const response = await safeRequest(uid, cloudsearch, { 36 | keywords: `${songName} ${artist}`, 37 | type: 1, 38 | }); 39 | if (response === false) { 40 | return false; 41 | } 42 | if (!response.result || response.result.songs.length === 0) { 43 | return false; 44 | } 45 | 46 | return response.result.songs.map(song => { 47 | let artists = []; 48 | 49 | if (song.ar.length !== 0) { 50 | song.ar.map(artist => { 51 | artists.push(artist.name); 52 | artist.alias && artists.push(...artist.alias); 53 | artist.alia && artists.push(...artist.alia); 54 | }); 55 | } 56 | return { 57 | songId: song.id, 58 | songName: song.name, 59 | album: song.al.name, 60 | artists: artists.filter(a => a !== '' && a !== undefined), 61 | }; 62 | }) 63 | } 64 | 65 | async function matchAndFixCloudSong(uid, cloudSongId, wySongId) { 66 | const response = await safeRequest(uid, cloud_match, { 67 | sid: cloudSongId, 68 | asid: wySongId, 69 | }); 70 | if (response === false) { 71 | return false; 72 | } 73 | if (response.code > 399) { 74 | logger.warn(response); 75 | return false; 76 | } 77 | return true; 78 | } 79 | 80 | async function getMyAccount(uid) { 81 | const response = await safeRequest(uid, user_account, { 82 | uid, 83 | }); 84 | if (response === false) { 85 | return false; 86 | } 87 | if (!response.profile) { 88 | return false; 89 | } 90 | return { 91 | userId: response.profile.userId, 92 | nickname: response.profile.nickname, 93 | avatarUrl: response.profile.avatarUrl, 94 | }; 95 | } 96 | 97 | async function getSongInfo(uid, id) { 98 | const response = await safeRequest(uid, song_detail, { 99 | ids: `"${id}"`, 100 | }); 101 | if (response === false) { 102 | return false; 103 | } 104 | if (!response.songs || response.songs.length === 0) { 105 | return false; 106 | } 107 | const songInfo = response['songs'][0]; 108 | return { 109 | songId: songInfo.id, 110 | songName: songInfo.name, 111 | artists: songInfo.ar.map(artist => artist.name), 112 | duration: songInfo.dt / 1000, 113 | album: songInfo.al.name, 114 | cover: songInfo.al.picUrl, 115 | }; 116 | } 117 | 118 | async function getPlayUrl(uid, id, isLossless = false) { 119 | const response = await safeRequest(uid, song_url, { 120 | id, 121 | br: isLossless ? 999000 : 320000 122 | }); 123 | if (response === false) { 124 | return ''; 125 | } 126 | if (!response.data || !response.data[0].url) { 127 | return ''; 128 | } 129 | return response.data[0].url; 130 | } 131 | 132 | async function getUserAllPlaylist(uid) { 133 | const wyAccount = await getMyAccount(uid); 134 | if (wyAccount === false) { 135 | logger.error(`uid(${uid}) get user's wycloud account failed.`); 136 | return false; 137 | } 138 | const response = await safeRequest(uid, user_playlist, { 139 | uid: wyAccount.userId, 140 | }); 141 | if (response === false) { 142 | return false; 143 | } 144 | if (!response.playlist || response.playlist.length === 0) { 145 | return false; 146 | } 147 | return response.playlist.map(playlist => { 148 | return { 149 | id: playlist.id, 150 | name: playlist.name, 151 | cover: playlist.coverImgUrl, 152 | trackCount: playlist.trackCount, 153 | isCreatedByMe: playlist.creator.userId === wyAccount.userId, 154 | }; 155 | }); 156 | } 157 | 158 | async function getSongsFromPlaylist(uid, source, playlistId) { 159 | const [detailResponse, songsResponse] = await Promise.all([ 160 | safeRequest(uid, playlist_detail, { 161 | id: playlistId, 162 | }), 163 | safeRequest(uid, playlist_track_all, { 164 | id: playlistId, 165 | offset: 0, 166 | limit: 1000, 167 | }), 168 | ]); 169 | if (detailResponse === false || songsResponse === false) { 170 | return false; 171 | } 172 | if (!detailResponse.playlist || !songsResponse.songs || songsResponse.songs.length === 0) { 173 | logger.error(`uid(${uid}) playlist(${playlistId}) has no songs.`, detailResponse, songsResponse); 174 | return false; 175 | } 176 | // console.log(JSON.stringify(songsResponse, null, 4)); 177 | // ddd 178 | if (songsResponse.songs.length >= 1000) { 179 | const songsPage2Response = await safeRequest(uid, playlist_track_all, { 180 | id: playlistId, 181 | offset: 1000, 182 | limit: 1000, 183 | }); 184 | if (songsPage2Response !== false && songsPage2Response.songs) { 185 | songsResponse.songs = songsResponse.songs.concat(songsPage2Response.songs); 186 | songsResponse.privileges = songsResponse.privileges.concat(songsPage2Response.privileges); 187 | } 188 | } 189 | 190 | let info = { 191 | id: playlistId, 192 | name: detailResponse.playlist.name, 193 | cover: detailResponse.playlist.coverImgUrl, 194 | songs: [], 195 | }; 196 | const songsMap = {}; 197 | songsResponse.songs.map(song => { 198 | songsMap[song.id] = song; 199 | }); 200 | 201 | const isBlockedSong = (song, songInfo) => { 202 | // the song has been added to cloud if the pc field is present 203 | if (songInfo.pc) { 204 | return false; 205 | } 206 | 207 | // 收费歌曲 208 | if (song.fee === 1) { 209 | if (song.realPayed === 1 || song.payed === 1) { 210 | return false; 211 | } 212 | return true; 213 | } 214 | // subp 或 cp === 1 可能都表示有版权 215 | // 免费歌曲 216 | if (song.subp === 1) { 217 | return false; 218 | } 219 | 220 | return true; 221 | }; 222 | 223 | songsResponse.privileges.forEach(song => { 224 | const songInfo = songsMap[song.id]; 225 | if (!songInfo) { 226 | return; 227 | } 228 | 229 | const isBlocked = isBlockedSong(song, songInfo); 230 | const isCloud = !!songInfo.pc; 231 | info.songs.push({ 232 | songId: songInfo.id, 233 | songName: songInfo.name, 234 | artists: songInfo.ar.map(artist => artist.name), 235 | artist: songInfo.ar.length > 0 ? songInfo.ar[0].name : '', 236 | duration: songInfo.dt / 1000, 237 | album: songInfo.al.name, 238 | cover: songInfo.al.picUrl, 239 | pageUrl: `https://music.163.com/song?id=${songInfo.id}`, 240 | playUrl: !isBlocked && !isCloud ? `http://music.163.com/song/media/outer/url?id=${songInfo.id}.mp3` : '', // 不再建议使用这个 url,建议每次都 Call API 获取 241 | isBlocked, 242 | isCloud, 243 | }); 244 | }); 245 | 246 | return info; 247 | } 248 | 249 | async function getBlockedSongsFromPlaylist(uid, source, playlistId) { 250 | const info = await getSongsFromPlaylist(uid, source, playlistId); 251 | if (info === false) { 252 | return false; 253 | } 254 | info.blockedSongs = info.songs.filter(song => song.isBlocked); 255 | return info; 256 | } 257 | 258 | async function qrLoginCreate(uid) { 259 | const keyResponse = await safeRequest(uid, login_qr_key, {}, false); 260 | if (keyResponse === false || !keyResponse.data.unikey) { 261 | logger.warn(`uid(${uid}) get qr login key failed.`); 262 | return false; 263 | } 264 | const qrKey = keyResponse.data.unikey; 265 | const qrCodeResponse = await safeRequest(uid, login_qr_create, {key: qrKey, qrimg: true}, false); 266 | if (qrCodeResponse === false || !qrCodeResponse.data.qrimg) { 267 | return false; 268 | } 269 | return { 270 | qrKey, 271 | qrCode: qrCodeResponse.data.qrimg, 272 | }; 273 | } 274 | 275 | async function qrLoginCheck(uid, qrKey) { 276 | const response = await safeRequest(uid, login_qr_check, {key: qrKey, cookie: { 277 | os: 'pc', 278 | }}, false); 279 | if (response === false) { 280 | return false; 281 | } 282 | return { 283 | code: response.code, 284 | cookie: response.cookie, 285 | }; 286 | } 287 | 288 | async function verifyAccountStatus(uid) { 289 | const account = await getMyAccount(uid); 290 | return account !== false; 291 | } 292 | 293 | async function safeRequest(uid, moduleFunc, params, cookieRequired = true) { 294 | try { 295 | const response = await requestApi(uid, moduleFunc, params, cookieRequired); 296 | if (response == false) { 297 | logger.error(`request failed.`, response); 298 | return false; 299 | } 300 | return response; 301 | } catch (error) { 302 | logger.error(`uid(${uid}) request failed.`, error); 303 | return false; 304 | } 305 | } 306 | 307 | module.exports = { 308 | getMyAccount: getMyAccount, 309 | uploadSong: uploadSong, 310 | matchAndFixCloudSong: matchAndFixCloudSong, 311 | searchSong: searchSong, 312 | getBlockedSongsFromPlaylist: getBlockedSongsFromPlaylist, 313 | getSongsFromPlaylist: getSongsFromPlaylist, 314 | getUserAllPlaylist: getUserAllPlaylist, 315 | getSongInfo: getSongInfo, 316 | getPlayUrl: getPlayUrl, 317 | qrLoginCreate: qrLoginCreate, 318 | qrLoginCheck: qrLoginCheck, 319 | verifyAccountStatus: verifyAccountStatus, 320 | } -------------------------------------------------------------------------------- /backend/src/service/music_platform/wycloud/transport.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const AccountService = require('../../account'); 3 | const { login_cellphone, login_refresh, login } = require('NeteaseCloudMusicApi'); 4 | const CookiePath = `${__dirname}/../../../../.profile/cookie/`; 5 | const fs = require('fs'); 6 | 7 | 8 | const LoginTypePhone = 'phone'; 9 | const LoginTypeEmail = 'email'; 10 | 11 | const CookieMap = {}; 12 | 13 | async function requestApi(uid, moduleFunc, request = {}, cookieRequired = true) { 14 | if (cookieRequired) { 15 | let cookie = await getCookie(uid); 16 | if (!cookie) { 17 | logger.error(`uid(${uid}) get cookie failed`); 18 | return false; 19 | } 20 | request.cookie = cookie; 21 | } 22 | 23 | let response = await requestWyyApi(moduleFunc, request); 24 | // need refresh 25 | if (response && response.status == 301) { 26 | cookie = await getCookie(uid, true); 27 | if (!cookie) { 28 | logger.error(`uid(${uid}) refresh cookie failed. request api abort`); 29 | return false; 30 | } 31 | 32 | // retry request 33 | request.cookie = cookie; 34 | response = await requestWyyApi(moduleFunc, request); 35 | } 36 | 37 | if (response && response.status == 200) { 38 | return response.body; 39 | } 40 | 41 | logger.error(`requestWyyApi respond non 200, response: `, response); 42 | return false; 43 | } 44 | 45 | async function requestWyyApi(moduleFunc, request) { 46 | return moduleFunc(request).then(response => { 47 | return response; 48 | }).catch(err => { 49 | console.log(err) 50 | 51 | logger.error(`requestWyyApi failed: `, err); 52 | if (typeof err == 'object' && err.status == '301') { 53 | return err; 54 | } 55 | return false; 56 | }); 57 | } 58 | 59 | 60 | async function getCookie(uid, refresh = false) { 61 | const account = AccountService.getAccount(uid); 62 | if (!account) { 63 | return false; 64 | } 65 | 66 | // fetch from cache 67 | const cookieFromCache = fetchCookieFromCache(uid, account); 68 | if (cookieFromCache) { 69 | if (!refresh) { 70 | return cookieFromCache; 71 | } 72 | 73 | logger.info('refresh cookie...', cookieFromCache); 74 | const response = await requestWyyApi(login_refresh, {cookie: cookieFromCache}); 75 | if (response && response.status == 200 && response.cookie && response.cookie.length > 1) { 76 | const cookie = response.cookie.map(line => line.replace('HTTPOnly', '')).join(';'); 77 | logger.info('refresh cookie succeed, ', cookie); 78 | storeCookie(uid, account, cookie); 79 | return cookie; 80 | } 81 | logger.info(`refresh failed, try login again`); 82 | } 83 | 84 | // login 85 | logger.info(`uid(${uid}) login with ${account.countryCode} ${account.account} via ${account.loginType}`); 86 | 87 | let result; 88 | if (account.loginType === LoginTypePhone) { 89 | result = await requestWyyApi(login_cellphone, { 90 | countrycode: account.countrycode, 91 | phone: account.account, 92 | password: account.password, 93 | }); 94 | } else if (account.loginType === LoginTypeEmail) { 95 | result = await requestWyyApi(login, { 96 | email: account.account, 97 | password: account.password, 98 | }); 99 | } else { 100 | if (account.loginType === 'qrcode') { 101 | logger.error(`uid(${uid})'s loginType(${account.loginType}) does not support auto login, please login in the browser page first`); 102 | } else { 103 | logger.error(`uid(${uid})'s loginType(${account.loginType}) does not support now`); 104 | } 105 | return false; 106 | } 107 | 108 | if (result && result.status == 200 && result.body && result.body.code == 200 && result.body.cookie) { 109 | logger.info(`uid(${uid}) login succeed`) 110 | storeCookie(uid, account, result.body.cookie); 111 | return result.body.cookie; 112 | } 113 | logger.error(`fetch cookie from response failed, uid(${uid}) login failed`, result); 114 | return false; 115 | } 116 | 117 | function storeCookie(uid, account, cookie) { 118 | fs.writeFileSync(getCookieFilePath(uid, account), cookie); 119 | CookieMap[getCookieMapKey(uid, account)] = cookie; 120 | } 121 | 122 | function fetchCookieFromCache(uid, account) { 123 | const cacheKey = getCookieMapKey(uid, account); 124 | if (CookieMap[cacheKey]) { 125 | return CookieMap[cacheKey]; 126 | } 127 | const CookieFile = getCookieFilePath(uid, account); 128 | 129 | if (!fs.existsSync(CookieFile)) { 130 | logger.info(`uid(${uid})'s cookie not found from .profile`); 131 | return null; 132 | } 133 | 134 | const cookie = fs.readFileSync(CookieFile).toString(); 135 | CookieMap[cacheKey] = cookie; 136 | 137 | return cookie; 138 | } 139 | 140 | function getCookieMapKey(uid, account) { 141 | return `${uid}-${account.platform}-${account.account}`; 142 | } 143 | 144 | function getCookieFilePath(uid, account) { 145 | return `${CookiePath}${uid}-${account.platform}-${account.account}`; 146 | } 147 | 148 | module.exports = { 149 | requestApi, 150 | storeCookie, 151 | } -------------------------------------------------------------------------------- /backend/src/service/remote_config/index.js: -------------------------------------------------------------------------------- 1 | const httpsGet = require('../../utils/network').asyncHttpsGet; 2 | const logger = require('consola'); 3 | const configManager = require('../config_manager'); 4 | 5 | // Store best proxy in memory for performance 6 | let cachedBestProxy = ''; 7 | 8 | async function validateGithubAccess(proxy = '') { 9 | try { 10 | const testUrl = proxy ? `${proxy}https://api.github.com/zen` : 'https://api.github.com/zen'; 11 | const response = await httpsGet(testUrl); 12 | return response !== null; 13 | } catch (err) { 14 | return false; 15 | } 16 | } 17 | 18 | async function findBestProxy(proxyList) { 19 | // Always try direct access first 20 | if (await validateGithubAccess()) { 21 | cachedBestProxy = ''; 22 | return ''; 23 | } 24 | 25 | // Try cached proxy if available 26 | if (cachedBestProxy && await validateGithubAccess(cachedBestProxy)) { 27 | return cachedBestProxy; 28 | } 29 | 30 | // Test each proxy in the list 31 | for (const proxy of proxyList) { 32 | if (proxy && await validateGithubAccess(proxy)) { 33 | cachedBestProxy = proxy; 34 | return proxy; 35 | } 36 | } 37 | 38 | logger.warn('No working GitHub access found, either direct or via proxy'); 39 | return ''; // Return empty string if no working access found 40 | } 41 | 42 | async function getRemoteConfig() { 43 | const fallbackConfig = { 44 | githubProxy: ['', 'https://ghp.ci/'], 45 | } 46 | 47 | const remoteConfigUrl = 'https://foamzou.com/tools/melody-config.php?v=2'; 48 | const remoteConfig = await httpsGet(remoteConfigUrl); 49 | 50 | let config = {}; 51 | if (remoteConfig === null) { 52 | config = fallbackConfig; 53 | } else { 54 | config = JSON.parse(remoteConfig); 55 | } 56 | 57 | let bestGithubProxy = await findBestProxy(config.githubProxy); 58 | if (bestGithubProxy !== '' && !bestGithubProxy.endsWith('/')) { 59 | bestGithubProxy = bestGithubProxy + '/'; 60 | } 61 | 62 | return { 63 | bestGithubProxy, 64 | } 65 | } 66 | 67 | module.exports = { 68 | getRemoteConfig, 69 | } -------------------------------------------------------------------------------- /backend/src/service/scheduler/index.js: -------------------------------------------------------------------------------- 1 | const schedule = require('node-schedule'); 2 | const logger = require('consola'); 3 | const configManager = require('../config_manager'); 4 | const AccountService = require('../account'); 5 | const syncPlaylist = require('../sync_music/sync_playlist'); 6 | const unblockMusicInPlaylist = require('../sync_music/unblock_music_in_playlist'); 7 | const { consts: sourceConsts } = require('../../consts/source'); 8 | const { getUserAllPlaylist, verifyAccountStatus } = require('../music_platform/wycloud'); 9 | 10 | class SchedulerService { 11 | constructor() { 12 | this.jobs = new Map(); 13 | } 14 | 15 | async start() { 16 | await this.scheduleLocalSyncJobs(); 17 | await this.scheduleCloudSyncJobs(); 18 | 19 | // Log initial schedule info 20 | const localNextRun = this.getLocalSyncNextRun(); 21 | if (localNextRun) { 22 | logger.info(`Next local sync scheduled at: ${localNextRun.nextRunTime}, in ${Math.round(localNextRun.remainingMs / 1000 / 60)} minutes`); 23 | } 24 | 25 | const accounts = await AccountService.getAllAccounts(); 26 | for (const uid in accounts) { 27 | const cloudNextRun = this.getCloudSyncNextRun(uid); 28 | if (cloudNextRun) { 29 | logger.info(`Next cloud sync for account ${uid} scheduled at: ${cloudNextRun.nextRunTime}, in ${Math.round(cloudNextRun.remainingMs / 1000 / 60)} minutes`); 30 | } 31 | } 32 | 33 | logger.info('Scheduler service started'); 34 | } 35 | 36 | async scheduleLocalSyncJobs() { 37 | // 系统级别的本地同步任务 38 | const config = await configManager.getGlobalConfig(); 39 | const syncAccounts = config.playlistSyncToLocal.syncAccounts || []; 40 | if (!config.playlistSyncToLocal.autoSync.enable || syncAccounts.length === 0) { 41 | return; 42 | } 43 | 44 | const frequency = config.playlistSyncToLocal.autoSync.frequency; 45 | const unit = config.playlistSyncToLocal.autoSync.frequencyUnit; 46 | 47 | const rule = this.buildScheduleRule(frequency, unit); 48 | const jobKey = 'localSync'; 49 | 50 | const job = schedule.scheduleJob(rule, async () => { 51 | logger.info('Start auto sync playlist to local'); 52 | for (const uid of syncAccounts) { 53 | const isActive = await verifyAccountStatus(uid); 54 | if (!isActive) { 55 | logger.warn(`Account ${uid} is not active, skip local sync`); 56 | continue; 57 | } 58 | const playlists = await getUserAllPlaylist(uid); 59 | for (const playlist of playlists) { 60 | logger.info(`Start sync playlist ${playlist.id} to local for account ${uid}`); 61 | await syncPlaylist(uid, sourceConsts.Netease.code, playlist.id); 62 | } 63 | } 64 | }); 65 | 66 | this.jobs.set(jobKey, job); 67 | 68 | logger.info(`Schedule local sync job success, rule: ${this.formatScheduleRule(rule)}`); 69 | } 70 | 71 | async scheduleCloudSyncJobs() { 72 | // 账号级别的云盘同步任务 73 | const accounts = await AccountService.getAllAccounts(); 74 | for (const uid in accounts) { 75 | const account = accounts[uid]; 76 | await this.scheduleCloudSyncJob(uid, account); 77 | } 78 | } 79 | 80 | async scheduleCloudSyncJob(uid, account) { 81 | if (!account.config?.playlistSyncToWyCloudDisk?.autoSync?.enable) { 82 | return; 83 | } 84 | 85 | const isActive = await verifyAccountStatus(uid); 86 | if (!isActive) { 87 | logger.warn(`Account ${uid} is not active, skip cloud sync`); 88 | return; 89 | } 90 | 91 | const frequency = account.config.playlistSyncToWyCloudDisk.autoSync.frequency; 92 | const unit = account.config.playlistSyncToWyCloudDisk.autoSync.frequencyUnit; 93 | const jobKey = `cloudSync_${uid}`; 94 | 95 | const rule = this.buildScheduleRule(frequency, unit); 96 | 97 | this.jobs.set(jobKey, schedule.scheduleJob(rule, async () => { 98 | logger.info(`Start cloud sync for account ${uid}`); 99 | const playlists = await getUserAllPlaylist(uid); 100 | for (const playlist of playlists) { 101 | logger.info(`Start sync playlist ${playlist.id} to cloud for account ${uid}`); 102 | await unblockMusicInPlaylist(uid, sourceConsts.Netease.code, playlist.id, { 103 | syncWySong: account.config.playlistSyncToWyCloudDisk.syncWySong, 104 | syncNotWySong: account.config.playlistSyncToWyCloudDisk.syncNotWySong 105 | }); 106 | } 107 | })); 108 | logger.info(`Schedule cloud sync job for account ${uid} success, rule: ${this.formatScheduleRule(rule)}`); 109 | } 110 | 111 | buildScheduleRule(frequency, unit) { 112 | if (unit === 'minute') { 113 | return `0 */${frequency} * * * *`; 114 | } else if (unit === 'hour') { 115 | return `0 0 */${frequency} * * *`; 116 | } else { 117 | return `0 0 0 */${frequency} * *`; 118 | } 119 | } 120 | 121 | formatScheduleRule(rule) { 122 | if (typeof rule === 'string') { 123 | return rule; 124 | } 125 | return `每天 ${rule.hour.map(h => h.toString().padStart(2, '0') + ':00').join(', ')} 执行`; 126 | } 127 | 128 | async updateLocalSyncJob() { 129 | logger.info('Update local sync job'); 130 | // 添加调试日志 131 | logger.info('Before update:'); 132 | logger.info(`- Jobs map size: ${this.jobs.size}`); 133 | logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`); 134 | 135 | const localJob = this.jobs.get('localSync'); 136 | if (localJob) { 137 | localJob.cancel(); 138 | this.jobs.delete('localSync'); 139 | } 140 | 141 | // 添加调试日志 142 | logger.info('After cancel:'); 143 | logger.info(`- Jobs map size: ${this.jobs.size}`); 144 | logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`); 145 | 146 | await this.scheduleLocalSyncJobs(); 147 | 148 | // 添加调试日志 149 | logger.info('After reschedule:'); 150 | logger.info(`- Jobs map size: ${this.jobs.size}`); 151 | logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`); 152 | const newJob = this.jobs.get('localSync'); 153 | logger.info(`- New job created: ${!!newJob}`); 154 | if (newJob) { 155 | logger.info(`- New job next run: ${newJob.nextInvocation()}`); 156 | } 157 | 158 | logger.info('Update local sync job success'); 159 | } 160 | 161 | async updateCloudSyncJob(uid) { 162 | logger.info(`Update cloud sync job for account ${uid}`); 163 | // 取消指定账号的云盘同步任务 164 | const cloudJobKey = `cloudSync_${uid}`; 165 | const cloudJob = this.jobs.get(cloudJobKey); 166 | if (cloudJob) { 167 | cloudJob.cancel(); 168 | this.jobs.delete(cloudJobKey); 169 | logger.info(`Cancel cloud sync job for account ${uid}`); 170 | } 171 | // 重新调度指定账号的云盘同步任务 172 | const account = (await AccountService.getAllAccounts())[uid]; 173 | if (account) { 174 | await this.scheduleCloudSyncJob(uid, account); 175 | } 176 | logger.info(`Update cloud sync job for account ${uid} success`); 177 | } 178 | 179 | getNextRunInfo(job) { 180 | if (!job) return null; 181 | 182 | const nextRun = job.nextInvocation(); 183 | if (!nextRun) return null; 184 | 185 | const now = new Date(); 186 | const remainingMs = nextRun.getTime() - now.getTime(); 187 | 188 | return { 189 | nextRunTime: nextRun, 190 | remainingMs: remainingMs 191 | }; 192 | } 193 | 194 | getLocalSyncNextRun() { 195 | const job = this.jobs.get('localSync'); 196 | 197 | // 添加调试日志 198 | logger.info('Debug getLocalSyncNextRun:'); 199 | logger.info(`- Has job: ${!!job}`); 200 | logger.info(`- Jobs map size: ${this.jobs.size}`); 201 | logger.info(`- Jobs keys: ${Array.from(this.jobs.keys()).join(', ')}`); 202 | if (job) { 203 | logger.info(`- Job next invocation: ${job.nextInvocation()}`); 204 | logger.info(`- Job scheduling info:`, job.scheduledJobs); 205 | } 206 | 207 | return this.getNextRunInfo(job); 208 | } 209 | 210 | getCloudSyncNextRun(uid) { 211 | const job = this.jobs.get(`cloudSync_${uid}`); 212 | return this.getNextRunInfo(job); 213 | } 214 | } 215 | 216 | const schedulerService = new SchedulerService(); 217 | module.exports = schedulerService; -------------------------------------------------------------------------------- /backend/src/service/search_songs/find_the_best_match_from_wycloud.js: -------------------------------------------------------------------------------- 1 | const { searchSong, getSongInfo } = require('../music_platform/wycloud'); 2 | const logger = require('consola'); 3 | 4 | module.exports = async function findTheBestMatchFromWyCloud(uid, {songName, artist, album, musicPlatformSongId} = {}) { 5 | if (musicPlatformSongId) { 6 | const songInfo = await getSongInfo(uid, musicPlatformSongId); 7 | 8 | if (songInfo) { 9 | return songInfo; 10 | } 11 | 12 | if (songName && artist) { 13 | return { 14 | songId: musicPlatformSongId, 15 | songName, 16 | artists: [artist], 17 | album, 18 | }; 19 | } 20 | 21 | return null; 22 | } 23 | 24 | if (songName === "" || artist === "") { 25 | return null; 26 | } 27 | const searchLists = await searchSong(uid, songName, artist); 28 | logger.info('searchLists', searchLists); 29 | if (searchLists === false) { 30 | logger.warn(`search song failed, no matter, go on`); 31 | return null; 32 | } 33 | 34 | let matchSongAndArtist = null; 35 | for (const searchItem of searchLists) { 36 | let hitArtist = false; 37 | for (const searchArtist of searchItem.artists) { 38 | if (artist === searchArtist) { 39 | hitArtist = true; 40 | } 41 | } 42 | if (!hitArtist) { 43 | continue; 44 | } 45 | 46 | if (searchItem.songName === songName) { 47 | if (searchItem.album === album) { 48 | logger.info('matched the best') 49 | return searchItem; 50 | } 51 | if (!matchSongAndArtist) { 52 | matchSongAndArtist = searchItem; 53 | } 54 | } 55 | } 56 | return matchSongAndArtist; 57 | } 58 | -------------------------------------------------------------------------------- /backend/src/service/search_songs/index.js: -------------------------------------------------------------------------------- 1 | const { searchSongFromAllPlatform } = require('../media_fetcher'); 2 | const searchSongsWithSongMeta = require('./search_songs_with_song_meta'); 3 | const findTheBestMatchFromWyCloud = require('./find_the_best_match_from_wycloud'); 4 | 5 | async function searchSongsWithKeyword(keyword) { 6 | const searchList = await searchSongFromAllPlatform({keyword}); 7 | if (searchList === false || searchList.length === 0) { 8 | return []; 9 | } 10 | 11 | return searchList; 12 | } 13 | 14 | 15 | module.exports = { 16 | searchSongsWithSongMeta: searchSongsWithSongMeta, 17 | searchSongsWithKeyword: searchSongsWithKeyword, 18 | findTheBestMatchFromWyCloud: findTheBestMatchFromWyCloud, 19 | } -------------------------------------------------------------------------------- /backend/src/service/search_songs/search_songs_with_song_meta.js: -------------------------------------------------------------------------------- 1 | const logger = require('consola'); 2 | const { searchSongFromAllPlatform } = require('../media_fetcher'); 3 | 4 | module.exports = async function searchSongsWithSongMeta(songMeta = { 5 | songName: '', 6 | artist: '', 7 | album: '', 8 | duration: 0, 9 | }, options = { 10 | expectArtistAkas: [], // 歌手名字,有的歌手有很多别名的,给出这些信息能够更好地排序 11 | allowSongsJustMatchDuration: false, // 关键信息不对的情况下,但 duration 很接近的歌曲,是否希望返回 12 | allowSongsNotMatchMeta: false, // 关键的 meta 信息不匹配的歌曲,是否希望返回 13 | }) { 14 | // search song with the meta 15 | const searchList = await searchSongFromAllPlatform({ 16 | songName:songMeta.songName, 17 | artist: songMeta.artist, 18 | album: songMeta.album 19 | }); 20 | if (searchList === false || searchList.length === 0) { 21 | logger.error(`search song failed, songMeta: ${JSON.stringify(songMeta)}`); 22 | return false; 23 | } 24 | 25 | return sortOutTheSearchList(searchList, options.expectArtistAkas, { 26 | songName: songMeta.songName, 27 | duration: songMeta.duration, 28 | }, { 29 | allowSongsJustMatchDuration: options.allowSongsJustMatchDuration, 30 | allowSongsNotMatchMeta: options.allowSongsNotMatchMeta, 31 | }); 32 | } 33 | 34 | 35 | function sortOutTheSearchList(searchList, expectArtistAkas, songMeta = { 36 | songName: '', 37 | duration: 0, 38 | }, options = { 39 | allowSongsJustMatchDuration: false, 40 | allowSongsNotMatchMeta: false, 41 | }) { 42 | let searchListfilttered = []; 43 | let searchListfiltterJustWithDuration = []; 44 | let searchListNotMatchMeta = []; 45 | 46 | // filter with song name, artist first 47 | for (const searchItem of searchList) { 48 | if (searchItem.cannotDownload) { 49 | logger.info(`song cannot download, continue. searchItem: ${JSON.stringify(searchItem)}`); 50 | searchListNotMatchMeta.push(searchItem); 51 | continue; 52 | } 53 | 54 | const durationDiff = Math.abs(searchItem.duration - songMeta.duration); 55 | searchItem.durationDiff = durationDiff; 56 | 57 | if (searchItem.duration != 0 && durationDiff > 10) { 58 | searchListNotMatchMeta.push(searchItem); 59 | continue; 60 | } 61 | 62 | if (thereAreWordNotExistFromInputButInSearchResult(['cover', '伴奏', '翻唱', 'instrumental'], searchItem.songName, songMeta.songName)) { 63 | logger.info(`there are word not exist from input but in search result, continue. searchItem.songName: ${searchItem.songName}, songMeta.songName: ${songMeta.songName}`); 64 | searchListNotMatchMeta.push(searchItem); 65 | continue; 66 | } 67 | 68 | if (durationDiff <= 5) { 69 | searchListfiltterJustWithDuration.push(searchItem); 70 | searchListNotMatchMeta.push(searchItem); 71 | } 72 | 73 | if (searchItem.songName.replace(' ', '').indexOf(songMeta.songName.replace(' ', '')) === -1) { 74 | logger.info(`songName not matched, continue. ${searchItem.songName} vs ${songMeta.songName}`); 75 | searchListNotMatchMeta.push(searchItem); 76 | continue; 77 | } 78 | 79 | if (searchItem.fromMusicPlatform && expectArtistAkas.length > 0) { 80 | logger.info(`should find the artist:${searchItem.artist} from ${expectArtistAkas.join(',')}`); 81 | if (!expectArtistAkas.find(artist => artist === searchItem.artist)) { 82 | logger.info(`artist not matched, continue.`); 83 | searchListNotMatchMeta.push(searchItem); 84 | continue; 85 | } 86 | } 87 | 88 | searchListfilttered.push(searchItem); 89 | } 90 | if (options.allowSongsJustMatchDuration) { 91 | searchListfiltterJustWithDuration = searchListfiltterJustWithDuration.sort((a, b) => a.durationDiff - b.durationDiff); 92 | searchListfilttered.push(...searchListfiltterJustWithDuration); 93 | } 94 | if (options.allowSongsNotMatchMeta) { 95 | searchListfilttered.push(...searchListNotMatchMeta); 96 | } 97 | 98 | // uniq with song url 99 | const uniqedSearchList = []; 100 | for (const searchItem of searchListfilttered) { 101 | if (uniqedSearchList.find(item => item.url === searchItem.url)) { 102 | continue; 103 | } 104 | uniqedSearchList.push(searchItem); 105 | } 106 | 107 | // stable sort。 resourceForbidden 排在后面 108 | return uniqedSearchList.map((data, i) => { 109 | return {i, data} 110 | }).sort((a,b)=>{ 111 | if (a.data.resourceForbidden == b.data.resourceForbidden) { 112 | return a.i-b.i; 113 | } if (a.data.resourceForbidden) { 114 | return 1; 115 | } 116 | return -1 117 | }).map(d=> d.data) 118 | } 119 | 120 | 121 | function thereAreWordNotExistFromInputButInSearchResult(words, searchResultWord, inputWord) { 122 | searchResultWord = searchResultWord.toLowerCase(); 123 | inputWord = inputWord.toLowerCase(); 124 | for (const word of words) { 125 | if (searchResultWord.indexOf(word) !== -1 && inputWord.indexOf(word) === -1) { 126 | return true; 127 | } 128 | } 129 | return false; 130 | } -------------------------------------------------------------------------------- /backend/src/service/songs_info/index.js: -------------------------------------------------------------------------------- 1 | const { getPlayUrl } = require('../music_platform/wycloud'); 2 | 3 | async function getPlayUrlWithOptions(uid, source, songId) { 4 | // Only support netease now 5 | if (source !== 'netease') { 6 | return ''; 7 | } 8 | return await getPlayUrl(uid, songId); 9 | } 10 | 11 | 12 | module.exports = { 13 | getPlayUrlWithOptions: getPlayUrlWithOptions, 14 | } -------------------------------------------------------------------------------- /backend/src/service/sync_music/download_to_local.js: -------------------------------------------------------------------------------- 1 | const { fetchWithUrl, getMetaWithUrl } = require('../media_fetcher'); 2 | const { uploadSong, searchSong, matchAndFixCloudSong } = require('../music_platform/wycloud'); 3 | const logger = require('consola'); 4 | const sleep = require('../../utils/sleep'); 5 | const configManager = require('../config_manager'); 6 | const fs = require('fs'); 7 | const libPath = require('path'); 8 | const utilFs = require('../../utils/fs'); 9 | 10 | 11 | module.exports = { 12 | downloadFromLocalTmpPath: downloadFromLocalTmpPath, 13 | buildDestFilename: buildDestFilename, 14 | } 15 | 16 | async function downloadFromLocalTmpPath(tmpPath, songInfo = { 17 | songName: "", 18 | artist: "", 19 | album: "", 20 | }, playlistName = '', collectResponse) { 21 | const globalConfig = (await configManager.getGlobalConfig()); 22 | const downloadPath = globalConfig.downloadPath; 23 | if (!downloadPath) { 24 | logger.error(`download path not set`); 25 | return "IOFailed"; 26 | } 27 | const destPathAndFilename = buildDestFilename(globalConfig, songInfo, playlistName); 28 | const destPath = libPath.dirname(destPathAndFilename); 29 | // make sure the path is exist 30 | await utilFs.asyncMkdir(destPath, {recursive: true}); 31 | try { 32 | if (await utilFs.asyncFileExisted(destPathAndFilename)) { 33 | logger.info(`file already exists, remove it: ${destPathAndFilename}`); 34 | await utilFs.asyncUnlinkFile(destPathAndFilename) 35 | } 36 | await utilFs.asyncMoveFile(tmpPath, destPathAndFilename); 37 | } catch (err) { 38 | logger.error(`move file failed, ${tmpPath} -> ${destPathAndFilename}`, err); 39 | return "IOFailed"; 40 | } 41 | if (collectResponse !== undefined) { 42 | try { 43 | const md5Value = await utilFs.asyncMd5(destPathAndFilename); 44 | collectResponse['md5Value'] = md5Value; 45 | } catch (err) { 46 | logger.error(`md5 failed, ${destPathAndFilename}`, err); 47 | // don't return false, just log it 48 | } 49 | } 50 | logger.info(`download song success, path: ${destPathAndFilename}`); 51 | return true; 52 | } 53 | 54 | function buildDestFilename(globalConfig, songInfo, playlistName) { 55 | const downloadPath = globalConfig.downloadPath; 56 | let filename = (playlistName ? globalConfig.playlistSyncToLocal?.filenameFormat : globalConfig.filenameFormat) 57 | .replace(/{artist}/g, songInfo.artist ? songInfo.artist : 'Unknown') 58 | .replace(/{songName}/g, songInfo.songName ? songInfo.songName : 'Unknown') 59 | .replace(/{playlistName}/g, playlistName ? playlistName : 'UnknownPlayList') 60 | .replace(/{album}/g, songInfo.album ? songInfo.album : 'Unknown'); 61 | // remove the head / and \ in filename 62 | filename = filename.replace(/^[\/\\]+/, '') + '.mp3'; 63 | return `${downloadPath}${libPath.sep}${filename}` 64 | } -------------------------------------------------------------------------------- /backend/src/service/sync_music/index.js: -------------------------------------------------------------------------------- 1 | const syncSingleSongWithUrl = require('./sync_single_song_with_url'); 2 | const unblockMusicInPlaylist = require('./unblock_music_in_playlist'); 3 | const unblockMusicWithSongId = require('./unblock_music_with_song_id'); 4 | const syncPlaylist = require('./sync_playlist'); 5 | 6 | module.exports = { 7 | syncSingleSongWithUrl: syncSingleSongWithUrl, 8 | unblockMusicInPlaylist: unblockMusicInPlaylist, 9 | unblockMusicWithSongId: unblockMusicWithSongId, 10 | syncPlaylist: syncPlaylist, 11 | }; -------------------------------------------------------------------------------- /backend/src/service/sync_music/sync_single_song_with_url.js: -------------------------------------------------------------------------------- 1 | const { fetchWithUrl, getMetaWithUrl } = require('../media_fetcher'); 2 | const logger = require('consola'); 3 | const sleep = require('../../utils/sleep'); 4 | const findTheBestMatchFromWyCloud = require('../search_songs/find_the_best_match_from_wycloud'); 5 | const JobManager = require('../job_manager'); 6 | const JobStatus = require('../../consts/job_status'); 7 | const JobType = require('../../consts/job_type'); 8 | const configManager = require('../config_manager'); 9 | const fs = require('fs'); 10 | const libPath = require('path'); 11 | const utilFs = require('../../utils/fs'); 12 | const { downloadFromLocalTmpPath } = require('./download_to_local'); 13 | const uploadWithRetryThenMatch = require('./upload_to_wycloud_disk_with_retry_then_match'); 14 | 15 | module.exports = async function syncSingleSongWithUrl(uid, url, { 16 | songName = "", 17 | artist = "", 18 | album = "", 19 | songFromWyCloud = null 20 | } = {}, jobId = 0, jobType = JobType.SyncSongFromUrl, playlistName = "", collectRet) { 21 | // step 1. fetch song info 22 | const songInfo = await getMetaWithUrl(url); 23 | logger.info(songInfo); 24 | if (songInfo === false || songInfo.isTrial) { 25 | logger.error(`fetch song info failed or it's a trial song. ${JSON.stringify(songInfo)}`); 26 | return false; 27 | } 28 | 29 | await updateJobIfNeed(uid, jobId, songInfo, jobType); 30 | 31 | // step 2. find the best match from wycloud 32 | if (songFromWyCloud === null) { 33 | let findSongName, findArtist, findAlbum; 34 | if (songName !== "" && artist !== "") { 35 | logger.info(`use the user input song name and artist, ${songName}, ${artist}, ${album}`); 36 | findSongName = songName; 37 | findArtist = artist; 38 | findAlbum = album; 39 | } else if (songInfo.fromMusicPlatform) { 40 | findSongName = songInfo.songName; 41 | findArtist = songInfo.artist; 42 | findAlbum = songInfo.album; 43 | } 44 | songFromWyCloud = await findTheBestMatchFromWyCloud(uid, { 45 | songName: findSongName, 46 | artist: findArtist, 47 | album: findAlbum, 48 | }); 49 | } else { 50 | logger.info(`use the songFromWyCloud by params`); 51 | } 52 | 53 | logger.info('songFromWyCloud:', songFromWyCloud); 54 | 55 | // step 3. download 56 | // should add meta tag if not matched song on wycloud 57 | const path = await fetchWithUrl(url, {songName: songInfo.songName, addMediaTag: songFromWyCloud ? false : true}); 58 | if (path === false) { 59 | return false; 60 | } 61 | 62 | // step 4. upload or download 63 | logger.info(`handle song start: ${path}`); 64 | 65 | if (jobType === JobType.DownloadSongFromUrl || jobType === JobType.SyncThePlaylistToLocalService) { 66 | return await downloadFromLocalTmpPath(path, songInfo, playlistName, collectRet); 67 | } else { 68 | return await uploadWithRetryThenMatch(uid, path, songInfo, songFromWyCloud); 69 | } 70 | } 71 | 72 | async function updateJobIfNeed(uid, jobId, songInfo, jobType) { 73 | if (!jobId) { 74 | return; 75 | } 76 | const operation = jobType === JobType.SyncSongFromUrl ? "上传" : "下载"; 77 | await JobManager.updateJob(uid, jobId, { 78 | name: `${operation}歌曲:${songInfo.songName}`, 79 | status: JobStatus.InProgress, 80 | desc: `歌曲: ${songInfo.songName}`, 81 | tip: "任务开始", 82 | }); 83 | } -------------------------------------------------------------------------------- /backend/src/service/sync_music/unblock_music_in_playlist.js: -------------------------------------------------------------------------------- 1 | const { getSongsFromPlaylist, getPlayUrl } = require('../music_platform/wycloud'); 2 | const syncSingleSongWithUrl = require('./sync_single_song_with_url'); 3 | const logger = require('consola'); 4 | const { findTheBestMatchFromWyCloud, searchSongsWithSongMeta } = require('../search_songs'); 5 | const JobManager = require('../job_manager'); 6 | const JobType = require('../../consts/job_type'); 7 | const JobStatus = require('../../consts/job_status'); 8 | const SoundQuality = require('../../consts/sound_quality'); 9 | const BusinessCode = require('../../consts/business_code'); 10 | const AccountService = require('../account'); 11 | const { downloadViaSourceUrl } = require("../media_fetcher/index"); 12 | const uploadWithRetryThenMatch = require('./upload_to_wycloud_disk_with_retry_then_match'); 13 | const asyncFS = require('../../utils/fs'); 14 | 15 | // scope: 16 | // 1. for not wy song: download from network then upload to cloud disk 17 | // 2. for wy song: download from wy then upload to cloud disk. (i.e. backup wy song to cloud disk) 18 | module.exports = async function unblockMusicInPlaylist(uid, source, playlistId, options = { 19 | syncWySong: false, 20 | syncNotWySong: false, 21 | asyncExecute: true, 22 | }) { 23 | // step 1. get songs 24 | const songsInfo = await getSongsFromPlaylist(uid, source, playlistId); 25 | if (songsInfo === false) { 26 | return false; 27 | } 28 | 29 | if (songsInfo.songs.length === 0) { 30 | return false; 31 | } 32 | 33 | const songsNeedToSync = []; 34 | songsInfo.songs.forEach(song => { 35 | if (song.isCloud) { 36 | return 37 | } 38 | // block song 39 | if (song.isBlocked) { 40 | if (options.syncNotWySong) { 41 | songsNeedToSync.push(song); 42 | } 43 | } else { 44 | // wy song 45 | if (options.syncWySong) { 46 | songsNeedToSync.push(song); 47 | } 48 | } 49 | }); 50 | if (songsNeedToSync.length === 0) { 51 | return BusinessCode.StatusNoNeedToSync; 52 | } 53 | 54 | // create job 55 | const args = `unblockMusicInPlaylist: {"source":${source},"playlistId":${playlistId}}`; 56 | if (await JobManager.findActiveJobByArgs(uid, args)) { 57 | logger.info(`unblock music in playlist job is already running.`); 58 | return BusinessCode.StatusJobAlreadyExisted; 59 | } 60 | const jobId = await JobManager.createJob(uid, { 61 | name: `解锁歌单:${songsInfo.name}`, 62 | args, 63 | type: JobType.UnblockedPlaylist, 64 | status: JobStatus.Pending, 65 | desc: `有${songsNeedToSync.length}首歌曲需要解锁`, 66 | progress: 0, 67 | tip: "等待解锁", 68 | createdAt: Date.now() 69 | }); 70 | 71 | // async do the job 72 | const job = (async () => { 73 | const songs = songsNeedToSync; 74 | logger.info(`${jobId}: try to unblock songs: ${JSON.stringify(songs)}`); 75 | await JobManager.updateJob(uid, jobId, { 76 | status: JobStatus.InProgress, 77 | }); 78 | const succeedList = []; 79 | const failedList = []; 80 | // step 2. download the songs and upload to cloud 81 | for (const song of songs) { 82 | let tip = `[${(succeedList.length + failedList.length + 1)}/${songs.length}] 正在解锁歌曲:${song.songName}`; 83 | await JobManager.updateJob(uid, jobId, { 84 | tip, 85 | }); 86 | const syncSucceed = await syncSingleSongWithMeta(uid, song); 87 | if (syncSucceed) { 88 | await JobManager.updateJob(uid, jobId, { 89 | log: song.songName + ": 解锁成功", 90 | }); 91 | succeedList.push({songName: song.songName, artist: song.artists[0]}); 92 | } else { 93 | await JobManager.updateJob(uid, jobId, { 94 | log: song.songName + ": 解锁失败", 95 | }); 96 | failedList.push({songName: song.songName, artist: song.artists[0]}); 97 | } 98 | await JobManager.updateJob(uid, jobId, { 99 | progress: (succeedList.length + failedList.length) / songs.length, 100 | }); 101 | } 102 | 103 | let tip = `任务完成,成功${succeedList.length}首,失败${failedList.length}首`; 104 | await JobManager.updateJob(uid, jobId, { 105 | progress: 1, 106 | status: succeedList.length > 0 ? JobStatus.Finished : JobStatus.Failed, 107 | tip, 108 | data: { 109 | succeedList, 110 | failedList, 111 | } 112 | }); 113 | })().catch(async e => { 114 | logger.error(`${jobId}: ${e}`); 115 | let tip = '遇到不可思议的错误了哦,任务终止'; 116 | await JobManager.updateJob(uid, jobId, { 117 | status: JobStatus.Failed, 118 | tip, 119 | }); 120 | }); 121 | 122 | // For sync execution, wait for job completion 123 | if (!options.asyncExecute) { 124 | await job; 125 | } 126 | 127 | return jobId; 128 | } 129 | 130 | async function syncSingleSongWithMeta(uid, wySongMeta) { 131 | logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`); 132 | // 获取 wycloud 的歌曲信息,有 id 就直接 get,没有就 search meta 选一个最匹配的 133 | const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, { 134 | songName: wySongMeta.songName, 135 | artist: wySongMeta.artists[0], 136 | album: wySongMeta.album, 137 | musicPlatformSongId: wySongMeta.songId, 138 | }); 139 | 140 | // Case 1: download the song from wy 141 | if (!wySongMeta.isBlocked) { 142 | const account = AccountService.getAccount(uid); 143 | const playUrl = await getPlayUrl(uid, wySongMeta.songId, account.config.playlistSyncToWyCloudDisk.soundQualityPreference === SoundQuality.Lossless); 144 | // if the playUrl is empty, we think the song is block as well. go through the search process 145 | if (playUrl) { 146 | const tmpPath = await downloadViaSourceUrl(playUrl); 147 | // if download failed, we think due to network issue, just return false. It will retry in the next time 148 | if (tmpPath === false) { 149 | return false; 150 | } 151 | 152 | // add some magic 153 | try { 154 | await asyncFS.asyncAppendFile(tmpPath, '00000'); 155 | } catch (e) { 156 | logger.error(`append file failed: ${tmpPath}`); 157 | // 追加失败,可以继续 158 | } 159 | 160 | const isSucceed = await uploadWithRetryThenMatch(uid, tmpPath, null, songFromWyCloud); 161 | 162 | if (isSucceed === true) { 163 | return true; 164 | } 165 | return false; 166 | } 167 | } 168 | 169 | // Case 2: search songs with the meta in the internet then upload to cloud 170 | const searchListfilttered = await searchSongsWithSongMeta({ 171 | songName: wySongMeta.songName, 172 | artist: wySongMeta.artists[0], 173 | album: wySongMeta.album, 174 | duration: wySongMeta.duration, 175 | }, { 176 | expectArtistAkas: songFromWyCloud.artists ? songFromWyCloud.artists : [], 177 | allowSongsJustMatchDuration: false, 178 | allowSongsNotMatchMeta: false, 179 | }); 180 | 181 | logger.info(`use the searchListfilttered: ${JSON.stringify(searchListfilttered)}`); 182 | if (searchListfilttered === false) { 183 | return false; 184 | } 185 | 186 | // find the best match song 187 | for (const searchItem of searchListfilttered) { 188 | logger.info(`try to the search item: ${JSON.stringify(searchItem)}`); 189 | 190 | const isUploadSucceed = await syncSingleSongWithUrl(uid, searchItem.url, { 191 | songName: wySongMeta.songName, 192 | artist: wySongMeta.artists[0], 193 | album: wySongMeta.album, 194 | songFromWyCloud, 195 | }); 196 | if (isUploadSucceed === "IOFailed") { 197 | logger.error(`not try others due to upload failed.`); 198 | return false; 199 | } 200 | if (isUploadSucceed) { 201 | return true; 202 | } 203 | } 204 | return false; 205 | } 206 | -------------------------------------------------------------------------------- /backend/src/service/sync_music/unblock_music_with_song_id.js: -------------------------------------------------------------------------------- 1 | const { getSongInfo } = require('../music_platform/wycloud'); 2 | const syncSingleSongWithUrl = require('./sync_single_song_with_url'); 3 | const logger = require('consola'); 4 | const { findTheBestMatchFromWyCloud, searchSongsWithSongMeta } = require('../search_songs'); 5 | const JobManager = require('../job_manager'); 6 | const JobType = require('../../consts/job_type'); 7 | const JobStatus = require('../../consts/job_status'); 8 | const BusinessCode = require('../../consts/business_code'); 9 | 10 | module.exports = async function unblockMusiWithSongId(uid, source, songId) { 11 | const songInfo = await getSongInfo(uid, songId); 12 | if (songInfo === false) { 13 | return false; 14 | } 15 | 16 | // create job 17 | const args = `unblockMusicWithSongId: {"source":${source},"songId":${songId}}`; 18 | if (await JobManager.findActiveJobByArgs(uid, args)) { 19 | logger.info(`unblock music with songID job is already running.`); 20 | return BusinessCode.StatusJobAlreadyExisted; 21 | } 22 | const jobId = await JobManager.createJob(uid, { 23 | name: `解锁歌曲:${songInfo.songName}`, 24 | args, 25 | type: JobType.UnblockedSong, 26 | status: JobStatus.Pending, 27 | desc: `${songInfo.songName} - ${songInfo.artists.join(',')}`, 28 | progress: 0, 29 | tip: "等待解锁", 30 | createdAt: Date.now() 31 | }); 32 | 33 | // async do the job 34 | (async () => { 35 | logger.info(`${jobId}: try to unblock song: ${JSON.stringify(songInfo)}`); 36 | // download the songs and upload to cloud 37 | const syncSucceed = await syncSingleSongWithMeta(uid, songInfo); 38 | 39 | let tip = songInfo.songName + (syncSucceed ? ": 解锁成功" : ": 解锁失败"); 40 | await JobManager.updateJob(uid, jobId, { 41 | progress: 1, 42 | status: syncSucceed ? JobStatus.Finished : JobStatus.Failed, 43 | tip, 44 | data: {} 45 | }); 46 | })().catch(async e => { 47 | logger.error(`${jobId}: ${e}`); 48 | let tip = '遇到不可思议的错误了哦,任务终止'; 49 | await JobManager.updateJob(uid, jobId, { 50 | status: JobStatus.Failed, 51 | tip, 52 | }); 53 | }) 54 | 55 | return jobId; 56 | } 57 | 58 | async function syncSingleSongWithMeta(uid, wySongMeta) { 59 | logger.info(`sync single song with meta: ${JSON.stringify(wySongMeta)}`); 60 | const songFromWyCloud = await findTheBestMatchFromWyCloud(uid, { 61 | songName: wySongMeta.songName, 62 | artist: wySongMeta.artists[0], 63 | album: wySongMeta.album, 64 | musicPlatformSongId: wySongMeta.songId, 65 | }); 66 | // search songs with the meta 67 | const searchListfilttered = await searchSongsWithSongMeta({ 68 | songName: wySongMeta.songName, 69 | artist: wySongMeta.artists[0], 70 | album: wySongMeta.album, 71 | duration: wySongMeta.duration, 72 | }, { 73 | expectArtistAkas: songFromWyCloud.artists ? songFromWyCloud.artists : [], 74 | allowSongsJustMatchDuration: false, 75 | allowSongsNotMatchMeta: false, 76 | }); 77 | 78 | logger.info(`use the searchListfilttered: ${JSON.stringify(searchListfilttered)}`); 79 | if (searchListfilttered === false) { 80 | return false; 81 | } 82 | 83 | // find the best match song 84 | for (const searchItem of searchListfilttered) { 85 | logger.info(`try to the search item: ${JSON.stringify(searchItem)}`); 86 | 87 | const isUploadSucceed = await syncSingleSongWithUrl(uid, searchItem.url, { 88 | songName: wySongMeta.songName, 89 | artist: wySongMeta.artists[0], 90 | album: wySongMeta.album, 91 | songFromWyCloud, 92 | }); 93 | if (isUploadSucceed === "IOFailed") { 94 | logger.error(`not try others due to upload failed.`); 95 | return false; 96 | } 97 | if (isUploadSucceed) { 98 | return true; 99 | } 100 | } 101 | return false; 102 | } 103 | -------------------------------------------------------------------------------- /backend/src/service/sync_music/upload_to_wycloud_disk_with_retry_then_match.js: -------------------------------------------------------------------------------- 1 | const { uploadSong, matchAndFixCloudSong } = require('../music_platform/wycloud'); 2 | const logger = require('consola'); 3 | const fs = require('fs'); 4 | const sleep = require('../../utils/sleep'); 5 | 6 | module.exports = async function uploadWithRetryThenMatch(uid, path, songInfo, songFromWyCloud) { 7 | const startTime = new Date(); 8 | let isHandleSucceed = false; 9 | let uploadResult; 10 | 11 | for (let tryCount = 0; tryCount < 5; tryCount++) { 12 | if (tryCount !== 0) { 13 | logger.info(`upload song failed, try again: ${path}`); 14 | } 15 | uploadResult = await uploadSong(uid, path); 16 | if (uploadResult === false) { 17 | logger.error(`upload song failed, uid: ${uid}, path: ${path}`); 18 | await sleep(3000); 19 | continue; 20 | } else { 21 | isHandleSucceed = true; 22 | break; 23 | } 24 | } 25 | 26 | // del file async 27 | fs.unlink(path, () => {}); 28 | 29 | if (!isHandleSucceed) { 30 | logger.error(`upload song failed, uid: ${uid}, path: ${path}`); 31 | return "IOFailed"; 32 | } 33 | 34 | const costSeconds = (new Date() - startTime) / 1000; 35 | logger.info(`upload song success, uid: ${uid}, path: ${path}, cost: ${costSeconds}s`); 36 | 37 | if (uploadResult.matched) { 38 | logger.info(`matched song already, uid: ${uid}, songId: ${uploadResult.songId}. ignore.`); 39 | return true; 40 | } 41 | 42 | // fix match manually IF not matched in music platform 43 | if (!songFromWyCloud) { 44 | logger.info(`would not try to match from wycloud!!! uid: ${uid}, ${JSON.stringify(songInfo)}`); 45 | return true; 46 | } 47 | const matchResult = await matchAndFixCloudSong(uid, uploadResult.songId, songFromWyCloud.songId); 48 | logger.info(`match song ${matchResult ? 'success' : 'failed'}, uid: ${uid}, songId: ${uploadResult.songId}, wySongId: ${songFromWyCloud.songId}`); 49 | return true; 50 | } -------------------------------------------------------------------------------- /backend/src/utils/cmd.js: -------------------------------------------------------------------------------- 1 | const logger = require("consola"); 2 | const spawnAsync = require("child_process").spawn; 3 | 4 | module.exports = function exec(exe, args) { 5 | return new Promise(function (resolve, reject) { 6 | console.log(exe, args); 7 | const process = spawnAsync(exe, args); 8 | let stdout = ""; 9 | let stderr = ""; 10 | 11 | process.stdout.on("data", function (data) { 12 | stdout += data; 13 | }); 14 | process.stderr.on("data", function (data) { 15 | stderr += data; 16 | }); 17 | 18 | process.on("close", function (code) { 19 | resolve({ 20 | code: stderr ? code : 0, 21 | message: stderr ? stderr: stdout, 22 | }); 23 | }); 24 | process.on("error", function (error) { 25 | logger.error('exec error: ', error); 26 | resolve({ 27 | code: -1, 28 | message: error.message, 29 | }); 30 | }); 31 | }); 32 | } -------------------------------------------------------------------------------- /backend/src/utils/download.js: -------------------------------------------------------------------------------- 1 | const got = require('got'); 2 | const fs = require('fs'); 3 | const pipeline = require('stream').pipeline; 4 | const { promisify } = require('util'); 5 | const streamPipeline = promisify(pipeline); 6 | 7 | 8 | module.exports = async function downloadFile(url, destination) { 9 | try { 10 | await streamPipeline( 11 | got.stream(url), 12 | fs.createWriteStream(destination) 13 | ); 14 | return true; 15 | } catch (error) { 16 | console.error('download failed:', error); 17 | return false; 18 | } 19 | 20 | } -------------------------------------------------------------------------------- /backend/src/utils/fs.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const crypto = require('crypto'); 3 | 4 | function asyncReadFile(filePath) { 5 | return new Promise((resolve, reject) => { 6 | fs.readFile(filePath, (err, data) => { 7 | if (err) { 8 | reject(err); 9 | return; 10 | } 11 | resolve(data); 12 | }); 13 | }); 14 | } 15 | 16 | function asyncWriteFile(filePath, data) { 17 | return new Promise((resolve, reject) => { 18 | fs.writeFile(filePath, data, (err) => { 19 | if (err) { 20 | reject(err); 21 | return; 22 | } 23 | resolve(); 24 | }); 25 | }); 26 | } 27 | 28 | function asyncFileExisted(filePath) { 29 | return new Promise((resolve, reject) => { 30 | fs.access(filePath, fs.constants.F_OK, (err) => { 31 | if (err) { 32 | resolve(false); 33 | return; 34 | } 35 | resolve(true); 36 | }); 37 | }); 38 | } 39 | 40 | function asyncMkdir(dirPath, options) { 41 | return new Promise((resolve, reject) => { 42 | fs.mkdir(dirPath, options, (err) => { 43 | if (err) { 44 | reject(err); 45 | return; 46 | } 47 | resolve(); 48 | }); 49 | }); 50 | } 51 | 52 | function asyncUnlinkFile(filePath) { 53 | return new Promise((resolve, reject) => { 54 | fs.unlink(filePath, (err) => { 55 | if (err) { 56 | reject(err); 57 | return; 58 | } 59 | resolve(); 60 | }); 61 | }); 62 | } 63 | 64 | const fsPromise = fs.promises; 65 | async function asyncMoveFile(oldPath, newPath) { 66 | await fsPromise.copyFile(oldPath, newPath) 67 | await fsPromise.unlink(oldPath); 68 | } 69 | 70 | function asyncReadDir(dirPath) { 71 | return new Promise((resolve, reject) => { 72 | fs.readdir(dirPath, (err, files) => { 73 | if (err) { 74 | reject(err); 75 | return; 76 | } 77 | resolve(files); 78 | } 79 | )}); 80 | } 81 | 82 | async function asyncMd5(filePath) { 83 | return new Promise((resolve, reject) => { 84 | const hash = crypto.createHash('md5'); 85 | const stream = fs.createReadStream(filePath); 86 | 87 | stream.on('data', (data) => { 88 | hash.update(data); 89 | }); 90 | 91 | stream.on('end', () => { 92 | resolve(hash.digest('hex')); 93 | }); 94 | 95 | stream.on('error', (error) => { 96 | reject(error); 97 | }); 98 | }); 99 | } 100 | 101 | async function asyncAppendFile(filePath, str) { 102 | return new Promise((resolve, reject) => { 103 | fs.appendFile(filePath, str, (err) => { 104 | if (err) { 105 | reject(err); 106 | return; 107 | } 108 | resolve(); 109 | }); 110 | }); 111 | } 112 | 113 | 114 | module.exports = { 115 | asyncReadFile, 116 | asyncWriteFile, 117 | asyncFileExisted, 118 | asyncMkdir, 119 | asyncUnlinkFile, 120 | asyncMoveFile, 121 | asyncReadDir, 122 | asyncMd5, 123 | asyncAppendFile, 124 | }; -------------------------------------------------------------------------------- /backend/src/utils/network.js: -------------------------------------------------------------------------------- 1 | const https = require('https'); 2 | 3 | function asyncHttpsGet(url) { 4 | return new Promise((resolve) => { 5 | https.get(url, res => { 6 | let data = ''; 7 | 8 | res.on('data', chunk => { 9 | data += chunk; 10 | }); 11 | 12 | res.on('end', () => { 13 | resolve(data.toString()); 14 | }); 15 | 16 | }).on('error', err => { 17 | console.error(err); 18 | resolve(null); 19 | }); 20 | }); 21 | } 22 | 23 | module.exports = { 24 | asyncHttpsGet 25 | } -------------------------------------------------------------------------------- /backend/src/utils/regex.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | matchUrlFromStr: (str) => { 3 | const matched = str.match(/https?:\/\/(www\.)?[-a-zA-Z0-9@:%._\+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_\+.~#?&//=]*)/); 4 | if (!matched) { 5 | return false; 6 | } 7 | return matched[0]; 8 | } 9 | } -------------------------------------------------------------------------------- /backend/src/utils/simple_locker.js: -------------------------------------------------------------------------------- 1 | const lockMap = {}; 2 | const sleep = require('./sleep'); 3 | 4 | async function lock(key, expireSeconds = 20) { 5 | let retryCount = 20; 6 | while (--retryCount >= 0) { 7 | if (!lockMap[key]) { 8 | lockMap[key] = true; 9 | 10 | setTimeout(() => { 11 | delete lockMap[key]; 12 | }, expireSeconds * 1000); 13 | 14 | return true; 15 | } 16 | await sleep(200); 17 | } 18 | 19 | return false; 20 | } 21 | 22 | function unlock(key) { 23 | delete lockMap[key]; 24 | } 25 | 26 | 27 | module.exports = { 28 | lock: lock, 29 | unlock: unlock, 30 | }; -------------------------------------------------------------------------------- /backend/src/utils/sleep.js: -------------------------------------------------------------------------------- 1 | module.exports = function sleep(ms) { 2 | return new Promise(resolve => setTimeout(resolve, ms)); 3 | } -------------------------------------------------------------------------------- /backend/src/utils/uuid.js: -------------------------------------------------------------------------------- 1 | const { v4: uuidv4 } = require('uuid'); 2 | 3 | module.exports = function () { 4 | return uuidv4().replace(/-/g, ''); 5 | } -------------------------------------------------------------------------------- /frontend/.env.development: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'development' 2 | VITE_APP_MODE = 'development' 3 | VITE_APP_API_URL = 'http://172.16.252.1:5566/api' 4 | VITE_APP_API_URL = 'http://10.0.0.2:5566/api' 5 | VITE_APP_API_URL = 'http://127.0.0.1:5566/api' 6 | -------------------------------------------------------------------------------- /frontend/.env.production: -------------------------------------------------------------------------------- 1 | NODE_ENV = 'production' 2 | VITE_APP_MODE = 'production' 3 | VITE_APP_API_URL = '/api' -------------------------------------------------------------------------------- /frontend/.nvmrc: -------------------------------------------------------------------------------- 1 | v16.13.0 -------------------------------------------------------------------------------- /frontend/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | Melody - 我的音乐精灵 12 | 13 | 14 | 15 | 16 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /frontend/mobile.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Melody - 我的音乐精灵 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 26 | 27 | 30 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "melody-frontend", 3 | "private": true, 4 | "version": "0.1.0", 5 | "scripts": { 6 | "dev": "vite", 7 | "build": "vite build", 8 | "preview": "vite preview" 9 | }, 10 | "dependencies": { 11 | "@element-plus/icons-vue": "^1.1.4", 12 | "axios": "0.26.1", 13 | "element-plus": "2.1.9", 14 | "howler": "github:foamzou/howler.js#0.0.1-foam", 15 | "vant": "^3.4.9", 16 | "vue": "3.2.33", 17 | "vue-router": "4.0.12", 18 | "vue-virtual-scroller": "2.0.0-alpha.1", 19 | "vuex": "4.0.2" 20 | }, 21 | "devDependencies": { 22 | "@vitejs/plugin-vue": "^2.3.1", 23 | "rollup": "^2.70.2", 24 | "vite": "^2.9.14", 25 | "vite-plugin-cdn-import": "^0.3.5", 26 | "vite-plugin-pwa": "^0.12.3", 27 | "workbox-window": "^6.5.4" 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/github-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/frontend/public/github-logo.png -------------------------------------------------------------------------------- /frontend/public/melody-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/frontend/public/melody-192x192.png -------------------------------------------------------------------------------- /frontend/public/melody-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/frontend/public/melody-512x512.png -------------------------------------------------------------------------------- /frontend/public/melody.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/frontend/public/melody.png -------------------------------------------------------------------------------- /frontend/src/Mobile.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | Melody 10 | 11 | 12 | 我的音乐精灵 13 | 14 | 15 | 16 | 34 | 35 | 41 | 47 | 48 | 49 | 50 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 78 | 83 | 84 | 85 | 86 | 搜索 89 | 歌单 92 | 音乐账号 95 | 96 | 97 | 98 | 99 | 198 | 199 | 235 | -------------------------------------------------------------------------------- /frontend/src/api/axios.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | const axiosApiInstance = axios.create(); 3 | import storage from "../utils/storage" 4 | 5 | axiosApiInstance.defaults.baseURL = import.meta.env.VITE_APP_API_URL 6 | 7 | //post请求头 8 | //允许跨域携带cookie信息 9 | axiosApiInstance.defaults.withCredentials = true; 10 | //设置超时 11 | axiosApiInstance.defaults.timeout = 12000; 12 | 13 | axiosApiInstance.interceptors.request.use( 14 | config => { 15 | config.headers = { 16 | 'mk': (config.params && config.params['mk']) ? config.params['mk'] : storage.get('mk') 17 | } 18 | return config; 19 | }, 20 | error => { 21 | return Promise.reject(error); 22 | } 23 | ); 24 | 25 | axiosApiInstance.interceptors.response.use( 26 | response => { 27 | return Promise.resolve(response); 28 | }, 29 | error => { 30 | // 返回错误响应中的数据 31 | if (error.response && error.response.data) { 32 | return Promise.resolve(error.response); 33 | } 34 | // 如果没有response.data,返回一个统一的错误格式 35 | return Promise.resolve({ 36 | data: { 37 | code: -1, 38 | message: error.message || '网络错误' 39 | } 40 | }); 41 | } 42 | ); 43 | 44 | export const post = (url, data) => { 45 | return new Promise((resolve, reject) => { 46 | axiosApiInstance({ 47 | method: 'post', 48 | url, 49 | data, 50 | }) 51 | .then(res => { 52 | resolve(res ? res.data : false) 53 | }) 54 | .catch(err => { 55 | reject(err.data) 56 | }); 57 | }) 58 | }; 59 | 60 | export const get = (url, data) => { 61 | return new Promise((resolve, reject) => { 62 | axiosApiInstance({ 63 | method: 'get', 64 | url, 65 | params: data, 66 | }) 67 | .then(res => { 68 | resolve(res ? res.data : false) 69 | }) 70 | .catch(err => { 71 | reject(err) 72 | }) 73 | }) 74 | } -------------------------------------------------------------------------------- /frontend/src/api/index.js: -------------------------------------------------------------------------------- 1 | import { get, post} from "./axios"; 2 | 3 | export const searchSongs = data => get("/songs", data); 4 | export const getSongsMeta = data => get("/songs-meta", data); 5 | export const getPlayUrl = songId => get(`/songs/netease/${songId}/playUrl`); 6 | 7 | export const getAccount = data => get("/account", data); 8 | export const setAccount = data => post("/account", data); 9 | export const qrLoginCreate = _ => get("/account/qrlogin-create", {}); 10 | export const qrLoginCheck = qrKey => get("/account/qrlogin-check", {qrKey}); 11 | 12 | export const getAllPlaylist = data => get("/playlists", data); 13 | export const getPlaylistDetail = playlistId => get(`/playlists/netease/${playlistId}/songs`); 14 | export const getJobDetail = jobId => get(`/sync-jobs/${jobId}`); 15 | export const createSyncSongFromUrlJob = (url, songId = "") => { 16 | return post("/sync-jobs", { 17 | "jobType": "SyncSongFromUrl", 18 | "urlJob": { 19 | "url": url, 20 | "meta": { 21 | "songId": songId 22 | } 23 | } 24 | }); 25 | }; 26 | export const createDownloadSongFromUrlJob = (url, songId = "") => { 27 | return post("/sync-jobs", { 28 | "jobType": "DownloadSongFromUrl", 29 | "urlJob": { 30 | "url": url, 31 | "meta": { 32 | "songId": songId 33 | } 34 | } 35 | }); 36 | }; 37 | export const createSyncSongFromPlaylistJob = (playlistId, options) => { 38 | return post("/sync-jobs", { 39 | "jobType": "UnblockedPlaylist", 40 | "playlist": { 41 | "id": playlistId, 42 | "source": "netease" 43 | }, 44 | "options": options 45 | }); 46 | }; 47 | export const createSyncThePlaylistToLocalServiceJob = (playlistId) => { 48 | return post("/sync-jobs", { 49 | "jobType": "SyncThePlaylistToLocalService", 50 | "playlist": { 51 | "id": playlistId, 52 | "source": "netease" 53 | } 54 | }); 55 | }; 56 | export const createSyncSongWithSongIdJob = (songId) => { 57 | return post("/sync-jobs", { 58 | "jobType": "UnblockedSong", 59 | "songId": songId, 60 | "source": "netease" 61 | }); 62 | }; 63 | 64 | export const checkMediaFetcherLib = data => get("/media-fetcher-lib/version-check", data); 65 | export const updateMediaFetcherLib = (version) => { 66 | return post("/media-fetcher-lib/update", { 67 | "version": version, 68 | }); 69 | }; 70 | 71 | export const getGlobalConfig = _ => get("/config/global", {}); 72 | export const setGlobalConfig = (config) => { 73 | return post("/config/global", config); 74 | }; 75 | 76 | export const getAllAccounts = _ => get("/accounts", {}); 77 | 78 | export const getNextRunInfo = () => get("/scheduler/next-run", {}); 79 | -------------------------------------------------------------------------------- /frontend/src/assets/logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/frontend/src/assets/logo.png -------------------------------------------------------------------------------- /frontend/src/components/Player.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | 11 | 12 | 13 | {{ keepSomeText(currentSong.songName, 22) }} 14 | 15 | 16 | 17 | {{ currentSong.artist }} 18 | 19 | 20 | 21 | 22 | 28 | 34 | 35 | 36 | 44 | 45 | 49 | 50 | 51 | 52 | 53 | 54 | {{ 55 | secondDurationToDisplayDuration(currentSeek, true) 56 | }} 57 | 58 | 65 | 66 | {{ secondDurationToDisplayDuration(totalTime) }} 71 | 72 | 73 | 74 | 75 | 85 | 86 | 278 | -------------------------------------------------------------------------------- /frontend/src/components/SearchResultListForMobile.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 9 | 10 | 11 | 12 | 13 | 14 | 19 | 20 | {{ ellipsis(item.songName, 18) }} 21 | 22 | 25 | {{ item.sourceName }} 26 | 27 | 28 | 29 | 30 | 31 | {{ item.artist }} / {{ ellipsis(item.album, 20) }} / 32 | {{ item.duration }} 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 44 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 220 | -------------------------------------------------------------------------------- /frontend/src/components/SearchResultTable.vue: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | 18 | 19 | 20 | 25 | 26 | 27 | {{ scope.row.songName }} 28 | 29 | 30 | 31 | 32 | 38 | 39 | 40 | 41 | 47 | 48 | 54 | 55 | 56 | 57 | 58 | 59 | 64 | 70 | 71 | 72 | 73 | 74 | 75 | 82 | 83 | 84 | 85 | 86 | 94 | 101 | 102 | 103 | 104 | 105 | 113 | 120 | 121 | 122 | 123 | 124 | 125 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | 209 | 210 | 329 | -------------------------------------------------------------------------------- /frontend/src/components/TaskNotification.js: -------------------------------------------------------------------------------- 1 | import { ElNotification } from 'element-plus' 2 | import { getJobDetail } from "../api"; 3 | 4 | export function startTaskListener(jobID) { 5 | let lastTip = ''; 6 | 7 | const task = (jobID) => { 8 | getJobDetail(jobID).then(res => { 9 | const status = res.data.jobs.status; 10 | if (status === '已完成' || status === '失败') { 11 | clearInterval(interval) 12 | } 13 | if (lastTip == res.data.jobs.tip) { 14 | return; 15 | }; 16 | lastTip = res.data.jobs.tip; 17 | 18 | let title; 19 | let type; 20 | let duration = 4500; 21 | if (status === '已完成') { 22 | title = '任务完成'; 23 | type = 'success'; 24 | duration = 6000; 25 | } else if (status === '失败') { 26 | title = '任务失败'; 27 | type = 'error'; 28 | } else { 29 | title = '任务进度提示'; 30 | type = 'info'; 31 | duration = 2500; 32 | } 33 | 34 | if (lastTip == "") { 35 | duration = 4500; 36 | } 37 | 38 | ElNotification({ 39 | title, 40 | message: "" + res.data.jobs.name + "" + "" + res.data.jobs.desc + "" + res.data.jobs.tip, 41 | dangerouslyUseHTMLString: true, 42 | type, 43 | duration, 44 | }); 45 | }) 46 | }; 47 | 48 | task(jobID); 49 | const interval = setInterval(() => { 50 | task(jobID); 51 | }, 1000) 52 | } 53 | 54 | -------------------------------------------------------------------------------- /frontend/src/components/TaskNotificationForMobile.js: -------------------------------------------------------------------------------- 1 | import { getJobDetail } from "../api"; 2 | import { Notify } from 'vant'; 3 | 4 | export function startTaskListener(jobID) { 5 | let lastTip = ''; 6 | 7 | const task = (jobID) => { 8 | getJobDetail(jobID).then(res => { 9 | const status = res.data.jobs.status; 10 | if (status === '已完成' || status === '失败') { 11 | clearInterval(interval) 12 | } 13 | if (lastTip == res.data.jobs.tip) { 14 | return; 15 | }; 16 | lastTip = res.data.jobs.tip; 17 | 18 | let title; 19 | let type; 20 | let duration = 4500; 21 | if (status === '已完成') { 22 | type = 'success'; 23 | duration = 6000; 24 | } else if (status === '失败') { 25 | type = 'danger'; 26 | } else { 27 | title = '任务进度提示'; 28 | type = 'primary'; 29 | duration = 2500; 30 | } 31 | 32 | if (lastTip == "") { 33 | duration = 4500; 34 | } 35 | 36 | const options = { 37 | message: `${res.data.jobs.tip}\n${res.data.jobs.name}\n${res.data.jobs.desc}`, 38 | type, 39 | duration, 40 | }; 41 | Notify(options); 42 | }) 43 | }; 44 | 45 | task(jobID); 46 | const interval = setInterval(() => { 47 | task(jobID); 48 | }, 1000) 49 | } 50 | 51 | -------------------------------------------------------------------------------- /frontend/src/main.js: -------------------------------------------------------------------------------- 1 | import { createApp } from 'vue' 2 | import ElementPlus from 'element-plus' 3 | import ElementPlusLocaleZhCn from 'element-plus/lib/locale/lang/zh-cn' 4 | import App from './App.vue' 5 | import router from './router' 6 | 7 | const app = createApp(App) 8 | app.use(router) 9 | app.use(ElementPlus, { locale: ElementPlusLocaleZhCn }) 10 | app.mount('#app') 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/mobile.js: -------------------------------------------------------------------------------- 1 | import vant from 'vant'; 2 | import 'vant/lib/index.css'; 3 | import VueVirtualScroller from 'vue-virtual-scroller' 4 | import { createApp } from 'vue' 5 | import 'vue-virtual-scroller/dist/vue-virtual-scroller.css' 6 | import { useRegisterSW } from 'virtual:pwa-register/vue'; 7 | 8 | import App from './Mobile.vue' 9 | import router from './router/mobile' 10 | 11 | useRegisterSW(); 12 | 13 | const app = createApp(App) 14 | app.use(router) 15 | app.use(vant) 16 | app.use(VueVirtualScroller) 17 | app.mount('#app') 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/router/index.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, createWebHashHistory } from "vue-router" 2 | import storage from "../utils/storage" 3 | 4 | const PathPlaylist = '/playlist'; 5 | const PathSetting = '/setting'; 6 | 7 | const routes = [ 8 | { 9 | path: '/', 10 | component: () => import('../views/pc/Home.vue') 11 | }, 12 | { 13 | path: '/account', 14 | name: "Account", 15 | component: () => import('../views/pc/Account.vue') 16 | }, 17 | { 18 | path: PathPlaylist, 19 | name: "Playlist", 20 | component: () => import('../views/pc/Playlist.vue') 21 | }, 22 | { 23 | path: PathSetting, 24 | name: "Setting", 25 | component: () => import('../views/pc/Setting.vue') 26 | }, 27 | ] 28 | export const router = createRouter({ 29 | history: createWebHashHistory(), 30 | routes: routes 31 | }) 32 | 33 | router.beforeEach((to, from, next) => { 34 | if (to.path === "/account") { 35 | next(); 36 | return; 37 | } 38 | const mk = storage.get('mk') 39 | const wyAccount = storage.get('wyAccount') 40 | if (!mk) { 41 | next("/account"); 42 | } 43 | if ([PathPlaylist].includes(to.path) && !wyAccount) { 44 | next("/account"); 45 | return; 46 | } 47 | next(); 48 | }); 49 | 50 | export default router -------------------------------------------------------------------------------- /frontend/src/router/mobile.js: -------------------------------------------------------------------------------- 1 | import { createRouter, createWebHistory, createWebHashHistory } from "vue-router" 2 | import storage from "../utils/storage" 3 | 4 | const routes = [ 5 | { 6 | path: '/', 7 | component: () => import('../views/mobile/Home.vue') 8 | }, 9 | { 10 | path: '/account', 11 | name: "Account", 12 | component: () => import('../views/mobile/Account.vue') 13 | }, 14 | { 15 | path: '/playlist', 16 | name: "Playlist", 17 | component: () => import('../views/mobile/Playlist.vue') 18 | }, 19 | ] 20 | export const router = createRouter({ 21 | history: createWebHashHistory(), 22 | routes: routes 23 | }) 24 | 25 | router.beforeEach((to, from, next) => { 26 | if (to.path === "/account") { 27 | next(); 28 | return; 29 | } 30 | const mk = storage.get('mk') 31 | const wyAccount = storage.get('wyAccount') 32 | if (!mk) { 33 | next("/account"); 34 | } 35 | if (to.path === "/playlist" && !wyAccount) { 36 | next("/account"); 37 | return; 38 | } 39 | next(); 40 | }); 41 | 42 | export default router -------------------------------------------------------------------------------- /frontend/src/utils/audio.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Get the proper play URL based on source 3 | * @param {string} source - The source platform (e.g., 'bilibili', 'netease') 4 | * @param {string} url - The original audio URL 5 | * @param {string} referer - The referer URL 6 | * @returns {string} The processed play URL 7 | */ 8 | export function getProperPlayUrl(source, url, referer) { 9 | console.log("--------getProperPlayUrl----------------"); 10 | console.log(source); 11 | console.log(url); 12 | console.log(referer); 13 | if (source === "bilibili") { 14 | const params = new URLSearchParams({ 15 | url: url, 16 | source: 'bilibili', 17 | referer: referer 18 | }); 19 | return `${import.meta.env.VITE_APP_API_URL}/proxy/audio?${params}`; 20 | } 21 | return url; 22 | } -------------------------------------------------------------------------------- /frontend/src/utils/index.js: -------------------------------------------------------------------------------- 1 | export function secondDurationToDisplayDuration(secondDuration, allowZero = false) { 2 | if (!secondDuration) { 3 | return allowZero ? "00:00" : " - "; 4 | } 5 | secondDuration = parseInt(secondDuration); 6 | let duration = secondDuration; 7 | let minute = Math.floor(duration / 60); 8 | let second = duration % 60; 9 | if (minute < 10) { 10 | minute = "0" + minute; 11 | } 12 | if (second < 10) { 13 | second = "0" + second; 14 | } 15 | return minute + ":" + second; 16 | } 17 | 18 | export function sourceCodeToName(source) { 19 | return { 20 | "qq": "QQ音乐", 21 | "xiami": "虾米音乐", 22 | "netease": "网易云音乐", 23 | "kugou": "酷狗音乐", 24 | "kuwo": "酷我音乐", 25 | "migu": "咪咕音乐", 26 | "bilibili": "Bilibili", 27 | "douyin": "抖音", 28 | "youtube": "YouTube", 29 | }[source] || "未知"; 30 | } 31 | 32 | export function sleep(ms) { 33 | return new Promise(resolve => setTimeout(resolve, ms)); 34 | } 35 | 36 | export function ellipsis(value, maxLength) { 37 | if (!value) { 38 | return ""; 39 | } 40 | value = value.trim(); 41 | if (!value) return ""; 42 | if (value.length > maxLength) { 43 | return value.slice(0, maxLength) + "..."; 44 | } 45 | return value; 46 | } -------------------------------------------------------------------------------- /frontend/src/utils/pwa.js: -------------------------------------------------------------------------------- 1 | let request; 2 | 3 | let isInstallable = false; 4 | 5 | // The file is not useful now. 6 | window.addEventListener('beforeinstallprompt', (r) => { 7 | console.log('beforeinstallprompt') 8 | // Prevent Chrome 67 and earlier from automatically showing the prompt 9 | r.preventDefault() 10 | request = r 11 | isInstallable = true; 12 | }); 13 | 14 | export async function installPWA() { 15 | if (request) { 16 | console.log('start install') 17 | let installResponse = await request.prompt(); 18 | console.info({installResponse}); 19 | return installResponse.outcome === 'accepted'; 20 | } else { 21 | console.log('The request is not available'); 22 | return false; 23 | } 24 | } -------------------------------------------------------------------------------- /frontend/src/utils/storage.js: -------------------------------------------------------------------------------- 1 | export default { 2 | set (key, val) { 3 | if (typeof val === 'object') { 4 | val = JSON.stringify(val) 5 | } 6 | window.localStorage.setItem(key, val) 7 | }, 8 | get (key) { 9 | let data = window.localStorage.getItem(key) 10 | try { 11 | data = JSON.parse(data); 12 | return data; 13 | } catch { 14 | return data; 15 | } 16 | }, 17 | del (key) { 18 | window.localStorage.removeItem(key) 19 | }, 20 | } -------------------------------------------------------------------------------- /frontend/src/views/mobile/Account.vue: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 填写你的 Melody Key 就可以开始使用啦 😘 7 | 8 | 9 | 10 | 20 | 21 | 22 | 23 | 24 | 31 | 开始使用 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 当前 Melody Key: {{ account.uid }} 42 | 43 | 44 | 退出 47 | 48 | 49 | 50 | 55 | 56 | 57 | 58 | 网易云账号信息 59 | 60 | 61 | 62 | 67 | 68 | 69 | {{ account.wyAccount.nickname }}(已绑定) 72 | 请先绑定正确的网易云账号 73 | 74 | 75 | 76 | 81 | 扫码登录 82 | 手机号登录 83 | 邮箱登录 84 | 85 | 86 | 87 | 88 | 89 | 90 | 98 | 99 | 104 | 105 | 扫码仅支持在 PC 端操作 106 | 107 | 108 | 109 | 110 | 111 | 112 | 更新账号密码 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 236 | -------------------------------------------------------------------------------- /frontend/vite.config.js: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vite' 2 | import vue from '@vitejs/plugin-vue' 3 | import importToCDN from 'vite-plugin-cdn-import' 4 | import path from 'path' 5 | import { VitePWA } from 'vite-plugin-pwa' 6 | 7 | export default defineConfig(({command, mode}) => { 8 | return { 9 | server: { 10 | host: '0.0.0.0' 11 | }, 12 | base: './', 13 | define: { 14 | 'process.env': process.env 15 | }, 16 | plugins: [ 17 | VitePWA({ 18 | registerType: 'autoUpdate', 19 | manifest: { 20 | name: 'Melody', 21 | short_name: 'Melody', 22 | description: 'Enjoy your music with Melody', 23 | theme_color: '#ffffff', 24 | start_url:"./mobile.html", 25 | icons: [ 26 | { 27 | src: 'melody-192x192.png', 28 | sizes: '192x192', 29 | type: 'image/png' 30 | }, 31 | { 32 | src: 'melody-512x512.png', 33 | sizes: '512x512', 34 | type: 'image/png' 35 | } 36 | ] 37 | } 38 | }), 39 | vue(), 40 | importToCDN({ 41 | modules:[ 42 | { 43 | name: "vue", 44 | var: "Vue", 45 | path: "https://cdnjs.cloudflare.com/ajax/libs/vue/3.2.33/vue.global.min.js", 46 | }, 47 | { 48 | name: "vue-router", 49 | var: "VueRouter", 50 | path: "https://cdnjs.cloudflare.com/ajax/libs/vue-router/4.0.14/vue-router.global.min.js", 51 | }, 52 | { 53 | name: "vuex", 54 | var: "Vuex", 55 | path: 'https://cdnjs.cloudflare.com/ajax/libs/vuex/4.0.2/vuex.global.min.js', 56 | }, 57 | { 58 | name: "axios", 59 | var: "axios", 60 | path: 'https://cdnjs.cloudflare.com/ajax/libs/axios/0.26.1/axios.min.js', 61 | }, 62 | { 63 | name: "element-plus", 64 | var: "ElementPlus", 65 | path: 'https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.1.9/index.full.js', 66 | css: ["https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.1.9/theme-chalk/index.min.css"], 67 | }, 68 | { 69 | name: "@element-plus/icons-vue", 70 | var: "ElementPlusIconsVue", 71 | path: 'https://cdn.jsdelivr.net/npm/@element-plus/icons-vue@1.1.4/dist/index.iife.min.js', 72 | }, 73 | { 74 | name: "element-plus/lib/locale/lang/zh-cn", 75 | var: "ElementPlusLocaleZhCn", 76 | path: 'https://cdnjs.cloudflare.com/ajax/libs/element-plus/2.1.9/locale/zh-cn.min.js', 77 | }, 78 | { 79 | name: "vant", 80 | var: "vant", 81 | path: 'https://cdnjs.cloudflare.com/ajax/libs/vant/3.4.8/vant.js', 82 | css: ["https://cdnjs.cloudflare.com/ajax/libs/vant/3.4.8/index.min.css"], 83 | } 84 | ] 85 | }), 86 | ], 87 | build: { 88 | rollupOptions: { 89 | input: { 90 | index: path.resolve(__dirname, 'index.html'), 91 | mobile: path.resolve(__dirname, 'mobile.html'), 92 | }, output: { 93 | chunkFileNames: 'static/js/[name]-[hash].js', 94 | entryFileNames: "static/js/[name]-[hash].js", 95 | assetFileNames: "static/[ext]/name-[hash].[ext]" 96 | } 97 | }, 98 | } 99 | } 100 | }) 101 | -------------------------------------------------------------------------------- /imgs/1-home-search-keyword.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/1-home-search-keyword.png -------------------------------------------------------------------------------- /imgs/2-home-search-url.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/2-home-search-url.png -------------------------------------------------------------------------------- /imgs/3-playlist.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/3-playlist.png -------------------------------------------------------------------------------- /imgs/4-playlist-search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/4-playlist-search.png -------------------------------------------------------------------------------- /imgs/billing.jpeg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/billing.jpeg -------------------------------------------------------------------------------- /imgs/melody.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/melody.png -------------------------------------------------------------------------------- /imgs/mobile-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/mobile-1.png -------------------------------------------------------------------------------- /imgs/mobile-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/mobile-2.png -------------------------------------------------------------------------------- /imgs/mobile-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/mobile-3.png -------------------------------------------------------------------------------- /imgs/mobile-4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/foamzou/melody/c531bee20a74a30a5d99d5b115ed25ded7220bc0/imgs/mobile-4.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foamzou-melody", 3 | "version": "0.1.2", 4 | "scripts": { 5 | "test": "echo \"Error: no test specified\" && exit 1", 6 | "init": "node scripts/setup.js", 7 | "app": "node backend/src/index.js", 8 | "update": "git pull && npm run init" 9 | }, 10 | "repository": { 11 | "type": "git", 12 | "url": "git+https://github.com/foamzou/melody.git" 13 | }, 14 | "author": "foamzou", 15 | "bugs": { 16 | "url": "https://github.com/foamzou/melody/issues" 17 | }, 18 | "homepage": "https://github.com/foamzou/melody#readme" 19 | } 20 | -------------------------------------------------------------------------------- /scripts/setup-for-build-docker.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const fs = require('fs'); 3 | const isWin = require('os').platform().indexOf('win32') > -1; 4 | const ROOT_DIR = `${__dirname}/../`; 5 | const l = m => console.log(m); 6 | 7 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); 8 | 9 | function getMediaGetBinPath() { 10 | return path.join(ROOT_DIR, 'backend', 'bin', `media-get${isWin ? '.exe' : ''}`); 11 | } 12 | 13 | async function downloadMediaGetWithRetry() { 14 | const MediaGetService = require('../backend/src/service/media_fetcher/media_get'); 15 | const maxRetries = 3; 16 | let retryCount = 0; 17 | 18 | // Get latest version first 19 | const latestVersion = await MediaGetService.getLatestMediaGetVersion(); 20 | if (latestVersion === false) { 21 | l('Failed to get latest media-get version'); 22 | return false; 23 | } 24 | 25 | while (retryCount < maxRetries) { 26 | l(`Downloading media-get (attempt ${retryCount + 1})`); 27 | const success = await MediaGetService.downloadTheLatestMediaGet(latestVersion); 28 | if (success) { 29 | return true; 30 | } 31 | retryCount++; 32 | if (retryCount < maxRetries) { 33 | l(`Download failed, waiting 5 seconds before retry...`); 34 | await sleep(5000); 35 | } 36 | } 37 | return false; 38 | } 39 | 40 | async function run() { 41 | try { 42 | l('Starting media-get installation...'); 43 | 44 | const mediaGetPath = getMediaGetBinPath(); 45 | if (!fs.existsSync(mediaGetPath)) { 46 | l('Downloading media-get...'); 47 | if (await downloadMediaGetWithRetry() === false) { 48 | l('Failed to download media-get'); 49 | return false; 50 | } 51 | l('Successfully downloaded media-get'); 52 | } else { 53 | l('media-get already exists'); 54 | } 55 | 56 | return true; 57 | } catch (error) { 58 | l('Error during execution:'); 59 | l(error.stack || error.message || error); 60 | return false; 61 | } 62 | } 63 | 64 | run().then(success => { 65 | if (!success) { 66 | l('Setup failed'); 67 | process.exit(1); 68 | } 69 | l('Setup completed successfully'); 70 | }); -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | const exec = require('child_process').exec; 2 | const https = require('https'); 3 | const path = require('path'); 4 | const fs = require('fs'); 5 | const isWin = require('os').platform().indexOf('win32') > -1; 6 | const isLinux = require('os').platform().indexOf('linux') > -1; 7 | const isDarwin = require('os').platform().indexOf('darwin') > -1; 8 | const ROOT_DIR = `${__dirname}/../`; 9 | const l = m => console.log(m); 10 | 11 | const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)); 12 | 13 | const runCmd = (cmd, shouldOutput = true, cwd = null) => { 14 | const startTime = new Date(); 15 | const option = cwd ? {cwd} : {}; 16 | const currentCwd = cwd || process.cwd(); 17 | l(`[${startTime.toISOString()}] 开始执行命令: ${cmd}`); 18 | l(`执行目录: ${currentCwd}`); 19 | 20 | return new Promise(r => { 21 | const childProcess = exec(cmd, option); 22 | l(`进程ID: ${childProcess.pid}`); 23 | let result = ''; 24 | let error = ''; 25 | 26 | childProcess.stdout.on('data', function(data) { 27 | shouldOutput && console.log(data); 28 | result += data.toString(); 29 | }); 30 | 31 | childProcess.stderr.on('data', (data) => { 32 | shouldOutput && console.log(data); 33 | error += data.toString(); 34 | }) 35 | 36 | childProcess.on('exit', (code, signal) => { 37 | const endTime = new Date(); 38 | const duration = (endTime - startTime) / 1000; 39 | l(`[${endTime.toISOString()}] 命令执行完成,耗时: ${duration}秒`); 40 | if (signal) { 41 | l(`进程被信号 ${signal} 终止`); 42 | } 43 | l(`退出码: ${code}`); 44 | r({code, signal, result, error}) 45 | }) 46 | }); 47 | } 48 | 49 | const runCmdAndExitWhenFailed = async (cmd, msg, shouldOutput = true, cwd = null) => { 50 | l('----------------------------------------'); 51 | l(`执行命令: ${cmd}`); 52 | l(`工作目录: ${cwd || process.cwd()}`); 53 | const ret = await runCmd(cmd, shouldOutput, cwd); 54 | if (ret.code !== 0 || ret.code === null) { 55 | l('命令执行失败:'); 56 | l(msg); 57 | l(`命令: ${cmd}`); 58 | l(`工作目录: ${cwd || process.cwd()}`); 59 | l('退出码: ' + ret.code); 60 | l('错误输出: ' + ret.error); 61 | l('标准输出: ' + ret.result); 62 | l('系统信息:'); 63 | l(`- 平台: ${process.platform}`); 64 | l(`- 架构: ${process.arch}`); 65 | l(`- Node版本: ${process.version}`); 66 | l(`- 内存使用: ${JSON.stringify(process.memoryUsage())}`); 67 | process.exit(1); 68 | } 69 | l('----------------------------------------'); 70 | return ret; 71 | } 72 | 73 | function getMediaGetBinPath() { 74 | return path.join(ROOT_DIR, 'backend', 'bin', `media-get${isWin ? '.exe' : ''}`); 75 | } 76 | 77 | async function checkAndUpdateMediaGet(currentMediaGetVersion) { 78 | const MediaGetService = require('../backend/src/service/media_fetcher/media_get'); 79 | 80 | const latestVersion = await MediaGetService.getLatestMediaGetVersion(); 81 | if (latestVersion === false) { 82 | return; 83 | } 84 | if (currentMediaGetVersion.localeCompare(latestVersion, undefined, { numeric: true, sensitivity: 'base' }) >= 0) { 85 | l('当前 media-get 版本已经是最新版本'); 86 | return; 87 | } 88 | l(`当前 media-get(${currentMediaGetVersion})版本不是最新版本, 开始更新到${latestVersion}`); 89 | await MediaGetService.downloadTheLatestMediaGet(latestVersion); 90 | } 91 | 92 | function copyDir(src, dest) { 93 | fs.mkdirSync(dest); 94 | fs.readdirSync(src).forEach(file => { 95 | const srcPath = path.join(src, file); 96 | const destPath = path.join(dest, file); 97 | if (fs.statSync(srcPath).isDirectory()) { 98 | copyDir(srcPath, destPath); 99 | } else { 100 | fs.copyFileSync(srcPath, destPath); 101 | } 102 | }); 103 | } 104 | 105 | async function getPackageManager() { 106 | let ret = await runCmd('pnpm --version', false); 107 | if (ret.code === 0) { 108 | return 'pnpm'; 109 | } 110 | ret = await runCmd('yarn --version', false); 111 | if (ret.code === 0) { 112 | return 'yarn'; 113 | } 114 | 115 | return 'npm'; 116 | } 117 | 118 | async function downloadMediaGetWithRetry(latestVersion) { 119 | const MediaGetService = require('../backend/src/service/media_fetcher/media_get'); 120 | const maxRetries = 3; 121 | let retryCount = 0; 122 | 123 | while (retryCount < maxRetries) { 124 | l(`尝试下载 media-get (第 ${retryCount + 1} 次尝试)`); 125 | const success = await MediaGetService.downloadTheLatestMediaGet(latestVersion); 126 | if (success) { 127 | return true; 128 | } 129 | retryCount++; 130 | if (retryCount < maxRetries) { 131 | l(`下载失败,等待 5 秒后重试...`); 132 | await sleep(5000); 133 | } 134 | } 135 | return false; 136 | } 137 | 138 | async function run() { 139 | try { 140 | l('开始执行...'); 141 | 142 | // 检查 FFmpeg 安装和位置 143 | l('检查 FFmpeg 安装状态...'); 144 | if (!process.env.CROSS_COMPILING) { 145 | const ffmpegRet = await runCmd('which ffmpeg && ls -l $(which ffmpeg)', true); 146 | l('FFmpeg location and permissions:'); 147 | l(ffmpegRet.result); 148 | await runCmdAndExitWhenFailed('ffmpeg -version', '请先安装 ffmpeg', false); 149 | } else { 150 | l('跳过 FFmpeg 检查 (CROSS_COMPILING=1)'); 151 | } 152 | 153 | await runCmdAndExitWhenFailed('npm version', '请先安装 npm', false); 154 | 155 | const pm = await getPackageManager(); 156 | l(`安装 node_module via ${pm}`) 157 | l('开始安装后端依赖...'); 158 | l(`执行命令: ${pm} install --production --verbose in ${path.join(ROOT_DIR, 'backend')}`); 159 | const backendInstallResult = await runCmdAndExitWhenFailed(`${pm} install --production --verbose`, '安装后端 node_module 失败', true, path.join(ROOT_DIR, 'backend')); 160 | l('后端依赖安装结果:'); 161 | l('Exit code: ' + backendInstallResult.code); 162 | l('Output: ' + backendInstallResult.result); 163 | if (backendInstallResult.error) { 164 | l('Error output: ' + backendInstallResult.error); 165 | } 166 | 167 | l('检查 media-get'); 168 | const MediaGetService = require('../backend/src/service/media_fetcher/media_get'); 169 | 170 | const mediaGetPath = getMediaGetBinPath(); 171 | l(`检查 media-get 权限: ${mediaGetPath}`); 172 | await runCmd(`ls -l ${mediaGetPath}`, true); 173 | 174 | if (!fs.existsSync(mediaGetPath)) { 175 | const latestVersion = await MediaGetService.getLatestMediaGetVersion(); 176 | if (latestVersion === false) { 177 | l('获取 media-get 最新版本失败,无法继续安装'); 178 | return false; 179 | } 180 | l('开始下载核心程序 media-get'); 181 | if (await downloadMediaGetWithRetry(latestVersion) === false) { 182 | l('下载核心程序 media-get 失败'); 183 | return false; 184 | } 185 | } else { 186 | const currentMediaGetVersion = await MediaGetService.getLatestMediaGetVersion(); 187 | await checkAndUpdateMediaGet(currentMediaGetVersion); 188 | } 189 | 190 | l('开始安装前端依赖...'); 191 | const frontendInstallResult = await runCmdAndExitWhenFailed(`${pm} install --verbose`, '安装前端 node_module 失败', true, path.join(ROOT_DIR, 'frontend')); 192 | l('前端依赖安装结果:'); 193 | l('Exit code: ' + frontendInstallResult.code); 194 | l('Output: ' + frontendInstallResult.result); 195 | if (frontendInstallResult.error) { 196 | l('Error output: ' + frontendInstallResult.error); 197 | } 198 | 199 | l('开始编译前端...'); 200 | const buildResult = await runCmdAndExitWhenFailed(`${pm} run build`, '前端编译失败', true, path.join(ROOT_DIR, 'frontend')); 201 | l('前端编译结果:'); 202 | l('Exit code: ' + buildResult.code); 203 | l('Output: ' + buildResult.result); 204 | if (buildResult.error) { 205 | l('Error output: ' + buildResult.error); 206 | } 207 | 208 | l('删除老目录'); 209 | try { 210 | fs.rmdirSync(path.join(ROOT_DIR, 'backend', 'public'), { recursive: true }); 211 | } catch(e) { 212 | l('删除老目录失败,但继续执行: ' + e.message); 213 | } 214 | 215 | l('拷贝前端目录'); 216 | try { 217 | copyDir(path.join(ROOT_DIR, 'frontend', 'dist'), path.join(ROOT_DIR, 'backend', 'public')); 218 | } catch(e) { 219 | l('拷贝前端目录失败: ' + e.message); 220 | return false; 221 | } 222 | 223 | return true; 224 | } catch (error) { 225 | l('执行过程中出现错误:'); 226 | l(error.stack || error.message || error); 227 | return false; 228 | } 229 | } 230 | 231 | run().then(isFine => { 232 | l(isFine ? `执行完毕,执行以下命令启动服务:\r\n\r\nnpm run app` : '执行出错,请检查'); 233 | if (!isFine) { 234 | process.exit(1); 235 | } 236 | }); --------------------------------------------------------------------------------
90 | 98 | 99 | 104 |
扫码仅支持在 PC 端操作