mirror of
https://git.unlock-music.dev/um/web.git
synced 2025-04-19 11:07:34 +00:00
Compare commits
198 Commits
v1.9.0-bet
...
main
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7f050a682d | ||
![]() |
ce45bdb8dd | ||
![]() |
1846e5c959 | ||
![]() |
c866d4909f | ||
![]() |
bded04e40f | ||
![]() |
b7cb485be2 | ||
![]() |
3dcf064b48 | ||
![]() |
437765f79f | ||
![]() |
584fb24fdd | ||
![]() |
2835ac08ec | ||
![]() |
201e44fb38 | ||
![]() |
bdb8d6da34 | ||
![]() |
1429c5786e | ||
![]() |
c311fb7f5d | ||
![]() |
821d936b2c | ||
![]() |
dfe69c8d1f | ||
![]() |
a8acb25cd7 | ||
![]() |
36774c165e | ||
![]() |
573435efc9 | ||
![]() |
18a22099d0 | ||
![]() |
a788f48b67 | ||
![]() |
6f405b5e06 | ||
![]() |
0e0cf234b5 | ||
![]() |
82bf23f468 | ||
![]() |
60921faf4d | ||
![]() |
0867a788c6 | ||
![]() |
bb818fe820 | ||
![]() |
820b98afed | ||
![]() |
d567e9f136 | ||
![]() |
06a83197f3 | ||
![]() |
87fdf1300e | ||
![]() |
44418486f6 | ||
![]() |
0b4444d0d2 | ||
![]() |
8475a5bc65 | ||
![]() |
c5b25eb3b5 | ||
![]() |
383957ecef | ||
![]() |
ad314a8c42 | ||
![]() |
b55f20e9f6 | ||
![]() |
4f7d20f87b | ||
![]() |
29cd3ed3d2 | ||
![]() |
e9bab4565e | ||
![]() |
dd96013e50 | ||
![]() |
dc02e85cf4 | ||
![]() |
855945353a | ||
![]() |
a1b703a701 | ||
![]() |
f6dd6935cd | ||
![]() |
712d437795 | ||
![]() |
e22e9ae698 | ||
![]() |
a06f38a437 | ||
![]() |
4d1295a0e6 | ||
![]() |
178666a1fc | ||
![]() |
d27dec439d | ||
![]() |
cd0db8f382 | ||
![]() |
9248e0f2a1 | ||
![]() |
b788946b9e | ||
![]() |
5bb3c7851e | ||
![]() |
69313eaeef | ||
![]() |
f97cb04159 | ||
![]() |
e21456d38f | ||
![]() |
79f1fd2b48 | ||
![]() |
e462dd5f35 | ||
![]() |
f2d7d6c2e3 | ||
![]() |
e2b81f666d | ||
![]() |
91706e3273 | ||
![]() |
b47fb178a9 | ||
![]() |
e80369703d | ||
![]() |
117a916374 | ||
![]() |
fcbd9fc1f0 | ||
![]() |
9c03746db7 | ||
![]() |
01efdbdfa4 | ||
![]() |
64cb4774ea | ||
![]() |
76b2676f38 | ||
![]() |
b1d97ea85c | ||
![]() |
deb6ceb52d | ||
![]() |
b9b76ea987 | ||
![]() |
a99d6485a3 | ||
![]() |
fc33348fd6 | ||
![]() |
b44d38989b | ||
![]() |
1a12ebf17c | ||
![]() |
ff869f088e | ||
![]() |
6e8e8e8794 | ||
![]() |
e82c634621 | ||
![]() |
8c2c1da28c | ||
![]() |
986e02f182 | ||
![]() |
e51c7b81b1 | ||
![]() |
c2556d7cc5 | ||
![]() |
fd5c0957a2 | ||
![]() |
04dd96684e | ||
![]() |
fee35d4a95 | ||
![]() |
2872ceb3bb | ||
![]() |
46415e9eea | ||
![]() |
c3b75c8fea | ||
![]() |
a5de3829db | ||
![]() |
21d6b3d90f | ||
![]() |
ccb4458429 | ||
![]() |
81136dd4f8 | ||
![]() |
f6af50077a | ||
![]() |
914020563e | ||
![]() |
de14ccb0b3 | ||
![]() |
97cd7afc44 | ||
![]() |
176bec4a44 | ||
![]() |
99a2c4aaa5 | ||
![]() |
c63718f7d1 | ||
![]() |
06530ad092 | ||
![]() |
99926a752e | ||
![]() |
3d8c0c8304 | ||
![]() |
c9d684491b | ||
![]() |
ded9621bfa | ||
![]() |
66bbed3d78 | ||
![]() |
8ccdf9a762 | ||
![]() |
69b497a4ca | ||
![]() |
8894b5379d | ||
![]() |
c4177be7df | ||
![]() |
edffe53495 | ||
![]() |
152b695b10 | ||
![]() |
a2210becc5 | ||
![]() |
f6c34cd7ba | ||
![]() |
0af8a0d714 | ||
![]() |
2e0b24a6fc | ||
![]() |
9edb12b008 | ||
![]() |
96fccaeec7 | ||
![]() |
62cd276c5d | ||
![]() |
18a8dbfaa4 | ||
![]() |
7fac4c60a5 | ||
![]() |
556b69d6ef | ||
![]() |
c336ac4d05 | ||
![]() |
6d7f4512c4 | ||
![]() |
de1c686231 | ||
![]() |
290986e546 | ||
![]() |
a69ed4f3ce | ||
![]() |
85544cd09a | ||
![]() |
0715eeea0b | ||
![]() |
eaf457e6a0 | ||
![]() |
d8b362efbe | ||
![]() |
8673adfda6 | ||
![]() |
37da3318c8 | ||
![]() |
2855f72c03 | ||
![]() |
fd54617c5c | ||
![]() |
c895c5c069 | ||
![]() |
1e7116a3a9 | ||
![]() |
9add76c060 | ||
![]() |
f7a215103a | ||
![]() |
cd5fba5e4e | ||
![]() |
76dd78130a | ||
![]() |
19486d4d34 | ||
![]() |
8ce76fa7dc | ||
![]() |
632651b371 | ||
![]() |
979168b68f | ||
![]() |
1ab05bb509 | ||
![]() |
910b00529e | ||
![]() |
23b096512e | ||
![]() |
fe89710968 | ||
![]() |
8c11f47aa4 | ||
![]() |
769d3392f8 | ||
![]() |
9c3e39502a | ||
![]() |
5d8a726746 | ||
![]() |
e4c574465c | ||
![]() |
8fb9dc029a | ||
![]() |
8151c79652 | ||
![]() |
aa6476bb2c | ||
![]() |
0f6dc53e54 | ||
![]() |
58dfeee960 | ||
![]() |
b4068e2edb | ||
![]() |
cd3f4d8c22 | ||
![]() |
522f4e9f67 | ||
![]() |
439003f021 | ||
![]() |
d6aa88f371 | ||
![]() |
fb33e80484 | ||
![]() |
d199647308 | ||
![]() |
733c1721ed | ||
![]() |
dbb5472f96 | ||
![]() |
1111aaf3b7 | ||
![]() |
f80cf29657 | ||
![]() |
0a52d2a20b | ||
![]() |
ada078df19 | ||
![]() |
8facd65834 | ||
![]() |
a6367401ae | ||
![]() |
565ee37a8b | ||
![]() |
aab611bff5 | ||
![]() |
942e43fa16 | ||
![]() |
84be57a903 | ||
![]() |
a6a3c02538 | ||
![]() |
216ff4021c | ||
![]() |
d3898161b9 | ||
![]() |
5a7a9e3add | ||
![]() |
652bb1fc32 | ||
![]() |
6737e8c11b | ||
![]() |
71862538b7 | ||
![]() |
4251b94b1f | ||
![]() |
8fdda048f6 | ||
![]() |
39c7294996 | ||
![]() |
48f879cb58 | ||
![]() |
f0875ad175 | ||
![]() |
02a146e069 | ||
![]() |
2e31853ffb | ||
![]() |
a7aaf246ae | ||
![]() |
4bc0a10c09 | ||
![]() |
3645dd7d01 |
25
.drone.yml
Normal file
25
.drone.yml
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
---
|
||||||
|
kind: pipeline
|
||||||
|
type: docker
|
||||||
|
name: default
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: build
|
||||||
|
image: node:16.18-bullseye
|
||||||
|
commands:
|
||||||
|
- apt-get update
|
||||||
|
- apt-get install -y jq zip
|
||||||
|
- npm ci
|
||||||
|
- npm run test
|
||||||
|
- ./scripts/build-and-package.sh legacy
|
||||||
|
- ./scripts/build-and-package.sh extension
|
||||||
|
- ./scripts/build-and-package.sh modern
|
||||||
|
|
||||||
|
- name: upload artifact
|
||||||
|
image: node:16.18-bullseye
|
||||||
|
environment:
|
||||||
|
DRONE_GITEA_SERVER: https://git.unlock-music.dev
|
||||||
|
GITEA_API_KEY:
|
||||||
|
from_secret: GITEA_API_KEY
|
||||||
|
commands:
|
||||||
|
- ./scripts/upload-packages.sh
|
39
.github/ISSUE_TEMPLATE/bug-report.md
vendored
39
.github/ISSUE_TEMPLATE/bug-report.md
vendored
@ -1,39 +0,0 @@
|
|||||||
---
|
|
||||||
name: Bug报告
|
|
||||||
about: 报告Bug以帮助改进程序
|
|
||||||
title: ''
|
|
||||||
labels: bug
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
* 请按照此模板填写,否则可能立即被关闭
|
|
||||||
|
|
||||||
- [x] 我确认已经搜索过Issue不存并确认相同的Issue
|
|
||||||
- [x] 我有证据表明这是程序导致的问题(如不确认,可以在[Discussions](https://github.com/ix64/unlock-music/discussions)内提出)
|
|
||||||
|
|
||||||
|
|
||||||
**Bug描述**
|
|
||||||
|
|
||||||
简要地复述你遇到的Bug
|
|
||||||
|
|
||||||
**复现方法**
|
|
||||||
|
|
||||||
描述复现方法,必要时请提供样本文件
|
|
||||||
|
|
||||||
**程序截图或者Console报错信息**
|
|
||||||
|
|
||||||
如果可以请提供二者之一
|
|
||||||
|
|
||||||
|
|
||||||
**环境信息:**
|
|
||||||
|
|
||||||
- 操作系统和浏览器:
|
|
||||||
- 程序版本:
|
|
||||||
- 获取音乐文件所使用的客户端及其版本信息:
|
|
||||||
|
|
||||||
|
|
||||||
**附加信息**
|
|
||||||
|
|
||||||
其他能够帮助确认问题的信息
|
|
||||||
|
|
26
.github/ISSUE_TEMPLATE/new-feature.md
vendored
26
.github/ISSUE_TEMPLATE/new-feature.md
vendored
@ -1,26 +0,0 @@
|
|||||||
---
|
|
||||||
name: 新功能
|
|
||||||
about: 对于程序新的想法或建议
|
|
||||||
title: ''
|
|
||||||
labels: enhancement
|
|
||||||
assignees: ''
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
- 请按照此模板填写,否则可能立即被关闭
|
|
||||||
|
|
||||||
**背景和说明**
|
|
||||||
|
|
||||||
简要说明产生此想法的背景和此想法的具体内容
|
|
||||||
|
|
||||||
|
|
||||||
**实现途径**
|
|
||||||
|
|
||||||
- 如果没有设计方案,请简要描述实现思路
|
|
||||||
- 如果你没有任何的实现思路,请通过[Discussions](https://github.com/ix64/unlock-music/discussions)或者Telegram进行讨论
|
|
||||||
|
|
||||||
|
|
||||||
**附加信息**
|
|
||||||
|
|
||||||
更多你想要表达的内容
|
|
||||||
|
|
74
.github/workflows/build.yml
vendored
74
.github/workflows/build.yml
vendored
@ -1,74 +0,0 @@
|
|||||||
name: Test Build
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
paths:
|
|
||||||
- "**/*.js"
|
|
||||||
- "**/*.vue"
|
|
||||||
- "public/**/*"
|
|
||||||
- "package-lock.json"
|
|
||||||
- "package.json"
|
|
||||||
pull_request:
|
|
||||||
branches: [ master ]
|
|
||||||
types: [ opened, synchronize, reopened ]
|
|
||||||
paths:
|
|
||||||
- "**/*.js"
|
|
||||||
- "**/*.vue"
|
|
||||||
- "public/**/*"
|
|
||||||
- "package-lock.json"
|
|
||||||
- "package.json"
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
strategy:
|
|
||||||
matrix:
|
|
||||||
build: [ legacy, modern ]
|
|
||||||
include:
|
|
||||||
- build: legacy
|
|
||||||
BUILD_ARGS: ""
|
|
||||||
BUILD_EXTENSION: true
|
|
||||||
- build: modern
|
|
||||||
BUILD_ARGS: "-- --modern"
|
|
||||||
BUILD_EXTENSION: false
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Use Node.js 14.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: "14"
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npm run fix-compatibility
|
|
||||||
|
|
||||||
- name: Build
|
|
||||||
env:
|
|
||||||
GZIP: "--best"
|
|
||||||
run: |
|
|
||||||
npm run build ${{ matrix.BUILD_ARGS }}
|
|
||||||
tar -czvf dist.tar.gz -C ./dist .
|
|
||||||
|
|
||||||
- name: Build Extension
|
|
||||||
if: ${{ matrix.BUILD_EXTENSION }}
|
|
||||||
run: |
|
|
||||||
npm run make-extension
|
|
||||||
cd dist
|
|
||||||
zip -rJ9 ../extension.zip *
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Publish artifact
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: unlock-music-${{ matrix.build }}.tar.gz
|
|
||||||
path: ./dist.tar.gz
|
|
||||||
|
|
||||||
- name: Publish artifact - Extension
|
|
||||||
if: ${{ matrix.BUILD_EXTENSION }}
|
|
||||||
uses: actions/upload-artifact@v2
|
|
||||||
with:
|
|
||||||
name: extension.zip
|
|
||||||
path: ./extension.zip
|
|
65
.github/workflows/post-release.yml
vendored
65
.github/workflows/post-release.yml
vendored
@ -1,65 +0,0 @@
|
|||||||
name: Post Release
|
|
||||||
on:
|
|
||||||
release:
|
|
||||||
types: [ published ]
|
|
||||||
jobs:
|
|
||||||
release-docker:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Setup vars
|
|
||||||
id: vars
|
|
||||||
env:
|
|
||||||
RELEASE_REF: ${{ github.ref }}
|
|
||||||
run: echo "::set-output name=tag::${RELEASE_REF#refs/tags/}"
|
|
||||||
|
|
||||||
- name: Download release content
|
|
||||||
run: |
|
|
||||||
echo "https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.tag }}/modern.tar.gz"
|
|
||||||
wget -O modern.tar.gz "https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.tag }}/modern.tar.gz"
|
|
||||||
mkdir ./dist
|
|
||||||
tar zxf modern.tar.gz -C ./dist
|
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
|
||||||
uses: docker/setup-buildx-action@v1
|
|
||||||
|
|
||||||
- name: Login to DockerHub
|
|
||||||
uses: docker/login-action@v1
|
|
||||||
with:
|
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
|
||||||
|
|
||||||
- name: Build docker and push (on modern)
|
|
||||||
uses: docker/build-push-action@v2
|
|
||||||
with:
|
|
||||||
context: .
|
|
||||||
file: ./Dockerfile
|
|
||||||
platforms: linux/amd64,linux/arm64,linux/386
|
|
||||||
push: true
|
|
||||||
tags: |
|
|
||||||
ix64/unlock-music:latest
|
|
||||||
ix64/unlock-music:${{ steps.vars.outputs.tag }}
|
|
||||||
|
|
||||||
gh-pages:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Setup vars
|
|
||||||
id: vars
|
|
||||||
env:
|
|
||||||
RELEASE_REF: ${{ github.ref }}
|
|
||||||
run: echo "::set-output name=tag::${RELEASE_REF#refs/tags/}"
|
|
||||||
|
|
||||||
- name: Download release content
|
|
||||||
run: |
|
|
||||||
echo "https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.tag }}/modern.tar.gz"
|
|
||||||
wget -O modern.tar.gz "https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.tag }}/modern.tar.gz"
|
|
||||||
mkdir ./dist
|
|
||||||
tar zxf modern.tar.gz -C ./dist
|
|
||||||
|
|
||||||
- name: Deploy
|
|
||||||
uses: peaceiris/actions-gh-pages@v3
|
|
||||||
with:
|
|
||||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
publish_dir: ./dist
|
|
||||||
|
|
128
.github/workflows/release-build.yml
vendored
128
.github/workflows/release-build.yml
vendored
@ -1,128 +0,0 @@
|
|||||||
name: Build Release
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
tags:
|
|
||||||
- "v*"
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
|
|
||||||
- name: Use Node.js 14.x
|
|
||||||
uses: actions/setup-node@v2
|
|
||||||
with:
|
|
||||||
node-version: "14"
|
|
||||||
|
|
||||||
- name: Install Dependencies
|
|
||||||
run: |
|
|
||||||
npm ci
|
|
||||||
npm run fix-compatibility
|
|
||||||
|
|
||||||
- name: Build Legacy
|
|
||||||
env:
|
|
||||||
GZIP: "--best"
|
|
||||||
run: |
|
|
||||||
npm run build
|
|
||||||
tar -czf legacy.tar.gz -C ./dist .
|
|
||||||
cd dist
|
|
||||||
zip -rJ9 ../legacy.zip *
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Build Extension (on legacy)
|
|
||||||
env:
|
|
||||||
GZIP: "--best"
|
|
||||||
run: |
|
|
||||||
npm run make-extension
|
|
||||||
cd dist
|
|
||||||
zip -rJ9 ../extension.zip *
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Build Modern
|
|
||||||
env:
|
|
||||||
GZIP: "--best"
|
|
||||||
run: |
|
|
||||||
npm run build -- --modern
|
|
||||||
tar -czf modern.tar.gz -C ./dist .
|
|
||||||
cd dist
|
|
||||||
zip -rJ9 ../modern.zip *
|
|
||||||
cd ..
|
|
||||||
|
|
||||||
- name: Checksum
|
|
||||||
run: sha256sum *.tar.gz *.zip > sha256sum.txt
|
|
||||||
|
|
||||||
- name: Get current time
|
|
||||||
id: date
|
|
||||||
run: echo "::set-output name=date::$(date +'%Y/%m/%d')"
|
|
||||||
|
|
||||||
- name: Create a Release
|
|
||||||
id: create_release
|
|
||||||
uses: actions/create-release@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
tag_name: ${{ github.ref }}
|
|
||||||
release_name: "Build ${{ steps.date.outputs.date }}"
|
|
||||||
draft: true
|
|
||||||
|
|
||||||
- name: Upload Release Assets - legacy.tar.gz
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: ./legacy.tar.gz
|
|
||||||
asset_name: legacy.tar.gz
|
|
||||||
asset_content_type: application/gzip
|
|
||||||
|
|
||||||
- name: Upload Release Assets - legacy.zip
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: ./legacy.zip
|
|
||||||
asset_name: legacy.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|
||||||
- name: Upload Release Assets - modern.tar.gz
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: ./modern.tar.gz
|
|
||||||
asset_name: modern.tar.gz
|
|
||||||
asset_content_type: application/gzip
|
|
||||||
|
|
||||||
- name: Upload Release Assets - modern.zip
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: ./modern.zip
|
|
||||||
asset_name: modern.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|
||||||
- name: Upload Release Assets - extension.zip
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: ./extension.zip
|
|
||||||
asset_name: extension.zip
|
|
||||||
asset_content_type: application/zip
|
|
||||||
|
|
||||||
- name: Upload Release Assets - sha256sum.txt
|
|
||||||
uses: actions/upload-release-asset@v1
|
|
||||||
env:
|
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
||||||
with:
|
|
||||||
upload_url: ${{ steps.create_release.outputs.upload_url }}
|
|
||||||
asset_path: ./sha256sum.txt
|
|
||||||
asset_name: sha256sum.txt
|
|
||||||
asset_content_type: text/plain
|
|
13
.gitignore
vendored
13
.gitignore
vendored
@ -1,6 +1,8 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
node_modules
|
node_modules
|
||||||
/dist
|
/dist
|
||||||
|
/build
|
||||||
|
/coverage
|
||||||
|
|
||||||
# local env files
|
# local env files
|
||||||
.env.local
|
.env.local
|
||||||
@ -19,3 +21,14 @@ yarn-error.log*
|
|||||||
*.njsproj
|
*.njsproj
|
||||||
*.sln
|
*.sln
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
|
/src/KgmWasm/build
|
||||||
|
/src/KgmWasm/*.js
|
||||||
|
/src/KgmWasm/*.wasm
|
||||||
|
/src/QmcWasm/build
|
||||||
|
/src/QmcWasm/*.js
|
||||||
|
/src/QmcWasm/*.wasm
|
||||||
|
|
||||||
|
*.zip
|
||||||
|
*.tar.gz
|
||||||
|
/sha256sum.txt
|
||||||
|
47
.gitlab-ci.yml
Normal file
47
.gitlab-ci.yml
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
image: node:16
|
||||||
|
cache:
|
||||||
|
paths:
|
||||||
|
- node_modules/
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- build
|
||||||
|
|
||||||
|
|
||||||
|
build-job:
|
||||||
|
stage: build
|
||||||
|
script: |
|
||||||
|
sed -i 's/deb.debian.org/mirrors.cloud.tencent.com/g' /etc/apt/sources.list
|
||||||
|
apt-get update
|
||||||
|
apt-get -y install zip
|
||||||
|
|
||||||
|
npm config set registry http://mirrors.cloud.tencent.com/npm/
|
||||||
|
npm ci
|
||||||
|
|
||||||
|
npm run build
|
||||||
|
tar -czf legacy.tar.gz -C ./dist .
|
||||||
|
cd dist
|
||||||
|
zip -rJ9 ../legacy.zip *
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
npm run make-extension
|
||||||
|
cd dist
|
||||||
|
zip -rJ9 ../extension.zip *
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
npm run build -- --modern
|
||||||
|
tar -czf modern.tar.gz -C ./dist .
|
||||||
|
cd dist
|
||||||
|
zip -rJ9 ../modern.zip *
|
||||||
|
cd ..
|
||||||
|
|
||||||
|
sha256sum *.tar.gz *.zip > sha256sum.txt
|
||||||
|
|
||||||
|
artifacts:
|
||||||
|
name: "$CI_JOB_NAME"
|
||||||
|
paths:
|
||||||
|
- legacy.zip
|
||||||
|
- legacy.tar.gz
|
||||||
|
- extension.zip
|
||||||
|
- modern.zip
|
||||||
|
- modern.tar.gz
|
||||||
|
- sha256sum.txt
|
76
.gitlab/ISSUE_TEMPLATE/bug-crypto-guided.yaml
Normal file
76
.gitlab/ISSUE_TEMPLATE/bug-crypto-guided.yaml
Normal file
@ -0,0 +1,76 @@
|
|||||||
|
name: 解码错误报告 (填表)
|
||||||
|
about: 遇到文件解码失败的问题请选择该项。
|
||||||
|
title: '[Bug/Crypto] '
|
||||||
|
labels:
|
||||||
|
- bug
|
||||||
|
- crypto
|
||||||
|
body:
|
||||||
|
- type: textarea
|
||||||
|
id: what-happened
|
||||||
|
attributes:
|
||||||
|
label: 错误描述
|
||||||
|
description: 请描述你所遇到的问题,以及你期待的行为。
|
||||||
|
placeholder: ''
|
||||||
|
value: ''
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: version
|
||||||
|
attributes:
|
||||||
|
label: Unlock Music 版本
|
||||||
|
description: |
|
||||||
|
能够重现错误的版本,版本号通常在页面底部。
|
||||||
|
如果不确定,请升级到最新版确认问题是否解决。
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 1.10.5 (仓库最新)
|
||||||
|
- 1.10.3 (官方 DEMO)
|
||||||
|
- 其它(请在错误描述中指定)
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
id: browsers
|
||||||
|
attributes:
|
||||||
|
label: 产生错误的浏览器
|
||||||
|
multiple: true
|
||||||
|
options:
|
||||||
|
- 火狐 / Firefox
|
||||||
|
- Chrome
|
||||||
|
- Safari
|
||||||
|
- 其它基于 Chromium 的浏览器 (Edge、Brave、Opera 等)
|
||||||
|
- type: dropdown
|
||||||
|
id: music-platform
|
||||||
|
attributes:
|
||||||
|
label: 音乐平台
|
||||||
|
description: |
|
||||||
|
如果需要报告多个平台的问题,请每个平台提交一个新的 Issue。
|
||||||
|
请注意:播放器缓存文件不属于该项目支持的文件类型。
|
||||||
|
multiple: false
|
||||||
|
options:
|
||||||
|
- 其它 (请在错误描述指定)
|
||||||
|
- QQ 音乐
|
||||||
|
- Joox (QQ 音乐海外版)
|
||||||
|
- 虾米音乐
|
||||||
|
- 网易云音乐
|
||||||
|
- 酷我音乐
|
||||||
|
- 酷狗音乐
|
||||||
|
- 喜马拉雅
|
||||||
|
- 咪咕 3D
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
|
- type: textarea
|
||||||
|
id: logs
|
||||||
|
attributes:
|
||||||
|
label: 日志信息
|
||||||
|
description: 如果有,请提供浏览器开发者控制台(Console)的错误日志:
|
||||||
|
render: text
|
||||||
|
- type: checkboxes
|
||||||
|
id: terms
|
||||||
|
attributes:
|
||||||
|
label: 我已经阅读并确认下述内容
|
||||||
|
description: ''
|
||||||
|
options:
|
||||||
|
- label: 我已经检索过 Issue 列表,并确认这是一个为报告过的问题。
|
||||||
|
required: true
|
||||||
|
- label: 我有证据表明这是程序导致的问题(如不确认,可以通过 Telegram 讨论组 (https://t.me/unlock_music_chat) 进行讨论)
|
||||||
|
required: true
|
40
.gitlab/ISSUE_TEMPLATE/bug-report.md
Normal file
40
.gitlab/ISSUE_TEMPLATE/bug-report.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
name: "错误报告"
|
||||||
|
about: "报告 Bug 以帮助改进程序,非填表。"
|
||||||
|
title: "[BUG] "
|
||||||
|
labels:
|
||||||
|
|
||||||
|
- bug
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
* 请按照此模板填写,否则可能立即被关闭
|
||||||
|
|
||||||
|
- [x] 我确认已经搜索过Issue不存并确认相同的Issue
|
||||||
|
- [x] 我有证据表明这是程序导致的问题(如不确认,可以通过 Telegram 讨论组 (https://t.me/unlock_music_chat) 进行讨论)
|
||||||
|
|
||||||
|
## Bug描述
|
||||||
|
|
||||||
|
简要地复述你遇到的Bug
|
||||||
|
|
||||||
|
## 复现方法
|
||||||
|
|
||||||
|
描述复现方法,必要时请提供样本文件
|
||||||
|
|
||||||
|
## 程序截图或浏览器开发者控制台(Console)的报错信息
|
||||||
|
|
||||||
|
如果可以请提供二者之一
|
||||||
|
|
||||||
|
## 环境信息
|
||||||
|
|
||||||
|
- 操作系统和浏览器:
|
||||||
|
- 程序版本:
|
||||||
|
- 网页版的地址(如果为非官方部署请注明):
|
||||||
|
|
||||||
|
注意:如果需要会员才能获取该资源,你可能也需要作为附件提交。
|
||||||
|
|
||||||
|
## 附加信息
|
||||||
|
|
||||||
|
如果有,请提供其他能够帮助确认问题的信息到下方:
|
||||||
|
|
29
.gitlab/ISSUE_TEMPLATE/new-feature.md
Normal file
29
.gitlab/ISSUE_TEMPLATE/new-feature.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
---
|
||||||
|
|
||||||
|
name: "新功能"
|
||||||
|
about: "对于程序新的想法或建议"
|
||||||
|
title: "[新功能] "
|
||||||
|
labels:
|
||||||
|
|
||||||
|
- enhancement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<!-- ⚠ 请按照此模板填写,否则可能立即被关闭 -->
|
||||||
|
<!-- 提交前请使用【Preview】预览提交的更改 -->
|
||||||
|
|
||||||
|
## 背景和说明
|
||||||
|
|
||||||
|
<!-- 简要说明产生此想法的背景和此想法的具体内容 -->
|
||||||
|
|
||||||
|
|
||||||
|
## 实现途径
|
||||||
|
|
||||||
|
- 如果没有设计方案,请简要描述实现思路
|
||||||
|
- 如果你没有任何的实现思路,请通过 Telegram 讨论组 (https://t.me/unlock_music_chat) 进行讨论
|
||||||
|
|
||||||
|
|
||||||
|
## 附加信息
|
||||||
|
|
||||||
|
<!-- 更多你想要表达的内容 -->
|
||||||
|
|
1
.npmrc
Normal file
1
.npmrc
Normal file
@ -0,0 +1 @@
|
|||||||
|
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/
|
42
.prettierrc.js
Normal file
42
.prettierrc.js
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
// .prettierrc.js
|
||||||
|
module.exports = {
|
||||||
|
// 一行最多 120 字符
|
||||||
|
printWidth: 120,
|
||||||
|
// 使用 2 个空格缩进
|
||||||
|
tabWidth: 2,
|
||||||
|
// 不使用缩进符,而使用空格
|
||||||
|
useTabs: false,
|
||||||
|
// 行尾需要有分号
|
||||||
|
semi: true,
|
||||||
|
// 使用单引号
|
||||||
|
singleQuote: true,
|
||||||
|
// 对象的 key 仅在必要时用引号
|
||||||
|
quoteProps: 'as-needed',
|
||||||
|
// jsx 不使用单引号,而使用双引号
|
||||||
|
jsxSingleQuote: false,
|
||||||
|
// 末尾需要有逗号
|
||||||
|
trailingComma: 'all',
|
||||||
|
// 大括号内的首尾需要空格
|
||||||
|
bracketSpacing: true,
|
||||||
|
// jsx 标签的反尖括号需要换行
|
||||||
|
bracketSameLine: false,
|
||||||
|
// 箭头函数,只有一个参数的时候,也需要括号
|
||||||
|
arrowParens: 'always',
|
||||||
|
// 每个文件格式化的范围是文件的全部内容
|
||||||
|
rangeStart: 0,
|
||||||
|
rangeEnd: Infinity,
|
||||||
|
// 不需要写文件开头的 @prettier
|
||||||
|
requirePragma: false,
|
||||||
|
// 不需要自动在文件开头插入 @prettier
|
||||||
|
insertPragma: false,
|
||||||
|
// 使用默认的折行标准
|
||||||
|
proseWrap: 'preserve',
|
||||||
|
// 根据显示样式决定 html 要不要折行
|
||||||
|
htmlWhitespaceSensitivity: 'css',
|
||||||
|
// vue 文件中的 script 和 style 内不用缩进
|
||||||
|
vueIndentScriptAndStyle: false,
|
||||||
|
// 换行符使用 lf
|
||||||
|
endOfLine: 'lf',
|
||||||
|
// 格式化嵌入的内容
|
||||||
|
embeddedLanguageFormatting: 'auto',
|
||||||
|
};
|
2
LICENSE
2
LICENSE
@ -1,6 +1,6 @@
|
|||||||
MIT License
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2019-2020 MengYX
|
Copyright (c) 2019-2023 MengYX
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the "Software"), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
93
README.md
93
README.md
@ -1,71 +1,80 @@
|
|||||||
# Unlock Music 音乐解锁
|
# Unlock Music 音乐解锁
|
||||||
|
|
||||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
[](https://ci.unlock-music.dev/um/web)
|
||||||
- unlock-music项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[License](https://github.com/ix64/unlock-music/blob/master/LICENSE)
|
|
||||||
- Unlock Music的CLI版本正在开发中。
|
|
||||||
- 我们新建了Telegram群组,欢迎加入
|
|
||||||
- [CLI版本 Alpha](https://github.com/unlock-music/cli) 大批量转换建议使用CLI版本
|
|
||||||
- [相关的其他项目](https://github.com/ix64/unlock-music/wiki/%E5%92%8CUnlockMusic%E7%9B%B8%E5%85%B3%E7%9A%84%E9%A1%B9%E7%9B%AE)
|
|
||||||
|
|
||||||

|
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||||

|
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。
|
||||||

|
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。
|
||||||
|
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入!
|
||||||
|
- CI 自动构建已经部署,可以在 [um-packages] 下载
|
||||||
|
|
||||||
|
> **WARNING**
|
||||||
|
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。
|
||||||
|
|
||||||
|
[授权协议]: https://git.unlock-music.dev/um/web/src/branch/master/LICENSE
|
||||||
|
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
|
||||||
|
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
|
||||||
|
[um-packages]: https://git.unlock-music.dev/um/-/packages/generic/web-build/
|
||||||
|
|
||||||
## 特性
|
## 特性
|
||||||
|
|
||||||
### 支持的格式
|
### 支持的格式
|
||||||
|
|
||||||
- [x] QQ音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/[.tkm](https://github.com/ix64/unlock-music/issues/9))
|
- [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm)
|
||||||
- [x] 写入封面图片
|
- [x] Moo 音乐格式 (.bkcmp3/.bkcflac/...)
|
||||||
- [x] Moo音乐格式 ([.bkcmp3/.bkcflac](https://github.com/ix64/unlock-music/issues/11))
|
- [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6)
|
||||||
- [x] QQ音乐Tm格式 (.tm0/.tm2/.tm3/.tm6)
|
- [x] QQ 音乐新格式 (.mflac/.mgg/.mflac0/.mgg1/.mggl)
|
||||||
- [x] QQ音乐新格式 (实验性支持)
|
- [x] <ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)
|
||||||
- [x] .mflac
|
|
||||||
- [x] [.mgg](https://github.com/ix64/unlock-music/issues/3)
|
|
||||||
- [x] 网易云音乐格式 (.ncm)
|
- [x] 网易云音乐格式 (.ncm)
|
||||||
- [x] 补全ncm的ID3/FlacMeta信息
|
- [x] 虾米音乐格式 (.xm)
|
||||||
- [x] 虾米音乐格式 (.xm) (测试阶段)
|
- [x] 酷我音乐格式 (.kwm)
|
||||||
- [x] 酷我音乐格式 (.kwm) (测试阶段)
|
- [x] 酷狗音乐格式 (.kgm/.vpr)
|
||||||
- [x] 酷狗音乐格式 (
|
- [x] Android 版喜马拉雅文件格式 (.x2m/.x3m)
|
||||||
.kgm) ([CLI版本](https://github.com/ix64/unlock-music/wiki/%E5%85%B6%E4%BB%96%E9%9F%B3%E4%B9%90%E6%A0%BC%E5%BC%8F%E5%B7%A5%E5%85%B7#%E9%85%B7%E7%8B%97%E9%9F%B3%E4%B9%90-kgmvpr%E8%A7%A3%E9%94%81%E5%B7%A5%E5%85%B7))
|
- [x] 咪咕音乐格式 (.mg3d)
|
||||||
|
|
||||||
### 其他特性
|
### 其他特性
|
||||||
|
|
||||||
- [x] 在浏览器中解锁
|
- [x] 在浏览器中解锁
|
||||||
- [x] 拖放文件
|
- [x] 拖放文件
|
||||||
- [x] 在线播放
|
|
||||||
- [x] 批量解锁
|
- [x] 批量解锁
|
||||||
- [x] 渐进式Web应用
|
- [x] 渐进式 Web 应用 (PWA)
|
||||||
- [x] 多线程
|
- [x] 多线程
|
||||||
|
- [x] 写入和编辑元信息与专辑封面
|
||||||
|
|
||||||
## 使用方法
|
## 使用方法
|
||||||
|
|
||||||
### 安装浏览器扩展
|
### 使用预构建版本
|
||||||
|
|
||||||
[](https://chrome.google.com/webstore/detail/gldlhhhmienbhlpkfanjpmffdjblmegd)
|
- 从 [Release] 或 [CI 构建][um-packages] 下载预构建的版本
|
||||||
[<img src="https://developer.microsoft.com/en-us/store/badges/images/Chinese_Simplified_get-it-from-MS.png" height="60" alt="Microsoft Edge Addons"/>](https://microsoftedge.microsoft.com/addons/detail/ggafoipegcmodfhakdkalpdpcdkiljmd)
|
- :warning: 本地使用请下载`legacy版本`(`modern版本`只能通过 **http(s)协议** 访问)
|
||||||
[](https://addons.mozilla.org/zh-CN/firefox/addon/unlock-music/)
|
|
||||||
|
|
||||||
### 使用已构建版本
|
|
||||||
|
|
||||||
- 从[GitHub Release](https://github.com/ix64/unlock-music/releases/latest)下载已构建的版本
|
|
||||||
- 本地使用请下载`legacy版本`(`modern版本`只能通过**http/https协议**访问)
|
|
||||||
- 解压缩后即可部署或本地使用(**请勿直接运行源代码**)
|
- 解压缩后即可部署或本地使用(**请勿直接运行源代码**)
|
||||||
|
|
||||||
### 使用Docker镜像
|
[release]: https://git.unlock-music.dev/um/web/releases/latest
|
||||||
|
|
||||||
```shell
|
|
||||||
docker run --name unlock-music -d -p 8080:80 ix64/unlock-music
|
|
||||||
```
|
|
||||||
|
|
||||||
### 自行构建
|
### 自行构建
|
||||||
|
|
||||||
- 环境要求
|
- 环境要求
|
||||||
- nodejs
|
- nodejs (v16.x)
|
||||||
- npm
|
- npm
|
||||||
|
|
||||||
1. 获取项目源代码后执行 `npm install` 安装相关依赖
|
1. 获取项目源代码后安装相关依赖:
|
||||||
2. 执行 `npm run build` 即可进行构建,构建输出为 dist 目录
|
|
||||||
|
|
||||||
- `npm run serve` 可用于开发
|
```sh
|
||||||
3. 如需构建浏览器扩展,build完成后还需要执行`npm run make-extension`
|
npm install
|
||||||
|
npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 然后进行构建:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
- 构建后的产物可以在 `dist` 目录找到。
|
||||||
|
- 如果是用于开发,可以执行 `npm run serve`。
|
||||||
|
|
||||||
|
3. 如需构建浏览器扩展,构建成功后还需要执行:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run make-extension
|
||||||
|
```
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
presets: [
|
presets: [
|
||||||
'@vue/app'
|
'@vue/app',
|
||||||
|
'@babel/preset-typescript'
|
||||||
],
|
],
|
||||||
plugins: [
|
plugins: [
|
||||||
["component", {
|
["component", {
|
||||||
|
@ -1,15 +1,29 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 2,
|
"manifest_version": 3,
|
||||||
|
"content_security_policy": {
|
||||||
|
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
|
||||||
|
},
|
||||||
"name": "音乐解锁",
|
"name": "音乐解锁",
|
||||||
"short_name": "音乐解锁",
|
"short_name": "音乐解锁",
|
||||||
"icons": {
|
"icons": {
|
||||||
"128": "./img/icons/msapplication-icon-144x144.png"
|
"16": "img/icons/favicon-16x16.png",
|
||||||
|
"32": "img/icons/favicon-32x32.png",
|
||||||
|
"192": "img/icons/android-chrome-192x192.png",
|
||||||
|
"512": "img/icons/android-chrome-512x512.png"
|
||||||
},
|
},
|
||||||
"description": "在任何设备上解锁已购的加密音乐!",
|
"description": "在任何设备上解锁已购的加密音乐!",
|
||||||
|
"permissions": ["storage"],
|
||||||
"offline_enabled": true,
|
"offline_enabled": true,
|
||||||
"options_page": "./index.html",
|
"options_page": "index.html",
|
||||||
"homepage_url": "https://github.com/ix64/unlock-music",
|
"homepage_url": "https://git.unlock-music.dev/um/web",
|
||||||
"browser_action": {
|
"action": {
|
||||||
|
"default_icon": "img/icons/favicon-32x32.png",
|
||||||
"default_popup": "./popup.html"
|
"default_popup": "./popup.html"
|
||||||
|
},
|
||||||
|
"browser_specific_settings": {
|
||||||
|
"gecko": {
|
||||||
|
"id": "addon@unlock-music.dev",
|
||||||
|
"strict_min_version": "128.0"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
7
jest.config.js
Normal file
7
jest.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module.exports = {
|
||||||
|
testPathIgnorePatterns: ['/build/', '/dist/', '/node_modules/'],
|
||||||
|
setupFilesAfterEnv: ['./src/__test__/setup_jest.js'],
|
||||||
|
moduleNameMapper: {
|
||||||
|
'@/(.*)': '<rootDir>/src/$1',
|
||||||
|
},
|
||||||
|
};
|
@ -1,7 +1,7 @@
|
|||||||
const fs = require('fs')
|
const fs = require('fs')
|
||||||
const path = require('path')
|
const path = require('path')
|
||||||
const src = "./src/extension/"
|
const src = __dirname + "/src/extension/"
|
||||||
const dst = "./dist"
|
const dst = __dirname + "/dist"
|
||||||
fs.readdirSync(src).forEach(file => {
|
fs.readdirSync(src).forEach(file => {
|
||||||
let srcPath = path.join(src, file)
|
let srcPath = path.join(src, file)
|
||||||
let dstPath = path.join(dst, file)
|
let dstPath = path.join(dst, file)
|
||||||
@ -9,12 +9,17 @@ fs.readdirSync(src).forEach(file => {
|
|||||||
console.log(`Copy: ${srcPath} => ${dstPath}`)
|
console.log(`Copy: ${srcPath} => ${dstPath}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
const manifestRaw = fs.readFileSync("./extension-manifest.json", "utf-8")
|
const manifestRaw = fs.readFileSync(__dirname + "/extension-manifest.json", "utf-8")
|
||||||
const manifest = JSON.parse(manifestRaw)
|
const manifest = JSON.parse(manifestRaw)
|
||||||
|
|
||||||
const pkgRaw = fs.readFileSync("./package.json", "utf-8")
|
const pkgRaw = fs.readFileSync(__dirname + "/package.json", "utf-8")
|
||||||
const pkg = JSON.parse(pkgRaw)
|
const pkg = JSON.parse(pkgRaw)
|
||||||
|
|
||||||
manifest["version"] = pkg["version"]
|
verExt = pkg["version"]
|
||||||
fs.writeFileSync("./dist/manifest.json", JSON.stringify(manifest), "utf-8")
|
if (verExt.startsWith("v")) verExt = verExt.slice(1)
|
||||||
|
if (verExt.includes("-")) verExt = verExt.split("-")[0]
|
||||||
|
manifest["version"] = `${verExt}.${pkg["ext_build"]}`
|
||||||
|
manifest["version_name"] = pkg["version"]
|
||||||
|
|
||||||
|
fs.writeFileSync(__dirname + "/dist/manifest.json", JSON.stringify(manifest), "utf-8")
|
||||||
console.log("Write: manifest.json")
|
console.log("Write: manifest.json")
|
||||||
|
31268
package-lock.json
generated
31268
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
41
package.json
41
package.json
@ -1,47 +1,60 @@
|
|||||||
{
|
{
|
||||||
"name": "unlock-music",
|
"name": "unlock-music",
|
||||||
"version": "v1.9.0-beta",
|
"version": "1.10.8",
|
||||||
"updateInfo": "新增写入本地文件系统; 优化.kwm解锁; 支持.acc嗅探; 使用Typescript重构",
|
"ext_build": 0,
|
||||||
|
"updateInfo": "修正 joox 在远程获取 API 信息出错时不能正确回退到本地元信息获取的错误。",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"description": "Unlock encrypted music file in browser.",
|
"description": "Unlock encrypted music file in browser.",
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/ix64/unlock-music"
|
"url": "https://git.unlock-music.dev/um/web"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
"postinstall": "patch-package",
|
||||||
"serve": "vue-cli-service serve",
|
"serve": "vue-cli-service serve",
|
||||||
"build": "vue-cli-service build",
|
"build": "vue-cli-service build",
|
||||||
"fix-compatibility": "node ./src/fix-compatibility.js",
|
"test": "jest",
|
||||||
|
"pretty": "prettier --write src/{**/*,*}.{js,ts,jsx,tsx,vue}",
|
||||||
|
"pretty:check": "prettier --check src/{**/*,*}.{js,ts,jsx,tsx,vue}",
|
||||||
"make-extension": "node ./make-extension.js"
|
"make-extension": "node ./make-extension.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/preset-typescript": "^7.16.5",
|
||||||
|
"@unlock-music/joox-crypto": "^0.0.1",
|
||||||
|
"@xhacker/kgmwasm": "^1.0.0",
|
||||||
|
"@xhacker/qmcwasm": "^1.0.0",
|
||||||
"base64-js": "^1.5.1",
|
"base64-js": "^1.5.1",
|
||||||
"browser-id3-writer": "^4.4.0",
|
"browser-id3-writer": "^4.4.0",
|
||||||
"core-js": "^3.12.1",
|
"core-js": "^3.16.0",
|
||||||
"crypto-js": "^4.0.0",
|
"crypto-js": "^4.1.1",
|
||||||
"element-ui": "^2.15.1",
|
"element-ui": "^2.15.5",
|
||||||
"iconv-lite": "^0.6.3",
|
"iconv-lite": "^0.6.3",
|
||||||
"jimp": "^0.16.1",
|
"jimp": "^0.16.1",
|
||||||
"metaflac-js": "^1.0.5",
|
"metaflac-js": "^1.0.5",
|
||||||
"music-metadata-browser": "^2.2.6",
|
"music-metadata": "7.9.0",
|
||||||
|
"music-metadata-browser": "2.2.7",
|
||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"threads": "^1.6.4",
|
"threads": "^1.6.5",
|
||||||
"vue": "^2.6.12"
|
"vue": "^2.6.14"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/crypto-js": "^4.0.1",
|
"@types/crypto-js": "^4.0.2",
|
||||||
|
"@types/jest": "^27.0.3",
|
||||||
"@vue/cli-plugin-babel": "^4.5.13",
|
"@vue/cli-plugin-babel": "^4.5.13",
|
||||||
"@vue/cli-plugin-pwa": "^4.5.13",
|
"@vue/cli-plugin-pwa": "^4.5.13",
|
||||||
"@vue/cli-plugin-typescript": "^4.5.13",
|
"@vue/cli-plugin-typescript": "^4.5.13",
|
||||||
"@vue/cli-service": "^4.5.13",
|
"@vue/cli-service": "^4.5.13",
|
||||||
"babel-plugin-component": "^1.1.1",
|
"babel-plugin-component": "^1.1.1",
|
||||||
"node-sass": "^5.0.0",
|
"jest": "^27.4.5",
|
||||||
|
"patch-package": "^6.4.7",
|
||||||
|
"prettier": "2.5.1",
|
||||||
|
"sass": "^1.38.1",
|
||||||
"sass-loader": "^10.2.0",
|
"sass-loader": "^10.2.0",
|
||||||
"semver": "^7.3.5",
|
"semver": "^7.3.5",
|
||||||
"threads-plugin": "^1.4.0",
|
"threads-plugin": "^1.4.0",
|
||||||
"typescript": "~4.1.5",
|
"typescript": "^4.5.4",
|
||||||
"vue-cli-plugin-element": "^1.0.1",
|
"vue-cli-plugin-element": "^1.0.1",
|
||||||
"vue-template-compiler": "^2.6.12"
|
"vue-template-compiler": "^2.6.14"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
11
patches/threads+1.7.0.patch
Normal file
11
patches/threads+1.7.0.patch
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
diff --git a/node_modules/threads/worker.mjs b/node_modules/threads/worker.mjs
|
||||||
|
index c53ac7d..619007b 100644
|
||||||
|
--- a/node_modules/threads/worker.mjs
|
||||||
|
+++ b/node_modules/threads/worker.mjs
|
||||||
|
@@ -1,4 +1,5 @@
|
||||||
|
-import WorkerContext from "./dist/worker/index.js"
|
||||||
|
+// Workaround: use of import seems to break minifier.
|
||||||
|
+const WorkerContext = require("./dist/worker/index.js")
|
||||||
|
|
||||||
|
export const expose = WorkerContext.expose
|
||||||
|
export const registerSerializer = WorkerContext.registerSerializer
|
@ -1,43 +1,89 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="zh-CN">
|
<html lang="zh-CN">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8" />
|
||||||
<meta content="webkit" name="renderer">
|
<meta content="webkit" name="renderer" />
|
||||||
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
|
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible" />
|
||||||
<meta content="width=device-width,initial-scale=1.0" name="viewport">
|
<meta content="width=device-width,initial-scale=1.0" name="viewport" />
|
||||||
<title>音乐解锁</title>
|
<title>音乐解锁</title>
|
||||||
<meta content="音乐,解锁,ncm,qmc,mgg,mflac,qq音乐,网易云音乐,加密" name="keywords"/>
|
<meta content="音乐,解锁,ncm,qmc,mgg,mflac,qq音乐,网易云音乐,加密" name="keywords" />
|
||||||
<meta content="音乐解锁 - 在任何设备上解锁已购的加密音乐!" name="description"/>
|
<meta content="音乐解锁 - 在任何设备上解锁已购的加密音乐!" name="description" />
|
||||||
<script src="./ixarea-stats.js"></script>
|
<style>
|
||||||
<!--@formatter:off-->
|
#loader {
|
||||||
<style>#loader{position:absolute;left:50%;top:50%;z-index:1010;margin:-75px 0 0 -75px;border:16px solid #f3f3f3;border-radius:50%;border-top:16px solid #1db1ff;width:120px;height:120px;animation:spin 2s linear infinite}@keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}#loader-mask{text-align:center;position:absolute;width:100%;height:100%;bottom:0;left:0;right:0;top:0;z-index:1009;background-color:rgba(242,246,252,.88)}@media (prefers-color-scheme:dark){#loader-mask{color:#fff;background-color:rgba(0,0,0,.85)}#loader-mask a{color:#ddd}#loader-mask a:hover{color:#1db1ff}}#loader-source{font-size:1.5rem}#loader-tips-timeout{font-size:1.2rem}</style>
|
position: absolute;
|
||||||
<!--@formatter:on-->
|
left: 50%;
|
||||||
</head>
|
top: 50%;
|
||||||
<body>
|
z-index: 1010;
|
||||||
|
margin: -75px 0 0 -75px;
|
||||||
|
border: 16px solid #f3f3f3;
|
||||||
|
border-radius: 50%;
|
||||||
|
border-top: 16px solid #1db1ff;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
animation: spin 2s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes spin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#loader-mask {
|
||||||
|
text-align: center;
|
||||||
|
position: absolute;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1009;
|
||||||
|
background-color: rgba(242, 246, 252, 0.88);
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
#loader-mask {
|
||||||
|
color: #fff;
|
||||||
|
background-color: rgba(0, 0, 0, 0.85);
|
||||||
|
}
|
||||||
|
#loader-mask a {
|
||||||
|
color: #ddd;
|
||||||
|
}
|
||||||
|
#loader-mask a:hover {
|
||||||
|
color: #1db1ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#loader-source {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
#loader-tips-timeout {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
<div id="loader-mask">
|
<body>
|
||||||
<div id="loader"></div>
|
<div id="loader-mask">
|
||||||
<noscript>
|
<div id="loader"></div>
|
||||||
|
<noscript>
|
||||||
<h3 id="loader-js">请启用JavaScript</h3>
|
<h3 id="loader-js">请启用JavaScript</h3>
|
||||||
<img alt=""
|
</noscript>
|
||||||
src="https://stats.ixarea.com/ixarea-stats/report?rec=1&action_name=音乐解锁-NoJS&idsite=2"
|
<h3 id="loader-source">请勿直接运行源代码!</h3>
|
||||||
style="border:0"/>
|
<div id="loader-tips-outdated" hidden>
|
||||||
</noscript>
|
<h2>您可能在使用不受支持的<span style="color: #f00">过时</span>浏览器,这可能导致此应用无法正常工作。</h2>
|
||||||
<h3 id="loader-source"> 请勿直接运行源代码! </h3>
|
<h3>如果您使用双核浏览器,您可以尝试切换到 <span style="color: #f00">“极速模式”</span> 解决此问题。</h3>
|
||||||
<div id="loader-tips-outdated" hidden>
|
|
||||||
<h2>您可能在使用不受支持的<span style="color:#f00;">过时</span>浏览器,这可能导致此应用无法正常工作。</h2>
|
|
||||||
<h3>如果您使用双核浏览器,您可以尝试切换到 <span style="color:#f00;">“极速模式”</span> 解决此问题。</h3>
|
|
||||||
<h3>或者,您可以尝试更换下方的几个浏览器之一。</h3>
|
<h3>或者,您可以尝试更换下方的几个浏览器之一。</h3>
|
||||||
</div>
|
</div>
|
||||||
<h3 id="loader-tips-timeout" hidden>
|
<h3 id="loader-tips-timeout" hidden>
|
||||||
音乐解锁采用了一些新特性!建议使用
|
音乐解锁采用了一些新特性!建议使用
|
||||||
<a href="https://www.microsoft.com/zh-cn/edge" target="_blank">Microsoft Edge Chromium</a>
|
<a href="https://www.microsoft.com/zh-cn/edge" target="_blank">Microsoft Edge Chromium</a>
|
||||||
<a href="https://www.google.cn/chrome/" target="_blank">Google Chrome</a>
|
<a href="https://www.google.cn/chrome/" target="_blank">Google Chrome</a>
|
||||||
<a href="https://www.firefox.com.cn/" target="_blank">Mozilla Firefox</a>
|
<a href="https://www.firefox.com.cn/" target="_blank">Mozilla Firefox</a>
|
||||||
| <a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
|
| <a href="https://git.unlock-music.dev/um/web/wiki/使用提示" target="_blank">使用提示</a>
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<script src="./loader.js"></script>
|
<script src="./loader.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
@ -1,10 +0,0 @@
|
|||||||
var _paq = window._paq || [];
|
|
||||||
_paq.push(["setRequestMethod", "POST"], ["trackPageView"], ["enableLinkTracking"],
|
|
||||||
["setSiteId", "2"], ["setTrackerUrl", "https://stats.ixarea.com/ixarea-stats/report"]);
|
|
||||||
|
|
||||||
var tag = document.createElement('script');
|
|
||||||
tag.type = 'text/javascript';
|
|
||||||
tag.async = true;
|
|
||||||
tag.src = 'https://stats.ixarea.com/ixarea-stats.js';
|
|
||||||
var s = document.getElementsByTagName('script')[0];
|
|
||||||
s.parentNode.insertBefore(tag, s);
|
|
Binary file not shown.
29
scripts/build-and-package.sh
Executable file
29
scripts/build-and-package.sh
Executable file
@ -0,0 +1,29 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -ex
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
VERSION="$(jq -r ".version" <package.json)"
|
||||||
|
DIST_NAME="um-web.$1.v${VERSION}"
|
||||||
|
|
||||||
|
case "$1" in
|
||||||
|
"modern") npm run build -- --modern ;;
|
||||||
|
"legacy") npm run build ;;
|
||||||
|
"extension") npm run make-extension ;;
|
||||||
|
|
||||||
|
*)
|
||||||
|
echo "Unknown command: $1"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
mv dist "${DIST_NAME}"
|
||||||
|
zip -rJ9 "${DIST_NAME}.zip" "${DIST_NAME}"
|
||||||
|
|
||||||
|
if [ "$1" = "legacy" ]; then
|
||||||
|
# For upcoming extension build
|
||||||
|
mv "${DIST_NAME}" dist
|
||||||
|
else
|
||||||
|
rm -rf "${DIST_NAME}"
|
||||||
|
fi
|
19
scripts/upload-packages.sh
Executable file
19
scripts/upload-packages.sh
Executable file
@ -0,0 +1,19 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
set -ex
|
||||||
|
|
||||||
|
cd "$(git rev-parse --show-toplevel)"
|
||||||
|
|
||||||
|
if [ -z "$GITEA_API_KEY" ]; then
|
||||||
|
echo "GITEA_API_KEY is empty, skip upload."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
URL_BASE="$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build"
|
||||||
|
|
||||||
|
for ZIP_NAME in *.zip; do
|
||||||
|
UPLOAD_URL="${URL_BASE}/${DRONE_BUILD_NUMBER}/${ZIP_NAME}"
|
||||||
|
sha256sum "${ZIP_NAME}"
|
||||||
|
curl -sLifu "um-release-bot:$GITEA_API_KEY" -T "${ZIP_NAME}" "${UPLOAD_URL}"
|
||||||
|
echo "Uploaded to: ${UPLOAD_URL}"
|
||||||
|
done
|
157
src/App.vue
157
src/App.vue
@ -1,85 +1,94 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-container id="app">
|
<el-container id="app">
|
||||||
<el-main>
|
<el-main>
|
||||||
<Home/>
|
<Home />
|
||||||
</el-main>
|
</el-main>
|
||||||
<el-footer id="app-footer">
|
<el-footer id="app-footer">
|
||||||
<el-row>
|
<div>
|
||||||
<a href="https://github.com/ix64/unlock-music" target="_blank">音乐解锁</a>({{ version }})
|
<a href="https://git.unlock-music.dev/um/web" target="_blank">音乐解锁</a>({{ version }})
|
||||||
:移除已购音乐的加密保护。
|
:移除已购音乐的加密保护。
|
||||||
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
|
<a href="https://git.unlock-music.dev/um/web/wiki/使用提示" target="_blank">使用提示</a>
|
||||||
</el-row>
|
</div>
|
||||||
<el-row>
|
<div>
|
||||||
目前支持网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
|
目前支持 网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
|
||||||
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>。
|
<a href="https://git.unlock-music.dev/um/web/src/branch/master/README.md" target="_blank">更多</a>。
|
||||||
</el-row>
|
</div>
|
||||||
<el-row>
|
<div>
|
||||||
<!--如果进行二次开发,此行版权信息不得移除且应明显地标注于页面上-->
|
<!--如果进行二次开发,此行版权信息不得移除且应明显地标注于页面上-->
|
||||||
<span>Copyright © 2019 - {{ (new Date()).getFullYear() }} MengYX</span>
|
<span>Copyright © 2019 - {{ new Date().getFullYear() }} MengYX</span>
|
||||||
音乐解锁使用
|
音乐解锁使用
|
||||||
<a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a>
|
<a href="https://git.unlock-music.dev/um/web/src/branch/master/LICENSE" target="_blank">MIT许可协议</a>
|
||||||
开放源代码
|
开放源代码
|
||||||
</el-row>
|
</div>
|
||||||
</el-footer>
|
</el-footer>
|
||||||
</el-container>
|
</el-container>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import FileSelector from '@/component/FileSelector';
|
||||||
import FileSelector from "@/component/FileSelector"
|
import PreviewTable from '@/component/PreviewTable';
|
||||||
import PreviewTable from "@/component/PreviewTable"
|
import config from '@/../package.json';
|
||||||
import config from "@/../package.json"
|
import Home from '@/view/Home';
|
||||||
import Home from "@/view/Home";
|
import { checkUpdate } from '@/utils/api';
|
||||||
import {checkUpdate} from "@/utils/api";
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'app',
|
name: 'app',
|
||||||
components: {
|
components: {
|
||||||
FileSelector,
|
FileSelector,
|
||||||
PreviewTable,
|
PreviewTable,
|
||||||
Home
|
Home,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
version: config.version,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
created() {
|
||||||
|
this.$nextTick(() => this.finishLoad());
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async finishLoad() {
|
||||||
|
const mask = document.getElementById('loader-mask');
|
||||||
|
if (!!mask) mask.remove();
|
||||||
|
let updateInfo;
|
||||||
|
try {
|
||||||
|
updateInfo = await checkUpdate(this.version);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('check version info failed', e);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
updateInfo &&
|
||||||
|
process.env.NODE_ENV === 'production' &&
|
||||||
|
(updateInfo.HttpsFound || (updateInfo.Found && window.location.protocol !== 'https:'))
|
||||||
|
) {
|
||||||
|
this.$notify.warning({
|
||||||
|
title: '发现更新',
|
||||||
|
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`,
|
||||||
|
dangerouslyUseHTMLString: true,
|
||||||
|
duration: 15000,
|
||||||
|
position: 'top-left',
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.$notify.info({
|
||||||
|
title: '离线使用',
|
||||||
|
message: `<div>
|
||||||
|
<p>我们使用 PWA 技术,无网络也能使用</p>
|
||||||
|
<div class="update-info">
|
||||||
|
<div class="update-title">最近更新</div>
|
||||||
|
<div class="update-content"> ${config.updateInfo} </div>
|
||||||
|
</div>
|
||||||
|
<a target="_blank" href="https://git.unlock-music.dev/um/web/wiki/使用提示">使用提示</a>
|
||||||
|
</div>`,
|
||||||
|
dangerouslyUseHTMLString: true,
|
||||||
|
duration: 10000,
|
||||||
|
position: 'top-left',
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
data() {
|
},
|
||||||
return {
|
};
|
||||||
version: config.version,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
created() {
|
|
||||||
this.$nextTick(() => this.finishLoad());
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
async finishLoad() {
|
|
||||||
const mask = document.getElementById("loader-mask");
|
|
||||||
if (!!mask) mask.remove();
|
|
||||||
let updateInfo;
|
|
||||||
try {
|
|
||||||
updateInfo = await checkUpdate(this.version)
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("check version info failed", e)
|
|
||||||
}
|
|
||||||
if ((updateInfo && process.env.NODE_ENV === 'production') && (updateInfo.HttpsFound ||
|
|
||||||
(updateInfo.Found && window.location.protocol !== "https:"))) {
|
|
||||||
this.$notify.warning({
|
|
||||||
title: '发现更新',
|
|
||||||
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`,
|
|
||||||
dangerouslyUseHTMLString: true,
|
|
||||||
duration: 15000,
|
|
||||||
position: 'top-left'
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
this.$notify.info({
|
|
||||||
title: '离线使用',
|
|
||||||
message: `我们使用PWA技术,无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`,
|
|
||||||
dangerouslyUseHTMLString: true,
|
|
||||||
duration: 10000,
|
|
||||||
position: 'top-left'
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
@import "scss/unlock-music";
|
@import 'scss/unlock-music';
|
||||||
</style>
|
</style>
|
||||||
|
2
src/__test__/setup_jest.js
Normal file
2
src/__test__/setup_jest.js
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
// Polyfill for node.
|
||||||
|
global.Blob = global.Blob || require("node:buffer").Blob;
|
105
src/component/ConfigDialog.vue
Normal file
105
src/component/ConfigDialog.vue
Normal file
@ -0,0 +1,105 @@
|
|||||||
|
<style scoped>
|
||||||
|
label {
|
||||||
|
cursor: pointer;
|
||||||
|
line-height: 1.2;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
form >>> input {
|
||||||
|
font-family: 'Courier New', Courier, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
* >>> .um-config-dialog {
|
||||||
|
max-width: 90%;
|
||||||
|
width: 40em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog @close="cancel()" title="解密设定" :visible="show" custom-class="um-config-dialog" center>
|
||||||
|
<el-form ref="form" :rules="rules" status-icon :model="form" label-width="0">
|
||||||
|
<section>
|
||||||
|
<label>
|
||||||
|
<span>
|
||||||
|
JOOX Music ·
|
||||||
|
<Ruby caption="Unique Device Identifier">设备唯一识别码</Ruby>
|
||||||
|
</span>
|
||||||
|
<el-form-item prop="jooxUUID">
|
||||||
|
<el-input type="text" v-model="form.jooxUUID" clearable maxlength="32" show-word-limit> </el-input>
|
||||||
|
</el-form-item>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<p class="tip">
|
||||||
|
下载该加密文件的 JOOX 应用所记录的设备唯一识别码。
|
||||||
|
<br />
|
||||||
|
参见:
|
||||||
|
<a
|
||||||
|
href="https://git.unlock-music.dev/um/joox-crypto/wiki/%E8%8E%B7%E5%8F%96%E8%AE%BE%E5%A4%87-UUID#%E5%89%8D%E8%A8%80"
|
||||||
|
>
|
||||||
|
获取设备 UUID · unlock-music/joox-crypto Wiki</a
|
||||||
|
>。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</el-form>
|
||||||
|
<span slot="footer" class="dialog-footer">
|
||||||
|
<el-button type="primary" :loading="saving" @click="emitConfirm()">确 定</el-button>
|
||||||
|
</span>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
import Ruby from './Ruby';
|
||||||
|
|
||||||
|
// FIXME: 看起来不会触发这个验证提示?
|
||||||
|
function validateJooxUUID(rule, value, callback) {
|
||||||
|
if (!value || !/^[\da-fA-F]{32}$/.test(value)) {
|
||||||
|
callback(new Error('无效的 Joox UUID,请参考 Wiki 获取。'));
|
||||||
|
} else {
|
||||||
|
callback();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rules = {
|
||||||
|
jooxUUID: { validator: validateJooxUUID, trigger: 'change' },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Ruby,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
show: { type: Boolean, required: true },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
rules,
|
||||||
|
saving: false,
|
||||||
|
form: {
|
||||||
|
jooxUUID: '',
|
||||||
|
},
|
||||||
|
centerDialogVisible: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
await this.resetForm();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
async resetForm() {
|
||||||
|
this.form.jooxUUID = await storage.loadJooxUUID();
|
||||||
|
},
|
||||||
|
|
||||||
|
async cancel() {
|
||||||
|
await this.resetForm();
|
||||||
|
this.$emit('done');
|
||||||
|
},
|
||||||
|
|
||||||
|
async emitConfirm() {
|
||||||
|
this.saving = true;
|
||||||
|
await storage.saveJooxUUID(this.form.jooxUUID);
|
||||||
|
this.saving = false;
|
||||||
|
this.$emit('done');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
163
src/component/EditDialog.vue
Normal file
163
src/component/EditDialog.vue
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
<style scoped>
|
||||||
|
* >>> .um-edit-dialog {
|
||||||
|
max-width: 90%;
|
||||||
|
width: 30em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<el-dialog @close="cancel()" title="音乐标签编辑" :visible="show" custom-class="um-edit-dialog" center>
|
||||||
|
<el-form ref="form" status-icon :model="form" label-width="0">
|
||||||
|
<section>
|
||||||
|
<div class="music-cover">
|
||||||
|
<el-image v-show="!editPicture" :src="imgFile.url || picture">
|
||||||
|
<div slot="error" class="image-slot el-image__error">暂无封面</div>
|
||||||
|
</el-image>
|
||||||
|
<el-upload v-show="editPicture" :auto-upload="false" :on-change="addFile" :on-remove="rmvFile" :show-file-list="true" :limit="1" list-type="picture" action="" drag>
|
||||||
|
<i class="el-icon-upload" />
|
||||||
|
<div class="el-upload__text">将新图片拖到此处,或<em>点击选择</em><br />以替换自动匹配的图片</div>
|
||||||
|
<div slot="tip" class="el-upload__tip">
|
||||||
|
新拖到此处的图片将覆盖原始图片
|
||||||
|
</div>
|
||||||
|
</el-upload>
|
||||||
|
<i :class="{'el-icon-edit': !editPicture, 'el-icon-check': editPicture}"
|
||||||
|
@click="changeCover"></i>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="edit-item">
|
||||||
|
<div class="label">标题</div>
|
||||||
|
<div class="value" v-show="!editTitle">{{title}}</div>
|
||||||
|
<el-input class="input" size="small" v-show="editTitle" v-model="title"/>
|
||||||
|
<i :class="{'el-icon-edit': !editTitle, 'el-icon-check': editTitle}"
|
||||||
|
@click="editTitle = !editTitle"/>
|
||||||
|
</div>
|
||||||
|
<div class="edit-item">
|
||||||
|
<div class="label">艺术家</div>
|
||||||
|
<div class="value" v-show="!editArtist">{{artist}}</div>
|
||||||
|
<el-input class="input" size="small" v-show="editArtist" v-model="artist"/>
|
||||||
|
<i :class="{'el-icon-edit': !editArtist, 'el-icon-check': editArtist}"
|
||||||
|
@click="editArtist = !editArtist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="edit-item">
|
||||||
|
<div class="label">专辑</div>
|
||||||
|
<div class="value" v-show="!editAlbum">{{album}}</div>
|
||||||
|
<el-input class="input" size="small" v-show="editAlbum" v-model="album"/>
|
||||||
|
<i :class="{'el-icon-edit': !editAlbum, 'el-icon-check': editAlbum}"
|
||||||
|
@click="editAlbum = !editAlbum"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="edit-item">
|
||||||
|
<div class="label">专辑艺术家</div>
|
||||||
|
<div class="value" v-show="!editAlbumartist">{{albumartist}}</div>
|
||||||
|
<el-input class="input" size="small" v-show="editAlbumartist" v-model="albumartist"/>
|
||||||
|
<i :class="{'el-icon-edit': !editAlbumartist, 'el-icon-check': editAlbumartist}"
|
||||||
|
@click="editAlbumartist = !editAlbumartist"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="edit-item">
|
||||||
|
<div class="label">风格</div>
|
||||||
|
<div class="value" v-show="!editGenre">{{genre}}</div>
|
||||||
|
<el-input class="input" size="small" v-show="editGenre" v-model="genre"/>
|
||||||
|
<i :class="{'el-icon-edit': !editGenre, 'el-icon-check': editGenre}"
|
||||||
|
@click="editGenre = !editGenre"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="tip">
|
||||||
|
为了节省您设备的资源,请在确定前充分检查,避免反复修改。<br />
|
||||||
|
直接关闭此对话框不会保留所作的更改。
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
</el-form>
|
||||||
|
<span slot="footer" class="dialog-footer">
|
||||||
|
<el-button type="primary" @click="emitConfirm()">确 定</el-button>
|
||||||
|
</span>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
import Ruby from './Ruby';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
components: {
|
||||||
|
Ruby,
|
||||||
|
},
|
||||||
|
props: {
|
||||||
|
show: { type: Boolean, required: true },
|
||||||
|
picture: { type: String | undefined, required: true },
|
||||||
|
title: { type: String | undefined, required: true },
|
||||||
|
artist: { type: String | undefined, required: true },
|
||||||
|
album: { type: String | undefined, required: true },
|
||||||
|
albumartist: { type: String | undefined, required: true },
|
||||||
|
genre: { type: String | undefined, required: true },
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
},
|
||||||
|
imgFile: { tmpblob: undefined, blob: undefined, url: undefined },
|
||||||
|
editPicture: false,
|
||||||
|
editTitle: false,
|
||||||
|
editArtist: false,
|
||||||
|
editAlbum: false,
|
||||||
|
editAlbumartist: false,
|
||||||
|
editGenre: false,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
async mounted() {
|
||||||
|
this.refreshForm();
|
||||||
|
},
|
||||||
|
methods: {
|
||||||
|
addFile(file) {
|
||||||
|
this.imgFile.tmpblob = file.raw;
|
||||||
|
},
|
||||||
|
rmvFile() {
|
||||||
|
this.imgFile.tmpblob = undefined;
|
||||||
|
},
|
||||||
|
changeCover() {
|
||||||
|
this.editPicture = !this.editPicture;
|
||||||
|
if (!this.editPicture && this.imgFile.tmpblob) {
|
||||||
|
this.imgFile.blob = this.imgFile.tmpblob;
|
||||||
|
if (this.imgFile.url) {
|
||||||
|
URL.revokeObjectURL(this.imgFile.url);
|
||||||
|
}
|
||||||
|
this.imgFile.url = URL.createObjectURL(this.imgFile.blob);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
async refreshForm() {
|
||||||
|
if (this.imgFile.url) {
|
||||||
|
URL.revokeObjectURL(this.imgFile.url);
|
||||||
|
}
|
||||||
|
this.imgFile = { tmpblob: undefined, blob: undefined, url: undefined };
|
||||||
|
this.editPicture = false;
|
||||||
|
this.editTitle = false;
|
||||||
|
this.editArtist = false;
|
||||||
|
this.editAlbum = false;
|
||||||
|
this.editAlbumartist = false;
|
||||||
|
this.editGenre = false;
|
||||||
|
},
|
||||||
|
async cancel() {
|
||||||
|
this.refreshForm();
|
||||||
|
this.$emit('cancel');
|
||||||
|
},
|
||||||
|
async emitConfirm() {
|
||||||
|
if (this.editPicture) {
|
||||||
|
this.changeCover();
|
||||||
|
}
|
||||||
|
if (this.imgFile.url) {
|
||||||
|
URL.revokeObjectURL(this.imgFile.url);
|
||||||
|
}
|
||||||
|
this.$emit('ok', {
|
||||||
|
picture: this.imgFile.blob,
|
||||||
|
title: this.title,
|
||||||
|
artist: this.artist,
|
||||||
|
album: this.album,
|
||||||
|
albumartist: this.albumartist,
|
||||||
|
genre: this.genre,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
@ -1,99 +1,91 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-upload
|
<el-upload :auto-upload="false" :on-change="addFile" :show-file-list="false" action="" drag multiple>
|
||||||
:auto-upload="false"
|
<i class="el-icon-upload" />
|
||||||
:on-change="addFile"
|
<div class="el-upload__text">将文件拖到此处,或 <em>点击选择</em></div>
|
||||||
:show-file-list="false"
|
<div slot="tip" class="el-upload__tip">
|
||||||
action=""
|
<div>
|
||||||
drag
|
仅在浏览器内对文件进行解锁,无需消耗流量
|
||||||
multiple>
|
<el-tooltip effect="dark" placement="top-start">
|
||||||
<i class="el-icon-upload"/>
|
<div slot="content">算法在源代码中已经提供,所有运算都发生在本地</div>
|
||||||
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
|
<i class="el-icon-info" style="font-size: 12px" />
|
||||||
<div slot="tip" class="el-upload__tip">
|
</el-tooltip>
|
||||||
<div>
|
</div>
|
||||||
仅在浏览器内对文件进行解锁,无需消耗流量
|
<div>
|
||||||
<el-tooltip effect="dark" placement="top-start">
|
工作模式: {{ parallel ? '多线程 Worker' : '单线程 Queue' }}
|
||||||
<div slot="content">
|
<el-tooltip effect="dark" placement="top-start">
|
||||||
算法在源代码中已经提供,所有运算都发生在本地
|
<div slot="content">
|
||||||
</div>
|
将此工具部署在HTTPS环境下,可以启用Web Worker特性,<br />
|
||||||
<i class="el-icon-info" style="font-size: 12px"/>
|
从而更快的利用并行处理完成解锁
|
||||||
</el-tooltip>
|
</div>
|
||||||
</div>
|
<i class="el-icon-info" style="font-size: 12px" />
|
||||||
<div>
|
</el-tooltip>
|
||||||
工作模式: {{ parallel ? "多线程 Worker" : "单线程 Queue" }}
|
</div>
|
||||||
<el-tooltip effect="dark" placement="top-start">
|
</div>
|
||||||
<div slot="content">
|
<transition name="el-fade-in"
|
||||||
将此工具部署在HTTPS环境下,可以启用Web Worker特性,<br/>
|
><!--todo: add delay to animation-->
|
||||||
从而更快的利用并行处理完成解锁
|
<el-progress
|
||||||
</div>
|
v-show="progress_show"
|
||||||
<i class="el-icon-info" style="font-size: 12px"/>
|
:format="progress_string"
|
||||||
</el-tooltip>
|
:percentage="progress_value"
|
||||||
</div>
|
:stroke-width="16"
|
||||||
</div>
|
:text-inside="true"
|
||||||
<transition name="el-fade-in"><!--todo: add delay to animation-->
|
style="margin: 16px 6px 0 6px"
|
||||||
<el-progress
|
></el-progress>
|
||||||
v-show="progress_show" :format="progress_string" :percentage="progress_value"
|
</transition>
|
||||||
:stroke-width="16" :text-inside="true"
|
</el-upload>
|
||||||
style="margin: 16px 6px 0 6px"
|
|
||||||
></el-progress>
|
|
||||||
</transition>
|
|
||||||
</el-upload>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {spawn, Worker, Pool} from "threads"
|
import { spawn, Worker, Pool } from 'threads';
|
||||||
import {CommonDecrypt} from "@/decrypt/common.ts";
|
import { Decrypt } from '@/decrypt';
|
||||||
import {DecryptQueue} from "@/utils/utils";
|
import { DecryptQueue } from '@/utils/utils';
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "FileSelector",
|
name: 'FileSelector',
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
task_all: 0,
|
task_all: 0,
|
||||||
task_finished: 0,
|
task_finished: 0,
|
||||||
queue: new DecryptQueue(), // for http or file protocol
|
queue: new DecryptQueue(), // for http or file protocol
|
||||||
parallel: false
|
parallel: false,
|
||||||
}
|
};
|
||||||
|
},
|
||||||
|
computed: {
|
||||||
|
progress_value() {
|
||||||
|
return this.task_all ? (this.task_finished / this.task_all) * 100 : 0;
|
||||||
},
|
},
|
||||||
computed: {
|
progress_show() {
|
||||||
progress_value() {
|
return this.task_all !== this.task_finished;
|
||||||
return this.task_all ? this.task_finished / this.task_all * 100 : 0
|
|
||||||
},
|
|
||||||
progress_show() {
|
|
||||||
return this.task_all !== this.task_finished
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
mounted() {
|
},
|
||||||
if (window.Worker && process.env.NODE_ENV === 'production') {
|
mounted() {
|
||||||
console.log("Using Worker Pool")
|
if (window.Worker && window.location.protocol !== 'file:' && process.env.NODE_ENV === 'production') {
|
||||||
this.queue = Pool(
|
console.log('Using Worker Pool');
|
||||||
() => spawn(new Worker('@/utils/worker.ts')),
|
this.queue = Pool(() => spawn(new Worker('@/utils/worker.ts')), navigator.hardwareConcurrency || 1);
|
||||||
navigator.hardwareConcurrency || 1
|
this.parallel = true;
|
||||||
)
|
} else {
|
||||||
this.parallel = true
|
console.log('Using Queue in Main Thread');
|
||||||
} else {
|
|
||||||
console.log("Using Queue in Main Thread")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
progress_string() {
|
|
||||||
return `${this.task_finished} / ${this.task_all}`
|
|
||||||
},
|
|
||||||
async addFile(file) {
|
|
||||||
this.task_all++
|
|
||||||
this.queue.queue(async (dec = CommonDecrypt) => {
|
|
||||||
console.log("start handling", file.name)
|
|
||||||
try {
|
|
||||||
this.$emit("success", await dec(file));
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
this.$emit("error", e, file.name)
|
|
||||||
} finally {
|
|
||||||
this.task_finished++
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
methods: {
|
||||||
|
progress_string() {
|
||||||
|
return `${this.task_finished} / ${this.task_all}`;
|
||||||
|
},
|
||||||
|
async addFile(file) {
|
||||||
|
this.task_all++;
|
||||||
|
this.queue.queue(async (dec = Decrypt) => {
|
||||||
|
console.log('start handling', file.name);
|
||||||
|
try {
|
||||||
|
this.$emit('success', await dec(file, await storage.getAll()));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
this.$emit('error', e, file.name);
|
||||||
|
} finally {
|
||||||
|
this.task_finished++;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
@ -1,71 +1,66 @@
|
|||||||
<template>
|
<template>
|
||||||
<el-table :data="tableData" style="width: 100%">
|
<el-table :data="tableData" style="width: 100%">
|
||||||
|
<el-table-column label="封面">
|
||||||
<el-table-column label="封面">
|
<template slot-scope="scope">
|
||||||
<template slot-scope="scope">
|
<el-image :src="scope.row.picture" style="width: 100px; height: 100px">
|
||||||
<el-image :src="scope.row.picture" style="width: 100px; height: 100px">
|
<div slot="error" class="image-slot el-image__error">暂无封面</div>
|
||||||
<div slot="error" class="image-slot el-image__error">
|
</el-image>
|
||||||
暂无封面
|
</template>
|
||||||
</div>
|
</el-table-column>
|
||||||
</el-image>
|
<el-table-column label="歌曲">
|
||||||
</template>
|
<template #default="scope">
|
||||||
</el-table-column>
|
<p>{{ scope.row.title }}</p>
|
||||||
<el-table-column label="歌曲">
|
</template>
|
||||||
<template #default="scope">
|
</el-table-column>
|
||||||
<span>{{ scope.row.title }}</span>
|
<el-table-column label="歌手">
|
||||||
</template>
|
<template #default="scope">
|
||||||
</el-table-column>
|
<p>{{ scope.row.artist }}</p>
|
||||||
<el-table-column label="歌手">
|
</template>
|
||||||
<template #default="scope">
|
</el-table-column>
|
||||||
<p>{{ scope.row.artist }}</p>
|
<el-table-column label="专辑">
|
||||||
</template>
|
<template #default="scope">
|
||||||
</el-table-column>
|
<p>{{ scope.row.album }}</p>
|
||||||
<el-table-column label="专辑">
|
</template>
|
||||||
<template #default="scope">
|
</el-table-column>
|
||||||
<p>{{ scope.row.album }}</p>
|
<el-table-column label="操作">
|
||||||
</template>
|
<template #default="scope">
|
||||||
</el-table-column>
|
<el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
|
||||||
<el-table-column label="操作">
|
</el-button>
|
||||||
<template #default="scope">
|
<el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button>
|
||||||
<el-button circle
|
<el-button circle icon="el-icon-edit" @click="handleEdit(scope.row)"></el-button>
|
||||||
icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
|
<el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
|
||||||
</el-button>
|
</el-button>
|
||||||
<el-button circle
|
</template>
|
||||||
icon="el-icon-download" @click="handleDownload(scope.row)">
|
</el-table-column>
|
||||||
</el-button>
|
</el-table>
|
||||||
<el-button circle
|
|
||||||
icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
|
|
||||||
</el-button>
|
|
||||||
</template>
|
|
||||||
</el-table-column>
|
|
||||||
</el-table>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import {RemoveBlobMusic} from '@/utils/utils'
|
import { RemoveBlobMusic } from '@/utils/utils';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: "PreviewTable",
|
name: 'PreviewTable',
|
||||||
props: {
|
props: {
|
||||||
tableData: {type: Array, required: true},
|
tableData: { type: Array, required: true },
|
||||||
policy: {type: Number, required: true}
|
policy: { type: Number, required: true },
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
handlePlay(index, row) {
|
handlePlay(index, row) {
|
||||||
this.$emit("play", row.file);
|
this.$emit('play', row.file);
|
||||||
},
|
},
|
||||||
handleDelete(index, row) {
|
handleDelete(index, row) {
|
||||||
RemoveBlobMusic(row);
|
RemoveBlobMusic(row);
|
||||||
this.tableData.splice(index, 1);
|
this.tableData.splice(index, 1);
|
||||||
},
|
},
|
||||||
handleDownload(row) {
|
handleDownload(row) {
|
||||||
this.$emit("download", row)
|
this.$emit('download', row);
|
||||||
},
|
},
|
||||||
}
|
handleEdit(row) {
|
||||||
}
|
this.$emit('edit', row);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped></style>
|
||||||
|
|
||||||
</style>
|
|
||||||
|
18
src/component/Ruby.vue
Normal file
18
src/component/Ruby.vue
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
<template>
|
||||||
|
<ruby :title="caption">
|
||||||
|
<slot></slot>
|
||||||
|
|
||||||
|
<rp>(</rp>
|
||||||
|
<rt v-text="caption"></rt>
|
||||||
|
<rp>)</rp>
|
||||||
|
</ruby>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
export default {
|
||||||
|
name: 'Ruby',
|
||||||
|
props: {
|
||||||
|
caption: { type: String, required: true },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
</script>
|
BIN
src/decrypt/__test__/fixture/joox_1.bin
Normal file
BIN
src/decrypt/__test__/fixture/joox_1.bin
Normal file
Binary file not shown.
BIN
src/decrypt/__test__/fixture/qmc_cache_expected.bin
Normal file
BIN
src/decrypt/__test__/fixture/qmc_cache_expected.bin
Normal file
Binary file not shown.
52
src/decrypt/__test__/joox.test.ts
Normal file
52
src/decrypt/__test__/joox.test.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import fs from 'fs';
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
|
||||||
|
import { Decrypt as decryptJoox } from '../joox';
|
||||||
|
import { extractQQMusicMeta as extractQQMusicMetaOrig } from '@/utils/qm_meta';
|
||||||
|
|
||||||
|
jest.mock('@/utils/storage');
|
||||||
|
jest.mock('@/utils/qm_meta');
|
||||||
|
|
||||||
|
const loadJooxUUID = storage.loadJooxUUID as jest.MockedFunction<typeof storage.loadJooxUUID>;
|
||||||
|
const extractQQMusicMeta = extractQQMusicMetaOrig as jest.MockedFunction<typeof extractQQMusicMetaOrig>;
|
||||||
|
|
||||||
|
const TEST_UUID_ZEROS = ''.padStart(32, '0');
|
||||||
|
const encryptedFile1 = fs.readFileSync(__dirname + '/fixture/joox_1.bin');
|
||||||
|
|
||||||
|
describe('decrypt/joox', () => {
|
||||||
|
it('should be able to decrypt sample file (v4)', async () => {
|
||||||
|
loadJooxUUID.mockResolvedValue(TEST_UUID_ZEROS);
|
||||||
|
extractQQMusicMeta.mockImplementationOnce(async (blob: Blob) => {
|
||||||
|
return {
|
||||||
|
title: 'unused',
|
||||||
|
album: 'unused',
|
||||||
|
blob: blob,
|
||||||
|
artist: 'unused',
|
||||||
|
imgUrl: 'https://example.unlock-music.dev/',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await decryptJoox(new Blob([encryptedFile1]), 'test.bin', 'bin');
|
||||||
|
const resultBuf = await result.blob.arrayBuffer();
|
||||||
|
expect(resultBuf).toEqual(Buffer.from('Hello World', 'utf-8').buffer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject E!99 files', async () => {
|
||||||
|
loadJooxUUID.mockResolvedValue(TEST_UUID_ZEROS);
|
||||||
|
|
||||||
|
const input = new Blob([Buffer.from('E!99....')]);
|
||||||
|
await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('不支持的 joox 加密格式');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject empty uuid', async () => {
|
||||||
|
loadJooxUUID.mockResolvedValue('');
|
||||||
|
const input = new Blob([encryptedFile1]);
|
||||||
|
await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid uuid', async () => {
|
||||||
|
loadJooxUUID.mockResolvedValue('hello!');
|
||||||
|
const input = new Blob([encryptedFile1]);
|
||||||
|
await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID');
|
||||||
|
});
|
||||||
|
});
|
@ -1,71 +0,0 @@
|
|||||||
import {Decrypt as NcmDecrypt} from "@/decrypt/ncm";
|
|
||||||
import {Decrypt as XmDecrypt} from "@/decrypt/xm";
|
|
||||||
import {Decrypt as QmcDecrypt} from "@/decrypt/qmc";
|
|
||||||
import {Decrypt as KgmDecrypt} from "@/decrypt/kgm";
|
|
||||||
import {Decrypt as KwmDecrypt} from "@/decrypt/kwm";
|
|
||||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
|
|
||||||
import {Decrypt as TmDecrypt} from "@/decrypt/tm";
|
|
||||||
import {DecryptResult, FileInfo} from "@/decrypt/entity";
|
|
||||||
|
|
||||||
|
|
||||||
export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
|
|
||||||
let raw_ext = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length).toLowerCase();
|
|
||||||
let raw_filename = file.name.substring(0, file.name.lastIndexOf("."));
|
|
||||||
let rt_data: DecryptResult;
|
|
||||||
switch (raw_ext) {
|
|
||||||
case "ncm":// Netease Mp3/Flac
|
|
||||||
rt_data = await NcmDecrypt(file.raw, raw_filename, raw_ext);
|
|
||||||
break;
|
|
||||||
case "kwm":// Kuwo Mp3/Flac
|
|
||||||
rt_data = await KwmDecrypt(file.raw, raw_filename, raw_ext);
|
|
||||||
break
|
|
||||||
case "xm": // Xiami Wav/M4a/Mp3/Flac
|
|
||||||
case "wav":// Xiami/Raw Wav
|
|
||||||
case "mp3":// Xiami/Raw Mp3
|
|
||||||
case "flac":// Xiami/Raw Flac
|
|
||||||
case "m4a":// Xiami/Raw M4a
|
|
||||||
rt_data = await XmDecrypt(file.raw, raw_filename, raw_ext);
|
|
||||||
break;
|
|
||||||
case "ogg":// Raw Ogg
|
|
||||||
rt_data = await RawDecrypt(file.raw, raw_filename, raw_ext);
|
|
||||||
break;
|
|
||||||
case "tm0":// QQ Music IOS Mp3
|
|
||||||
case "tm3":// QQ Music IOS Mp3
|
|
||||||
rt_data = await RawDecrypt(file.raw, raw_filename, "mp3");
|
|
||||||
break;
|
|
||||||
case "qmc3"://QQ Music Android Mp3
|
|
||||||
case "qmc2"://QQ Music Android Ogg
|
|
||||||
case "qmc0"://QQ Music Android Mp3
|
|
||||||
case "qmcflac"://QQ Music Android Flac
|
|
||||||
case "qmcogg"://QQ Music Android Ogg
|
|
||||||
case "tkm"://QQ Music Accompaniment M4a
|
|
||||||
case "bkcmp3"://Moo Music Mp3
|
|
||||||
case "bkcflac"://Moo Music Flac
|
|
||||||
case "mflac"://QQ Music Desktop Flac
|
|
||||||
case "mgg": //QQ Music Desktop Ogg
|
|
||||||
case "666c6163"://QQ Music Weiyun Flac
|
|
||||||
case "6d7033"://QQ Music Weiyun Mp3
|
|
||||||
case "6f6767"://QQ Music Weiyun Ogg
|
|
||||||
case "6d3461"://QQ Music Weiyun M4a
|
|
||||||
case "776176"://QQ Music Weiyun Wav
|
|
||||||
rt_data = await QmcDecrypt(file.raw, raw_filename, raw_ext);
|
|
||||||
break;
|
|
||||||
case "tm2":// QQ Music IOS M4a
|
|
||||||
case "tm6":// QQ Music IOS M4a
|
|
||||||
rt_data = await TmDecrypt(file.raw, raw_filename);
|
|
||||||
break;
|
|
||||||
case "vpr":
|
|
||||||
case "kgm":
|
|
||||||
case "kgma":
|
|
||||||
rt_data = await KgmDecrypt(file.raw, raw_filename, raw_ext);
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
throw "不支持此文件格式"
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!rt_data.rawExt) rt_data.rawExt = raw_ext;
|
|
||||||
if (!rt_data.rawFilename) rt_data.rawFilename = raw_filename;
|
|
||||||
console.log(rt_data);
|
|
||||||
return rt_data;
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +1,25 @@
|
|||||||
export interface DecryptResult {
|
export interface DecryptResult {
|
||||||
title: string
|
title: string;
|
||||||
album?: string
|
album?: string;
|
||||||
artist?: string
|
artist?: string;
|
||||||
|
|
||||||
mime: string
|
mime: string;
|
||||||
ext: string
|
ext: string;
|
||||||
|
|
||||||
file: string
|
file: string;
|
||||||
blob: Blob
|
blob: Blob;
|
||||||
picture?: string
|
picture?: string;
|
||||||
|
|
||||||
message?: string
|
|
||||||
rawExt?: string
|
|
||||||
rawFilename?: string
|
|
||||||
|
|
||||||
|
message?: string;
|
||||||
|
rawExt?: string;
|
||||||
|
rawFilename?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface FileInfo {
|
export interface FileInfo {
|
||||||
status: string
|
status: string;
|
||||||
name: string,
|
name: string;
|
||||||
size: number,
|
size: number;
|
||||||
percentage: number,
|
percentage: number;
|
||||||
uid: number,
|
uid: number;
|
||||||
raw: File
|
raw: File;
|
||||||
}
|
}
|
||||||
|
115
src/decrypt/index.ts
Normal file
115
src/decrypt/index.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import { Decrypt as Mg3dDecrypt } from '@/decrypt/mg3d';
|
||||||
|
import { Decrypt as NcmDecrypt } from '@/decrypt/ncm';
|
||||||
|
import { Decrypt as NcmCacheDecrypt } from '@/decrypt/ncmcache';
|
||||||
|
import { Decrypt as XmDecrypt } from '@/decrypt/xm';
|
||||||
|
import { Decrypt as QmcDecrypt } from '@/decrypt/qmc';
|
||||||
|
import { Decrypt as QmcCacheDecrypt } from '@/decrypt/qmccache';
|
||||||
|
import { Decrypt as KgmDecrypt } from '@/decrypt/kgm';
|
||||||
|
import { Decrypt as KwmDecrypt } from '@/decrypt/kwm';
|
||||||
|
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
|
||||||
|
import { Decrypt as TmDecrypt } from '@/decrypt/tm';
|
||||||
|
import { Decrypt as JooxDecrypt } from '@/decrypt/joox';
|
||||||
|
import { Decrypt as XimalayaDecrypt } from './ximalaya';
|
||||||
|
import { DecryptResult, FileInfo } from '@/decrypt/entity';
|
||||||
|
import { SplitFilename } from '@/decrypt/utils';
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
import InMemoryStorage from '@/utils/storage/InMemoryStorage';
|
||||||
|
|
||||||
|
export async function Decrypt(file: FileInfo, config: Record<string, any>): Promise<DecryptResult> {
|
||||||
|
// Worker thread will fallback to in-memory storage.
|
||||||
|
if (storage instanceof InMemoryStorage) {
|
||||||
|
await storage.setAll(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = SplitFilename(file.name);
|
||||||
|
let rt_data: DecryptResult;
|
||||||
|
switch (raw.ext) {
|
||||||
|
case 'mg3d': // Migu Wav
|
||||||
|
rt_data = await Mg3dDecrypt(file.raw, raw.name);
|
||||||
|
break;
|
||||||
|
case 'ncm': // Netease Mp3/Flac
|
||||||
|
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'uc': // Netease Cache
|
||||||
|
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'kwm': // Kuwo Mp3/Flac
|
||||||
|
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'xm': // Xiami Wav/M4a/Mp3/Flac
|
||||||
|
case 'wav': // Xiami/Raw Wav
|
||||||
|
case 'mp3': // Xiami/Raw Mp3
|
||||||
|
case 'flac': // Xiami/Raw Flac
|
||||||
|
case 'm4a': // Xiami/Raw M4a
|
||||||
|
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'ogg': // Raw Ogg
|
||||||
|
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'tm0': // QQ Music IOS Mp3
|
||||||
|
case 'tm3': // QQ Music IOS Mp3
|
||||||
|
rt_data = await RawDecrypt(file.raw, raw.name, 'mp3');
|
||||||
|
break;
|
||||||
|
case 'qmc0': //QQ Music Android Mp3
|
||||||
|
case 'qmc3': //QQ Music Android Mp3
|
||||||
|
case 'qmc2': //QQ Music Android Ogg
|
||||||
|
case 'qmc4': //QQ Music Android Ogg
|
||||||
|
case 'qmc6': //QQ Music Android Ogg
|
||||||
|
case 'qmc8': //QQ Music Android Ogg
|
||||||
|
case 'qmcflac': //QQ Music Android Flac
|
||||||
|
case 'qmcogg': //QQ Music Android Ogg
|
||||||
|
case 'tkm': //QQ Music Accompaniment M4a
|
||||||
|
// Moo Music
|
||||||
|
case 'bkcmp3':
|
||||||
|
case 'bkcm4a':
|
||||||
|
case 'bkcflac':
|
||||||
|
case 'bkcwav':
|
||||||
|
case 'bkcape':
|
||||||
|
case 'bkcogg':
|
||||||
|
case 'bkcwma':
|
||||||
|
// QQ Music v2
|
||||||
|
case 'mggl': //QQ Music Mac
|
||||||
|
case 'mflac': //QQ Music New Flac
|
||||||
|
case 'mflac0': //QQ Music New Flac
|
||||||
|
case 'mflach': //QQ Music New Flac
|
||||||
|
case 'mgg': //QQ Music New Ogg
|
||||||
|
case 'mgg1': //QQ Music New Ogg
|
||||||
|
case 'mgg0':
|
||||||
|
case 'mmp4': // QMC MP4 Container w/ E-AC-3 JOC
|
||||||
|
case '666c6163': //QQ Music Weiyun Flac
|
||||||
|
case '6d7033': //QQ Music Weiyun Mp3
|
||||||
|
case '6f6767': //QQ Music Weiyun Ogg
|
||||||
|
case '6d3461': //QQ Music Weiyun M4a
|
||||||
|
case '776176': //QQ Music Weiyun Wav
|
||||||
|
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'tm2': // QQ Music IOS M4a
|
||||||
|
case 'tm6': // QQ Music IOS M4a
|
||||||
|
rt_data = await TmDecrypt(file.raw, raw.name);
|
||||||
|
break;
|
||||||
|
case 'cache': //QQ Music Cache
|
||||||
|
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'vpr':
|
||||||
|
case 'kgm':
|
||||||
|
case 'kgma':
|
||||||
|
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'ofl_en':
|
||||||
|
rt_data = await JooxDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'x2m':
|
||||||
|
case 'x3m':
|
||||||
|
rt_data = await XimalayaDecrypt(file.raw, raw.name, raw.ext);
|
||||||
|
break;
|
||||||
|
case 'mflach': //QQ Music New Flac
|
||||||
|
throw '网页版无法解锁,请使用<a target="_blank" href="https://git.unlock-music.dev/um/cli">CLI版本</a>'
|
||||||
|
default:
|
||||||
|
throw '不支持此文件格式';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!rt_data.rawExt) rt_data.rawExt = raw.ext;
|
||||||
|
if (!rt_data.rawFilename) rt_data.rawFilename = raw.name;
|
||||||
|
console.log(rt_data);
|
||||||
|
return rt_data;
|
||||||
|
}
|
44
src/decrypt/joox.ts
Normal file
44
src/decrypt/joox.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import jooxFactory from '@unlock-music/joox-crypto';
|
||||||
|
|
||||||
|
import { DecryptResult } from './entity';
|
||||||
|
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from './utils';
|
||||||
|
|
||||||
|
import { MergeUint8Array } from '@/utils/MergeUint8Array';
|
||||||
|
import { storage } from '@/utils/storage';
|
||||||
|
import { extractQQMusicMeta } from '@/utils/qm_meta';
|
||||||
|
|
||||||
|
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||||
|
const uuid = await storage.loadJooxUUID('');
|
||||||
|
if (!uuid || uuid.length !== 32) {
|
||||||
|
throw new Error('请在“解密设定”填写应用 Joox 应用的 UUID。');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileBuffer = new Uint8Array(await GetArrayBuffer(file));
|
||||||
|
const decryptor = jooxFactory(fileBuffer, uuid);
|
||||||
|
if (!decryptor) {
|
||||||
|
throw new Error('不支持的 joox 加密格式');
|
||||||
|
}
|
||||||
|
|
||||||
|
const musicDecoded = MergeUint8Array(decryptor.decryptFile(fileBuffer));
|
||||||
|
const ext = SniffAudioExt(musicDecoded);
|
||||||
|
const mime = AudioMimeType[ext];
|
||||||
|
|
||||||
|
const songId = raw_filename.match(/^(\d+)\s\[mqms\d*]$/i)?.[1];
|
||||||
|
const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta(
|
||||||
|
new Blob([musicDecoded], { type: mime }),
|
||||||
|
raw_filename,
|
||||||
|
ext,
|
||||||
|
songId,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: title,
|
||||||
|
artist: artist,
|
||||||
|
ext: ext,
|
||||||
|
album: album,
|
||||||
|
picture: imgUrl,
|
||||||
|
file: URL.createObjectURL(blob),
|
||||||
|
blob: blob,
|
||||||
|
mime: mime,
|
||||||
|
};
|
||||||
|
}
|
@ -1,116 +1,57 @@
|
|||||||
import {
|
import {
|
||||||
AudioMimeType,
|
AudioMimeType,
|
||||||
BytesHasPrefix,
|
BytesHasPrefix,
|
||||||
GetArrayBuffer,
|
GetArrayBuffer,
|
||||||
GetCoverFromFile,
|
GetCoverFromFile,
|
||||||
GetMetaFromFile,
|
GetMetaFromFile,
|
||||||
SniffAudioExt
|
SniffAudioExt,
|
||||||
} from "@/decrypt/utils.ts";
|
} from '@/decrypt/utils';
|
||||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
import { DecryptKgmWasm } from '@/decrypt/kgm_wasm';
|
||||||
|
|
||||||
|
//prettier-ignore
|
||||||
const VprHeader = [
|
const VprHeader = [
|
||||||
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
|
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
|
||||||
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31]
|
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31
|
||||||
|
]
|
||||||
|
//prettier-ignore
|
||||||
const KgmHeader = [
|
const KgmHeader = [
|
||||||
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
|
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
|
||||||
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14]
|
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14
|
||||||
const VprMaskDiff = [
|
|
||||||
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E,
|
|
||||||
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11,
|
|
||||||
0x00]
|
|
||||||
|
|
||||||
|
|
||||||
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
|
||||||
if (window?.location?.protocol === "file:") {
|
|
||||||
throw Error("请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁")
|
|
||||||
}
|
|
||||||
|
|
||||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
|
||||||
if (raw_ext === "vpr") {
|
|
||||||
if (!BytesHasPrefix(oriData, VprHeader)) throw Error("Not a valid vpr file!")
|
|
||||||
} else {
|
|
||||||
if (!BytesHasPrefix(oriData, KgmHeader)) throw Error("Not a valid kgm(a) file!")
|
|
||||||
}
|
|
||||||
let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer)
|
|
||||||
let headerLen = bHeaderLen.getUint32(0, true)
|
|
||||||
|
|
||||||
let audioData = oriData.slice(headerLen)
|
|
||||||
let dataLen = audioData.length
|
|
||||||
if (audioData.byteLength > 1 << 26) {
|
|
||||||
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁")
|
|
||||||
}
|
|
||||||
|
|
||||||
let key1 = new Uint8Array(17)
|
|
||||||
key1.set(oriData.slice(0x1c, 0x2c), 0)
|
|
||||||
if (MaskV2.length === 0) {
|
|
||||||
if (!await LoadMaskV2()) throw Error("加载Kgm/Vpr Mask数据失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < dataLen; i++) {
|
|
||||||
let med8 = key1[i % 17] ^ audioData[i]
|
|
||||||
med8 ^= (med8 & 0xf) << 4
|
|
||||||
|
|
||||||
let msk8 = GetMask(i)
|
|
||||||
msk8 ^= (msk8 & 0xf) << 4
|
|
||||||
audioData[i] = med8 ^ msk8
|
|
||||||
}
|
|
||||||
if (raw_ext === "vpr") {
|
|
||||||
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17]
|
|
||||||
}
|
|
||||||
|
|
||||||
const ext = SniffAudioExt(audioData);
|
|
||||||
const mime = AudioMimeType[ext];
|
|
||||||
let musicBlob = new Blob([audioData], {type: mime});
|
|
||||||
const musicMeta = await metaParseBlob(musicBlob);
|
|
||||||
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
|
|
||||||
return {
|
|
||||||
album: musicMeta.common.album,
|
|
||||||
picture: GetCoverFromFile(musicMeta),
|
|
||||||
file: URL.createObjectURL(musicBlob),
|
|
||||||
blob: musicBlob,
|
|
||||||
ext,
|
|
||||||
mime,
|
|
||||||
title,
|
|
||||||
artist
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function GetMask(pos: number) {
|
|
||||||
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4]
|
|
||||||
}
|
|
||||||
|
|
||||||
let MaskV2: Uint8Array = new Uint8Array(0);
|
|
||||||
|
|
||||||
async function LoadMaskV2(): Promise<boolean> {
|
|
||||||
try {
|
|
||||||
const resp = await fetch("./static/kgm.mask", {method: "GET"})
|
|
||||||
MaskV2 = new Uint8Array(await resp.arrayBuffer());
|
|
||||||
return true
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const MaskV2PreDef = [
|
|
||||||
0xB8, 0xD5, 0x3D, 0xB2, 0xE9, 0xAF, 0x78, 0x8C, 0x83, 0x33, 0x71, 0x51, 0x76, 0xA0, 0xCD, 0x37,
|
|
||||||
0x2F, 0x3E, 0x35, 0x8D, 0xA9, 0xBE, 0x98, 0xB7, 0xE7, 0x8C, 0x22, 0xCE, 0x5A, 0x61, 0xDF, 0x68,
|
|
||||||
0x69, 0x89, 0xFE, 0xA5, 0xB6, 0xDE, 0xA9, 0x77, 0xFC, 0xC8, 0xBD, 0xBD, 0xE5, 0x6D, 0x3E, 0x5A,
|
|
||||||
0x36, 0xEF, 0x69, 0x4E, 0xBE, 0xE1, 0xE9, 0x66, 0x1C, 0xF3, 0xD9, 0x02, 0xB6, 0xF2, 0x12, 0x9B,
|
|
||||||
0x44, 0xD0, 0x6F, 0xB9, 0x35, 0x89, 0xB6, 0x46, 0x6D, 0x73, 0x82, 0x06, 0x69, 0xC1, 0xED, 0xD7,
|
|
||||||
0x85, 0xC2, 0x30, 0xDF, 0xA2, 0x62, 0xBE, 0x79, 0x2D, 0x62, 0x62, 0x3D, 0x0D, 0x7E, 0xBE, 0x48,
|
|
||||||
0x89, 0x23, 0x02, 0xA0, 0xE4, 0xD5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xFD, 0x16, 0x3A, 0x21, 0x3B,
|
|
||||||
0x16, 0x0F, 0xC3, 0xB2, 0xBB, 0xB3, 0xE2, 0xBA, 0x3A, 0x3D, 0x13, 0xEC, 0xF6, 0x01, 0x45, 0x84,
|
|
||||||
0xA5, 0x70, 0x0F, 0x93, 0x49, 0x0C, 0x64, 0xCD, 0x31, 0xD5, 0xCC, 0x4C, 0x07, 0x01, 0x9E, 0x00,
|
|
||||||
0x1A, 0x23, 0x90, 0xBF, 0x88, 0x1E, 0x3B, 0xAB, 0xA6, 0x3E, 0xC4, 0x73, 0x47, 0x10, 0x7E, 0x3B,
|
|
||||||
0x5E, 0xBC, 0xE3, 0x00, 0x84, 0xFF, 0x09, 0xD4, 0xE0, 0x89, 0x0F, 0x5B, 0x58, 0x70, 0x4F, 0xFB,
|
|
||||||
0x65, 0xD8, 0x5C, 0x53, 0x1B, 0xD3, 0xC8, 0xC6, 0xBF, 0xEF, 0x98, 0xB0, 0x50, 0x4F, 0x0F, 0xEA,
|
|
||||||
0xE5, 0x83, 0x58, 0x8C, 0x28, 0x2C, 0x84, 0x67, 0xCD, 0xD0, 0x9E, 0x47, 0xDB, 0x27, 0x50, 0xCA,
|
|
||||||
0xF4, 0x63, 0x63, 0xE8, 0x97, 0x7F, 0x1B, 0x4B, 0x0C, 0xC2, 0xC1, 0x21, 0x4C, 0xCC, 0x58, 0xF5,
|
|
||||||
0x94, 0x52, 0xA3, 0xF3, 0xD3, 0xE0, 0x68, 0xF4, 0x00, 0x23, 0xF3, 0x5E, 0x0A, 0x7B, 0x93, 0xDD,
|
|
||||||
0xAB, 0x12, 0xB2, 0x13, 0xE8, 0x84, 0xD7, 0xA7, 0x9F, 0x0F, 0x32, 0x4C, 0x55, 0x1D, 0x04, 0x36,
|
|
||||||
0x52, 0xDC, 0x03, 0xF3, 0xF9, 0x4E, 0x42, 0xE9, 0x3D, 0x61, 0xEF, 0x7C, 0xB6, 0xB3, 0x93, 0x50,
|
|
||||||
]
|
]
|
||||||
|
|
||||||
|
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||||
|
const oriData = await GetArrayBuffer(file);
|
||||||
|
if (raw_ext === 'vpr') {
|
||||||
|
if (!BytesHasPrefix(new Uint8Array(oriData), VprHeader)) throw Error('Not a valid vpr file!');
|
||||||
|
} else {
|
||||||
|
if (!BytesHasPrefix(new Uint8Array(oriData), KgmHeader)) throw Error('Not a valid kgm(a) file!');
|
||||||
|
}
|
||||||
|
let musicDecoded = new Uint8Array();
|
||||||
|
if (globalThis.WebAssembly) {
|
||||||
|
const kgmDecrypted = await DecryptKgmWasm(oriData, raw_ext);
|
||||||
|
if (kgmDecrypted.success) {
|
||||||
|
musicDecoded = kgmDecrypted.data;
|
||||||
|
console.log('kgm wasm decoder suceeded');
|
||||||
|
} else {
|
||||||
|
throw new Error(kgmDecrypted.error || '(unknown error)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const ext = SniffAudioExt(musicDecoded);
|
||||||
|
const mime = AudioMimeType[ext];
|
||||||
|
let musicBlob = new Blob([musicDecoded], { type: mime });
|
||||||
|
const musicMeta = await metaParseBlob(musicBlob);
|
||||||
|
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, String(musicMeta.common.artists || musicMeta.common.artist || ""));
|
||||||
|
return {
|
||||||
|
album: musicMeta.common.album,
|
||||||
|
picture: GetCoverFromFile(musicMeta),
|
||||||
|
file: URL.createObjectURL(musicBlob),
|
||||||
|
blob: musicBlob,
|
||||||
|
ext,
|
||||||
|
mime,
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
68
src/decrypt/kgm_wasm.ts
Normal file
68
src/decrypt/kgm_wasm.ts
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
import { KgmCrypto } from '@xhacker/kgmwasm/KgmWasmBundle';
|
||||||
|
import KgmCryptoModule from '@xhacker/kgmwasm/KgmWasmBundle';
|
||||||
|
import { MergeUint8Array } from '@/utils/MergeUint8Array';
|
||||||
|
|
||||||
|
// 每次可以处理 2M 的数据
|
||||||
|
const DECRYPTION_BUF_SIZE = 2 *1024 * 1024;
|
||||||
|
|
||||||
|
export interface KGMDecryptionResult {
|
||||||
|
success: boolean;
|
||||||
|
data: Uint8Array;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密一个 KGM 加密的文件。
|
||||||
|
*
|
||||||
|
* 如果检测并解密成功,返回解密后的 Uint8Array 数据。
|
||||||
|
* @param {ArrayBuffer} kgmBlob 读入的文件 Blob
|
||||||
|
*/
|
||||||
|
export async function DecryptKgmWasm(kgmBlob: ArrayBuffer, ext: string): Promise<KGMDecryptionResult> {
|
||||||
|
const result: KGMDecryptionResult = { success: false, data: new Uint8Array(), error: '' };
|
||||||
|
|
||||||
|
// 初始化模组
|
||||||
|
let KgmCryptoObj: KgmCrypto;
|
||||||
|
|
||||||
|
try {
|
||||||
|
KgmCryptoObj = await KgmCryptoModule();
|
||||||
|
} catch (err: any) {
|
||||||
|
result.error = err?.message || 'wasm 加载失败';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!KgmCryptoObj) {
|
||||||
|
result.error = 'wasm 加载失败';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 申请内存块,并文件末端数据到 WASM 的内存堆
|
||||||
|
let kgmBuf = new Uint8Array(kgmBlob);
|
||||||
|
const pKgmBuf = KgmCryptoObj._malloc(DECRYPTION_BUF_SIZE);
|
||||||
|
const preDecDataSize = Math.min(DECRYPTION_BUF_SIZE, kgmBlob.byteLength); // 初始化缓冲区大小
|
||||||
|
KgmCryptoObj.writeArrayToMemory(kgmBuf.slice(0, preDecDataSize), pKgmBuf);
|
||||||
|
|
||||||
|
// 进行解密初始化
|
||||||
|
const headerSize = KgmCryptoObj.preDec(pKgmBuf, preDecDataSize, ext);
|
||||||
|
kgmBuf = kgmBuf.slice(headerSize);
|
||||||
|
|
||||||
|
const decryptedParts = [];
|
||||||
|
let offset = 0;
|
||||||
|
let bytesToDecrypt = kgmBuf.length;
|
||||||
|
while (bytesToDecrypt > 0) {
|
||||||
|
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
|
||||||
|
|
||||||
|
// 解密一些片段
|
||||||
|
const blockData = new Uint8Array(kgmBuf.slice(offset, offset + blockSize));
|
||||||
|
KgmCryptoObj.writeArrayToMemory(blockData, pKgmBuf);
|
||||||
|
KgmCryptoObj.decBlob(pKgmBuf, blockSize, offset);
|
||||||
|
decryptedParts.push(KgmCryptoObj.HEAPU8.slice(pKgmBuf, pKgmBuf + blockSize));
|
||||||
|
|
||||||
|
offset += blockSize;
|
||||||
|
bytesToDecrypt -= blockSize;
|
||||||
|
}
|
||||||
|
KgmCryptoObj._free(pKgmBuf);
|
||||||
|
|
||||||
|
result.data = MergeUint8Array(decryptedParts);
|
||||||
|
result.success = true;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
@ -1,77 +1,78 @@
|
|||||||
import {
|
import {
|
||||||
AudioMimeType,
|
AudioMimeType,
|
||||||
BytesHasPrefix,
|
BytesHasPrefix,
|
||||||
GetArrayBuffer,
|
GetArrayBuffer,
|
||||||
GetCoverFromFile,
|
GetCoverFromFile,
|
||||||
GetMetaFromFile,
|
GetMetaFromFile,
|
||||||
SniffAudioExt
|
SniffAudioExt,
|
||||||
} from "@/decrypt/utils.ts";
|
} from '@/decrypt/utils';
|
||||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw.ts";
|
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
|
||||||
|
|
||||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
|
||||||
|
//prettier-ignore
|
||||||
const MagicHeader = [
|
const MagicHeader = [
|
||||||
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
|
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
|
||||||
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65,
|
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65,
|
||||||
]
|
];
|
||||||
const PreDefinedKey = "MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk"
|
const MagicHeader2 = [
|
||||||
|
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
|
||||||
|
0x6B, 0x75, 0x77, 0x6F, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
];
|
||||||
|
const PreDefinedKey = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';
|
||||||
|
|
||||||
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
|
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
|
||||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
||||||
if (!BytesHasPrefix(oriData, MagicHeader)) {
|
if (!BytesHasPrefix(oriData, MagicHeader) && !BytesHasPrefix(oriData, MagicHeader2)) {
|
||||||
if (SniffAudioExt(oriData) === "aac") {
|
if (SniffAudioExt(oriData) === 'aac') {
|
||||||
return await RawDecrypt(file, raw_filename, "aac", false)
|
return await RawDecrypt(file, raw_filename, 'aac', false);
|
||||||
}
|
|
||||||
throw Error("not a valid kwm file")
|
|
||||||
}
|
}
|
||||||
|
throw Error('not a valid kwm file');
|
||||||
|
}
|
||||||
|
|
||||||
let fileKey = oriData.slice(0x18, 0x20)
|
let fileKey = oriData.slice(0x18, 0x20);
|
||||||
let mask = createMaskFromKey(fileKey)
|
let mask = createMaskFromKey(fileKey);
|
||||||
let audioData = oriData.slice(0x400);
|
let audioData = oriData.slice(0x400);
|
||||||
let lenAudioData = audioData.length;
|
let lenAudioData = audioData.length;
|
||||||
for (let cur = 0; cur < lenAudioData; ++cur)
|
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= mask[cur % 0x20];
|
||||||
audioData[cur] ^= mask[cur % 0x20];
|
|
||||||
|
|
||||||
|
const ext = SniffAudioExt(audioData);
|
||||||
|
const mime = AudioMimeType[ext];
|
||||||
|
let musicBlob = new Blob([audioData], { type: mime });
|
||||||
|
|
||||||
const ext = SniffAudioExt(audioData);
|
const musicMeta = await metaParseBlob(musicBlob);
|
||||||
const mime = AudioMimeType[ext];
|
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, String(musicMeta.common.artists || musicMeta.common.artist || ""));
|
||||||
let musicBlob = new Blob([audioData], {type: mime});
|
return {
|
||||||
|
album: musicMeta.common.album,
|
||||||
const musicMeta = await metaParseBlob(musicBlob);
|
picture: GetCoverFromFile(musicMeta),
|
||||||
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
|
file: URL.createObjectURL(musicBlob),
|
||||||
return {
|
blob: musicBlob,
|
||||||
album: musicMeta.common.album,
|
mime,
|
||||||
picture: GetCoverFromFile(musicMeta),
|
title,
|
||||||
file: URL.createObjectURL(musicBlob),
|
artist,
|
||||||
blob: musicBlob,
|
ext,
|
||||||
mime,
|
};
|
||||||
title,
|
|
||||||
artist,
|
|
||||||
ext
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array {
|
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array {
|
||||||
let keyView = new DataView(keyBytes.buffer)
|
let keyView = new DataView(keyBytes.buffer);
|
||||||
let keyStr = keyView.getBigUint64(0, true).toString()
|
let keyStr = keyView.getBigUint64(0, true).toString();
|
||||||
let keyStrTrim = trimKey(keyStr)
|
let keyStrTrim = trimKey(keyStr);
|
||||||
let key = new Uint8Array(32)
|
let key = new Uint8Array(32);
|
||||||
for (let i = 0; i < 32; i++) {
|
for (let i = 0; i < 32; i++) {
|
||||||
key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i)
|
key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i);
|
||||||
}
|
}
|
||||||
return key
|
return key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
function trimKey(keyRaw: string): string {
|
function trimKey(keyRaw: string): string {
|
||||||
let lenRaw = keyRaw.length;
|
let lenRaw = keyRaw.length;
|
||||||
let out = keyRaw;
|
let out = keyRaw;
|
||||||
if (lenRaw > 32) {
|
if (lenRaw > 32) {
|
||||||
out = keyRaw.slice(0, 32)
|
out = keyRaw.slice(0, 32);
|
||||||
} else if (lenRaw < 32) {
|
} else if (lenRaw < 32) {
|
||||||
out = keyRaw.padEnd(32, keyRaw)
|
out = keyRaw.padEnd(32, keyRaw);
|
||||||
}
|
}
|
||||||
return out
|
return out;
|
||||||
}
|
}
|
||||||
|
71
src/decrypt/mg3d.ts
Normal file
71
src/decrypt/mg3d.ts
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
import { Decrypt as RawDecrypt } from './raw';
|
||||||
|
import { GetArrayBuffer } from '@/decrypt/utils';
|
||||||
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
|
||||||
|
const segmentSize = 0x20;
|
||||||
|
|
||||||
|
function isPrintableAsciiChar(ch: number) {
|
||||||
|
return ch >= 0x20 && ch <= 0x7E;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUpperHexChar(ch: number) {
|
||||||
|
return (ch >= 0x30 && ch <= 0x39) || (ch >= 0x41 && ch <= 0x46);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {Buffer} data
|
||||||
|
* @param {Buffer} key
|
||||||
|
* @param {boolean} copy
|
||||||
|
* @returns Buffer
|
||||||
|
*/
|
||||||
|
function decryptSegment(data: Uint8Array, key: Uint8Array) {
|
||||||
|
for (let i = 0; i < data.byteLength; i++) {
|
||||||
|
data[i] -= key[i % segmentSize];
|
||||||
|
}
|
||||||
|
return Buffer.from(data);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> {
|
||||||
|
const buf = new Uint8Array(await GetArrayBuffer(file));
|
||||||
|
|
||||||
|
// 咪咕编码的 WAV 文件有很多“空洞”内容,尝试密钥。
|
||||||
|
const header = buf.slice(0, 0x100);
|
||||||
|
const bytesRIFF = Buffer.from('RIFF', 'ascii');
|
||||||
|
const bytesWaveFormat = Buffer.from('WAVEfmt ', 'ascii');
|
||||||
|
const possibleKeys = [];
|
||||||
|
|
||||||
|
for (let i = segmentSize; i < segmentSize * 20; i += segmentSize) {
|
||||||
|
const possibleKey = buf.slice(i, i + segmentSize);
|
||||||
|
if (!possibleKey.every(isUpperHexChar)) continue;
|
||||||
|
|
||||||
|
const tempHeader = decryptSegment(header, possibleKey);
|
||||||
|
if (tempHeader.slice(0, 4).compare(bytesRIFF)) continue;
|
||||||
|
if (tempHeader.slice(8, 16).compare(bytesWaveFormat)) continue;
|
||||||
|
|
||||||
|
// fmt chunk 大小可以是 16 / 18 / 40。
|
||||||
|
const fmtChunkSize = tempHeader.readUInt32LE(0x10);
|
||||||
|
if (![16, 18, 40].includes(fmtChunkSize)) continue;
|
||||||
|
|
||||||
|
// 下一个 chunk
|
||||||
|
const firstDataChunkOffset = 0x14 + fmtChunkSize;
|
||||||
|
const chunkName = tempHeader.slice(firstDataChunkOffset, firstDataChunkOffset + 4);
|
||||||
|
if (!chunkName.every(isPrintableAsciiChar)) continue;
|
||||||
|
|
||||||
|
const secondDataChunkOffset = firstDataChunkOffset + 8 + tempHeader.readUInt32LE(firstDataChunkOffset + 4);
|
||||||
|
if (secondDataChunkOffset <= header.byteLength) {
|
||||||
|
const secondChunkName = tempHeader.slice(secondDataChunkOffset, secondDataChunkOffset + 4);
|
||||||
|
if (!secondChunkName.every(isPrintableAsciiChar)) continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
possibleKeys.push(Buffer.from(possibleKey).toString('ascii'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (possibleKeys.length <= 0) {
|
||||||
|
throw new Error(`ERROR: no suitable key discovered`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptionKey = Buffer.from(possibleKeys[0], 'ascii');
|
||||||
|
decryptSegment(buf, decryptionKey);
|
||||||
|
const musicData = new Blob([buf], { type: 'audio/x-wav' });
|
||||||
|
return await RawDecrypt(musicData, raw_filename, 'wav', false);
|
||||||
|
}
|
@ -1,235 +1,248 @@
|
|||||||
import {
|
import {
|
||||||
AudioMimeType,
|
AudioMimeType,
|
||||||
BytesHasPrefix,
|
BytesHasPrefix,
|
||||||
GetArrayBuffer,
|
GetArrayBuffer,
|
||||||
GetImageFromURL,
|
GetImageFromURL,
|
||||||
GetMetaFromFile, IMusicMeta,
|
GetMetaFromFile,
|
||||||
SniffAudioExt,
|
IMusicMeta,
|
||||||
WriteMetaToFlac,
|
SniffAudioExt,
|
||||||
WriteMetaToMp3
|
WriteMetaToFlac,
|
||||||
} from "@/decrypt/utils.ts";
|
WriteMetaToMp3,
|
||||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
} from '@/decrypt/utils';
|
||||||
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
import jimp from 'jimp';
|
import jimp from 'jimp';
|
||||||
|
|
||||||
import CryptoJS from "crypto-js";
|
import AES from 'crypto-js/aes';
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import PKCS7 from 'crypto-js/pad-pkcs7';
|
||||||
|
import ModeECB from 'crypto-js/mode-ecb';
|
||||||
|
import WordArray from 'crypto-js/lib-typedarrays';
|
||||||
|
import Base64 from 'crypto-js/enc-base64';
|
||||||
|
import EncUTF8 from 'crypto-js/enc-utf8';
|
||||||
|
import EncHex from 'crypto-js/enc-hex';
|
||||||
|
|
||||||
const CORE_KEY = CryptoJS.enc.Hex.parse("687a4852416d736f356b496e62617857");
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
const META_KEY = CryptoJS.enc.Hex.parse("2331346C6A6B5F215C5D2630553C2728");
|
|
||||||
const MagicHeader = [0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D];
|
|
||||||
|
|
||||||
|
const CORE_KEY = EncHex.parse('687a4852416d736f356b496e62617857');
|
||||||
|
const META_KEY = EncHex.parse('2331346C6A6B5F215C5D2630553C2728');
|
||||||
|
const MagicHeader = [0x43, 0x54, 0x45, 0x4e, 0x46, 0x44, 0x41, 0x4d];
|
||||||
|
|
||||||
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
|
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
|
||||||
return (new NcmDecrypt(await GetArrayBuffer(file), raw_filename)).decrypt()
|
return new NcmDecrypt(await GetArrayBuffer(file), raw_filename).decrypt();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
interface NcmMusicMeta {
|
interface NcmMusicMeta {
|
||||||
//musicId: number
|
//musicId: number
|
||||||
musicName?: string
|
musicName?: string;
|
||||||
artist?: Array<string | number>[]
|
artist?: Array<string | number>[];
|
||||||
format?: string
|
format?: string;
|
||||||
album?: string
|
album?: string;
|
||||||
albumPic?: string
|
albumPic?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NcmDjMeta {
|
interface NcmDjMeta {
|
||||||
mainMusic: NcmMusicMeta
|
mainMusic: NcmMusicMeta;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class NcmDecrypt {
|
class NcmDecrypt {
|
||||||
raw: ArrayBuffer
|
raw: ArrayBuffer;
|
||||||
view: DataView
|
view: DataView;
|
||||||
offset: number = 0
|
offset: number = 0;
|
||||||
filename: string
|
filename: string;
|
||||||
format: string = ""
|
format: string = '';
|
||||||
mime: string = ""
|
mime: string = '';
|
||||||
audio?: Uint8Array
|
audio?: Uint8Array;
|
||||||
blob?: Blob
|
blob?: Blob;
|
||||||
oriMeta?: NcmMusicMeta
|
oriMeta?: NcmMusicMeta;
|
||||||
newMeta?: IMusicMeta
|
newMeta?: IMusicMeta;
|
||||||
image?: { mime: string, buffer: ArrayBuffer, url: string }
|
image?: { mime: string; buffer: ArrayBuffer; url: string };
|
||||||
|
|
||||||
constructor(buf: ArrayBuffer, filename: string) {
|
constructor(buf: ArrayBuffer, filename: string) {
|
||||||
const prefix = new Uint8Array(buf, 0, 8)
|
const prefix = new Uint8Array(buf, 0, 8);
|
||||||
if (!BytesHasPrefix(prefix, MagicHeader)) throw Error("此ncm文件已损坏")
|
if (!BytesHasPrefix(prefix, MagicHeader)) throw Error('此ncm文件已损坏');
|
||||||
this.offset = 10
|
this.offset = 10;
|
||||||
this.raw = buf
|
this.raw = buf;
|
||||||
this.view = new DataView(buf)
|
this.view = new DataView(buf);
|
||||||
this.filename = filename
|
this.filename = filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getKeyData(): Uint8Array {
|
||||||
|
const keyLen = this.view.getUint32(this.offset, true);
|
||||||
|
this.offset += 4;
|
||||||
|
const cipherText = new Uint8Array(this.raw, this.offset, keyLen).map((uint8) => uint8 ^ 0x64);
|
||||||
|
this.offset += keyLen;
|
||||||
|
|
||||||
|
const plainText = AES.decrypt(
|
||||||
|
// @ts-ignore
|
||||||
|
{ ciphertext: WordArray.create(cipherText) },
|
||||||
|
CORE_KEY,
|
||||||
|
{ mode: ModeECB, padding: PKCS7 },
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = new Uint8Array(plainText.sigBytes);
|
||||||
|
|
||||||
|
const words = plainText.words;
|
||||||
|
const sigBytes = plainText.sigBytes;
|
||||||
|
for (let i = 0; i < sigBytes; i++) {
|
||||||
|
result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
|
||||||
}
|
}
|
||||||
|
|
||||||
_getKeyData(): Uint8Array {
|
return result.slice(17);
|
||||||
const keyLen = this.view.getUint32(this.offset, true);
|
}
|
||||||
this.offset += 4;
|
|
||||||
const cipherText = new Uint8Array(this.raw, this.offset, keyLen)
|
|
||||||
.map(uint8 => uint8 ^ 0x64);
|
|
||||||
this.offset += keyLen;
|
|
||||||
|
|
||||||
const plainText = CryptoJS.AES.decrypt(
|
_getKeyBox(): Uint8Array {
|
||||||
// @ts-ignore
|
const keyData = this._getKeyData();
|
||||||
{ciphertext: CryptoJS.lib.WordArray.create(cipherText)},
|
const box = new Uint8Array(Array(256).keys());
|
||||||
CORE_KEY,
|
|
||||||
{mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}
|
|
||||||
);
|
|
||||||
|
|
||||||
const result = new Uint8Array(plainText.sigBytes);
|
const keyDataLen = keyData.length;
|
||||||
|
|
||||||
const words = plainText.words;
|
let j = 0;
|
||||||
const sigBytes = plainText.sigBytes;
|
|
||||||
for (let i = 0; i < sigBytes; i++) {
|
|
||||||
result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result.slice(17)
|
for (let i = 0; i < 256; i++) {
|
||||||
|
j = (box[i] + j + keyData[i % keyDataLen]) & 0xff;
|
||||||
|
[box[i], box[j]] = [box[j], box[i]];
|
||||||
}
|
}
|
||||||
|
|
||||||
_getKeyBox(): Uint8Array {
|
return box.map((_, i, arr) => {
|
||||||
const keyData = this._getKeyData()
|
i = (i + 1) & 0xff;
|
||||||
const box = new Uint8Array(Array(256).keys());
|
const si = arr[i];
|
||||||
|
const sj = arr[(i + si) & 0xff];
|
||||||
|
return arr[(si + sj) & 0xff];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const keyDataLen = keyData.length;
|
_getMetaData(): NcmMusicMeta {
|
||||||
|
const metaDataLen = this.view.getUint32(this.offset, true);
|
||||||
|
this.offset += 4;
|
||||||
|
if (metaDataLen === 0) return {};
|
||||||
|
|
||||||
let j = 0;
|
const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen).map((data) => data ^ 0x63);
|
||||||
|
this.offset += metaDataLen;
|
||||||
|
|
||||||
for (let i = 0; i < 256; i++) {
|
WordArray.create();
|
||||||
j = (box[i] + j + keyData[i % keyDataLen]) & 0xff;
|
const plainText = AES.decrypt(
|
||||||
[box[i], box[j]] = [box[j], box[i]];
|
// @ts-ignore
|
||||||
|
{
|
||||||
|
ciphertext: Base64.parse(
|
||||||
|
// @ts-ignore
|
||||||
|
WordArray.create(cipherText.slice(22)).toString(EncUTF8),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
META_KEY,
|
||||||
|
{ mode: ModeECB, padding: PKCS7 },
|
||||||
|
).toString(EncUTF8);
|
||||||
|
|
||||||
|
const labelIndex = plainText.indexOf(':');
|
||||||
|
let result: NcmMusicMeta;
|
||||||
|
if (plainText.slice(0, labelIndex) === 'dj') {
|
||||||
|
const tmp: NcmDjMeta = JSON.parse(plainText.slice(labelIndex + 1));
|
||||||
|
result = tmp.mainMusic;
|
||||||
|
} else {
|
||||||
|
result = JSON.parse(plainText.slice(labelIndex + 1));
|
||||||
|
}
|
||||||
|
if (result.albumPic) {
|
||||||
|
result.albumPic = result.albumPic.replace('http://', 'https://') + '?param=500y500';
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
_getAudio(keyBox: Uint8Array): Uint8Array {
|
||||||
|
this.offset += this.view.getUint32(this.offset + 5, true) + 13;
|
||||||
|
const audioData = new Uint8Array(this.raw, this.offset);
|
||||||
|
let lenAudioData = audioData.length;
|
||||||
|
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff];
|
||||||
|
return audioData;
|
||||||
|
}
|
||||||
|
|
||||||
|
async _buildMeta() {
|
||||||
|
if (!this.oriMeta) throw Error('invalid sequence');
|
||||||
|
|
||||||
|
const info = GetMetaFromFile(this.filename, this.oriMeta.musicName);
|
||||||
|
|
||||||
|
// build artists
|
||||||
|
let artists: string[] = [];
|
||||||
|
if (typeof this.oriMeta.artist === 'string') {
|
||||||
|
// v3.0: artist 现在可能是字符串了?
|
||||||
|
artists.push(this.oriMeta.artist);
|
||||||
|
} else if (Array.isArray(this.oriMeta.artist)) {
|
||||||
|
this.oriMeta.artist.forEach((artist) => {
|
||||||
|
if (typeof artist === 'string') {
|
||||||
|
artists.push(artist);
|
||||||
|
} else if (Array.isArray(artist) && artist[0] && typeof artist[0] === 'string') {
|
||||||
|
artists.push(artist[0]);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
return box.map((_, i, arr) => {
|
|
||||||
i = (i + 1) & 0xff;
|
|
||||||
const si = arr[i];
|
|
||||||
const sj = arr[(i + si) & 0xff];
|
|
||||||
return arr[(si + sj) & 0xff];
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_getMetaData(): NcmMusicMeta {
|
if (artists.length === 0 && info.artist) {
|
||||||
const metaDataLen = this.view.getUint32(this.offset, true);
|
artists = info.artist
|
||||||
this.offset += 4;
|
.split(',')
|
||||||
if (metaDataLen === 0) return {};
|
.map((val) => val.trim())
|
||||||
|
.filter((val) => val != '');
|
||||||
const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen)
|
|
||||||
.map(data => data ^ 0x63);
|
|
||||||
this.offset += metaDataLen;
|
|
||||||
|
|
||||||
const plainText = CryptoJS.AES.decrypt(
|
|
||||||
//@ts-ignore
|
|
||||||
{
|
|
||||||
ciphertext: CryptoJS.enc.Base64.parse(
|
|
||||||
//@ts-ignore
|
|
||||||
CryptoJS.lib.WordArray.create(cipherText.slice(22)).toString(CryptoJS.enc.Utf8)
|
|
||||||
)
|
|
||||||
},
|
|
||||||
META_KEY,
|
|
||||||
{mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}
|
|
||||||
).toString(CryptoJS.enc.Utf8);
|
|
||||||
|
|
||||||
const labelIndex = plainText.indexOf(":");
|
|
||||||
let result: NcmMusicMeta;
|
|
||||||
if (plainText.slice(0, labelIndex) === "dj") {
|
|
||||||
const tmp: NcmDjMeta = JSON.parse(plainText.slice(labelIndex + 1));
|
|
||||||
result = tmp.mainMusic;
|
|
||||||
} else {
|
|
||||||
result = JSON.parse(plainText.slice(labelIndex + 1));
|
|
||||||
}
|
|
||||||
if (!!result.albumPic) {
|
|
||||||
result.albumPic = result.albumPic.replace("http://", "https://") + "?param=500y500"
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_getAudio(keyBox: Uint8Array): Uint8Array {
|
if (this.oriMeta.albumPic)
|
||||||
this.offset += this.view.getUint32(this.offset + 5, true) + 13
|
try {
|
||||||
const audioData = new Uint8Array(this.raw, this.offset)
|
this.image = await GetImageFromURL(this.oriMeta.albumPic);
|
||||||
let lenAudioData = audioData.length
|
while (this.image && this.image.buffer.byteLength >= 1 << 24) {
|
||||||
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff]
|
let img = await jimp.read(Buffer.from(this.image.buffer));
|
||||||
return audioData
|
await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO);
|
||||||
|
this.image.buffer = await img.getBufferAsync('image/jpeg');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.log('fetch cover image failed', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.newMeta = { title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer };
|
||||||
|
}
|
||||||
|
|
||||||
|
async _writeMeta() {
|
||||||
|
if (!this.audio || !this.newMeta) throw Error('invalid sequence');
|
||||||
|
|
||||||
|
if (!this.blob) this.blob = new Blob([this.audio], { type: this.mime });
|
||||||
|
const ori = await metaParseBlob(this.blob);
|
||||||
|
|
||||||
|
let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title;
|
||||||
|
if (shouldWrite || this.newMeta.picture) {
|
||||||
|
if (this.format === 'mp3') {
|
||||||
|
this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori);
|
||||||
|
} else if (this.format === 'flac') {
|
||||||
|
this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori);
|
||||||
|
} else {
|
||||||
|
console.info(`writing meta for ${this.format} is not being supported for now`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
this.blob = new Blob([this.audio], { type: this.mime });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
gatherResult(): DecryptResult {
|
||||||
|
if (!this.newMeta || !this.blob) throw Error('bad sequence');
|
||||||
|
return {
|
||||||
|
title: this.newMeta.title,
|
||||||
|
artist: this.newMeta.artists?.join('; '),
|
||||||
|
ext: this.format,
|
||||||
|
album: this.newMeta.album,
|
||||||
|
picture: this.image?.url,
|
||||||
|
file: URL.createObjectURL(this.blob),
|
||||||
|
blob: this.blob,
|
||||||
|
mime: this.mime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async decrypt() {
|
||||||
|
const keyBox = this._getKeyBox();
|
||||||
|
this.oriMeta = this._getMetaData();
|
||||||
|
this.audio = this._getAudio(keyBox);
|
||||||
|
this.format = this.oriMeta.format || SniffAudioExt(this.audio);
|
||||||
|
this.mime = AudioMimeType[this.format];
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this._buildMeta();
|
||||||
|
await this._writeMeta();
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('build/write meta failed, skip.', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
async _buildMeta() {
|
return this.gatherResult();
|
||||||
if (!this.oriMeta) throw Error("invalid sequence")
|
}
|
||||||
|
|
||||||
const info = GetMetaFromFile(this.filename, this.oriMeta.musicName)
|
|
||||||
|
|
||||||
// build artists
|
|
||||||
let artists: string[] = [];
|
|
||||||
if (!!this.oriMeta.artist) {
|
|
||||||
this.oriMeta.artist.forEach(arr => artists.push(<string>arr[0]));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (artists.length === 0 && !!info.artist) {
|
|
||||||
artists = info.artist.split(',')
|
|
||||||
.map(val => val.trim()).filter(val => val != "");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.oriMeta.albumPic) try {
|
|
||||||
this.image = await GetImageFromURL(this.oriMeta.albumPic)
|
|
||||||
while (this.image && this.image.buffer.byteLength >= 1 << 24) {
|
|
||||||
let img = await jimp.read(Buffer.from(this.image.buffer))
|
|
||||||
await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO)
|
|
||||||
this.image.buffer = await img.getBufferAsync("image/jpeg")
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log("get cover image failed", e)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
this.newMeta = {title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer}
|
|
||||||
}
|
|
||||||
|
|
||||||
async _writeMeta() {
|
|
||||||
if (!this.audio || !this.newMeta) throw Error("invalid sequence")
|
|
||||||
|
|
||||||
if (!this.blob) this.blob = new Blob([this.audio], {type: this.mime})
|
|
||||||
const ori = await metaParseBlob(this.blob);
|
|
||||||
|
|
||||||
let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title
|
|
||||||
if (shouldWrite || this.newMeta.picture) {
|
|
||||||
if (this.format === "mp3") {
|
|
||||||
this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori)
|
|
||||||
} else if (this.format === "flac") {
|
|
||||||
this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori)
|
|
||||||
} else {
|
|
||||||
console.info(`writing meta for ${this.format} is not being supported for now`)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.blob = new Blob([this.audio], {type: this.mime})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
gatherResult(): DecryptResult {
|
|
||||||
if (!this.newMeta) throw Error("bad sequence")
|
|
||||||
return {
|
|
||||||
title: this.newMeta.title,
|
|
||||||
artist: this.newMeta.artists?.join("; "),
|
|
||||||
ext: this.format,
|
|
||||||
album: this.newMeta.album,
|
|
||||||
picture: this.image?.url,
|
|
||||||
file: URL.createObjectURL(this.blob),
|
|
||||||
blob: this.blob as Blob,
|
|
||||||
mime: this.mime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async decrypt() {
|
|
||||||
const keyBox = this._getKeyBox()
|
|
||||||
this.oriMeta = this._getMetaData()
|
|
||||||
this.audio = this._getAudio(keyBox)
|
|
||||||
this.format = this.oriMeta.format || SniffAudioExt(this.audio)
|
|
||||||
this.mime = AudioMimeType[this.format]
|
|
||||||
await this._buildMeta()
|
|
||||||
try {
|
|
||||||
await this._writeMeta()
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("write meta data failed", e)
|
|
||||||
}
|
|
||||||
return this.gatherResult()
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
28
src/decrypt/ncmcache.ts
Normal file
28
src/decrypt/ncmcache.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils';
|
||||||
|
|
||||||
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
|
||||||
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
|
|
||||||
|
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||||
|
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||||
|
let length = buffer.length;
|
||||||
|
for (let i = 0; i < length; i++) {
|
||||||
|
buffer[i] ^= 163;
|
||||||
|
}
|
||||||
|
const ext = SniffAudioExt(buffer, raw_ext);
|
||||||
|
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] });
|
||||||
|
const tag = await metaParseBlob(file);
|
||||||
|
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || ""));
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
ext,
|
||||||
|
album: tag.common.album,
|
||||||
|
picture: GetCoverFromFile(tag),
|
||||||
|
file: URL.createObjectURL(file),
|
||||||
|
blob: file,
|
||||||
|
mime: AudioMimeType[ext],
|
||||||
|
};
|
||||||
|
}
|
@ -1,138 +1,88 @@
|
|||||||
import {QmcMask, QmcMaskDetectMflac, QmcMaskDetectMgg, QmcMaskGetDefault} from "./qmcMask";
|
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils';
|
||||||
import {toByteArray as Base64Decode} from 'base64-js'
|
|
||||||
import {
|
|
||||||
AudioMimeType,
|
|
||||||
GetArrayBuffer,
|
|
||||||
GetCoverFromFile,
|
|
||||||
GetImageFromURL,
|
|
||||||
GetMetaFromFile,
|
|
||||||
SniffAudioExt, WriteMetaToFlac, WriteMetaToMp3
|
|
||||||
} from "@/decrypt/utils.ts";
|
|
||||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
|
||||||
|
|
||||||
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
import iconv from "iconv-lite";
|
import { DecryptQmcWasm } from '@/decrypt/qmc_wasm';
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { extractQQMusicMeta } from '@/utils/qm_meta';
|
||||||
import {queryAlbumCover, queryKeyInfo, reportKeyUsage} from "@/utils/api";
|
|
||||||
|
|
||||||
interface Handler {
|
interface Handler {
|
||||||
ext: string
|
ext: string;
|
||||||
detect: boolean
|
version: number;
|
||||||
|
|
||||||
handler(data?: Uint8Array): QmcMask | undefined
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const HandlerMap: { [key: string]: Handler } = {
|
export const HandlerMap: { [key: string]: Handler } = {
|
||||||
"mgg": {handler: QmcMaskDetectMgg, ext: "ogg", detect: true},
|
mgg: { ext: 'ogg', version: 2 },
|
||||||
"mflac": {handler: QmcMaskDetectMflac, ext: "flac", detect: true},
|
mgg0: { ext: 'ogg', version: 2 },
|
||||||
"qmc0": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
mggl: { ext: 'ogg', version: 2 },
|
||||||
"qmc2": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
|
mgg1: { ext: 'ogg', version: 2 },
|
||||||
"qmc3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
mflac: { ext: 'flac', version: 2 },
|
||||||
"qmcogg": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
|
mflac0: { ext: 'flac', version: 2 },
|
||||||
"qmcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
|
mmp4: { ext: 'mp4', version: 2 },
|
||||||
"bkcmp3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
|
||||||
"bkcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
|
// qmcflac / qmcogg:
|
||||||
"tkm": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
|
// 有可能是 v2 加密但混用同一个后缀名。
|
||||||
"666c6163": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
|
qmcflac: { ext: 'flac', version: 2 },
|
||||||
"6d7033": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
qmcogg: { ext: 'ogg', version: 2 },
|
||||||
"6f6767": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
|
|
||||||
"6d3461": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
|
qmc0: { ext: 'mp3', version: 2 },
|
||||||
"776176": {handler: QmcMaskGetDefault, ext: "wav", detect: false}
|
qmc2: { ext: 'ogg', version: 2 },
|
||||||
|
qmc3: { ext: 'mp3', version: 2 },
|
||||||
|
qmc4: { ext: 'ogg', version: 2 },
|
||||||
|
qmc6: { ext: 'ogg', version: 2 },
|
||||||
|
qmc8: { ext: 'ogg', version: 2 },
|
||||||
|
bkcmp3: { ext: 'mp3', version: 1 },
|
||||||
|
bkcm4a: { ext: 'm4a', version: 1 },
|
||||||
|
bkcflac: { ext: 'flac', version: 1 },
|
||||||
|
bkcwav: { ext: 'wav', version: 1 },
|
||||||
|
bkcape: { ext: 'ape', version: 1 },
|
||||||
|
bkcogg: { ext: 'ogg', version: 1 },
|
||||||
|
bkcwma: { ext: 'wma', version: 1 },
|
||||||
|
tkm: { ext: 'm4a', version: 1 },
|
||||||
|
'666c6163': { ext: 'flac', version: 1 },
|
||||||
|
'6d7033': { ext: 'mp3', version: 1 },
|
||||||
|
'6f6767': { ext: 'ogg', version: 1 },
|
||||||
|
'6d3461': { ext: 'm4a', version: 1 },
|
||||||
|
'776176': { ext: 'wav', version: 1 },
|
||||||
};
|
};
|
||||||
|
|
||||||
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||||
if (!(raw_ext in HandlerMap)) throw "File type is incorrect!";
|
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
|
||||||
const handler = HandlerMap[raw_ext];
|
const handler = HandlerMap[raw_ext];
|
||||||
|
let { version } = handler;
|
||||||
|
|
||||||
const fileData = new Uint8Array(await GetArrayBuffer(file));
|
const fileBuffer = await GetArrayBuffer(file);
|
||||||
let audioData, seed, keyData;
|
let musicDecoded = new Uint8Array();
|
||||||
if (handler.detect) {
|
let musicID: number | string | undefined;
|
||||||
const keyLen = new DataView(fileData.slice(fileData.length - 4).buffer).getUint32(0, true)
|
|
||||||
const keyPos = fileData.length - 4 - keyLen;
|
if (version === 2 && globalThis.WebAssembly) {
|
||||||
audioData = fileData.slice(0, keyPos);
|
const v2Decrypted = await DecryptQmcWasm(fileBuffer, raw_ext);
|
||||||
seed = handler.handler(audioData);
|
// 若 v2 检测失败,降级到 v1 再尝试一次
|
||||||
keyData = fileData.slice(keyPos, keyPos + keyLen);
|
if (v2Decrypted.success) {
|
||||||
if (!seed) seed = await queryKey(keyData, raw_filename, raw_ext);
|
musicDecoded = v2Decrypted.data;
|
||||||
if (!seed) throw raw_ext + "格式仅提供实验性支持";
|
musicID = v2Decrypted.songId;
|
||||||
|
console.log('qmc wasm decoder suceeded');
|
||||||
} else {
|
} else {
|
||||||
audioData = fileData;
|
throw new Error(v2Decrypted.error || '(unknown error)');
|
||||||
seed = handler.handler(audioData) as QmcMask;
|
|
||||||
}
|
}
|
||||||
let musicDecoded = seed.Decrypt(audioData);
|
}
|
||||||
|
|
||||||
const ext = SniffAudioExt(musicDecoded, handler.ext);
|
const ext = SniffAudioExt(musicDecoded, handler.ext);
|
||||||
const mime = AudioMimeType[ext];
|
const mime = AudioMimeType[ext];
|
||||||
|
|
||||||
let musicBlob = new Blob([musicDecoded], {type: mime});
|
const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta(
|
||||||
|
new Blob([musicDecoded], { type: mime }),
|
||||||
|
raw_filename,
|
||||||
|
ext,
|
||||||
|
musicID,
|
||||||
|
);
|
||||||
|
|
||||||
const musicMeta = await metaParseBlob(musicBlob);
|
return {
|
||||||
for (let metaIdx in musicMeta.native) {
|
title: title,
|
||||||
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue
|
artist: artist,
|
||||||
if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) {
|
ext: ext,
|
||||||
console.warn("try using gbk encoding to decode meta")
|
album: album,
|
||||||
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ""), "gbk");
|
picture: imgUrl,
|
||||||
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ""), "gbk");
|
file: URL.createObjectURL(blob),
|
||||||
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ""), "gbk");
|
blob: blob,
|
||||||
}
|
mime: mime,
|
||||||
}
|
};
|
||||||
|
|
||||||
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
|
|
||||||
if (keyData) reportKeyUsage(keyData, seed.getMatrix128(),
|
|
||||||
raw_filename, raw_ext, info.title, info.artist, musicMeta.common.album).then().catch();
|
|
||||||
|
|
||||||
let imgUrl = GetCoverFromFile(musicMeta);
|
|
||||||
if (!imgUrl) {
|
|
||||||
imgUrl = await getCoverImage(info.title, info.artist, musicMeta.common.album);
|
|
||||||
if (imgUrl) {
|
|
||||||
const imageInfo = await GetImageFromURL(imgUrl);
|
|
||||||
if (imageInfo) {
|
|
||||||
imgUrl = imageInfo.url
|
|
||||||
try {
|
|
||||||
const newMeta = {picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(" _ ")}
|
|
||||||
if (ext === "mp3") {
|
|
||||||
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta)
|
|
||||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
|
||||||
} else if (ext === 'flac') {
|
|
||||||
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta)
|
|
||||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
|
||||||
} else {
|
|
||||||
console.info("writing metadata for " + ext + " is not being supported for now")
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn("Error while appending cover image to file " + e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
title: info.title,
|
|
||||||
artist: info.artist,
|
|
||||||
ext: ext,
|
|
||||||
album: musicMeta.common.album,
|
|
||||||
picture: imgUrl,
|
|
||||||
file: URL.createObjectURL(musicBlob),
|
|
||||||
blob: musicBlob,
|
|
||||||
mime: mime
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
async function queryKey(keyData: Uint8Array, filename: string, format: string): Promise<QmcMask | undefined> {
|
|
||||||
try {
|
|
||||||
const data = await queryKeyInfo(keyData, filename, format)
|
|
||||||
return new QmcMask(Base64Decode(data.Matrix44));
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
|
|
||||||
const song_query_url = "https://stats.ixarea.com/apis" + "/music/qq-cover"
|
|
||||||
try {
|
|
||||||
const data = await queryAlbumCover(title, artist, album)
|
|
||||||
return `${song_query_url}/${data.Type}/${data.Id}`
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(e);
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
|
@ -1,206 +0,0 @@
|
|||||||
import {BytesHasPrefix, FLAC_HEADER, OGG_HEADER} from "@/decrypt/utils.ts";
|
|
||||||
|
|
||||||
const QMOggPublicHeader1 = [
|
|
||||||
0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff,
|
|
||||||
0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x1e, 0x01, 0x76, 0x6f, 0x72,
|
|
||||||
0x62, 0x69, 0x73, 0x00, 0x00, 0x00, 0x00, 0x02, 0x44, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x00, 0xee, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb8, 0x01, 0x4f, 0x67, 0x67, 0x53, 0x00, 0x00,
|
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00,
|
|
||||||
0xff, 0xff, 0xff, 0xff];
|
|
||||||
const QMOggPublicHeader2 = [
|
|
||||||
0x03, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x2c, 0x00, 0x00, 0x00, 0x58, 0x69, 0x70, 0x68, 0x2e,
|
|
||||||
0x4f, 0x72, 0x67, 0x20, 0x6c, 0x69, 0x62, 0x56, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x20, 0x49, 0x20,
|
|
||||||
0x32, 0x30, 0x31, 0x35, 0x30, 0x31, 0x30, 0x35, 0x20, 0x28, 0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84,
|
|
||||||
0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84, 0x29, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x54,
|
|
||||||
0x49, 0x54, 0x4c, 0x45, 0x3d];
|
|
||||||
const QMOggPublicConf1 = [
|
|
||||||
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0,
|
|
||||||
0, 0, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9, 9, 9,
|
|
||||||
9, 9, 9, 9, 9, 9, 9, 6, 3, 3, 3, 3, 6, 6, 6, 6,
|
|
||||||
3, 3, 3, 3, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9,
|
|
||||||
9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9,
|
|
||||||
0, 0, 0, 0];
|
|
||||||
const QMOggPublicConf2 = [
|
|
||||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
|
|
||||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
|
|
||||||
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
|
|
||||||
3, 3, 3, 3, 3, 3, 3, 0, 1, 3, 3, 0, 1, 3, 3, 3,
|
|
||||||
3, 3, 3, 3, 3];
|
|
||||||
const QMCDefaultMaskMatrix = [
|
|
||||||
0xde, 0x51, 0xfa, 0xc3, 0x4a, 0xd6, 0xca, 0x90,
|
|
||||||
0x7e, 0x67, 0x5e, 0xf7, 0xd5, 0x52, 0x84, 0xd8,
|
|
||||||
0x47, 0x95, 0xbb, 0xa1, 0xaa, 0xc6, 0x66, 0x23,
|
|
||||||
0x92, 0x62, 0xf3, 0x74, 0xa1, 0x9f, 0xf4, 0xa0,
|
|
||||||
0x1d, 0x3f, 0x5b, 0xf0, 0x13, 0x0e, 0x09, 0x3d,
|
|
||||||
0xf9, 0xbc, 0x00, 0x11];
|
|
||||||
|
|
||||||
|
|
||||||
const AllMapping: number[][] = [];
|
|
||||||
const Mask128to44: number[] = [];
|
|
||||||
|
|
||||||
(function () {
|
|
||||||
for (let i = 0; i < 128; i++) {
|
|
||||||
let realIdx = (i * i + 27) % 256
|
|
||||||
if (realIdx in AllMapping) {
|
|
||||||
AllMapping[realIdx].push(i)
|
|
||||||
} else {
|
|
||||||
AllMapping[realIdx] = [i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let idx44 = 0
|
|
||||||
AllMapping.forEach(all128 => {
|
|
||||||
all128.forEach(_i128 => {
|
|
||||||
Mask128to44[_i128] = idx44
|
|
||||||
})
|
|
||||||
idx44++
|
|
||||||
})
|
|
||||||
})();
|
|
||||||
|
|
||||||
|
|
||||||
export class QmcMask {
|
|
||||||
private readonly Matrix128: number[];
|
|
||||||
|
|
||||||
constructor(matrix: number[] | Uint8Array) {
|
|
||||||
if (matrix instanceof Uint8Array) matrix = Array.from(matrix)
|
|
||||||
if (matrix.length === 44) {
|
|
||||||
this.Matrix128 = this._generate128(matrix)
|
|
||||||
} else if (matrix.length === 128) {
|
|
||||||
this.Matrix128 = matrix
|
|
||||||
} else {
|
|
||||||
throw Error("invalid mask length")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getMatrix128() {
|
|
||||||
return this.Matrix128
|
|
||||||
}
|
|
||||||
|
|
||||||
getMatrix44(): number[] {
|
|
||||||
const matrix44: number[] = []
|
|
||||||
let idxI44 = 0
|
|
||||||
AllMapping.forEach(it256 => {
|
|
||||||
let it256Len = it256.length
|
|
||||||
for (let i = 1; i < it256Len; i++) {
|
|
||||||
if (this.Matrix128[it256[0]] !== this.Matrix128[it256[i]]) {
|
|
||||||
throw "decode mask-128 to mask-44 failed"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
matrix44[idxI44] = this.Matrix128[it256[0]]
|
|
||||||
idxI44++
|
|
||||||
})
|
|
||||||
return matrix44
|
|
||||||
}
|
|
||||||
|
|
||||||
Decrypt(data: Uint8Array) {
|
|
||||||
if (!this.Matrix128) throw Error("bad call sequence")
|
|
||||||
let dst = data.slice(0);
|
|
||||||
let index = -1;
|
|
||||||
let maskIdx = -1;
|
|
||||||
for (let cur = 0; cur < data.length; cur++) {
|
|
||||||
index++;
|
|
||||||
maskIdx++;
|
|
||||||
if (index === 0x8000 || (index > 0x8000 && (index + 1) % 0x8000 === 0)) {
|
|
||||||
index++;
|
|
||||||
maskIdx++;
|
|
||||||
}
|
|
||||||
if (maskIdx >= 128) maskIdx -= 128;
|
|
||||||
dst[cur] ^= this.Matrix128[maskIdx];
|
|
||||||
}
|
|
||||||
return dst;
|
|
||||||
}
|
|
||||||
|
|
||||||
private _generate128(matrix44: number[]): number[] {
|
|
||||||
const matrix128: number[] = []
|
|
||||||
let idx44 = 0
|
|
||||||
AllMapping.forEach(it256 => {
|
|
||||||
it256.forEach(m => {
|
|
||||||
matrix128[m] = matrix44[idx44]
|
|
||||||
})
|
|
||||||
idx44++
|
|
||||||
})
|
|
||||||
return matrix128
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QmcMaskGetDefault() {
|
|
||||||
return new QmcMask(QMCDefaultMaskMatrix)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QmcMaskDetectMflac(data: Uint8Array) {
|
|
||||||
let search_len = Math.min(0x8000, data.length), mask;
|
|
||||||
for (let block_idx = 0; block_idx < search_len; block_idx += 128) {
|
|
||||||
try {
|
|
||||||
mask = new QmcMask(data.slice(block_idx, block_idx + 128));
|
|
||||||
if (BytesHasPrefix(mask.Decrypt(data.slice(0, FLAC_HEADER.length)), FLAC_HEADER)) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return mask;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function QmcMaskDetectMgg(data: Uint8Array) {
|
|
||||||
if (data.length < 0x100) return
|
|
||||||
let matrixConfidence: { [key: number]: { [key: number]: number } } = {};
|
|
||||||
for (let i = 0; i < 44; i++) matrixConfidence[i] = {};
|
|
||||||
|
|
||||||
const page2 = data[0x54] ^ data[0xC] ^ QMOggPublicHeader1[0xC];
|
|
||||||
const spHeader = QmcGenerateOggHeader(page2)
|
|
||||||
const spConf = QmcGenerateOggConf(page2)
|
|
||||||
|
|
||||||
for (let idx128 = 0; idx128 < spHeader.length; idx128++) {
|
|
||||||
if (spConf[idx128] === 0) continue;
|
|
||||||
let idx44 = Mask128to44[idx128 % 128];
|
|
||||||
let _m = data[idx128] ^ spHeader[idx128]
|
|
||||||
let confidence = spConf[idx128];
|
|
||||||
if (_m in matrixConfidence[idx44]) {
|
|
||||||
matrixConfidence[idx44][_m] += confidence
|
|
||||||
} else {
|
|
||||||
matrixConfidence[idx44][_m] = confidence
|
|
||||||
}
|
|
||||||
}
|
|
||||||
let matrix = [];
|
|
||||||
try {
|
|
||||||
for (let i = 0; i < 44; i++)
|
|
||||||
matrix[i] = calcMaskFromConfidence(matrixConfidence[i]);
|
|
||||||
} catch (e) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const mask = new QmcMask(matrix);
|
|
||||||
if (!BytesHasPrefix(mask.Decrypt(data.slice(0, OGG_HEADER.length)), OGG_HEADER)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
return mask;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
function calcMaskFromConfidence(confidence: { [key: number]: number }) {
|
|
||||||
const count = Object.keys(confidence).length
|
|
||||||
if (count === 0) throw "can not match at least one key";
|
|
||||||
if (count > 1) console.warn("There are 2 potential value for the mask!")
|
|
||||||
let result = ""
|
|
||||||
let conf = 0
|
|
||||||
for (let idx in confidence) {
|
|
||||||
if (confidence[idx] > conf) {
|
|
||||||
result = idx;
|
|
||||||
conf = confidence[idx];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return Number(result)
|
|
||||||
}
|
|
||||||
|
|
||||||
function QmcGenerateOggHeader(page2: number) {
|
|
||||||
let spec = [page2, 0xFF]
|
|
||||||
for (let i = 2; i < page2; i++) spec.push(0xFF)
|
|
||||||
spec.push(0xFF)
|
|
||||||
return QMOggPublicHeader1.concat(spec, QMOggPublicHeader2)
|
|
||||||
}
|
|
||||||
|
|
||||||
function QmcGenerateOggConf(page2: number) {
|
|
||||||
let specConf = [6, 0]
|
|
||||||
for (let i = 2; i < page2; i++) specConf.push(4)
|
|
||||||
specConf.push(0)
|
|
||||||
return QMOggPublicConf1.concat(specConf, QMOggPublicConf2)
|
|
||||||
}
|
|
75
src/decrypt/qmc_wasm.ts
Normal file
75
src/decrypt/qmc_wasm.ts
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
import { QmcCrypto } from '@xhacker/qmcwasm/QmcWasmBundle';
|
||||||
|
import QmcCryptoModule from '@xhacker/qmcwasm/QmcWasmBundle';
|
||||||
|
import { MergeUint8Array } from '@/utils/MergeUint8Array';
|
||||||
|
|
||||||
|
// 每次可以处理 2M 的数据
|
||||||
|
const DECRYPTION_BUF_SIZE = 2 *1024 * 1024;
|
||||||
|
|
||||||
|
export interface QMCDecryptionResult {
|
||||||
|
success: boolean;
|
||||||
|
data: Uint8Array;
|
||||||
|
songId: string | number;
|
||||||
|
error: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解密一个 QMC 加密的文件。
|
||||||
|
*
|
||||||
|
* 如果检测并解密成功,返回解密后的 Uint8Array 数据。
|
||||||
|
* @param {ArrayBuffer} qmcBlob 读入的文件 Blob
|
||||||
|
*/
|
||||||
|
export async function DecryptQmcWasm(qmcBlob: ArrayBuffer, ext: string): Promise<QMCDecryptionResult> {
|
||||||
|
const result: QMCDecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' };
|
||||||
|
|
||||||
|
// 初始化模组
|
||||||
|
let QmcCryptoObj: QmcCrypto;
|
||||||
|
|
||||||
|
try {
|
||||||
|
QmcCryptoObj = await QmcCryptoModule();
|
||||||
|
} catch (err: any) {
|
||||||
|
result.error = err?.message || 'wasm 加载失败';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
if (!QmcCryptoObj) {
|
||||||
|
result.error = 'wasm 加载失败';
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 申请内存块,并文件末端数据到 WASM 的内存堆
|
||||||
|
const qmcBuf = new Uint8Array(qmcBlob);
|
||||||
|
const pQmcBuf = QmcCryptoObj._malloc(DECRYPTION_BUF_SIZE);
|
||||||
|
const preDecDataSize = Math.min(DECRYPTION_BUF_SIZE, qmcBlob.byteLength); // 初始化缓冲区大小
|
||||||
|
QmcCryptoObj.writeArrayToMemory(qmcBuf.slice(-preDecDataSize), pQmcBuf);
|
||||||
|
|
||||||
|
// 进行解密初始化
|
||||||
|
ext = '.' + ext;
|
||||||
|
const tailSize = QmcCryptoObj.preDec(pQmcBuf, preDecDataSize, ext);
|
||||||
|
if (tailSize == -1) {
|
||||||
|
result.error = QmcCryptoObj.getErr();
|
||||||
|
return result;
|
||||||
|
} else {
|
||||||
|
result.songId = QmcCryptoObj.getSongId();
|
||||||
|
result.songId = result.songId == "0" ? 0 : result.songId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const decryptedParts = [];
|
||||||
|
let offset = 0;
|
||||||
|
let bytesToDecrypt = qmcBuf.length - tailSize;
|
||||||
|
while (bytesToDecrypt > 0) {
|
||||||
|
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
|
||||||
|
|
||||||
|
// 解密一些片段
|
||||||
|
const blockData = new Uint8Array(qmcBuf.slice(offset, offset + blockSize));
|
||||||
|
QmcCryptoObj.writeArrayToMemory(blockData, pQmcBuf);
|
||||||
|
decryptedParts.push(QmcCryptoObj.HEAPU8.slice(pQmcBuf, pQmcBuf + QmcCryptoObj.decBlob(pQmcBuf, blockSize, offset)));
|
||||||
|
|
||||||
|
offset += blockSize;
|
||||||
|
bytesToDecrypt -= blockSize;
|
||||||
|
}
|
||||||
|
QmcCryptoObj._free(pQmcBuf);
|
||||||
|
|
||||||
|
result.data = MergeUint8Array(decryptedParts);
|
||||||
|
result.success = true;
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
58
src/decrypt/qmccache.ts
Normal file
58
src/decrypt/qmccache.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import {
|
||||||
|
AudioMimeType,
|
||||||
|
GetArrayBuffer,
|
||||||
|
GetCoverFromFile,
|
||||||
|
GetMetaFromFile,
|
||||||
|
SniffAudioExt,
|
||||||
|
SplitFilename,
|
||||||
|
} from '@/decrypt/utils';
|
||||||
|
|
||||||
|
import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc';
|
||||||
|
import { DecryptQmcWasm } from '@/decrypt/qmc_wasm';
|
||||||
|
|
||||||
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
|
||||||
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
|
|
||||||
|
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||||
|
const buffer = await GetArrayBuffer(file);
|
||||||
|
|
||||||
|
let musicDecoded = new Uint8Array();
|
||||||
|
if (globalThis.WebAssembly) {
|
||||||
|
console.log('qmc: using wasm decoder');
|
||||||
|
|
||||||
|
const qmcDecrypted = await DecryptQmcWasm(buffer, raw_ext);
|
||||||
|
// 若 wasm 失败,使用 js 再尝试一次
|
||||||
|
if (qmcDecrypted.success) {
|
||||||
|
musicDecoded = qmcDecrypted.data;
|
||||||
|
console.log('qmc wasm decoder suceeded');
|
||||||
|
} else {
|
||||||
|
throw new Error(qmcDecrypted.error || '(unknown error)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ext = SniffAudioExt(musicDecoded, '');
|
||||||
|
const newName = SplitFilename(raw_filename);
|
||||||
|
let audioBlob: Blob;
|
||||||
|
if (ext !== '' || newName.ext === 'mp3') {
|
||||||
|
audioBlob = new Blob([musicDecoded], { type: AudioMimeType[ext] });
|
||||||
|
} else if (newName.ext in HandlerMap) {
|
||||||
|
audioBlob = new Blob([musicDecoded], { type: 'application/octet-stream' });
|
||||||
|
return QmcDecrypt(audioBlob, newName.name, newName.ext);
|
||||||
|
} else {
|
||||||
|
throw '不支持的QQ音乐缓存格式';
|
||||||
|
}
|
||||||
|
const tag = await metaParseBlob(audioBlob);
|
||||||
|
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || ""));
|
||||||
|
|
||||||
|
return {
|
||||||
|
title,
|
||||||
|
artist,
|
||||||
|
ext,
|
||||||
|
album: tag.common.album,
|
||||||
|
picture: GetCoverFromFile(tag),
|
||||||
|
file: URL.createObjectURL(audioBlob),
|
||||||
|
blob: audioBlob,
|
||||||
|
mime: AudioMimeType[ext],
|
||||||
|
};
|
||||||
|
}
|
@ -1,28 +1,32 @@
|
|||||||
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils.ts";
|
import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils';
|
||||||
|
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
|
||||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
|
|
||||||
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string, detect: boolean = true)
|
export async function Decrypt(
|
||||||
: Promise<DecryptResult> {
|
file: Blob,
|
||||||
let ext = raw_ext;
|
raw_filename: string,
|
||||||
if (detect) {
|
raw_ext: string,
|
||||||
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
detect: boolean = true,
|
||||||
ext = SniffAudioExt(buffer, raw_ext);
|
): Promise<DecryptResult> {
|
||||||
if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]})
|
let ext = raw_ext;
|
||||||
}
|
if (detect) {
|
||||||
const tag = await metaParseBlob(file);
|
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||||
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist)
|
ext = SniffAudioExt(buffer, raw_ext);
|
||||||
|
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] });
|
||||||
|
}
|
||||||
|
const tag = await metaParseBlob(file);
|
||||||
|
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || ''));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
ext,
|
ext,
|
||||||
album: tag.common.album,
|
album: tag.common.album,
|
||||||
picture: GetCoverFromFile(tag),
|
picture: GetCoverFromFile(tag),
|
||||||
file: URL.createObjectURL(file),
|
file: URL.createObjectURL(file),
|
||||||
blob: file,
|
blob: file,
|
||||||
mime: AudioMimeType[ext]
|
mime: AudioMimeType[ext],
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
@ -1,14 +1,14 @@
|
|||||||
import {Decrypt as RawDecrypt} from "./raw";
|
import { Decrypt as RawDecrypt } from './raw';
|
||||||
import {GetArrayBuffer} from "@/decrypt/utils.ts";
|
import { GetArrayBuffer } from '@/decrypt/utils';
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
|
||||||
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
|
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
|
||||||
|
|
||||||
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> {
|
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> {
|
||||||
const audioData = new Uint8Array(await GetArrayBuffer(file));
|
const audioData = new Uint8Array(await GetArrayBuffer(file));
|
||||||
for (let cur = 0; cur < 8; ++cur) {
|
for (let cur = 0; cur < 8; ++cur) {
|
||||||
audioData[cur] = TM_HEADER[cur];
|
audioData[cur] = TM_HEADER[cur];
|
||||||
}
|
}
|
||||||
const musicData = new Blob([audioData], {type: "audio/mp4"});
|
const musicData = new Blob([audioData], { type: 'audio/mp4' });
|
||||||
return await RawDecrypt(musicData, raw_filename, "m4a", false)
|
return await RawDecrypt(musicData, raw_filename, 'm4a', false);
|
||||||
}
|
}
|
||||||
|
@ -1,158 +1,262 @@
|
|||||||
import {IAudioMetadata} from "music-metadata-browser";
|
import { IAudioMetadata } from 'music-metadata-browser';
|
||||||
import ID3Writer from "browser-id3-writer";
|
import ID3Writer from 'browser-id3-writer';
|
||||||
import MetaFlac from "metaflac-js";
|
import MetaFlac from 'metaflac-js';
|
||||||
|
|
||||||
export const FLAC_HEADER = [0x66, 0x4C, 0x61, 0x43];
|
export const split_regex = /[ ]?[,;/_、][ ]?/;
|
||||||
|
|
||||||
|
export const FLAC_HEADER = [0x66, 0x4c, 0x61, 0x43];
|
||||||
export const MP3_HEADER = [0x49, 0x44, 0x33];
|
export const MP3_HEADER = [0x49, 0x44, 0x33];
|
||||||
export const OGG_HEADER = [0x4F, 0x67, 0x67, 0x53];
|
export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53];
|
||||||
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
|
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
|
||||||
|
//prettier-ignore
|
||||||
export const WMA_HEADER = [
|
export const WMA_HEADER = [
|
||||||
0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11,
|
0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11,
|
||||||
0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C,
|
0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c,
|
||||||
]
|
];
|
||||||
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]
|
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46];
|
||||||
export const AAC_HEADER = [0xFF, 0xF1]
|
export const AAC_HEADER = [0xff, 0xf1];
|
||||||
|
export const DFF_HEADER = [0x46, 0x52, 0x4d, 0x38];
|
||||||
|
|
||||||
export const AudioMimeType: { [key: string]: string } = {
|
export const AudioMimeType: { [key: string]: string } = {
|
||||||
mp3: "audio/mpeg",
|
mp3: 'audio/mpeg',
|
||||||
flac: "audio/flac",
|
flac: 'audio/flac',
|
||||||
m4a: "audio/mp4",
|
m4a: 'audio/mp4',
|
||||||
ogg: "audio/ogg",
|
ogg: 'audio/ogg',
|
||||||
wma: "audio/x-ms-wma",
|
wma: 'audio/x-ms-wma',
|
||||||
wav: "audio/x-wav"
|
wav: 'audio/x-wav',
|
||||||
|
dff: 'audio/x-dff',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean {
|
export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean {
|
||||||
if (prefix.length > data.length) return false
|
if (prefix.length > data.length) return false;
|
||||||
return prefix.every((val, idx) => {
|
return prefix.every((val, idx) => {
|
||||||
return val === data[idx];
|
return val === data[idx];
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function BytesEqual(a: Uint8Array, b: Uint8Array): boolean {
|
||||||
|
if (a.length !== b.length) return false;
|
||||||
|
return a.every((val, idx) => {
|
||||||
|
return val === b[idx];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function SniffAudioExt(data: Uint8Array, fallback_ext: string = "mp3"): string {
|
export function SniffAudioExt(data: Uint8Array, fallback_ext: string = 'mp3'): string {
|
||||||
if (BytesHasPrefix(data, MP3_HEADER)) return "mp3"
|
if (BytesHasPrefix(data, MP3_HEADER)) return 'mp3';
|
||||||
if (BytesHasPrefix(data, FLAC_HEADER)) return "flac"
|
if (BytesHasPrefix(data, FLAC_HEADER)) return 'flac';
|
||||||
if (BytesHasPrefix(data, OGG_HEADER)) return "ogg"
|
if (BytesHasPrefix(data, OGG_HEADER)) return 'ogg';
|
||||||
if (data.length >= 4 + M4A_HEADER.length &&
|
if (data.length >= 4 + M4A_HEADER.length && BytesHasPrefix(data.slice(4), M4A_HEADER)) return 'm4a';
|
||||||
BytesHasPrefix(data.slice(4), M4A_HEADER)) return "m4a"
|
if (BytesHasPrefix(data, WAV_HEADER)) return 'wav';
|
||||||
if (BytesHasPrefix(data, WAV_HEADER)) return "wav"
|
if (BytesHasPrefix(data, WMA_HEADER)) return 'wma';
|
||||||
if (BytesHasPrefix(data, WMA_HEADER)) return "wma"
|
if (BytesHasPrefix(data, AAC_HEADER)) return 'aac';
|
||||||
if (BytesHasPrefix(data, AAC_HEADER)) return "aac"
|
if (BytesHasPrefix(data, DFF_HEADER)) return 'dff';
|
||||||
return fallback_ext;
|
return fallback_ext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
|
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
|
||||||
if (!!obj.arrayBuffer) return obj.arrayBuffer()
|
if (!!obj.arrayBuffer) return obj.arrayBuffer();
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const rs = e.target?.result
|
const rs = e.target?.result;
|
||||||
if (!rs) {
|
if (!rs) {
|
||||||
reject("read file failed")
|
reject('read file failed');
|
||||||
} else {
|
} else {
|
||||||
resolve(rs as ArrayBuffer)
|
resolve(rs as ArrayBuffer);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
reader.readAsArrayBuffer(obj);
|
reader.readAsArrayBuffer(obj);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetCoverFromFile(metadata: IAudioMetadata): string {
|
export function GetCoverFromFile(metadata: IAudioMetadata): string {
|
||||||
if (metadata.common?.picture && metadata.common.picture.length > 0) {
|
if (metadata.common?.picture && metadata.common.picture.length > 0) {
|
||||||
return URL.createObjectURL(new Blob(
|
return URL.createObjectURL(
|
||||||
[metadata.common.picture[0].data],
|
new Blob([metadata.common.picture[0].data], { type: metadata.common.picture[0].format }),
|
||||||
{type: metadata.common.picture[0].format}
|
);
|
||||||
));
|
}
|
||||||
}
|
return '';
|
||||||
return "";
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IMusicMetaBasic {
|
export interface IMusicMetaBasic {
|
||||||
title: string
|
title: string;
|
||||||
artist?: string
|
artist?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function GetMetaFromFile(filename: string, exist_title?: string, exist_artist?: string, separator = "-")
|
export function GetMetaFromFile(
|
||||||
: IMusicMetaBasic {
|
filename: string,
|
||||||
const meta: IMusicMetaBasic = {title: exist_title ?? "", artist: exist_artist}
|
exist_title?: string,
|
||||||
|
exist_artist?: string,
|
||||||
|
separator = '-',
|
||||||
|
): IMusicMetaBasic {
|
||||||
|
const meta: IMusicMetaBasic = { title: exist_title ?? '', artist: exist_artist };
|
||||||
|
|
||||||
const items = filename.split(separator);
|
const items = filename.split(separator);
|
||||||
if (items.length > 1) {
|
if (items.length > 1) {
|
||||||
if (!meta.artist) meta.artist = items[0].trim();
|
//由文件名和原metadata共同决定歌手tag(有时从文件名看有多个歌手,而metadata只有一个)
|
||||||
if (!meta.title) meta.title = items[1].trim();
|
if (!meta.artist || meta.artist.split(split_regex).length < items[0].trim().split(split_regex).length) meta.artist = items[0].trim();
|
||||||
} else if (items.length === 1) {
|
if (!meta.title) meta.title = items[1].trim();
|
||||||
if (!meta.title) meta.title = items[0].trim();
|
} else if (items.length === 1) {
|
||||||
|
if (!meta.title) meta.title = items[0].trim();
|
||||||
|
}
|
||||||
|
return meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GetImageFromURL(
|
||||||
|
src: string,
|
||||||
|
): Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> {
|
||||||
|
try {
|
||||||
|
const resp = await fetch(src);
|
||||||
|
const mime = resp.headers.get('Content-Type');
|
||||||
|
if (mime?.startsWith('image/')) {
|
||||||
|
const buffer = await resp.arrayBuffer();
|
||||||
|
const url = URL.createObjectURL(new Blob([buffer], { type: mime }));
|
||||||
|
return { buffer, url, mime };
|
||||||
}
|
}
|
||||||
return meta
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function GetImageFromURL(src: string):
|
|
||||||
Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> {
|
|
||||||
try {
|
|
||||||
const resp = await fetch(src);
|
|
||||||
const mime = resp.headers.get("Content-Type");
|
|
||||||
if (mime?.startsWith("image/")) {
|
|
||||||
const buffer = await resp.arrayBuffer();
|
|
||||||
const url = URL.createObjectURL(new Blob([buffer], {type: mime}))
|
|
||||||
return {buffer, url, mime}
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export interface IMusicMeta {
|
export interface IMusicMeta {
|
||||||
title: string
|
title: string;
|
||||||
artists?: string[]
|
artists?: string[];
|
||||||
album?: string
|
album?: string;
|
||||||
picture?: ArrayBuffer
|
albumartist?: string;
|
||||||
picture_desc?: string
|
genre?: string[];
|
||||||
|
picture?: ArrayBuffer;
|
||||||
|
picture_desc?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
||||||
const writer = new ID3Writer(audioData);
|
const writer = new ID3Writer(audioData);
|
||||||
|
|
||||||
// reserve original data
|
// reserve original data
|
||||||
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []
|
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || [];
|
||||||
frames.forEach(frame => {
|
frames.forEach((frame) => {
|
||||||
if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') {
|
if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') {
|
||||||
try {
|
try {
|
||||||
writer.setFrame(frame.id, frame.value)
|
writer.setFrame(frame.id, frame.value);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
}
|
console.warn(`failed to write ID3 tag '${frame.id}'`);
|
||||||
}
|
}
|
||||||
})
|
|
||||||
|
|
||||||
const old = original.common
|
|
||||||
writer.setFrame('TPE1', old?.artists || info.artists || [])
|
|
||||||
.setFrame('TIT2', old?.title || info.title)
|
|
||||||
.setFrame('TALB', old?.album || info.album || "");
|
|
||||||
if (info.picture) {
|
|
||||||
writer.setFrame('APIC', {
|
|
||||||
type: 3,
|
|
||||||
data: info.picture,
|
|
||||||
description: info.picture_desc || "Cover",
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
return writer.addTag();
|
});
|
||||||
|
|
||||||
|
const old = original.common;
|
||||||
|
writer
|
||||||
|
.setFrame('TPE1', old?.artists || info.artists || [])
|
||||||
|
.setFrame('TIT2', old?.title || info.title)
|
||||||
|
.setFrame('TALB', old?.album || info.album || '');
|
||||||
|
if (info.picture) {
|
||||||
|
writer.setFrame('APIC', {
|
||||||
|
type: 3,
|
||||||
|
data: info.picture,
|
||||||
|
description: info.picture_desc || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return writer.addTag();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
||||||
const writer = new MetaFlac(audioData)
|
const writer = new MetaFlac(audioData);
|
||||||
const old = original.common
|
const old = original.common;
|
||||||
if (!old.title && !old.album && old.artists) {
|
if (!old.title && !old.album && old.artists) {
|
||||||
writer.setTag("TITLE=" + info.title)
|
writer.setTag('TITLE=' + info.title);
|
||||||
writer.setTag("ALBUM=" + info.album)
|
writer.setTag('ALBUM=' + info.album);
|
||||||
if (info.artists) {
|
if (info.artists) {
|
||||||
writer.removeTag("ARTIST")
|
writer.removeTag('ARTIST');
|
||||||
info.artists.forEach(artist => writer.setTag("ARTIST=" + artist))
|
info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist));
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (info.picture) {
|
if (info.picture) {
|
||||||
writer.importPictureFromBuffer(Buffer.from(info.picture))
|
writer.importPictureFromBuffer(Buffer.from(info.picture));
|
||||||
}
|
}
|
||||||
return writer.save()
|
return writer.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RewriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
||||||
|
const writer = new ID3Writer(audioData);
|
||||||
|
|
||||||
|
// preserve original data
|
||||||
|
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || [];
|
||||||
|
frames.forEach((frame) => {
|
||||||
|
if (frame.id !== 'TPE1'
|
||||||
|
&& frame.id !== 'TIT2'
|
||||||
|
&& frame.id !== 'TALB'
|
||||||
|
&& frame.id !== 'TPE2'
|
||||||
|
&& frame.id !== 'TCON'
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
writer.setFrame(frame.id, frame.value);
|
||||||
|
} catch (e) {
|
||||||
|
throw new Error(`failed to write ID3 tag '${frame.id}'`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const old = original.common;
|
||||||
|
writer
|
||||||
|
.setFrame('TPE1', info?.artists || old.artists || [])
|
||||||
|
.setFrame('TIT2', info?.title || old.title)
|
||||||
|
.setFrame('TALB', info?.album || old.album || '')
|
||||||
|
.setFrame('TPE2', info?.albumartist || old.albumartist || '')
|
||||||
|
.setFrame('TCON', info?.genre || old.genre || []);
|
||||||
|
if (info.picture) {
|
||||||
|
writer.setFrame('APIC', {
|
||||||
|
type: 3,
|
||||||
|
data: info.picture,
|
||||||
|
description: info.picture_desc || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return writer.addTag();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RewriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
||||||
|
const writer = new MetaFlac(audioData);
|
||||||
|
const old = original.common;
|
||||||
|
if (info.title) {
|
||||||
|
if (old.title) {
|
||||||
|
writer.removeTag('TITLE');
|
||||||
|
}
|
||||||
|
writer.setTag('TITLE=' + info.title);
|
||||||
|
}
|
||||||
|
if (info.album) {
|
||||||
|
if (old.album) {
|
||||||
|
writer.removeTag('ALBUM');
|
||||||
|
}
|
||||||
|
writer.setTag('ALBUM=' + info.album);
|
||||||
|
}
|
||||||
|
if (info.albumartist) {
|
||||||
|
if (old.albumartist) {
|
||||||
|
writer.removeTag('ALBUMARTIST');
|
||||||
|
}
|
||||||
|
writer.setTag('ALBUMARTIST=' + info.albumartist);
|
||||||
|
}
|
||||||
|
if (info.artists) {
|
||||||
|
if (old.artists) {
|
||||||
|
writer.removeTag('ARTIST');
|
||||||
|
}
|
||||||
|
info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist));
|
||||||
|
}
|
||||||
|
if (info.genre) {
|
||||||
|
if (old.genre) {
|
||||||
|
writer.removeTag('GENRE');
|
||||||
|
}
|
||||||
|
info.genre.forEach((singlegenre) => writer.setTag('GENRE=' + singlegenre));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (info.picture) {
|
||||||
|
writer.importPictureFromBuffer(Buffer.from(info.picture));
|
||||||
|
}
|
||||||
|
return writer.save();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SplitFilename(n: string): { name: string; ext: string } {
|
||||||
|
const pos = n.lastIndexOf('.');
|
||||||
|
return {
|
||||||
|
ext: n.substring(pos + 1).toLowerCase(),
|
||||||
|
name: n.substring(0, pos),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
193
src/decrypt/ximalaya.ts
Normal file
193
src/decrypt/ximalaya.ts
Normal file
@ -0,0 +1,193 @@
|
|||||||
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
|
import { AudioMimeType, SniffAudioExt, GetArrayBuffer, GetMetaFromFile } from './utils';
|
||||||
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
|
|
||||||
|
const HandlerMap: Map<string, (data: Uint8Array) => Uint8Array> = new Map([
|
||||||
|
['x2m', ProcessX2M],
|
||||||
|
['x3m', ProcessX3M],
|
||||||
|
]);
|
||||||
|
|
||||||
|
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||||
|
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||||
|
const handler = HandlerMap.get(raw_ext);
|
||||||
|
if (!handler) throw 'File type is incorrect!';
|
||||||
|
let musicDecoded: Uint8Array = handler(buffer);
|
||||||
|
|
||||||
|
const ext = SniffAudioExt(musicDecoded, 'm4a');
|
||||||
|
const mime = AudioMimeType[ext];
|
||||||
|
|
||||||
|
let musicBlob = new Blob([musicDecoded], { type: mime });
|
||||||
|
|
||||||
|
const musicMeta = await metaParseBlob(musicBlob);
|
||||||
|
|
||||||
|
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist);
|
||||||
|
|
||||||
|
return {
|
||||||
|
picture: '',
|
||||||
|
title: info.title,
|
||||||
|
artist: info.artist,
|
||||||
|
ext: ext,
|
||||||
|
album: musicMeta.common.album,
|
||||||
|
blob: musicBlob,
|
||||||
|
file: URL.createObjectURL(musicBlob),
|
||||||
|
mime: mime,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcessX2M(data: Uint8Array) {
|
||||||
|
const x2mHeaderSize = 1024;
|
||||||
|
const x2mKey = [0x78, 0x6d, 0x6c, 0x79];
|
||||||
|
let encryptedHeader = data.slice(0, x2mHeaderSize);
|
||||||
|
for (let idx = 0; idx < x2mHeaderSize; idx++) {
|
||||||
|
let srcIdx = x2mScrambleTable[idx];
|
||||||
|
data[idx] = encryptedHeader[srcIdx] ^ x2mKey[idx % x2mKey.length];
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProcessX3M(data: Uint8Array) {
|
||||||
|
const x3mHeaderSize = 1024;
|
||||||
|
|
||||||
|
//prettier-ignore: uint8, size 32(8x4)
|
||||||
|
const x3mKey = [
|
||||||
|
0x33, 0x39, 0x38, 0x39, 0x64, 0x31, 0x31, 0x31, 0x61, 0x61, 0x64, 0x35, 0x36, 0x31, 0x33, 0x39, 0x34, 0x30, 0x66,
|
||||||
|
0x34, 0x66, 0x63, 0x34, 0x34, 0x62, 0x36, 0x33, 0x39, 0x62, 0x32, 0x39, 0x32,
|
||||||
|
];
|
||||||
|
|
||||||
|
let encryptedHeader = data.slice(0, x3mHeaderSize);
|
||||||
|
for (let dstIdx = 0; dstIdx < x3mHeaderSize; dstIdx++) {
|
||||||
|
let srcIdx = x3mScrambleTable[dstIdx];
|
||||||
|
data[dstIdx] = encryptedHeader[srcIdx] ^ x3mKey[dstIdx % x3mKey.length];
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
//prettier-ignore: uint16, size 1024 (64x16)
|
||||||
|
const x2mScrambleTable = [
|
||||||
|
0x2a9, 0x2ab, 0x154, 0x2aa, 0x2a8, 0x2ac, 0x153, 0x2a7, 0x2ad, 0x152, 0x2a6, 0x3ff, 0x000, 0x155, 0x2ae, 0x151, 0x2a5,
|
||||||
|
0x3fe, 0x001, 0x156, 0x2af, 0x150, 0x2a4, 0x3fd, 0x002, 0x157, 0x2b0, 0x14f, 0x2a3, 0x3fc, 0x003, 0x158, 0x2b1, 0x14e,
|
||||||
|
0x2a2, 0x3fb, 0x004, 0x159, 0x2b2, 0x14d, 0x2a1, 0x3fa, 0x005, 0x15a, 0x2b3, 0x14c, 0x2a0, 0x3f9, 0x006, 0x15b, 0x2b4,
|
||||||
|
0x14b, 0x29f, 0x3f8, 0x007, 0x15c, 0x2b5, 0x14a, 0x29e, 0x3f7, 0x008, 0x15d, 0x2b6, 0x149, 0x29d, 0x3f6, 0x009, 0x15e,
|
||||||
|
0x2b7, 0x148, 0x29c, 0x3f5, 0x00a, 0x15f, 0x2b8, 0x147, 0x29b, 0x3f4, 0x00b, 0x160, 0x2b9, 0x146, 0x29a, 0x3f3, 0x00c,
|
||||||
|
0x161, 0x2ba, 0x145, 0x299, 0x3f2, 0x00d, 0x162, 0x2bb, 0x144, 0x298, 0x3f1, 0x00e, 0x163, 0x2bc, 0x143, 0x297, 0x3f0,
|
||||||
|
0x00f, 0x164, 0x2bd, 0x142, 0x296, 0x3ef, 0x010, 0x165, 0x2be, 0x141, 0x295, 0x3ee, 0x011, 0x166, 0x2bf, 0x140, 0x294,
|
||||||
|
0x3ed, 0x012, 0x167, 0x2c0, 0x13f, 0x293, 0x3ec, 0x013, 0x168, 0x2c1, 0x13e, 0x292, 0x3eb, 0x014, 0x169, 0x2c2, 0x13d,
|
||||||
|
0x291, 0x3ea, 0x015, 0x16a, 0x2c3, 0x13c, 0x290, 0x3e9, 0x016, 0x16b, 0x2c4, 0x13b, 0x28f, 0x3e8, 0x017, 0x16c, 0x2c5,
|
||||||
|
0x13a, 0x28e, 0x3e7, 0x018, 0x16d, 0x2c6, 0x139, 0x28d, 0x3e6, 0x019, 0x16e, 0x2c7, 0x138, 0x28c, 0x3e5, 0x01a, 0x16f,
|
||||||
|
0x2c8, 0x137, 0x28b, 0x3e4, 0x01b, 0x170, 0x2c9, 0x136, 0x28a, 0x3e3, 0x01c, 0x171, 0x2ca, 0x135, 0x289, 0x3e2, 0x01d,
|
||||||
|
0x172, 0x2cb, 0x134, 0x288, 0x3e1, 0x01e, 0x173, 0x2cc, 0x133, 0x287, 0x3e0, 0x01f, 0x174, 0x2cd, 0x0a9, 0x1fe, 0x357,
|
||||||
|
0x020, 0x175, 0x2ce, 0x0aa, 0x1ff, 0x358, 0x021, 0x176, 0x2cf, 0x0ab, 0x200, 0x359, 0x022, 0x177, 0x2d0, 0x0ac, 0x201,
|
||||||
|
0x35a, 0x023, 0x178, 0x2d1, 0x0ad, 0x202, 0x35b, 0x024, 0x179, 0x2d2, 0x0ae, 0x203, 0x35c, 0x025, 0x17a, 0x2d3, 0x0af,
|
||||||
|
0x204, 0x35d, 0x026, 0x17b, 0x2d4, 0x0b0, 0x205, 0x35e, 0x027, 0x17c, 0x2d5, 0x0b1, 0x206, 0x35f, 0x028, 0x17d, 0x2d6,
|
||||||
|
0x0b2, 0x207, 0x360, 0x029, 0x17e, 0x2d7, 0x0b3, 0x208, 0x361, 0x02a, 0x17f, 0x2d8, 0x0b4, 0x209, 0x362, 0x02b, 0x180,
|
||||||
|
0x2d9, 0x0b5, 0x20a, 0x363, 0x02c, 0x181, 0x2da, 0x0b6, 0x20b, 0x364, 0x02d, 0x182, 0x2db, 0x0b7, 0x20c, 0x365, 0x02e,
|
||||||
|
0x183, 0x2dc, 0x0b8, 0x20d, 0x366, 0x02f, 0x184, 0x2dd, 0x0b9, 0x20e, 0x367, 0x030, 0x185, 0x2de, 0x0ba, 0x20f, 0x368,
|
||||||
|
0x031, 0x186, 0x2df, 0x0bb, 0x210, 0x369, 0x032, 0x187, 0x2e0, 0x0bc, 0x211, 0x36a, 0x033, 0x188, 0x2e1, 0x0bd, 0x212,
|
||||||
|
0x36b, 0x034, 0x189, 0x2e2, 0x0be, 0x213, 0x36c, 0x035, 0x18a, 0x2e3, 0x0bf, 0x214, 0x36d, 0x036, 0x18b, 0x2e4, 0x0c0,
|
||||||
|
0x215, 0x36e, 0x037, 0x18c, 0x2e5, 0x0c1, 0x216, 0x36f, 0x038, 0x18d, 0x2e6, 0x0c2, 0x217, 0x370, 0x039, 0x18e, 0x2e7,
|
||||||
|
0x0c3, 0x218, 0x371, 0x03a, 0x18f, 0x2e8, 0x0c4, 0x219, 0x372, 0x03b, 0x190, 0x2e9, 0x0c5, 0x21a, 0x373, 0x03c, 0x191,
|
||||||
|
0x2ea, 0x0c6, 0x21b, 0x374, 0x03d, 0x192, 0x2eb, 0x0c7, 0x21c, 0x375, 0x03e, 0x193, 0x2ec, 0x0c8, 0x21d, 0x376, 0x03f,
|
||||||
|
0x194, 0x2ed, 0x0c9, 0x21e, 0x377, 0x040, 0x195, 0x2ee, 0x0ca, 0x21f, 0x378, 0x041, 0x196, 0x2ef, 0x0cb, 0x220, 0x379,
|
||||||
|
0x042, 0x197, 0x2f0, 0x0cc, 0x221, 0x37a, 0x043, 0x198, 0x2f1, 0x0cd, 0x222, 0x37b, 0x044, 0x199, 0x2f2, 0x0ce, 0x223,
|
||||||
|
0x37c, 0x045, 0x19a, 0x2f3, 0x0cf, 0x224, 0x37d, 0x046, 0x19b, 0x2f4, 0x0d0, 0x225, 0x37e, 0x047, 0x19c, 0x2f5, 0x0d1,
|
||||||
|
0x226, 0x37f, 0x048, 0x19d, 0x2f6, 0x0d2, 0x227, 0x380, 0x049, 0x19e, 0x2f7, 0x0d3, 0x228, 0x381, 0x04a, 0x19f, 0x2f8,
|
||||||
|
0x0d4, 0x229, 0x382, 0x04b, 0x1a0, 0x2f9, 0x0d5, 0x22a, 0x383, 0x04c, 0x1a1, 0x2fa, 0x0d6, 0x22b, 0x384, 0x04d, 0x1a2,
|
||||||
|
0x2fb, 0x0d7, 0x22c, 0x385, 0x04e, 0x1a3, 0x2fc, 0x0d8, 0x22d, 0x386, 0x04f, 0x1a4, 0x2fd, 0x0d9, 0x22e, 0x387, 0x050,
|
||||||
|
0x1a5, 0x2fe, 0x0da, 0x22f, 0x388, 0x051, 0x1a6, 0x2ff, 0x0db, 0x230, 0x389, 0x052, 0x1a7, 0x300, 0x0dc, 0x231, 0x38a,
|
||||||
|
0x053, 0x1a8, 0x301, 0x0dd, 0x232, 0x38b, 0x054, 0x1a9, 0x302, 0x0de, 0x233, 0x38c, 0x055, 0x1aa, 0x303, 0x0df, 0x234,
|
||||||
|
0x38d, 0x056, 0x1ab, 0x304, 0x0e0, 0x235, 0x38e, 0x057, 0x1ac, 0x305, 0x0e1, 0x236, 0x38f, 0x058, 0x1ad, 0x306, 0x0e2,
|
||||||
|
0x237, 0x390, 0x059, 0x1ae, 0x307, 0x0e3, 0x238, 0x391, 0x05a, 0x1af, 0x308, 0x0e4, 0x239, 0x392, 0x05b, 0x1b0, 0x309,
|
||||||
|
0x0e5, 0x23a, 0x393, 0x05c, 0x1b1, 0x30a, 0x0e6, 0x23b, 0x394, 0x05d, 0x1b2, 0x30b, 0x0e7, 0x23c, 0x395, 0x05e, 0x1b3,
|
||||||
|
0x30c, 0x0e8, 0x23d, 0x396, 0x05f, 0x1b4, 0x30d, 0x0e9, 0x23e, 0x397, 0x060, 0x1b5, 0x30e, 0x0ea, 0x23f, 0x398, 0x061,
|
||||||
|
0x1b6, 0x30f, 0x0eb, 0x240, 0x399, 0x062, 0x1b7, 0x310, 0x0ec, 0x241, 0x39a, 0x063, 0x1b8, 0x311, 0x0ed, 0x242, 0x39b,
|
||||||
|
0x064, 0x1b9, 0x312, 0x0ee, 0x243, 0x39c, 0x065, 0x1ba, 0x313, 0x0ef, 0x244, 0x39d, 0x066, 0x1bb, 0x314, 0x0f0, 0x245,
|
||||||
|
0x39e, 0x067, 0x1bc, 0x315, 0x0f1, 0x246, 0x39f, 0x068, 0x1bd, 0x316, 0x0f2, 0x247, 0x3a0, 0x069, 0x1be, 0x317, 0x0f3,
|
||||||
|
0x248, 0x3a1, 0x06a, 0x1bf, 0x318, 0x0f4, 0x249, 0x3a2, 0x06b, 0x1c0, 0x319, 0x0f5, 0x24a, 0x3a3, 0x06c, 0x1c1, 0x31a,
|
||||||
|
0x0f6, 0x24b, 0x3a4, 0x06d, 0x1c2, 0x31b, 0x0f7, 0x24c, 0x3a5, 0x06e, 0x1c3, 0x31c, 0x0f8, 0x24d, 0x3a6, 0x06f, 0x1c4,
|
||||||
|
0x31d, 0x0f9, 0x24e, 0x3a7, 0x070, 0x1c5, 0x31e, 0x0fa, 0x24f, 0x3a8, 0x071, 0x1c6, 0x31f, 0x0fb, 0x250, 0x3a9, 0x072,
|
||||||
|
0x1c7, 0x320, 0x0fc, 0x251, 0x3aa, 0x073, 0x1c8, 0x321, 0x0fd, 0x252, 0x3ab, 0x074, 0x1c9, 0x322, 0x0fe, 0x253, 0x3ac,
|
||||||
|
0x075, 0x1ca, 0x323, 0x0ff, 0x254, 0x3ad, 0x076, 0x1cb, 0x324, 0x100, 0x255, 0x3ae, 0x077, 0x1cc, 0x325, 0x101, 0x256,
|
||||||
|
0x3af, 0x078, 0x1cd, 0x326, 0x102, 0x257, 0x3b0, 0x079, 0x1ce, 0x327, 0x103, 0x258, 0x3b1, 0x07a, 0x1cf, 0x328, 0x104,
|
||||||
|
0x259, 0x3b2, 0x07b, 0x1d0, 0x329, 0x105, 0x25a, 0x3b3, 0x07c, 0x1d1, 0x32a, 0x106, 0x25b, 0x3b4, 0x07d, 0x1d2, 0x32b,
|
||||||
|
0x107, 0x25c, 0x3b5, 0x07e, 0x1d3, 0x32c, 0x108, 0x25d, 0x3b6, 0x07f, 0x1d4, 0x32d, 0x109, 0x25e, 0x3b7, 0x080, 0x1d5,
|
||||||
|
0x32e, 0x10a, 0x25f, 0x3b8, 0x081, 0x1d6, 0x32f, 0x10b, 0x260, 0x3b9, 0x082, 0x1d7, 0x330, 0x10c, 0x261, 0x3ba, 0x083,
|
||||||
|
0x1d8, 0x331, 0x10d, 0x262, 0x3bb, 0x084, 0x1d9, 0x332, 0x10e, 0x263, 0x3bc, 0x085, 0x1da, 0x333, 0x10f, 0x264, 0x3bd,
|
||||||
|
0x086, 0x1db, 0x334, 0x110, 0x265, 0x3be, 0x087, 0x1dc, 0x335, 0x111, 0x266, 0x3bf, 0x088, 0x1dd, 0x336, 0x112, 0x267,
|
||||||
|
0x3c0, 0x089, 0x1de, 0x337, 0x113, 0x268, 0x3c1, 0x08a, 0x1df, 0x338, 0x114, 0x269, 0x3c2, 0x08b, 0x1e0, 0x339, 0x115,
|
||||||
|
0x26a, 0x3c3, 0x08c, 0x1e1, 0x33a, 0x116, 0x26b, 0x3c4, 0x08d, 0x1e2, 0x33b, 0x117, 0x26c, 0x3c5, 0x08e, 0x1e3, 0x33c,
|
||||||
|
0x118, 0x26d, 0x3c6, 0x08f, 0x1e4, 0x33d, 0x119, 0x26e, 0x3c7, 0x090, 0x1e5, 0x33e, 0x11a, 0x26f, 0x3c8, 0x091, 0x1e6,
|
||||||
|
0x33f, 0x11b, 0x270, 0x3c9, 0x092, 0x1e7, 0x340, 0x11c, 0x271, 0x3ca, 0x093, 0x1e8, 0x341, 0x11d, 0x272, 0x3cb, 0x094,
|
||||||
|
0x1e9, 0x342, 0x11e, 0x273, 0x3cc, 0x095, 0x1ea, 0x343, 0x11f, 0x274, 0x3cd, 0x096, 0x1eb, 0x344, 0x120, 0x275, 0x3ce,
|
||||||
|
0x097, 0x1ec, 0x345, 0x121, 0x276, 0x3cf, 0x098, 0x1ed, 0x346, 0x122, 0x277, 0x3d0, 0x099, 0x1ee, 0x347, 0x123, 0x278,
|
||||||
|
0x3d1, 0x09a, 0x1ef, 0x348, 0x124, 0x279, 0x3d2, 0x09b, 0x1f0, 0x349, 0x125, 0x27a, 0x3d3, 0x09c, 0x1f1, 0x34a, 0x126,
|
||||||
|
0x27b, 0x3d4, 0x09d, 0x1f2, 0x34b, 0x127, 0x27c, 0x3d5, 0x09e, 0x1f3, 0x34c, 0x128, 0x27d, 0x3d6, 0x09f, 0x1f4, 0x34d,
|
||||||
|
0x129, 0x27e, 0x3d7, 0x0a0, 0x1f5, 0x34e, 0x12a, 0x27f, 0x3d8, 0x0a1, 0x1f6, 0x34f, 0x12b, 0x280, 0x3d9, 0x0a2, 0x1f7,
|
||||||
|
0x350, 0x12c, 0x281, 0x3da, 0x0a3, 0x1f8, 0x351, 0x12d, 0x282, 0x3db, 0x0a4, 0x1f9, 0x352, 0x12e, 0x283, 0x3dc, 0x0a5,
|
||||||
|
0x1fa, 0x353, 0x12f, 0x284, 0x3dd, 0x0a6, 0x1fb, 0x354, 0x130, 0x285, 0x3de, 0x0a7, 0x1fc, 0x355, 0x131, 0x286, 0x3df,
|
||||||
|
0x0a8, 0x1fd, 0x356, 0x132,
|
||||||
|
];
|
||||||
|
|
||||||
|
//prettier-ignore: uint16, size 1024 (64x16)
|
||||||
|
const x3mScrambleTable = [
|
||||||
|
0x256, 0x28d, 0x213, 0x307, 0x156, 0x39d, 0x062, 0x170, 0x3ca, 0x035, 0x0ed, 0x2a4, 0x1e4, 0x359, 0x0d3, 0x26b, 0x265,
|
||||||
|
0x274, 0x251, 0x297, 0x202, 0x322, 0x126, 0x32b, 0x117, 0x302, 0x15c, 0x3a8, 0x057, 0x148, 0x380, 0x090, 0x1f6, 0x335,
|
||||||
|
0x10c, 0x2ee, 0x175, 0x3d4, 0x02b, 0x0cc, 0x260, 0x27b, 0x23d, 0x2bb, 0x1b6, 0x3a1, 0x05e, 0x157, 0x39e, 0x061, 0x16f,
|
||||||
|
0x3c6, 0x039, 0x0f7, 0x2b9, 0x1b8, 0x39f, 0x060, 0x166, 0x3b9, 0x046, 0x122, 0x31c, 0x12f, 0x33d, 0x0fc, 0x2ca, 0x1a4,
|
||||||
|
0x3cc, 0x033, 0x0e6, 0x293, 0x209, 0x315, 0x13d, 0x358, 0x0d5, 0x26e, 0x25e, 0x27d, 0x23a, 0x2c0, 0x1b1, 0x3af, 0x050,
|
||||||
|
0x136, 0x346, 0x0ef, 0x2aa, 0x1ce, 0x376, 0x0a0, 0x210, 0x30c, 0x14c, 0x389, 0x082, 0x1db, 0x367, 0x0b9, 0x23e, 0x2ba,
|
||||||
|
0x1b7, 0x3a0, 0x05f, 0x164, 0x3b7, 0x048, 0x125, 0x326, 0x11c, 0x30a, 0x14f, 0x38f, 0x070, 0x1a8, 0x3c7, 0x038, 0x0f5,
|
||||||
|
0x2b5, 0x1bd, 0x393, 0x06c, 0x199, 0x3e1, 0x01e, 0x0b3, 0x22f, 0x2d7, 0x193, 0x3ea, 0x015, 0x09d, 0x20a, 0x314, 0x13e,
|
||||||
|
0x35a, 0x0d2, 0x26a, 0x267, 0x272, 0x253, 0x294, 0x208, 0x319, 0x137, 0x34c, 0x0e7, 0x295, 0x205, 0x31d, 0x12e, 0x33c,
|
||||||
|
0x0fe, 0x2cd, 0x1a0, 0x3d5, 0x02a, 0x0c8, 0x258, 0x286, 0x22a, 0x2dc, 0x18e, 0x3f7, 0x008, 0x07c, 0x1d3, 0x370, 0x0a7,
|
||||||
|
0x21d, 0x2f1, 0x171, 0x3cd, 0x032, 0x0e5, 0x292, 0x20b, 0x313, 0x13f, 0x35c, 0x0d0, 0x266, 0x273, 0x252, 0x296, 0x204,
|
||||||
|
0x31f, 0x12a, 0x332, 0x10f, 0x2f4, 0x16c, 0x3c3, 0x03c, 0x101, 0x2d2, 0x19a, 0x3e0, 0x01f, 0x0b5, 0x233, 0x2c9, 0x1a6,
|
||||||
|
0x3c9, 0x036, 0x0f0, 0x2ab, 0x1cb, 0x37c, 0x095, 0x1fd, 0x328, 0x11a, 0x306, 0x158, 0x3a2, 0x05d, 0x155, 0x39c, 0x063,
|
||||||
|
0x174, 0x3d3, 0x02c, 0x0cf, 0x264, 0x275, 0x24f, 0x299, 0x1fa, 0x32c, 0x115, 0x2ff, 0x15f, 0x3ab, 0x054, 0x143, 0x36c,
|
||||||
|
0x0ad, 0x225, 0x2e5, 0x181, 0x3ef, 0x010, 0x08c, 0x1f1, 0x344, 0x0f3, 0x2af, 0x1c4, 0x386, 0x088, 0x1e3, 0x35b, 0x0d1,
|
||||||
|
0x269, 0x268, 0x26d, 0x25f, 0x27c, 0x23b, 0x2bf, 0x1b2, 0x3ae, 0x051, 0x13b, 0x355, 0x0da, 0x278, 0x248, 0x2a6, 0x1dc,
|
||||||
|
0x365, 0x0c0, 0x246, 0x2a8, 0x1d6, 0x36d, 0x0ac, 0x224, 0x2e8, 0x17e, 0x3eb, 0x014, 0x09c, 0x207, 0x31a, 0x133, 0x341,
|
||||||
|
0x0f8, 0x2bc, 0x1b5, 0x3a3, 0x05c, 0x152, 0x395, 0x06a, 0x18c, 0x3f9, 0x006, 0x07a, 0x1d1, 0x373, 0x0a4, 0x217, 0x2fe,
|
||||||
|
0x160, 0x3ad, 0x052, 0x13c, 0x357, 0x0d7, 0x270, 0x25c, 0x281, 0x235, 0x2c6, 0x1aa, 0x3bc, 0x043, 0x11d, 0x30d, 0x14a,
|
||||||
|
0x384, 0x08a, 0x1e7, 0x353, 0x0dd, 0x284, 0x22e, 0x2d8, 0x192, 0x3ec, 0x013, 0x099, 0x201, 0x323, 0x124, 0x321, 0x127,
|
||||||
|
0x32d, 0x114, 0x2fd, 0x161, 0x3b0, 0x04f, 0x135, 0x343, 0x0f4, 0x2b4, 0x1be, 0x392, 0x06d, 0x19d, 0x3db, 0x024, 0x0be,
|
||||||
|
0x244, 0x2b0, 0x1c2, 0x38a, 0x080, 0x1d9, 0x369, 0x0b6, 0x234, 0x2c8, 0x1a7, 0x3c8, 0x037, 0x0f1, 0x2ad, 0x1c6, 0x383,
|
||||||
|
0x08d, 0x1f2, 0x33b, 0x100, 0x2d1, 0x19b, 0x3de, 0x021, 0x0bb, 0x240, 0x2b6, 0x1bb, 0x399, 0x066, 0x17a, 0x3df, 0x020,
|
||||||
|
0x0b8, 0x23c, 0x2bd, 0x1b4, 0x3a5, 0x05a, 0x150, 0x390, 0x06f, 0x1a5, 0x3cb, 0x034, 0x0ea, 0x29d, 0x1ee, 0x348, 0x0ec,
|
||||||
|
0x2a3, 0x1e5, 0x356, 0x0d8, 0x271, 0x257, 0x289, 0x220, 0x2ec, 0x178, 0x3d9, 0x026, 0x0c2, 0x24b, 0x2a1, 0x1ea, 0x34d,
|
||||||
|
0x0e4, 0x291, 0x20c, 0x312, 0x141, 0x360, 0x0ca, 0x25a, 0x283, 0x230, 0x2d0, 0x19c, 0x3dd, 0x022, 0x0bc, 0x241, 0x2b3,
|
||||||
|
0x1bf, 0x391, 0x06e, 0x1a2, 0x3d1, 0x02e, 0x0d6, 0x26f, 0x25d, 0x27f, 0x237, 0x2c4, 0x1ac, 0x3ba, 0x045, 0x121, 0x318,
|
||||||
|
0x138, 0x34e, 0x0e3, 0x28f, 0x211, 0x30b, 0x14d, 0x38c, 0x073, 0x1c3, 0x387, 0x084, 0x1df, 0x362, 0x0c7, 0x255, 0x28e,
|
||||||
|
0x212, 0x309, 0x153, 0x396, 0x069, 0x18b, 0x3fa, 0x005, 0x079, 0x1d0, 0x374, 0x0a2, 0x215, 0x301, 0x15d, 0x3a9, 0x056,
|
||||||
|
0x147, 0x37a, 0x098, 0x200, 0x324, 0x11f, 0x316, 0x13a, 0x352, 0x0df, 0x288, 0x223, 0x2e9, 0x17d, 0x3e9, 0x016, 0x09e,
|
||||||
|
0x20d, 0x310, 0x144, 0x372, 0x0a5, 0x219, 0x2fa, 0x165, 0x3b8, 0x047, 0x123, 0x31e, 0x12d, 0x338, 0x107, 0x2e0, 0x188,
|
||||||
|
0x3fe, 0x001, 0x075, 0x1c9, 0x37e, 0x093, 0x1f9, 0x32f, 0x112, 0x2f9, 0x167, 0x3be, 0x041, 0x10b, 0x2e7, 0x17f, 0x3ed,
|
||||||
|
0x012, 0x097, 0x1ff, 0x325, 0x11e, 0x311, 0x142, 0x366, 0x0ba, 0x23f, 0x2b8, 0x1b9, 0x39b, 0x064, 0x176, 0x3d6, 0x029,
|
||||||
|
0x0c5, 0x250, 0x298, 0x1fc, 0x329, 0x119, 0x304, 0x15a, 0x3a6, 0x059, 0x14e, 0x38e, 0x071, 0x1ad, 0x3b6, 0x049, 0x128,
|
||||||
|
0x32e, 0x113, 0x2fc, 0x162, 0x3b2, 0x04d, 0x131, 0x33f, 0x0fa, 0x2c2, 0x1af, 0x3b3, 0x04c, 0x130, 0x33e, 0x0fb, 0x2c7,
|
||||||
|
0x1a9, 0x3bd, 0x042, 0x116, 0x300, 0x15e, 0x3aa, 0x055, 0x146, 0x378, 0x09b, 0x206, 0x31b, 0x132, 0x340, 0x0f9, 0x2be,
|
||||||
|
0x1b3, 0x3ac, 0x053, 0x140, 0x35d, 0x0ce, 0x262, 0x279, 0x247, 0x2a7, 0x1d7, 0x36b, 0x0ae, 0x226, 0x2e3, 0x185, 0x3f6,
|
||||||
|
0x009, 0x07d, 0x1d4, 0x36f, 0x0a8, 0x21e, 0x2f0, 0x172, 0x3ce, 0x031, 0x0de, 0x287, 0x228, 0x2df, 0x189, 0x3fd, 0x002,
|
||||||
|
0x076, 0x1ca, 0x37d, 0x094, 0x1fb, 0x32a, 0x118, 0x303, 0x15b, 0x3a7, 0x058, 0x14b, 0x388, 0x083, 0x1dd, 0x364, 0x0c1,
|
||||||
|
0x24a, 0x2a2, 0x1e9, 0x350, 0x0e1, 0x28b, 0x21a, 0x2f8, 0x168, 0x3bf, 0x040, 0x10a, 0x2e6, 0x180, 0x3ee, 0x011, 0x091,
|
||||||
|
0x1f7, 0x334, 0x10d, 0x2ef, 0x173, 0x3cf, 0x030, 0x0dc, 0x280, 0x236, 0x2c5, 0x1ab, 0x3bb, 0x044, 0x120, 0x317, 0x139,
|
||||||
|
0x34f, 0x0e2, 0x28c, 0x218, 0x2fb, 0x163, 0x3b4, 0x04b, 0x12c, 0x337, 0x108, 0x2e2, 0x186, 0x3fc, 0x003, 0x077, 0x1cc,
|
||||||
|
0x37b, 0x096, 0x1fe, 0x327, 0x11b, 0x308, 0x154, 0x397, 0x068, 0x183, 0x3f3, 0x00c, 0x085, 0x1e0, 0x361, 0x0c9, 0x259,
|
||||||
|
0x285, 0x22c, 0x2da, 0x190, 0x3f2, 0x00d, 0x086, 0x1e1, 0x35f, 0x0cb, 0x25b, 0x282, 0x232, 0x2cc, 0x1a1, 0x3d2, 0x02d,
|
||||||
|
0x0d4, 0x26c, 0x263, 0x277, 0x249, 0x2a5, 0x1de, 0x363, 0x0c6, 0x254, 0x290, 0x20e, 0x30f, 0x145, 0x377, 0x09f, 0x20f,
|
||||||
|
0x30e, 0x149, 0x382, 0x08e, 0x1f3, 0x33a, 0x102, 0x2d3, 0x198, 0x3e2, 0x01d, 0x0b2, 0x22d, 0x2d9, 0x191, 0x3f1, 0x00e,
|
||||||
|
0x087, 0x1e2, 0x35e, 0x0cd, 0x261, 0x27a, 0x243, 0x2b1, 0x1c1, 0x38b, 0x07f, 0x1d8, 0x36a, 0x0b4, 0x231, 0x2cf, 0x19e,
|
||||||
|
0x3da, 0x025, 0x0bf, 0x245, 0x2ac, 0x1c7, 0x381, 0x08f, 0x1f5, 0x336, 0x109, 0x2e4, 0x184, 0x3f4, 0x00b, 0x081, 0x1da,
|
||||||
|
0x368, 0x0b7, 0x239, 0x2c1, 0x1b0, 0x3b1, 0x04e, 0x134, 0x342, 0x0f6, 0x2b7, 0x1ba, 0x39a, 0x065, 0x179, 0x3dc, 0x023,
|
||||||
|
0x0bd, 0x242, 0x2b2, 0x1c0, 0x38d, 0x072, 0x1bc, 0x398, 0x067, 0x182, 0x3f0, 0x00f, 0x08b, 0x1e8, 0x351, 0x0e0, 0x28a,
|
||||||
|
0x21c, 0x2f3, 0x16d, 0x3c4, 0x03b, 0x0ff, 0x2ce, 0x19f, 0x3d7, 0x028, 0x0c4, 0x24e, 0x29b, 0x1f0, 0x345, 0x0f2, 0x2ae,
|
||||||
|
0x1c5, 0x385, 0x089, 0x1e6, 0x354, 0x0db, 0x27e, 0x238, 0x2c3, 0x1ae, 0x3b5, 0x04a, 0x12b, 0x333, 0x10e, 0x2f2, 0x16e,
|
||||||
|
0x3c5, 0x03a, 0x0fd, 0x2cb, 0x1a3, 0x3d0, 0x02f, 0x0d9, 0x276, 0x24c, 0x29f, 0x1ec, 0x34a, 0x0e9, 0x29c, 0x1ef, 0x347,
|
||||||
|
0x0ee, 0x2a9, 0x1cf, 0x375, 0x0a1, 0x214, 0x305, 0x159, 0x3a4, 0x05b, 0x151, 0x394, 0x06b, 0x196, 0x3e6, 0x019, 0x0ab,
|
||||||
|
0x222, 0x2ea, 0x17c, 0x3e5, 0x01a, 0x0af, 0x227, 0x2e1, 0x187, 0x3ff, 0x000, 0x074, 0x1c8, 0x37f, 0x092, 0x1f8, 0x331,
|
||||||
|
0x110, 0x2f6, 0x16a, 0x3c1, 0x03e, 0x104, 0x2d5, 0x195, 0x3e7, 0x018, 0x0aa, 0x221, 0x2eb, 0x17b, 0x3e4, 0x01b, 0x0b0,
|
||||||
|
0x229, 0x2dd, 0x18d, 0x3f8, 0x007, 0x07b, 0x1d2, 0x371, 0x0a6, 0x21b, 0x2f5, 0x16b, 0x3c2, 0x03d, 0x103, 0x2d4, 0x197,
|
||||||
|
0x3e3, 0x01c, 0x0b1, 0x22b, 0x2db, 0x18f, 0x3f5, 0x00a, 0x07e, 0x1d5, 0x36e, 0x0a9, 0x21f, 0x2ed, 0x177, 0x3d8, 0x027,
|
||||||
|
0x0c3, 0x24d, 0x29e, 0x1ed, 0x349, 0x0eb, 0x2a0, 0x1eb, 0x34b, 0x0e8, 0x29a, 0x1f4, 0x339, 0x106, 0x2de, 0x18a, 0x3fb,
|
||||||
|
0x004, 0x078, 0x1cd, 0x379, 0x09a, 0x203, 0x320, 0x129, 0x330, 0x111, 0x2f7, 0x169, 0x3c0, 0x03f, 0x105, 0x2d6, 0x194,
|
||||||
|
0x3e8, 0x017, 0x0a3, 0x216,
|
||||||
|
];
|
@ -1,66 +1,67 @@
|
|||||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
|
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
|
||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
import {AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile} from "@/decrypt/utils.ts";
|
import { AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile } from '@/decrypt/utils';
|
||||||
|
|
||||||
import {parseBlob as metaParseBlob} from "music-metadata-browser";
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
|
|
||||||
const MagicHeader = [0x69, 0x66, 0x6D, 0x74]
|
const MagicHeader = [0x69, 0x66, 0x6d, 0x74];
|
||||||
const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe]
|
const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe];
|
||||||
const FileTypeMap: { [key: string]: string } = {
|
const FileTypeMap: { [key: string]: string } = {
|
||||||
" WAV": ".wav",
|
' WAV': '.wav',
|
||||||
"FLAC": ".flac",
|
FLAC: '.flac',
|
||||||
" MP3": ".mp3",
|
' MP3': '.mp3',
|
||||||
" A4M": ".m4a",
|
' A4M': '.m4a',
|
||||||
}
|
};
|
||||||
|
|
||||||
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
||||||
if (!BytesHasPrefix(oriData, MagicHeader) || !BytesHasPrefix(oriData.slice(8, 12), MagicHeader2)) {
|
if (!BytesHasPrefix(oriData, MagicHeader) || !BytesHasPrefix(oriData.slice(8, 12), MagicHeader2)) {
|
||||||
if (raw_ext === "xm") {
|
if (raw_ext === 'xm') {
|
||||||
throw Error("此xm文件已损坏")
|
throw Error('此xm文件已损坏');
|
||||||
} else {
|
} else {
|
||||||
return await RawDecrypt(file, raw_filename, raw_ext, true)
|
return await RawDecrypt(file, raw_filename, raw_ext, true);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let typeText = (new TextDecoder()).decode(oriData.slice(4, 8))
|
let typeText = new TextDecoder().decode(oriData.slice(4, 8));
|
||||||
if (!FileTypeMap.hasOwnProperty(typeText)) {
|
if (!FileTypeMap.hasOwnProperty(typeText)) {
|
||||||
throw Error("未知的.xm文件类型")
|
throw Error('未知的.xm文件类型');
|
||||||
}
|
}
|
||||||
|
|
||||||
let key = oriData[0xf]
|
let key = oriData[0xf];
|
||||||
let dataOffset = oriData[0xc] | oriData[0xd] << 8 | oriData[0xe] << 16
|
let dataOffset = oriData[0xc] | (oriData[0xd] << 8) | (oriData[0xe] << 16);
|
||||||
let audioData = oriData.slice(0x10);
|
let audioData = oriData.slice(0x10);
|
||||||
let lenAudioData = audioData.length;
|
let lenAudioData = audioData.length;
|
||||||
for (let cur = dataOffset; cur < lenAudioData; ++cur)
|
for (let cur = dataOffset; cur < lenAudioData; ++cur) audioData[cur] = (audioData[cur] - key) ^ 0xff;
|
||||||
audioData[cur] = (audioData[cur] - key) ^ 0xff;
|
|
||||||
|
|
||||||
const ext = FileTypeMap[typeText];
|
const ext = FileTypeMap[typeText];
|
||||||
const mime = AudioMimeType[ext];
|
const mime = AudioMimeType[ext];
|
||||||
let musicBlob = new Blob([audioData], {type: mime});
|
let musicBlob = new Blob([audioData], { type: mime });
|
||||||
|
|
||||||
const musicMeta = await metaParseBlob(musicBlob);
|
const musicMeta = await metaParseBlob(musicBlob);
|
||||||
if (ext === "wav") {
|
if (ext === 'wav') {
|
||||||
//todo:未知的编码方式
|
//todo:未知的编码方式
|
||||||
console.info(musicMeta.common)
|
console.info(musicMeta.common);
|
||||||
musicMeta.common.album = "";
|
musicMeta.common.album = '';
|
||||||
musicMeta.common.artist = "";
|
musicMeta.common.artist = '';
|
||||||
musicMeta.common.title = "";
|
musicMeta.common.title = '';
|
||||||
}
|
}
|
||||||
const {title, artist} = GetMetaFromFile(raw_filename,
|
const { title, artist } = GetMetaFromFile(
|
||||||
musicMeta.common.title, musicMeta.common.artist,
|
raw_filename,
|
||||||
raw_filename.indexOf("_") === -1 ? "-" : "_")
|
musicMeta.common.title,
|
||||||
|
String(musicMeta.common.artists || musicMeta.common.artist || ""),
|
||||||
|
raw_filename.indexOf('_') === -1 ? '-' : '_',
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title,
|
title,
|
||||||
artist,
|
artist,
|
||||||
ext,
|
ext,
|
||||||
mime,
|
mime,
|
||||||
album: musicMeta.common.album,
|
album: musicMeta.common.album,
|
||||||
picture: GetCoverFromFile(musicMeta),
|
picture: GetCoverFromFile(musicMeta),
|
||||||
file: URL.createObjectURL(musicBlob),
|
file: URL.createObjectURL(musicBlob),
|
||||||
blob: musicBlob,
|
blob: musicBlob,
|
||||||
rawExt: "xm"
|
rawExt: 'xm',
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,5 +1,2 @@
|
|||||||
const bs = chrome || browser
|
const bs = chrome || browser;
|
||||||
bs.tabs.create({
|
bs.tabs.create({ url: bs.runtime.getURL('./index.html') }, (tab) => console.log(tab));
|
||||||
url: bs.runtime.getURL('./index.html')
|
|
||||||
}, tab => console.log(tab))
|
|
||||||
|
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
//TODO: Use other method to fix this
|
|
||||||
// !! Only Temporary Solution
|
|
||||||
// it seems like that @babel/plugin-proposal-object-rest-spread not working
|
|
||||||
// to fix up the compatibility for Edge 18 and some older Chromium
|
|
||||||
// now manually edit the dependency files
|
|
||||||
|
|
||||||
const fs = require('fs');
|
|
||||||
const filePath = "./node_modules/file-type/core.js";
|
|
||||||
const regReplace = /{\s*([a-zA-Z0-9:,\s]*),\s*\.\.\.([a-zA-Z0-9]*)\s*};/m;
|
|
||||||
if (fs.existsSync(filePath)) {
|
|
||||||
console.log("File Found!");
|
|
||||||
let data = fs.readFileSync(filePath).toString();
|
|
||||||
const regResult = regReplace.exec(data);
|
|
||||||
if (regResult != null) {
|
|
||||||
data = data.replace(regResult[0],
|
|
||||||
"Object.assign({ " + regResult[1] + " }, " + regResult[2] + ");"
|
|
||||||
);
|
|
||||||
fs.writeFileSync(filePath, data);
|
|
||||||
console.log("Object rest spread in file-type fixed!");
|
|
||||||
} else {
|
|
||||||
console.log("No fix needed.");
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log("File Not Found!");
|
|
||||||
}
|
|
52
src/main.ts
52
src/main.ts
@ -1,31 +1,39 @@
|
|||||||
import Vue from 'vue'
|
import Vue from 'vue';
|
||||||
import App from '@/App.vue'
|
import App from '@/App.vue';
|
||||||
import '@/registerServiceWorker'
|
import '@/registerServiceWorker';
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Col,
|
Col,
|
||||||
Container,
|
Container,
|
||||||
Footer,
|
Dialog,
|
||||||
Icon,
|
Form,
|
||||||
Image,
|
FormItem,
|
||||||
Link,
|
Footer,
|
||||||
Main,
|
Icon,
|
||||||
Notification,
|
Image,
|
||||||
Progress,
|
Input,
|
||||||
Radio,
|
Link,
|
||||||
Row,
|
Main,
|
||||||
Table,
|
Notification,
|
||||||
TableColumn,
|
Progress,
|
||||||
Tooltip,
|
Radio,
|
||||||
Upload,
|
Row,
|
||||||
MessageBox
|
Table,
|
||||||
|
TableColumn,
|
||||||
|
Tooltip,
|
||||||
|
Upload,
|
||||||
|
MessageBox,
|
||||||
} from 'element-ui';
|
} from 'element-ui';
|
||||||
import 'element-ui/lib/theme-chalk/base.css';
|
import 'element-ui/lib/theme-chalk/base.css';
|
||||||
|
|
||||||
Vue.use(Link);
|
Vue.use(Link);
|
||||||
Vue.use(Image);
|
Vue.use(Image);
|
||||||
Vue.use(Button);
|
Vue.use(Button);
|
||||||
|
Vue.use(Dialog);
|
||||||
|
Vue.use(Form);
|
||||||
|
Vue.use(FormItem);
|
||||||
|
Vue.use(Input);
|
||||||
Vue.use(Table);
|
Vue.use(Table);
|
||||||
Vue.use(TableColumn);
|
Vue.use(TableColumn);
|
||||||
Vue.use(Main);
|
Vue.use(Main);
|
||||||
@ -44,5 +52,5 @@ Vue.prototype.$confirm = MessageBox.confirm;
|
|||||||
|
|
||||||
Vue.config.productionTip = false;
|
Vue.config.productionTip = false;
|
||||||
new Vue({
|
new Vue({
|
||||||
render: h => h(App),
|
render: (h) => h(App),
|
||||||
}).$mount('#app');
|
}).$mount('#app');
|
||||||
|
@ -1,31 +1,30 @@
|
|||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
|
|
||||||
import {register} from 'register-service-worker'
|
import { register } from 'register-service-worker';
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production' && window.location.protocol === "https:") {
|
if (process.env.NODE_ENV === 'production' && window.location.protocol === 'https:') {
|
||||||
|
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
ready() {
|
||||||
ready() {
|
console.log('App is being served from cache by a service worker.');
|
||||||
console.log('App is being served from cache by a service worker.')
|
},
|
||||||
},
|
registered() {
|
||||||
registered() {
|
console.log('Service worker has been registered.');
|
||||||
console.log('Service worker has been registered.')
|
},
|
||||||
},
|
cached() {
|
||||||
cached() {
|
console.log('Content has been cached for offline use.');
|
||||||
console.log('Content has been cached for offline use.')
|
},
|
||||||
},
|
updatefound() {
|
||||||
updatefound() {
|
console.log('New content is downloading.');
|
||||||
console.log('New content is downloading.')
|
},
|
||||||
},
|
updated() {
|
||||||
updated() {
|
console.log('New content is available.');
|
||||||
console.log('New content is available.');
|
window.location.reload();
|
||||||
window.location.reload();
|
},
|
||||||
},
|
offline() {
|
||||||
offline() {
|
console.log('No internet connection found. App is running in offline mode.');
|
||||||
console.log('No internet connection found. App is running in offline mode.')
|
},
|
||||||
},
|
error(error) {
|
||||||
error(error) {
|
console.error('Error during service worker registration:', error);
|
||||||
console.error('Error during service worker registration:', error)
|
},
|
||||||
}
|
});
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,5 @@
|
|||||||
/*
|
/*
|
||||||
* name: 样式 - 夜间模式
|
* 样式 - 暗黑模式
|
||||||
* author: @KyleBing
|
|
||||||
* date: 2020-11-24
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
@ -12,155 +10,57 @@
|
|||||||
background-color: $dark-bg;
|
background-color: $dark-bg;
|
||||||
}
|
}
|
||||||
|
|
||||||
// FORM
|
// 编辑歌曲信息
|
||||||
.el-radio{
|
.music-cover{
|
||||||
&__label{
|
i{
|
||||||
color: $dark-text-main;
|
|
||||||
}
|
|
||||||
&__input{
|
|
||||||
color: $dark-text-info;
|
|
||||||
.el-radio__inner{
|
|
||||||
border-color: $dark-border;
|
|
||||||
background-color: $dark-btn-bg;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
&.is-checked{
|
|
||||||
.el-radio__inner{
|
|
||||||
background-color: $blue;
|
|
||||||
}
|
|
||||||
.el-radio__label{
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-checkbox.is-bordered{
|
|
||||||
border-color: $dark-border;
|
|
||||||
.el-checkbox__inner{
|
|
||||||
background-color: $dark-btn-bg;
|
|
||||||
border-color: $dark-border;
|
|
||||||
}
|
|
||||||
&:hover{
|
|
||||||
border-color: $dark-border-highlight;
|
|
||||||
.el-checkbox__inner{
|
|
||||||
background-color: $dark-btn-bg-highlight;
|
|
||||||
border-color: $dark-border-highlight;
|
|
||||||
}
|
|
||||||
.el-checkbox__label{
|
|
||||||
color: $dark-text-info;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&.is-checked{
|
|
||||||
background-color: $blue;
|
|
||||||
.el-checkbox__inner{
|
|
||||||
border-color: $dark-btn-bg-highlight;
|
|
||||||
}
|
|
||||||
.el-checkbox__label{
|
|
||||||
color: white;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// BUTTON
|
|
||||||
.el-button{
|
|
||||||
background-color: $dark-btn-bg;
|
|
||||||
border-color: $dark-border;
|
|
||||||
color: $dark-text-main;
|
|
||||||
|
|
||||||
&:active{
|
|
||||||
transform: translateY(2px);
|
|
||||||
}
|
|
||||||
|
|
||||||
&--default{
|
|
||||||
&.is-plain {
|
|
||||||
background-color: $dark-btn-bg;
|
|
||||||
&:hover {
|
|
||||||
background-color: $blue;
|
|
||||||
border-color: $blue;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
&--danger{
|
|
||||||
&.is-plain{
|
|
||||||
border-color: $dark-border;
|
|
||||||
background-color: $dark-btn-bg;
|
|
||||||
&:hover{
|
|
||||||
background-color: $red;
|
|
||||||
border-color: $red;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 文件拖放区
|
|
||||||
.el-upload__tip{
|
|
||||||
color: $dark-text-info;
|
|
||||||
}
|
|
||||||
.el-upload-dragger{
|
|
||||||
background-color: $dark-uploader-bg;
|
|
||||||
border-color: $dark-border;
|
|
||||||
.el-upload__text{
|
|
||||||
color: $dark-text-info;
|
|
||||||
}
|
|
||||||
&:hover{
|
|
||||||
background: $dark-uploader-bg-highlight;
|
|
||||||
border-color: $dark-border-highlight;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
//TABLE
|
|
||||||
.el-table{
|
|
||||||
background-color: $dark-bg-td;
|
|
||||||
&:before{ // 去除表格末尾的横线
|
|
||||||
content: none;
|
|
||||||
}
|
|
||||||
&__header{
|
|
||||||
th{
|
|
||||||
border-bottom-color: $dark-border !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
th{
|
|
||||||
background-color: $dark-bg-th;
|
|
||||||
color: $dark-text-info;
|
|
||||||
}
|
|
||||||
td{
|
|
||||||
border-bottom-color: $dark-border !important;
|
|
||||||
}
|
|
||||||
tr{
|
|
||||||
background-color: $dark-bg-td;
|
|
||||||
color: $dark-text-main;
|
|
||||||
&:hover{
|
&:hover{
|
||||||
td{
|
color: $color-checkbox;
|
||||||
background-color: $dark-bg-th !important;
|
}
|
||||||
}
|
}
|
||||||
|
.el-image{
|
||||||
|
border: 1px solid $dark-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.edit-item{
|
||||||
|
.label{
|
||||||
|
}
|
||||||
|
.value{
|
||||||
|
}
|
||||||
|
.input{
|
||||||
|
input{
|
||||||
|
background-color: transparent !important;
|
||||||
|
border-bottom: 1px solid $dark-border;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i{
|
||||||
|
&:hover{
|
||||||
|
color: $color-checkbox;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// LINKS
|
// footer
|
||||||
a{
|
#app-footer {
|
||||||
text-decoration: none;
|
a {
|
||||||
color: darken($dark-color-link, 15%);
|
color: lighten($text-comment, 5%);
|
||||||
&:hover{
|
&:hover{
|
||||||
color: $dark-color-link;
|
color: $color-link;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ALERT
|
// 自定义样式
|
||||||
.el-notification{
|
// 首页弹窗提示信息的 更新信息 面板
|
||||||
background-color: $dark-btn-bg-highlight;
|
.update-info{
|
||||||
border-color: $dark-border;
|
border: 1px solid $dark-btn-bg !important;
|
||||||
&__title{
|
.update-title{
|
||||||
color: white;
|
color: $dark-text-main;
|
||||||
|
background-color: $dark-btn-bg !important;
|
||||||
}
|
}
|
||||||
&__content{
|
.update-content{
|
||||||
color: $dark-text-info;
|
color: $dark-text-info;
|
||||||
|
padding: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
291
src/scss/_element-ui-overwrite.scss
Normal file
291
src/scss/_element-ui-overwrite.scss
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
$color-checkbox: $blue;
|
||||||
|
$color-border-el: #DCDFE6;
|
||||||
|
|
||||||
|
$btn-radius: 6px;
|
||||||
|
|
||||||
|
/* FORM */
|
||||||
|
// checkbox
|
||||||
|
.el-checkbox.is-bordered{
|
||||||
|
@include border-radius($btn-radius) ;
|
||||||
|
&:hover{
|
||||||
|
border-color: $color-checkbox;
|
||||||
|
.el-checkbox__label{
|
||||||
|
color: $color-checkbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-checkbox__input.is-focus{
|
||||||
|
.el-checkbox__inner{
|
||||||
|
border-color: $color-border-el;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.is-checked{
|
||||||
|
background-color: $color-checkbox;
|
||||||
|
.el-checkbox__label{
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
.el-checkbox__inner{
|
||||||
|
border-color: white;
|
||||||
|
background-color: white;
|
||||||
|
&:after{
|
||||||
|
border-color: $color-checkbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// el-button
|
||||||
|
.el-button{
|
||||||
|
@include border-radius($btn-radius) ;
|
||||||
|
}
|
||||||
|
|
||||||
|
// upload
|
||||||
|
.el-upload-dragger{
|
||||||
|
&:hover{
|
||||||
|
background-color: transparentize($color-checkbox, 0.9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-upload__tip{
|
||||||
|
text-align: center;
|
||||||
|
color: $text-comment;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// dialog
|
||||||
|
.el-dialog{
|
||||||
|
@include border-radius(5px);
|
||||||
|
&.el-dialog--center{
|
||||||
|
.el-dialog__body{
|
||||||
|
padding: 25px 25px 15px;
|
||||||
|
}
|
||||||
|
.el-dialog__footer{
|
||||||
|
padding: 10px 20px 30px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
|
||||||
|
// FORM
|
||||||
|
.el-radio{
|
||||||
|
&__label{
|
||||||
|
color: $dark-text-main;
|
||||||
|
}
|
||||||
|
&__input{
|
||||||
|
color: $dark-text-info;
|
||||||
|
.el-radio__inner{
|
||||||
|
border-color: $dark-border;
|
||||||
|
background-color: $dark-btn-bg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-checked{
|
||||||
|
.el-radio__inner{
|
||||||
|
background-color: $blue;
|
||||||
|
}
|
||||||
|
.el-radio__label{
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-checkbox.is-bordered{
|
||||||
|
border-color: $dark-border;
|
||||||
|
color: $dark-text-main;
|
||||||
|
background-color: $dark-btn-bg;
|
||||||
|
.el-checkbox__inner{
|
||||||
|
background-color: $dark-btn-bg-highlight;
|
||||||
|
border-color: $dark-border-highlight;
|
||||||
|
}
|
||||||
|
&:hover{
|
||||||
|
border-color: $dark-border-highlight;
|
||||||
|
.el-checkbox__inner{
|
||||||
|
background-color: $dark-btn-bg-highlight;
|
||||||
|
border-color: $dark-border-highlight;
|
||||||
|
}
|
||||||
|
.el-checkbox__label{
|
||||||
|
color: $dark-text-info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.is-checked{
|
||||||
|
background-color: $blue;
|
||||||
|
.el-checkbox__inner{
|
||||||
|
border-color: white;
|
||||||
|
}
|
||||||
|
.el-checkbox__label{
|
||||||
|
color: white;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
&:hover{
|
||||||
|
border-color: $blue;
|
||||||
|
.el-checkbox__inner{
|
||||||
|
background-color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// BUTTON
|
||||||
|
.el-button{
|
||||||
|
background-color: $dark-btn-bg;
|
||||||
|
border-color: $dark-border;
|
||||||
|
color: $dark-text-main;
|
||||||
|
|
||||||
|
&:active{
|
||||||
|
transform: translateY(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--default{
|
||||||
|
&.is-plain {
|
||||||
|
background-color: $dark-btn-bg;
|
||||||
|
&:hover {
|
||||||
|
background-color: $blue;
|
||||||
|
border-color: $blue;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.is-circle {
|
||||||
|
background-color: $dark-blue;
|
||||||
|
border-color: $dark-blue;
|
||||||
|
&:hover {
|
||||||
|
background-color: $blue;
|
||||||
|
border-color: $blue;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&--success{
|
||||||
|
&.is-plain {
|
||||||
|
background-color: $dark-btn-bg;
|
||||||
|
&:hover {
|
||||||
|
background-color: $green;
|
||||||
|
border-color: $green;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.is-circle {
|
||||||
|
background-color: $dark-green;
|
||||||
|
border-color: $dark-green;
|
||||||
|
&:hover {
|
||||||
|
background-color: $green;
|
||||||
|
border-color: $green;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&--danger{
|
||||||
|
&.is-plain{
|
||||||
|
border-color: $dark-border;
|
||||||
|
background-color: $dark-btn-bg;
|
||||||
|
&:hover{
|
||||||
|
background-color: $red;
|
||||||
|
border-color: $red;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
&.is-circle {
|
||||||
|
background-color: $dark-red;
|
||||||
|
border-color: $dark-red;
|
||||||
|
&:hover {
|
||||||
|
background-color: $red;
|
||||||
|
border-color: $red;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 文件拖放区
|
||||||
|
.el-upload__tip{
|
||||||
|
color: $dark-text-info;
|
||||||
|
}
|
||||||
|
.el-upload-dragger{
|
||||||
|
background-color: $dark-uploader-bg;
|
||||||
|
border-color: $dark-border;
|
||||||
|
.el-upload__text{
|
||||||
|
color: $dark-text-info;
|
||||||
|
}
|
||||||
|
&:hover{
|
||||||
|
background: $dark-uploader-bg-highlight;
|
||||||
|
border-color: $dark-border-highlight;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TABLE
|
||||||
|
.el-table{
|
||||||
|
background-color: $dark-bg-td;
|
||||||
|
&:before{ // 去除表格末尾的横线
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
&__header{
|
||||||
|
th{
|
||||||
|
border-bottom-color: $dark-border !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
th.el-table__cell{
|
||||||
|
background-color: $dark-bg-th;
|
||||||
|
color: $dark-text-info;
|
||||||
|
}
|
||||||
|
td{
|
||||||
|
border-bottom-color: $dark-border !important;
|
||||||
|
}
|
||||||
|
tr{
|
||||||
|
background-color: $dark-bg-td;
|
||||||
|
color: $dark-text-main;
|
||||||
|
&:hover{
|
||||||
|
td{
|
||||||
|
background-color: $dark-bg-th !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// ALERT
|
||||||
|
.el-notification{
|
||||||
|
background-color: $dark-btn-bg-highlight;
|
||||||
|
border-color: $dark-border;
|
||||||
|
&__title{
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
&__content{
|
||||||
|
color: $dark-text-info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DIALOG
|
||||||
|
.el-dialog{
|
||||||
|
background-color: $dark-dialog-bg;
|
||||||
|
.el-dialog__header{
|
||||||
|
.el-dialog__title{
|
||||||
|
color: $dark-text-main;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-dialog__body{
|
||||||
|
color: $dark-text-main;
|
||||||
|
.el-input{
|
||||||
|
.el-input__inner{
|
||||||
|
border-color: $dark-border;
|
||||||
|
color: $dark-text-main;
|
||||||
|
background-color: $dark-btn-bg;
|
||||||
|
}
|
||||||
|
.el-input__suffix{
|
||||||
|
.el-input__suffix-inner{
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-input__count{
|
||||||
|
.el-input__count-inner{
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.item-desc{
|
||||||
|
color: $dark-text-info;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
$gap: 5px;
|
$gap: 5px;
|
||||||
@for $item from 1 through 7 {
|
@for $item from 0 through 8 {
|
||||||
.mt-#{$item} { margin-top : $gap * $item !important;}
|
.mt-#{$item} { margin-top : $gap * $item !important;}
|
||||||
.mb-#{$item} { margin-bottom : $gap * $item !important;}
|
.mb-#{$item} { margin-bottom : $gap * $item !important;}
|
||||||
.ml-#{$item} { margin-left : $gap * $item !important;}
|
.ml-#{$item} { margin-left : $gap * $item !important;}
|
||||||
|
@ -1,38 +0,0 @@
|
|||||||
body{
|
|
||||||
font-family: $font-family;
|
|
||||||
font-size: $fz-main;
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app {
|
|
||||||
text-align: center;
|
|
||||||
color: $text-main;
|
|
||||||
padding-top: 30px;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app-footer a {
|
|
||||||
padding-left: 0.2em;
|
|
||||||
padding-right: 0.2em;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app-footer {
|
|
||||||
text-align: center;
|
|
||||||
font-size: small;
|
|
||||||
}
|
|
||||||
|
|
||||||
#app-control {
|
|
||||||
padding-top: 1em;
|
|
||||||
padding-bottom: 1em;
|
|
||||||
}
|
|
||||||
|
|
||||||
audio{
|
|
||||||
margin-bottom: 15px; // 播放控件与表格间隔
|
|
||||||
}
|
|
||||||
|
|
||||||
a{
|
|
||||||
color: darken($color-link, 15%);
|
|
||||||
&:hover{
|
|
||||||
color: $color-link;
|
|
||||||
}
|
|
||||||
}
|
|
73
src/scss/_utility.scss
Normal file
73
src/scss/_utility.scss
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
// box-shadow
|
||||||
|
@mixin box-shadow($value...){
|
||||||
|
-webkit-box-shadow: $value;
|
||||||
|
-moz-box-shadow: $value;
|
||||||
|
box-shadow: $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
// border-radius
|
||||||
|
@mixin border-radius($corner...){
|
||||||
|
-webkit-border-radius: $corner;
|
||||||
|
-moz-border-radius: $corner;
|
||||||
|
border-radius: $corner;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin clearfix(){
|
||||||
|
&:after{
|
||||||
|
content: '';
|
||||||
|
display: block;
|
||||||
|
clear: both;
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin transform($value){
|
||||||
|
-webkit-transform: $value;
|
||||||
|
-moz-transform: $value;
|
||||||
|
-ms-transform: $value;
|
||||||
|
-o-transform: $value;
|
||||||
|
transform: $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin transition($value...){
|
||||||
|
-webkit-transition: $value;
|
||||||
|
-moz-transition: $value;
|
||||||
|
-ms-transition: $value;
|
||||||
|
-o-transition: $value;
|
||||||
|
transition: $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin animation($value){
|
||||||
|
animation: $value;
|
||||||
|
-webkit-animation: $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin linear-gradient($direct, $colors){
|
||||||
|
background: linear-gradient($direct, $colors);
|
||||||
|
background: -webkit-linear-gradient($direct, $colors);
|
||||||
|
background: -moz-linear-gradient($direct, $colors);
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin backdrop-filter($value){
|
||||||
|
backdrop-filter: $value ;
|
||||||
|
-webkit-backdrop-filter: $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/*
|
||||||
|
Extension
|
||||||
|
*/
|
||||||
|
|
||||||
|
.unselectable {
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-like{
|
||||||
|
cursor: pointer;
|
||||||
|
&:active{
|
||||||
|
@include transform(translateY(2px))
|
||||||
|
}
|
||||||
|
}
|
@ -3,15 +3,16 @@ $blue : #409EFF;
|
|||||||
$red : #F56C6C;
|
$red : #F56C6C;
|
||||||
$green : #85ce61;
|
$green : #85ce61;
|
||||||
|
|
||||||
// TEXT
|
// TEXT COLOR
|
||||||
$text-main : #2C3E50;
|
$text-main : #2C3E50;
|
||||||
$color-link: $blue;
|
$text-copyright : #777;
|
||||||
|
$text-comment : #999;
|
||||||
|
$color-link : $blue;
|
||||||
|
|
||||||
|
// FONT SIZE
|
||||||
$fz-main: 14px;
|
$fz-main: 14px;
|
||||||
|
$fz-mini-title: 13px;
|
||||||
$font-family: "Helvetica Neue", Helvetica, "PingFang SC",
|
$fz-mini-content: 12px;
|
||||||
"Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
|
|
||||||
|
|
||||||
|
|
||||||
// DARK MODE
|
// DARK MODE
|
||||||
$dark-border : lighten(black, 25%);
|
$dark-border : lighten(black, 25%);
|
||||||
@ -20,9 +21,14 @@ $dark-bg : lighten(black, 10%);
|
|||||||
$dark-text-main : lighten(black, 90%);
|
$dark-text-main : lighten(black, 90%);
|
||||||
$dark-text-info : lighten(black, 60%);
|
$dark-text-info : lighten(black, 60%);
|
||||||
$dark-uploader-bg : lighten(black, 13%);
|
$dark-uploader-bg : lighten(black, 13%);
|
||||||
|
$dark-dialog-bg : lighten(black, 15%);
|
||||||
$dark-uploader-bg-highlight : lighten(black, 18%);
|
$dark-uploader-bg-highlight : lighten(black, 18%);
|
||||||
$dark-btn-bg : lighten(black, 20%);
|
$dark-btn-bg : lighten(black, 20%);
|
||||||
$dark-btn-bg-highlight : lighten(black, 30%);
|
$dark-btn-bg-highlight : lighten(black, 30%);
|
||||||
$dark-bg-th : lighten(black, 18%);
|
$dark-bg-th : lighten(black, 18%);
|
||||||
$dark-bg-td : lighten(black, 13%);
|
$dark-bg-td : lighten(black, 13%);
|
||||||
$dark-color-link : $green;
|
$dark-color-link : $green;
|
||||||
|
|
||||||
|
$dark-blue : darken(desaturate($blue, 40%), 30%);
|
||||||
|
$dark-red : darken(desaturate($red, 50%), 30%);
|
||||||
|
$dark-green : darken(desaturate($green, 30%), 30%);
|
||||||
|
@ -1,5 +1,144 @@
|
|||||||
@import "variables";
|
@import "variables";
|
||||||
|
@import "utility";
|
||||||
@import "gaps";
|
@import "gaps";
|
||||||
|
@import "element-ui-overwrite";
|
||||||
|
|
||||||
|
// MAIN CONTENT
|
||||||
|
body{
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-family: "PingFang SC", "微软雅黑", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;
|
||||||
|
font-size: $fz-main;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
-moz-osx-font-smoothing: grayscale;
|
||||||
|
}
|
||||||
|
|
||||||
|
#app {
|
||||||
|
text-align: center;
|
||||||
|
color: $text-main;
|
||||||
|
padding: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频文件操作
|
||||||
|
#app-control {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 音频播放
|
||||||
|
audio{
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-content{
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑歌曲信息
|
||||||
|
.music-cover{
|
||||||
|
margin-bottom: 20px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
flex-flow: column nowrap;
|
||||||
|
i{
|
||||||
|
margin-top: 10px;
|
||||||
|
@extend .btn-like;
|
||||||
|
&:hover{
|
||||||
|
color: $color-checkbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.el-image{
|
||||||
|
padding: 5px;
|
||||||
|
@include border-radius(5px);
|
||||||
|
border: 1px solid $color-border-el;
|
||||||
|
width: 150px;
|
||||||
|
height: 150px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.edit-item{
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
align-items: center;
|
||||||
|
.label{
|
||||||
|
font-weight: bold;
|
||||||
|
width: 80px;
|
||||||
|
text-align: right;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.value{
|
||||||
|
padding: 5px 0;
|
||||||
|
height: 20px;
|
||||||
|
line-height: 20px;
|
||||||
|
margin-left: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
.input{
|
||||||
|
margin-left: 10px;
|
||||||
|
input{
|
||||||
|
font-family: inherit;
|
||||||
|
height: 30px;
|
||||||
|
line-height: 20px;
|
||||||
|
@include border-radius(0);
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px solid $color-border-el;
|
||||||
|
padding: 5px 5px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
i{
|
||||||
|
margin-left: 10px;
|
||||||
|
@extend .btn-like;
|
||||||
|
&:hover{
|
||||||
|
color: $color-checkbox;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tip{
|
||||||
|
margin-top: 20px;
|
||||||
|
color: $text-comment;
|
||||||
|
font-size: $fz-mini-content;
|
||||||
|
a{
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// footer
|
||||||
|
#app-footer {
|
||||||
|
margin-top: 40px;
|
||||||
|
text-align: center;
|
||||||
|
color: $text-copyright;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-size: $fz-mini-content;
|
||||||
|
a {
|
||||||
|
padding-left: 0.2rem;
|
||||||
|
padding-right: 0.2rem;
|
||||||
|
color: darken($text-copyright, 10%);
|
||||||
|
&:hover{
|
||||||
|
color: $color-link;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 首页弹窗提示信息的 更新信息 面板
|
||||||
|
.update-info{
|
||||||
|
@include border-radius(8px);
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid $color-border-el;
|
||||||
|
margin: 10px 0;
|
||||||
|
.update-title{
|
||||||
|
font-size: $fz-mini-title;
|
||||||
|
padding: 3px 10px;
|
||||||
|
background-color: $color-border-el;
|
||||||
|
}
|
||||||
|
.update-content{
|
||||||
|
font-size: $fz-mini-content;
|
||||||
|
line-height: 1.5;
|
||||||
|
padding: 5px 8px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@import "normal";
|
|
||||||
@import "dark-mode"; // dark-mode 放在 normal 后面,以获得更高优先级
|
@import "dark-mode"; // dark-mode 放在 normal 后面,以获得更高优先级
|
30
src/shims-browser-id3-writer.d.ts
vendored
30
src/shims-browser-id3-writer.d.ts
vendored
@ -1,25 +1,23 @@
|
|||||||
declare module "browser-id3-writer" {
|
declare module 'browser-id3-writer' {
|
||||||
export default class ID3Writer {
|
export default class ID3Writer {
|
||||||
constructor(buffer: Buffer | ArrayBuffer)
|
constructor(buffer: Buffer | ArrayBuffer);
|
||||||
|
|
||||||
setFrame(name: string, value: string | object | string[])
|
setFrame(name: string, value: string | object | string[]);
|
||||||
|
|
||||||
addTag(): Uint8Array
|
addTag(): Uint8Array;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
declare module "metaflac-js" {
|
declare module 'metaflac-js' {
|
||||||
export default class Metaflac {
|
export default class Metaflac {
|
||||||
constructor(buffer: Buffer)
|
constructor(buffer: Buffer);
|
||||||
|
|
||||||
setTag(field: string)
|
setTag(field: string);
|
||||||
|
|
||||||
removeTag(name: string)
|
removeTag(name: string);
|
||||||
|
|
||||||
importPictureFromBuffer(picture: Buffer)
|
importPictureFromBuffer(picture: Buffer);
|
||||||
|
|
||||||
save(): Buffer
|
save(): Buffer;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
49
src/shims-fs.d.ts
vendored
49
src/shims-fs.d.ts
vendored
@ -1,51 +1,54 @@
|
|||||||
export interface FileSystemGetFileOptions {
|
export interface FileSystemGetFileOptions {
|
||||||
create?: boolean
|
create?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileSystemCreateWritableOptions {
|
interface FileSystemCreateWritableOptions {
|
||||||
keepExistingData?: boolean
|
keepExistingData?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FileSystemRemoveOptions {
|
||||||
|
recursive?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface FileSystemFileHandle {
|
interface FileSystemFileHandle {
|
||||||
getFile(): Promise<File>;
|
getFile(): Promise<File>;
|
||||||
|
|
||||||
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>
|
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum WriteCommandType {
|
enum WriteCommandType {
|
||||||
write = "write",
|
write = 'write',
|
||||||
seek = "seek",
|
seek = 'seek',
|
||||||
truncate = "truncate",
|
truncate = 'truncate',
|
||||||
}
|
}
|
||||||
|
|
||||||
interface WriteParams {
|
interface WriteParams {
|
||||||
type: WriteCommandType
|
type: WriteCommandType;
|
||||||
size?: number
|
size?: number;
|
||||||
position?: number
|
position?: number;
|
||||||
data: BufferSource | Blob | string
|
data: BufferSource | Blob | string;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams
|
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams;
|
||||||
|
|
||||||
interface FileSystemWritableFileStream extends WritableStream {
|
interface FileSystemWritableFileStream extends WritableStream {
|
||||||
write(data: FileSystemWriteChunkType): Promise<undefined>
|
write(data: FileSystemWriteChunkType): Promise<undefined>;
|
||||||
|
|
||||||
seek(position: number): Promise<undefined>
|
seek(position: number): Promise<undefined>;
|
||||||
|
|
||||||
truncate(size: number): Promise<undefined>
|
truncate(size: number): Promise<undefined>;
|
||||||
|
|
||||||
close(): Promise<undefined> // should be implemented in WritableStream
|
close(): Promise<undefined>; // should be implemented in WritableStream
|
||||||
}
|
}
|
||||||
|
|
||||||
export declare interface FileSystemDirectoryHandle {
|
export declare interface FileSystemDirectoryHandle {
|
||||||
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>
|
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>;
|
||||||
|
|
||||||
|
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>;
|
||||||
}
|
}
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
interface Window {
|
interface Window {
|
||||||
FileSystemDirectoryHandle
|
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>;
|
||||||
|
}
|
||||||
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
10
src/shims-tsx.d.ts
vendored
10
src/shims-tsx.d.ts
vendored
@ -1,17 +1,15 @@
|
|||||||
import Vue, {VNode} from 'vue'
|
import Vue, { VNode } from 'vue';
|
||||||
|
|
||||||
declare global {
|
declare global {
|
||||||
namespace JSX {
|
namespace JSX {
|
||||||
// tslint:disable no-empty-interface
|
// tslint:disable no-empty-interface
|
||||||
interface Element extends VNode {
|
interface Element extends VNode {}
|
||||||
}
|
|
||||||
|
|
||||||
// tslint:disable no-empty-interface
|
// tslint:disable no-empty-interface
|
||||||
interface ElementClass extends Vue {
|
interface ElementClass extends Vue {}
|
||||||
}
|
|
||||||
|
|
||||||
interface IntrinsicElements {
|
interface IntrinsicElements {
|
||||||
[elem: string]: any
|
[elem: string]: any;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
4
src/shims-vue.d.ts
vendored
4
src/shims-vue.d.ts
vendored
@ -1,4 +1,4 @@
|
|||||||
declare module '*.vue' {
|
declare module '*.vue' {
|
||||||
import Vue from 'vue'
|
import Vue from 'vue';
|
||||||
export default Vue
|
export default Vue;
|
||||||
}
|
}
|
||||||
|
15
src/utils/MergeUint8Array.ts
Normal file
15
src/utils/MergeUint8Array.ts
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
export function MergeUint8Array(array: Uint8Array[]): Uint8Array {
|
||||||
|
let length = 0;
|
||||||
|
array.forEach((item) => {
|
||||||
|
length += item.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
let mergedArray = new Uint8Array(length);
|
||||||
|
let offset = 0;
|
||||||
|
array.forEach((item) => {
|
||||||
|
mergedArray.set(item, offset);
|
||||||
|
offset += item.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
return mergedArray;
|
||||||
|
}
|
1
src/utils/__mocks__/qm_meta.ts
Normal file
1
src/utils/__mocks__/qm_meta.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export const extractQQMusicMeta = jest.fn();
|
4
src/utils/__mocks__/storage.ts
Normal file
4
src/utils/__mocks__/storage.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
export const storage = {
|
||||||
|
loadJooxUUID: jest.fn(),
|
||||||
|
saveJooxUUID: jest.fn(),
|
||||||
|
};
|
145
src/utils/api.ts
145
src/utils/api.ts
@ -1,56 +1,113 @@
|
|||||||
import {fromByteArray as Base64Encode} from "base64-js";
|
export const IXAREA_API_ENDPOINT = 'https://um-api.ixarea.com';
|
||||||
|
|
||||||
export const IXAREA_API_ENDPOINT = "https://stats.ixarea.com/apis"
|
|
||||||
|
|
||||||
export interface UpdateInfo {
|
export interface UpdateInfo {
|
||||||
Found: boolean
|
Found: boolean;
|
||||||
HttpsFound: boolean
|
HttpsFound: boolean;
|
||||||
Version: string
|
Version: string;
|
||||||
URL: string
|
URL: string;
|
||||||
Detail: string
|
Detail: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function checkUpdate(version: string): Promise<UpdateInfo> {
|
export async function checkUpdate(version: string): Promise<UpdateInfo> {
|
||||||
const resp = await fetch(IXAREA_API_ENDPOINT + "/music/app-version", {
|
const resp = await fetch(IXAREA_API_ENDPOINT + '/music/app-version', {
|
||||||
method: "POST",
|
method: 'POST',
|
||||||
headers: {"Content-Type": "application/json"},
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({"Version": version})
|
body: JSON.stringify({ Version: version }),
|
||||||
});
|
});
|
||||||
return await resp.json();
|
return await resp.json();
|
||||||
}
|
|
||||||
|
|
||||||
export function reportKeyUsage(keyData: Uint8Array, maskData: number[], filename: string, format: string, title: string, artist?: string, album?: string) {
|
|
||||||
return fetch(IXAREA_API_ENDPOINT + "/qmcmask/usage", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify({
|
|
||||||
Mask: Base64Encode(new Uint8Array(maskData)), Key: Base64Encode(keyData),
|
|
||||||
Artist: artist, Title: title, Album: album, Filename: filename, Format: format
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
interface KeyInfo {
|
|
||||||
Matrix44: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function queryKeyInfo(keyData: Uint8Array, filename: string, format: string): Promise<KeyInfo> {
|
|
||||||
const resp = await fetch(IXAREA_API_ENDPOINT + "/qmcmask/query", {
|
|
||||||
method: "POST",
|
|
||||||
headers: {"Content-Type": "application/json"},
|
|
||||||
body: JSON.stringify({Format: format, Key: Base64Encode(keyData), Filename: filename, Type: 44}),
|
|
||||||
});
|
|
||||||
return await resp.json();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CoverInfo {
|
export interface CoverInfo {
|
||||||
Id: string
|
Id: string;
|
||||||
Type: number
|
Type: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> {
|
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> {
|
||||||
const endpoint = IXAREA_API_ENDPOINT + "/music/qq-cover"
|
const endpoint = IXAREA_API_ENDPOINT + '/music/qq-cover';
|
||||||
const params = new URLSearchParams([["Title", title], ["Artist", artist ?? ""], ["Album", album ?? ""]])
|
const params = new URLSearchParams([
|
||||||
const resp = await fetch(`${endpoint}?${params.toString()}`)
|
['Title', title],
|
||||||
return await resp.json()
|
['Artist', artist ?? ''],
|
||||||
|
['Album', album ?? ''],
|
||||||
|
]);
|
||||||
|
const resp = await fetch(`${endpoint}?${params.toString()}`);
|
||||||
|
return await resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TrackInfo {
|
||||||
|
id: number;
|
||||||
|
type: number;
|
||||||
|
mid: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
singer: {
|
||||||
|
id: number;
|
||||||
|
mid: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
type: number;
|
||||||
|
uin: number;
|
||||||
|
}[];
|
||||||
|
album: {
|
||||||
|
id: number;
|
||||||
|
mid: string;
|
||||||
|
name: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
time_public: string;
|
||||||
|
pmid: string;
|
||||||
|
};
|
||||||
|
interval: number;
|
||||||
|
index_cd: number;
|
||||||
|
index_album: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SongItemInfo {
|
||||||
|
title: string;
|
||||||
|
content: {
|
||||||
|
value: string;
|
||||||
|
}[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SongInfoResponse {
|
||||||
|
info: {
|
||||||
|
company: SongItemInfo;
|
||||||
|
genre: SongItemInfo;
|
||||||
|
intro: SongItemInfo;
|
||||||
|
lan: SongItemInfo;
|
||||||
|
pub_time: SongItemInfo;
|
||||||
|
};
|
||||||
|
extras: {
|
||||||
|
name: string;
|
||||||
|
transname: string;
|
||||||
|
subtitle: string;
|
||||||
|
from: string;
|
||||||
|
wikiurl: string;
|
||||||
|
};
|
||||||
|
track_info: TrackInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RawQMBatchResponse<T> {
|
||||||
|
code: number;
|
||||||
|
ts: number;
|
||||||
|
start_ts: number;
|
||||||
|
traceid: string;
|
||||||
|
req_1: {
|
||||||
|
code: number;
|
||||||
|
data: T;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function querySongInfoById(id: string | number): Promise<SongInfoResponse> {
|
||||||
|
const url = `${IXAREA_API_ENDPOINT}/meta/qq-music-raw/${id}`;
|
||||||
|
const result: RawQMBatchResponse<SongInfoResponse> = await fetch(url).then((r) => r.json());
|
||||||
|
if (result.code === 0 && result.req_1.code === 0) {
|
||||||
|
return result.req_1.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('请求信息失败');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getQMImageURLFromPMID(pmid: string, type = 1): string {
|
||||||
|
return `${IXAREA_API_ENDPOINT}/music/qq-cover/${type}/${pmid}`;
|
||||||
}
|
}
|
||||||
|
156
src/utils/qm_meta.ts
Normal file
156
src/utils/qm_meta.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import { IAudioMetadata, parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
|
import iconv from 'iconv-lite';
|
||||||
|
|
||||||
|
import {
|
||||||
|
GetCoverFromFile,
|
||||||
|
GetImageFromURL,
|
||||||
|
GetMetaFromFile,
|
||||||
|
WriteMetaToFlac,
|
||||||
|
WriteMetaToMp3,
|
||||||
|
AudioMimeType,
|
||||||
|
split_regex,
|
||||||
|
} from '@/decrypt/utils';
|
||||||
|
import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api';
|
||||||
|
|
||||||
|
interface MetaResult {
|
||||||
|
title: string;
|
||||||
|
artist: string;
|
||||||
|
album: string;
|
||||||
|
imgUrl: string;
|
||||||
|
blob: Blob;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fromGBK = (text?: string) => iconv.decode(new Buffer(text || ''), 'gbk');
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @param musicBlob 音乐文件(解密后)
|
||||||
|
* @param name 文件名
|
||||||
|
* @param ext 原始后缀名
|
||||||
|
* @param id 曲目 ID(<code>number</code>类型或纯数字组成的字符串)
|
||||||
|
* @returns Promise
|
||||||
|
*/
|
||||||
|
export async function extractQQMusicMeta(
|
||||||
|
musicBlob: Blob,
|
||||||
|
name: string,
|
||||||
|
ext: string,
|
||||||
|
id?: number | string,
|
||||||
|
): Promise<MetaResult> {
|
||||||
|
const musicMeta = await metaParseBlob(musicBlob);
|
||||||
|
for (let metaIdx in musicMeta.native) {
|
||||||
|
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue;
|
||||||
|
if (musicMeta.native[metaIdx].some((item) => item.id === 'TCON' && item.value === '(12)')) {
|
||||||
|
console.warn('try using gbk encoding to decode meta');
|
||||||
|
musicMeta.common.artist = '';
|
||||||
|
if (!musicMeta.common.artists) {
|
||||||
|
musicMeta.common.artist = fromGBK(musicMeta.common.artist);
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
musicMeta.common.artist = musicMeta.common.artists.map(fromGBK).join();
|
||||||
|
}
|
||||||
|
musicMeta.common.title = fromGBK(musicMeta.common.title);
|
||||||
|
musicMeta.common.album = fromGBK(musicMeta.common.album);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (id && id !== '0') {
|
||||||
|
try {
|
||||||
|
return await fetchMetadataFromSongId(id, ext, musicMeta, musicBlob);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('在线获取曲目信息失败,回退到本地 meta 提取', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const info = GetMetaFromFile(name, musicMeta.common.title, musicMeta.common.artist);
|
||||||
|
info.artist = info.artist || '';
|
||||||
|
|
||||||
|
let imageURL = GetCoverFromFile(musicMeta);
|
||||||
|
if (!imageURL) {
|
||||||
|
imageURL = await getCoverImage(info.title, info.artist, musicMeta.common.album);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: info.title,
|
||||||
|
artist: info.artist,
|
||||||
|
album: musicMeta.common.album || '',
|
||||||
|
imgUrl: imageURL,
|
||||||
|
blob: await writeMetaToAudioFile({
|
||||||
|
title: info.title,
|
||||||
|
artists: info.artist.split(split_regex),
|
||||||
|
ext,
|
||||||
|
imageURL,
|
||||||
|
musicMeta,
|
||||||
|
blob: musicBlob,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchMetadataFromSongId(
|
||||||
|
id: number | string,
|
||||||
|
ext: string,
|
||||||
|
musicMeta: IAudioMetadata,
|
||||||
|
blob: Blob,
|
||||||
|
): Promise<MetaResult> {
|
||||||
|
const info = await querySongInfoById(id);
|
||||||
|
const imageURL = getQMImageURLFromPMID(info.track_info.album.pmid);
|
||||||
|
const artists = info.track_info.singer.map((singer) => singer.name);
|
||||||
|
|
||||||
|
return {
|
||||||
|
title: info.track_info.title,
|
||||||
|
artist: artists.join(','),
|
||||||
|
album: info.track_info.album.name,
|
||||||
|
imgUrl: imageURL,
|
||||||
|
|
||||||
|
blob: await writeMetaToAudioFile({
|
||||||
|
title: info.track_info.title,
|
||||||
|
artists,
|
||||||
|
ext,
|
||||||
|
imageURL,
|
||||||
|
musicMeta,
|
||||||
|
blob,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
|
||||||
|
try {
|
||||||
|
const data = await queryAlbumCover(title, artist, album);
|
||||||
|
return getQMImageURLFromPMID(data.Id, data.Type);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NewAudioMeta {
|
||||||
|
title: string;
|
||||||
|
artists: string[];
|
||||||
|
ext: string;
|
||||||
|
|
||||||
|
musicMeta: IAudioMetadata;
|
||||||
|
|
||||||
|
blob: Blob;
|
||||||
|
imageURL: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function writeMetaToAudioFile(info: NewAudioMeta): Promise<Blob> {
|
||||||
|
try {
|
||||||
|
const imageInfo = await GetImageFromURL(info.imageURL);
|
||||||
|
if (!imageInfo) {
|
||||||
|
console.warn('获取图像失败');
|
||||||
|
}
|
||||||
|
const newMeta = { picture: imageInfo?.buffer, title: info.title, artists: info.artists };
|
||||||
|
const buffer = Buffer.from(await info.blob.arrayBuffer());
|
||||||
|
const mime = AudioMimeType[info.ext] || AudioMimeType.mp3;
|
||||||
|
if (info.ext === 'mp3') {
|
||||||
|
return new Blob([WriteMetaToMp3(buffer, newMeta, info.musicMeta)], { type: mime });
|
||||||
|
} else if (info.ext === 'flac') {
|
||||||
|
return new Blob([WriteMetaToFlac(buffer, newMeta, info.musicMeta)], { type: mime });
|
||||||
|
} else {
|
||||||
|
console.info('writing metadata for ' + info.ext + ' is not being supported for now');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Error while appending cover image to file ' + e);
|
||||||
|
}
|
||||||
|
return info.blob;
|
||||||
|
}
|
3
src/utils/storage.ts
Normal file
3
src/utils/storage.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import storageFactory from './storage/StorageFactory';
|
||||||
|
|
||||||
|
export const storage = storageFactory();
|
17
src/utils/storage/BaseStorage.ts
Normal file
17
src/utils/storage/BaseStorage.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
export const KEY_PREFIX = 'um.conf.';
|
||||||
|
const KEY_JOOX_UUID = `${KEY_PREFIX}joox.uuid`;
|
||||||
|
|
||||||
|
export default abstract class BaseStorage {
|
||||||
|
protected abstract save<T>(name: string, value: T): Promise<void>;
|
||||||
|
protected abstract load<T>(name: string, defaultValue: T): Promise<T>;
|
||||||
|
public abstract getAll(): Promise<Record<string, any>>;
|
||||||
|
public abstract setAll(obj: Record<string, any>): Promise<void>;
|
||||||
|
|
||||||
|
public saveJooxUUID(uuid: string): Promise<void> {
|
||||||
|
return this.save(KEY_JOOX_UUID, uuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
public loadJooxUUID(defaultValue: string = ''): Promise<string> {
|
||||||
|
return this.load(KEY_JOOX_UUID, defaultValue);
|
||||||
|
}
|
||||||
|
}
|
43
src/utils/storage/BrowserNativeStorage.ts
Normal file
43
src/utils/storage/BrowserNativeStorage.ts
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
import BaseStorage, { KEY_PREFIX } from './BaseStorage';
|
||||||
|
|
||||||
|
export default class BrowserNativeStorage extends BaseStorage {
|
||||||
|
public static get works() {
|
||||||
|
return typeof localStorage !== 'undefined' && localStorage.getItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async load<T>(name: string, defaultValue: T): Promise<T> {
|
||||||
|
const result = localStorage.getItem(name);
|
||||||
|
if (result === null) {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return JSON.parse(result);
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async save<T>(name: string, value: T): Promise<void> {
|
||||||
|
localStorage.setItem(name, JSON.stringify(value));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAll(): Promise<Record<string, any>> {
|
||||||
|
const result = {};
|
||||||
|
for (const [key, value] of Object.entries(localStorage)) {
|
||||||
|
if (key.startsWith(KEY_PREFIX)) {
|
||||||
|
try {
|
||||||
|
Object.assign(result, { [key]: JSON.parse(value) });
|
||||||
|
} catch {
|
||||||
|
// ignored
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setAll(obj: Record<string, any>): Promise<void> {
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
await this.save(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
47
src/utils/storage/ChromeExtensionStorage.ts
Normal file
47
src/utils/storage/ChromeExtensionStorage.ts
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
import BaseStorage, { KEY_PREFIX } from './BaseStorage';
|
||||||
|
|
||||||
|
declare var chrome: any;
|
||||||
|
|
||||||
|
export default class ChromeExtensionStorage extends BaseStorage {
|
||||||
|
static get works(): boolean {
|
||||||
|
return typeof chrome !== 'undefined' && Boolean(chrome?.storage?.local?.set);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async load<T>(name: string, defaultValue: T): Promise<T> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.get({ [name]: defaultValue }, (result: any) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(result, name)) {
|
||||||
|
resolve(result[name]);
|
||||||
|
} else {
|
||||||
|
resolve(defaultValue);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async save<T>(name: string, value: T): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.set({ [name]: value }, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAll(): Promise<Record<string, any>> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.get(null, (obj: Record<string, any>) => {
|
||||||
|
const result: Record<string, any> = {};
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
if (key.startsWith(KEY_PREFIX)) {
|
||||||
|
result[key] = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setAll(obj: Record<string, any>): Promise<void> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
chrome.storage.local.set(obj, resolve);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
32
src/utils/storage/InMemoryStorage.ts
Normal file
32
src/utils/storage/InMemoryStorage.ts
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import BaseStorage from './BaseStorage';
|
||||||
|
|
||||||
|
export default class InMemoryStorage extends BaseStorage {
|
||||||
|
private values = new Map<string, any>();
|
||||||
|
protected async load<T>(name: string, defaultValue: T): Promise<T> {
|
||||||
|
if (this.values.has(name)) {
|
||||||
|
return this.values.get(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected async save<T>(name: string, value: T): Promise<void> {
|
||||||
|
this.values.set(name, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getAll(): Promise<Record<string, any>> {
|
||||||
|
const result = {};
|
||||||
|
this.values.forEach((value, key) => {
|
||||||
|
Object.assign(result, {
|
||||||
|
[key]: value,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setAll(obj: Record<string, any>): Promise<void> {
|
||||||
|
for (const [key, value] of Object.entries(obj)) {
|
||||||
|
this.values.set(key, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
13
src/utils/storage/StorageFactory.ts
Normal file
13
src/utils/storage/StorageFactory.ts
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import BaseStorage from './BaseStorage';
|
||||||
|
import BrowserNativeStorage from './BrowserNativeStorage';
|
||||||
|
import ChromeExtensionStorage from './ChromeExtensionStorage';
|
||||||
|
import InMemoryStorage from './InMemoryStorage';
|
||||||
|
|
||||||
|
export default function storageFactory(): BaseStorage {
|
||||||
|
if (ChromeExtensionStorage.works) {
|
||||||
|
return new ChromeExtensionStorage();
|
||||||
|
} else if (BrowserNativeStorage.works) {
|
||||||
|
return new BrowserNativeStorage();
|
||||||
|
}
|
||||||
|
return new InMemoryStorage();
|
||||||
|
}
|
@ -1,79 +1,80 @@
|
|||||||
import {DecryptResult} from "@/decrypt/entity";
|
import { DecryptResult } from '@/decrypt/entity';
|
||||||
import {FileSystemDirectoryHandle} from "@/shims-fs";
|
import { FileSystemDirectoryHandle } from '@/shims-fs';
|
||||||
|
|
||||||
export enum FilenamePolicy {
|
export enum FilenamePolicy {
|
||||||
ArtistAndTitle,
|
ArtistAndTitle,
|
||||||
TitleOnly,
|
TitleOnly,
|
||||||
TitleAndArtist,
|
TitleAndArtist,
|
||||||
SameAsOriginal,
|
SameAsOriginal,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const FilenamePolicies: { key: FilenamePolicy, text: string }[] = [
|
export const FilenamePolicies: { key: FilenamePolicy; text: string }[] = [
|
||||||
{key: FilenamePolicy.ArtistAndTitle, text: "歌手-歌曲名"},
|
{ key: FilenamePolicy.ArtistAndTitle, text: '歌手-歌曲名' },
|
||||||
{key: FilenamePolicy.TitleOnly, text: "歌曲名"},
|
{ key: FilenamePolicy.TitleOnly, text: '歌曲名' },
|
||||||
{key: FilenamePolicy.TitleAndArtist, text: "歌曲名-歌手"},
|
{ key: FilenamePolicy.TitleAndArtist, text: '歌曲名-歌手' },
|
||||||
{key: FilenamePolicy.SameAsOriginal, text: "同源文件名"},
|
{ key: FilenamePolicy.SameAsOriginal, text: '同源文件名' },
|
||||||
]
|
];
|
||||||
|
|
||||||
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string {
|
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string {
|
||||||
switch (policy) {
|
switch (policy) {
|
||||||
case FilenamePolicy.TitleOnly:
|
case FilenamePolicy.TitleOnly:
|
||||||
return `${data.title}.${data.ext}`;
|
return `${data.title}.${data.ext}`;
|
||||||
case FilenamePolicy.TitleAndArtist:
|
case FilenamePolicy.TitleAndArtist:
|
||||||
return `${data.title} - ${data.artist}.${data.ext}`;
|
return `${data.title} - ${data.artist}.${data.ext}`;
|
||||||
case FilenamePolicy.SameAsOriginal:
|
case FilenamePolicy.SameAsOriginal:
|
||||||
return `${data.rawFilename}.${data.ext}`;
|
return `${data.rawFilename}.${data.ext}`;
|
||||||
default:
|
default:
|
||||||
case FilenamePolicy.ArtistAndTitle:
|
case FilenamePolicy.ArtistAndTitle:
|
||||||
return `${data.artist} - ${data.title}.${data.ext}`;
|
return `${data.artist} - ${data.title}.${data.ext}`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) {
|
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) {
|
||||||
let filename = GetDownloadFilename(data, policy)
|
let filename = GetDownloadFilename(data, policy);
|
||||||
// prevent filename exist
|
// prevent filename exist
|
||||||
try {
|
try {
|
||||||
await dir.getFileHandle(filename)
|
await dir.getFileHandle(filename);
|
||||||
filename = `${new Date().getTime()} - ${filename}`
|
filename = `${new Date().getTime()} - ${filename}`;
|
||||||
} catch (e) {
|
} catch (e) {}
|
||||||
}
|
const file = await dir.getFileHandle(filename, { create: true });
|
||||||
const file = await dir.getFileHandle(filename, {create: true})
|
const w = await file.createWritable();
|
||||||
const w = await file.createWritable()
|
await w.write(data.blob);
|
||||||
await w.write(data.blob)
|
await w.close();
|
||||||
await w.close()
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
|
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
|
||||||
const a = document.createElement('a');
|
const a = document.createElement('a');
|
||||||
a.href = data.file;
|
a.href = data.file;
|
||||||
a.download = GetDownloadFilename(data, policy)
|
a.download = GetDownloadFilename(data, policy);
|
||||||
document.body.append(a);
|
document.body.append(a);
|
||||||
a.click();
|
a.click();
|
||||||
a.remove();
|
a.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function RemoveBlobMusic(data: DecryptResult) {
|
export function RemoveBlobMusic(data: DecryptResult) {
|
||||||
URL.revokeObjectURL(data.file);
|
URL.revokeObjectURL(data.file);
|
||||||
if (data.picture?.startsWith("blob:")) {
|
if (data.picture?.startsWith('blob:')) {
|
||||||
URL.revokeObjectURL(data.picture);
|
URL.revokeObjectURL(data.picture);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DecryptQueue {
|
export class DecryptQueue {
|
||||||
private readonly pending: (() => Promise<void>)[];
|
private readonly pending: (() => Promise<void>)[];
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.pending = []
|
this.pending = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
queue(fn: () => Promise<void>) {
|
queue(fn: () => Promise<void>) {
|
||||||
this.pending.push(fn)
|
this.pending.push(fn);
|
||||||
this.consume()
|
this.consume();
|
||||||
}
|
}
|
||||||
|
|
||||||
private consume() {
|
private consume() {
|
||||||
const fn = this.pending.shift()
|
const fn = this.pending.shift();
|
||||||
if (fn) fn().then(() => this.consume).catch(console.error)
|
if (fn)
|
||||||
}
|
fn()
|
||||||
|
.then(() => this.consume)
|
||||||
|
.catch(console.error);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import {expose} from "threads/worker";
|
import { expose } from 'threads/worker';
|
||||||
import {CommonDecrypt} from "@/decrypt/common";
|
import { Decrypt } from '@/decrypt';
|
||||||
|
|
||||||
expose(CommonDecrypt)
|
expose(Decrypt);
|
||||||
|
@ -1,157 +1,264 @@
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<file-selector @error="showFail" @success="showSuccess"/>
|
<file-selector @error="showFail" @success="showSuccess" />
|
||||||
|
|
||||||
<div id="app-control">
|
<div id="app-control">
|
||||||
<el-row class="mb-3">
|
<el-row class="mb-3">
|
||||||
<span>歌曲命名格式:</span>
|
<span>歌曲命名格式:</span>
|
||||||
<el-radio v-for="k in FilenamePolicies" :key="k.key"
|
<el-radio v-for="k in FilenamePolicies" :key="k.key" v-model="filename_policy" :label="k.key">
|
||||||
v-model="filename_policy" :label="k.key">
|
{{ k.text }}
|
||||||
{{ k.text }}
|
</el-radio>
|
||||||
</el-radio>
|
</el-row>
|
||||||
</el-row>
|
<el-row>
|
||||||
<el-row>
|
<edit-dialog
|
||||||
<el-button icon="el-icon-download" plain @click="handleDownloadAll">下载全部</el-button>
|
:show="showEditDialog"
|
||||||
<el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button>
|
:picture="editing_data.picture"
|
||||||
|
:title="editing_data.title"
|
||||||
|
:artist="editing_data.artist"
|
||||||
|
:album="editing_data.album"
|
||||||
|
:albumartist="editing_data.albumartist"
|
||||||
|
:genre="editing_data.genre"
|
||||||
|
@cancel="showEditDialog = false"
|
||||||
|
@ok="handleEdit"
|
||||||
|
></edit-dialog>
|
||||||
|
<config-dialog :show="showConfigDialog" @done="showConfigDialog = false"></config-dialog>
|
||||||
|
<el-tooltip class="item" effect="dark" placement="top">
|
||||||
|
<div slot="content">
|
||||||
|
<span> 部分解密方案需要设定解密参数。 </span>
|
||||||
|
</div>
|
||||||
|
<el-button icon="el-icon-s-tools" plain @click="showConfigDialog = true">解密设定</el-button>
|
||||||
|
</el-tooltip>
|
||||||
|
<el-button icon="el-icon-download" plain @click="handleDownloadAll">下载全部</el-button>
|
||||||
|
<el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button>
|
||||||
|
|
||||||
<el-tooltip class="item" effect="dark" placement="top-start">
|
<el-tooltip class="item" effect="dark" placement="top-start">
|
||||||
<div slot="content">
|
<div slot="content">
|
||||||
<span v-if="instant_save">工作模式: {{ dir ? "写入本地文件系统" : "调用浏览器下载" }}</span>
|
<span v-if="instant_save">工作模式: {{ dir ? '写入本地文件系统' : '调用浏览器下载' }}</span>
|
||||||
<span v-else>
|
<span v-else>
|
||||||
当您使用此工具进行大量文件解锁的时候,建议开启此选项。<br/>
|
当您使用此工具进行大量文件解锁的时候,建议开启此选项。<br />
|
||||||
开启后,解锁结果将不会存留于浏览器中,防止内存不足。
|
开启后,解锁结果将不会存留于浏览器中,防止内存不足。
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<el-checkbox v-model="instant_save" border class="ml-2">立即保存</el-checkbox>
|
<el-checkbox v-model="instant_save" type="success" border class="ml-2">立即保存</el-checkbox>
|
||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</el-row>
|
</el-row>
|
||||||
</div>
|
|
||||||
|
|
||||||
<audio :autoplay="playing_auto" :src="playing_url" controls/>
|
|
||||||
|
|
||||||
<PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @play="changePlaying"/>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<audio :autoplay="playing_auto" :src="playing_url" controls />
|
||||||
|
|
||||||
|
<PreviewTable
|
||||||
|
class="table-content"
|
||||||
|
:policy="filename_policy"
|
||||||
|
:table-data="tableData"
|
||||||
|
@download="saveFile"
|
||||||
|
@edit="editFile"
|
||||||
|
@play="changePlaying" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import FileSelector from '@/component/FileSelector';
|
||||||
|
import PreviewTable from '@/component/PreviewTable';
|
||||||
|
import ConfigDialog from '@/component/ConfigDialog';
|
||||||
|
import EditDialog from '@/component/EditDialog';
|
||||||
|
|
||||||
import FileSelector from "@/component/FileSelector"
|
import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils';
|
||||||
import PreviewTable from "@/component/PreviewTable"
|
import { GetImageFromURL, RewriteMetaToMp3, RewriteMetaToFlac, AudioMimeType, split_regex } from '@/decrypt/utils';
|
||||||
import {DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile} from "@/utils/utils"
|
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Home',
|
name: 'Home',
|
||||||
components: {
|
components: {
|
||||||
FileSelector,
|
FileSelector,
|
||||||
PreviewTable
|
PreviewTable,
|
||||||
|
ConfigDialog,
|
||||||
|
EditDialog,
|
||||||
|
},
|
||||||
|
data() {
|
||||||
|
return {
|
||||||
|
showConfigDialog: false,
|
||||||
|
showEditDialog: false,
|
||||||
|
editing_data: { picture: '', title: '', artist: '', album: '', albumartist: '', genre: '' },
|
||||||
|
tableData: [],
|
||||||
|
playing_url: '',
|
||||||
|
playing_auto: false,
|
||||||
|
filename_policy: FilenamePolicy.ArtistAndTitle,
|
||||||
|
instant_save: false,
|
||||||
|
FilenamePolicies,
|
||||||
|
dir: null,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
watch: {
|
||||||
|
instant_save(val) {
|
||||||
|
if (val) this.showDirectlySave();
|
||||||
},
|
},
|
||||||
data() {
|
},
|
||||||
return {
|
methods: {
|
||||||
tableData: [],
|
async showSuccess(data) {
|
||||||
playing_url: "",
|
if (this.instant_save) {
|
||||||
playing_auto: false,
|
await this.saveFile(data);
|
||||||
filename_policy: FilenamePolicy.ArtistAndTitle,
|
RemoveBlobMusic(data);
|
||||||
instant_save: false,
|
} else {
|
||||||
FilenamePolicies,
|
this.tableData.push(data);
|
||||||
dir: null
|
this.$notify.success({
|
||||||
|
title: '解锁成功',
|
||||||
|
message: '成功解锁 ' + data.title,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
let _rp_data = [data.title, data.artist, data.album];
|
||||||
|
window._paq.push(['trackEvent', 'Unlock', data.rawExt + ',' + data.mime, JSON.stringify(_rp_data)]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
showFail(errInfo, filename) {
|
||||||
|
console.error(errInfo, filename);
|
||||||
|
this.$notify.error({
|
||||||
|
title: '出现问题',
|
||||||
|
message:
|
||||||
|
errInfo +
|
||||||
|
',' +
|
||||||
|
filename +
|
||||||
|
',参考<a target="_blank" href="https://git.unlock-music.dev/um/web/wiki/使用提示">使用提示</a>',
|
||||||
|
dangerouslyUseHTMLString: true,
|
||||||
|
duration: 6000,
|
||||||
|
});
|
||||||
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
window._paq.push(['trackEvent', 'Error', String(errInfo), filename]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
changePlaying(url) {
|
||||||
|
this.playing_url = url;
|
||||||
|
this.playing_auto = true;
|
||||||
|
},
|
||||||
|
handleDeleteAll() {
|
||||||
|
this.tableData.forEach((value) => {
|
||||||
|
RemoveBlobMusic(value);
|
||||||
|
});
|
||||||
|
this.tableData = [];
|
||||||
|
},
|
||||||
|
handleDecryptionConfig() {
|
||||||
|
this.showConfigDialog = true;
|
||||||
|
},
|
||||||
|
handleDownloadAll() {
|
||||||
|
let index = 0;
|
||||||
|
let c = setInterval(() => {
|
||||||
|
if (index < this.tableData.length) {
|
||||||
|
this.saveFile(this.tableData[index]);
|
||||||
|
index++;
|
||||||
|
} else {
|
||||||
|
clearInterval(c);
|
||||||
}
|
}
|
||||||
|
}, 300);
|
||||||
},
|
},
|
||||||
watch: {
|
async handleEdit(data) {
|
||||||
instant_save(val) {
|
this.showEditDialog = false;
|
||||||
if (val) this.showDirectlySave()
|
URL.revokeObjectURL(this.editing_data.file);
|
||||||
|
if (data.picture) {
|
||||||
|
URL.revokeObjectURL(this.editing_data.picture);
|
||||||
|
this.editing_data.picture = URL.createObjectURL(data.picture);
|
||||||
|
}
|
||||||
|
this.editing_data.title = data.title;
|
||||||
|
this.editing_data.artist = data.artist;
|
||||||
|
this.editing_data.album = data.album;
|
||||||
|
let writeSuccess = true;
|
||||||
|
let notifyMsg = '成功修改 ' + this.editing_data.title;
|
||||||
|
try {
|
||||||
|
const musicMeta = await metaParseBlob(new Blob([this.editing_data.blob], { type: mime }));
|
||||||
|
let imageInfo = undefined;
|
||||||
|
if (this.editing_data.picture !== '') {
|
||||||
|
imageInfo = await GetImageFromURL(this.editing_data.picture);
|
||||||
|
if (!imageInfo) {
|
||||||
|
console.warn('获取图像失败', this.editing_data.picture);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
const newMeta = {
|
||||||
|
picture: imageInfo?.buffer,
|
||||||
|
title: data.title,
|
||||||
|
artists: data.artist.split(split_regex),
|
||||||
|
album: data.album,
|
||||||
|
albumartist: data.albumartist,
|
||||||
|
genre: data.genre.split(split_regex),
|
||||||
|
};
|
||||||
|
const buffer = Buffer.from(await this.editing_data.blob.arrayBuffer());
|
||||||
|
const mime = AudioMimeType[this.editing_data.ext] || AudioMimeType.mp3;
|
||||||
|
if (this.editing_data.ext === 'mp3') {
|
||||||
|
this.editing_data.blob = new Blob([RewriteMetaToMp3(buffer, newMeta, musicMeta)], { type: mime });
|
||||||
|
} else if (this.editing_data.ext === 'flac') {
|
||||||
|
this.editing_data.blob = new Blob([RewriteMetaToFlac(buffer, newMeta, musicMeta)], { type: mime });
|
||||||
|
} else {
|
||||||
|
writeSuccess = undefined;
|
||||||
|
notifyMsg = this.editing_data.ext + '类型文件暂时不支持修改音乐标签';
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
writeSuccess = false;
|
||||||
|
notifyMsg = '修改' + this.editing_data.title + '未能完成。在写入新的元数据时发生错误:' + e;
|
||||||
|
}
|
||||||
|
this.editing_data.file = URL.createObjectURL(this.editing_data.blob);
|
||||||
|
if (writeSuccess === true) {
|
||||||
|
this.$notify.success({
|
||||||
|
title: '修改成功',
|
||||||
|
message: notifyMsg,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} else if (writeSuccess === false) {
|
||||||
|
this.$notify.error({
|
||||||
|
title: '修改失败',
|
||||||
|
message: notifyMsg,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
this.$notify.warning({
|
||||||
|
title: '修改取消',
|
||||||
|
message: notifyMsg,
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
|
||||||
async showSuccess(data) {
|
|
||||||
if (this.instant_save) {
|
|
||||||
await this.saveFile(data)
|
|
||||||
RemoveBlobMusic(data);
|
|
||||||
} else {
|
|
||||||
this.tableData.push(data);
|
|
||||||
this.$notify.success({
|
|
||||||
title: '解锁成功',
|
|
||||||
message: '成功解锁 ' + data.title,
|
|
||||||
duration: 3000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
let _rp_data = [data.title, data.artist, data.album];
|
|
||||||
window._paq.push(["trackEvent", "Unlock", data.rawExt + "," + data.mime, JSON.stringify(_rp_data)]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
showFail(errInfo, filename) {
|
|
||||||
console.error(errInfo, filename)
|
|
||||||
this.$notify.error({
|
|
||||||
title: '出现问题',
|
|
||||||
message: errInfo + "," + filename +
|
|
||||||
',参考<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
|
|
||||||
dangerouslyUseHTMLString: true,
|
|
||||||
duration: 6000
|
|
||||||
});
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
|
||||||
window._paq.push(["trackEvent", "Error", String(errInfo), filename]);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
changePlaying(url) {
|
|
||||||
this.playing_url = url;
|
|
||||||
this.playing_auto = true;
|
|
||||||
},
|
|
||||||
handleDeleteAll() {
|
|
||||||
this.tableData.forEach(value => {
|
|
||||||
RemoveBlobMusic(value);
|
|
||||||
});
|
|
||||||
this.tableData = [];
|
|
||||||
},
|
|
||||||
handleDownloadAll() {
|
|
||||||
let index = 0;
|
|
||||||
let c = setInterval(() => {
|
|
||||||
if (index < this.tableData.length) {
|
|
||||||
this.saveFile(this.tableData[index])
|
|
||||||
index++;
|
|
||||||
} else {
|
|
||||||
clearInterval(c);
|
|
||||||
}
|
|
||||||
}, 300);
|
|
||||||
},
|
|
||||||
|
|
||||||
async saveFile(data) {
|
async editFile(data) {
|
||||||
if (this.dir) {
|
this.editing_data = data;
|
||||||
await DirectlyWriteFile(data, this.filename_policy, this.dir)
|
const musicMeta = await metaParseBlob(this.editing_data.blob);
|
||||||
this.$notify({
|
this.editing_data.albumartist = musicMeta.common.albumartist || '';
|
||||||
title: "保存成功",
|
this.editing_data.genre = musicMeta.common.genre?.toString() || '';
|
||||||
message: data.title,
|
this.showEditDialog = true;
|
||||||
position: "top-left",
|
|
||||||
type: "success",
|
|
||||||
duration: 3000
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
DownloadBlobMusic(data, this.filename_policy)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
async showDirectlySave() {
|
|
||||||
if (!window.showDirectoryPicker) return
|
|
||||||
try {
|
|
||||||
await this.$confirm("您的浏览器支持文件直接保存到磁盘,是否使用?",
|
|
||||||
"新特性提示", {
|
|
||||||
confirmButtonText: "使用",
|
|
||||||
cancelButtonText: "不使用",
|
|
||||||
type: "warning",
|
|
||||||
center: true
|
|
||||||
})
|
|
||||||
} catch (e) {
|
|
||||||
console.log(e)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
this.dir = await window.showDirectoryPicker()
|
|
||||||
window.dir = this.dir
|
|
||||||
window.f = await this.dir.getFileHandle("write-test.txt", {create: true})
|
|
||||||
|
|
||||||
} catch (e) {
|
|
||||||
console.error(e)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
}
|
async saveFile(data) {
|
||||||
|
if (this.dir) {
|
||||||
|
await DirectlyWriteFile(data, this.filename_policy, this.dir);
|
||||||
|
this.$notify({
|
||||||
|
title: '保存成功',
|
||||||
|
message: data.title,
|
||||||
|
position: 'top-left',
|
||||||
|
type: 'success',
|
||||||
|
duration: 3000,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
DownloadBlobMusic(data, this.filename_policy);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
async showDirectlySave() {
|
||||||
|
if (!window.showDirectoryPicker) return;
|
||||||
|
try {
|
||||||
|
await this.$confirm('您的浏览器支持文件直接保存到磁盘,是否使用?', '新特性提示', {
|
||||||
|
confirmButtonText: '使用',
|
||||||
|
cancelButtonText: '不使用',
|
||||||
|
type: 'warning',
|
||||||
|
center: true,
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.log(e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
this.dir = await window.showDirectoryPicker();
|
||||||
|
const test_filename = '__unlock_music_write_test.txt';
|
||||||
|
await this.dir.getFileHandle(test_filename, { create: true });
|
||||||
|
await this.dir.removeEntry(test_filename);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
1
testdata/mflac0_rc4_key.bin
vendored
Normal file
1
testdata/mflac0_rc4_key.bin
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
dRzX3p5ZYqAlp7lLSs9Zr0rw1iEZy23bB670x4ch2w97x14Zwpk1UXbKU4C2sOS7uZ0NB5QM7ve9GnSrr2JHxP74hVNONwVV77CdOOVb807317KvtI5Yd6h08d0c5W88rdV46C235YGDjUSZj5314YTzy0b6vgh4102P7E273r911Nl464XV83Hr00rkAHkk791iMGSJH95GztN28u2Nv5s9Xx38V69o4a8aIXxbx0g1EM0623OEtbtO9zsqCJfj6MhU7T8iVS6M3q19xhq6707E6r7wzPO6Yp4BwBmgg4F95Lfl0vyF7YO6699tb5LMnr7iFx29o98hoh3O3Rd8h9Juu8P1wG7vdnO5YtRlykhUluYQblNn7XwjBJ53HAyKVraWN5dG7pv7OMl1s0RykPh0p23qfYzAAMkZ1M422pEd07TA9OCKD1iybYxWH06xj6A8mzmcnYGT9P1a5Ytg2EF5LG3IknL2r3AUz99Y751au6Cr401mfAWK68WyEBe5
|
1
testdata/mflac0_rc4_key_raw.bin
vendored
Normal file
1
testdata/mflac0_rc4_key_raw.bin
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
ZFJ6WDNwNVrjEJZB1o6QjkQV2ZbHSw/2Eb00q1+4z9SVWYyFWO1PcSQrJ5326ubLklmk2ab3AEyIKNUu8DFoAoAc9dpzpTmc+pdkBHjM/bW2jWx+dCyC8vMTHE+DHwaK14UEEGW47ZXMDi7PRCQ2Jpm/oXVdHTIlyrc+bRmKfMith0L2lFQ+nW8CCjV6ao5ydwkZhhNOmRdrCDcUXSJH9PveYwra9/wAmGKWSs9nemuMWKnbjp1PkcxNQexicirVTlLX7PVgRyFyzNyUXgu+R2S4WTmLwjd8UsOyW/dc2mEoYt+vY2lq1X4hFBtcQGOAZDeC+mxrN0EcW8tjS6P4TjOjiOKNMxIfMGSWkSKL3H7z5K7nR1AThW20H2bP/LcpsdaL0uZ/js1wFGpdIfFx9rnLC78itL0WwDleIqp9TBMX/NwakGgIPIbjBwfgyD8d8XKYuLEscIH0ZGdjsadB5XjybgdE3ppfeFEcQiqpnodlTaQRm3KDIF9ATClP0mTl8XlsSojsZ468xseS1Ib2iinx/0SkK3UtJDwp8DH3/+ELisgXd69Bf0pve7wbrQzzMUs9/Ogvvo6ULsIkQfApJ8cSegDYklzGXiLNH7hZYnXDLLSNejD7NvQouULSmGsBbGzhZ5If0NP/6AhSbpzqWLDlabTDgeWWnFeZpBnlK6SMxo+YFFk1Y0XLKsd69+jj
|
BIN
testdata/mflac0_rc4_raw.bin
vendored
Normal file
BIN
testdata/mflac0_rc4_raw.bin
vendored
Normal file
Binary file not shown.
BIN
testdata/mflac0_rc4_suffix.bin
vendored
Normal file
BIN
testdata/mflac0_rc4_suffix.bin
vendored
Normal file
Binary file not shown.
BIN
testdata/mflac0_rc4_target.bin
vendored
Normal file
BIN
testdata/mflac0_rc4_target.bin
vendored
Normal file
Binary file not shown.
1
testdata/mflac_map_key.bin
vendored
Normal file
1
testdata/mflac_map_key.bin
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
yw7xWOyNQ8585Jwx3hjB49QLPKi38F89awnrQ0fq66NT9TDq1ppHNrFqhaDrU5AFk916D58I53h86304GqOFCCyFzBem68DqiXJ81bILEQwG3P3MOnoNzM820kNW9Lv9IJGNn9Xo497p82BLTm4hAX8JLBs0T2pilKvT429sK9jfg508GSk4d047Jxdz5Fou4aa33OkyFRBU3x430mgNBn04Lc9BzXUI2IGYXv3FGa9qE4Vb54kSjVv8ogbg47j3
|
1
testdata/mflac_map_key_raw.bin
vendored
Normal file
1
testdata/mflac_map_key_raw.bin
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
eXc3eFdPeU6+3f7GVeF35bMpIEIQj5JWOWt7G+jsR68Hx3BUFBavkTQ8dpPdP0XBIwPe+OfdsnTGVQqPyg3GCtQSrkgA0mwSQdr4DPzKLkEZFX+Cf1V6ChyipOuC6KT37eAxWMdV1UHf9/OCvydr1dc6SWK1ijRUcP6IAHQhiB+mZLay7XXrSPo32WjdBkn9c9sa2SLtI48atj5kfZ4oOq6QGeld2JA3Z+3wwCe6uTHthKaEHY8ufDYodEe3qqrjYpzkdx55pCtxCQa1JiNqFmJigWm4m3CDzhuJ7YqnjbD+mXxLi7BP1+z4L6nccE2h+DGHVqpGjR9+4LBpe4WHB4DrAzVp2qQRRQJxeHd1v88=
|
BIN
testdata/mflac_map_raw.bin
vendored
Normal file
BIN
testdata/mflac_map_raw.bin
vendored
Normal file
Binary file not shown.
BIN
testdata/mflac_map_suffix.bin
vendored
Normal file
BIN
testdata/mflac_map_suffix.bin
vendored
Normal file
Binary file not shown.
BIN
testdata/mflac_map_target.bin
vendored
Normal file
BIN
testdata/mflac_map_target.bin
vendored
Normal file
Binary file not shown.
1
testdata/mflac_rc4_key.bin
vendored
Normal file
1
testdata/mflac_rc4_key.bin
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
pUtyvqr0TgAvR95mNmY7DmNl386TsJNAEIz95CEcgIgJCcs28686O7llxD5E74ldn70xMtd5cG58TA5ILw09I8BOTf5EdHKd6wwPn689DUK13y3Req6H0P33my2miJ5bQ2AA22B8vp4V0NJ3hBqNtFf7cId48V6W51e1kwgu1xKKawxe9BByT92MFlqrFaKH32dB2zFgyd38l2P1outr4l2XLq48F9G17ptRz4W8Loxu28RvZgv0BzL26Ht9I2L5VCwMzzt7OeZ55iQs40Tr6k81QGraIUJj5zeBMgJRMTaSgi19hU5x5a08Qd662MbFhZZ0FjVvaDy1nbIDhrC62c1lX6wf70O45h4W42VxloBVeZ9Sef4V7cWrjrEjj3DJ5w2iu6Q9uoal2f4390kue42Um5HcDFWqv3m56k6O89bRV424PaRra1k9Cd2L56IN2zfBYqNo2WP5VC68G8w1hfflOY0O52h4WdcpoHSjZm4b35N7l47dT4dwEXj1U4J5
|
1
testdata/mflac_rc4_key_raw.bin
vendored
Normal file
1
testdata/mflac_rc4_key_raw.bin
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
cFV0eXZxcjAF/IXJ9qJT1u5C3S5AgY9BoVtIQNBKfxQMt5hH7BF36ndIJGV5L6qw5h4G0IOIOOewdHmMCNfKJftHM4nv3B0iRlSdqJKdL08wO3sV0v8eZk0OiYAlxgseGcBquQWYS/0b5Lj/Ioi2NfpOthAY9vUiRPnfH3+7/2AJGudHjj4Gg1KkpPW3mXIKbsk+Ou9fhrUqs873BCdsmI6qRmVNhOkLaUcbG6Zin3XU0WkgnnjebR43S8N4bw5BTphFvhy42QvspnD7Ewb1tVZQMQ2N1s38nBjukdfCB9R6aRwITOvg2U7Lr0RjLpbrIn6A6iVilpINjK4VptuKUTlpDXQwgCjoqeHQaHNCWgYpdjB69lXn8km/BfzK7QyDbh0VgTikwAHF9tvPhin3AIDRcU0xsaWYKURRfJelX3pSN495ADlhXdEKL/+l60hVnY7t6iCMxJL3lOtdGtdUYUGUCc76PB1fX+0HTWCcfcwvXTEdczr9J1h2yTeJNqFQ5pNy8vX7Ws8k7vDQVFkw4llZjPhb0kg9aDNePTNIKSGwy/7eofrcUQlC9DI+qqqwQ5abA/93fNsPq6XU3uwawnrbBsdz8DDdjJiEDI7abkPIDIfr/uR0YzgBxW90t5bt6xAtuW+VSYAM7kGxI3RZTl7JgOT60MLyIWkYASrRhRPMGks8zL10ED/4yGTEB1nt
|
BIN
testdata/mflac_rc4_raw.bin
vendored
Normal file
BIN
testdata/mflac_rc4_raw.bin
vendored
Normal file
Binary file not shown.
BIN
testdata/mflac_rc4_suffix.bin
vendored
Normal file
BIN
testdata/mflac_rc4_suffix.bin
vendored
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user