├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .eslintrc.cjs ├── .github └── workflows │ ├── docker-publish.yml │ └── release.yml ├── .gitignore ├── .ncurc.js ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── Readme.md ├── config.js ├── ecosystem.config.js ├── index.js ├── package-lock.json ├── package.json ├── publish ├── changeLog.md ├── index.js ├── updateChangeLog.js └── utils.js ├── src ├── constants.ts ├── defaultConfig.ts ├── event.ts ├── index.ts ├── modules │ ├── dislike │ │ ├── dislikeDataManage.ts │ │ ├── event.ts │ │ ├── index.ts │ │ ├── manage.ts │ │ ├── snapshotDataManage.ts │ │ ├── sync │ │ │ ├── handler.ts │ │ │ ├── index.ts │ │ │ ├── localEvent.ts │ │ │ └── sync.ts │ │ └── utils.ts │ ├── index.ts │ └── list │ │ ├── event.ts │ │ ├── index.ts │ │ ├── listDataManage.ts │ │ ├── manage.ts │ │ ├── snapshotDataManage.ts │ │ ├── sync │ │ ├── handler.ts │ │ ├── index.ts │ │ ├── localEvent.ts │ │ └── sync.ts │ │ └── utils.ts ├── server │ ├── auth.ts │ ├── index.ts │ ├── server.ts │ └── sync │ │ ├── event.ts │ │ ├── handler.ts │ │ ├── index.ts │ │ └── sync.ts ├── types │ ├── app.d.ts │ ├── common.d.ts │ ├── config.d.ts │ ├── dislike_list.d.ts │ ├── dislike_sync.d.ts │ ├── list.d.ts │ ├── list_sync.d.ts │ ├── log4js.d.ts │ ├── music.d.ts │ ├── sync_common.d.ts │ ├── utils.d.ts │ └── ws.d.ts ├── user │ ├── data.ts │ └── index.ts └── utils │ ├── cache.ts │ ├── common.ts │ ├── index.ts │ ├── log4js.ts │ ├── migrate │ ├── index.ts │ └── v2.ts │ └── tools.ts └── tsconfig.json /.dockerignore: -------------------------------------------------------------------------------- 1 | .git 2 | .gitignore 3 | node_modules 4 | data 5 | logs 6 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 2 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | /server 2 | data 3 | logs 4 | -------------------------------------------------------------------------------- /.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | overrides: [ 4 | { 5 | extends: ['standard-with-typescript'], 6 | files: ['*.js', '*.ts', '*.tsx'], 7 | rules: { 8 | 'no-new': 'off', 9 | camelcase: 'off', 10 | 'no-return-assign': 'off', 11 | 'space-before-function-paren': ['error', 'never'], 12 | 'no-var': 'error', 13 | 'no-fallthrough': 'off', 14 | eqeqeq: 'off', 15 | 'require-atomic-updates': ['error', { allowProperties: true }], 16 | 'no-multiple-empty-lines': [1, { max: 2 }], 17 | 'comma-dangle': [2, 'always-multiline'], 18 | 'standard/no-callback-literal': 'off', 19 | 'prefer-const': 'off', 20 | 'no-labels': 'off', 21 | 'node/no-callback-literal': 'off', 22 | '@typescript-eslint/strict-boolean-expressions': 'off', 23 | '@typescript-eslint/explicit-function-return-type': 'off', 24 | '@typescript-eslint/space-before-function-paren': 'off', 25 | '@typescript-eslint/no-non-null-assertion': 'off', 26 | '@typescript-eslint/restrict-template-expressions': [1, { 27 | allowBoolean: true, 28 | allowAny: true, 29 | }], 30 | '@typescript-eslint/no-misused-promises': [ 31 | 'error', 32 | { 33 | checksVoidReturn: { 34 | arguments: false, 35 | attributes: false, 36 | }, 37 | }, 38 | ], 39 | '@typescript-eslint/naming-convention': 'off', 40 | '@typescript-eslint/return-await': 'off', 41 | 'multiline-ternary': 'off', 42 | '@typescript-eslint/comma-dangle': 'off', 43 | '@typescript-eslint/no-dynamic-delete': 'off', 44 | '@typescript-eslint/ban-ts-comment': 'off', 45 | '@typescript-eslint/ban-types': 'off', 46 | }, 47 | parserOptions: { 48 | project: './tsconfig.json', 49 | }, 50 | }, 51 | ], 52 | ignorePatterns: [ 53 | 'node_modules', 54 | '*.min.js', 55 | 'dist', 56 | 'publish', 57 | '/*.js', 58 | ], 59 | } 60 | -------------------------------------------------------------------------------- /.github/workflows/docker-publish.yml: -------------------------------------------------------------------------------- 1 | name: Docker Publish 2 | 3 | on: 4 | workflow_dispatch: 5 | push: 6 | tags: 7 | - "v*.*.*" 8 | 9 | env: 10 | # Use docker.io for Docker Hub if empty 11 | REGISTRY: docker.io 12 | # github.repository as / 13 | IMAGE_NAME: ${{ github.repository }} 14 | 15 | jobs: 16 | Linux: 17 | name: Publish 18 | runs-on: ubuntu-latest 19 | steps: 20 | - name: Check out git repository 21 | uses: actions/checkout@v3 22 | 23 | - name: Set up QEMU 24 | uses: docker/setup-qemu-action@v2 25 | 26 | # Workaround: https://github.com/docker/build-push-action/issues/461 27 | - name: Set up Docker Buildx 28 | uses: docker/setup-buildx-action@v2 29 | 30 | # Extract metadata (tags, labels) for Docker 31 | # https://github.com/docker/metadata-action 32 | - name: Extract Docker metadata 33 | id: meta 34 | uses: docker/metadata-action@v4 35 | with: 36 | images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} 37 | # tags: type=raw,value=latest 38 | 39 | # Login to Docker Hub 40 | # https://github.com/docker/login-action 41 | - name: Log into registry ${{ env.REGISTRY }} 42 | if: github.event_name != 'pull_request' 43 | uses: docker/login-action@v2 44 | with: 45 | registry: ${{ env.REGISTRY }} 46 | username: ${{ secrets.DOCKER_HUB_USER }} 47 | password: ${{ secrets.DOCKER_HUB_TOKEN }} 48 | 49 | # Build and push Docker image with Buildx (don't push on PR) 50 | # https://github.com/docker/build-push-action 51 | - name: Build and push Docker image 52 | id: build-and-push 53 | uses: docker/build-push-action@v4 54 | with: 55 | context: . 56 | platforms: linux/amd64,linux/arm64,linux/arm/v7 57 | push: ${{ github.event_name != 'pull_request' }} 58 | tags: ${{ steps.meta.outputs.tags }} 59 | labels: ${{ steps.meta.outputs.labels }} 60 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*.*.*" 7 | 8 | jobs: 9 | Linux: 10 | name: Build 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out git repository 14 | uses: actions/checkout@v3 15 | 16 | - name: Install Node.js 17 | uses: actions/setup-node@v3 18 | with: 19 | node-version: '16' 20 | 21 | - name: Cache file 22 | uses: actions/cache@v3 23 | with: 24 | path: | 25 | node_modules 26 | $HOME/.npm/_prebuilds 27 | key: ${{ runner.os }}-build-caches-${{ hashFiles('**/package-lock.json') }} 28 | restore-keys: | 29 | ${{ runner.os }}-build- 30 | 31 | - name: Install Dependencies 32 | run: | 33 | npm install 34 | 35 | - name: Build src code 36 | run: npm run build 37 | 38 | - name: Get package info 39 | run: | 40 | node -p -e '`PACKAGE_NAME=${require("./package.json").name}`' >> $GITHUB_ENV 41 | node -p -e '`PACKAGE_VERSION=${require("./package.json").version}`' >> $GITHUB_ENV 42 | 43 | - name: Create git tag 44 | uses: pkgdeps/git-tag-action@v2 45 | with: 46 | github_token: ${{ secrets.GITHUB_TOKEN }} 47 | github_repo: ${{ github.repository }} 48 | version: ${{ env.PACKAGE_VERSION }} 49 | git_commit_sha: ${{ github.sha }} 50 | git_tag_prefix: "v" 51 | 52 | - name: Zip files 53 | run: zip -r lx-music-sync-server_v${{ env.PACKAGE_VERSION }}.zip server ecosystem.config.js config.js index.js package-lock.json package.json LICENSE Readme.md 54 | 55 | - name: Release 56 | uses: softprops/action-gh-release@v1 57 | with: 58 | body_path: ./publish/changeLog.md 59 | prerelease: false 60 | draft: false 61 | tag_name: v${{ env.PACKAGE_VERSION }} 62 | files: | 63 | lx-music-sync-server_v${{ env.PACKAGE_VERSION }}.zip 64 | env: 65 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 66 | 67 | - name: Generate file MD5 68 | run: | 69 | md5sum *.zip 70 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # next.js build output 61 | .next 62 | 63 | /data 64 | /server 65 | -------------------------------------------------------------------------------- /.ncurc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | upgrade: true, 3 | reject: [ 4 | 'chalk', 5 | '@types/ws', 6 | ], 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # lx-music-script change log 2 | 3 | All notable changes to this project will be documented in this file. 4 | 5 | Project versioning adheres to [Semantic Versioning](http://semver.org/). 6 | Commit convention is based on [Conventional Commits](http://conventionalcommits.org). 7 | Change log format is based on [Keep a Changelog](http://keepachangelog.com/). 8 | 9 | ## [2.1.2](https://github.com/lyswhut/lx-music-sync-server/compare/v2.1.1...v2.1.2) - 2023-10-26 10 | 11 | ### 修复 12 | 13 | - 修复绑定IP为IPv6时,连接用户时会导致服务报错的问题(#55) 14 | 15 | ## [2.1.1](https://github.com/lyswhut/lx-music-sync-server/compare/v2.1.0...v2.1.1) - 2023-10-03 16 | 17 | ### 优化 18 | 19 | - 在控制台显示所有创建的用户名及密码 20 | 21 | ## [2.1.0](https://github.com/lyswhut/lx-music-sync-server/compare/v2.0.3...v2.1.0) - 2023-09-14 22 | 23 | ### 新增 24 | 25 | - 添加armv7l docker 镜像构建 26 | 27 | ## [2.0.3](https://github.com/lyswhut/lx-music-sync-server/compare/v2.0.2...v2.0.3) - 2023-09-12 28 | 29 | ### 修复 30 | 31 | - 修复两边设备的数据为空时快照没有被创建的问题 32 | 33 | ## [2.0.2](https://github.com/lyswhut/lx-music-sync-server/compare/v2.0.1...v2.0.2) - 2023-09-10 34 | 35 | ### 修复 36 | 37 | - 修复同步数据字段顺序不一致导致两边列表数据相同时会出现MD5不匹配进而导致多余的同步流程问题 38 | 39 | ## [2.0.1](https://github.com/lyswhut/lx-music-sync-server/compare/v2.0.0...v2.0.1) - 2023-09-10 40 | 41 | ### 修复 42 | 43 | - v1 -> v2的数据迁移时跳过对空数据的用户文件夹处理 44 | 45 | ## [2.0.0](https://github.com/lyswhut/lx-music-sync-server/compare/v1.3.1...v2.0.0) - 2023-09-09 46 | 47 | ### 不兼容性变更 48 | 49 | 该版本修改了同步协议逻辑,同步功能至少需要PC端v2.4.0或移动端v1.1.0或同步服务v2.0.0版本才能连接使用 50 | 这个版本涉及 data 文件夹内的数据迁移,首次运行该版本时会自动将旧版本数据迁移到新版本,数据迁移完毕后不要再降级到v2.0.0之前的版本,否则会出现意料之外的问题,所以在升级前建议备份一下 data 目录 51 | 52 | ### 新增 53 | 54 | - 新增自动压缩数据机制,要传输的数据过大时自动压缩数据以减少传输流量 55 | - 新增对“不喜欢歌曲”列表的同步 56 | 57 | ### 优化 58 | 59 | - 添加重复的客户端连接检测 60 | - 为socket连接添加IP阻止名单校验 61 | - 优化数据传输逻辑,列表同步指令使用队列机制,保证列表同步操作的顺序 62 | - 重构代码,使其易于后续功能扩展 63 | 64 | ### 修复 65 | 66 | - 修复潜在导致列表数据不同步的问题 67 | - 修复密码长度缺陷问题 68 | 69 | ### 变更 70 | 71 | socket的连接地址从原来的 `/` 改为 `/socket`,这意味着不用再像之前那样配置两条规则,可以使用类似以下的方式合并配置: 72 | 73 | ```conf 74 | location /xxx/ { 75 | proxy_set_header X-Real-IP $remote_addr; # 该头部与config.js文件的 proxy.header 对应 76 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 77 | proxy_set_header Host $http_host; 78 | proxy_pass http://127.0.0.1:9527; 79 | proxy_http_version 1.1; 80 | proxy_set_header Upgrade $http_upgrade; 81 | proxy_set_header Connection $connection_upgrade; 82 | } 83 | ``` 84 | 85 | ## [1.3.1](https://github.com/lyswhut/lx-music-sync-server/compare/v1.3.0...v1.3.1) - 2023-03-31 86 | 87 | ### 优化 88 | 89 | - 添加重复的客户端连接检测 90 | - 为socket连接添加IP阻止名单校验 91 | 92 | ### 修复 93 | 94 | - 修复潜在导致列表数据不同步的问题 95 | 96 | ## [1.3.0](https://github.com/lyswhut/lx-music-sync-server/compare/v1.2.3...v1.3.0) - 2023-03-27 97 | 98 | ### 新增 99 | 100 | - 新增从配置文件读取环境变量的功能,在配置文件中,所有以`env.`开头的配置将视为环境变量配置,例如想要在配置文件中指定端口号,可以添加`'env.PORT': '9527'` 101 | 102 | ## [1.2.3](https://github.com/lyswhut/lx-music-sync-server/compare/v1.2.2...v1.2.3) - 2023-03-27 103 | 104 | ### 优化 105 | 106 | - 添加用户空间管理延迟销毁 107 | 108 | ### 修复 109 | 110 | - 修复在环境变量使用简写方式创建用户的数据解析问题(#6) 111 | 112 | ## [1.2.2](https://github.com/lyswhut/lx-music-sync-server/compare/v1.2.1...v1.2.2) - 2023-03-26 113 | 114 | ### 修复 115 | 116 | - 修复默认绑定IP被意外绑定到`0.0.0.0`的问题 117 | 118 | ## [1.2.1](https://github.com/lyswhut/lx-music-sync-server/compare/v1.2.0...v1.2.1) - 2023-03-17 119 | 120 | ### 其他 121 | 122 | - 移除多余的日志输出 123 | 124 | ## [1.2.0](https://github.com/lyswhut/lx-music-sync-server/compare/v1.1.0...v1.2.0) - 2023-03-16 125 | 126 | 该版本配置文件数据结构已更改,更新时请注意更新配置文件 127 | 128 | ### 优化 129 | 130 | - 修改配置文件数据结构 131 | 132 | ## [1.1.0](https://github.com/lyswhut/lx-music-sync-server/compare/v1.0.0...v1.1.0) - 2023-03-16 133 | 134 | 该版本配置文件格式已更改,更新时请注意更新配置文件 135 | 136 | ### 新增 137 | 138 | - 新增多用户支持 139 | - 允许使用环境变量配置更多设置项 140 | 141 | ## 1.0.0 - 2023-03-10 142 | 143 | v1.0.0发布~ 144 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine AS base 2 | 3 | FROM base AS builder 4 | WORKDIR /source-code 5 | COPY . . 6 | 7 | RUN apk add --update \ 8 | g++ \ 9 | make \ 10 | py3-pip \ 11 | nodejs \ 12 | npm \ 13 | && npm ci && npm run build \ 14 | && rm -rf node_modules && npm ci --omit=dev \ 15 | && mkdir build-output \ 16 | && mv server node_modules config.js index.js package.json -t build-output 17 | 18 | 19 | FROM base AS final 20 | WORKDIR /server 21 | 22 | RUN apk add --update --no-cache nodejs 23 | 24 | COPY --from=builder ./source-code/build-output ./ 25 | 26 | VOLUME /server/data 27 | ENV DATA_PATH '/server/data/data' 28 | ENV LOG_PATH '/server/data/logs' 29 | 30 | EXPOSE 9527 31 | ENV NODE_ENV 'production' 32 | ENV PORT 9527 33 | ENV BIND_IP '0.0.0.0' 34 | # ENV PROXY_HEADER 'x-real-ip' 35 | # ENV SERVER_NAME 'My Sync Server' 36 | # ENV MAX_SNAPSHOT_NUM '10' 37 | # ENV LIST_ADD_MUSIC_LOCATION_TYPE 'top' 38 | # ENV LX_USER_user1 '123.123' 39 | # ENV LX_USER_user2 '{ "password": "123.456", "maxSnapshotNum": 10, "list.addMusicLocationType": "top" }' 40 | # ENV CONFIG_PATH '/server/config.js' 41 | # ENV LOG_PATH '/server/logs' 42 | # ENV DATA_PATH '/server/data' 43 | 44 | CMD [ "node", "index.js" ] 45 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # LX Music Sync Server 2 | 3 | LX Music 数据同步服务端。本项目目前用于收藏列表数据同步,类似桌面版的数据同步服务,只不过它现在是一个独立版的服务,可以将其部署到服务器上使用。 4 | 5 | 本项目需要有一些服务器操作经验的人使用,若遇到问题欢迎反馈。 6 | 7 | **由于服务本身不提供 HTTPS 协议支持,若将服务部署在公网,请务必使用 Nginx 之类的服务做反向代理(SSL 证书需可信且[证书链完整](https://stackoverflow.com/a/60020493)),实现客户端到服务器之间的 HTTPS 连接。** 8 | 9 | 10 | ## 环境要求 11 | 12 | - Node.js 16+ 13 | 14 | ## 使用方法 15 | 16 | ### 安装 Node.js 17 | 18 | Cent OS 可以运行以下命令安装: 19 | 20 | ```bash 21 | sudo yum install -y gcc-c++ make 22 | curl -sL https://rpm.nodesource.com/setup_16.x | sudo -E bash - 23 | sudo yum install nodejs -y 24 | ``` 25 | 26 | 基于 Debian、Ubuntu 发行版的系统使用以下命令安装: 27 | 28 | ```bash 29 | sudo apt-get install -y build-essential 30 | curl -fsSL https://deb.nodesource.com/setup_16.x | sudo -E bash - 31 | sudo apt-get install -y nodejs 32 | ``` 33 | 34 | 安装完毕后输入以下命令,正常情况下会显示 Node.js 的版本号。 35 | 36 | ```bash 37 | node -v 38 | ``` 39 | 40 | ### 安装 PM2(非必须) 41 | 42 | PM2 是一个 Node.js 服务管理工具,可以在服务崩溃时自动重启,更多使用方式请自行百度。 43 | 44 | ```bash 45 | npm i -g pm2 46 | ``` 47 | 48 | *注:若安装失败,则可能需要以管理员权限安装。* 49 | 50 | 若没有安装 PM2,则后面 `pm2` 开头的命令都可以跳过。 51 | 52 | ### 安装依赖 53 | 54 | *若安装依赖过程中出现因 `utf-8-validate` 包编译失败的错误,请尝试搜索相关错误解决。若实在无法解决,则可以编辑 `package.json` 文件删除`dependencies` 下的 `utf-8-validate` 后,重新运行 `npm ci --omit=dev` 或 `npm ci` 即可。* 55 | 56 | 如果你是在 GitHub Releases 下载的压缩包,则解压后在项目目录执行以下命令安装依赖: 57 | 58 | ```bash 59 | npm ci --omit=dev 60 | ``` 61 | 62 | 如果你是直接克隆的源码,则在本目录中运行以下命令: 63 | 64 | ```bash 65 | npm ci 66 | npm run build 67 | ``` 68 | 69 | ### 配置 `config.js` 70 | 71 | 按照文件中的说明配置好本目录下的 `config.js` 文件 72 | 73 | ### 配置 `ecosystem.config.js` 中的 `env_production` 74 | 75 | 可以在这里配置 PM2 的启动配置,具体根据你的需求配置 76 | 77 | ### 启动服务器 78 | 79 | ```bash 80 | npm run prd 81 | ``` 82 | 83 | 若你没有安装 PM2,则可以用 `npm start` 启动。 84 | 85 | ### 查看启动日志 86 | 87 | ```bash 88 | pm2 logs 89 | ``` 90 | 91 | 若无报错相关的日志,则说明服务启动成功。 92 | 93 | ### 设置服务开机启动 94 | 95 | ***注意:该命令对 Windows 系统无效,Windows 需用批处理的方式设置。*** 96 | 97 | ```bash 98 | pm2 save 99 | pm2 startup 100 | ``` 101 | 102 | 到这里服务已搭建完成,但是为了你的数据安全,我们**强烈建议**使用 Nginx 之类的服务为同步服务添加 TLS 保护! 103 | 104 | ### 配置 Nginx 105 | 106 | 107 | 108 | #### 说明 109 | 110 | 代理需要配置两条规则: 111 | 112 | 1. 代理链接 URL 根路径下所有子路径的 **WebSocket** 请求到 LX Sync 服务; 113 | 2. 代理链接 URL 根路径下所有子路径的 **HTTP** 请求到 LX Sync 服务。 114 | 115 | #### 配置 116 | 117 | 编辑 Nginx 配置文件,在 `server` 下添加代理规则,如果你当前 `server` 块下只打算配置 LX Sync 服务,那么可以使用以下配置: 118 | 119 | ```conf 120 | map $http_upgrade $connection_upgrade{ 121 | default upgrade; 122 | '' close; 123 | } 124 | server { 125 | # ... 126 | location / { 127 | proxy_set_header X-Real-IP $remote_addr; # 该头部与config.js文件的 proxy.header 对应 128 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 129 | proxy_set_header Host $http_host; 130 | proxy_pass http://127.0.0.1:9527; 131 | proxy_http_version 1.1; 132 | proxy_set_header Upgrade $http_upgrade; 133 | proxy_set_header Connection $connection_upgrade; 134 | } 135 | } 136 | ``` 137 | 138 | 如果你当前 `server` 块下存在其他服务,那么可以配置路径前缀转发: 139 | 140 | ```conf 141 | map $http_upgrade $connection_upgrade{ 142 | default upgrade; 143 | '' close; 144 | } 145 | server { 146 | # ... 147 | location /xxx/ { 148 | proxy_set_header X-Real-IP $remote_addr; # 该头部与config.js文件的 proxy.header 对应 149 | proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; 150 | proxy_set_header Host $http_host; 151 | proxy_pass http://127.0.0.1:9527; 152 | proxy_http_version 1.1; 153 | proxy_set_header Upgrade $http_upgrade; 154 | proxy_set_header Connection $connection_upgrade; 155 | } 156 | } 157 | ``` 158 | 159 | *注:上面的 `xxx` 是你想要代理的路径前缀(可以多级)。* 160 | 161 | 注意 `$remote_addr` 的转发名字与 `config.js` 中的 `proxy.header` 对应,并同时启用 `proxy.enabled`(或与环境变量的 `PROXY_HEADER` 对应),这用于校验相同 IP 多次使用错误连接码连接时的封禁。 162 | 163 | ## 升级新版本 164 | 165 | 若更新日志无特别说明,注意保留**你修改过**的 `config.js`、`ecosystem.config.js` 或 `Dockerfile` 之类的配置文件,以及 `data`、`logs` 目录即可,其他的都可以删除后再将新版本的文件复制进去,以下是更新日志无特别说明的更新流程: 166 | 167 | 使用在 GitHub Releases 下载的压缩包运行的服务: 168 | 169 | 1. 删除项目目录下的 `server`、`node_modules` 目录以及 `index.js`、`package.json`、`package-lock.json` 文件; 170 | 2. 将新版本的 `server` 目录以及 `index.js`、`package.json`、`package-lock.json` 文件复制进去; 171 | 3. 执行 `npm ci --omit=dev`; 172 | 4. 重启服务,执行 `pm2 restart <服务名称/ID>` 重启服务(可以先执行 `pm2 list` 查看服务 ID 或名称)。 173 | 174 | 使用源码编译运行的服务: 175 | 176 | 1. 重新下载源码或使用 Git 将代码更新到最新版本; 177 | 2. 执行 `npm ci` 与 `npm run build`; 178 | 3. 重启你的服务。 179 | 180 | 使用 `docker:` 将代码更新到最新后,再打包镜像即可。 181 | 182 | ## 从快照文件恢复数据 183 | 184 | 方式一: 185 | 186 | 使用快照文件转换工具将其转换成列表备份文件后再导入备份:https://lyswhut.github.io/lx-music-sync-snapshot-transform/ 187 | 188 | 方式二: 189 | 190 | 1. 停止同步服务; 191 | 2. 修改 `data/users/<用户名>/list/snapshotInfo.json` 里面的 `latest` 为你那个备份文件的 key 名(即 `snapshot` 文件夹下去掉 `snapshot_` 前缀后的名字); 192 | 3. 删除 `snapshotInfo.json` 文件内 `clients` 内的所有设备信息,删除后的内容类似于:`{...其他内容,"clients":{}}`; 193 | 4. 启用同步服务,连接后勾选「完全覆盖」,选择「远程覆盖本地」。 194 | 195 | ## 附录 196 | 197 | ### 可用的环境变量 198 | 199 | | 变量名称 | 说明 | 200 | | --- | --- | 201 | | `PORT` | 绑定的端口号,默认为 `9527`。 | 202 | | `BIND_IP` | 绑定的 IP 地址,默认为 `127.0.0.1`,使用 `0.0.0.0` 将接受所有 IPv4 请求,使用 `::` 将接受所有 IP 请求。 | 203 | | `PROXY_HEADER` | 代理转发的请求头 原始 IP,如果设置,则自动启用。 | 204 | | `CONFIG_PATH` | 配置文件路径,默认使用项目目录下的 `config.js`。 | 205 | | `LOG_PATH` | 服务日志保存路径,默认保存在服务目录下的 `logs` 文件夹内。 | 206 | | `DATA_PATH` | 同步数据保存路径,默认保存在服务目录下的 `data` 文件夹内。 | 207 | | `MAX_SNAPSHOT_NUM` | 公共最大备份快照数。 | 208 | | `SERVER_NAME` | 同步服务名称。 | 209 | | `LIST_ADD_MUSIC_LOCATION_TYPE` | 公共添加歌曲到我的列表时的方式,可用值为 `top` 和 `bottom`。 | 210 | | `LX_USER_` | 以 `LX_USER_` 开头的环境变量将被识别为用户配置,可用的配置语法为:
1. `LX_USER_user1='xxx'`;
2. `LX_USER_user1='{"password":"xxx"}'`。
其中 `LX_USER_` 会被去掉,剩下的 `user1` 为用户名,`xxx` 为用户密码(**连接码**)。
配置方式 1 为简写模式,只指定用户名及密码(链接码),其他配置使用公共配置。
配置方式 2 为 JSON 字符串格式,配置内容参考 `config.js`,由于该方式在变量名指定了用户名,所以 JSON 里的用户名是可选的。 | 211 | 212 | ### PM2 常用命令 213 | 214 | - 查看服务列表:`pm2 list`。 215 | - 服务控制台的输出日志:`pm2 logs`。 216 | - 重启服务:`pm2 restart <服务名称/ID>`。 217 | - 停止服务:`pm2 stop <服务名称/ID>`。 218 | 219 | ### Docker 220 | 221 | 可以使用以下方式构建 docker 镜像(Dockerfile 用的是源码构建): 222 | 223 | ```bash 224 | docker build -t lx-music-sync-server . 225 | ``` 226 | 227 | 或者使用已发布到 Docker Hub 的镜像: 228 | 229 | 也可以看此 Issue 提供的解决方案: 230 | -------------------------------------------------------------------------------- /config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | serverName: 'My Sync Server', // 同步服务名称 3 | 'proxy.enabled': false, // 是否使用代理转发请求到本服务器 4 | 'proxy.header': 'x-real-ip', // 代理转发的请求头,原始 IP 5 | 6 | maxSnapshotNum: 10, // 公共最大备份快照数 7 | 'list.addMusicLocationType': 'top', // 公共添加歌曲到我的列表时的位置 top | bottom,参考客户端的「设置 → 列表设置 → 添加歌曲到列表时的位置」 8 | 9 | users: [ 10 | // 用户配置例子,有两种配置格式 11 | // { 12 | // name: 'user1', // 用户名,必须,不能与其他用户名重复 13 | // password: '123.def', // 连接密码,必须,不能与其他用户密码重复,若在外网,务必增加密码复杂度 14 | // maxSnapshotNum: 10, // 可选,最大备份快照数 15 | // 'list.addMusicLocationType': 'top', // 可选,添加歌曲到我的列表时的位置 top | bottom,参考客户端的「设置 → 列表设置 → 添加歌曲到列表时的位置」 16 | // }, 17 | ], 18 | 19 | 20 | // 所有名称以 `env.` 开头的配置将解析成环境变量 21 | // 'env.PORT': '9527', 22 | // 'env.BIND_IP': '0.0.0.0', 23 | // ...其他环境变量看 Readme.md 内附录的「可用的环境变量」部分 24 | } 25 | -------------------------------------------------------------------------------- /ecosystem.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | apps: [ 3 | { 4 | name: 'lx-music-sync-server', 5 | script: './index.js', 6 | // node_args: '-r ts-node/register -r tsconfig-paths/register', 7 | // script: './bin/www', 8 | max_memory_restart: '1024M', 9 | stop_exit_codes: [0], 10 | exp_backoff_restart_delay: 100, 11 | watch: false, 12 | ignore_watch: ['node_modules', 'logs', 'data'], 13 | env: { 14 | // PORT: 3100, 15 | NODE_ENV: 'development', 16 | }, 17 | env_production: { 18 | // PORT: 3100, // 配置绑定的端口号,默认为 9527 19 | // BIND_IP: '0.0.0.0', // 配置绑定 IP 为`0.0.0.0` 将接受所有 IP 访问 20 | // CONFIG_PATH: '', 21 | // LOG_PATH: '', 22 | // DATA_PATH: '', 23 | NODE_ENV: 'production', 24 | }, 25 | }, 26 | ], 27 | } 28 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | if (process.env.NODE_ENV == null) process.env.NODE_ENV = 'production' 3 | require('./server') 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lx-music-sync-server", 3 | "version": "2.1.2", 4 | "private": true, 5 | "scripts": { 6 | "build": "rimraf server && tsc --project tsconfig.json && tsc-alias -p tsconfig.json", 7 | "start": "node ./index.js", 8 | "dev": "nodemon", 9 | "prd": "pm2 start ecosystem.config.js --env production", 10 | "publish": "node publish" 11 | }, 12 | "engines": { 13 | "node": ">= 16", 14 | "npm": ">= 8.5.2" 15 | }, 16 | "repository": { 17 | "type": "git", 18 | "url": "git+https://github.com/lyswhut/lx-music-sync-server.git" 19 | }, 20 | "author": { 21 | "name": "lyswhut", 22 | "email": "lyswhut@qq.com" 23 | }, 24 | "license": "Apache-2.0", 25 | "bugs": { 26 | "url": "https://github.com/lyswhut/lx-music-sync-server/issues" 27 | }, 28 | "homepage": "https://github.com/lyswhut/lx-music-sync-server#readme", 29 | "dependencies": { 30 | "bufferutil": "^4.0.8", 31 | "log4js": "^6.9.1", 32 | "lru-cache": "^10.0.1", 33 | "message2call": "^0.1.3", 34 | "utf-8-validate": "^6.0.3", 35 | "ws": "^8.14.2" 36 | }, 37 | "devDependencies": { 38 | "@tsconfig/node16": "^16.1.1", 39 | "@types/ws": "8.5.4", 40 | "chalk": "^4.1.2", 41 | "changelog-parser": "^3.0.1", 42 | "eslint-config-standard-with-typescript": "^39.1.1", 43 | "nodemon": "^3.0.1", 44 | "rimraf": "^5.0.5", 45 | "ts-node": "^10.9.1", 46 | "tsc-alias": "^1.8.8", 47 | "tsconfig-paths": "^4.2.0", 48 | "typescript": "^5.2.2" 49 | }, 50 | "nodemonConfig": { 51 | "ext": "js,ts", 52 | "ignore": [], 53 | "exec": "node -r ts-node/register -r tsconfig-paths/register ./src/index.ts" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /publish/changeLog.md: -------------------------------------------------------------------------------- 1 | ### 修复 2 | 3 | - 修复绑定IP为IPv6时,连接用户时会导致服务报错的问题(#55) 4 | -------------------------------------------------------------------------------- /publish/index.js: -------------------------------------------------------------------------------- 1 | const chalk = require('chalk') 2 | const updateVersionFile = require('./updateChangeLog') 3 | 4 | const run = async() => { 5 | // const params = parseArgv(process.argv.slice(2)) 6 | // const bak = await updateVersionFile(params.ver) 7 | await updateVersionFile(process.argv.slice(2)[0]) 8 | console.log(chalk.green('日志更新完成~')) 9 | } 10 | 11 | 12 | run() 13 | -------------------------------------------------------------------------------- /publish/updateChangeLog.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const { jp, formatTime } = require('./utils') 3 | const pkgDir = '../package.json' 4 | const pkg = require(pkgDir) 5 | const chalk = require('chalk') 6 | const parseChangelog = require('changelog-parser') 7 | const changelogPath = jp('../CHANGELOG.md') 8 | 9 | const getPrevVer = () => parseChangelog(changelogPath).then(res => { 10 | if (!res.versions.length) throw new Error('CHANGELOG 无法解析到版本号') 11 | return res.versions[0].version 12 | }) 13 | 14 | const updateChangeLog = async(newVerNum, newChangeLog) => { 15 | let changeLog = fs.readFileSync(changelogPath, 'utf-8') 16 | const prevVer = await getPrevVer() 17 | const log = `## [${newVerNum}](${pkg.repository.url.replace(/^git\+(http.+)\.git$/, '$1')}/compare/v${prevVer}...v${newVerNum}) - ${formatTime()}\n\n${newChangeLog}` 18 | fs.writeFileSync(changelogPath, changeLog.replace(/(## [?0.1.1]?)/, log + '\n$1'), 'utf-8') 19 | } 20 | 21 | 22 | module.exports = async newVerNum => { 23 | if (!newVerNum) { 24 | let verArr = pkg.version.split('.') 25 | verArr[verArr.length - 1] = parseInt(verArr[verArr.length - 1]) + 1 26 | newVerNum = verArr.join('.') 27 | } 28 | const newMDChangeLog = fs.readFileSync(jp('./changeLog.md'), 'utf-8') 29 | pkg.version = newVerNum 30 | 31 | console.log(chalk.blue('new version: ') + chalk.green(newVerNum)) 32 | 33 | await updateChangeLog(newVerNum, newMDChangeLog) 34 | 35 | fs.writeFileSync(jp(pkgDir), JSON.stringify(pkg, null, 2) + '\n', 'utf-8') 36 | } 37 | 38 | -------------------------------------------------------------------------------- /publish/utils.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs') 2 | const path = require('path') 3 | 4 | exports.jp = (...p) => p.length ? path.join(__dirname, ...p) : __dirname 5 | 6 | exports.copyFile = (source, target) => new Promise((resolve, reject) => { 7 | const rd = fs.createReadStream(source) 8 | rd.on('error', err => { 9 | reject(err) 10 | }) 11 | const wr = fs.createWriteStream(target) 12 | wr.on('error', err => { 13 | reject(err) 14 | }) 15 | wr.on('close', () => resolve()) 16 | rd.pipe(wr) 17 | }) 18 | 19 | /** 20 | * 时间格式化 21 | * @param {Date} d 格式化的时间 22 | * @param {boolean} b 是否精确到秒 23 | */ 24 | exports.formatTime = (d, b) => { 25 | const _date = d == null ? new Date() : typeof d == 'string' ? new Date(d) : d 26 | const year = _date.getFullYear() 27 | const month = fm(_date.getMonth() + 1) 28 | const day = fm(_date.getDate()) 29 | if (!b) return year + '-' + month + '-' + day 30 | return year + '-' + month + '-' + day + ' ' + fm(_date.getHours()) + ':' + fm(_date.getMinutes()) + ':' + fm(_date.getSeconds()) 31 | } 32 | 33 | function fm(value) { 34 | if (value < 10) return '0' + value 35 | return value 36 | } 37 | 38 | exports.sizeFormate = size => { 39 | // https://gist.github.com/thomseddon/3511330 40 | if (!size) return '0 b' 41 | let units = ['b', 'kB', 'MB', 'GB', 'TB'] 42 | let number = Math.floor(Math.log(size) / Math.log(1024)) 43 | return `${(size / Math.pow(1024, Math.floor(number))).toFixed(2)} ${units[number]}` 44 | } 45 | 46 | exports.parseArgv = argv => { 47 | const params = {} 48 | argv.forEach(item => { 49 | const argv = item.split('=') 50 | switch (argv[0]) { 51 | case 'ver': 52 | params.ver = argv[1] 53 | break 54 | case 'draft': 55 | params.isDraft = argv[1] === 'true' || argv[1] === undefined 56 | break 57 | case 'prerelease': 58 | params.isPrerelease = argv[1] === 'true' || argv[1] === undefined 59 | break 60 | case 'target_commitish': 61 | params.target_commitish = argv[1] 62 | break 63 | } 64 | }) 65 | return params 66 | } 67 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | export const ENV_PARAMS = [ 2 | 'PORT', 3 | 'BIND_IP', 4 | 'CONFIG_PATH', 5 | 'LOG_PATH', 6 | 'DATA_PATH', 7 | 'PROXY_HEADER', 8 | 'MAX_SNAPSHOT_NUM', 9 | 'LIST_ADD_MUSIC_LOCATION_TYPE', 10 | 'LX_USER_', 11 | ] as const 12 | 13 | export const SPLIT_CHAR = { 14 | DISLIKE_NAME: '@', 15 | DISLIKE_NAME_ALIAS: '#', 16 | } as const 17 | 18 | export const LIST_IDS = { 19 | DEFAULT: 'default', 20 | LOVE: 'love', 21 | TEMP: 'temp', 22 | DOWNLOAD: 'download', 23 | PLAY_LATER: null, 24 | } as const 25 | 26 | export const SYNC_CODE = { 27 | helloMsg: 'Hello~::^-^::~v4~', 28 | idPrefix: 'OjppZDo6', 29 | authMsg: 'lx-music auth::', 30 | msgAuthFailed: 'Auth failed', 31 | msgBlockedIp: 'Blocked IP', 32 | msgConnect: 'lx-music connect', 33 | 34 | 35 | authFailed: 'Auth failed', 36 | missingAuthCode: 'Missing auth code', 37 | getServiceIdFailed: 'Get service id failed', 38 | connectServiceFailed: 'Connect service failed', 39 | connecting: 'Connecting...', 40 | unknownServiceAddress: 'Unknown service address', 41 | } as const 42 | 43 | export const SYNC_CLOSE_CODE = { 44 | normal: 1000, 45 | failed: 4100, 46 | } as const 47 | 48 | export const TRANS_MODE: Readonly> = { 49 | merge_local_remote: 'merge_remote_local', 50 | merge_remote_local: 'merge_local_remote', 51 | overwrite_local_remote: 'overwrite_remote_local', 52 | overwrite_remote_local: 'overwrite_local_remote', 53 | overwrite_local_remote_full: 'overwrite_remote_local_full', 54 | overwrite_remote_local_full: 'overwrite_local_remote_full', 55 | cancel: 'cancel', 56 | } as const 57 | 58 | export const File = { 59 | serverInfoJSON: 'serverInfo.json', 60 | userDir: 'users', 61 | userDevicesJSON: 'devices.json', 62 | listDir: 'list', 63 | listSnapshotDir: 'snapshot', 64 | listSnapshotInfoJSON: 'snapshotInfo.json', 65 | dislikeDir: 'dislike', 66 | dislikeSnapshotDir: 'snapshot', 67 | dislikeSnapshotInfoJSON: 'snapshotInfo.json', 68 | } as const 69 | 70 | export const FeaturesList = [ 71 | 'list', 72 | 'dislike', 73 | ] as const 74 | -------------------------------------------------------------------------------- /src/defaultConfig.ts: -------------------------------------------------------------------------------- 1 | 2 | const config: LX.Config = { 3 | serverName: 'My Sync Server', // 同步服务名称 4 | 'proxy.enabled': false, // 是否使用代理转发请求到本服务器 5 | 'proxy.header': 'x-real-ip', // 代理转发的请求头 原始IP 6 | 7 | maxSnapshotNum: 10, // 公共最大备份快照数 8 | 'list.addMusicLocationType': 'top', // 公共添加歌曲到我的列表时的位置 top | bottom,参考客户端的「设置 → 列表设置 → 添加歌曲到列表时的位置」 9 | 10 | users: [ 11 | // 用户配置例子 12 | // { 13 | // name: 'user1', // 用户名,必须,不能与其他用户名重复 14 | // password: '123.def', // 是连接密码,必须,不能与其他用户密码重复,若在外网,务必增加密码复杂度 15 | // maxSnapshotNum: 10, // 可选,最大备份快照数 16 | // 'list.addMusicLocationType': 'top', // 可选,添加歌曲到我的列表时的位置 top | bottom,参考客户端的「设置 → 列表设置 → 添加歌曲到列表时的位置」 17 | // }, 18 | ], 19 | } 20 | 21 | export default config 22 | -------------------------------------------------------------------------------- /src/event.ts: -------------------------------------------------------------------------------- 1 | // import { Event as App, type Type as AppType } from './AppEvent' 2 | import { 3 | ListEvent, 4 | type ListEventType, 5 | DislikeEvent, 6 | type DislikeEventType, 7 | } from '@/modules' 8 | 9 | export type { 10 | // AppType, 11 | ListEventType, 12 | DislikeEventType, 13 | } 14 | 15 | // export const createAppEvent = (): AppType => { 16 | // return new App() 17 | // } 18 | 19 | export const createModuleEvent = () => { 20 | global.event_list = new ListEvent() 21 | global.event_dislike = new DislikeEvent() 22 | } 23 | 24 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import fs from 'fs' 4 | import path from 'path' 5 | import { initLogger } from '@/utils/log4js' 6 | import defaultConfig from './defaultConfig' 7 | import { ENV_PARAMS, File } from './constants' 8 | import { checkAndCreateDirSync } from './utils' 9 | 10 | type ENV_PARAMS_Type = typeof ENV_PARAMS 11 | type ENV_PARAMS_Value_Type = ENV_PARAMS_Type[number] 12 | 13 | let envParams: Partial, string>> = {} 14 | let envUsers: LX.User[] = [] 15 | const envParamKeys = Object.values(ENV_PARAMS).filter(v => v != 'LX_USER_') 16 | 17 | { 18 | const envLog = [ 19 | ...(envParamKeys.map(e => [e, process.env[e]]) as Array<[Exclude, string]>).filter(([k, v]) => { 20 | if (!v) return false 21 | envParams[k] = v 22 | return true 23 | }), 24 | ...Object.entries(process.env) 25 | .filter(([k, v]) => { 26 | if (k.startsWith('LX_USER_') && !!v) { 27 | const name = k.replace('LX_USER_', '') 28 | if (name) { 29 | envUsers.push({ 30 | name, 31 | password: v, 32 | }) 33 | return true 34 | } 35 | } 36 | return false 37 | }), 38 | ].map(([e, v]) => `${e}: ${v as string}`) 39 | if (envLog.length) console.log(`Load env: \n ${envLog.join('\n ')}`) 40 | } 41 | 42 | const dataPath = envParams.DATA_PATH ?? path.join(__dirname, '../data') 43 | global.lx = { 44 | logPath: envParams.LOG_PATH ?? path.join(__dirname, '../logs'), 45 | dataPath, 46 | userPath: path.join(dataPath, File.userDir), 47 | config: defaultConfig, 48 | } 49 | 50 | const mergeConfigFileEnv = (config: Partial>) => { 51 | const envLog = [] 52 | for (const [k, v] of Object.entries(config).filter(([k]) => k.startsWith('env.'))) { 53 | const envKey = k.replace('env.', '') as keyof typeof envParams 54 | let value = String(v) 55 | if (envParamKeys.includes(envKey)) { 56 | if (envParams[envKey] == null) { 57 | envLog.push(`${envKey}: ${value}`) 58 | envParams[envKey] = value 59 | } 60 | } else if (envKey.startsWith('LX_USER_') && value) { 61 | const name = k.replace('LX_USER_', '') 62 | if (name) { 63 | envUsers.push({ 64 | name, 65 | password: value, 66 | }) 67 | envLog.push(`${envKey}: ${value}`) 68 | } 69 | } 70 | } 71 | if (envLog.length) console.log(`Load config file env:\n ${envLog.join('\n ')}`) 72 | } 73 | 74 | const margeConfig = (p: string) => { 75 | let config 76 | try { 77 | config = path.extname(p) == '.js' 78 | ? require(p) 79 | : JSON.parse(fs.readFileSync(p).toString()) as LX.Config 80 | } catch (err: any) { 81 | console.warn('Read config error: ' + (err.message as string)) 82 | return false 83 | } 84 | const newConfig = { ...global.lx.config } 85 | for (const key of Object.keys(defaultConfig) as Array) { 86 | // @ts-expect-error 87 | if (config[key] !== undefined) newConfig[key] = config[key] 88 | } 89 | 90 | console.log('Load config: ' + p) 91 | if (newConfig.users.length) { 92 | const users: LX.UserConfig[] = [] 93 | for (const user of newConfig.users) { 94 | users.push({ 95 | ...user, 96 | dataPath: '', 97 | }) 98 | } 99 | newConfig.users = users 100 | } 101 | global.lx.config = newConfig 102 | 103 | mergeConfigFileEnv(config) 104 | return true 105 | } 106 | 107 | 108 | const p1 = path.join(__dirname, '../config.js') 109 | fs.existsSync(p1) && margeConfig(p1) 110 | envParams.CONFIG_PATH && fs.existsSync(envParams.CONFIG_PATH) && margeConfig(envParams.CONFIG_PATH) 111 | if (envParams.PROXY_HEADER) { 112 | global.lx.config['proxy.enabled'] = true 113 | global.lx.config['proxy.header'] = envParams.PROXY_HEADER 114 | } 115 | if (envParams.MAX_SNAPSHOT_NUM) { 116 | const num = parseInt(envParams.MAX_SNAPSHOT_NUM) 117 | if (!isNaN(num)) global.lx.config.maxSnapshotNum = num 118 | } 119 | if (envParams.LIST_ADD_MUSIC_LOCATION_TYPE) { 120 | switch (envParams.LIST_ADD_MUSIC_LOCATION_TYPE) { 121 | case 'top': 122 | case 'bottom': 123 | global.lx.config['list.addMusicLocationType'] = envParams.LIST_ADD_MUSIC_LOCATION_TYPE 124 | break 125 | } 126 | } 127 | 128 | if (envUsers.length) { 129 | const users: LX.Config['users'] = [] 130 | let u 131 | for (let user of envUsers) { 132 | let isLikeJSON = true 133 | try { 134 | u = JSON.parse(user.password) as Omit 135 | } catch { 136 | isLikeJSON = false 137 | } 138 | if (isLikeJSON && typeof u == 'object') { 139 | users.push({ 140 | name: user.name, 141 | ...u, 142 | dataPath: '', 143 | }) 144 | } else { 145 | users.push({ 146 | name: user.name, 147 | password: user.password, 148 | dataPath: '', 149 | }) 150 | } 151 | } 152 | global.lx.config.users = users 153 | } 154 | 155 | const exit = (message: string): never => { 156 | console.error(message) 157 | process.exit(0) 158 | } 159 | 160 | const checkAndCreateDir = (path: string) => { 161 | try { 162 | checkAndCreateDirSync(path) 163 | } catch (e: any) { 164 | if (e.code !== 'EEXIST') { 165 | exit(`Could not set up log directory, error was: ${e.message as string}`) 166 | } 167 | } 168 | } 169 | 170 | const checkUserConfig = (users: LX.Config['users']) => { 171 | const userNames: string[] = [] 172 | const passwords: string[] = [] 173 | for (const user of users) { 174 | if (userNames.includes(user.name)) exit('User name duplicate: ' + user.name) 175 | if (passwords.includes(user.password)) exit('User password duplicate: ' + user.password) 176 | userNames.push(user.name) 177 | passwords.push(user.password) 178 | } 179 | } 180 | 181 | checkAndCreateDir(global.lx.logPath) 182 | checkAndCreateDir(global.lx.dataPath) 183 | checkAndCreateDir(global.lx.userPath) 184 | checkUserConfig(global.lx.config.users) 185 | 186 | console.log(`Users: 187 | ${global.lx.config.users.map(user => ` ${user.name}: ${user.password}`).join('\n') || ' No User'} 188 | `) 189 | // eslint-disable-next-line @typescript-eslint/no-var-requires 190 | const { getUserDirname } = require('@/user') 191 | for (const user of global.lx.config.users) { 192 | const dataPath = path.join(global.lx.userPath, getUserDirname(user.name)) 193 | checkAndCreateDir(dataPath) 194 | user.dataPath = dataPath 195 | } 196 | initLogger() 197 | 198 | 199 | /** 200 | * Normalize a port into a number, string, or false. 201 | */ 202 | 203 | function normalizePort(val: string) { 204 | const port = parseInt(val, 10) 205 | 206 | if (isNaN(port) || port < 1) { 207 | // named pipe 208 | exit(`port illegal: ${val}`) 209 | } 210 | return port 211 | } 212 | 213 | /** 214 | * Get port from environment and store in Express. 215 | */ 216 | 217 | const port = normalizePort(envParams.PORT ?? '9527') 218 | const bindIP = envParams.BIND_IP ?? '127.0.0.1' 219 | 220 | // eslint-disable-next-line @typescript-eslint/no-var-requires 221 | const { createModuleEvent } = require('@/event') 222 | createModuleEvent() 223 | 224 | // eslint-disable-next-line @typescript-eslint/no-var-requires 225 | require('@/utils/migrate').default(global.lx.dataPath, global.lx.userPath) 226 | 227 | // eslint-disable-next-line @typescript-eslint/no-var-requires 228 | const { startServer } = require('@/server') 229 | startServer(port, bindIP) 230 | 231 | -------------------------------------------------------------------------------- /src/modules/dislike/dislikeDataManage.ts: -------------------------------------------------------------------------------- 1 | import { SPLIT_CHAR } from '@/constants' 2 | import { type SnapshotDataManage } from './snapshotDataManage' 3 | import { filterRules } from './utils' 4 | 5 | const filterRulesToString = (rules: string) => { 6 | return Array.from(filterRules(rules)).join('\n') 7 | } 8 | 9 | export class DislikeDataManage { 10 | snapshotDataManage: SnapshotDataManage 11 | dislikeRules = '' 12 | 13 | constructor(snapshotDataManage: SnapshotDataManage) { 14 | this.snapshotDataManage = snapshotDataManage 15 | 16 | let dislikeRules: LX.Dislike.DislikeRules | null 17 | void this.snapshotDataManage.getSnapshotInfo().then(async(snapshotInfo) => { 18 | if (snapshotInfo.latest) dislikeRules = await this.snapshotDataManage.getSnapshot(snapshotInfo.latest) 19 | if (!dislikeRules) dislikeRules = '' 20 | this.dislikeRules = dislikeRules 21 | }) 22 | } 23 | 24 | getDislikeRules = async(): Promise => { 25 | return this.dislikeRules 26 | } 27 | 28 | addDislikeInfo = async(infos: LX.Dislike.DislikeMusicInfo[]) => { 29 | this.dislikeRules = filterRulesToString(this.dislikeRules + '\n' + infos.map(info => `${info.name ?? ''}${SPLIT_CHAR.DISLIKE_NAME}${info.singer ?? ''}`).join('\n')) 30 | return this.dislikeRules 31 | } 32 | 33 | overwirteDislikeInfo = async(rules: string) => { 34 | this.dislikeRules = filterRulesToString(rules) 35 | return this.dislikeRules 36 | } 37 | } 38 | 39 | -------------------------------------------------------------------------------- /src/modules/dislike/event.ts: -------------------------------------------------------------------------------- 1 | import { getUserSpace } from '@/user' 2 | import { EventEmitter } from 'events' 3 | // import { 4 | // userDislikeCreate, 5 | // userDislikesUpdate, 6 | // userDislikesRemove, 7 | // userDislikesUpdatePosition, 8 | // dislikeDataOverwrite, 9 | // dislikeMusicOverwrite, 10 | // dislikeMusicAdd, 11 | // dislikeMusicMove, 12 | // dislikeMusicRemove, 13 | // dislikeMusicUpdateInfo, 14 | // dislikeMusicUpdatePosition, 15 | // dislikeMusicClear, 16 | // } from '@/dislikeManage/action' 17 | 18 | 19 | const dislikeUpdated = () => { 20 | // return createSnapshot() 21 | } 22 | export const checkUpdateDislike = async(changedIds: string[]) => { 23 | // if (!changedIds.length) return 24 | // await saveDislikeMusics(changedIds.map(id => ({ id, musics: allMusicDislike.get(id) as LX.Dislike.DislikeMusics }))) 25 | // global.app_event.myDislikeMusicUpdate(changedIds) 26 | } 27 | 28 | 29 | export class DislikeEvent extends EventEmitter { 30 | dislike_changed() { 31 | this.emit('dislike_changed') 32 | } 33 | 34 | /** 35 | * 覆盖整个列表数据 36 | * @param dislikeData 列表数据 37 | * @param isRemote 是否属于远程操作 38 | */ 39 | async dislike_data_overwrite(userName: string, dislikeData: LX.Dislike.DislikeRules, isRemote: boolean = false) { 40 | const userSpace = getUserSpace(userName) 41 | await userSpace.dislikeManage.dislikeDataManage.overwirteDislikeInfo(dislikeData) 42 | this.emit('dislike_data_overwrite', userName, dislikeData, isRemote) 43 | dislikeUpdated() 44 | } 45 | 46 | /** 47 | * 批量添加歌曲到列表 48 | * @param dislikeId 列表id 49 | * @param musicInfos 添加的歌曲信息 50 | * @param addMusicLocationType 添加在到列表的位置 51 | * @param isRemote 是否属于远程操作 52 | */ 53 | async dislike_music_add(userName: string, musicInfo: LX.Dislike.DislikeMusicInfo[], isRemote: boolean = false) { 54 | const userSpace = getUserSpace(userName) 55 | // const changedIds = 56 | await userSpace.dislikeManage.dislikeDataManage.addDislikeInfo(musicInfo) 57 | // await checkUpdateDislike(changedIds) 58 | this.emit('dislike_music_add', userName, musicInfo, isRemote) 59 | dislikeUpdated() 60 | } 61 | 62 | /** 63 | * 清空列表内的歌曲 64 | * @param ids 列表Id 65 | * @param isRemote 是否属于远程操作 66 | */ 67 | async dislike_music_clear(userName: string, isRemote: boolean = false) { 68 | const userSpace = getUserSpace(userName) 69 | // const changedIds = 70 | await userSpace.dislikeManage.dislikeDataManage.overwirteDislikeInfo('') 71 | // await checkUpdateDislike(changedIds) 72 | this.emit('dislike_music_clear', userName, isRemote) 73 | dislikeUpdated() 74 | } 75 | } 76 | 77 | 78 | type EventMethods = Omit 79 | declare class EventType extends DislikeEvent { 80 | on(event: K, dislikeener: EventMethods[K]): this 81 | once(event: K, dislikeener: EventMethods[K]): this 82 | off(event: K, dislikeener: EventMethods[K]): this 83 | } 84 | export type DislikeEventType = Omit> 85 | -------------------------------------------------------------------------------- /src/modules/dislike/index.ts: -------------------------------------------------------------------------------- 1 | export * as sync from './sync' 2 | export { DislikeManage } from './manage' 3 | export { DislikeEvent, type DislikeEventType } from './event' 4 | -------------------------------------------------------------------------------- /src/modules/dislike/manage.ts: -------------------------------------------------------------------------------- 1 | import { type UserDataManage } from '@/user' 2 | import { SnapshotDataManage } from './snapshotDataManage' 3 | import { DislikeDataManage } from './dislikeDataManage' 4 | import { toMD5 } from '@/utils' 5 | 6 | export class DislikeManage { 7 | snapshotDataManage: SnapshotDataManage 8 | dislikeDataManage: DislikeDataManage 9 | 10 | constructor(userDataManage: UserDataManage) { 11 | this.snapshotDataManage = new SnapshotDataManage(userDataManage) 12 | this.dislikeDataManage = new DislikeDataManage(this.snapshotDataManage) 13 | } 14 | 15 | createSnapshot = async() => { 16 | const listData = await this.getDislikeRules() 17 | const md5 = toMD5(listData.trim()) 18 | const snapshotInfo = await this.snapshotDataManage.getSnapshotInfo() 19 | console.log(md5, snapshotInfo.latest) 20 | if (snapshotInfo.latest == md5) return md5 21 | if (snapshotInfo.list.includes(md5)) { 22 | snapshotInfo.list.splice(snapshotInfo.list.indexOf(md5), 1) 23 | } else await this.snapshotDataManage.saveSnapshot(md5, listData) 24 | if (snapshotInfo.latest) snapshotInfo.list.unshift(snapshotInfo.latest) 25 | snapshotInfo.latest = md5 26 | snapshotInfo.time = Date.now() 27 | this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) 28 | return md5 29 | } 30 | 31 | getCurrentListInfoKey = async() => { 32 | const snapshotInfo = await this.snapshotDataManage.getSnapshotInfo() 33 | if (snapshotInfo.latest) return snapshotInfo.latest 34 | // snapshotInfo.latest = toMD5((await this.getDislikeRules()).trim()) 35 | // this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) 36 | return this.createSnapshot() 37 | } 38 | 39 | getDeviceCurrentSnapshotKey = async(clientId: string) => { 40 | return this.snapshotDataManage.getDeviceCurrentSnapshotKey(clientId) 41 | } 42 | 43 | updateDeviceSnapshotKey = async(clientId: string, key: string) => { 44 | await this.snapshotDataManage.updateDeviceSnapshotKey(clientId, key) 45 | } 46 | 47 | removeDevice = async(clientId: string) => { 48 | this.snapshotDataManage.removeSnapshotInfo(clientId) 49 | } 50 | 51 | getDislikeRules = async() => { 52 | return await this.dislikeDataManage.getDislikeRules() 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /src/modules/dislike/snapshotDataManage.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from '@/utils/common' 2 | import fs from 'node:fs' 3 | import path from 'node:path' 4 | import { syncLog } from '@/utils/log4js' 5 | import { checkAndCreateDirSync } from '@/utils' 6 | import { getUserConfig, type UserDataManage } from '@/user/data' 7 | import { File } from '@/constants' 8 | 9 | interface SnapshotInfo { 10 | latest: string | null 11 | time: number 12 | list: string[] 13 | clients: Record 14 | } 15 | export class SnapshotDataManage { 16 | userDataManage: UserDataManage 17 | dislikeDir: string 18 | snapshotDir: string 19 | snapshotInfoFilePath: string 20 | snapshotInfo: SnapshotInfo 21 | clientSnapshotKeys: string[] 22 | private readonly saveSnapshotInfoThrottle: () => void 23 | 24 | isIncluedsDevice = (key: string) => { 25 | return this.clientSnapshotKeys.includes(key) 26 | } 27 | 28 | clearOldSnapshot = async() => { 29 | if (!this.snapshotInfo) return 30 | const snapshotList = this.snapshotInfo.list.filter(key => !this.isIncluedsDevice(key)) 31 | // console.log(snapshotList.length, lx.config.maxSnapshotNum) 32 | const userMaxSnapshotNum = getUserConfig(this.userDataManage.userName).maxSnapshotNum 33 | let requiredSave = snapshotList.length > userMaxSnapshotNum 34 | while (snapshotList.length > userMaxSnapshotNum) { 35 | const name = snapshotList.pop() 36 | if (name) { 37 | await this.removeSnapshot(name) 38 | this.snapshotInfo.list.splice(this.snapshotInfo.list.indexOf(name), 1) 39 | } else break 40 | } 41 | if (requiredSave) this.saveSnapshotInfo(this.snapshotInfo) 42 | } 43 | 44 | updateDeviceSnapshotKey = async(clientId: string, key: string) => { 45 | // console.log('updateDeviceSnapshotKey', key) 46 | let client = this.snapshotInfo.clients[clientId] 47 | if (!client) client = this.snapshotInfo.clients[clientId] = { snapshotKey: '', lastSyncDate: 0 } 48 | if (client.snapshotKey) this.clientSnapshotKeys.splice(this.clientSnapshotKeys.indexOf(client.snapshotKey), 1) 49 | client.snapshotKey = key 50 | client.lastSyncDate = Date.now() 51 | this.clientSnapshotKeys.push(key) 52 | this.saveSnapshotInfoThrottle() 53 | } 54 | 55 | getDeviceCurrentSnapshotKey = async(clientId: string) => { 56 | // console.log('updateDeviceSnapshotKey', key) 57 | const client = this.snapshotInfo.clients[clientId] 58 | return client?.snapshotKey 59 | } 60 | 61 | getSnapshotInfo = async(): Promise => { 62 | return this.snapshotInfo 63 | } 64 | 65 | saveSnapshotInfo = (info: SnapshotInfo) => { 66 | this.snapshotInfo = info 67 | this.saveSnapshotInfoThrottle() 68 | } 69 | 70 | removeSnapshotInfo = (clientId: string) => { 71 | let client = this.snapshotInfo.clients[clientId] 72 | if (!client) return 73 | if (client.snapshotKey) this.clientSnapshotKeys.splice(this.clientSnapshotKeys.indexOf(client.snapshotKey), 1) 74 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 75 | delete this.snapshotInfo.clients[clientId] 76 | this.saveSnapshotInfoThrottle() 77 | } 78 | 79 | getSnapshot = async(name: string) => { 80 | const filePath = path.join(this.snapshotDir, `snapshot_${name}`) 81 | let listData: LX.Dislike.DislikeRules 82 | try { 83 | listData = (await fs.promises.readFile(filePath)).toString('utf-8') 84 | } catch (err) { 85 | syncLog.warn(err) 86 | return null 87 | } 88 | return listData 89 | } 90 | 91 | saveSnapshot = async(name: string, data: string) => { 92 | syncLog.info('saveSnapshot', this.userDataManage.userName, name) 93 | const filePath = path.join(this.snapshotDir, `snapshot_${name}`) 94 | try { 95 | fs.writeFileSync(filePath, data) 96 | } catch (err) { 97 | syncLog.error(err) 98 | throw err 99 | } 100 | } 101 | 102 | removeSnapshot = async(name: string) => { 103 | syncLog.info('removeSnapshot', this.userDataManage.userName, name) 104 | const filePath = path.join(this.snapshotDir, `snapshot_${name}`) 105 | try { 106 | fs.unlinkSync(filePath) 107 | } catch (err) { 108 | syncLog.error(err) 109 | } 110 | } 111 | 112 | 113 | constructor(userDataManage: UserDataManage) { 114 | this.userDataManage = userDataManage 115 | 116 | this.dislikeDir = path.join(userDataManage.userDir, File.dislikeDir) 117 | checkAndCreateDirSync(this.dislikeDir) 118 | 119 | this.snapshotDir = path.join(this.dislikeDir, File.dislikeSnapshotDir) 120 | checkAndCreateDirSync(this.snapshotDir) 121 | 122 | this.snapshotInfoFilePath = path.join(this.dislikeDir, File.dislikeSnapshotInfoJSON) 123 | this.snapshotInfo = fs.existsSync(this.snapshotInfoFilePath) 124 | ? JSON.parse(fs.readFileSync(this.snapshotInfoFilePath).toString()) 125 | : { latest: null, time: 0, list: [], clients: {} } 126 | 127 | this.saveSnapshotInfoThrottle = throttle(() => { 128 | fs.writeFile(this.snapshotInfoFilePath, JSON.stringify(this.snapshotInfo), 'utf8', (err) => { 129 | if (err) console.error(err) 130 | void this.clearOldSnapshot() 131 | }) 132 | }) 133 | 134 | this.clientSnapshotKeys = Object.values(this.snapshotInfo.clients).map(device => device.snapshotKey).filter(k => k) 135 | } 136 | } 137 | // type UserDataManages = Map 138 | 139 | // export const createUserDataManage = (user: LX.UserConfig) => { 140 | // const manage = Object.create(userDataManage) as typeof userDataManage 141 | // manage.userDir = user.dataPath 142 | // } 143 | -------------------------------------------------------------------------------- /src/modules/dislike/sync/handler.ts: -------------------------------------------------------------------------------- 1 | // 这个文件导出的方法将暴露给客户端调用,第一个参数固定为当前 socket 对象 2 | // import { throttle } from '@common/utils/common' 3 | // import { sendSyncActionList } from '@main/modules/winMain' 4 | // import { SYNC_CLOSE_CODE } from '@/constants' 5 | import { SYNC_CLOSE_CODE } from '@/constants' 6 | import { getUserSpace } from '@/user' 7 | // import { encryptMsg } from '@/utils/tools' 8 | 9 | // let wss: LX.SocketServer | null 10 | // let removeListener: (() => void) | null 11 | 12 | // type listAction = 'list:action' 13 | 14 | const handleListAction = async(userName: string, param: LX.Sync.Dislike.ActionList) => { 15 | console.log('handleListAction', userName, param.action) 16 | switch (param.action) { 17 | case 'dislike_data_overwrite': 18 | await global.event_dislike.dislike_data_overwrite(userName, param.data, true) 19 | break 20 | case 'dislike_music_add': 21 | await global.event_dislike.dislike_music_add(userName, param.data, true) 22 | break 23 | case 'dislike_music_clear': 24 | await global.event_dislike.dislike_music_clear(userName, true) 25 | break 26 | default: 27 | throw new Error('unknown dislike sync action') 28 | } 29 | const userSpace = getUserSpace(userName) 30 | let key = userSpace.dislikeManage.createSnapshot() 31 | return key 32 | } 33 | const handler: LX.Sync.ServerSyncHandlerDislikeActions = { 34 | async onDislikeSyncAction(socket, action) { 35 | if (!socket.moduleReadys?.dislike) return 36 | const key = await handleListAction(socket.userInfo.name, action) 37 | console.log(key) 38 | const userSpace = getUserSpace(socket.userInfo.name) 39 | await userSpace.dislikeManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, key) 40 | const currentUserName = socket.userInfo.name 41 | const currentId = socket.keyInfo.clientId 42 | socket.broadcast((client) => { 43 | if (client.keyInfo.clientId == currentId || !client.moduleReadys?.dislike || client.userInfo.name != currentUserName) return 44 | void client.remoteQueueDislike.onDislikeSyncAction(action).then(async() => { 45 | return userSpace.dislikeManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) 46 | }).catch(err => { 47 | // TODO send status 48 | client.close(SYNC_CLOSE_CODE.failed) 49 | // client.moduleReadys.dislike = false 50 | console.log(err.message) 51 | }) 52 | }) 53 | }, 54 | } 55 | 56 | export default handler 57 | -------------------------------------------------------------------------------- /src/modules/dislike/sync/index.ts: -------------------------------------------------------------------------------- 1 | export { default as handler } from './handler' 2 | export { sync } from './sync' 3 | export * from './localEvent' 4 | -------------------------------------------------------------------------------- /src/modules/dislike/sync/localEvent.ts: -------------------------------------------------------------------------------- 1 | // import { updateDeviceSnapshotKey } from '@main/modules/sync/data' 2 | // import { registerListActionEvent } from '../../../utils' 3 | // import { getCurrentListInfoKey } from '../../utils' 4 | 5 | // let socket: LX.Sync.Server.Socket | null 6 | let unregisterLocalListAction: (() => void) | null 7 | 8 | 9 | // const sendListAction = async(wss: LX.SocketServer, action: LX.Sync.List.ActionList) => { 10 | // // console.log('sendListAction', action.action) 11 | // const key = await getCurrentListInfoKey() 12 | // for (const client of wss.clients) { 13 | // if (!client.moduleReadys?.dislike) continue 14 | // void client.remoteQueueList.onListSyncAction(action).then(() => { 15 | // updateDeviceSnapshotKey(client.keyInfo, key) 16 | // }) 17 | // } 18 | // } 19 | 20 | export const registerEvent = (wss: LX.SocketServer) => { 21 | // socket = _socket 22 | // socket.onClose(() => { 23 | // unregisterLocalListAction?.() 24 | // unregisterLocalListAction = null 25 | // }) 26 | // unregisterEvent() 27 | // unregisterLocalListAction = registerListActionEvent((action) => { 28 | // void sendListAction(wss, action) 29 | // }) 30 | } 31 | 32 | export const unregisterEvent = () => { 33 | unregisterLocalListAction?.() 34 | unregisterLocalListAction = null 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/dislike/sync/sync.ts: -------------------------------------------------------------------------------- 1 | // import { SYNC_CLOSE_CODE } from '@/constants' 2 | import { SYNC_CLOSE_CODE, TRANS_MODE } from '@/constants' 3 | import { getUserSpace } from '@/user' 4 | import { filterRules } from '../utils' 5 | // import { LIST_IDS } from '@common/constants' 6 | 7 | // type ListInfoType = LX.List.UserListInfoFull | LX.List.MyDefaultListInfoFull | LX.List.MyLoveListInfoFull 8 | 9 | // let wss: LX.SocketServer | null 10 | let syncingId: string | null = null 11 | const wait = async(time = 1000) => await new Promise((resolve, reject) => setTimeout(resolve, time)) 12 | 13 | 14 | const getRemoteListData = async(socket: LX.Socket): Promise => { 15 | console.log('getRemoteListData') 16 | return (await socket.remoteQueueDislike.dislike_sync_get_list_data()) ?? '' 17 | } 18 | 19 | const getRemoteDataMD5 = async(socket: LX.Socket): Promise => { 20 | return socket.remoteQueueDislike.dislike_sync_get_md5() 21 | } 22 | 23 | const getLocalListData = async(socket: LX.Socket): Promise => { 24 | return getUserSpace(socket.userInfo.name).dislikeManage.getDislikeRules() 25 | } 26 | const getSyncMode = async(socket: LX.Socket): Promise => { 27 | const mode = await socket.remoteQueueDislike.dislike_sync_get_sync_mode() 28 | return TRANS_MODE[mode] ?? 'cancel' 29 | } 30 | 31 | const finishedSync = async(socket: LX.Socket) => { 32 | await socket.remoteQueueDislike.dislike_sync_finished() 33 | } 34 | 35 | 36 | const setLocalList = async(socket: LX.Socket, listData: LX.Dislike.DislikeRules) => { 37 | await global.event_dislike.dislike_data_overwrite(socket.userInfo.name, listData, true) 38 | const userSpace = getUserSpace(socket.userInfo.name) 39 | return userSpace.dislikeManage.createSnapshot() 40 | } 41 | 42 | const overwriteRemoteListData = async(socket: LX.Socket, listData: LX.Dislike.DislikeRules, key: string, excludeIds: string[] = []) => { 43 | const action = { action: 'dislike_data_overwrite', data: listData } as const 44 | const tasks: Array> = [] 45 | const userSpace = getUserSpace(socket.userInfo.name) 46 | socket.broadcast((client) => { 47 | if (excludeIds.includes(client.keyInfo.clientId) || client.userInfo?.name != socket.userInfo.name || !client.moduleReadys?.dislike) return 48 | tasks.push(client.remoteQueueDislike.onDislikeSyncAction(action).then(async() => { 49 | return userSpace.dislikeManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) 50 | }).catch(err => { 51 | // TODO send status 52 | client.close(SYNC_CLOSE_CODE.failed) 53 | // client.moduleReadys.list = false 54 | console.log(err.message) 55 | })) 56 | }) 57 | if (!tasks.length) return 58 | await Promise.all(tasks) 59 | } 60 | const setRemotelList = async(socket: LX.Socket, listData: LX.Dislike.DislikeRules, key: string): Promise => { 61 | await socket.remoteQueueDislike.dislike_sync_set_list_data(listData) 62 | const userSpace = getUserSpace(socket.userInfo.name) 63 | await userSpace.dislikeManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, key) 64 | } 65 | 66 | 67 | const mergeList = (socket: LX.Socket, sourceListData: LX.Dislike.DislikeRules, targetListData: LX.Dislike.DislikeRules): LX.Dislike.DislikeRules => { 68 | return Array.from(filterRules(sourceListData + '\n' + targetListData)).join('\n') 69 | } 70 | 71 | const handleMergeListData = async(socket: LX.Socket): Promise<[LX.Dislike.DislikeRules, boolean, boolean]> => { 72 | const mode: LX.Sync.List.SyncMode = await getSyncMode(socket) 73 | 74 | if (mode == 'cancel') throw new Error('cancel') 75 | const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalListData(socket)]) 76 | console.log('handleMergeListData', 'remoteListData, localListData') 77 | let listData: LX.Dislike.DislikeRules 78 | let requiredUpdateLocalListData = true 79 | let requiredUpdateRemoteListData = true 80 | switch (mode) { 81 | case 'merge_local_remote': 82 | listData = mergeList(socket, localListData, remoteListData) 83 | break 84 | case 'merge_remote_local': 85 | listData = mergeList(socket, remoteListData, localListData) 86 | break 87 | case 'overwrite_local_remote': 88 | listData = localListData 89 | requiredUpdateLocalListData = false 90 | break 91 | case 'overwrite_remote_local': 92 | listData = remoteListData 93 | requiredUpdateRemoteListData = false 94 | break 95 | // case 'none': return null 96 | // case 'cancel': 97 | default: throw new Error('cancel') 98 | } 99 | return [listData, requiredUpdateLocalListData, requiredUpdateRemoteListData] 100 | } 101 | 102 | const handleSyncList = async(socket: LX.Socket) => { 103 | const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalListData(socket)]) 104 | console.log('handleSyncList', 'remoteListData, localListData') 105 | console.log('localListData', localListData.length) 106 | console.log('remoteListData', remoteListData.length) 107 | const userSpace = getUserSpace(socket.userInfo.name) 108 | const clientId = socket.keyInfo.clientId 109 | if (localListData.length) { 110 | if (remoteListData.length) { 111 | const [mergedList, requiredUpdateLocalListData, requiredUpdateRemoteListData] = await handleMergeListData(socket) 112 | console.log('handleMergeListData', 'mergedList', requiredUpdateLocalListData, requiredUpdateRemoteListData) 113 | let key 114 | if (requiredUpdateLocalListData) { 115 | key = await setLocalList(socket, mergedList) 116 | await overwriteRemoteListData(socket, mergedList, key, [clientId]) 117 | if (!requiredUpdateRemoteListData) await userSpace.dislikeManage.updateDeviceSnapshotKey(clientId, key) 118 | } 119 | if (requiredUpdateRemoteListData) { 120 | if (!key) key = await userSpace.dislikeManage.getCurrentListInfoKey() 121 | await setRemotelList(socket, mergedList, key) 122 | } 123 | } else { 124 | await setRemotelList(socket, localListData, await userSpace.dislikeManage.getCurrentListInfoKey()) 125 | } 126 | } else { 127 | let key: string 128 | if (remoteListData.length) { 129 | key = await setLocalList(socket, remoteListData) 130 | await overwriteRemoteListData(socket, remoteListData, key, [clientId]) 131 | } 132 | key ??= await userSpace.dislikeManage.getCurrentListInfoKey() 133 | await userSpace.dislikeManage.updateDeviceSnapshotKey(clientId, key) 134 | } 135 | } 136 | 137 | const mergeDataFromSnapshot = ( 138 | sourceList: LX.Dislike.DislikeRules, 139 | targetList: LX.Dislike.DislikeRules, 140 | snapshotList: LX.Dislike.DislikeRules, 141 | ): LX.Dislike.DislikeRules => { 142 | const removedRules = new Set() 143 | const sourceRules = filterRules(sourceList) 144 | const targetRules = filterRules(targetList) 145 | 146 | if (snapshotList) { 147 | const snapshotRules = filterRules(snapshotList) 148 | for (const m of snapshotRules.values()) { 149 | if (!sourceRules.has(m) || !targetRules.has(m)) removedRules.add(m) 150 | } 151 | } 152 | return Array.from(new Set(Array.from([...sourceRules, ...targetRules]).filter((rule) => { 153 | return !removedRules.has(rule) 154 | }))).join('\n') 155 | } 156 | const checkListLatest = async(socket: LX.Socket) => { 157 | const remoteListMD5 = await getRemoteDataMD5(socket) 158 | const userSpace = getUserSpace(socket.userInfo.name) 159 | const userCurrentListInfoKey = await userSpace.dislikeManage.getDeviceCurrentSnapshotKey(socket.keyInfo.clientId) 160 | const currentListInfoKey = await userSpace.dislikeManage.getCurrentListInfoKey() 161 | const latest = remoteListMD5 == currentListInfoKey 162 | if (latest && userCurrentListInfoKey != currentListInfoKey) await userSpace.dislikeManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, currentListInfoKey) 163 | return latest 164 | } 165 | 166 | const handleMergeListDataFromSnapshot = async(socket: LX.Socket, snapshot: LX.Dislike.DislikeRules) => { 167 | if (await checkListLatest(socket)) return 168 | 169 | const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalListData(socket)]) 170 | const newDislikeData = mergeDataFromSnapshot(localListData, remoteListData, snapshot) 171 | 172 | const key = await setLocalList(socket, newDislikeData) 173 | const err = await setRemotelList(socket, newDislikeData, key).catch(err => err) 174 | await overwriteRemoteListData(socket, newDislikeData, key, [socket.keyInfo.clientId]) 175 | if (err) throw err 176 | } 177 | 178 | const syncDislike = async(socket: LX.Socket) => { 179 | // socket.data.snapshotFilePath = getSnapshotFilePath(socket.keyInfo) 180 | // console.log(socket.keyInfo) 181 | if (!socket.feature.dislike) throw new Error('dislike feature options not available') 182 | if (!socket.feature.dislike.skipSnapshot) { 183 | const user = getUserSpace(socket.userInfo.name) 184 | const userCurrentDislikeInfoKey = await user.dislikeManage.getDeviceCurrentSnapshotKey(socket.keyInfo.clientId) 185 | if (userCurrentDislikeInfoKey) { 186 | const listData = await user.dislikeManage.snapshotDataManage.getSnapshot(userCurrentDislikeInfoKey) 187 | if (listData) { 188 | console.log('handleMergeDislikeDataFromSnapshot') 189 | await handleMergeListDataFromSnapshot(socket, listData) 190 | return 191 | } 192 | } 193 | } 194 | await handleSyncList(socket) 195 | } 196 | 197 | export const sync = async(socket: LX.Socket) => { 198 | let disconnected = false 199 | socket.onClose(() => { 200 | disconnected = true 201 | if (syncingId == socket.keyInfo.clientId) syncingId = null 202 | }) 203 | 204 | while (true) { 205 | if (disconnected) throw new Error('disconnected') 206 | if (!syncingId) break 207 | await wait() 208 | } 209 | 210 | syncingId = socket.keyInfo.clientId 211 | await syncDislike(socket).then(async() => { 212 | await finishedSync(socket) 213 | socket.moduleReadys.dislike = true 214 | }).finally(() => { 215 | syncingId = null 216 | }) 217 | } 218 | -------------------------------------------------------------------------------- /src/modules/dislike/utils.ts: -------------------------------------------------------------------------------- 1 | import { SPLIT_CHAR } from '@/constants' 2 | 3 | export const filterRules = (rules: string) => { 4 | const list: string[] = [] 5 | for (const item of rules.split('\n')) { 6 | if (!item) continue 7 | let [name, singer] = item.split(SPLIT_CHAR.DISLIKE_NAME) 8 | if (name) { 9 | name = name.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() 10 | if (singer) { 11 | singer = singer.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() 12 | list.push(`${name}${SPLIT_CHAR.DISLIKE_NAME}${singer}`) 13 | } else { 14 | list.push(name) 15 | } 16 | } else if (singer) { 17 | singer = singer.replaceAll(SPLIT_CHAR.DISLIKE_NAME, SPLIT_CHAR.DISLIKE_NAME_ALIAS).toLocaleLowerCase().trim() 18 | list.push(`${SPLIT_CHAR.DISLIKE_NAME}${singer}`) 19 | } 20 | } 21 | return new Set(list) 22 | } 23 | -------------------------------------------------------------------------------- /src/modules/index.ts: -------------------------------------------------------------------------------- 1 | import { sync as listSync } from './list' 2 | import { sync as dislikeSync } from './dislike' 3 | 4 | export const callObj = Object.assign({}, 5 | listSync.handler, 6 | dislikeSync.handler, 7 | ) 8 | 9 | export const modules = { 10 | list: listSync, 11 | dislike: dislikeSync, 12 | } 13 | 14 | 15 | export { ListManage, ListEvent, type ListEventType } from './list' 16 | 17 | export { DislikeManage, DislikeEvent, type DislikeEventType } from './dislike' 18 | 19 | export const featureVersion = { 20 | list: 1, 21 | dislike: 1, 22 | } as const 23 | -------------------------------------------------------------------------------- /src/modules/list/event.ts: -------------------------------------------------------------------------------- 1 | import { getUserSpace } from '@/user' 2 | import { EventEmitter } from 'events' 3 | // import { 4 | // userListCreate, 5 | // userListsUpdate, 6 | // userListsRemove, 7 | // userListsUpdatePosition, 8 | // listDataOverwrite, 9 | // listMusicOverwrite, 10 | // listMusicAdd, 11 | // listMusicMove, 12 | // listMusicRemove, 13 | // listMusicUpdateInfo, 14 | // listMusicUpdatePosition, 15 | // listMusicClear, 16 | // } from '@/listManage/action' 17 | 18 | 19 | const listUpdated = () => { 20 | // return createSnapshot() 21 | } 22 | export const checkUpdateList = async(changedIds: string[]) => { 23 | // if (!changedIds.length) return 24 | // await saveListMusics(changedIds.map(id => ({ id, musics: allMusicList.get(id) as LX.List.ListMusics }))) 25 | // global.app_event.myListMusicUpdate(changedIds) 26 | } 27 | 28 | 29 | export class ListEvent extends EventEmitter { 30 | list_changed() { 31 | this.emit('list_changed') 32 | } 33 | 34 | /** 35 | * 覆盖整个列表数据 36 | * @param listData 列表数据 37 | * @param isRemote 是否属于远程操作 38 | */ 39 | async list_data_overwrite(userName: string, listData: MakeOptional, isRemote: boolean = false) { 40 | const userSpace = getUserSpace(userName) 41 | // const oldIds = userLists.map(l => l.id) 42 | // const changedIds = 43 | await userSpace.listManage.listDataManage.listDataOverwrite(listData) 44 | // await updateUserList(userLists) 45 | // await checkUpdateList(changedIds) 46 | // const removedList = oldIds.filter(id => !allMusicList.has(id)) 47 | // if (removedList.length) await removeListMusics(removedList) 48 | // const allListIds = [LIST_IDS.DEFAULT, LIST_IDS.LOVE, ...userLists.map(l => l.id)] 49 | // if (changedIds.includes(LIST_IDS.TEMP)) allListIds.push(LIST_IDS.TEMP) 50 | // await saveListMusics([...allListIds.map(id => ({ id, musics: allMusicList.get(id) as LX.List.ListMusics }))]) 51 | // global.app_event.myListMusicUpdate(changedIds) 52 | this.emit('list_data_overwrite', userName, listData, isRemote) 53 | listUpdated() 54 | } 55 | 56 | /** 57 | * 批量创建列表 58 | * @param position 列表位置 59 | * @param lists 列表信息 60 | * @param isRemote 是否属于远程操作 61 | */ 62 | async list_create(userName: string, position: number, lists: LX.List.UserListInfo[], isRemote: boolean = false) { 63 | const userSpace = getUserSpace(userName) 64 | // const changedIds: string[] = [] 65 | for (const list of lists) { 66 | await userSpace.listManage.listDataManage.userListCreate({ ...list, position }) 67 | // changedIds.push(list.id) 68 | } 69 | // await updateUserList(userLists) 70 | this.emit('list_create', userName, position, lists, isRemote) 71 | listUpdated() 72 | } 73 | 74 | /** 75 | * 批量删除列表及列表内歌曲 76 | * @param ids 列表ids 77 | * @param isRemote 是否属于远程操作 78 | */ 79 | async list_remove(userName: string, ids: string[], isRemote: boolean = false) { 80 | const userSpace = getUserSpace(userName) 81 | // const changedIds = 82 | await userSpace.listManage.listDataManage.userListsRemove(ids) 83 | // await updateUserList(userLists) 84 | // await removeListMusics(ids) 85 | this.emit('list_remove', userName, ids, isRemote) 86 | listUpdated() 87 | // global.app_event.myListMusicUpdate(changedIds) 88 | } 89 | 90 | /** 91 | * 批量更新列表信息 92 | * @param lists 列表信息 93 | * @param isRemote 是否属于远程操作 94 | */ 95 | async list_update(userName: string, lists: LX.List.UserListInfo[], isRemote: boolean = false) { 96 | const userSpace = getUserSpace(userName) 97 | await userSpace.listManage.listDataManage.userListsUpdate(lists) 98 | // await updateUserList(userLists) 99 | this.emit('list_update', userName, lists, isRemote) 100 | listUpdated() 101 | } 102 | 103 | /** 104 | * 批量更新列表位置 105 | * @param position 列表位置 106 | * @param ids 列表ids 107 | * @param isRemote 是否属于远程操作 108 | */ 109 | async list_update_position(userName: string, position: number, ids: string[], isRemote: boolean = false) { 110 | const userSpace = getUserSpace(userName) 111 | await userSpace.listManage.listDataManage.userListsUpdatePosition(position, ids) 112 | // await updateUserList(userLists) 113 | this.emit('list_update_position', userName, position, ids, isRemote) 114 | listUpdated() 115 | } 116 | 117 | /** 118 | * 覆盖列表内歌曲 119 | * @param listId 列表id 120 | * @param musicInfos 音乐信息 121 | * @param isRemote 是否属于远程操作 122 | */ 123 | async list_music_overwrite(userName: string, listId: string, musicInfos: LX.Music.MusicInfo[], isRemote: boolean = false) { 124 | const userSpace = getUserSpace(userName) 125 | // const changedIds = 126 | await userSpace.listManage.listDataManage.listMusicOverwrite(listId, musicInfos) 127 | // await checkUpdateList(changedIds) 128 | this.emit('list_music_overwrite', userName, listId, musicInfos, isRemote) 129 | listUpdated() 130 | } 131 | 132 | /** 133 | * 批量添加歌曲到列表 134 | * @param listId 列表id 135 | * @param musicInfos 添加的歌曲信息 136 | * @param addMusicLocationType 添加在到列表的位置 137 | * @param isRemote 是否属于远程操作 138 | */ 139 | async list_music_add(userName: string, listId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, isRemote: boolean = false) { 140 | const userSpace = getUserSpace(userName) 141 | // const changedIds = 142 | await userSpace.listManage.listDataManage.listMusicAdd(listId, musicInfos, addMusicLocationType) 143 | // await checkUpdateList(changedIds) 144 | this.emit('list_music_add', userName, listId, musicInfos, addMusicLocationType, isRemote) 145 | listUpdated() 146 | } 147 | 148 | /** 149 | * 批量移动歌曲 150 | * @param fromId 源列表id 151 | * @param toId 目标列表id 152 | * @param musicInfos 移动的歌曲信息 153 | * @param addMusicLocationType 添加在到列表的位置 154 | * @param isRemote 是否属于远程操作 155 | */ 156 | async list_music_move(userName: string, fromId: string, toId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, isRemote: boolean = false) { 157 | const userSpace = getUserSpace(userName) 158 | // const changedIds = 159 | await userSpace.listManage.listDataManage.listMusicMove(fromId, toId, musicInfos, addMusicLocationType) 160 | // await checkUpdateList(changedIds) 161 | this.emit('list_music_move', userName, fromId, toId, musicInfos, addMusicLocationType, isRemote) 162 | listUpdated() 163 | } 164 | 165 | /** 166 | * 批量移除歌曲 167 | * @param listId 168 | * @param listId 列表Id 169 | * @param ids 要删除歌曲的id 170 | * @param isRemote 是否属于远程操作 171 | */ 172 | async list_music_remove(userName: string, listId: string, ids: string[], isRemote: boolean = false) { 173 | const userSpace = getUserSpace(userName) 174 | // const changedIds = 175 | await userSpace.listManage.listDataManage.listMusicRemove(listId, ids) 176 | // console.log(changedIds) 177 | // await checkUpdateList(changedIds) 178 | this.emit('list_music_remove', userName, listId, ids, isRemote) 179 | listUpdated() 180 | } 181 | 182 | /** 183 | * 批量更新歌曲信息 184 | * @param musicInfos 歌曲&列表信息 185 | * @param isRemote 是否属于远程操作 186 | */ 187 | async list_music_update(userName: string, musicInfos: LX.List.ListActionMusicUpdate, isRemote: boolean = false) { 188 | const userSpace = getUserSpace(userName) 189 | // const changedIds = 190 | await userSpace.listManage.listDataManage.listMusicUpdateInfo(musicInfos) 191 | // await checkUpdateList(changedIds) 192 | this.emit('list_music_update', userName, musicInfos, isRemote) 193 | listUpdated() 194 | } 195 | 196 | /** 197 | * 清空列表内的歌曲 198 | * @param ids 列表Id 199 | * @param isRemote 是否属于远程操作 200 | */ 201 | async list_music_clear(userName: string, ids: string[], isRemote: boolean = false) { 202 | const userSpace = getUserSpace(userName) 203 | // const changedIds = 204 | await userSpace.listManage.listDataManage.listMusicClear(ids) 205 | // await checkUpdateList(changedIds) 206 | this.emit('list_music_clear', userName, ids, isRemote) 207 | listUpdated() 208 | } 209 | 210 | /** 211 | * 批量更新歌曲位置 212 | * @param listId 列表ID 213 | * @param position 新位置 214 | * @param ids 歌曲id 215 | * @param isRemote 是否属于远程操作 216 | */ 217 | async list_music_update_position(userName: string, listId: string, position: number, ids: string[], isRemote: boolean = false) { 218 | const userSpace = getUserSpace(userName) 219 | // const changedIds = 220 | await userSpace.listManage.listDataManage.listMusicUpdatePosition(listId, position, ids) 221 | // await checkUpdateList(changedIds) 222 | this.emit('list_music_update_position', userName, listId, position, ids, isRemote) 223 | listUpdated() 224 | } 225 | } 226 | 227 | 228 | type EventMethods = Omit 229 | declare class EventType extends ListEvent { 230 | on(event: K, listener: EventMethods[K]): this 231 | once(event: K, listener: EventMethods[K]): this 232 | off(event: K, listener: EventMethods[K]): this 233 | } 234 | export type ListEventType = Omit> 235 | -------------------------------------------------------------------------------- /src/modules/list/index.ts: -------------------------------------------------------------------------------- 1 | export * as sync from './sync' 2 | export { ListManage } from './manage' 3 | export { ListEvent, type ListEventType } from './event' 4 | -------------------------------------------------------------------------------- /src/modules/list/listDataManage.ts: -------------------------------------------------------------------------------- 1 | import { arrPush, arrPushByPosition, arrUnshift } from '@/utils/common' 2 | import { LIST_IDS } from '@/constants' 3 | import { type SnapshotDataManage } from './snapshotDataManage' 4 | 5 | export class ListDataManage { 6 | snapshotDataManage: SnapshotDataManage 7 | userLists: LX.List.UserListInfo[] = [] 8 | allMusicList = new Map() 9 | 10 | constructor(snapshotDataManage: SnapshotDataManage) { 11 | this.snapshotDataManage = snapshotDataManage 12 | 13 | let listData: LX.Sync.List.ListData | null 14 | void this.snapshotDataManage.getSnapshotInfo().then(async(snapshotInfo) => { 15 | if (snapshotInfo.latest) listData = await this.snapshotDataManage.getSnapshot(snapshotInfo.latest) 16 | if (!listData) listData = { defaultList: [], loveList: [], userList: [] } 17 | this.allMusicList.set(LIST_IDS.DEFAULT, listData.defaultList) 18 | this.allMusicList.set(LIST_IDS.LOVE, listData.loveList) 19 | this.userLists.push(...listData.userList.map(({ list, ...l }) => { 20 | this.allMusicList.set(l.id, list) 21 | return l 22 | })) 23 | }) 24 | } 25 | 26 | getListData = async(): Promise => { 27 | return { 28 | defaultList: this.allMusicList.get(LIST_IDS.DEFAULT) ?? [], 29 | loveList: this.allMusicList.get(LIST_IDS.LOVE) ?? [], 30 | userList: this.userLists.map(l => ({ ...l, list: this.allMusicList.get(l.id) ?? [] })), 31 | } 32 | } 33 | 34 | 35 | private readonly setUserLists = (lists: LX.List.UserListInfo[]) => { 36 | this.userLists.splice(0, this.userLists.length, ...lists) 37 | return this.userLists 38 | } 39 | 40 | private readonly setMusicList = (listId: string, musicList: LX.Music.MusicInfo[]): LX.Music.MusicInfo[] => { 41 | this.allMusicList.set(listId, musicList) 42 | return musicList 43 | } 44 | 45 | private readonly removeMusicList = (id: string) => { 46 | this.allMusicList.delete(id) 47 | } 48 | 49 | private readonly createUserList = ({ 50 | name, 51 | id, 52 | source, 53 | sourceListId, 54 | locationUpdateTime, 55 | }: LX.List.UserListInfo, position: number) => { 56 | if (position < 0 || position >= this.userLists.length) { 57 | this.userLists.push({ 58 | name, 59 | id, 60 | source, 61 | sourceListId, 62 | locationUpdateTime, 63 | }) 64 | } else { 65 | this.userLists.splice(position, 0, { 66 | name, 67 | id, 68 | source, 69 | sourceListId, 70 | locationUpdateTime, 71 | }) 72 | } 73 | } 74 | 75 | private readonly updateList = ({ 76 | name, 77 | id, 78 | source, 79 | sourceListId, 80 | // meta, 81 | locationUpdateTime, 82 | }: LX.List.UserListInfo & { meta?: { id?: string } }) => { 83 | let targetList 84 | switch (id) { 85 | case LIST_IDS.DEFAULT: 86 | case LIST_IDS.LOVE: 87 | break 88 | case LIST_IDS.TEMP: 89 | // tempList.meta = meta ?? {} 90 | // break 91 | default: 92 | targetList = this.userLists.find(l => l.id == id) 93 | if (!targetList) return 94 | targetList.name = name 95 | targetList.source = source 96 | targetList.sourceListId = sourceListId 97 | targetList.locationUpdateTime = locationUpdateTime 98 | break 99 | } 100 | } 101 | 102 | private readonly removeUserList = (id: string) => { 103 | const index = this.userLists.findIndex(l => l.id == id) 104 | if (index < 0) return 105 | this.userLists.splice(index, 1) 106 | // removeMusicList(id) 107 | } 108 | 109 | private readonly overwriteUserList = (lists: LX.List.UserListInfo[]) => { 110 | this.userLists.splice(0, this.userLists.length, ...lists) 111 | } 112 | 113 | 114 | // const sendMyListUpdateEvent = (ids: string[]) => { 115 | // window.app_event.myListUpdate(ids) 116 | // } 117 | 118 | 119 | listDataOverwrite = async({ defaultList, loveList, userList, tempList }: MakeOptional): Promise => { 120 | const updatedListIds: string[] = [] 121 | const newUserIds: string[] = [] 122 | const newUserListInfos = userList.map(({ list, ...listInfo }) => { 123 | if (this.allMusicList.has(listInfo.id)) updatedListIds.push(listInfo.id) 124 | newUserIds.push(listInfo.id) 125 | this.setMusicList(listInfo.id, list) 126 | return listInfo 127 | }) 128 | for (const list of this.userLists) { 129 | if (!this.allMusicList.has(list.id) || newUserIds.includes(list.id)) continue 130 | this.removeMusicList(list.id) 131 | updatedListIds.push(list.id) 132 | } 133 | this.overwriteUserList(newUserListInfos) 134 | 135 | if (this.allMusicList.has(LIST_IDS.DEFAULT)) updatedListIds.push(LIST_IDS.DEFAULT) 136 | this.setMusicList(LIST_IDS.DEFAULT, defaultList) 137 | this.setMusicList(LIST_IDS.LOVE, loveList) 138 | updatedListIds.push(LIST_IDS.LOVE) 139 | 140 | if (tempList && this.allMusicList.has(LIST_IDS.TEMP)) { 141 | this.setMusicList(LIST_IDS.TEMP, tempList) 142 | updatedListIds.push(LIST_IDS.TEMP) 143 | } 144 | const newIds = [LIST_IDS.DEFAULT, LIST_IDS.LOVE, ...userList.map(l => l.id)] 145 | if (tempList) newIds.push(LIST_IDS.TEMP) 146 | // void overwriteListPosition(newIds) 147 | // void overwriteListUpdateInfo(newIds) 148 | return updatedListIds 149 | } 150 | 151 | userListCreate = async({ name, id, source, sourceListId, position, locationUpdateTime }: { 152 | name: string 153 | id: string 154 | source?: LX.OnlineSource 155 | sourceListId?: string 156 | position: number 157 | locationUpdateTime: number | null 158 | }) => { 159 | if (this.userLists.some(item => item.id == id)) return 160 | const newList: LX.List.UserListInfo = { 161 | name, 162 | id, 163 | source, 164 | sourceListId, 165 | locationUpdateTime, 166 | } 167 | this.createUserList(newList, position) 168 | } 169 | 170 | userListsRemove = async(ids: string[]) => { 171 | const changedIds = [] 172 | for (const id of ids) { 173 | this.removeUserList(id) 174 | // removeListPosition(id) 175 | // removeListUpdateInfo(id) 176 | if (!this.allMusicList.has(id)) continue 177 | this.removeMusicList(id) 178 | changedIds.push(id) 179 | } 180 | 181 | return changedIds 182 | } 183 | 184 | userListsUpdate = async(listInfos: LX.List.UserListInfo[]) => { 185 | for (const info of listInfos) { 186 | this.updateList(info) 187 | } 188 | } 189 | 190 | userListsUpdatePosition = async(position: number, ids: string[]) => { 191 | const newUserLists = [...this.userLists] 192 | 193 | // console.log(position, ids) 194 | 195 | const updateLists: LX.List.UserListInfo[] = [] 196 | 197 | // const targetItem = list[position] 198 | const map = new Map() 199 | for (const item of newUserLists) map.set(item.id, item) 200 | for (const id of ids) { 201 | const listInfo = map.get(id) as LX.List.UserListInfo 202 | listInfo.locationUpdateTime = Date.now() 203 | updateLists.push(listInfo) 204 | map.delete(id) 205 | } 206 | newUserLists.splice(0, newUserLists.length, ...newUserLists.filter(mInfo => map.has(mInfo.id))) 207 | newUserLists.splice(Math.min(position, newUserLists.length), 0, ...updateLists) 208 | 209 | this.setUserLists(newUserLists) 210 | } 211 | 212 | 213 | /** 214 | * 获取列表内的歌曲 215 | * @param listId 216 | */ 217 | getListMusics = async(listId: string): Promise => { 218 | if (!listId || !this.allMusicList.has(listId)) return [] 219 | return this.allMusicList.get(listId) as LX.Music.MusicInfo[] 220 | } 221 | 222 | listMusicOverwrite = async(listId: string, musicInfos: LX.Music.MusicInfo[]): Promise => { 223 | this.setMusicList(listId, musicInfos) 224 | return [listId] 225 | } 226 | 227 | listMusicAdd = async(id: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType): Promise => { 228 | const targetList = await this.getListMusics(id) 229 | 230 | const listSet = new Set() 231 | for (const item of targetList) listSet.add(item.id) 232 | musicInfos = musicInfos.filter(item => { 233 | if (listSet.has(item.id)) return false 234 | listSet.add(item.id) 235 | return true 236 | }) 237 | switch (addMusicLocationType) { 238 | case 'top': 239 | arrUnshift(targetList, musicInfos) 240 | break 241 | case 'bottom': 242 | default: 243 | arrPush(targetList, musicInfos) 244 | break 245 | } 246 | 247 | this.setMusicList(id, targetList) 248 | 249 | return [id] 250 | } 251 | 252 | listMusicRemove = async(listId: string, ids: string[]): Promise => { 253 | let targetList = await this.getListMusics(listId) 254 | 255 | const listSet = new Set() 256 | for (const item of targetList) listSet.add(item.id) 257 | for (const id of ids) listSet.delete(id) 258 | const newList = targetList.filter(mInfo => listSet.has(mInfo.id)) 259 | targetList.splice(0, targetList.length) 260 | arrPush(targetList, newList) 261 | 262 | return [listId] 263 | } 264 | 265 | listMusicMove = async(fromId: string, toId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType): Promise => { 266 | return [ 267 | ...await this.listMusicRemove(fromId, musicInfos.map(musicInfo => musicInfo.id)), 268 | ...await this.listMusicAdd(toId, musicInfos, addMusicLocationType), 269 | ] 270 | } 271 | 272 | listMusicUpdateInfo = async(musicInfos: LX.List.ListActionMusicUpdate): Promise => { 273 | const updateListIds = new Set() 274 | for (const { id, musicInfo } of musicInfos) { 275 | const targetList = await this.getListMusics(id) 276 | if (!targetList.length) continue 277 | const index = targetList.findIndex(l => l.id == musicInfo.id) 278 | if (index < 0) continue 279 | const info: LX.Music.MusicInfo = { ...targetList[index] } 280 | // console.log(musicInfo) 281 | Object.assign(info, { 282 | name: musicInfo.name, 283 | singer: musicInfo.singer, 284 | source: musicInfo.source, 285 | interval: musicInfo.interval, 286 | meta: musicInfo.meta, 287 | }) 288 | targetList.splice(index, 1, info) 289 | updateListIds.add(id) 290 | } 291 | return Array.from(updateListIds) 292 | } 293 | 294 | 295 | listMusicUpdatePosition = async(listId: string, position: number, ids: string[]): Promise => { 296 | let targetList = await this.getListMusics(listId) 297 | 298 | // const infos = Array(ids.length) 299 | // for (let i = targetList.length; i--;) { 300 | // const item = targetList[i] 301 | // const index = ids.indexOf(item.id) 302 | // if (index < 0) continue 303 | // infos.splice(index, 1, targetList.splice(i, 1)[0]) 304 | // } 305 | // targetList.splice(Math.min(position, targetList.length - 1), 0, ...infos) 306 | 307 | // console.time('ts') 308 | 309 | // const list = createSortedList(targetList, position, ids) 310 | const infos: LX.Music.MusicInfo[] = [] 311 | const map = new Map() 312 | for (const item of targetList) map.set(item.id, item) 313 | for (const id of ids) { 314 | infos.push(map.get(id) as LX.Music.MusicInfo) 315 | map.delete(id) 316 | } 317 | const list = targetList.filter(mInfo => map.has(mInfo.id)) 318 | arrPushByPosition(list, infos, Math.min(position, list.length)) 319 | 320 | targetList.splice(0, targetList.length) 321 | arrPush(targetList, list) 322 | 323 | // console.timeEnd('ts') 324 | return [listId] 325 | } 326 | 327 | 328 | listMusicClear = async(ids: string[]): Promise => { 329 | const changedIds: string[] = [] 330 | for (const id of ids) { 331 | const list = await this.getListMusics(id) 332 | if (!list.length) continue 333 | this.setMusicList(id, []) 334 | changedIds.push(id) 335 | } 336 | return changedIds 337 | } 338 | } 339 | 340 | -------------------------------------------------------------------------------- /src/modules/list/manage.ts: -------------------------------------------------------------------------------- 1 | import { type UserDataManage } from '@/user' 2 | import { SnapshotDataManage } from './snapshotDataManage' 3 | import { ListDataManage } from './listDataManage' 4 | import { toMD5 } from '@/utils' 5 | 6 | export class ListManage { 7 | snapshotDataManage: SnapshotDataManage 8 | listDataManage: ListDataManage 9 | 10 | constructor(userDataManage: UserDataManage) { 11 | this.snapshotDataManage = new SnapshotDataManage(userDataManage) 12 | this.listDataManage = new ListDataManage(this.snapshotDataManage) 13 | } 14 | 15 | createSnapshot = async() => { 16 | const listData = JSON.stringify(await this.getListData()) 17 | const md5 = toMD5(listData) 18 | const snapshotInfo = await this.snapshotDataManage.getSnapshotInfo() 19 | console.log(md5, snapshotInfo.latest) 20 | if (snapshotInfo.latest == md5) return md5 21 | if (snapshotInfo.list.includes(md5)) { 22 | snapshotInfo.list.splice(snapshotInfo.list.indexOf(md5), 1) 23 | } else await this.snapshotDataManage.saveSnapshot(md5, listData) 24 | if (snapshotInfo.latest) snapshotInfo.list.unshift(snapshotInfo.latest) 25 | snapshotInfo.latest = md5 26 | snapshotInfo.time = Date.now() 27 | this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) 28 | return md5 29 | } 30 | 31 | getCurrentListInfoKey = async() => { 32 | const snapshotInfo = await this.snapshotDataManage.getSnapshotInfo() 33 | if (snapshotInfo.latest) return snapshotInfo.latest 34 | // snapshotInfo.latest = toMD5(JSON.stringify(await this.getListData())) 35 | // this.snapshotDataManage.saveSnapshotInfo(snapshotInfo) 36 | return this.createSnapshot() 37 | } 38 | 39 | getDeviceCurrentSnapshotKey = async(clientId: string) => { 40 | return this.snapshotDataManage.getDeviceCurrentSnapshotKey(clientId) 41 | } 42 | 43 | updateDeviceSnapshotKey = async(clientId: string, key: string) => { 44 | await this.snapshotDataManage.updateDeviceSnapshotKey(clientId, key) 45 | } 46 | 47 | removeDevice = async(clientId: string) => { 48 | this.snapshotDataManage.removeSnapshotInfo(clientId) 49 | } 50 | 51 | getListData = async() => { 52 | return await this.listDataManage.getListData() 53 | } 54 | } 55 | 56 | -------------------------------------------------------------------------------- /src/modules/list/snapshotDataManage.ts: -------------------------------------------------------------------------------- 1 | import { throttle } from '@/utils/common' 2 | import fs from 'node:fs' 3 | import path from 'node:path' 4 | import { syncLog } from '@/utils/log4js' 5 | import { checkAndCreateDirSync } from '@/utils' 6 | import { getUserConfig, type UserDataManage } from '@/user/data' 7 | import { File } from '@/constants' 8 | 9 | interface SnapshotInfo { 10 | latest: string | null 11 | time: number 12 | list: string[] 13 | clients: Record 14 | } 15 | export class SnapshotDataManage { 16 | userDataManage: UserDataManage 17 | listDir: string 18 | snapshotDir: string 19 | snapshotInfoFilePath: string 20 | snapshotInfo: SnapshotInfo 21 | clientSnapshotKeys: string[] 22 | private readonly saveSnapshotInfoThrottle: () => void 23 | 24 | isIncluedsDevice = (key: string) => { 25 | return this.clientSnapshotKeys.includes(key) 26 | } 27 | 28 | clearOldSnapshot = async() => { 29 | if (!this.snapshotInfo) return 30 | const snapshotList = this.snapshotInfo.list.filter(key => !this.isIncluedsDevice(key)) 31 | // console.log(snapshotList.length, lx.config.maxSnapshotNum) 32 | const userMaxSnapshotNum = getUserConfig(this.userDataManage.userName).maxSnapshotNum 33 | let requiredSave = snapshotList.length > userMaxSnapshotNum 34 | while (snapshotList.length > userMaxSnapshotNum) { 35 | const name = snapshotList.pop() 36 | if (name) { 37 | await this.removeSnapshot(name) 38 | this.snapshotInfo.list.splice(this.snapshotInfo.list.indexOf(name), 1) 39 | } else break 40 | } 41 | if (requiredSave) this.saveSnapshotInfo(this.snapshotInfo) 42 | } 43 | 44 | updateDeviceSnapshotKey = async(clientId: string, key: string) => { 45 | // console.log('updateDeviceSnapshotKey', key) 46 | let client = this.snapshotInfo.clients[clientId] 47 | if (!client) client = this.snapshotInfo.clients[clientId] = { snapshotKey: '', lastSyncDate: 0 } 48 | if (client.snapshotKey) this.clientSnapshotKeys.splice(this.clientSnapshotKeys.indexOf(client.snapshotKey), 1) 49 | client.snapshotKey = key 50 | client.lastSyncDate = Date.now() 51 | this.clientSnapshotKeys.push(key) 52 | this.saveSnapshotInfoThrottle() 53 | } 54 | 55 | getDeviceCurrentSnapshotKey = async(clientId: string) => { 56 | // console.log('updateDeviceSnapshotKey', key) 57 | const client = this.snapshotInfo.clients[clientId] 58 | return client?.snapshotKey 59 | } 60 | 61 | getSnapshotInfo = async(): Promise => { 62 | return this.snapshotInfo 63 | } 64 | 65 | saveSnapshotInfo = (info: SnapshotInfo) => { 66 | this.snapshotInfo = info 67 | this.saveSnapshotInfoThrottle() 68 | } 69 | 70 | removeSnapshotInfo = (clientId: string) => { 71 | let client = this.snapshotInfo.clients[clientId] 72 | if (!client) return 73 | if (client.snapshotKey) this.clientSnapshotKeys.splice(this.clientSnapshotKeys.indexOf(client.snapshotKey), 1) 74 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 75 | delete this.snapshotInfo.clients[clientId] 76 | this.saveSnapshotInfoThrottle() 77 | } 78 | 79 | getSnapshot = async(name: string) => { 80 | const filePath = path.join(this.snapshotDir, `snapshot_${name}`) 81 | let listData: LX.Sync.List.ListData 82 | try { 83 | listData = JSON.parse((await fs.promises.readFile(filePath)).toString('utf-8')) 84 | } catch (err) { 85 | syncLog.warn(err) 86 | return null 87 | } 88 | return listData 89 | } 90 | 91 | saveSnapshot = async(name: string, data: string) => { 92 | syncLog.info('saveSnapshot', this.userDataManage.userName, name) 93 | const filePath = path.join(this.snapshotDir, `snapshot_${name}`) 94 | try { 95 | fs.writeFileSync(filePath, data) 96 | } catch (err) { 97 | syncLog.error(err) 98 | throw err 99 | } 100 | } 101 | 102 | removeSnapshot = async(name: string) => { 103 | syncLog.info('removeSnapshot', this.userDataManage.userName, name) 104 | const filePath = path.join(this.snapshotDir, `snapshot_${name}`) 105 | try { 106 | fs.unlinkSync(filePath) 107 | } catch (err) { 108 | syncLog.error(err) 109 | } 110 | } 111 | 112 | 113 | constructor(userDataManage: UserDataManage) { 114 | this.userDataManage = userDataManage 115 | 116 | this.listDir = path.join(userDataManage.userDir, File.listDir) 117 | checkAndCreateDirSync(this.listDir) 118 | 119 | this.snapshotDir = path.join(this.listDir, File.listSnapshotDir) 120 | checkAndCreateDirSync(this.snapshotDir) 121 | 122 | this.snapshotInfoFilePath = path.join(this.listDir, File.listSnapshotInfoJSON) 123 | this.snapshotInfo = fs.existsSync(this.snapshotInfoFilePath) 124 | ? JSON.parse(fs.readFileSync(this.snapshotInfoFilePath).toString()) 125 | : { latest: null, time: 0, list: [], clients: {} } 126 | 127 | this.saveSnapshotInfoThrottle = throttle(() => { 128 | fs.writeFile(this.snapshotInfoFilePath, JSON.stringify(this.snapshotInfo), 'utf8', (err) => { 129 | if (err) console.error(err) 130 | void this.clearOldSnapshot() 131 | }) 132 | }) 133 | 134 | this.clientSnapshotKeys = Object.values(this.snapshotInfo.clients).map(device => device.snapshotKey).filter(k => k) 135 | } 136 | } 137 | // type UserDataManages = Map 138 | 139 | // export const createUserDataManage = (user: LX.UserConfig) => { 140 | // const manage = Object.create(userDataManage) as typeof userDataManage 141 | // manage.userDir = user.dataPath 142 | // } 143 | -------------------------------------------------------------------------------- /src/modules/list/sync/handler.ts: -------------------------------------------------------------------------------- 1 | // 这个文件导出的方法将暴露给客户端调用,第一个参数固定为当前 socket 对象 2 | // import { throttle } from '@common/utils/common' 3 | // import { sendSyncActionList } from '@main/modules/winMain' 4 | // import { SYNC_CLOSE_CODE } from '@/constants' 5 | import { SYNC_CLOSE_CODE } from '@/constants' 6 | import { getUserSpace } from '@/user' 7 | // import { encryptMsg } from '@/utils/tools' 8 | 9 | // let wss: LX.SocketServer | null 10 | // let removeListener: (() => void) | null 11 | 12 | // type listAction = 'list:action' 13 | 14 | const handleListAction = async(userName: string, { action, data }: LX.Sync.List.ActionList) => { 15 | console.log('handleListAction', userName, action) 16 | switch (action) { 17 | case 'list_data_overwrite': 18 | await global.event_list.list_data_overwrite(userName, data, true) 19 | break 20 | case 'list_create': 21 | await global.event_list.list_create(userName, data.position, data.listInfos, true) 22 | break 23 | case 'list_remove': 24 | await global.event_list.list_remove(userName, data, true) 25 | break 26 | case 'list_update': 27 | await global.event_list.list_update(userName, data, true) 28 | break 29 | case 'list_update_position': 30 | await global.event_list.list_update_position(userName, data.position, data.ids, true) 31 | break 32 | case 'list_music_add': 33 | await global.event_list.list_music_add(userName, data.id, data.musicInfos, data.addMusicLocationType, true) 34 | break 35 | case 'list_music_move': 36 | await global.event_list.list_music_move(userName, data.fromId, data.toId, data.musicInfos, data.addMusicLocationType, true) 37 | break 38 | case 'list_music_remove': 39 | await global.event_list.list_music_remove(userName, data.listId, data.ids, true) 40 | break 41 | case 'list_music_update': 42 | await global.event_list.list_music_update(userName, data, true) 43 | break 44 | case 'list_music_update_position': 45 | await global.event_list.list_music_update_position(userName, data.listId, data.position, data.ids, true) 46 | break 47 | case 'list_music_overwrite': 48 | await global.event_list.list_music_overwrite(userName, data.listId, data.musicInfos, true) 49 | break 50 | case 'list_music_clear': 51 | await global.event_list.list_music_clear(userName, data, true) 52 | break 53 | default: 54 | throw new Error('unknown list sync action') 55 | } 56 | const userSpace = getUserSpace(userName) 57 | let key = userSpace.listManage.createSnapshot() 58 | return key 59 | } 60 | 61 | // const registerListActionEvent = () => { 62 | // const list_data_overwrite = async(listData: MakeOptional, isRemote: boolean = false) => { 63 | // if (isRemote) return 64 | // await sendListAction({ action: 'list_data_overwrite', data: listData }) 65 | // } 66 | // const list_create = async(position: number, listInfos: LX.List.UserListInfo[], isRemote: boolean = false) => { 67 | // if (isRemote) return 68 | // await sendListAction({ action: 'list_create', data: { position, listInfos } }) 69 | // } 70 | // const list_remove = async(ids: string[], isRemote: boolean = false) => { 71 | // if (isRemote) return 72 | // await sendListAction({ action: 'list_remove', data: ids }) 73 | // } 74 | // const list_update = async(lists: LX.List.UserListInfo[], isRemote: boolean = false) => { 75 | // if (isRemote) return 76 | // await sendListAction({ action: 'list_update', data: lists }) 77 | // } 78 | // const list_update_position = async(position: number, ids: string[], isRemote: boolean = false) => { 79 | // if (isRemote) return 80 | // await sendListAction({ action: 'list_update_position', data: { position, ids } }) 81 | // } 82 | // const list_music_overwrite = async(listId: string, musicInfos: LX.Music.MusicInfo[], isRemote: boolean = false) => { 83 | // if (isRemote) return 84 | // await sendListAction({ action: 'list_music_overwrite', data: { listId, musicInfos } }) 85 | // } 86 | // const list_music_add = async(id: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, isRemote: boolean = false) => { 87 | // if (isRemote) return 88 | // await sendListAction({ action: 'list_music_add', data: { id, musicInfos, addMusicLocationType } }) 89 | // } 90 | // const list_music_move = async(fromId: string, toId: string, musicInfos: LX.Music.MusicInfo[], addMusicLocationType: LX.AddMusicLocationType, isRemote: boolean = false) => { 91 | // if (isRemote) return 92 | // await sendListAction({ action: 'list_music_move', data: { fromId, toId, musicInfos, addMusicLocationType } }) 93 | // } 94 | // const list_music_remove = async(listId: string, ids: string[], isRemote: boolean = false) => { 95 | // if (isRemote) return 96 | // await sendListAction({ action: 'list_music_remove', data: { listId, ids } }) 97 | // } 98 | // const list_music_update = async(musicInfos: LX.List.ListActionMusicUpdate, isRemote: boolean = false) => { 99 | // if (isRemote) return 100 | // await sendListAction({ action: 'list_music_update', data: musicInfos }) 101 | // } 102 | // const list_music_clear = async(ids: string[], isRemote: boolean = false) => { 103 | // if (isRemote) return 104 | // await sendListAction({ action: 'list_music_clear', data: ids }) 105 | // } 106 | // const list_music_update_position = async(listId: string, position: number, ids: string[], isRemote: boolean = false) => { 107 | // if (isRemote) return 108 | // await sendListAction({ action: 'list_music_update_position', data: { listId, position, ids } }) 109 | // } 110 | // global.event_list.on('list_data_overwrite', list_data_overwrite) 111 | // global.event_list.on('list_create', list_create) 112 | // global.event_list.on('list_remove', list_remove) 113 | // global.event_list.on('list_update', list_update) 114 | // global.event_list.on('list_update_position', list_update_position) 115 | // global.event_list.on('list_music_overwrite', list_music_overwrite) 116 | // global.event_list.on('list_music_add', list_music_add) 117 | // global.event_list.on('list_music_move', list_music_move) 118 | // global.event_list.on('list_music_remove', list_music_remove) 119 | // global.event_list.on('list_music_update', list_music_update) 120 | // global.event_list.on('list_music_clear', list_music_clear) 121 | // global.event_list.on('list_music_update_position', list_music_update_position) 122 | // return () => { 123 | // global.event_list.off('list_data_overwrite', list_data_overwrite) 124 | // global.event_list.off('list_create', list_create) 125 | // global.event_list.off('list_remove', list_remove) 126 | // global.event_list.off('list_update', list_update) 127 | // global.event_list.off('list_update_position', list_update_position) 128 | // global.event_list.off('list_music_overwrite', list_music_overwrite) 129 | // global.event_list.off('list_music_add', list_music_add) 130 | // global.event_list.off('list_music_move', list_music_move) 131 | // global.event_list.off('list_music_remove', list_music_remove) 132 | // global.event_list.off('list_music_update', list_music_update) 133 | // global.event_list.off('list_music_clear', list_music_clear) 134 | // global.event_list.off('list_music_update_position', list_music_update_position) 135 | // } 136 | // } 137 | 138 | // const addMusic = (orderId, callback) => { 139 | // // ... 140 | // } 141 | 142 | // const broadcast = async(socket: LX.Socket, key: string, data: any, excludeIds: string[] = []) => { 143 | // if (!wss) return 144 | // const dataStr = JSON.stringify({ action: 'list:sync:action', data }) 145 | // const userSpace = getUserSpace(socket.userInfo.name) 146 | // for (const client of wss.clients) { 147 | // if (excludeIds.includes(client.keyInfo.clientId) || !client.isReady || client.userInfo.name != socket.userInfo.name) continue 148 | // client.send(encryptMsg(client.keyInfo, dataStr), (err) => { 149 | // if (err) { 150 | // client.close(SYNC_CLOSE_CODE.failed) 151 | // return 152 | // } 153 | // userSpace.dataManage.updateDeviceSnapshotKey(client.keyInfo, key) 154 | // }) 155 | // } 156 | // } 157 | 158 | // export const sendListAction = async(action: LX.Sync.List.ActionList) => { 159 | // console.log('sendListAction', action.action) 160 | // // io.sockets 161 | // await broadcast('list:sync:action', action) 162 | // } 163 | 164 | // export const registerListHandler = (_wss: LX.SocketServer, socket: LX.Socket) => { 165 | // if (!wss) { 166 | // wss = _wss 167 | // // removeListener = registerListActionEvent() 168 | // } 169 | 170 | // const userSpace = getUserSpace(socket.userInfo.name) 171 | // socket.onRemoteEvent('list:sync:action', (action) => { 172 | // if (!socket.isReady) return 173 | // // console.log(msg) 174 | // void handleListAction(socket.userInfo.name, action).then(key => { 175 | // if (!key) return 176 | // console.log(key) 177 | // userSpace.dataManage.updateDeviceSnapshotKey(socket.keyInfo, key) 178 | // void broadcast(socket, key, action, [socket.keyInfo.clientId]) 179 | // }) 180 | // // socket.broadcast.emit('list:action', { action: 'list_remove', data: { id: 'default', index: 0 } }) 181 | // }) 182 | 183 | // // socket.on('list:add', addMusic) 184 | // } 185 | // export const unregisterListHandler = () => { 186 | // wss = null 187 | 188 | // // if (removeListener) { 189 | // // removeListener() 190 | // // removeListener = null 191 | // // } 192 | // } 193 | 194 | const handler: LX.Sync.ServerSyncHandlerListActions = { 195 | async onListSyncAction(socket, action) { 196 | if (!socket.moduleReadys?.list) return 197 | const key = await handleListAction(socket.userInfo.name, action) 198 | console.log(key) 199 | const userSpace = getUserSpace(socket.userInfo.name) 200 | await userSpace.listManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, key) 201 | const currentUserName = socket.userInfo.name 202 | const currentId = socket.keyInfo.clientId 203 | socket.broadcast((client) => { 204 | if (client.keyInfo.clientId == currentId || !client.moduleReadys?.list || client.userInfo.name != currentUserName) return 205 | void client.remoteQueueList.onListSyncAction(action).then(async() => { 206 | return userSpace.listManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) 207 | }).catch(err => { 208 | // TODO send status 209 | client.close(SYNC_CLOSE_CODE.failed) 210 | // client.moduleReadys.list = false 211 | console.log(err.message) 212 | }) 213 | }) 214 | }, 215 | } 216 | 217 | export default handler 218 | -------------------------------------------------------------------------------- /src/modules/list/sync/index.ts: -------------------------------------------------------------------------------- 1 | export { default as handler } from './handler' 2 | export { sync } from './sync' 3 | export * from './localEvent' 4 | -------------------------------------------------------------------------------- /src/modules/list/sync/localEvent.ts: -------------------------------------------------------------------------------- 1 | // import { updateDeviceSnapshotKey } from '@main/modules/sync/data' 2 | // import { registerListActionEvent } from '../../../utils' 3 | // import { getCurrentListInfoKey } from '../../utils' 4 | 5 | // let socket: LX.Sync.Server.Socket | null 6 | let unregisterLocalListAction: (() => void) | null 7 | 8 | 9 | // const sendListAction = async(wss: LX.SocketServer, action: LX.Sync.List.ActionList) => { 10 | // // console.log('sendListAction', action.action) 11 | // const key = await getCurrentListInfoKey() 12 | // for (const client of wss.clients) { 13 | // if (!client.moduleReadys?.list) continue 14 | // void client.remoteQueueList.onListSyncAction(action).then(() => { 15 | // updateDeviceSnapshotKey(client.keyInfo, key) 16 | // }) 17 | // } 18 | // } 19 | 20 | export const registerEvent = (wss: LX.SocketServer) => { 21 | // socket = _socket 22 | // socket.onClose(() => { 23 | // unregisterLocalListAction?.() 24 | // unregisterLocalListAction = null 25 | // }) 26 | // unregisterEvent() 27 | // unregisterLocalListAction = registerListActionEvent((action) => { 28 | // void sendListAction(wss, action) 29 | // }) 30 | } 31 | 32 | export const unregisterEvent = () => { 33 | unregisterLocalListAction?.() 34 | unregisterLocalListAction = null 35 | } 36 | -------------------------------------------------------------------------------- /src/modules/list/sync/sync.ts: -------------------------------------------------------------------------------- 1 | // import { SYNC_CLOSE_CODE } from '@/constants' 2 | import { SYNC_CLOSE_CODE, TRANS_MODE } from '@/constants' 3 | import { getUserSpace, getUserConfig } from '@/user' 4 | import { buildUserListInfoFull } from '../utils' 5 | // import { LIST_IDS } from '@common/constants' 6 | 7 | // type ListInfoType = LX.List.UserListInfoFull | LX.List.MyDefaultListInfoFull | LX.List.MyLoveListInfoFull 8 | 9 | // let wss: LX.SocketServer | null 10 | let syncingId: string | null = null 11 | const wait = async(time = 1000) => await new Promise((resolve, reject) => setTimeout(resolve, time)) 12 | 13 | const patchListData = (listData: Partial): LX.Sync.List.ListData => { 14 | return Object.assign({ 15 | defaultList: [], 16 | loveList: [], 17 | userList: [], 18 | }, listData) 19 | } 20 | 21 | const getRemoteListData = async(socket: LX.Socket): Promise => { 22 | console.log('getRemoteListData') 23 | return patchListData(await socket.remoteQueueList.list_sync_get_list_data()) 24 | } 25 | 26 | const getRemoteListMD5 = async(socket: LX.Socket): Promise => { 27 | return socket.remoteQueueList.list_sync_get_md5() 28 | } 29 | 30 | const getLocalListData = async(socket: LX.Socket): Promise => { 31 | return getUserSpace(socket.userInfo.name).listManage.getListData() 32 | } 33 | const getSyncMode = async(socket: LX.Socket): Promise => { 34 | const mode = await socket.remoteQueueList.list_sync_get_sync_mode() 35 | return TRANS_MODE[mode] ?? 'cancel' 36 | } 37 | 38 | const finishedSync = async(socket: LX.Socket) => { 39 | await socket.remoteQueueList.list_sync_finished() 40 | } 41 | 42 | 43 | const setLocalList = async(socket: LX.Socket, listData: LX.Sync.List.ListData) => { 44 | await global.event_list.list_data_overwrite(socket.userInfo.name, listData, true) 45 | const userSpace = getUserSpace(socket.userInfo.name) 46 | return userSpace.listManage.createSnapshot() 47 | } 48 | 49 | const overwriteRemoteListData = async(socket: LX.Socket, listData: LX.Sync.List.ListData, key: string, excludeIds: string[] = []) => { 50 | const action = { action: 'list_data_overwrite', data: listData } as const 51 | const tasks: Array> = [] 52 | const userSpace = getUserSpace(socket.userInfo.name) 53 | socket.broadcast((client) => { 54 | if (excludeIds.includes(client.keyInfo.clientId) || client.userInfo?.name != socket.userInfo.name || !client.moduleReadys?.list) return 55 | tasks.push(client.remoteQueueList.onListSyncAction(action).then(async() => { 56 | return userSpace.listManage.updateDeviceSnapshotKey(client.keyInfo.clientId, key) 57 | }).catch(err => { 58 | // TODO send status 59 | client.close(SYNC_CLOSE_CODE.failed) 60 | // client.moduleReadys.list = false 61 | console.log(err.message) 62 | })) 63 | }) 64 | if (!tasks.length) return 65 | await Promise.all(tasks) 66 | } 67 | const setRemotelList = async(socket: LX.Socket, listData: LX.Sync.List.ListData, key: string): Promise => { 68 | await socket.remoteQueueList.list_sync_set_list_data(listData) 69 | const userSpace = getUserSpace(socket.userInfo.name) 70 | await userSpace.listManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, key) 71 | } 72 | 73 | type UserDataObj = Map 74 | const createUserListDataObj = (listData: LX.Sync.List.ListData): UserDataObj => { 75 | const userListDataObj: UserDataObj = new Map() 76 | for (const list of listData.userList) userListDataObj.set(list.id, list) 77 | return userListDataObj 78 | } 79 | 80 | const handleMergeList = ( 81 | sourceList: LX.Music.MusicInfo[], 82 | targetList: LX.Music.MusicInfo[], 83 | addMusicLocationType: LX.AddMusicLocationType, 84 | ): LX.Music.MusicInfo[] => { 85 | let newList 86 | switch (addMusicLocationType) { 87 | case 'top': 88 | newList = [...targetList, ...sourceList] 89 | break 90 | case 'bottom': 91 | default: 92 | newList = [...sourceList, ...targetList] 93 | break 94 | } 95 | const map = new Map() 96 | const ids: Array = [] 97 | switch (addMusicLocationType) { 98 | case 'top': 99 | newList = [...targetList, ...sourceList] 100 | for (let i = newList.length - 1; i > -1; i--) { 101 | const item = newList[i] 102 | if (map.has(item.id)) continue 103 | ids.unshift(item.id) 104 | map.set(item.id, item) 105 | } 106 | break 107 | case 'bottom': 108 | default: 109 | newList = [...sourceList, ...targetList] 110 | for (const item of newList) { 111 | if (map.has(item.id)) continue 112 | ids.push(item.id) 113 | map.set(item.id, item) 114 | } 115 | break 116 | } 117 | return ids.map(id => map.get(id)) as LX.Music.MusicInfo[] 118 | } 119 | const mergeList = (socket: LX.Socket, sourceListData: LX.Sync.List.ListData, targetListData: LX.Sync.List.ListData): LX.Sync.List.ListData => { 120 | const addMusicLocationType = getUserConfig(socket.userInfo.name)['list.addMusicLocationType'] 121 | const newListData: LX.Sync.List.ListData = { 122 | defaultList: [], 123 | loveList: [], 124 | userList: [], 125 | } 126 | newListData.defaultList = handleMergeList(sourceListData.defaultList, targetListData.defaultList, addMusicLocationType) 127 | newListData.loveList = handleMergeList(sourceListData.loveList, targetListData.loveList, addMusicLocationType) 128 | 129 | const userListDataObj = createUserListDataObj(sourceListData) 130 | newListData.userList = [...sourceListData.userList] 131 | 132 | targetListData.userList.forEach((list, index) => { 133 | const targetUpdateTime = list?.locationUpdateTime ?? 0 134 | const sourceList = userListDataObj.get(list.id) 135 | if (sourceList) { 136 | sourceList.list = handleMergeList(sourceList.list, list.list, addMusicLocationType) 137 | 138 | const sourceUpdateTime = sourceList?.locationUpdateTime ?? 0 139 | if (targetUpdateTime >= sourceUpdateTime) return 140 | // 调整位置 141 | const [newList] = newListData.userList.splice(newListData.userList.findIndex(l => l.id == list.id), 1) 142 | newList.locationUpdateTime = targetUpdateTime 143 | newListData.userList.splice(index, 0, newList) 144 | } else { 145 | if (targetUpdateTime) { 146 | newListData.userList.splice(index, 0, list) 147 | } else { 148 | newListData.userList.push(list) 149 | } 150 | } 151 | }) 152 | 153 | return newListData 154 | } 155 | const overwriteList = (sourceListData: LX.Sync.List.ListData, targetListData: LX.Sync.List.ListData): LX.Sync.List.ListData => { 156 | const newListData: LX.Sync.List.ListData = { 157 | defaultList: [], 158 | loveList: [], 159 | userList: [], 160 | } 161 | newListData.defaultList = sourceListData.defaultList 162 | newListData.loveList = sourceListData.loveList 163 | 164 | const userListDataObj = createUserListDataObj(sourceListData) 165 | newListData.userList = [...sourceListData.userList] 166 | 167 | targetListData.userList.forEach((list, index) => { 168 | if (userListDataObj.has(list.id)) return 169 | if (list?.locationUpdateTime) { 170 | newListData.userList.splice(index, 0, list) 171 | } else { 172 | newListData.userList.push(list) 173 | } 174 | }) 175 | 176 | return newListData 177 | } 178 | 179 | const handleMergeListData = async(socket: LX.Socket): Promise<[LX.Sync.List.ListData, boolean, boolean]> => { 180 | const mode: LX.Sync.List.SyncMode = await getSyncMode(socket) 181 | 182 | if (mode == 'cancel') throw new Error('cancel') 183 | const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalListData(socket)]) 184 | console.log('handleMergeListData', 'remoteListData, localListData') 185 | let listData: LX.Sync.List.ListData 186 | let requiredUpdateLocalListData = true 187 | let requiredUpdateRemoteListData = true 188 | switch (mode) { 189 | case 'merge_local_remote': 190 | listData = mergeList(socket, localListData, remoteListData) 191 | break 192 | case 'merge_remote_local': 193 | listData = mergeList(socket, remoteListData, localListData) 194 | break 195 | case 'overwrite_local_remote': 196 | listData = overwriteList(localListData, remoteListData) 197 | break 198 | case 'overwrite_remote_local': 199 | listData = overwriteList(remoteListData, localListData) 200 | break 201 | case 'overwrite_local_remote_full': 202 | listData = localListData 203 | requiredUpdateLocalListData = false 204 | break 205 | case 'overwrite_remote_local_full': 206 | listData = remoteListData 207 | requiredUpdateRemoteListData = false 208 | break 209 | // case 'none': return null 210 | // case 'cancel': 211 | default: throw new Error('cancel') 212 | } 213 | return [listData, requiredUpdateLocalListData, requiredUpdateRemoteListData] 214 | } 215 | 216 | const handleSyncList = async(socket: LX.Socket) => { 217 | const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalListData(socket)]) 218 | console.log('handleSyncList', 'remoteListData, localListData') 219 | console.log('localListData', localListData.defaultList.length || localListData.loveList.length || localListData.userList.length) 220 | console.log('remoteListData', remoteListData.defaultList.length || remoteListData.loveList.length || remoteListData.userList.length) 221 | const userSpace = getUserSpace(socket.userInfo.name) 222 | const clientId = socket.keyInfo.clientId 223 | if (localListData.defaultList.length || localListData.loveList.length || localListData.userList.length) { 224 | if (remoteListData.defaultList.length || remoteListData.loveList.length || remoteListData.userList.length) { 225 | const [mergedList, requiredUpdateLocalListData, requiredUpdateRemoteListData] = await handleMergeListData(socket) 226 | console.log('handleMergeListData', 'mergedList', requiredUpdateLocalListData, requiredUpdateRemoteListData) 227 | let key 228 | if (requiredUpdateLocalListData) { 229 | key = await setLocalList(socket, mergedList) 230 | await overwriteRemoteListData(socket, mergedList, key, [clientId]) 231 | if (!requiredUpdateRemoteListData) await userSpace.listManage.updateDeviceSnapshotKey(clientId, key) 232 | } 233 | if (requiredUpdateRemoteListData) { 234 | if (!key) key = await userSpace.listManage.getCurrentListInfoKey() 235 | await setRemotelList(socket, mergedList, key) 236 | } 237 | } else { 238 | await setRemotelList(socket, localListData, await userSpace.listManage.getCurrentListInfoKey()) 239 | } 240 | } else { 241 | let key: string 242 | if (remoteListData.defaultList.length || remoteListData.loveList.length || remoteListData.userList.length) { 243 | key = await setLocalList(socket, remoteListData) 244 | await overwriteRemoteListData(socket, remoteListData, key, [clientId]) 245 | } 246 | key ??= await userSpace.listManage.getCurrentListInfoKey() 247 | await userSpace.listManage.updateDeviceSnapshotKey(clientId, key) 248 | } 249 | } 250 | 251 | const mergeListDataFromSnapshot = ( 252 | sourceList: LX.Music.MusicInfo[], 253 | targetList: LX.Music.MusicInfo[], 254 | snapshotList: LX.Music.MusicInfo[], 255 | addMusicLocationType: LX.AddMusicLocationType, 256 | ): LX.Music.MusicInfo[] => { 257 | const removedListIds = new Set() 258 | const sourceListItemIds = new Set() 259 | const targetListItemIds = new Set() 260 | for (const m of sourceList) sourceListItemIds.add(m.id) 261 | for (const m of targetList) targetListItemIds.add(m.id) 262 | if (snapshotList) { 263 | for (const m of snapshotList) { 264 | if (!sourceListItemIds.has(m.id) || !targetListItemIds.has(m.id)) removedListIds.add(m.id) 265 | } 266 | } 267 | 268 | let newList 269 | const map = new Map() 270 | const ids = [] 271 | switch (addMusicLocationType) { 272 | case 'top': 273 | newList = [...targetList, ...sourceList] 274 | for (let i = newList.length - 1; i > -1; i--) { 275 | const item = newList[i] 276 | if (map.has(item.id) || removedListIds.has(item.id)) continue 277 | ids.unshift(item.id) 278 | map.set(item.id, item) 279 | } 280 | break 281 | case 'bottom': 282 | default: 283 | newList = [...sourceList, ...targetList] 284 | for (const item of newList) { 285 | if (map.has(item.id) || removedListIds.has(item.id)) continue 286 | ids.push(item.id) 287 | map.set(item.id, item) 288 | } 289 | break 290 | } 291 | return ids.map(id => map.get(id)) as LX.Music.MusicInfo[] 292 | } 293 | const checkListLatest = async(socket: LX.Socket) => { 294 | const remoteListMD5 = await getRemoteListMD5(socket) 295 | const userSpace = getUserSpace(socket.userInfo.name) 296 | const userCurrentListInfoKey = await userSpace.listManage.getDeviceCurrentSnapshotKey(socket.keyInfo.clientId) 297 | const currentListInfoKey = await userSpace.listManage.getCurrentListInfoKey() 298 | // console.log('checkListLatest', remoteListMD5, currentListInfoKey) 299 | const latest = remoteListMD5 == currentListInfoKey 300 | if (latest && userCurrentListInfoKey != currentListInfoKey) await userSpace.listManage.updateDeviceSnapshotKey(socket.keyInfo.clientId, currentListInfoKey) 301 | return latest 302 | } 303 | const selectData = (snapshot: T | null, local: T, remote: T): T => { 304 | return snapshot == local 305 | ? remote 306 | // ? (snapshot == remote ? snapshot as T : remote) 307 | : local 308 | } 309 | const handleMergeListDataFromSnapshot = async(socket: LX.Socket, snapshot: LX.Sync.List.ListData) => { 310 | if (await checkListLatest(socket)) return 311 | 312 | const addMusicLocationType = getUserConfig(socket.userInfo.name)['list.addMusicLocationType'] 313 | const [remoteListData, localListData] = await Promise.all([getRemoteListData(socket), getLocalListData(socket)]) 314 | const newListData: LX.Sync.List.ListData = { 315 | defaultList: [], 316 | loveList: [], 317 | userList: [], 318 | } 319 | newListData.defaultList = mergeListDataFromSnapshot(localListData.defaultList, remoteListData.defaultList, snapshot.defaultList, addMusicLocationType) 320 | newListData.loveList = mergeListDataFromSnapshot(localListData.loveList, remoteListData.loveList, snapshot.loveList, addMusicLocationType) 321 | const localUserListData = createUserListDataObj(localListData) 322 | const remoteUserListData = createUserListDataObj(remoteListData) 323 | const snapshotUserListData = createUserListDataObj(snapshot) 324 | const removedListIds = new Set() 325 | const localUserListIds = new Set() 326 | const remoteUserListIds = new Set() 327 | 328 | for (const l of localListData.userList) localUserListIds.add(l.id) 329 | for (const l of remoteListData.userList) remoteUserListIds.add(l.id) 330 | 331 | for (const l of snapshot.userList) { 332 | if (!localUserListIds.has(l.id) || !remoteUserListIds.has(l.id)) removedListIds.add(l.id) 333 | } 334 | 335 | let newUserList: LX.List.UserListInfoFull[] = [] 336 | for (const list of localListData.userList) { 337 | if (removedListIds.has(list.id)) continue 338 | const remoteList = remoteUserListData.get(list.id) 339 | let newList: LX.List.UserListInfoFull 340 | if (remoteList) { 341 | const snapshotList = snapshotUserListData.get(list.id) ?? { name: null, source: null, sourceListId: null, list: [] } 342 | newList = buildUserListInfoFull({ 343 | id: list.id, 344 | name: selectData(snapshotList.name, list.name, remoteList.name), 345 | source: selectData(snapshotList.source, list.source, remoteList.source), 346 | sourceListId: selectData(snapshotList.sourceListId, list.sourceListId, remoteList.sourceListId), 347 | locationUpdateTime: list.locationUpdateTime, 348 | list: mergeListDataFromSnapshot(list.list, remoteList.list, snapshotList.list, addMusicLocationType), 349 | }) 350 | } else { 351 | newList = { ...list } 352 | } 353 | newUserList.push(newList) 354 | } 355 | 356 | remoteListData.userList.forEach((list, index) => { 357 | if (removedListIds.has(list.id)) return 358 | const remoteUpdateTime = list?.locationUpdateTime ?? 0 359 | if (localUserListData.has(list.id)) { 360 | const localUpdateTime = localUserListData.get(list.id)?.locationUpdateTime ?? 0 361 | if (localUpdateTime >= remoteUpdateTime) return 362 | // 调整位置 363 | const [newList] = newUserList.splice(newUserList.findIndex(l => l.id == list.id), 1) 364 | newList.locationUpdateTime = localUpdateTime 365 | newUserList.splice(index, 0, newList) 366 | } else { 367 | if (remoteUpdateTime) { 368 | newUserList.splice(index, 0, { ...list }) 369 | } else { 370 | newUserList.push({ ...list }) 371 | } 372 | } 373 | }) 374 | 375 | newListData.userList = newUserList 376 | const key = await setLocalList(socket, newListData) 377 | const err = await setRemotelList(socket, newListData, key).catch(err => err) 378 | await overwriteRemoteListData(socket, newListData, key, [socket.keyInfo.clientId]) 379 | if (err) throw err 380 | } 381 | 382 | const syncList = async(socket: LX.Socket) => { 383 | // socket.data.snapshotFilePath = getSnapshotFilePath(socket.keyInfo) 384 | // console.log(socket.keyInfo) 385 | if (!socket.feature.list) throw new Error('list feature options not available') 386 | if (!socket.feature.list.skipSnapshot) { 387 | const user = getUserSpace(socket.userInfo.name) 388 | const userCurrentListInfoKey = await user.listManage.getDeviceCurrentSnapshotKey(socket.keyInfo.clientId) 389 | if (userCurrentListInfoKey) { 390 | const listData = await user.listManage.snapshotDataManage.getSnapshot(userCurrentListInfoKey) 391 | if (listData) { 392 | console.log('handleMergeListDataFromSnapshot') 393 | await handleMergeListDataFromSnapshot(socket, listData) 394 | return 395 | } 396 | } 397 | } 398 | await handleSyncList(socket) 399 | } 400 | 401 | // export default async(_wss: LX.SocketServer, socket: LX.Socket) => { 402 | // if (!wss) { 403 | // wss = _wss 404 | // _wss.addListener('close', () => { 405 | // wss = null 406 | // }) 407 | // } 408 | 409 | // let disconnected = false 410 | // socket.onClose(() => { 411 | // disconnected = true 412 | // if (syncingId == socket.keyInfo.clientId) syncingId = null 413 | // }) 414 | 415 | // while (true) { 416 | // if (disconnected) throw new Error('disconnected') 417 | // if (!syncingId) break 418 | // await wait() 419 | // } 420 | 421 | // syncingId = socket.keyInfo.clientId 422 | // await syncList(socket).then(async() => { 423 | // return finishedSync(socket) 424 | // }).finally(() => { 425 | // syncingId = null 426 | // }) 427 | // } 428 | 429 | // const removeSnapshot = async(keyInfo: LX.Sync.KeyInfo) => { 430 | // const filePath = getSnapshotFilePath(keyInfo) 431 | // await fsPromises.unlink(filePath) 432 | // } 433 | 434 | export const sync = async(socket: LX.Socket) => { 435 | let disconnected = false 436 | socket.onClose(() => { 437 | disconnected = true 438 | if (syncingId == socket.keyInfo.clientId) syncingId = null 439 | }) 440 | 441 | while (true) { 442 | if (disconnected) throw new Error('disconnected') 443 | if (!syncingId) break 444 | await wait() 445 | } 446 | 447 | syncingId = socket.keyInfo.clientId 448 | await syncList(socket).then(async() => { 449 | await finishedSync(socket) 450 | socket.moduleReadys.list = true 451 | }).finally(() => { 452 | syncingId = null 453 | }) 454 | } 455 | -------------------------------------------------------------------------------- /src/modules/list/utils.ts: -------------------------------------------------------------------------------- 1 | 2 | // 构建列表信息对象,用于统一字段位置顺序 3 | export const buildUserListInfoFull = ({ id, name, source, sourceListId, list, locationUpdateTime }: LX.List.UserListInfoFull) => { 4 | return { 5 | id, 6 | name, 7 | source, 8 | sourceListId, 9 | locationUpdateTime, 10 | list, 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /src/server/auth.ts: -------------------------------------------------------------------------------- 1 | import type http from 'http' 2 | import { SYNC_CODE } from '@/constants' 3 | import { 4 | aesEncrypt, 5 | aesDecrypt, 6 | rsaEncrypt, 7 | getIP, 8 | } from '@/utils/tools' 9 | import querystring from 'node:querystring' 10 | import store from '@/utils/cache' 11 | import { getUserSpace, getUserName, setUserName, createClientKeyInfo } from '@/user' 12 | import { toMD5 } from '@/utils' 13 | 14 | const getAvailableIP = (req: http.IncomingMessage) => { 15 | let ip = getIP(req) 16 | return ip && (store.get(ip) ?? 0) < 10 ? ip : null 17 | } 18 | 19 | const verifyByKey = (encryptMsg: string, userId: string) => { 20 | const userName = getUserName(userId) 21 | if (!userName) return null 22 | const userSpace = getUserSpace(userName) 23 | const keyInfo = userSpace.dataManage.getClientKeyInfo(userId) 24 | if (!keyInfo) return null 25 | let text 26 | try { 27 | text = aesDecrypt(encryptMsg, keyInfo.key) 28 | } catch (err) { 29 | return null 30 | } 31 | // console.log(text) 32 | if (text.startsWith(SYNC_CODE.authMsg)) { 33 | const deviceName = text.replace(SYNC_CODE.authMsg, '') || 'Unknown' 34 | if (deviceName != keyInfo.deviceName) { 35 | keyInfo.deviceName = deviceName 36 | userSpace.dataManage.saveClientKeyInfo(keyInfo) 37 | } 38 | return aesEncrypt(SYNC_CODE.helloMsg, keyInfo.key) 39 | } 40 | return null 41 | } 42 | 43 | const verifyByCode = (encryptMsg: string, users: LX.Config['users']) => { 44 | for (const userInfo of users) { 45 | let key = toMD5(userInfo.password).substring(0, 16) 46 | // const iv = Buffer.from(key.split('').reverse().join('')).toString('base64') 47 | key = Buffer.from(key).toString('base64') 48 | // console.log(req.headers.m, authCode, key) 49 | let text 50 | try { 51 | text = aesDecrypt(encryptMsg, key) 52 | } catch { continue } 53 | // console.log(text) 54 | if (text.startsWith(SYNC_CODE.authMsg)) { 55 | const data = text.split('\n') 56 | const publicKey = `-----BEGIN PUBLIC KEY-----\n${data[1]}\n-----END PUBLIC KEY-----` 57 | const deviceName = data[2] || 'Unknown' 58 | const isMobile = data[3] == 'lx_music_mobile' 59 | const keyInfo = createClientKeyInfo(deviceName, isMobile) 60 | const userSpace = getUserSpace(userInfo.name) 61 | userSpace.dataManage.saveClientKeyInfo(keyInfo) 62 | setUserName(keyInfo.clientId, userInfo.name) 63 | return rsaEncrypt(Buffer.from(JSON.stringify({ 64 | clientId: keyInfo.clientId, 65 | key: keyInfo.key, 66 | serverName: global.lx.config.serverName, 67 | })), publicKey) 68 | } 69 | } 70 | return null 71 | } 72 | 73 | export const authCode = async(req: http.IncomingMessage, res: http.ServerResponse, users: LX.Config['users']) => { 74 | let code = 401 75 | let msg: string = SYNC_CODE.msgAuthFailed 76 | 77 | let ip = getAvailableIP(req) 78 | if (ip) { 79 | if (typeof req.headers.m == 'string' && req.headers.m) { 80 | const userId = req.headers.i 81 | const _msg = typeof userId == 'string' && userId 82 | ? verifyByKey(req.headers.m, userId) 83 | : verifyByCode(req.headers.m, users) 84 | if (_msg != null) { 85 | msg = _msg 86 | code = 200 87 | } 88 | } 89 | 90 | if (code != 200) { 91 | const num = store.get(ip) ?? 0 92 | // if (num > 20) return 93 | store.set(ip, num + 1) 94 | } 95 | } else { 96 | code = 403 97 | msg = SYNC_CODE.msgBlockedIp 98 | } 99 | // console.log(req.headers) 100 | 101 | res.writeHead(code) 102 | res.end(msg) 103 | } 104 | 105 | const verifyConnection = (encryptMsg: string, userId: string) => { 106 | const userName = getUserName(userId) 107 | // console.log(userName) 108 | if (!userName) return false 109 | const userSpace = getUserSpace(userName) 110 | const keyInfo = userSpace.dataManage.getClientKeyInfo(userId) 111 | if (!keyInfo) return false 112 | let text 113 | try { 114 | text = aesDecrypt(encryptMsg, keyInfo.key) 115 | } catch (err) { 116 | return false 117 | } 118 | // console.log(text) 119 | return text == SYNC_CODE.msgConnect 120 | } 121 | export const authConnect = async(req: http.IncomingMessage) => { 122 | let ip = getAvailableIP(req) 123 | if (ip) { 124 | const query = querystring.parse((req.url as string).split('?')[1]) 125 | const i = query.i 126 | const t = query.t 127 | if (typeof i == 'string' && typeof t == 'string' && verifyConnection(t, i)) return 128 | 129 | const num = store.get(ip) ?? 0 130 | store.set(ip, num + 1) 131 | } 132 | throw new Error('failed') 133 | } 134 | 135 | -------------------------------------------------------------------------------- /src/server/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | startServer, 3 | // stopServer, 4 | getStatus, 5 | // generateCode, 6 | } from './server' 7 | 8 | 9 | export { 10 | startServer, 11 | // stopServer, 12 | getStatus, 13 | // generateCode, 14 | } 15 | -------------------------------------------------------------------------------- /src/server/server.ts: -------------------------------------------------------------------------------- 1 | import http, { type IncomingMessage } from 'node:http' 2 | import { WebSocketServer } from 'ws' 3 | import { registerLocalSyncEvent, callObj, sync } from './sync' 4 | import { authCode, authConnect } from './auth' 5 | import { getAddress, sendStatus, decryptMsg, encryptMsg } from '@/utils/tools' 6 | import { accessLog, startupLog, syncLog } from '@/utils/log4js' 7 | import { SYNC_CLOSE_CODE, SYNC_CODE } from '@/constants' 8 | import { getUserSpace, releaseUserSpace, getUserName, getServerId } from '@/user' 9 | import { createMsg2call } from 'message2call' 10 | 11 | 12 | let status: LX.Sync.Status = { 13 | status: false, 14 | message: '', 15 | address: [], 16 | // code: '', 17 | devices: [], 18 | } 19 | 20 | let host = 'http://localhost' 21 | 22 | // const codeTools: { 23 | // timeout: NodeJS.Timer | null 24 | // start: () => void 25 | // stop: () => void 26 | // } = { 27 | // timeout: null, 28 | // start() { 29 | // this.stop() 30 | // this.timeout = setInterval(() => { 31 | // void generateCode() 32 | // }, 60 * 3 * 1000) 33 | // }, 34 | // stop() { 35 | // if (!this.timeout) return 36 | // clearInterval(this.timeout) 37 | // this.timeout = null 38 | // }, 39 | // } 40 | 41 | const checkDuplicateClient = (newSocket: LX.Socket) => { 42 | for (const client of [...wss!.clients]) { 43 | if (client === newSocket || client.keyInfo.clientId != newSocket.keyInfo.clientId) continue 44 | syncLog.info('duplicate client', client.userInfo.name, client.keyInfo.deviceName) 45 | client.isReady = false 46 | for (const name of Object.keys(client.moduleReadys) as Array) { 47 | client.moduleReadys[name] = false 48 | } 49 | client.close(SYNC_CLOSE_CODE.normal) 50 | } 51 | } 52 | 53 | const handleConnection = async(socket: LX.Socket, request: IncomingMessage) => { 54 | const queryData = new URL(request.url as string, host).searchParams 55 | const clientId = queryData.get('i') 56 | 57 | // // if (typeof socket.handshake.query.i != 'string') return socket.disconnect(true) 58 | const userName = getUserName(clientId) 59 | if (!userName) { 60 | socket.close(SYNC_CLOSE_CODE.failed) 61 | return 62 | } 63 | const userSpace = getUserSpace(userName) 64 | const keyInfo = userSpace.dataManage.getClientKeyInfo(clientId) 65 | if (!keyInfo) { 66 | socket.close(SYNC_CLOSE_CODE.failed) 67 | return 68 | } 69 | const user = global.lx.config.users.find(u => u.name == userName) 70 | if (!user) { 71 | socket.close(SYNC_CLOSE_CODE.failed) 72 | return 73 | } 74 | keyInfo.lastConnectDate = Date.now() 75 | userSpace.dataManage.saveClientKeyInfo(keyInfo) 76 | // // socket.lx_keyInfo = keyInfo 77 | socket.keyInfo = keyInfo 78 | socket.userInfo = user 79 | 80 | checkDuplicateClient(socket) 81 | 82 | try { 83 | await sync(socket) 84 | } catch (err) { 85 | // console.log(err) 86 | syncLog.warn(err) 87 | socket.close(SYNC_CLOSE_CODE.failed) 88 | return 89 | } 90 | status.devices.push(keyInfo) 91 | // handleConnection(io, socket) 92 | sendStatus(status) 93 | socket.onClose(() => { 94 | status.devices.splice(status.devices.findIndex(k => k.clientId == keyInfo.clientId), 1) 95 | sendStatus(status) 96 | }) 97 | 98 | // console.log('connection', keyInfo.deviceName) 99 | accessLog.info('connection', user.name, keyInfo.deviceName) 100 | // console.log(socket.handshake.query) 101 | 102 | socket.isReady = true 103 | } 104 | 105 | const handleUnconnection = (userName: string) => { 106 | // console.log('unconnection') 107 | releaseUserSpace(userName) 108 | } 109 | 110 | const authConnection = (req: http.IncomingMessage, callback: (err: string | null | undefined, success: boolean) => void) => { 111 | // console.log(req.headers) 112 | // // console.log(req.auth) 113 | // console.log(req._query.authCode) 114 | authConnect(req).then(() => { 115 | callback(null, true) 116 | }).catch(err => { 117 | callback(err, false) 118 | }) 119 | } 120 | 121 | let wss: LX.SocketServer | null 122 | 123 | function noop() {} 124 | function onSocketError(err: Error) { 125 | console.error(err) 126 | } 127 | 128 | const handleStartServer = async(port = 9527, ip = '127.0.0.1') => await new Promise((resolve, reject) => { 129 | const httpServer = http.createServer((req, res) => { 130 | // console.log(req.url) 131 | const endUrl = `/${req.url?.split('/').at(-1) ?? ''}` 132 | let code 133 | let msg 134 | switch (endUrl) { 135 | case '/hello': 136 | code = 200 137 | msg = SYNC_CODE.helloMsg 138 | break 139 | case '/id': 140 | code = 200 141 | msg = SYNC_CODE.idPrefix + getServerId() 142 | break 143 | case '/ah': 144 | void authCode(req, res, lx.config.users) 145 | break 146 | default: 147 | code = 401 148 | msg = 'Forbidden' 149 | break 150 | } 151 | if (!code) return 152 | res.writeHead(code) 153 | res.end(msg) 154 | }) 155 | 156 | wss = new WebSocketServer({ 157 | noServer: true, 158 | }) 159 | 160 | wss.on('connection', function(socket, request) { 161 | socket.isReady = false 162 | socket.moduleReadys = { 163 | list: false, 164 | dislike: false, 165 | } 166 | socket.feature = { 167 | list: false, 168 | dislike: false, 169 | } 170 | socket.on('pong', () => { 171 | socket.isAlive = true 172 | }) 173 | 174 | // const events = new Map void>>() 175 | // const events = new Map void>>() 176 | // let events: Partial<{ [K in keyof LX.Sync.ActionSyncType]: Array<(data: LX.Sync.ActionSyncType[K]) => void> }> = {} 177 | let closeEvents: Array<(err: Error) => (void | Promise)> = [] 178 | let disconnected = false 179 | const msg2call = createMsg2call({ 180 | funcsObj: callObj, 181 | timeout: 120 * 1000, 182 | sendMessage(data) { 183 | if (disconnected) throw new Error('disconnected') 184 | void encryptMsg(socket.keyInfo, JSON.stringify(data)).then((data) => { 185 | // console.log('sendData', eventName) 186 | socket.send(data) 187 | }).catch(err => { 188 | syncLog.error('encrypt message error:', err) 189 | syncLog.error(err.message) 190 | socket.close(SYNC_CLOSE_CODE.failed) 191 | }) 192 | }, 193 | onCallBeforeParams(rawArgs) { 194 | return [socket, ...rawArgs] 195 | }, 196 | onError(error, path, groupName) { 197 | const name = groupName ?? '' 198 | const userName = socket.userInfo?.name ?? '' 199 | const deviceName = socket.keyInfo?.deviceName ?? '' 200 | syncLog.error(`sync call ${userName} ${deviceName} ${name} ${path.join('.')} error:`, error) 201 | // if (groupName == null) return 202 | // // TODO 203 | // socket.close(SYNC_CLOSE_CODE.failed) 204 | }, 205 | }) 206 | socket.remote = msg2call.remote 207 | socket.remoteQueueList = msg2call.createQueueRemote('list') 208 | socket.remoteQueueDislike = msg2call.createQueueRemote('dislike') 209 | socket.addEventListener('message', ({ data }) => { 210 | if (typeof data != 'string') return 211 | void decryptMsg(socket.keyInfo, data).then((data) => { 212 | let syncData: any 213 | try { 214 | syncData = JSON.parse(data) 215 | } catch (err) { 216 | syncLog.error('parse message error:', err) 217 | socket.close(SYNC_CLOSE_CODE.failed) 218 | return 219 | } 220 | msg2call.message(syncData) 221 | }).catch(err => { 222 | syncLog.error('decrypt message error:', err) 223 | syncLog.error(err.message) 224 | socket.close(SYNC_CLOSE_CODE.failed) 225 | }) 226 | }) 227 | socket.addEventListener('close', () => { 228 | const err = new Error('closed') 229 | try { 230 | for (const handler of closeEvents) void handler(err) 231 | } catch (err: any) { 232 | syncLog.error(err?.message) 233 | } 234 | closeEvents = [] 235 | disconnected = true 236 | msg2call.destroy() 237 | if (socket.isReady) { 238 | accessLog.info('deconnection', socket.userInfo.name, socket.keyInfo.deviceName) 239 | // events = {} 240 | if (!status.devices.map(d => getUserName(d.clientId)).filter(n => n == socket.userInfo.name).length) handleUnconnection(socket.userInfo.name) 241 | } else { 242 | const queryData = new URL(request.url as string, host).searchParams 243 | accessLog.info('deconnection', queryData.get('i')) 244 | } 245 | }) 246 | socket.onClose = function(handler: typeof closeEvents[number]) { 247 | closeEvents.push(handler) 248 | return () => { 249 | closeEvents.splice(closeEvents.indexOf(handler), 1) 250 | } 251 | } 252 | socket.broadcast = function(handler) { 253 | if (!wss) return 254 | for (const client of wss.clients) handler(client) 255 | } 256 | 257 | void handleConnection(socket, request) 258 | }) 259 | 260 | httpServer.on('upgrade', function upgrade(request, socket, head) { 261 | socket.addListener('error', onSocketError) 262 | // This function is not defined on purpose. Implement it with your own logic. 263 | authConnection(request, err => { 264 | if (err) { 265 | console.log(err) 266 | socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n') 267 | socket.destroy() 268 | return 269 | } 270 | socket.removeListener('error', onSocketError) 271 | 272 | wss?.handleUpgrade(request, socket, head, function done(ws) { 273 | wss?.emit('connection', ws, request) 274 | }) 275 | }) 276 | }) 277 | 278 | const interval = setInterval(() => { 279 | wss?.clients.forEach(socket => { 280 | if (socket.isAlive == false) { 281 | syncLog.info('alive check false:', socket.userInfo.name, socket.keyInfo.deviceName) 282 | socket.terminate() 283 | return 284 | } 285 | 286 | socket.isAlive = false 287 | socket.ping(noop) 288 | if (socket.keyInfo.isMobile) socket.send('ping', noop) 289 | }) 290 | }, 30000) 291 | 292 | wss.on('close', function close() { 293 | clearInterval(interval) 294 | }) 295 | 296 | httpServer.on('error', error => { 297 | console.log(error) 298 | reject(error) 299 | }) 300 | 301 | httpServer.on('listening', () => { 302 | const addr = httpServer.address() 303 | // console.log(addr) 304 | if (!addr) { 305 | reject(new Error('address is null')) 306 | return 307 | } 308 | const bind = typeof addr == 'string' ? `pipe ${addr}` : `port ${addr.port}` 309 | startupLog.info(`Listening on ${ip} ${bind}`) 310 | resolve(null) 311 | void registerLocalSyncEvent(wss as LX.SocketServer) 312 | }) 313 | 314 | host = `http://${ip.includes(':') ? `[${ip}]` : ip}:${port}` 315 | httpServer.listen(port, ip) 316 | }) 317 | 318 | // const handleStopServer = async() => new Promise((resolve, reject) => { 319 | // if (!wss) return 320 | // for (const client of wss.clients) client.close(SYNC_CLOSE_CODE.normal) 321 | // unregisterLocalSyncEvent() 322 | // wss.close() 323 | // wss = null 324 | // httpServer.close((err) => { 325 | // if (err) { 326 | // reject(err) 327 | // return 328 | // } 329 | // resolve() 330 | // }) 331 | // }) 332 | 333 | // export const stopServer = async() => { 334 | // codeTools.stop() 335 | // if (!status.status) { 336 | // status.status = false 337 | // status.message = '' 338 | // status.address = [] 339 | // status.code = '' 340 | // sendStatus(status) 341 | // return 342 | // } 343 | // console.log('stoping sync server...') 344 | // await handleStopServer().then(() => { 345 | // console.log('sync server stoped') 346 | // status.status = false 347 | // status.message = '' 348 | // status.address = [] 349 | // status.code = '' 350 | // }).catch(err => { 351 | // console.log(err) 352 | // status.message = err.message 353 | // }).finally(() => { 354 | // sendStatus(status) 355 | // }) 356 | // } 357 | 358 | export const startServer = async(port: number, ip: string) => { 359 | // if (status.status) await handleStopServer() 360 | 361 | startupLog.info(`starting sync server in ${process.env.NODE_ENV == 'production' ? 'production' : 'development'}`) 362 | await handleStartServer(port, ip).then(() => { 363 | // console.log('sync server started') 364 | status.status = true 365 | status.message = '' 366 | status.address = ip == '0.0.0.0' ? getAddress() : [ip] 367 | 368 | // void generateCode() 369 | // codeTools.start() 370 | }).catch(err => { 371 | console.log(err) 372 | status.status = false 373 | status.message = err.message 374 | status.address = [] 375 | // status.code = '' 376 | }) 377 | // .finally(() => { 378 | // sendStatus(status) 379 | // }) 380 | } 381 | 382 | export const getStatus = (): LX.Sync.Status => status 383 | 384 | // export const generateCode = async() => { 385 | // status.code = handleGenerateCode() 386 | // sendStatus(status) 387 | // return status.code 388 | // } 389 | 390 | export const getDevices = async(userName: string) => { 391 | const userSpace = getUserSpace(userName) 392 | return userSpace.getDecices() 393 | } 394 | 395 | export const removeDevice = async(userName: string, clientId: string) => { 396 | if (wss) { 397 | for (const client of wss.clients) { 398 | if (client.userInfo?.name == userName && client.keyInfo?.clientId == clientId) client.close(SYNC_CLOSE_CODE.normal) 399 | } 400 | } 401 | const userSpace = getUserSpace(userName) 402 | await userSpace.removeDevice(clientId) 403 | } 404 | 405 | -------------------------------------------------------------------------------- /src/server/sync/event.ts: -------------------------------------------------------------------------------- 1 | import { modules } from '@/modules' 2 | 3 | export const registerLocalSyncEvent = async(wss: LX.SocketServer) => { 4 | unregisterLocalSyncEvent() 5 | for (const module of Object.values(modules)) { 6 | module.registerEvent(wss) 7 | } 8 | } 9 | 10 | export const unregisterLocalSyncEvent = () => { 11 | for (const module of Object.values(modules)) { 12 | module.unregisterEvent() 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/server/sync/handler.ts: -------------------------------------------------------------------------------- 1 | // 这个文件导出的方法将暴露给客户端调用,第一个参数固定为当前 socket 对象 2 | // import { getUserSpace } from '@/user' 3 | import { FeaturesList } from '@/constants' 4 | import { modules } from '@/modules' 5 | 6 | 7 | const handler: LX.Sync.ServerSyncHandlerActions = { 8 | async onFeatureChanged(socket, feature) { 9 | // const userSpace = getUserSpace(socket.userInfo.name) 10 | const beforeFeature = socket.feature 11 | 12 | for (const name of FeaturesList) { 13 | const newStatus = feature[name] 14 | if (newStatus == null) continue 15 | beforeFeature[name] = feature[name] 16 | socket.moduleReadys[name] = false 17 | if (feature[name]) await modules[name].sync(socket).catch(_ => _) 18 | } 19 | }, 20 | } 21 | 22 | export default handler 23 | -------------------------------------------------------------------------------- /src/server/sync/index.ts: -------------------------------------------------------------------------------- 1 | import handler from './handler' 2 | import { callObj as _callObj } from '@/modules' 3 | export { sync } from './sync' 4 | export { modules } from '@/modules' 5 | export * from './event' 6 | 7 | export const callObj = { 8 | ...handler, 9 | ..._callObj, 10 | } 11 | -------------------------------------------------------------------------------- /src/server/sync/sync.ts: -------------------------------------------------------------------------------- 1 | import { FeaturesList } from '@/constants' 2 | import { featureVersion, modules } from '@/modules' 3 | 4 | 5 | export const sync = async(socket: LX.Socket) => { 6 | let disconnected = false 7 | socket.onClose(() => { 8 | disconnected = true 9 | }) 10 | const enabledFeatures = await socket.remote.getEnabledFeatures('server', featureVersion) 11 | 12 | if (disconnected) throw new Error('disconnected') 13 | for (const moduleName of FeaturesList) { 14 | if (enabledFeatures[moduleName]) { 15 | socket.feature[moduleName] = enabledFeatures[moduleName] 16 | await modules[moduleName].sync(socket).catch(_ => _) 17 | } 18 | if (disconnected) throw new Error('disconnected') 19 | } 20 | await socket.remote.finished() 21 | } 22 | -------------------------------------------------------------------------------- /src/types/app.d.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-var */ 2 | import { type ListEventType, type DislikeEventType } from '@/event' 3 | 4 | declare global { 5 | interface Lx { 6 | logPath: string 7 | dataPath: string 8 | userPath: string 9 | config: LX.Config 10 | } 11 | 12 | // var envParams: LX.EnvParams 13 | var lx: Lx 14 | var event_list: ListEventType 15 | var event_dislike: DislikeEventType 16 | 17 | } 18 | 19 | export {} 20 | -------------------------------------------------------------------------------- /src/types/common.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace LX { 2 | type OnlineSource = 'kw' | 'kg' | 'tx' | 'wy' | 'mg' 3 | type Source = OnlineSource | 'local' 4 | type Quality = '128k' | '320k' | 'flac' | 'flac24bit' | '192k' | 'ape' | 'wav' 5 | type QualityList = Partial> 6 | type AddMusicLocationType = 'top' | 'bottom' 7 | 8 | namespace Sync { 9 | interface Status { 10 | status: boolean 11 | message: string 12 | address: string[] 13 | // code: string 14 | devices: KeyInfo[] 15 | } 16 | interface KeyInfo { 17 | clientId: string 18 | key: string 19 | deviceName: string 20 | lastConnectDate?: number 21 | isMobile: boolean 22 | } 23 | 24 | interface ListConfig { 25 | skipSnapshot: boolean 26 | } 27 | interface DislikeConfig { 28 | skipSnapshot: boolean 29 | } 30 | type ServerType = 'desktop-app' | 'server' 31 | interface EnabledFeatures { 32 | list?: false | ListConfig 33 | dislike?: false | DislikeConfig 34 | } 35 | type SupportedFeatures = Partial<{ [k in keyof EnabledFeatures]: number }> 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/types/config.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace LX { 2 | type AddMusicLocationType = 'top' | 'bottom' 3 | 4 | interface User { 5 | /** 6 | * 用户名 7 | */ 8 | name: string 9 | 10 | /** 11 | * 连接密码 12 | */ 13 | password: string 14 | 15 | /** 16 | * 最大备份快照数 17 | */ 18 | maxSnapshotNum?: number 19 | 20 | /** 21 | * 添加歌曲到我的列表时的方式 22 | */ 23 | 'list.addMusicLocationType'?: AddMusicLocationType 24 | } 25 | 26 | interface UserConfig extends User { 27 | dataPath: string 28 | } 29 | 30 | interface Config { 31 | /** 32 | * 同步服务名称 33 | */ 34 | 'serverName': string 35 | 36 | /** 37 | * 是否使用代理转发请求到本服务器 38 | */ 39 | 'proxy.enabled': boolean 40 | 41 | /** 42 | * 代理转发的请求头 原始IP 43 | */ 44 | 'proxy.header': string 45 | 46 | /** 47 | * 公共最大备份快照数 48 | */ 49 | maxSnapshotNum: number 50 | 51 | /** 52 | * 公共添加歌曲到我的列表时的方式 top | bottom,参考客户端的设置-列表设置-添加歌曲到我的列表时的方式 53 | */ 54 | 'list.addMusicLocationType': AddMusicLocationType 55 | 56 | /** 57 | * 同步用户 58 | */ 59 | users: UserConfig[] 60 | } 61 | } 62 | 63 | -------------------------------------------------------------------------------- /src/types/dislike_list.d.ts: -------------------------------------------------------------------------------- 1 | 2 | 3 | declare namespace LX { 4 | namespace Dislike { 5 | // interface ListItemMusicText { 6 | // id?: string 7 | // // type: 'music' 8 | // name: string | null 9 | // singer: string | null 10 | // } 11 | // interface ListItemMusic { 12 | // id?: number 13 | // type: 'musicId' 14 | // musicId: string 15 | // meta: LX.Music.MusicInfo 16 | // } 17 | // type ListItem = ListItemMusicText 18 | // type ListItem = string 19 | // type ListItem = ListItemMusic | ListItemMusicText 20 | 21 | interface DislikeMusicInfo { 22 | name: string 23 | singer: string 24 | } 25 | 26 | type DislikeRules = string 27 | 28 | interface DislikeInfo { 29 | // musicIds: Set 30 | names: Set 31 | musicNames: Set 32 | singerNames: Set 33 | // list: LX.Dislike.ListItem[] 34 | rules: DislikeRules 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/types/dislike_sync.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace LX { 2 | 3 | namespace Sync { 4 | namespace Dislike { 5 | interface ListInfo { 6 | lastSyncDate?: number 7 | snapshotKey: string 8 | } 9 | 10 | interface SyncActionBase { 11 | action: A 12 | } 13 | interface SyncActionData extends SyncActionBase { 14 | data: D 15 | } 16 | type SyncAction = D extends undefined ? SyncActionBase : SyncActionData 17 | type ActionList = SyncAction<'dislike_data_overwrite', LX.Dislike.DislikeRules> 18 | | SyncAction<'dislike_music_add', LX.Dislike.DislikeMusicInfo[]> 19 | | SyncAction<'dislike_music_clear'> 20 | 21 | type SyncMode = 'merge_local_remote' 22 | | 'merge_remote_local' 23 | | 'overwrite_local_remote' 24 | | 'overwrite_remote_local' 25 | // | 'none' 26 | | 'cancel' 27 | } 28 | 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/types/list.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace LX { 2 | namespace List { 3 | interface UserListInfo { 4 | id: string 5 | name: string 6 | // list: LX.Music.MusicInfo[] 7 | source?: LX.OnlineSource 8 | sourceListId?: string 9 | // position?: number 10 | locationUpdateTime: number | null 11 | } 12 | 13 | interface MyDefaultListInfo { 14 | id: 'default' 15 | name: '试听列表' 16 | // list: LX.Music.MusicInfo[] 17 | } 18 | 19 | interface MyLoveListInfo { 20 | id: 'love' 21 | name: '我的收藏' 22 | // list: LX.Music.MusicInfo[] 23 | } 24 | 25 | interface MyTempListInfo { 26 | id: 'temp' 27 | name: '临时列表' 28 | // list: LX.Music.MusicInfo[] 29 | // TODO: save default lists info 30 | meta: { 31 | id?: string 32 | } 33 | } 34 | 35 | type MyListInfo = MyDefaultListInfo | MyLoveListInfo | UserListInfo 36 | 37 | interface MyAllList { 38 | defaultList: MyDefaultListInfo 39 | loveList: MyLoveListInfo 40 | userList: UserListInfo[] 41 | tempList: MyTempListInfo 42 | } 43 | 44 | type ListActionDataOverwrite = MakeOptional 45 | interface ListActionAdd { 46 | position: number 47 | listInfos: UserListInfo[] 48 | } 49 | type ListActionRemove = string[] 50 | type ListActionUpdate = UserListInfo[] 51 | interface ListActionUpdatePosition { 52 | /** 53 | * 列表id 54 | */ 55 | ids: string[] 56 | /** 57 | * 位置 58 | */ 59 | position: number 60 | } 61 | 62 | interface ListActionMusicAdd { 63 | id: string 64 | musicInfos: LX.Music.MusicInfo[] 65 | addMusicLocationType: LX.AddMusicLocationType 66 | } 67 | 68 | interface ListActionMusicMove { 69 | fromId: string 70 | toId: string 71 | musicInfos: LX.Music.MusicInfo[] 72 | addMusicLocationType: LX.AddMusicLocationType 73 | } 74 | 75 | interface ListActionCheckMusicExistList { 76 | listId: string 77 | musicInfoId: string 78 | } 79 | 80 | interface ListActionMusicRemove { 81 | listId: string 82 | ids: string[] 83 | } 84 | 85 | type ListActionMusicUpdate = Array<{ 86 | id: string 87 | musicInfo: LX.Music.MusicInfo 88 | }> 89 | 90 | interface ListActionMusicUpdatePosition { 91 | listId: string 92 | position: number 93 | ids: string[] 94 | } 95 | 96 | interface ListActionMusicOverwrite { 97 | listId: string 98 | musicInfos: LX.Music.MusicInfo[] 99 | } 100 | 101 | type ListActionMusicClear = string[] 102 | 103 | interface MyDefaultListInfoFull extends MyDefaultListInfo { 104 | list: LX.Music.MusicInfo[] 105 | } 106 | interface MyLoveListInfoFull extends MyLoveListInfo { 107 | list: LX.Music.MusicInfo[] 108 | } 109 | interface UserListInfoFull extends UserListInfo { 110 | list: LX.Music.MusicInfo[] 111 | } 112 | interface MyTempListInfoFull extends MyTempListInfo { 113 | list: LX.Music.MusicInfo[] 114 | } 115 | 116 | interface ListDataFull { 117 | defaultList: LX.Music.MusicInfo[] 118 | loveList: LX.Music.MusicInfo[] 119 | userList: UserListInfoFull[] 120 | tempList: LX.Music.MusicInfo[] 121 | } 122 | 123 | type ListMusics = LX.Music.MusicInfo[] 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /src/types/list_sync.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace LX { 2 | 3 | namespace Sync { 4 | namespace List { 5 | interface ListInfo { 6 | lastSyncDate?: number 7 | snapshotKey: string 8 | } 9 | 10 | interface SyncActionBase { 11 | action: A 12 | } 13 | interface SyncActionData extends SyncActionBase { 14 | data: D 15 | } 16 | type SyncAction = D extends undefined ? SyncActionBase : SyncActionData 17 | type ActionList = SyncAction<'list_data_overwrite', LX.List.ListActionDataOverwrite> 18 | | SyncAction<'list_create', LX.List.ListActionAdd> 19 | | SyncAction<'list_remove', LX.List.ListActionRemove> 20 | | SyncAction<'list_update', LX.List.ListActionUpdate> 21 | | SyncAction<'list_update_position', LX.List.ListActionUpdatePosition> 22 | | SyncAction<'list_music_add', LX.List.ListActionMusicAdd> 23 | | SyncAction<'list_music_move', LX.List.ListActionMusicMove> 24 | | SyncAction<'list_music_remove', LX.List.ListActionMusicRemove> 25 | | SyncAction<'list_music_update', LX.List.ListActionMusicUpdate> 26 | | SyncAction<'list_music_update_position', LX.List.ListActionMusicUpdatePosition> 27 | | SyncAction<'list_music_overwrite', LX.List.ListActionMusicOverwrite> 28 | | SyncAction<'list_music_clear', LX.List.ListActionMusicClear> 29 | 30 | type ListData = Omit 31 | type SyncMode = 'merge_local_remote' 32 | | 'merge_remote_local' 33 | | 'overwrite_local_remote' 34 | | 'overwrite_remote_local' 35 | | 'overwrite_local_remote_full' 36 | | 'overwrite_remote_local_full' 37 | // | 'none' 38 | | 'cancel' 39 | } 40 | 41 | 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/types/log4js.d.ts: -------------------------------------------------------------------------------- 1 | import Log4js from 'log4js/types/log4js' 2 | 3 | declare module 'log4js' { 4 | export = Log4js 5 | } 6 | 7 | -------------------------------------------------------------------------------- /src/types/music.d.ts: -------------------------------------------------------------------------------- 1 | declare namespace LX { 2 | namespace Music { 3 | interface MusicQualityType { // {"type": "128k", size: "3.56M"} 4 | type: LX.Quality 5 | size: string | null 6 | } 7 | interface MusicQualityTypeKg { // {"type": "128k", size: "3.56M"} 8 | type: LX.Quality 9 | size: string | null 10 | hash: string 11 | } 12 | type _MusicQualityType = Partial> 15 | type _MusicQualityTypeKg = Partial> 19 | 20 | 21 | interface MusicInfoMetaBase { 22 | songId: string | number // 歌曲ID,mg源为copyrightId,local为文件路径 23 | albumName: string // 歌曲专辑名称 24 | picUrl?: string | null // 歌曲图片链接 25 | } 26 | 27 | interface MusicInfoMeta_online extends MusicInfoMetaBase { 28 | qualitys: MusicQualityType[] 29 | _qualitys: _MusicQualityType 30 | albumId?: string | number // 歌曲专辑ID 31 | } 32 | 33 | interface MusicInfoMeta_local extends MusicInfoMetaBase { 34 | filePath: string 35 | ext: string 36 | } 37 | 38 | 39 | interface MusicInfoBase { 40 | id: string 41 | name: string // 歌曲名 42 | singer: string // 艺术家名 43 | source: S // 源 44 | interval: string | null // 格式化后的歌曲时长,例:03:55 45 | meta: MusicInfoMetaBase 46 | } 47 | 48 | interface MusicInfoLocal extends MusicInfoBase<'local'> { 49 | meta: MusicInfoMeta_local 50 | } 51 | 52 | interface MusicInfo_online_common extends MusicInfoBase<'kw' | 'wy'> { 53 | meta: MusicInfoMeta_online 54 | } 55 | 56 | interface MusicInfoMeta_kg extends MusicInfoMeta_online { 57 | qualitys: MusicQualityTypeKg[] 58 | _qualitys: _MusicQualityTypeKg 59 | hash: string // 歌曲hash 60 | } 61 | interface MusicInfo_kg extends MusicInfoBase<'kg'> { 62 | meta: MusicInfoMeta_kg 63 | } 64 | 65 | interface MusicInfoMeta_tx extends MusicInfoMeta_online { 66 | strMediaMid: string // 歌曲strMediaMid 67 | id?: number // 歌曲songId 68 | albumMid?: string // 歌曲albumMid 69 | } 70 | interface MusicInfo_tx extends MusicInfoBase<'tx'> { 71 | meta: MusicInfoMeta_tx 72 | } 73 | 74 | interface MusicInfoMeta_mg extends MusicInfoMeta_online { 75 | copyrightId: string // 歌曲copyrightId 76 | lrcUrl?: string // 歌曲lrcUrl 77 | mrcUrl?: string // 歌曲mrcUrl 78 | trcUrl?: string // 歌曲trcUrl 79 | } 80 | interface MusicInfo_mg extends MusicInfoBase<'mg'> { 81 | meta: MusicInfoMeta_mg 82 | } 83 | 84 | type MusicInfoOnline = MusicInfo_online_common | MusicInfo_kg | MusicInfo_tx | MusicInfo_mg 85 | type MusicInfo = MusicInfoOnline | MusicInfoLocal 86 | 87 | interface LyricInfo { 88 | // 歌曲歌词 89 | lyric: string 90 | // 翻译歌词 91 | tlyric?: string | null 92 | // 罗马音歌词 93 | rlyric?: string | null 94 | // 逐字歌词 95 | lxlyric?: string | null 96 | } 97 | 98 | interface LyricInfoSave { 99 | id: string 100 | lyrics: LyricInfo 101 | } 102 | 103 | interface MusicFileMeta { 104 | title: string 105 | artist: string | null 106 | album: string | null 107 | APIC: string | null 108 | lyrics: string | null 109 | } 110 | 111 | interface MusicUrlInfo { 112 | id: string 113 | url: string 114 | } 115 | 116 | interface MusicInfoOtherSourceSave { 117 | id: string 118 | list: MusicInfoOnline[] 119 | } 120 | 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/types/sync_common.d.ts: -------------------------------------------------------------------------------- 1 | type WarpSyncHandlerActions = { 2 | [K in keyof Actions]: (...args: [Socket, ...Parameters]) => ReturnType 3 | } 4 | 5 | declare namespace LX { 6 | namespace Sync { 7 | type ServerSyncActions = WarpPromiseRecord<{ 8 | onFeatureChanged: (feature: EnabledFeatures) => void 9 | }> 10 | type ServerSyncHandlerActions = WarpSyncHandlerActions 11 | 12 | type ServerSyncListActions = WarpPromiseRecord<{ 13 | onListSyncAction: (action: LX.Sync.List.ActionList) => void 14 | }> 15 | type ServerSyncHandlerListActions = WarpSyncHandlerActions 16 | 17 | type ServerSyncDislikeActions = WarpPromiseRecord<{ 18 | onDislikeSyncAction: (action: LX.Sync.Dislike.ActionList) => void 19 | }> 20 | type ServerSyncHandlerDislikeActions = WarpSyncHandlerActions 21 | 22 | type ClientSyncActions = WarpPromiseRecord<{ 23 | getEnabledFeatures: (serverType: ServerType, supportedFeatures: SupportedFeatures) => EnabledFeatures 24 | finished: () => void 25 | }> 26 | type ClientSyncHandlerActions = WarpSyncHandlerActions 27 | 28 | type ClientSyncListActions = WarpPromiseRecord<{ 29 | onListSyncAction: (action: LX.Sync.List.ActionList) => void 30 | list_sync_get_md5: () => string 31 | list_sync_get_sync_mode: () => LX.Sync.List.SyncMode 32 | list_sync_get_list_data: () => LX.Sync.List.ListData 33 | list_sync_set_list_data: (data: LX.Sync.List.ListData) => void 34 | list_sync_finished: () => void 35 | }> 36 | type ClientSyncHandlerListActions = WarpSyncHandlerActions 37 | 38 | type ClientSyncDislikeActions = WarpPromiseRecord<{ 39 | onDislikeSyncAction: (action: LX.Sync.Dislike.ActionList) => void 40 | dislike_sync_get_md5: () => string 41 | dislike_sync_get_sync_mode: () => LX.Sync.Dislike.SyncMode 42 | dislike_sync_get_list_data: () => LX.Dislike.DislikeRules 43 | dislike_sync_set_list_data: (data: LX.Dislike.DislikeRules) => void 44 | dislike_sync_finished: () => void 45 | }> 46 | type ClientSyncHandlerDislikeActions = WarpSyncHandlerActions 47 | } 48 | } 49 | 50 | 51 | -------------------------------------------------------------------------------- /src/types/utils.d.ts: -------------------------------------------------------------------------------- 1 | type MakeOptional = Omit & Partial> 2 | type MakeRequired = Pick & Partial> 3 | 4 | type DeepPartial = { 5 | [P in keyof T]?: T[P] extends object ? DeepPartial : T[P]; 6 | } 7 | 8 | type Modify = Omit & R 9 | 10 | // type UndefinedOrNever = undefined 11 | type Actions = { 12 | [U in T as U['action']]: 'data' extends keyof U ? U['data'] : undefined 13 | } 14 | 15 | type WarpPromiseValue = T extends ((...args: infer P) => Promise) 16 | ? ((...args: P) => Promise) 17 | : T extends ((...args: infer P2) => infer R2) 18 | ? ((...args: P2) => Promise) 19 | : Promise 20 | 21 | type WarpPromiseRecord> = { 22 | [K in keyof T]: WarpPromiseValue 23 | } 24 | -------------------------------------------------------------------------------- /src/types/ws.d.ts: -------------------------------------------------------------------------------- 1 | import type WS from 'ws' 2 | 3 | declare global { 4 | namespace LX { 5 | interface Socket extends WS.WebSocket { 6 | isAlive?: boolean 7 | isReady: boolean 8 | keyInfo: LX.Sync.KeyInfo 9 | userInfo: LX.UserConfig 10 | feature: LX.Sync.EnabledFeatures 11 | moduleReadys: { 12 | list: boolean 13 | dislike: boolean 14 | } 15 | 16 | onClose: (handler: (err: Error) => (void | Promise)) => () => void 17 | broadcast: (handler: (client: LX.Socket) => void) => void 18 | 19 | remote: LX.Sync.ClientSyncActions 20 | remoteQueueList: LX.Sync.ClientSyncListActions 21 | remoteQueueDislike: LX.Sync.ClientSyncDislikeActions 22 | } 23 | type SocketServer = WS.Server 24 | } 25 | } 26 | 27 | -------------------------------------------------------------------------------- /src/user/data.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import path from 'node:path' 3 | import { randomBytes } from 'node:crypto' 4 | import { throttle } from '@/utils/common' 5 | import { filterFileName, toMD5 } from '@/utils' 6 | import { File } from '@/constants' 7 | 8 | 9 | interface ServerInfo { 10 | serverId: string 11 | version: number 12 | } 13 | interface DevicesInfo { 14 | userName: string 15 | clients: Record 16 | } 17 | const serverInfoFilePath = path.join(global.lx.dataPath, File.serverInfoJSON) 18 | const saveServerInfoThrottle = throttle(() => { 19 | fs.writeFile(serverInfoFilePath, JSON.stringify(serverInfo), 'utf8', (err) => { 20 | if (err) console.error(err) 21 | }) 22 | }) 23 | let serverInfo: ServerInfo 24 | if (fs.existsSync(serverInfoFilePath)) { 25 | serverInfo = JSON.parse(fs.readFileSync(serverInfoFilePath).toString()) 26 | } else { 27 | serverInfo = { 28 | serverId: randomBytes(4 * 4).toString('base64'), 29 | version: 2, 30 | } 31 | saveServerInfoThrottle() 32 | } 33 | export const getServerId = (): string => { 34 | return serverInfo.serverId 35 | } 36 | export const getVersion = () => { 37 | return serverInfo.version ?? 1 38 | } 39 | export const setVersion = (version: number) => { 40 | serverInfo.version = version 41 | saveServerInfoThrottle() 42 | } 43 | 44 | export const getUserDirname = (userName: string) => `${filterFileName(userName)}_${toMD5(userName).substring(0, 6)}` 45 | 46 | export const getUserConfig = (userName: string): Required => { 47 | const user = global.lx.config.users.find(u => u.name == userName) 48 | if (!user) throw new Error('user not found: ' + userName) 49 | return { 50 | maxSnapshotNum: global.lx.config.maxSnapshotNum, 51 | 'list.addMusicLocationType': global.lx.config['list.addMusicLocationType'], 52 | ...user, 53 | } 54 | } 55 | 56 | 57 | // 读取所有用户目录下的devicesInfo信息,建立clientId与用户的对应关系,用于非首次连接 58 | const deviceUserMap = new Map() 59 | for (const deviceInfo of fs.readdirSync(global.lx.userPath).map(dirname => { 60 | const devicesFilePath = path.join(global.lx.userPath, dirname, File.userDevicesJSON) 61 | if (fs.existsSync(devicesFilePath)) { 62 | const devicesInfo = JSON.parse(fs.readFileSync(devicesFilePath).toString()) as DevicesInfo 63 | if (getUserDirname(devicesInfo.userName) == dirname) return { userName: devicesInfo.userName, devices: devicesInfo.clients } 64 | } 65 | return { userName: '', devices: {} } 66 | })) { 67 | for (const device of Object.values(deviceInfo.devices)) { 68 | if (deviceInfo.userName) deviceUserMap.set(device.clientId, deviceInfo.userName) 69 | } 70 | } 71 | export const getUserName = (clientId: string | null): string | null => { 72 | if (!clientId) return null 73 | return deviceUserMap.get(clientId) ?? null 74 | } 75 | export const setUserName = (clientId: string, dir: string) => { 76 | deviceUserMap.set(clientId, dir) 77 | } 78 | export const deleteUserName = (clientId: string) => { 79 | deviceUserMap.delete(clientId) 80 | } 81 | 82 | export const createClientKeyInfo = (deviceName: string, isMobile: boolean): LX.Sync.KeyInfo => { 83 | const keyInfo: LX.Sync.KeyInfo = { 84 | clientId: randomBytes(4 * 4).toString('base64'), 85 | key: randomBytes(16).toString('base64'), 86 | deviceName, 87 | isMobile, 88 | lastConnectDate: 0, 89 | } 90 | return keyInfo 91 | } 92 | 93 | export class UserDataManage { 94 | userName: string 95 | userDir: string 96 | devicesFilePath: string 97 | devicesInfo: DevicesInfo 98 | private readonly saveDevicesInfoThrottle: () => void 99 | 100 | getAllClientKeyInfo = () => { 101 | return Object.values(this.devicesInfo.clients).sort((a, b) => (b.lastConnectDate ?? 0) - (a.lastConnectDate ?? 0)) 102 | } 103 | 104 | saveClientKeyInfo = (keyInfo: LX.Sync.KeyInfo) => { 105 | if (this.devicesInfo.clients[keyInfo.clientId] == null && Object.keys(this.devicesInfo.clients).length > 101) throw new Error('max keys') 106 | this.devicesInfo.clients[keyInfo.clientId] = keyInfo 107 | this.saveDevicesInfoThrottle() 108 | } 109 | 110 | getClientKeyInfo = (clientId: string | null): LX.Sync.KeyInfo | null => { 111 | if (!clientId) return null 112 | return this.devicesInfo.clients[clientId] ?? null 113 | } 114 | 115 | removeClientKeyInfo = async(clientId: string) => { 116 | // eslint-disable-next-line @typescript-eslint/no-dynamic-delete 117 | delete this.devicesInfo.clients[clientId] 118 | this.saveDevicesInfoThrottle() 119 | } 120 | 121 | isIncluedsClient = (clientId: string) => { 122 | return Object.values(this.devicesInfo.clients).some(client => client.clientId == clientId) 123 | } 124 | 125 | constructor(userName: string) { 126 | this.userName = userName 127 | this.userDir = path.join(global.lx.userPath, getUserDirname(userName)) 128 | this.devicesFilePath = path.join(this.userDir, File.userDevicesJSON) 129 | this.devicesInfo = fs.existsSync(this.devicesFilePath) ? JSON.parse(fs.readFileSync(this.devicesFilePath).toString()) : { userName, clients: {} } 130 | 131 | this.saveDevicesInfoThrottle = throttle(() => { 132 | fs.writeFile(this.devicesFilePath, JSON.stringify(this.devicesInfo), 'utf8', (err) => { 133 | if (err) console.error(err) 134 | }) 135 | }) 136 | } 137 | } 138 | // type UserDataManages = Map 139 | 140 | // export const createUserDataManage = (user: LX.UserConfig) => { 141 | // const manage = Object.create(userDataManage) as typeof userDataManage 142 | // manage.userDir = user.dataPath 143 | // } 144 | -------------------------------------------------------------------------------- /src/user/index.ts: -------------------------------------------------------------------------------- 1 | import { UserDataManage } from './data' 2 | import { 3 | ListManage, 4 | DislikeManage, 5 | } from '@/modules' 6 | 7 | export interface UserSpace { 8 | dataManage: UserDataManage 9 | listManage: ListManage 10 | dislikeManage: DislikeManage 11 | getDecices: () => Promise 12 | removeDevice: (clientId: string) => Promise 13 | } 14 | const users = new Map() 15 | 16 | const delayTime = 10 * 1000 17 | const delayReleaseTimeouts = new Map() 18 | const clearDelayReleaseTimeout = (userName: string) => { 19 | if (!delayReleaseTimeouts.has(userName)) return 20 | 21 | clearTimeout(delayReleaseTimeouts.get(userName)) 22 | delayReleaseTimeouts.delete(userName) 23 | } 24 | const seartDelayReleaseTimeout = (userName: string) => { 25 | clearDelayReleaseTimeout(userName) 26 | delayReleaseTimeouts.set(userName, setTimeout(() => { 27 | users.delete(userName) 28 | }, delayTime)) 29 | } 30 | 31 | export const getUserSpace = (userName: string) => { 32 | clearDelayReleaseTimeout(userName) 33 | 34 | let user = users.get(userName) 35 | if (!user) { 36 | console.log('new user data manage:', userName) 37 | const dataManage = new UserDataManage(userName) 38 | const listManage = new ListManage(dataManage) 39 | const dislikeManage = new DislikeManage(dataManage) 40 | users.set(userName, user = { 41 | dataManage, 42 | listManage, 43 | dislikeManage, 44 | async getDecices() { 45 | return this.dataManage.getAllClientKeyInfo() 46 | }, 47 | async removeDevice(clientId) { 48 | await listManage.removeDevice(clientId) 49 | await dataManage.removeClientKeyInfo(clientId) 50 | }, 51 | }) 52 | } 53 | return user 54 | } 55 | 56 | export const releaseUserSpace = (userName: string, force = false) => { 57 | if (force) { 58 | clearDelayReleaseTimeout(userName) 59 | users.delete(userName) 60 | } else seartDelayReleaseTimeout(userName) 61 | } 62 | 63 | 64 | export * from './data' 65 | -------------------------------------------------------------------------------- /src/utils/cache.ts: -------------------------------------------------------------------------------- 1 | import { LRUCache } from 'lru-cache' 2 | 3 | export default { 4 | store: new LRUCache({ 5 | max: 10000, 6 | ttl: 1000 * 60 * 60 * 24 * 2, 7 | // updateAgeOnGet: true, 8 | }), 9 | 10 | get(key: string) { 11 | return this.store.get(key) as T | null 12 | }, 13 | 14 | set(key: string, value: any) { 15 | return this.store.set(key, value) 16 | }, 17 | 18 | has(key: string) { 19 | return this.store.has(key) 20 | }, 21 | 22 | delete(key: string) { 23 | return this.store.delete(key) 24 | }, 25 | 26 | clear() { 27 | this.store.clear() 28 | }, 29 | } 30 | -------------------------------------------------------------------------------- /src/utils/common.ts: -------------------------------------------------------------------------------- 1 | // 非业务工具方法 2 | 3 | /** 4 | * 获取两个数之间的随机整数,大于等于min,小于max 5 | * @param {*} min 6 | * @param {*} max 7 | */ 8 | export const getRandom = (min: number, max: number): number => Math.floor(Math.random() * (max - min)) + min 9 | 10 | 11 | export const sizeFormate = (size: number): string => { 12 | // https://gist.github.com/thomseddon/3511330 13 | if (!size) return '0 B' 14 | let units = ['B', 'KB', 'MB', 'GB', 'TB'] 15 | let number = Math.floor(Math.log(size) / Math.log(1024)) 16 | return `${(size / Math.pow(1024, Math.floor(number))).toFixed(2)} ${units[number]}` 17 | } 18 | 19 | /** 20 | * 将字符串、时间戳等格式转成时间对象 21 | * @param date 时间 22 | * @returns 时间对象或空字符串 23 | */ 24 | export const toDateObj = (date: any): Date | '' => { 25 | // console.log(date) 26 | if (!date) return '' 27 | switch (typeof date) { 28 | case 'string': 29 | if (!date.includes('T')) date = date.split('.')[0].replace(/-/g, '/') 30 | // eslint-disable-next-line no-fallthrough 31 | case 'number': 32 | date = new Date(date) 33 | // eslint-disable-next-line no-fallthrough 34 | case 'object': 35 | break 36 | default: return '' 37 | } 38 | return date 39 | } 40 | 41 | const numFix = (n: number): string => n < 10 ? (`0${n}`) : n.toString() 42 | /** 43 | * 时间格式化 44 | * @param _date 时间 45 | * @param format Y-M-D h:m:s Y年 M月 D日 h时 m分 s秒 46 | */ 47 | export const dateFormat = (_date: any, format = 'Y-M-D h:m:s') => { 48 | // console.log(date) 49 | const date = toDateObj(_date) 50 | if (!date) return '' 51 | return format 52 | .replace('Y', date.getFullYear().toString()) 53 | .replace('M', numFix(date.getMonth() + 1)) 54 | .replace('D', numFix(date.getDate())) 55 | .replace('h', numFix(date.getHours())) 56 | .replace('m', numFix(date.getMinutes())) 57 | .replace('s', numFix(date.getSeconds())) 58 | } 59 | 60 | 61 | export const formatPlayTime = (time: number) => { 62 | let m = Math.trunc(time / 60) 63 | let s = Math.trunc(time % 60) 64 | return m == 0 && s == 0 ? '--/--' : numFix(m) + ':' + numFix(s) 65 | } 66 | 67 | export const formatPlayTime2 = (time: number) => { 68 | let m = Math.trunc(time / 60) 69 | let s = Math.trunc(time % 60) 70 | return numFix(m) + ':' + numFix(s) 71 | } 72 | 73 | 74 | const encodeNames = { 75 | ' ': ' ', 76 | '&': '&', 77 | '<': '<', 78 | '>': '>', 79 | '"': '"', 80 | ''': "'", 81 | ''': "'", 82 | } as const 83 | export const decodeName = (str: string | null = '') => { 84 | return str?.replace(/(?:&|<|>|"|'|'| )/gm, (s: string) => encodeNames[s as keyof typeof encodeNames]) ?? '' 85 | } 86 | 87 | export const isUrl = (path: string) => /https?:\/\//.test(path) 88 | 89 | // 解析URL参数为对象 90 | export const parseUrlParams = (str: string): Record => { 91 | const params: Record = {} 92 | if (typeof str !== 'string') return params 93 | const paramsArr = str.split('&') 94 | for (const param of paramsArr) { 95 | let [key, value] = param.split('=') 96 | params[key] = value 97 | } 98 | return params 99 | } 100 | 101 | /** 102 | * 生成节流函数 103 | * @param fn 回调 104 | * @param delay 延迟 105 | * @returns 106 | */ 107 | export function throttle(fn: (...args: Args) => void | Promise, delay = 100) { 108 | let timer: NodeJS.Timeout | null = null 109 | let _args: Args 110 | return (...args: Args) => { 111 | _args = args 112 | if (timer) return 113 | timer = setTimeout(() => { 114 | timer = null 115 | void fn(..._args) 116 | }, delay) 117 | } 118 | } 119 | 120 | /** 121 | * 生成防抖函数 122 | * @param fn 回调 123 | * @param delay 延迟 124 | * @returns 125 | */ 126 | export function debounce(fn: (...args: Args) => void | Promise, delay = 100) { 127 | let timer: NodeJS.Timeout | null = null 128 | let _args: Args 129 | return (...args: Args) => { 130 | _args = args 131 | if (timer) clearTimeout(timer) 132 | timer = setTimeout(() => { 133 | timer = null 134 | void fn(..._args) 135 | }, delay) 136 | } 137 | } 138 | 139 | const fileNameRxp = /[\\/:*?#"<>|]/g 140 | export const filterFileName = (name: string): string => name.replace(fileNameRxp, '') 141 | 142 | 143 | // https://blog.csdn.net/xcxy2015/article/details/77164126#comments 144 | /** 145 | * 146 | * @param a 147 | * @param b 148 | */ 149 | export const similar = (a: string, b: string) => { 150 | if (!a || !b) return 0 151 | if (a.length > b.length) { // 保证 a <= b 152 | let t = b 153 | b = a 154 | a = t 155 | } 156 | let al = a.length 157 | let bl = b.length 158 | let mp = [] // 一个表 159 | let i, j, ai, lt, tmp // ai:字符串a的第i个字符。 lt:左上角的值。 tmp:暂存新的值。 160 | for (i = 0; i <= bl; i++) mp[i] = i 161 | for (i = 1; i <= al; i++) { 162 | ai = a.charAt(i - 1) 163 | lt = mp[0] 164 | mp[0] = mp[0] + 1 165 | for (j = 1; j <= bl; j++) { 166 | tmp = Math.min(mp[j] + 1, mp[j - 1] + 1, lt + (ai == b.charAt(j - 1) ? 0 : 1)) 167 | lt = mp[j] 168 | mp[j] = tmp 169 | } 170 | } 171 | return 1 - (mp[bl] / bl) 172 | } 173 | 174 | /** 175 | * 排序字符串 176 | * @param arr 177 | * @param data 178 | */ 179 | export const sortInsert = (arr: Array<{ num: number, data: T }>, data: { num: number, data: T }) => { 180 | let key = data.num 181 | let left = 0 182 | let right = arr.length - 1 183 | 184 | while (left <= right) { 185 | let middle = Math.trunc((left + right) / 2) 186 | if (key == arr[middle].num) { 187 | left = middle 188 | break 189 | } else if (key < arr[middle].num) { 190 | right = middle - 1 191 | } else { 192 | left = middle + 1 193 | } 194 | } 195 | while (left > 0) { 196 | if (arr[left - 1].num != key) break 197 | left-- 198 | } 199 | 200 | arr.splice(left, 0, data) 201 | } 202 | 203 | export const encodePath = (path: string) => { 204 | return encodeURI(path.replaceAll('\\', '/')) 205 | } 206 | 207 | 208 | export const arrPush = (list: T[], newList: T[]) => { 209 | for (let i = 0; i * 1000 < newList.length; i++) { 210 | list.push(...newList.slice(i * 1000, (i + 1) * 1000)) 211 | } 212 | return list 213 | } 214 | 215 | export const arrUnshift = (list: T[], newList: T[]) => { 216 | for (let i = 0; i * 1000 < newList.length; i++) { 217 | list.splice(i * 1000, 0, ...newList.slice(i * 1000, (i + 1) * 1000)) 218 | } 219 | return list 220 | } 221 | 222 | export const arrPushByPosition = (list: T[], newList: T[], position: number) => { 223 | for (let i = 0; i * 1000 < newList.length; i++) { 224 | list.splice(position + i * 1000, 0, ...newList.slice(i * 1000, (i + 1) * 1000)) 225 | } 226 | return list 227 | } 228 | -------------------------------------------------------------------------------- /src/utils/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'node:fs' 2 | import crypto from 'node:crypto' 3 | 4 | 5 | export const createDirSync = (path: string) => { 6 | if (!fs.existsSync(path)) { 7 | try { 8 | fs.mkdirSync(path, { recursive: true }) 9 | } catch (e: any) { 10 | if (e.code !== 'EEXIST') { 11 | console.error('Could not set up log directory, error was: ', e) 12 | process.exit(1) 13 | } 14 | } 15 | } 16 | } 17 | 18 | const fileNameRxp = /[\\/:*?#"<>|]/g 19 | export const filterFileName = (name: string): string => name.replace(fileNameRxp, '') 20 | 21 | /** 22 | * 创建 MD5 hash 23 | * @param {*} str 24 | */ 25 | export const toMD5 = (str: string) => crypto.createHash('md5').update(str).digest('hex') 26 | 27 | export const checkAndCreateDirSync = (path: string) => { 28 | if (!fs.existsSync(path)) { 29 | fs.mkdirSync(path, { recursive: true }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/utils/log4js.ts: -------------------------------------------------------------------------------- 1 | import path from 'node:path' 2 | import log4js from 'log4js' 3 | 4 | const createLogConfig = (logPath: string) => { 5 | return { 6 | appenders: { 7 | access: { 8 | type: 'file', 9 | filename: path.join(logPath, 'access.log'), 10 | maxLogSize: 1024 * 1024 * 10, 11 | category: 'access', 12 | // compress: true, 13 | keepFileExt: true, 14 | numBackups: 10, 15 | }, 16 | app: { 17 | type: 'file', 18 | filename: path.join(logPath, 'app.log'), 19 | maxLogSize: 10485760, 20 | backups: 10, 21 | keepFileExt: true, 22 | }, 23 | errorFile: { 24 | type: 'file', 25 | filename: path.join(logPath, 'errors.log'), 26 | }, 27 | errors: { 28 | type: 'logLevelFilter', 29 | level: 'ERROR', 30 | appender: 'errorFile', 31 | }, 32 | console: { 33 | type: 'console', 34 | }, 35 | }, 36 | categories: { 37 | default: { appenders: ['app', 'errors', 'console'], level: 'DEBUG' }, 38 | access: { appenders: ['access'], level: 'ALL' }, 39 | }, 40 | } 41 | } 42 | 43 | 44 | export const initLogger = () => { 45 | log4js.configure(createLogConfig(global.lx.logPath)) 46 | } 47 | 48 | 49 | export const startupLog = log4js.getLogger('startup') 50 | export const syncLog = log4js.getLogger('sync') 51 | export const accessLog = log4js.getLogger('access') 52 | -------------------------------------------------------------------------------- /src/utils/migrate/index.ts: -------------------------------------------------------------------------------- 1 | import v2 from './v2' 2 | 3 | export default (dataPath: string, userPath: string) => { 4 | v2(dataPath, userPath) 5 | } 6 | 7 | -------------------------------------------------------------------------------- /src/utils/migrate/v2.ts: -------------------------------------------------------------------------------- 1 | import { File } from '@/constants' 2 | import { getVersion, setVersion } from '@/user' 3 | import fs from 'node:fs' 4 | import path from 'node:path' 5 | import { checkAndCreateDirSync } from '..' 6 | 7 | interface ServerKeyInfo { 8 | clientId: string 9 | key: string 10 | deviceName: string 11 | lastSyncDate?: number 12 | snapshotKey?: string 13 | lastConnectDate?: number 14 | isMobile: boolean 15 | } 16 | 17 | export default (dataPath: string, userPath: string) => { 18 | const version = getVersion() 19 | if (version != 1) return 20 | console.log('数据迁移:v1 -> v2') 21 | for (const dir of fs.readdirSync(userPath)) { 22 | const userDir = path.join(userPath, dir) 23 | const listDir = path.join(userDir, File.listDir) 24 | checkAndCreateDirSync(listDir) 25 | const oldSnapshotDir = path.join(userDir, File.listSnapshotDir) 26 | if (fs.existsSync(oldSnapshotDir)) fs.renameSync(oldSnapshotDir, path.join(listDir, File.listSnapshotDir)) 27 | 28 | const oldSnapshotInfoPath = path.join(userDir, File.listSnapshotInfoJSON) 29 | if (!fs.existsSync(oldSnapshotInfoPath)) continue 30 | const devicesInfoPath = path.join(userDir, File.userDevicesJSON) 31 | const snapshotInfo = JSON.parse(fs.readFileSync(oldSnapshotInfoPath).toString()) 32 | const devicesInfo = JSON.parse(fs.readFileSync(devicesInfoPath).toString()) 33 | snapshotInfo.clients = {} 34 | for (const device of (Object.values(devicesInfo.clients))) { 35 | snapshotInfo.clients[device.clientId] = { 36 | snapshotKey: device.snapshotKey, 37 | lastSyncDate: device.lastSyncDate, 38 | } 39 | device.lastConnectDate = device.lastSyncDate 40 | delete device.lastSyncDate 41 | delete device.snapshotKey 42 | } 43 | fs.writeFileSync(path.join(listDir, File.listSnapshotInfoJSON), JSON.stringify(snapshotInfo)) 44 | fs.writeFileSync(devicesInfoPath, JSON.stringify(devicesInfo)) 45 | fs.unlinkSync(oldSnapshotInfoPath) 46 | } 47 | setVersion(2) 48 | } 49 | 50 | -------------------------------------------------------------------------------- /src/utils/tools.ts: -------------------------------------------------------------------------------- 1 | import { networkInterfaces } from 'node:os' 2 | import { createCipheriv, createDecipheriv, publicEncrypt, privateDecrypt, constants } from 'node:crypto' 3 | // import { join } from 'node:path' 4 | import zlib from 'node:zlib' 5 | import type http from 'node:http' 6 | // import getStore from '@/utils/store' 7 | import { syncLog } from './log4js' 8 | import { getUserName } from '../user/data' 9 | // import { saveClientKeyInfo } from './data' 10 | 11 | export const getAddress = (): string[] => { 12 | const nets = networkInterfaces() 13 | const results: string[] = [] 14 | // console.log(nets) 15 | 16 | for (const interfaceInfos of Object.values(nets)) { 17 | if (!interfaceInfos) continue 18 | // Skip over non-IPv4 and internal (i.e. 127.0.0.1) addresses 19 | for (const interfaceInfo of interfaceInfos) { 20 | if (interfaceInfo.family === 'IPv4' && !interfaceInfo.internal) { 21 | results.push(interfaceInfo.address) 22 | } 23 | } 24 | } 25 | return results 26 | } 27 | 28 | export const generateCode = (): string => { 29 | return Math.random().toString().substring(2, 8) 30 | } 31 | 32 | export const getIP = (request: http.IncomingMessage) => { 33 | let ip: string | undefined 34 | if (global.lx.config['proxy.enabled']) { 35 | const proxyIp = request.headers[global.lx.config['proxy.header']] 36 | if (typeof proxyIp == 'string') ip = proxyIp 37 | } 38 | ip ||= request.socket.remoteAddress 39 | 40 | return ip 41 | } 42 | 43 | 44 | export const aesEncrypt = (buffer: string | Buffer, key: string): string => { 45 | const cipher = createCipheriv('aes-128-ecb', Buffer.from(key, 'base64'), '') 46 | return Buffer.concat([cipher.update(buffer), cipher.final()]).toString('base64') 47 | } 48 | 49 | export const aesDecrypt = (text: string, key: string): string => { 50 | const decipher = createDecipheriv('aes-128-ecb', Buffer.from(key, 'base64'), '') 51 | return Buffer.concat([decipher.update(Buffer.from(text, 'base64')), decipher.final()]).toString() 52 | } 53 | 54 | export const rsaEncrypt = (buffer: Buffer, key: string): string => { 55 | return publicEncrypt({ key, padding: constants.RSA_PKCS1_OAEP_PADDING }, buffer).toString('base64') 56 | } 57 | export const rsaDecrypt = (buffer: Buffer, key: string): Buffer => { 58 | return privateDecrypt({ key, padding: constants.RSA_PKCS1_OAEP_PADDING }, buffer) 59 | } 60 | 61 | 62 | const gzip = async(data: string) => new Promise((resolve, reject) => { 63 | zlib.gzip(data, (err, buf) => { 64 | if (err) { 65 | reject(err) 66 | return 67 | } 68 | resolve(buf.toString('base64')) 69 | }) 70 | }) 71 | const unGzip = async(data: string) => new Promise((resolve, reject) => { 72 | zlib.gunzip(Buffer.from(data, 'base64'), (err, buf) => { 73 | if (err) { 74 | reject(err) 75 | return 76 | } 77 | resolve(buf.toString()) 78 | }) 79 | }) 80 | 81 | export const encryptMsg = async(keyInfo: LX.Sync.KeyInfo | null, msg: string): Promise => { 82 | return msg.length > 1024 83 | ? 'cg_' + await gzip(msg) 84 | : msg 85 | // if (!keyInfo) return '' 86 | // return aesEncrypt(msg, keyInfo.key, keyInfo.iv) 87 | } 88 | 89 | export const decryptMsg = async(keyInfo: LX.Sync.KeyInfo | null, enMsg: string): Promise => { 90 | return enMsg.substring(0, 3) == 'cg_' 91 | ? await unGzip(enMsg.replace('cg_', '')) 92 | : enMsg 93 | // console.log('decmsg raw: ', len.length, 'en: ', enMsg.length) 94 | 95 | // if (!keyInfo) return '' 96 | // let msg = '' 97 | // try { 98 | // msg = aesDecrypt(enMsg, keyInfo.key, keyInfo.iv) 99 | // } catch (err) { 100 | // console.log(err) 101 | // } 102 | // return msg 103 | } 104 | 105 | // export const getSnapshotFilePath = (keyInfo: LX.Sync.KeyInfo): string => { 106 | // return join(global.lx.snapshotPath, `snapshot_${keyInfo.snapshotKey}.json`) 107 | // } 108 | 109 | export const sendStatus = (status: LX.Sync.Status) => { 110 | syncLog.info('status', status.devices.map(d => `${getUserName(d.clientId) ?? ''} ${d.deviceName}`)) 111 | } 112 | 113 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://github.com/tsconfig/bases#node-16-tsconfigjson 3 | "extends": "@tsconfig/node16/tsconfig.json", 4 | 5 | // Most ts-node options can be specified here using their programmatic names. 6 | "ts-node": { 7 | // It is faster to skip typechecking. 8 | // Remove if you want ts-node to do typechecking. 9 | "transpileOnly": true, 10 | "files": true, 11 | "compilerOptions": { 12 | // compilerOptions specified here will override those declared below, 13 | // but *only* in ts-node. Useful if you want ts-node and tsc to use 14 | // different options with a single tsconfig.json. 15 | } 16 | }, 17 | "compilerOptions": { 18 | "module": "NodeNext", 19 | "moduleResolution": "NodeNext", 20 | // typescript options here 21 | "allowJs": true, 22 | "rootDir": "./src", 23 | "outDir": "./server", 24 | "noImplicitReturns": false, 25 | "baseUrl": "./src", 26 | "paths": { 27 | "@/*": ["./*"], 28 | }, 29 | }, 30 | "include": ["src"], 31 | "exclude": ["node_modules"] 32 | } 33 | --------------------------------------------------------------------------------