Compare commits
161 Commits
Author | SHA1 | Date |
---|---|---|
Unlock Music Dev | 27bccbbd55 | |
Jixun | 5db14d1d2a | |
Jixun | 4872efbf87 | |
邴新科 | 6c51c1a17d | |
邴新科 | 9688cf13c9 | |
MengYX | 1f7ac44829 | |
MengYX | 08a6f1d946 | |
MengYX | 05b47e3188 | |
MengYX | 9525045cd9 | |
MengYX | 4a5f3849a0 | |
MengYX | 711468601a | |
MengYX | 48255367b4 | |
MengYX | 8260e744ca | |
MengYX | 976077e3e1 | |
MengYX | 4f7d16e2b0 | |
MengYX | af10576b88 | |
Jixun Wu | fc4a0e002f | |
Jixun Wu | 55772dec31 | |
Jixun Wu | a08e189d8e | |
MengYX | 0b786b9885 | |
MengYX | b710255100 | |
MengYX | 381f224a68 | |
Jixun Wu | a73212f026 | |
Jixun Wu | c098b73617 | |
Jixun Wu | 107eaef5e6 | |
Jixun Wu | eb61d81817 | |
Jixun Wu | c1aba5a10f | |
Jixun Wu | 3567e7e625 | |
Jixun Wu | 302f422a5f | |
Jixun Wu | 1c4185750a | |
Jixun Wu | 13f2d86df4 | |
Jixun Wu | 4ae92cd630 | |
Jixun Wu | 57d2244c28 | |
Jixun Wu | 5e6cf8bf24 | |
Jixun Wu | 10aa05c244 | |
Jixun | 5d3d8ce485 | |
Jixun | f94266bacc | |
Jixun | f414f978b4 | |
Jixun | ac6336993e | |
Jixun | 8a17dd352d | |
Jixun | dbfff5feca | |
MengYX | 3d86eb19b9 | |
MengYX | 6287283cde | |
MengYX | 477d66e9e9 | |
MengYX | 3441b7a3b1 | |
MengYX | 5dc89502cb | |
MengYX | b9efb68851 | |
MengYX | c1320c811b | |
MengYX | fce4734ed9 | |
MengYX | 334864f6d8 | |
MengYX | 76c3887eec | |
MengYX | 4703667a44 | |
MengYX | 183ac63864 | |
MengYX | d5ac9ad56e | |
MengYX | 7b3b701924 | |
MengYX | 97ef3f0d7b | |
MengYX | cbed2332fb | |
MengYX | fab64f19d4 | |
MengYX | 70b46f9d63 | |
MengYX | de01d7ff9c | |
MengYX | 768f30a2fe | |
MengYX | 59048aef6b | |
MengYX | 60ea6239eb | |
MengYX | 42ea3651f2 | |
Jixun Wu | 7f48acd214 | |
Jixun Wu | 8e007ff0a3 | |
Jixun Wu | b2bf878c89 | |
Jixun Wu | b550b407e5 | |
Jixun Wu | 1e927ad962 | |
Jixun Wu | 9edcaadb83 | |
Jixun Wu | ec6be66cc1 | |
Jixun Wu | ab3f54cb47 | |
Jixun Wu | da11e3a9a1 | |
Jixun Wu | 8d35afae62 | |
Jixun Wu | 840d750716 | |
MengYX | ada078df19 | |
MengYX | 8facd65834 | |
MengYX | a6367401ae | |
lvzx123 | 565ee37a8b | |
MengYX | aab611bff5 | |
MengYX | 942e43fa16 | |
MengYX | 84be57a903 | |
MengYX | a6a3c02538 | |
Emmm Monster | 216ff4021c | |
Emmm Monster | d3898161b9 | |
MengYX | 5a7a9e3add | |
MengYX | 652bb1fc32 | |
MengYX | 6737e8c11b | |
sunhao03 | 71862538b7 | |
Emmm Monster | 4251b94b1f | |
Emmm Monster | 8fdda048f6 | |
Emmm Monster | 39c7294996 | |
Emmm Monster | 48f879cb58 | |
Emmm Monster | f0875ad175 | |
qq1010903229 | 02a146e069 | |
Emmm Monster | 2e31853ffb | |
Emmm Monster | a7aaf246ae | |
Emmm Monster | 4bc0a10c09 | |
Emmm Monster | 3645dd7d01 | |
Emmm Monster | 73bb9438b1 | |
Emmm Monster | 21d5ae305c | |
Emmm Monster | 759c1bd87e | |
Emmm Monster | c7e5dfb4c4 | |
Emmm Monster | ca4ed149b2 | |
Emmm Monster | b3c6fe2f24 | |
Emmm Monster | aca1c11332 | |
Emmm Monster | 15dba7b92f | |
Emmm Monster | 37a641e69e | |
Emmm Monster | 342241b379 | |
Emmm Monster | a1eddb230f | |
Emmm Monster | 3c0a9e92f9 | |
Emmm Monster | 4637a3650a | |
EmmmX | 9ae860cb11 | |
Emmm Monster | ec711990a1 | |
Emmm Monster | f3f6f9ef40 | |
Emmm Monster | 213ac35157 | |
Emmm Monster | e36df21f01 | |
MengYX | fc52423976 | |
MengYX | 5ca9b1fab4 | |
MengYX | 3dfed44021 | |
MengYX | 9e04bc8690 | |
MengYX | 7716c356ed | |
MengYX | 701f750476 | |
MengYX | d73493a624 | |
MengYX | 85fdbff00d | |
MengYX | 3a5afeb8a6 | |
MengYX | 8dc1a66d69 | |
MengYX | 7733fa6ad1 | |
MengYX | 6a2dd672f3 | |
MengYX | ca82842b04 | |
MengYX | 137df9c4c2 | |
MengYX | b17bb37c38 | |
MengYX | 9607580e8b | |
MengYX | 5956412d7e | |
MengYX | 22312959f3 | |
MengYX | 549983a928 | |
MengYX | ce2642ad1f | |
MengYX | c6ea98333e | |
MengYX | 042b1ca0dd | |
MengYX | e089fe1268 | |
MengYX | 67966d4b54 | |
MengYX | ca462f94fa | |
MengYX | 297c7c9252 | |
MengYX | 8acc1ade81 | |
MengYX | e543024641 | |
MengYX | cf48554424 | |
MengYX | 2e0cd04255 | |
MengYX | adbcdfd083 | |
MengYX | e1505148c8 | |
MengYX | b65e47514f | |
MengYX | 31215772e3 | |
dependabot[bot] | 070c642dbf | |
MengYX | 8e135f7004 | |
MengYX | 0fb30ddc17 | |
KyleBing | e9a25f3140 | |
KyleBing | 5e2f3d36c2 | |
KyleBing | a040c88a07 | |
KyleBing | b370f4ceb6 | |
MengYX | bf0df4e68d | |
flosacca | f24ea6a07b | |
MengYX | c11f3fd130 |
65
.drone.yml
65
.drone.yml
|
@ -1,65 +0,0 @@
|
|||
---
|
||||
kind: pipeline
|
||||
type: docker
|
||||
name: default
|
||||
|
||||
clone:
|
||||
depth: 1
|
||||
|
||||
steps:
|
||||
- name: installDependencies
|
||||
image: node:lts
|
||||
commands:
|
||||
- npm ci --registry=https://registry.npm.taobao.org
|
||||
|
||||
- name: build
|
||||
image: node:lts
|
||||
commands:
|
||||
- npm run fix-compatibility
|
||||
- npm run build
|
||||
- tar -czf legacy.tar.gz -C ./dist .
|
||||
- npm run build -- --modern
|
||||
- tar -czf modern.tar.gz -C ./dist .
|
||||
|
||||
|
||||
- name: release
|
||||
image: plugins/gitea-release
|
||||
settings:
|
||||
base_url: https://git.ixarea.com
|
||||
files:
|
||||
- modern.tar.gz
|
||||
- legacy.tar.gz
|
||||
api_key:
|
||||
from_secret: gitea_token
|
||||
checksum:
|
||||
- sha256
|
||||
when:
|
||||
event: [tag]
|
||||
|
||||
- name: deploy
|
||||
image: plugins/s3
|
||||
settings:
|
||||
bucket: unlock-music
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
source: dist/**/*
|
||||
strip_prefix: dist/
|
||||
target: /public
|
||||
path_style: true
|
||||
endpoint: https://fs.sz2.ixarea.com
|
||||
|
||||
|
||||
- name: upload
|
||||
image: plugins/s3
|
||||
settings:
|
||||
bucket: unlock-music
|
||||
access_key:
|
||||
from_secret: aws_access_key_id
|
||||
secret_key:
|
||||
from_secret: aws_secret_access_key
|
||||
source: ./*.tar.gz
|
||||
target: /build/${DRONE_BUILD_NUMBER}
|
||||
path_style: true
|
||||
endpoint: https://fs.sz2.ixarea.com
|
|
@ -7,9 +7,10 @@ assignees: ''
|
|||
|
||||
---
|
||||
|
||||
- 请按照此模板填写,否则可能立即被关闭
|
||||
* 请按照此模板填写,否则可能立即被关闭
|
||||
|
||||
- [x] 我确认已经搜索过Issue不存并确认相同的Issue
|
||||
- [x] 我认为这是程序导致的问题(如不确认,请先通过Telegram或者Email进行咨询)
|
||||
- [x] 我有证据表明这是程序导致的问题(如不确认,可以在[Discussions](https://github.com/ix64/unlock-music/discussions)内提出)
|
||||
|
||||
|
||||
**Bug描述**
|
||||
|
|
|
@ -17,7 +17,7 @@ assignees: ''
|
|||
**实现途径**
|
||||
|
||||
- 如果没有设计方案,请简要描述实现思路
|
||||
- 如果你没有任何的实现思路,请通过Telegram进行讨论
|
||||
- 如果你没有任何的实现思路,请通过[Discussions](https://github.com/ix64/unlock-music/discussions)或者Telegram进行讨论
|
||||
|
||||
|
||||
**附加信息**
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
name: Build
|
||||
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
|
||||
|
||||
name: Test Build
|
||||
on:
|
||||
push:
|
||||
branches: [ master ]
|
||||
paths:
|
||||
- ".github/workflows/*"
|
||||
- "**/*.js"
|
||||
- "**/*.ts"
|
||||
- "**/*.vue"
|
||||
- "public/**/*"
|
||||
- "package-lock.json"
|
||||
|
@ -13,14 +16,30 @@ on:
|
|||
types: [ opened, synchronize, reopened ]
|
||||
paths:
|
||||
- "**/*.js"
|
||||
- "**/*.ts"
|
||||
- "**/*.vue"
|
||||
- "public/**/*"
|
||||
- "package-lock.json"
|
||||
- "package.json"
|
||||
|
||||
|
||||
|
||||
jobs:
|
||||
test-coverage:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- run: npm ci
|
||||
# note: forks can not access to GITHUB_TOKEN for coverage update.
|
||||
# instead, we just ran the test in this case.
|
||||
- name: Test only
|
||||
if: github.event_name != 'push'
|
||||
run: npm test
|
||||
- name: Test + Publish Coverage
|
||||
uses: ArtiomTr/jest-coverage-report-action@v2.0-rc.6
|
||||
if: github.event_name == 'push'
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
annotations: none
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
|
@ -28,40 +47,38 @@ jobs:
|
|||
build: [ legacy, modern ]
|
||||
include:
|
||||
- build: legacy
|
||||
BUILD_ARGS:
|
||||
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@v1
|
||||
- name: Use Node.js 16.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "::set-output name=dir::$(npm config get cache)"
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-node-
|
||||
node-version: "16"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm run fix-compatibility
|
||||
run: npm ci
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
GZIP: "--best"
|
||||
run: |
|
||||
npm run build ${{ matrix.BUILD_ARGS }}
|
||||
tar -czvf dist.tar.gz -C ./dist .
|
||||
run: npm run build ${{ matrix.BUILD_ARGS }}
|
||||
|
||||
- name: Publish artifact
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: unlock-music-${{ matrix.build }}.tar.gz
|
||||
path: ./dist.tar.gz
|
||||
name: ${{ matrix.build }}
|
||||
path: ./dist
|
||||
|
||||
- name: Build Extension
|
||||
if: ${{ matrix.BUILD_EXTENSION }}
|
||||
run: npm run make-extension
|
||||
|
||||
- name: Publish artifact - Extension
|
||||
if: ${{ matrix.BUILD_EXTENSION }}
|
||||
uses: actions/upload-artifact@v2
|
||||
with:
|
||||
name: extension
|
||||
path: ./dist
|
||||
|
|
|
@ -0,0 +1,65 @@
|
|||
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
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
name: Release and GitHub Pages
|
||||
name: Build Release
|
||||
|
||||
on:
|
||||
push:
|
||||
|
@ -11,24 +11,13 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
|
||||
- name: Use Node.js 14.x
|
||||
uses: actions/setup-node@v1
|
||||
- name: Use Node.js 16.x
|
||||
uses: actions/setup-node@v2
|
||||
with:
|
||||
node-version: 14.x
|
||||
|
||||
- name: Get npm cache directory
|
||||
id: npm-cache
|
||||
run: echo "::set-output name=dir::$(npm config get cache)"
|
||||
- uses: actions/cache@v2
|
||||
with:
|
||||
path: ${{ steps.npm-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }}
|
||||
restore-keys: ${{ runner.os }}-node-
|
||||
node-version: "16"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: |
|
||||
npm ci
|
||||
npm run fix-compatibility
|
||||
run: npm ci
|
||||
|
||||
- name: Build Legacy
|
||||
env:
|
||||
|
@ -36,7 +25,18 @@ jobs:
|
|||
run: |
|
||||
npm run build
|
||||
tar -czf legacy.tar.gz -C ./dist .
|
||||
zip -rJ9 legacy.zip ./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:
|
||||
|
@ -44,17 +44,13 @@ jobs:
|
|||
run: |
|
||||
npm run build -- --modern
|
||||
tar -czf modern.tar.gz -C ./dist .
|
||||
zip -rJ9 modern.zip ./dist
|
||||
cd dist
|
||||
zip -rJ9 ../modern.zip *
|
||||
cd ..
|
||||
|
||||
- name: Checksum
|
||||
run: sha256sum *.tar.gz *.zip > sha256sum.txt
|
||||
|
||||
- name: Deploy
|
||||
uses: peaceiris/actions-gh-pages@v3
|
||||
with:
|
||||
github_token: ${{ secrets.GITHUB_TOKEN }}
|
||||
publish_dir: ./dist
|
||||
|
||||
- name: Get current time
|
||||
id: date
|
||||
run: echo "::set-output name=date::$(date +'%Y/%m/%d')"
|
||||
|
@ -70,7 +66,7 @@ jobs:
|
|||
draft: true
|
||||
|
||||
- name: Upload Release Assets - legacy.tar.gz
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
@ -80,7 +76,7 @@ jobs:
|
|||
asset_content_type: application/gzip
|
||||
|
||||
- name: Upload Release Assets - legacy.zip
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
@ -90,7 +86,7 @@ jobs:
|
|||
asset_content_type: application/zip
|
||||
|
||||
- name: Upload Release Assets - modern.tar.gz
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
@ -100,7 +96,7 @@ jobs:
|
|||
asset_content_type: application/gzip
|
||||
|
||||
- name: Upload Release Assets - modern.zip
|
||||
uses: actions/upload-release-asset@v1.0.2
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
@ -109,8 +105,18 @@ jobs:
|
|||
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.0.2
|
||||
uses: actions/upload-release-asset@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
|
@ -118,4 +124,3 @@ jobs:
|
|||
asset_path: ./sha256sum.txt
|
||||
asset_name: sha256sum.txt
|
||||
asset_content_type: text/plain
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
.DS_Store
|
||||
node_modules
|
||||
/dist
|
||||
/coverage
|
||||
|
||||
# local env files
|
||||
.env.local
|
||||
|
|
|
@ -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
|
|
@ -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',
|
||||
};
|
|
@ -0,0 +1,10 @@
|
|||
FROM --platform=$TARGETPLATFORM nginx:stable-alpine
|
||||
|
||||
LABEL org.opencontainers.image.title="Unlock Music"
|
||||
LABEL org.opencontainers.image.description="Unlock encrypted music file in browser"
|
||||
LABEL org.opencontainers.image.authors="MengYX"
|
||||
LABEL org.opencontainers.image.source="https://github.com/ix64/unlock-music"
|
||||
LABEL org.opencontainers.image.licenses="MIT"
|
||||
LABEL maintainer="MengYX"
|
||||
|
||||
COPY ./dist /usr/share/nginx/html
|
4
LICENSE
4
LICENSE
|
@ -1,6 +1,6 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2019-2020 MengYX
|
||||
Copyright (c) 2019-2021 MengYX
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
@ -18,4 +18,4 @@ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
SOFTWARE.
|
||||
|
|
116
README.md
116
README.md
|
@ -1,46 +1,92 @@
|
|||
# Unlock Music 音乐解锁
|
||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||
- unlock-music项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[License](https://github.com/ix64/unlock-music/blob/master/LICENSE)
|
||||
- Unlock Music的CLI版本正在开发中。
|
||||
- 我们新建了Telegram群组,欢迎加入![https://t.me/unlock_music_chat](https://t.me/unlock_music_chat)
|
||||
- [其他测试版工具](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)
|
||||
|
||||
- ![Release and GitHub Pages](https://github.com/ix64/unlock-music/workflows/Release%20and%20GitHub%20Pages/badge.svg)
|
||||
|
||||
# 特性
|
||||
## 支持的格式
|
||||
- [x] QQ音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/[.tkm](https://github.com/ix64/unlock-music/issues/9))
|
||||
- [x] 写入封面图片
|
||||
- [x] Moo音乐格式 ([.bkcmp3/.bkcflac](https://github.com/ix64/unlock-music/issues/11))
|
||||
- [x] QQ音乐Tm格式 (.tm0/.tm2/.tm3/.tm6)
|
||||
- [x] QQ音乐新格式 (实验性支持)
|
||||
- [x] .mflac
|
||||
- [x] [.mgg](https://github.com/ix64/unlock-music/issues/3)
|
||||
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
|
||||
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循 [License][license]
|
||||
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli][repo_cli] 找到,大批量转换建议使用 CLI 版本。
|
||||
- 我们新建了 Telegram 群组 [`@unlock_music_chat`][tg_group] ,欢迎加入!
|
||||
- [相关的其他项目][related_projects]
|
||||
|
||||
![Test Build](https://github.com/unlock-music/unlock-music/workflows/Test%20Build/badge.svg)
|
||||
![GitHub releases](https://img.shields.io/github/downloads/unlock-music/unlock-music/total)
|
||||
![Docker Pulls](https://img.shields.io/docker/pulls/ix64/unlock-music)
|
||||
|
||||
[license]: https://github.com/unlock-music/unlock-music/blob/master/LICENSE
|
||||
|
||||
[repo_cli]: https://github.com/unlock-music/cli
|
||||
|
||||
[tg_group]: https://t.me/unlock_music_chat
|
||||
|
||||
[related_projects]: https://github.com/unlock-music/unlock-music/wiki/和UnlockMusic相关的项目
|
||||
|
||||
## 特性
|
||||
|
||||
### 支持的格式
|
||||
|
||||
- [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm)
|
||||
- [x] Moo 音乐格式 (.bkcmp3/.bkcflac/...)
|
||||
- [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6)
|
||||
- [x] QQ 音乐新格式 (.mflac/.mgg/.mflac0/.mgg1/.mggl)
|
||||
- [x] <ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en)
|
||||
- [x] 网易云音乐格式 (.ncm)
|
||||
- [x] 补全ncm的ID3/FlacMeta信息
|
||||
- [x] 虾米音乐格式 (.xm) (测试阶段)
|
||||
- [x] 酷我音乐格式 (.kwm) (测试阶段)
|
||||
- [x] 酷狗音乐格式 (.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] 虾米音乐格式 (.xm)
|
||||
- [x] 酷我音乐格式 (.kwm)
|
||||
- [x] 酷狗音乐格式 (.kgm/.vpr) ([CLI 版本][kgm_cli])
|
||||
|
||||
[kgm_cli]: https://github.com/unlock-music/unlock-music/wiki/其他音乐格式工具#酷狗音乐-kgmvpr解锁工具
|
||||
|
||||
[joox_wiki]: https://github.com/unlock-music/joox-crypto/wiki/加密格式
|
||||
|
||||
### 其他特性
|
||||
|
||||
## 其他特性
|
||||
- [x] 在浏览器中解锁
|
||||
- [x] 拖放文件
|
||||
- [x] 在线播放
|
||||
- [x] 批量解锁
|
||||
- [x] 渐进式Web应用
|
||||
- [x] 多线程
|
||||
- [x] 渐进式 Web 应用 (PWA)
|
||||
- [x] 多线程
|
||||
- [x] 写入Meta和封面图片
|
||||
|
||||
## 使用方法
|
||||
|
||||
# 使用方法
|
||||
## 使用已构建版本
|
||||
- 从[GitHub Release](https://github.com/ix64/unlock-music/releases/latest)下载已构建的版本
|
||||
- 本地使用请下载`legacy版本`(`modern版本`只能通过**http/https协议**访问)
|
||||
### 安装浏览器扩展
|
||||
|
||||
[![Chrome Web Store](https://storage.googleapis.com/chrome-gcs-uploader.appspot.com/image/WlD8wC6g8khYWPJUsQceQkhXSlv1/UV4C4ybeBTsZt43U4xis.png)](https://chrome.google.com/webstore/detail/gldlhhhmienbhlpkfanjpmffdjblmegd)
|
||||
[<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)
|
||||
[![Firefox Browser Addons](https://ffp4g1ylyit3jdyti1hqcvtb-wpengine.netdna-ssl.com/addons/files/2015/11/get-the-addon.png)](https://addons.mozilla.org/zh-CN/firefox/addon/unlock-music/)
|
||||
|
||||
### 使用已构建版本
|
||||
|
||||
- 从[GitHub Release](https://github.com/unlock-music/unlock-music/releases/latest)下载已构建的版本
|
||||
- 本地使用请下载`legacy版本`(`modern版本`只能通过 **http(s)协议** 访问)
|
||||
- 解压缩后即可部署或本地使用(**请勿直接运行源代码**)
|
||||
|
||||
## 自行构建
|
||||
- 环境要求
|
||||
- nodejs
|
||||
- npm
|
||||
1. 获取项目源代码后执行 `npm install` 安装相关依赖
|
||||
2. 执行 `npm run build` 即可进行构建,构建输出为 dist 目录
|
||||
- `npm run serve` 可用于开发
|
||||
### 使用 Docker 镜像
|
||||
|
||||
```shell
|
||||
docker run --name unlock-music -d -p 8080:80 ix64/unlock-music
|
||||
```
|
||||
|
||||
### 自行构建
|
||||
|
||||
- 环境要求
|
||||
- nodejs (v16.x)
|
||||
- npm
|
||||
|
||||
1. 获取项目源代码后安装相关依赖:
|
||||
|
||||
```sh
|
||||
npm ci
|
||||
```
|
||||
|
||||
2. 然后进行构建。编译后的文件保存到 dist 目录下:
|
||||
|
||||
```sh
|
||||
npm run build
|
||||
```
|
||||
|
||||
- 如果是用于开发,可以执行 `npm run serve`。
|
||||
|
||||
3. 如需构建浏览器扩展,build 完成后还需要执行:
|
||||
|
||||
```sh
|
||||
npm run make-extension
|
||||
```
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
module.exports = {
|
||||
presets: [
|
||||
'@vue/app'
|
||||
'@vue/app',
|
||||
'@babel/preset-typescript'
|
||||
],
|
||||
plugins: [
|
||||
["component", {
|
||||
|
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"manifest_version": 2,
|
||||
"name": "音乐解锁",
|
||||
"short_name": "音乐解锁",
|
||||
"icons": {
|
||||
"128": "./img/icons/msapplication-icon-144x144.png"
|
||||
},
|
||||
"description": "在任何设备上解锁已购的加密音乐!",
|
||||
"permissions": ["storage"],
|
||||
"offline_enabled": true,
|
||||
"options_page": "./index.html",
|
||||
"homepage_url": "https://github.com/ix64/unlock-music",
|
||||
"browser_action": {
|
||||
"default_popup": "./popup.html"
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
module.exports = {
|
||||
setupFilesAfterEnv: [
|
||||
'./src/__test__/setup_jest.js'
|
||||
],
|
||||
moduleNameMapper: {
|
||||
'@/(.*)': '<rootDir>/src/$1'
|
||||
}
|
||||
};
|
|
@ -0,0 +1,25 @@
|
|||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const src = "./src/extension/"
|
||||
const dst = "./dist"
|
||||
fs.readdirSync(src).forEach(file => {
|
||||
let srcPath = path.join(src, file)
|
||||
let dstPath = path.join(dst, file)
|
||||
fs.copyFileSync(srcPath, dstPath)
|
||||
console.log(`Copy: ${srcPath} => ${dstPath}`)
|
||||
})
|
||||
|
||||
const manifestRaw = fs.readFileSync("./extension-manifest.json", "utf-8")
|
||||
const manifest = JSON.parse(manifestRaw)
|
||||
|
||||
const pkgRaw = fs.readFileSync("./package.json", "utf-8")
|
||||
const pkg = JSON.parse(pkgRaw)
|
||||
|
||||
verExt = pkg["version"]
|
||||
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("./dist/manifest.json", JSON.stringify(manifest), "utf-8")
|
||||
console.log("Write: manifest.json")
|
File diff suppressed because it is too large
Load Diff
55
package.json
55
package.json
|
@ -1,7 +1,8 @@
|
|||
{
|
||||
"name": "unlock-music",
|
||||
"version": "1.7.0",
|
||||
"updateInfo": "适配深色模式;修复.ncm解锁的一些问题",
|
||||
"version": "v1.10.0",
|
||||
"ext_build": 0,
|
||||
"updateInfo": "重写QMC解锁,完全支持.mflac*/.mgg*; 支持JOOX解锁",
|
||||
"license": "MIT",
|
||||
"description": "Unlock encrypted music file in browser.",
|
||||
"repository": {
|
||||
|
@ -10,30 +11,50 @@
|
|||
},
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"postinstall": "patch-package",
|
||||
"serve": "vue-cli-service serve",
|
||||
"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"
|
||||
},
|
||||
"dependencies": {
|
||||
"base64-js": "^1.3.1",
|
||||
"@babel/preset-typescript": "^7.16.5",
|
||||
"@jixun/kugou-crypto": "^1.0.3",
|
||||
"@jixun/qmc2-crypto": "^0.0.6-R1",
|
||||
"@unlock-music/joox-crypto": "^0.0.1-R5",
|
||||
"base64-js": "^1.5.1",
|
||||
"browser-id3-writer": "^4.4.0",
|
||||
"core-js": "^3.7.0",
|
||||
"crypto-js": "^4.0.0",
|
||||
"element-ui": "^2.14.0",
|
||||
"iconv-lite": "^0.5.1",
|
||||
"jimp": "^0.14.0",
|
||||
"core-js": "^3.16.0",
|
||||
"crypto-js": "^4.1.1",
|
||||
"element-ui": "^2.15.5",
|
||||
"iconv-lite": "^0.6.3",
|
||||
"jimp": "^0.16.1",
|
||||
"metaflac-js": "^1.0.5",
|
||||
"music-metadata-browser": "^2.1.6",
|
||||
"register-service-worker": "^1.7.1",
|
||||
"vue": "^2.6.12"
|
||||
"music-metadata": "7.9.0",
|
||||
"music-metadata-browser": "2.2.7",
|
||||
"register-service-worker": "^1.7.2",
|
||||
"threads": "^1.6.5",
|
||||
"vue": "^2.6.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vue/cli-plugin-babel": "^4.5.8",
|
||||
"@vue/cli-plugin-pwa": "^4.5.8",
|
||||
"@vue/cli-service": "^4.5.8",
|
||||
"@types/crypto-js": "^4.0.2",
|
||||
"@types/jest": "^27.0.3",
|
||||
"@vue/cli-plugin-babel": "^4.5.13",
|
||||
"@vue/cli-plugin-pwa": "^4.5.13",
|
||||
"@vue/cli-plugin-typescript": "^4.5.13",
|
||||
"@vue/cli-service": "^4.5.13",
|
||||
"babel-plugin-component": "^1.1.1",
|
||||
"jest": "^27.4.5",
|
||||
"patch-package": "^6.4.7",
|
||||
"prettier": "2.5.1",
|
||||
"sass": "^1.38.1",
|
||||
"sass-loader": "^10.2.0",
|
||||
"semver": "^7.3.5",
|
||||
"threads-plugin": "^1.4.0",
|
||||
"typescript": "^4.5.4",
|
||||
"vue-cli-plugin-element": "^1.0.1",
|
||||
"vue-template-compiler": "^2.6.12",
|
||||
"workerize-loader": "^1.3.0"
|
||||
"vue-template-compiler": "^2.6.14"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
|
@ -5,16 +5,12 @@
|
|||
<meta content="webkit" name="renderer">
|
||||
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
|
||||
<meta content="width=device-width,initial-scale=1.0" name="viewport">
|
||||
<!--@formatter:off-->
|
||||
<script>var _paq=window._paq||[];_paq.push(["setRequestMethod","POST"],["trackPageView"],["enableLinkTracking"],["setSiteId","2"],["setTrackerUrl","https://stats.ixarea.com/ixarea-stats/report"]);</script>
|
||||
<!--@formatter:on-->
|
||||
<script async src="https://stats.ixarea.com/ixarea-stats.js"></script>
|
||||
<title>音乐解锁 - By IXarea</title>
|
||||
<title>音乐解锁</title>
|
||||
<meta content="音乐,解锁,ncm,qmc,mgg,mflac,qq音乐,网易云音乐,加密" name="keywords"/>
|
||||
<meta content="音乐解锁 - 在任何设备上解锁已购的加密音乐!" name="description"/>
|
||||
<script src="./ixarea-stats.js"></script>
|
||||
<!--@formatter:off-->
|
||||
<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 #3498db;width:120px;height:120px;animation:spin 2s linear infinite}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}#loader-mask{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)}</style>
|
||||
<!--深色模式--><style>@media(prefers-color-scheme:dark){#loader-mask,html,.el-notification,.el-upload-dragger,.el-icon-upload,.el-table,.el-table__expanded-cell,.el-table th,.el-table tr{background-color:#111 !important;}html,.el-notification__content,.el-notification__title,#app,.el-upload__text,.el-table,.el-table__expanded-cell,.el-table th,.el-table tr{color:#DDD !important;}.el-upload__tip,.el-radio__label,.el-checkbox__label,.el-table__empty-text{color:#AAA !important;}.el-table tr:hover,.el-table tr:hover td{background-color:#555 !important;transition:background-color .4s !important;}}</style>
|
||||
<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>
|
||||
<!--@formatter:on-->
|
||||
</head>
|
||||
<body>
|
||||
|
@ -28,12 +24,12 @@
|
|||
style="border:0"/>
|
||||
</noscript>
|
||||
<h3 id="loader-source"> 请勿直接运行源代码! </h3>
|
||||
<div hidden id="loader-tips-outdated">
|
||||
<div id="loader-tips-outdated" hidden>
|
||||
<h2>您可能在使用不受支持的<span style="color:#f00;">过时</span>浏览器,这可能导致此应用无法正常工作。</h2>
|
||||
<h3>如果您使用双核浏览器,您可以尝试切换到 <span style="color:#f00;">“极速模式”</span> 解决此问题。</h3>
|
||||
<h3>或者,您可以尝试更换下方的几个浏览器之一。</h3>
|
||||
</div>
|
||||
<h3 hidden id="loader-tips-timeout">
|
||||
<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.google.cn/chrome/" target="_blank">Google Chrome</a>
|
||||
|
@ -42,32 +38,6 @@
|
|||
</h3>
|
||||
</div>
|
||||
<div id="app"></div>
|
||||
<script>
|
||||
(function () {
|
||||
setTimeout(function () {
|
||||
var ele = document.getElementById("loader-tips-timeout");
|
||||
if (ele != null) {
|
||||
ele.hidden = false;
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
var ua = navigator && navigator.userAgent;
|
||||
var detected = (function () {
|
||||
var m;
|
||||
if (!ua) return true;
|
||||
if (/MSIE |Trident\//.exec(ua)) return true; // no IE
|
||||
m = /Edge\/([\d.]+)/.exec(ua); // Edge >= 17
|
||||
if (m && Number(m[1]) < 17) return true;
|
||||
m = /Chrome\/([\d.]+)/.exec(ua); // Chrome >= 58
|
||||
if (m && Number(m[1]) < 58) return true;
|
||||
m = /Firefox\/([\d.]+)/.exec(ua); // Firefox >= 45
|
||||
return m && Number(m[1]) < 45;
|
||||
})();
|
||||
if (detected) {
|
||||
document.getElementById('loader-tips-outdated').hidden = false;
|
||||
document.getElementById("loader-tips-timeout").hidden = false;
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="./loader.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
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);
|
|
@ -0,0 +1,25 @@
|
|||
(function () {
|
||||
setTimeout(function () {
|
||||
var ele = document.getElementById("loader-tips-timeout");
|
||||
if (ele != null) {
|
||||
ele.hidden = false;
|
||||
}
|
||||
}, 2000);
|
||||
|
||||
var ua = navigator && navigator.userAgent;
|
||||
var detected = (function () {
|
||||
var m;
|
||||
if (!ua) return true;
|
||||
if (/MSIE |Trident\//.exec(ua)) return true; // no IE
|
||||
m = /Edge\/([\d.]+)/.exec(ua); // Edge >= 17
|
||||
if (m && Number(m[1]) < 17) return true;
|
||||
m = /Chrome\/([\d.]+)/.exec(ua); // Chrome >= 58
|
||||
if (m && Number(m[1]) < 58) return true;
|
||||
m = /Firefox\/([\d.]+)/.exec(ua); // Firefox >= 45
|
||||
return m && Number(m[1]) < 45;
|
||||
})();
|
||||
if (detected) {
|
||||
document.getElementById('loader-tips-outdated').hidden = false;
|
||||
document.getElementById("loader-tips-timeout").hidden = false;
|
||||
}
|
||||
})();
|
|
@ -1,21 +0,0 @@
|
|||
{
|
||||
"name": "音乐解锁 - By IXarea",
|
||||
"short_name": "音乐解锁",
|
||||
"description": "在任何设备上解锁已购的加密音乐!支持QQ音乐与网易云音乐!",
|
||||
"icons": [
|
||||
{
|
||||
"src": "./img/icons/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "./img/icons/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"background_color": "#000000",
|
||||
"theme_color": "#4DBA87"
|
||||
}
|
Binary file not shown.
290
src/App.vue
290
src/App.vue
|
@ -1,212 +1,94 @@
|
|||
<template>
|
||||
|
||||
<el-container id="app">
|
||||
<el-main>
|
||||
<x-upload v-on:handle_error="showFail" v-on:handle_finish="showSuccess"></x-upload>
|
||||
|
||||
<div id="app-control">
|
||||
<el-row style="padding-bottom: 1em; font-size: 14px">
|
||||
歌曲命名格式:
|
||||
<el-radio label="1" name="format" v-model="download_format">歌手-歌曲名</el-radio>
|
||||
<el-radio label="2" name="format" v-model="download_format">歌曲名</el-radio>
|
||||
<el-radio label="3" name="format" v-model="download_format">歌曲名-歌手</el-radio>
|
||||
<el-radio label="4" name="format" v-model="download_format">同原文件名</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<el-button @click="handleDownloadAll" icon="el-icon-download" plain>下载全部</el-button>
|
||||
<el-button @click="handleDeleteAll" icon="el-icon-delete" plain type="danger">清除全部</el-button>
|
||||
|
||||
|
||||
<el-tooltip class="item" effect="dark" placement="top-start">
|
||||
<div slot="content">
|
||||
当您使用此工具进行大量文件解锁的时候,建议开启此选项。<br/>
|
||||
开启后,解锁结果将不会存留于浏览器中,防止内存不足。
|
||||
</div>
|
||||
<el-checkbox border style="margin-left: 1em" v-model="instant_download">立即保存</el-checkbox>
|
||||
</el-tooltip>
|
||||
|
||||
|
||||
</el-row>
|
||||
</div>
|
||||
<audio :autoplay="playing_auto" :src="playing_url" controls/>
|
||||
|
||||
<x-preview :download_format="download_format" :table-data="tableData"
|
||||
v-on:music_changed="changePlaying"></x-preview>
|
||||
|
||||
</el-main>
|
||||
<el-footer id="app-footer">
|
||||
<el-row>
|
||||
<a href="https://github.com/ix64/unlock-music" target="_blank">音乐解锁</a>(v<span
|
||||
v-text="version"></span>):移除已购音乐的加密保护。
|
||||
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
|
||||
</el-row>
|
||||
<el-row>
|
||||
目前支持网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
|
||||
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>。
|
||||
</el-row>
|
||||
<el-row>
|
||||
<!--如果进行二次开发,此行版权信息不得移除且应明显地标注于页面上-->
|
||||
<span>Copyright © 2019-</span><span v-text="(new Date()).getFullYear()"></span> MengYX
|
||||
音乐解锁使用
|
||||
<a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a>
|
||||
开放源代码
|
||||
</el-row>
|
||||
</el-footer>
|
||||
</el-container>
|
||||
|
||||
<el-container id="app">
|
||||
<el-main>
|
||||
<Home />
|
||||
</el-main>
|
||||
<el-footer id="app-footer">
|
||||
<el-row>
|
||||
<a href="https://github.com/ix64/unlock-music" target="_blank">音乐解锁</a>({{ version }})
|
||||
:移除已购音乐的加密保护。
|
||||
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
|
||||
</el-row>
|
||||
<el-row>
|
||||
目前支持 网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
|
||||
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>。
|
||||
</el-row>
|
||||
<el-row>
|
||||
<!--如果进行二次开发,此行版权信息不得移除且应明显地标注于页面上-->
|
||||
<span>Copyright © 2019 - {{ new Date().getFullYear() }} MengYX</span>
|
||||
音乐解锁使用
|
||||
<a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a>
|
||||
开放源代码
|
||||
</el-row>
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FileSelector from '@/component/FileSelector';
|
||||
import PreviewTable from '@/component/PreviewTable';
|
||||
import config from '@/../package.json';
|
||||
import Home from '@/view/Home';
|
||||
import { checkUpdate } from '@/utils/api';
|
||||
|
||||
import upload from "./component/upload"
|
||||
import preview from "./component/preview"
|
||||
import {DownloadBlobMusic, RemoveBlobMusic} from "./component/util"
|
||||
import config from "../package"
|
||||
import {IXAREA_API_ENDPOINT} from "./decrypt/util";
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: {
|
||||
xUpload: upload,
|
||||
xPreview: preview
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
version: config.version,
|
||||
activeIndex: '1',
|
||||
tableData: [],
|
||||
playing_url: "",
|
||||
playing_auto: false,
|
||||
download_format: '1',
|
||||
instant_download: false,
|
||||
}
|
||||
},
|
||||
created() {
|
||||
this.$nextTick(function () {
|
||||
this.finishLoad();
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
async finishLoad() {
|
||||
const mask = document.getElementById("loader-mask");
|
||||
if (!!mask) mask.remove();
|
||||
let updateInfo;
|
||||
try {
|
||||
const resp = await fetch(IXAREA_API_ENDPOINT + "/music/app-version", {
|
||||
method: "POST", headers: {"Content-Type": "application/json"},
|
||||
body: JSON.stringify({"Version": this.version})
|
||||
});
|
||||
updateInfo = await resp.json();
|
||||
} catch (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'
|
||||
});
|
||||
}
|
||||
},
|
||||
showSuccess(data) {
|
||||
if (data.status) {
|
||||
if (this.instant_download) {
|
||||
DownloadBlobMusic(data, this.download_format);
|
||||
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)]);
|
||||
}
|
||||
} else {
|
||||
this.showFail(data.message, data.rawFilename + "." + data.rawExt)
|
||||
}
|
||||
},
|
||||
showFail(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", errInfo, filename]);
|
||||
}
|
||||
console.error(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) {
|
||||
DownloadBlobMusic(this.tableData[index], this.download_format);
|
||||
index++;
|
||||
} else {
|
||||
clearInterval(c);
|
||||
}
|
||||
}, 300);
|
||||
}
|
||||
},
|
||||
|
||||
}
|
||||
|
||||
export default {
|
||||
name: 'app',
|
||||
components: {
|
||||
FileSelector,
|
||||
PreviewTable,
|
||||
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://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>
|
||||
</div>`,
|
||||
dangerouslyUseHTMLString: true,
|
||||
duration: 10000,
|
||||
position: 'top-left',
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style>
|
||||
#app {
|
||||
font-family: "Helvetica Neue", Helvetica, "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-align: center;
|
||||
color: #2c3e50;
|
||||
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;
|
||||
}
|
||||
|
||||
<style lang="scss">
|
||||
@import 'scss/unlock-music';
|
||||
</style>
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
// Polyfill for node.
|
||||
global.Blob = global.Blob || require("node:buffer").Blob;
|
|
@ -0,0 +1,113 @@
|
|||
<style scoped>
|
||||
label {
|
||||
cursor: pointer;
|
||||
line-height: 1.2;
|
||||
display: block;
|
||||
}
|
||||
.item-desc {
|
||||
color: #aaa;
|
||||
font-size: small;
|
||||
display: block;
|
||||
line-height: 1.2;
|
||||
margin-top: 0.2em;
|
||||
}
|
||||
.item-desc a {
|
||||
color: #aaa;
|
||||
}
|
||||
|
||||
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="item-desc">
|
||||
下载该加密文件的 JOOX 应用所记录的设备唯一识别码。
|
||||
<br />
|
||||
参见:
|
||||
<a href="https://github.com/unlock-music/joox-crypto/wiki/%E8%8E%B7%E5%8F%96%E8%AE%BE%E5%A4%87-UUID">
|
||||
获取设备 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>
|
|
@ -0,0 +1,91 @@
|
|||
<template>
|
||||
<el-upload :auto-upload="false" :on-change="addFile" :show-file-list="false" action="" drag multiple>
|
||||
<i class="el-icon-upload" />
|
||||
<div class="el-upload__text">将文件拖到此处,或 <em>点击选择</em></div>
|
||||
<div slot="tip" class="el-upload__tip">
|
||||
<div>
|
||||
仅在浏览器内对文件进行解锁,无需消耗流量
|
||||
<el-tooltip effect="dark" placement="top-start">
|
||||
<div slot="content">算法在源代码中已经提供,所有运算都发生在本地</div>
|
||||
<i class="el-icon-info" style="font-size: 12px" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
<div>
|
||||
工作模式: {{ parallel ? '多线程 Worker' : '单线程 Queue' }}
|
||||
<el-tooltip effect="dark" placement="top-start">
|
||||
<div slot="content">
|
||||
将此工具部署在HTTPS环境下,可以启用Web Worker特性,<br />
|
||||
从而更快的利用并行处理完成解锁
|
||||
</div>
|
||||
<i class="el-icon-info" style="font-size: 12px" />
|
||||
</el-tooltip>
|
||||
</div>
|
||||
</div>
|
||||
<transition name="el-fade-in"
|
||||
><!--todo: add delay to animation-->
|
||||
<el-progress
|
||||
v-show="progress_show"
|
||||
:format="progress_string"
|
||||
:percentage="progress_value"
|
||||
:stroke-width="16"
|
||||
:text-inside="true"
|
||||
style="margin: 16px 6px 0 6px"
|
||||
></el-progress>
|
||||
</transition>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { spawn, Worker, Pool } from 'threads';
|
||||
import { Decrypt } from '@/decrypt';
|
||||
import { DecryptQueue } from '@/utils/utils';
|
||||
import { storage } from '@/utils/storage';
|
||||
|
||||
export default {
|
||||
name: 'FileSelector',
|
||||
data() {
|
||||
return {
|
||||
task_all: 0,
|
||||
task_finished: 0,
|
||||
queue: new DecryptQueue(), // for http or file protocol
|
||||
parallel: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
progress_value() {
|
||||
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 && window.location.protocol !== 'file:' && process.env.NODE_ENV === 'production') {
|
||||
console.log('Using Worker Pool');
|
||||
this.queue = Pool(() => spawn(new Worker('@/utils/worker.ts')), navigator.hardwareConcurrency || 1);
|
||||
this.parallel = true;
|
||||
} 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 = 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>
|
|
@ -0,0 +1,62 @@
|
|||
<template>
|
||||
<el-table :data="tableData" style="width: 100%">
|
||||
<el-table-column label="封面">
|
||||
<template slot-scope="scope">
|
||||
<el-image :src="scope.row.picture" style="width: 100px; height: 100px">
|
||||
<div slot="error" class="image-slot el-image__error">暂无封面</div>
|
||||
</el-image>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="歌曲">
|
||||
<template #default="scope">
|
||||
<span>{{ scope.row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="歌手">
|
||||
<template #default="scope">
|
||||
<p>{{ scope.row.artist }}</p>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="专辑">
|
||||
<template #default="scope">
|
||||
<p>{{ scope.row.album }}</p>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作">
|
||||
<template #default="scope">
|
||||
<el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
|
||||
</el-button>
|
||||
<el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button>
|
||||
<el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { RemoveBlobMusic } from '@/utils/utils';
|
||||
|
||||
export default {
|
||||
name: 'PreviewTable',
|
||||
props: {
|
||||
tableData: { type: Array, required: true },
|
||||
policy: { type: Number, required: true },
|
||||
},
|
||||
|
||||
methods: {
|
||||
handlePlay(index, row) {
|
||||
this.$emit('play', row.file);
|
||||
},
|
||||
handleDelete(index, row) {
|
||||
RemoveBlobMusic(row);
|
||||
this.tableData.splice(index, 1);
|
||||
},
|
||||
handleDownload(row) {
|
||||
this.$emit('download', row);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
|
@ -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>
|
|
@ -1,71 +0,0 @@
|
|||
<template>
|
||||
<el-table :data="tableData" style="width: 100%">
|
||||
|
||||
<el-table-column label="封面">
|
||||
<template slot-scope="scope">
|
||||
<el-image :src="scope.row.picture" style="width: 100px; height: 100px">
|
||||
<div class="image-slot el-image__error" slot="error">
|
||||
暂无封面
|
||||
</div>
|
||||
</el-image>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="歌曲">
|
||||
<template slot-scope="scope">
|
||||
<span style="margin-left: 10px">{{ scope.row.title }}</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="歌手">
|
||||
<template slot-scope="scope">
|
||||
<p>{{ scope.row.artist }}</p>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="专辑">
|
||||
<template slot-scope="scope">
|
||||
<p>{{ scope.row.album }}</p>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作">
|
||||
<template slot-scope="scope">
|
||||
<el-button @click="handlePlay(scope.$index, scope.row)"
|
||||
circle icon="el-icon-video-play" type="success">
|
||||
</el-button>
|
||||
<el-button @click="handleDownload(scope.row)"
|
||||
circle icon="el-icon-download">
|
||||
</el-button>
|
||||
<el-button @click="handleDelete(scope.$index, scope.row)"
|
||||
circle icon="el-icon-delete" type="danger">
|
||||
</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import {DownloadBlobMusic, RemoveBlobMusic} from './util'
|
||||
|
||||
export default {
|
||||
name: "preview",
|
||||
props: {
|
||||
tableData: {type: Array, required: true},
|
||||
download_format: {type: String, required: true}
|
||||
},
|
||||
|
||||
methods: {
|
||||
handlePlay(index, row) {
|
||||
this.$emit("music_changed", row.file);
|
||||
},
|
||||
handleDelete(index, row) {
|
||||
RemoveBlobMusic(row);
|
||||
this.tableData.splice(index, 1);
|
||||
},
|
||||
handleDownload(row) {
|
||||
DownloadBlobMusic(row, this.download_format)
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
</style>
|
|
@ -1,120 +0,0 @@
|
|||
<template>
|
||||
<el-upload
|
||||
:auto-upload="false"
|
||||
:on-change="handleFile"
|
||||
:show-file-list="false"
|
||||
action=""
|
||||
drag
|
||||
multiple>
|
||||
<i class="el-icon-upload"/>
|
||||
<div class="el-upload__text">将文件拖到此处,或<em>点击选择</em></div>
|
||||
<div class="el-upload__tip" slot="tip">本工具仅在浏览器内对文件进行解锁,无需消耗流量</div>
|
||||
<transition name="el-fade-in">
|
||||
<el-progress
|
||||
:format="progressFormat" :percentage="progress_percent" :stroke-width="16"
|
||||
:text-inside="true" style="margin: 16px 6px 0 6px"
|
||||
v-show="progress_show"
|
||||
></el-progress>
|
||||
</transition>
|
||||
</el-upload>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
"use strict";// 严格模式 用于尾调用优化
|
||||
|
||||
export default {
|
||||
name: "upload",
|
||||
data() {
|
||||
return {
|
||||
cacheQueue: [],
|
||||
workers: [],
|
||||
idle_workers: [],
|
||||
thread_num: 1,
|
||||
|
||||
progress_show: false,
|
||||
|
||||
progress_finished: 0,
|
||||
progress_all: 0,
|
||||
progress_percent: 0,
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (document.location.host !== "" && process.env.NODE_ENV === 'production') {
|
||||
this.thread_num = navigator.hardwareConcurrency || 1;
|
||||
const worker = require("workerize-loader!../decrypt/common");
|
||||
// noinspection JSValidateTypes,JSUnresolvedVariable
|
||||
this.workers.push(worker().CommonDecrypt);
|
||||
this.idle_workers.push(0);
|
||||
// delay to optimize for first loading
|
||||
setTimeout(() => {
|
||||
for (let i = 1; i < this.thread_num; i++) {
|
||||
// noinspection JSValidateTypes,JSUnresolvedVariable
|
||||
this.workers.push(worker().CommonDecrypt);
|
||||
this.idle_workers.push(i);
|
||||
}
|
||||
}, 5000);
|
||||
} else {
|
||||
const dec = require('../decrypt/common');
|
||||
this.workers.push(dec.CommonDecrypt);
|
||||
this.idle_workers.push(0)
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
progressFormat() {
|
||||
return this.progress_finished + "/" + (this.progress_all)
|
||||
},
|
||||
progressChange(finish, all) {
|
||||
this.progress_all += all;
|
||||
this.progress_finished += finish;
|
||||
this.progress_percent = Math.round(this.progress_finished / this.progress_all * 100);
|
||||
if (this.progress_finished === this.progress_all) {
|
||||
setTimeout(() => {
|
||||
this.progress_show = false;
|
||||
this.progress_finished = 0;
|
||||
this.progress_all = 0;
|
||||
}, 3000);
|
||||
} else {
|
||||
this.progress_show = true;
|
||||
}
|
||||
},
|
||||
handleFile(file) {
|
||||
this.progressChange(0, +1);
|
||||
// 有空闲worker 立刻处理文件
|
||||
if (this.idle_workers.length > 0) {
|
||||
this.handleDoFile(file, this.idle_workers.shift());
|
||||
}
|
||||
// 无空闲worker 则放入缓存队列
|
||||
else {
|
||||
this.cacheQueue.push(file);
|
||||
}
|
||||
},
|
||||
handleCacheQueue(worker_id) {
|
||||
// 调用方法消费缓存队列中的数据
|
||||
if (this.cacheQueue.length === 0) {
|
||||
this.idle_workers.push(worker_id);
|
||||
return
|
||||
}
|
||||
this.handleDoFile(this.cacheQueue.shift(), worker_id);
|
||||
},
|
||||
handleDoFile(file, worker_id) {
|
||||
this.workers[worker_id](file).then(data => {
|
||||
this.$emit("handle_finish", data);
|
||||
// 完成之后 执行新任务 todo: 可能导致call stack过长
|
||||
this.handleCacheQueue(worker_id);
|
||||
this.progressChange(+1, 0);
|
||||
}).catch(err => {
|
||||
this.$emit("handle_error", err, file.name);
|
||||
this.handleCacheQueue(worker_id);
|
||||
this.progressChange(+1, 0);
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/*noinspection CssUnusedSymbol*/
|
||||
.el-upload-dragger {
|
||||
width: 80vw !important;
|
||||
}
|
||||
</style>
|
|
@ -1,30 +0,0 @@
|
|||
export function DownloadBlobMusic(data, format) {
|
||||
const a = document.createElement('a');
|
||||
a.href = data.file;
|
||||
switch (format) {
|
||||
default:
|
||||
case "1":
|
||||
a.download = data.artist + " - " + data.title + "." + data.ext;
|
||||
break;
|
||||
case "2":
|
||||
a.download = data.title + "." + data.ext;
|
||||
break;
|
||||
case "3":
|
||||
a.download = data.title + " - " + data.artist + "." + data.ext;
|
||||
break;
|
||||
case "4":
|
||||
a.download = data.rawFilename + "." + data.ext;
|
||||
break;
|
||||
}
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
export function RemoveBlobMusic(data) {
|
||||
URL.revokeObjectURL(data.file);
|
||||
if (data.picture.startsWith("blob:")) {
|
||||
URL.revokeObjectURL(data.picture);
|
||||
}
|
||||
|
||||
}
|
Binary file not shown.
|
@ -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://github.com/unlock-music',
|
||||
};
|
||||
});
|
||||
|
||||
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,68 +0,0 @@
|
|||
const NcmDecrypt = require("./ncm");
|
||||
const KwmDecrypt = require("./kwm");
|
||||
const XmDecrypt = require("./xm");
|
||||
const QmcDecrypt = require("./qmc");
|
||||
const RawDecrypt = require("./raw");
|
||||
const TmDecrypt = require("./tm");
|
||||
const KgmDecrypt = require("./kgm");
|
||||
|
||||
export async function CommonDecrypt(file) {
|
||||
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;
|
||||
switch (raw_ext) {
|
||||
case "ncm":// Netease Mp3/Flac
|
||||
rt_data = await NcmDecrypt.Decrypt(file.raw, raw_filename, raw_ext);
|
||||
break;
|
||||
case "kwm":// Kuwo Mp3/Flac
|
||||
rt_data = await KwmDecrypt.Decrypt(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.Decrypt(file.raw, raw_filename, raw_ext);
|
||||
break;
|
||||
case "ogg":// Raw Ogg
|
||||
rt_data = await RawDecrypt.Decrypt(file.raw, raw_filename, raw_ext);
|
||||
break;
|
||||
case "tm0":// QQ Music IOS Mp3
|
||||
case "tm3":// QQ Music IOS Mp3
|
||||
rt_data = await RawDecrypt.Decrypt(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.Decrypt(file.raw, raw_filename, raw_ext);
|
||||
break;
|
||||
case "tm2":// QQ Music IOS M4a
|
||||
case "tm6":// QQ Music IOS M4a
|
||||
rt_data = await TmDecrypt.Decrypt(file.raw, raw_filename);
|
||||
break;
|
||||
case "vpr":
|
||||
case "kgm":
|
||||
case "kgma":
|
||||
rt_data = await KgmDecrypt.Decrypt(file.raw, raw_filename, raw_ext);
|
||||
break
|
||||
default:
|
||||
rt_data = {status: false, message: "不支持此文件格式",}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
export interface DecryptResult {
|
||||
title: string;
|
||||
album?: string;
|
||||
artist?: string;
|
||||
|
||||
mime: string;
|
||||
ext: string;
|
||||
|
||||
file: string;
|
||||
blob: Blob;
|
||||
picture?: string;
|
||||
|
||||
message?: string;
|
||||
rawExt?: string;
|
||||
rawFilename?: string;
|
||||
}
|
||||
|
||||
export interface FileInfo {
|
||||
status: string;
|
||||
name: string;
|
||||
size: number;
|
||||
percentage: number;
|
||||
uid: number;
|
||||
raw: File;
|
||||
}
|
|
@ -0,0 +1,99 @@
|
|||
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 { 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 '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 '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
|
||||
// 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 'mgg': //QQ Music New Ogg
|
||||
case 'mgg1': //QQ Music New Ogg
|
||||
case 'mgg0':
|
||||
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;
|
||||
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;
|
||||
}
|
|
@ -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,120 +0,0 @@
|
|||
import {AudioMimeType, DetectAudioExt, GetArrayBuffer, GetFileInfo, GetMetaCoverURL, IsBytesEqual} from "./util";
|
||||
|
||||
const musicMetadata = require("music-metadata-browser");
|
||||
const VprHeader = [
|
||||
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
|
||||
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31]
|
||||
const KgmHeader = [
|
||||
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
|
||||
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, raw_filename, raw_ext) {
|
||||
try {
|
||||
if (window.location.protocol === "file:") {
|
||||
return {
|
||||
status: false,
|
||||
message: "请使用<a target='_blank' href='https://github.com/ix64/unlock-music/wiki/其他音乐格式工具'>CLI版本</a>进行解锁"
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
||||
if (raw_ext === "vpr") {
|
||||
if (!IsBytesEqual(VprHeader, oriData.slice(0, 0x10)))
|
||||
return {status: false, message: "Not a valid vpr file!"}
|
||||
} else {
|
||||
if (!IsBytesEqual(KgmHeader, oriData.slice(0, 0x10)))
|
||||
return {status: false, message: "Not a valid kgm/kgma 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) {
|
||||
return {
|
||||
status: false,
|
||||
message: "文件过大,请使用<a target='_blank' href='https://github.com/ix64/unlock-music/wiki/其他音乐格式工具'>CLI版本</a>进行解锁"
|
||||
}
|
||||
}
|
||||
|
||||
let key1 = new Uint8Array(17)
|
||||
key1.set(oriData.slice(0x1c, 0x2c), 0)
|
||||
if (MaskV2 == null) {
|
||||
if (!await LoadMaskV2()) {
|
||||
return {status: false, message: "加载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 = DetectAudioExt(audioData, "mp3");
|
||||
const mime = AudioMimeType[ext];
|
||||
let musicBlob = new Blob([audioData], {type: mime});
|
||||
const musicMeta = await musicMetadata.parseBlob(musicBlob);
|
||||
const info = GetFileInfo(musicMeta.common.artist, musicMeta.common.title, raw_filename);
|
||||
const imgUrl = GetMetaCoverURL(musicMeta);
|
||||
return {
|
||||
status: true,
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
ext: ext,
|
||||
album: musicMeta.common.album,
|
||||
picture: imgUrl,
|
||||
file: URL.createObjectURL(musicBlob),
|
||||
mime: mime
|
||||
}
|
||||
}
|
||||
|
||||
function GetMask(pos) {
|
||||
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4]
|
||||
}
|
||||
|
||||
let MaskV2 = null;
|
||||
|
||||
async function LoadMaskV2() {
|
||||
try {
|
||||
let 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,
|
||||
]
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
import {
|
||||
AudioMimeType,
|
||||
BytesHasPrefix,
|
||||
GetArrayBuffer,
|
||||
GetCoverFromFile,
|
||||
GetMetaFromFile,
|
||||
SniffAudioExt,
|
||||
} from '@/decrypt/utils';
|
||||
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||
import { DecryptResult } from '@/decrypt/entity';
|
||||
import { decryptKgmByteAtOffsetV2, decryptVprByteAtOffset } from '@jixun/kugou-crypto/dist/utils/decryptionHelper';
|
||||
|
||||
//prettier-ignore
|
||||
const VprHeader = [
|
||||
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
|
||||
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31
|
||||
]
|
||||
//prettier-ignore
|
||||
const KgmHeader = [
|
||||
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
|
||||
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14
|
||||
]
|
||||
|
||||
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||
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;
|
||||
|
||||
let key1 = Array.from(oriData.slice(0x1c, 0x2c));
|
||||
key1.push(0);
|
||||
|
||||
const decryptByte = raw_ext === 'vpr' ? decryptVprByteAtOffset : decryptKgmByteAtOffsetV2;
|
||||
for (let i = 0; i < dataLen; i++) {
|
||||
audioData[i] = decryptByte(audioData[i], key1, i);
|
||||
}
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
|
@ -1,66 +0,0 @@
|
|||
import {AudioMimeType, DetectAudioExt, GetArrayBuffer, GetFileInfo, GetMetaCoverURL, IsBytesEqual} from "./util";
|
||||
|
||||
const musicMetadata = require("music-metadata-browser");
|
||||
const MagicHeader = [
|
||||
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
|
||||
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65,
|
||||
]
|
||||
const PreDefinedKey = "MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk"
|
||||
|
||||
export async function Decrypt(file, raw_filename, raw_ext) {
|
||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
||||
if (!IsBytesEqual(MagicHeader, oriData.slice(0, 0x10)))
|
||||
return {status: false, message: "Not a valid kwm file!"}
|
||||
|
||||
let fileKey = oriData.slice(0x18, 0x20)
|
||||
let mask = createMaskFromKey(fileKey)
|
||||
let audioData = oriData.slice(0x400);
|
||||
let lenAudioData = audioData.length;
|
||||
for (let cur = 0; cur < lenAudioData; ++cur)
|
||||
audioData[cur] ^= mask[cur % 0x20];
|
||||
|
||||
|
||||
const ext = DetectAudioExt(audioData, "mp3");
|
||||
const mime = AudioMimeType[ext];
|
||||
let musicBlob = new Blob([audioData], {type: mime});
|
||||
|
||||
const musicMeta = await musicMetadata.parseBlob(musicBlob);
|
||||
const info = GetFileInfo(musicMeta.common.artist, musicMeta.common.title, raw_filename);
|
||||
|
||||
const imgUrl = GetMetaCoverURL(musicMeta);
|
||||
|
||||
return {
|
||||
status: true,
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
ext: ext,
|
||||
album: musicMeta.common.album,
|
||||
picture: imgUrl,
|
||||
file: URL.createObjectURL(musicBlob),
|
||||
mime: mime
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function createMaskFromKey(keyBytes) {
|
||||
let keyView = new DataView(keyBytes.buffer)
|
||||
let keyStr = keyView.getBigUint64(0, true).toString()
|
||||
let keyStrTrim = trimKey(keyStr)
|
||||
let key = new Uint8Array(32)
|
||||
for (let i = 0; i < 32; i++) {
|
||||
key[i] = PreDefinedKey[i].charCodeAt() ^ keyStrTrim[i].charCodeAt()
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
|
||||
function trimKey(keyRaw) {
|
||||
let lenRaw = keyRaw.length;
|
||||
let out = keyRaw;
|
||||
if (lenRaw > 32) {
|
||||
out = keyRaw.slice(0, 32)
|
||||
} else if (lenRaw < 32) {
|
||||
out = keyRaw.padEnd(32, keyRaw)
|
||||
}
|
||||
return out
|
||||
}
|
|
@ -0,0 +1,74 @@
|
|||
import {
|
||||
AudioMimeType,
|
||||
BytesHasPrefix,
|
||||
GetArrayBuffer,
|
||||
GetCoverFromFile,
|
||||
GetMetaFromFile,
|
||||
SniffAudioExt,
|
||||
} from '@/decrypt/utils';
|
||||
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
|
||||
|
||||
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||
import { DecryptResult } from '@/decrypt/entity';
|
||||
|
||||
//prettier-ignore
|
||||
const MagicHeader = [
|
||||
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
|
||||
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65,
|
||||
]
|
||||
const PreDefinedKey = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';
|
||||
|
||||
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
|
||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
||||
if (!BytesHasPrefix(oriData, MagicHeader)) {
|
||||
if (SniffAudioExt(oriData) === 'aac') {
|
||||
return await RawDecrypt(file, raw_filename, 'aac', false);
|
||||
}
|
||||
throw Error('not a valid kwm file');
|
||||
}
|
||||
|
||||
let fileKey = oriData.slice(0x18, 0x20);
|
||||
let mask = createMaskFromKey(fileKey);
|
||||
let audioData = oriData.slice(0x400);
|
||||
let lenAudioData = audioData.length;
|
||||
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= mask[cur % 0x20];
|
||||
|
||||
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,
|
||||
mime,
|
||||
title,
|
||||
artist,
|
||||
ext,
|
||||
};
|
||||
}
|
||||
|
||||
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array {
|
||||
let keyView = new DataView(keyBytes.buffer);
|
||||
let keyStr = keyView.getBigUint64(0, true).toString();
|
||||
let keyStrTrim = trimKey(keyStr);
|
||||
let key = new Uint8Array(32);
|
||||
for (let i = 0; i < 32; i++) {
|
||||
key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function trimKey(keyRaw: string): string {
|
||||
let lenRaw = keyRaw.length;
|
||||
let out = keyRaw;
|
||||
if (lenRaw > 32) {
|
||||
out = keyRaw.slice(0, 32);
|
||||
} else if (lenRaw < 32) {
|
||||
out = keyRaw.padEnd(32, keyRaw);
|
||||
}
|
||||
return out;
|
||||
}
|
|
@ -1,179 +0,0 @@
|
|||
const CryptoJS = require("crypto-js");
|
||||
const MetaFlac = require('metaflac-js');
|
||||
const CORE_KEY = CryptoJS.enc.Hex.parse("687a4852416d736f356b496e62617857");
|
||||
const META_KEY = CryptoJS.enc.Hex.parse("2331346C6A6B5F215C5D2630553C2728");
|
||||
const MagicHeader = [0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D];
|
||||
const musicMetadata = require("music-metadata-browser");
|
||||
import jimp from 'jimp';
|
||||
|
||||
import {
|
||||
AudioMimeType,
|
||||
DetectAudioExt,
|
||||
GetArrayBuffer,
|
||||
GetFileInfo,
|
||||
GetWebImage,
|
||||
IsBytesEqual,
|
||||
WriteMp3Meta
|
||||
} from "./util"
|
||||
|
||||
export async function Decrypt(file, raw_filename, raw_ext) {
|
||||
const fileBuffer = await GetArrayBuffer(file);
|
||||
const dataView = new DataView(fileBuffer);
|
||||
|
||||
if (!IsBytesEqual(MagicHeader, new Uint8Array(fileBuffer, 0, 8)))
|
||||
return {status: false, message: "此ncm文件已损坏"};
|
||||
|
||||
const keyDataObj = getKeyData(dataView, fileBuffer, 10);
|
||||
const keyBox = getKeyBox(keyDataObj.data);
|
||||
|
||||
const musicMetaObj = getMetaData(dataView, fileBuffer, keyDataObj.offset);
|
||||
const musicMeta = musicMetaObj.data;
|
||||
let audioOffset = musicMetaObj.offset + dataView.getUint32(musicMetaObj.offset + 5, true) + 13;
|
||||
let audioData = new Uint8Array(fileBuffer, audioOffset);
|
||||
|
||||
let lenAudioData = audioData.length;
|
||||
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff];
|
||||
|
||||
if (musicMeta.album === undefined) musicMeta.album = "";
|
||||
|
||||
const artists = [];
|
||||
if (!!musicMeta.artist) musicMeta.artist.forEach(arr => artists.push(arr[0]));
|
||||
const info = GetFileInfo(artists.join("; "), musicMeta.musicName, raw_filename);
|
||||
if (artists.length === 0) artists.push(info.artist);
|
||||
|
||||
if (musicMeta.format === undefined) musicMeta.format = DetectAudioExt(audioData, "mp3");
|
||||
console.log(musicMeta)
|
||||
|
||||
const imageInfo = await GetWebImage(musicMeta.albumPic);
|
||||
while (!!imageInfo.buffer && imageInfo.buffer.byteLength >= 16 * 1024 * 1024) {
|
||||
let img = await jimp.read(imageInfo.buffer)
|
||||
await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO)
|
||||
imageInfo.buffer = await img.getBufferAsync("image/jpeg")
|
||||
}
|
||||
console.log(imageInfo)
|
||||
const mime = AudioMimeType[musicMeta.format]
|
||||
try {
|
||||
let musicBlob = new Blob([audioData], {type: mime});
|
||||
const originalMeta = await musicMetadata.parseBlob(musicBlob);
|
||||
let shouldWrite = !originalMeta.common.album && !originalMeta.common.artists && !originalMeta.common.title
|
||||
if (musicMeta.format === "mp3") {
|
||||
audioData = await WriteMp3Meta(
|
||||
audioData, artists, info.title, musicMeta.album, imageInfo.buffer, musicMeta.albumPic, !shouldWrite)
|
||||
} else if (musicMeta.format === "flac") {
|
||||
const writer = new MetaFlac(Buffer.from(audioData))
|
||||
if (shouldWrite) {
|
||||
writer.setTag("TITLE=" + info.title)
|
||||
writer.setTag("ALBUM=" + musicMeta.album)
|
||||
writer.removeTag("ARTIST")
|
||||
artists.forEach(artist => writer.setTag("ARTIST=" + artist))
|
||||
}
|
||||
writer.importPictureFromBuffer(Buffer.from(imageInfo.buffer))
|
||||
audioData = writer.save()
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error while appending cover image to file " + e)
|
||||
}
|
||||
|
||||
const musicData = new Blob([audioData], {type: mime})
|
||||
|
||||
return {
|
||||
status: true,
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
ext: musicMeta.format,
|
||||
album: musicMeta.album,
|
||||
picture: imageInfo.url,
|
||||
file: URL.createObjectURL(musicData),
|
||||
mime: mime
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
function getKeyData(dataView, fileBuffer, offset) {
|
||||
const keyLen = dataView.getUint32(offset, true);
|
||||
offset += 4;
|
||||
const cipherText = new Uint8Array(fileBuffer, offset, keyLen).map(
|
||||
uint8 => uint8 ^ 0x64
|
||||
);
|
||||
offset += keyLen;
|
||||
|
||||
const plainText = CryptoJS.AES.decrypt(
|
||||
{ciphertext: CryptoJS.lib.WordArray.create(cipherText)},
|
||||
CORE_KEY,
|
||||
{
|
||||
mode: CryptoJS.mode.ECB,
|
||||
padding: CryptoJS.pad.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;
|
||||
}
|
||||
|
||||
return {offset: offset, data: result.slice(17)};
|
||||
}
|
||||
|
||||
function getKeyBox(keyData) {
|
||||
const box = new Uint8Array(Array(256).keys());
|
||||
|
||||
const keyDataLen = keyData.length;
|
||||
|
||||
let j = 0;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
j = (box[i] + j + keyData[i % keyDataLen]) & 0xff;
|
||||
[box[i], box[j]] = [box[j], box[i]];
|
||||
}
|
||||
|
||||
return box.map((_, i, arr) => {
|
||||
i = (i + 1) & 0xff;
|
||||
const si = arr[i];
|
||||
const sj = arr[(i + si) & 0xff];
|
||||
return arr[(si + sj) & 0xff];
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} MusicMetaType
|
||||
* @property {Number} musicId
|
||||
* @property {String} musicName
|
||||
* @property {[[String, Number]]} artist
|
||||
* @property {String} album
|
||||
* @property {"flac"|"mp3"} format
|
||||
* @property {String} albumPic
|
||||
*/
|
||||
|
||||
function getMetaData(dataView, fileBuffer, offset) {
|
||||
const metaDataLen = dataView.getUint32(offset, true);
|
||||
offset += 4;
|
||||
if (metaDataLen === 0) return {data: {}, offset: offset};
|
||||
|
||||
const cipherText = new Uint8Array(fileBuffer, offset, metaDataLen).map(
|
||||
data => data ^ 0x63
|
||||
);
|
||||
offset += metaDataLen;
|
||||
|
||||
const plainText = CryptoJS.AES.decrypt({
|
||||
ciphertext: CryptoJS.enc.Base64.parse(
|
||||
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 = JSON.parse(plainText.slice(labelIndex + 1));
|
||||
if (plainText.slice(0, labelIndex) === "dj") {
|
||||
result = result.mainMusic;
|
||||
}
|
||||
if (!!result.albumPic && result.albumPic !== "")
|
||||
result.albumPic = result.albumPic.replace("http://", "https://") + "?param=500y500";
|
||||
|
||||
return {data: result, offset: offset};
|
||||
}
|
||||
|
||||
|
|
@ -0,0 +1,237 @@
|
|||
import {
|
||||
AudioMimeType,
|
||||
BytesHasPrefix,
|
||||
GetArrayBuffer,
|
||||
GetImageFromURL,
|
||||
GetMetaFromFile,
|
||||
IMusicMeta,
|
||||
SniffAudioExt,
|
||||
WriteMetaToFlac,
|
||||
WriteMetaToMp3,
|
||||
} from '@/decrypt/utils';
|
||||
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||
import jimp from 'jimp';
|
||||
|
||||
import AES from 'crypto-js/aes';
|
||||
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';
|
||||
|
||||
import { DecryptResult } from '@/decrypt/entity';
|
||||
|
||||
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> {
|
||||
return new NcmDecrypt(await GetArrayBuffer(file), raw_filename).decrypt();
|
||||
}
|
||||
|
||||
interface NcmMusicMeta {
|
||||
//musicId: number
|
||||
musicName?: string;
|
||||
artist?: Array<string | number>[];
|
||||
format?: string;
|
||||
album?: string;
|
||||
albumPic?: string;
|
||||
}
|
||||
|
||||
interface NcmDjMeta {
|
||||
mainMusic: NcmMusicMeta;
|
||||
}
|
||||
|
||||
class NcmDecrypt {
|
||||
raw: ArrayBuffer;
|
||||
view: DataView;
|
||||
offset: number = 0;
|
||||
filename: string;
|
||||
format: string = '';
|
||||
mime: string = '';
|
||||
audio?: Uint8Array;
|
||||
blob?: Blob;
|
||||
oriMeta?: NcmMusicMeta;
|
||||
newMeta?: IMusicMeta;
|
||||
image?: { mime: string; buffer: ArrayBuffer; url: string };
|
||||
|
||||
constructor(buf: ArrayBuffer, filename: string) {
|
||||
const prefix = new Uint8Array(buf, 0, 8);
|
||||
if (!BytesHasPrefix(prefix, MagicHeader)) throw Error('此ncm文件已损坏');
|
||||
this.offset = 10;
|
||||
this.raw = buf;
|
||||
this.view = new DataView(buf);
|
||||
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;
|
||||
}
|
||||
|
||||
return result.slice(17);
|
||||
}
|
||||
|
||||
_getKeyBox(): Uint8Array {
|
||||
const keyData = this._getKeyData();
|
||||
const box = new Uint8Array(Array(256).keys());
|
||||
|
||||
const keyDataLen = keyData.length;
|
||||
|
||||
let j = 0;
|
||||
|
||||
for (let i = 0; i < 256; i++) {
|
||||
j = (box[i] + j + keyData[i % keyDataLen]) & 0xff;
|
||||
[box[i], box[j]] = [box[j], box[i]];
|
||||
}
|
||||
|
||||
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 {
|
||||
const metaDataLen = this.view.getUint32(this.offset, true);
|
||||
this.offset += 4;
|
||||
if (metaDataLen === 0) return {};
|
||||
|
||||
const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen).map((data) => data ^ 0x63);
|
||||
this.offset += metaDataLen;
|
||||
|
||||
WordArray.create();
|
||||
const plainText = AES.decrypt(
|
||||
// @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 (!!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 || !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];
|
||||
await this._buildMeta();
|
||||
try {
|
||||
await this._writeMeta();
|
||||
} catch (e) {
|
||||
console.warn('write meta data failed', e);
|
||||
}
|
||||
return this.gatherResult();
|
||||
}
|
||||
}
|
|
@ -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, tag.common.artist);
|
||||
|
||||
return {
|
||||
title,
|
||||
artist,
|
||||
ext,
|
||||
album: tag.common.album,
|
||||
picture: GetCoverFromFile(tag),
|
||||
file: URL.createObjectURL(file),
|
||||
blob: file,
|
||||
mime: AudioMimeType[ext],
|
||||
};
|
||||
}
|
|
@ -1,164 +0,0 @@
|
|||
import {
|
||||
AudioMimeType,
|
||||
DetectAudioExt,
|
||||
GetArrayBuffer,
|
||||
GetFileInfo,
|
||||
GetMetaCoverURL,
|
||||
GetWebImage,
|
||||
IXAREA_API_ENDPOINT
|
||||
} from "./util";
|
||||
import {QmcMaskCreate58, QmcMaskDetectMflac, QmcMaskDetectMgg, QmcMaskGetDefault} from "./qmcMask";
|
||||
import {fromByteArray as Base64Encode, toByteArray as Base64Decode} from 'base64-js'
|
||||
|
||||
const MetaFlac = require('metaflac-js');
|
||||
|
||||
const ID3Writer = require("browser-id3-writer");
|
||||
|
||||
const iconv = require('iconv-lite');
|
||||
const decode = iconv.decode
|
||||
|
||||
const musicMetadata = require("music-metadata-browser");
|
||||
|
||||
const HandlerMap = {
|
||||
"mgg": {handler: QmcMaskDetectMgg, ext: "ogg", detect: true},
|
||||
"mflac": {handler: QmcMaskDetectMflac, ext: "flac", detect: true},
|
||||
"qmc0": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
||||
"qmc2": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
|
||||
"qmc3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
||||
"qmcogg": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
|
||||
"qmcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
|
||||
"bkcmp3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
||||
"bkcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
|
||||
"tkm": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
|
||||
"666c6163": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
|
||||
"6d7033": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
|
||||
"6f6767": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
|
||||
"6d3461": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
|
||||
"776176": {handler: QmcMaskGetDefault, ext: "wav", detect: false}
|
||||
};
|
||||
|
||||
export async function Decrypt(file, raw_filename, raw_ext) {
|
||||
if (!(raw_ext in HandlerMap)) return {status: false, message: "File type is incorrect!"};
|
||||
const handler = HandlerMap[raw_ext];
|
||||
|
||||
const fileData = new Uint8Array(await GetArrayBuffer(file));
|
||||
let audioData, seed, keyData;
|
||||
if (handler.detect) {
|
||||
const keyLen = new DataView(fileData.slice(fileData.length - 4).buffer).getUint32(0, true)
|
||||
const keyPos = fileData.length - 4 - keyLen;
|
||||
audioData = fileData.slice(0, keyPos);
|
||||
seed = handler.handler(audioData);
|
||||
keyData = fileData.slice(keyPos, keyPos + keyLen);
|
||||
if (seed === undefined) seed = await queryKeyInfo(keyData, raw_filename, raw_ext);
|
||||
if (seed === undefined) return {status: false, message: raw_ext + "格式仅提供实验性支持"};
|
||||
} else {
|
||||
audioData = fileData;
|
||||
seed = handler.handler(audioData);
|
||||
}
|
||||
let musicDecoded = seed.Decrypt(audioData);
|
||||
|
||||
const ext = DetectAudioExt(musicDecoded, handler.ext);
|
||||
const mime = AudioMimeType[ext];
|
||||
|
||||
let musicBlob = new Blob([musicDecoded], {type: mime});
|
||||
|
||||
const musicMeta = await musicMetadata.parseBlob(musicBlob);
|
||||
for (let metaIdx in musicMeta.native) {
|
||||
if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) {
|
||||
console.warn("The metadata is using gbk encoding")
|
||||
musicMeta.common.artist = decode(musicMeta.common.artist, "gbk");
|
||||
musicMeta.common.title = decode(musicMeta.common.title, "gbk");
|
||||
musicMeta.common.album = decode(musicMeta.common.album, "gbk");
|
||||
}
|
||||
}
|
||||
|
||||
const info = GetFileInfo(musicMeta.common.artist, musicMeta.common.title, raw_filename);
|
||||
if (handler.detect) reportKeyUsage(keyData, seed.Matrix128,
|
||||
info.artist, info.title, musicMeta.common.album, raw_filename, raw_ext);
|
||||
|
||||
let imgUrl = GetMetaCoverURL(musicMeta);
|
||||
if (imgUrl === "") {
|
||||
imgUrl = await queryAlbumCoverImage(info.artist, info.title, musicMeta.common.album);
|
||||
if (imgUrl !== "") {
|
||||
const imageInfo = await GetWebImage(imgUrl);
|
||||
if (imageInfo.url !== "") {
|
||||
imgUrl = imageInfo.url
|
||||
try {
|
||||
if (ext === "mp3") {
|
||||
let writer = new ID3Writer(musicDecoded)
|
||||
writer.setFrame('APIC', {
|
||||
type: 3,
|
||||
data: imageInfo.buffer,
|
||||
description: "Cover",
|
||||
})
|
||||
writer.addTag();
|
||||
musicDecoded = writer.arrayBuffer
|
||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
||||
} else if (ext === 'flac') {
|
||||
const writer = new MetaFlac(Buffer.from(musicDecoded))
|
||||
writer.importPictureFromBuffer(Buffer.from(imageInfo.buffer))
|
||||
musicDecoded = writer.save()
|
||||
musicBlob = new Blob([musicDecoded], {type: mime});
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("Error while appending cover image to file " + e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
status: true,
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
ext: ext,
|
||||
album: musicMeta.common.album,
|
||||
picture: imgUrl,
|
||||
file: URL.createObjectURL(musicBlob),
|
||||
mime: mime
|
||||
}
|
||||
}
|
||||
|
||||
function reportKeyUsage(keyData, maskData, artist, title, album, filename, format) {
|
||||
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
|
||||
}),
|
||||
}).then().catch()
|
||||
}
|
||||
|
||||
async function queryKeyInfo(keyData, filename, format) {
|
||||
try {
|
||||
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}),
|
||||
});
|
||||
let data = await resp.json();
|
||||
return QmcMaskCreate58(Base64Decode(data.Matrix44));
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
async function queryAlbumCoverImage(artist, title, album) {
|
||||
const song_query_url = IXAREA_API_ENDPOINT + "/music/qq-cover"
|
||||
try {
|
||||
const params = {Artist: artist, Title: title, Album: album};
|
||||
let _url = song_query_url + "?";
|
||||
for (let pKey in params) {
|
||||
_url += pKey + "=" + encodeURIComponent(params[pKey]) + "&"
|
||||
}
|
||||
const resp = await fetch(_url)
|
||||
if (resp.ok) {
|
||||
let data = await resp.json();
|
||||
return song_query_url + "/" + data.Type + "/" + data.Id
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
return "";
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
import fs from 'fs';
|
||||
import { QmcDecoder } from '@/decrypt/qmc';
|
||||
import { BytesEqual } from '@/decrypt/utils';
|
||||
|
||||
function loadTestDataDecoder(name: string): {
|
||||
cipherText: Uint8Array;
|
||||
clearText: Uint8Array;
|
||||
} {
|
||||
const cipherBody = fs.readFileSync(`./testdata/${name}_raw.bin`);
|
||||
const cipherSuffix = fs.readFileSync(`./testdata/${name}_suffix.bin`);
|
||||
const cipherText = new Uint8Array(cipherBody.length + cipherSuffix.length);
|
||||
cipherText.set(cipherBody);
|
||||
cipherText.set(cipherSuffix, cipherBody.length);
|
||||
return {
|
||||
cipherText,
|
||||
clearText: fs.readFileSync(`testdata/${name}_target.bin`),
|
||||
};
|
||||
}
|
||||
|
||||
test('qmc: real file', async () => {
|
||||
const cases = ['mflac0_rc4', 'mflac_rc4', 'mflac_map', 'mgg_map', 'qmc0_static'];
|
||||
for (const name of cases) {
|
||||
const { clearText, cipherText } = loadTestDataDecoder(name);
|
||||
const c = new QmcDecoder(cipherText);
|
||||
const buf = c.decrypt();
|
||||
|
||||
expect(BytesEqual(buf, clearText)).toBeTruthy();
|
||||
}
|
||||
});
|
|
@ -0,0 +1,173 @@
|
|||
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher';
|
||||
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils';
|
||||
|
||||
import { DecryptResult } from '@/decrypt/entity';
|
||||
import { QmcDeriveKey } from '@/decrypt/qmc_key';
|
||||
import { DecryptQMCWasm } from '@/decrypt/qmc_wasm';
|
||||
import { extractQQMusicMeta } from '@/utils/qm_meta';
|
||||
|
||||
interface Handler {
|
||||
ext: string;
|
||||
version: number;
|
||||
}
|
||||
|
||||
export const HandlerMap: { [key: string]: Handler } = {
|
||||
mgg: { ext: 'ogg', version: 2 },
|
||||
mgg0: { ext: 'ogg', version: 2 },
|
||||
mggl: { ext: 'ogg', version: 2 },
|
||||
mgg1: { ext: 'ogg', version: 2 },
|
||||
mflac: { ext: 'flac', version: 2 },
|
||||
mflac0: { ext: 'flac', version: 2 },
|
||||
|
||||
// qmcflac / qmcogg:
|
||||
// 有可能是 v2 加密但混用同一个后缀名。
|
||||
qmcflac: { ext: 'flac', version: 2 },
|
||||
qmcogg: { ext: 'ogg', version: 2 },
|
||||
|
||||
qmc0: { ext: 'mp3', version: 1 },
|
||||
qmc2: { ext: 'ogg', version: 1 },
|
||||
qmc3: { ext: 'mp3', version: 1 },
|
||||
bkcmp3: { ext: 'mp3', version: 1 },
|
||||
bkcflac: { ext: 'flac', 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: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
|
||||
const handler = HandlerMap[raw_ext];
|
||||
let { version } = handler;
|
||||
|
||||
const fileBuffer = await GetArrayBuffer(file);
|
||||
let musicDecoded: Uint8Array | undefined;
|
||||
let musicID: number | string | undefined;
|
||||
|
||||
if (version === 2 && globalThis.WebAssembly) {
|
||||
console.log('qmc: using wasm decoder');
|
||||
|
||||
const v2Decrypted = await DecryptQMCWasm(fileBuffer);
|
||||
// 若 v2 检测失败,降级到 v1 再尝试一次
|
||||
if (v2Decrypted.success) {
|
||||
musicDecoded = v2Decrypted.data;
|
||||
musicID = v2Decrypted.songId;
|
||||
} else {
|
||||
console.warn('qmc2-wasm failed with error %s', v2Decrypted.error || '(no error)');
|
||||
}
|
||||
}
|
||||
|
||||
if (!musicDecoded) {
|
||||
// may throw error
|
||||
console.log('qmc: using js decoder');
|
||||
const d = new QmcDecoder(new Uint8Array(fileBuffer));
|
||||
musicDecoded = d.decrypt();
|
||||
musicID = d.songID;
|
||||
}
|
||||
|
||||
const ext = SniffAudioExt(musicDecoded, handler.ext);
|
||||
const mime = AudioMimeType[ext];
|
||||
|
||||
const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta(
|
||||
new Blob([musicDecoded], { type: mime }),
|
||||
raw_filename,
|
||||
ext,
|
||||
musicID,
|
||||
);
|
||||
|
||||
return {
|
||||
title: title,
|
||||
artist: artist,
|
||||
ext: ext,
|
||||
album: album,
|
||||
picture: imgUrl,
|
||||
file: URL.createObjectURL(blob),
|
||||
blob: blob,
|
||||
mime: mime,
|
||||
};
|
||||
}
|
||||
|
||||
export class QmcDecoder {
|
||||
private static readonly BYTE_COMMA = ','.charCodeAt(0);
|
||||
private readonly file: Uint8Array;
|
||||
private readonly size: number;
|
||||
private decoded: boolean = false;
|
||||
private audioSize?: number;
|
||||
private cipher?: QmcStreamCipher;
|
||||
|
||||
public constructor(file: Uint8Array) {
|
||||
this.file = file;
|
||||
this.size = file.length;
|
||||
this.searchKey();
|
||||
}
|
||||
|
||||
private _songID?: number;
|
||||
|
||||
public get songID() {
|
||||
return this._songID;
|
||||
}
|
||||
|
||||
public decrypt(): Uint8Array {
|
||||
if (!this.cipher) {
|
||||
throw new Error('no cipher found');
|
||||
}
|
||||
if (!this.audioSize || this.audioSize <= 0) {
|
||||
throw new Error('invalid audio size');
|
||||
}
|
||||
const audioBuf = this.file.subarray(0, this.audioSize);
|
||||
|
||||
if (!this.decoded) {
|
||||
this.cipher.decrypt(audioBuf, 0);
|
||||
this.decoded = true;
|
||||
}
|
||||
|
||||
return audioBuf;
|
||||
}
|
||||
|
||||
private searchKey() {
|
||||
const last4Byte = this.file.slice(-4);
|
||||
const textEnc = new TextDecoder();
|
||||
if (textEnc.decode(last4Byte) === 'QTag') {
|
||||
const sizeBuf = this.file.slice(-8, -4);
|
||||
const sizeView = new DataView(sizeBuf.buffer, sizeBuf.byteOffset);
|
||||
const keySize = sizeView.getUint32(0, false);
|
||||
this.audioSize = this.size - keySize - 8;
|
||||
|
||||
const rawKey = this.file.subarray(this.audioSize, this.size - 8);
|
||||
const keyEnd = rawKey.findIndex((v) => v == QmcDecoder.BYTE_COMMA);
|
||||
if (keyEnd < 0) {
|
||||
throw new Error('invalid key: search raw key failed');
|
||||
}
|
||||
this.setCipher(rawKey.subarray(0, keyEnd));
|
||||
|
||||
const idBuf = rawKey.subarray(keyEnd + 1);
|
||||
const idEnd = idBuf.findIndex((v) => v == QmcDecoder.BYTE_COMMA);
|
||||
if (keyEnd < 0) {
|
||||
throw new Error('invalid key: search song id failed');
|
||||
}
|
||||
this._songID = parseInt(textEnc.decode(idBuf.subarray(0, idEnd)), 10);
|
||||
} else {
|
||||
const sizeView = new DataView(last4Byte.buffer, last4Byte.byteOffset);
|
||||
const keySize = sizeView.getUint32(0, true);
|
||||
if (keySize < 0x300) {
|
||||
this.audioSize = this.size - keySize - 4;
|
||||
const rawKey = this.file.subarray(this.audioSize, this.size - 4);
|
||||
this.setCipher(rawKey);
|
||||
} else {
|
||||
this.audioSize = this.size;
|
||||
this.cipher = new QmcStaticCipher();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private setCipher(keyRaw: Uint8Array) {
|
||||
const keyDec = QmcDeriveKey(keyRaw);
|
||||
if (keyDec.length > 300) {
|
||||
this.cipher = new QmcRC4Cipher(keyDec);
|
||||
} else {
|
||||
this.cipher = new QmcMapCipher(keyDec);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,273 +0,0 @@
|
|||
import {FLAC_HEADER, IsBytesEqual, OGG_HEADER} from "./util"
|
||||
|
||||
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];
|
||||
|
||||
|
||||
class QmcMask {
|
||||
constructor(matrix, superA, superB) {
|
||||
if (superA === undefined || superB === undefined) {
|
||||
if (matrix.length === 44) {
|
||||
this.Matrix44 = matrix
|
||||
this.generateMask128from44()
|
||||
} else {
|
||||
this.Matrix128 = matrix
|
||||
this.generateMask44from128()
|
||||
}
|
||||
this.generateMask58from128()
|
||||
} else {
|
||||
this.Matrix58 = matrix;
|
||||
this.Super58A = superA;
|
||||
this.Super58B = superB;
|
||||
this.generateMask128from58();
|
||||
this.generateMask44from128()
|
||||
}
|
||||
}
|
||||
|
||||
generateMask128from58() {
|
||||
if (this.Matrix58.length !== 56) throw "incorrect mask58 matrix length";
|
||||
|
||||
let matrix128 = [];
|
||||
for (let rowIdx = 0; rowIdx < 8; rowIdx += 1) {
|
||||
matrix128 = matrix128.concat(
|
||||
[this.Super58A],
|
||||
this.Matrix58.slice(7 * rowIdx, 7 * rowIdx + 7),
|
||||
[this.Super58B],
|
||||
this.Matrix58.slice(56 - 7 - 7 * rowIdx, 56 - 7 * rowIdx).reverse()
|
||||
);
|
||||
}
|
||||
this.Matrix128 = matrix128;
|
||||
}
|
||||
|
||||
generateMask58from128() {
|
||||
if (this.Matrix128.length !== 128) throw "incorrect mask128 length";
|
||||
|
||||
const superA = this.Matrix128[0], superB = this.Matrix128[8];
|
||||
let matrix58 = [];
|
||||
|
||||
for (let rowIdx = 0; rowIdx < 8; rowIdx += 1) {
|
||||
let lenStart = 16 * rowIdx;
|
||||
let lenRightStart = 120 - lenStart;
|
||||
if (this.Matrix128[lenStart] !== superA || this.Matrix128[lenStart + 8] !== superB) {
|
||||
throw "decode mask-128 to mask-58 failed"
|
||||
}
|
||||
let rowLeft = this.Matrix128.slice(lenStart + 1, lenStart + 8);
|
||||
let rowRight = this.Matrix128.slice(lenRightStart + 1, lenRightStart + 8).reverse();
|
||||
if (IsBytesEqual(rowLeft, rowRight)) {
|
||||
matrix58 = matrix58.concat(rowLeft);
|
||||
} else {
|
||||
throw "decode mask-128 to mask-58 failed"
|
||||
}
|
||||
}
|
||||
this.Matrix58 = matrix58;
|
||||
this.Super58A = superA;
|
||||
this.Super58B = superB;
|
||||
}
|
||||
|
||||
generateMask44from128() {
|
||||
if (this.Matrix128.length !== 128) throw "incorrect mask128 matrix length";
|
||||
let mapping = GetConvertMapping()
|
||||
this.Matrix44 = []
|
||||
let idxI44 = 0
|
||||
mapping.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"
|
||||
}
|
||||
}
|
||||
this.Matrix44[idxI44] = this.Matrix128[it256[0]]
|
||||
idxI44++
|
||||
})
|
||||
}
|
||||
|
||||
generateMask128from44() {
|
||||
if (this.Matrix44.length !== 44) throw "incorrect mask length"
|
||||
this.Matrix128 = []
|
||||
let idx44 = 0
|
||||
GetConvertMapping().forEach(it256 => {
|
||||
it256.forEach(m => {
|
||||
this.Matrix128[m] = this.Matrix44[idx44]
|
||||
})
|
||||
idx44++
|
||||
})
|
||||
}
|
||||
|
||||
Decrypt(data) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
export function QmcMaskGetDefault() {
|
||||
return new QmcMask(QMCDefaultMaskMatrix)
|
||||
}
|
||||
|
||||
export function QmcMaskDetectMflac(data) {
|
||||
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 (IsBytesEqual(FLAC_HEADER, mask.Decrypt(data.slice(0, FLAC_HEADER.length)))) break;
|
||||
} catch (e) {
|
||||
}
|
||||
}
|
||||
return mask;
|
||||
}
|
||||
|
||||
export function QmcMaskDetectMgg(data) {
|
||||
if (data.length < 0x100) return
|
||||
let matrixConfidence = {};
|
||||
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 = GetMask44Index(idx128);
|
||||
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] = getMaskConfidenceResult(matrixConfidence[i]);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
const mask = new QmcMask(matrix);
|
||||
let dx = mask.Decrypt(data.slice(0, OGG_HEADER.length));
|
||||
if (!IsBytesEqual(OGG_HEADER, dx)) {
|
||||
return;
|
||||
}
|
||||
|
||||
return mask;
|
||||
}
|
||||
|
||||
export function QmcMaskCreate128(mask128) {
|
||||
return new QmcMask(mask128)
|
||||
}
|
||||
|
||||
export function QmcMaskCreate58(matrix, superA, superB) {
|
||||
return new QmcMask(matrix, superA, superB)
|
||||
}
|
||||
|
||||
export function QmcMaskCreate44(mask44) {
|
||||
return new QmcMask(mask44)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param confidence {{}}
|
||||
* @returns {number}
|
||||
*/
|
||||
function getMaskConfidenceResult(confidence) {
|
||||
if (confidence.length === 0) throw "can not match at least one key";
|
||||
if (confidence.length > 1) console.warn("There are 2 potential value for the mask!")
|
||||
let result, conf = 0;
|
||||
for (let idx in confidence) {
|
||||
if (confidence[idx] > conf) {
|
||||
result = idx;
|
||||
conf = confidence[idx];
|
||||
}
|
||||
}
|
||||
return parseInt(result)
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {number}
|
||||
*/
|
||||
|
||||
|
||||
const allMapping = [];
|
||||
const mask128to44 = [];
|
||||
|
||||
(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++
|
||||
})
|
||||
})();
|
||||
|
||||
function GetConvertMapping() {
|
||||
return allMapping;
|
||||
}
|
||||
|
||||
function GetMask44Index(idx128) {
|
||||
return mask128to44[idx128 % 128]
|
||||
}
|
||||
|
||||
function QmcGenerateOggHeader(page2) {
|
||||
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) {
|
||||
let specConf = [6, 0]
|
||||
for (let i = 2; i < page2; i++) specConf.push(4)
|
||||
specConf.push(0)
|
||||
return QMOggPublicConf1.concat(specConf, QMOggPublicConf2)
|
||||
}
|
|
@ -0,0 +1,117 @@
|
|||
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher } from '@/decrypt/qmc_cipher';
|
||||
import fs from 'fs';
|
||||
|
||||
test('static cipher [0x7ff8,0x8000) ', () => {
|
||||
//prettier-ignore
|
||||
const expected = new Uint8Array([
|
||||
0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A,
|
||||
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8,
|
||||
])
|
||||
|
||||
const c = new QmcStaticCipher();
|
||||
const buf = new Uint8Array(16);
|
||||
c.decrypt(buf, 0x7ff8);
|
||||
|
||||
expect(buf).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test('static cipher [0,0x10) ', () => {
|
||||
//prettier-ignore
|
||||
const expected = new Uint8Array([
|
||||
0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52,
|
||||
0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00,
|
||||
])
|
||||
|
||||
const c = new QmcStaticCipher();
|
||||
const buf = new Uint8Array(16);
|
||||
c.decrypt(buf, 0);
|
||||
|
||||
expect(buf).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
test('map cipher: get mask', () => {
|
||||
//prettier-ignore
|
||||
const expected = new Uint8Array([
|
||||
0xBB, 0x7D, 0x80, 0xBE, 0xFF, 0x38, 0x81, 0xFB,
|
||||
0xBB, 0xFF, 0x82, 0x3C, 0xFF, 0xBA, 0x83, 0x79,
|
||||
])
|
||||
const key = new Uint8Array(256);
|
||||
for (let i = 0; i < 256; i++) key[i] = i;
|
||||
const buf = new Uint8Array(16);
|
||||
|
||||
const c = new QmcMapCipher(key);
|
||||
c.decrypt(buf, 0);
|
||||
expect(buf).toStrictEqual(expected);
|
||||
});
|
||||
|
||||
function loadTestDataCipher(name: string): {
|
||||
key: Uint8Array;
|
||||
cipherText: Uint8Array;
|
||||
clearText: Uint8Array;
|
||||
} {
|
||||
return {
|
||||
key: fs.readFileSync(`testdata/${name}_key.bin`),
|
||||
cipherText: fs.readFileSync(`testdata/${name}_raw.bin`),
|
||||
clearText: fs.readFileSync(`testdata/${name}_target.bin`),
|
||||
};
|
||||
}
|
||||
|
||||
test('map cipher: real file', async () => {
|
||||
const cases = ['mflac_map', 'mgg_map'];
|
||||
for (const name of cases) {
|
||||
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||
const c = new QmcMapCipher(key);
|
||||
|
||||
c.decrypt(cipherText, 0);
|
||||
|
||||
expect(cipherText).toStrictEqual(clearText);
|
||||
}
|
||||
});
|
||||
|
||||
test('rc4 cipher: real file', async () => {
|
||||
const cases = ['mflac0_rc4', 'mflac_rc4'];
|
||||
for (const name of cases) {
|
||||
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||
const c = new QmcRC4Cipher(key);
|
||||
|
||||
c.decrypt(cipherText, 0);
|
||||
|
||||
expect(cipherText).toStrictEqual(clearText);
|
||||
}
|
||||
});
|
||||
|
||||
test('rc4 cipher: first segment', async () => {
|
||||
const cases = ['mflac0_rc4', 'mflac_rc4'];
|
||||
for (const name of cases) {
|
||||
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||
const c = new QmcRC4Cipher(key);
|
||||
|
||||
const buf = cipherText.slice(0, 128);
|
||||
c.decrypt(buf, 0);
|
||||
expect(buf).toStrictEqual(clearText.slice(0, 128));
|
||||
}
|
||||
});
|
||||
|
||||
test('rc4 cipher: align block (128~5120)', async () => {
|
||||
const cases = ['mflac0_rc4', 'mflac_rc4'];
|
||||
for (const name of cases) {
|
||||
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||
const c = new QmcRC4Cipher(key);
|
||||
|
||||
const buf = cipherText.slice(128, 5120);
|
||||
c.decrypt(buf, 128);
|
||||
expect(buf).toStrictEqual(clearText.slice(128, 5120));
|
||||
}
|
||||
});
|
||||
|
||||
test('rc4 cipher: simple block (5120~10240)', async () => {
|
||||
const cases = ['mflac0_rc4', 'mflac_rc4'];
|
||||
for (const name of cases) {
|
||||
const { key, clearText, cipherText } = loadTestDataCipher(name);
|
||||
const c = new QmcRC4Cipher(key);
|
||||
|
||||
const buf = cipherText.slice(5120, 10240);
|
||||
c.decrypt(buf, 5120);
|
||||
expect(buf).toStrictEqual(clearText.slice(5120, 10240));
|
||||
}
|
||||
});
|
|
@ -0,0 +1,199 @@
|
|||
export interface QmcStreamCipher {
|
||||
decrypt(buf: Uint8Array, offset: number): void;
|
||||
}
|
||||
|
||||
export class QmcStaticCipher implements QmcStreamCipher {
|
||||
//prettier-ignore
|
||||
private static readonly staticCipherBox: Uint8Array = new Uint8Array([
|
||||
0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00
|
||||
0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08
|
||||
0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, //0x10
|
||||
0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, //0x18
|
||||
0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, //0x20
|
||||
0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, //0x28
|
||||
0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, //0x30
|
||||
0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, //0x38
|
||||
0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, //0x40
|
||||
0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, //0x48
|
||||
0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, //0x50
|
||||
0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, //0x58
|
||||
0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, //0x60
|
||||
0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, //0x68
|
||||
0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, //0x70
|
||||
0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, //0x78
|
||||
0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, //0x80
|
||||
0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, //0x88
|
||||
0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, //0x90
|
||||
0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, //0x98
|
||||
0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, //0xA0
|
||||
0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, //0xA8
|
||||
0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, //0xB0
|
||||
0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, //0xB8
|
||||
0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, //0xC0
|
||||
0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, //0xC8
|
||||
0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, //0xD0
|
||||
0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, //0xD8
|
||||
0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, //0xE0
|
||||
0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, //0xE8
|
||||
0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, //0xF0
|
||||
0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11, //0xF8
|
||||
])
|
||||
|
||||
public getMask(offset: number) {
|
||||
if (offset > 0x7fff) offset %= 0x7fff;
|
||||
return QmcStaticCipher.staticCipherBox[(offset * offset + 27) & 0xff];
|
||||
}
|
||||
|
||||
public decrypt(buf: Uint8Array, offset: number) {
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
buf[i] ^= this.getMask(offset + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class QmcMapCipher implements QmcStreamCipher {
|
||||
key: Uint8Array;
|
||||
n: number;
|
||||
|
||||
constructor(key: Uint8Array) {
|
||||
if (key.length == 0) throw Error('qmc/cipher_map: invalid key size');
|
||||
|
||||
this.key = key;
|
||||
this.n = key.length;
|
||||
}
|
||||
|
||||
private static rotate(value: number, bits: number) {
|
||||
let rotate = (bits + 4) % 8;
|
||||
let left = value << rotate;
|
||||
let right = value >> rotate;
|
||||
return (left | right) & 0xff;
|
||||
}
|
||||
|
||||
decrypt(buf: Uint8Array, offset: number): void {
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
buf[i] ^= this.getMask(offset + i);
|
||||
}
|
||||
}
|
||||
|
||||
private getMask(offset: number) {
|
||||
if (offset > 0x7fff) offset %= 0x7fff;
|
||||
|
||||
const idx = (offset * offset + 71214) % this.n;
|
||||
return QmcMapCipher.rotate(this.key[idx], idx & 0x7);
|
||||
}
|
||||
}
|
||||
|
||||
export class QmcRC4Cipher implements QmcStreamCipher {
|
||||
private static readonly FIRST_SEGMENT_SIZE = 0x80;
|
||||
private static readonly SEGMENT_SIZE = 5120;
|
||||
|
||||
S: Uint8Array;
|
||||
N: number;
|
||||
key: Uint8Array;
|
||||
hash: number;
|
||||
|
||||
constructor(key: Uint8Array) {
|
||||
if (key.length == 0) {
|
||||
throw Error('invalid key size');
|
||||
}
|
||||
|
||||
this.key = key;
|
||||
this.N = key.length;
|
||||
|
||||
// init seed box
|
||||
this.S = new Uint8Array(this.N);
|
||||
for (let i = 0; i < this.N; ++i) {
|
||||
this.S[i] = i & 0xff;
|
||||
}
|
||||
let j = 0;
|
||||
for (let i = 0; i < this.N; ++i) {
|
||||
j = (this.S[i] + j + this.key[i % this.N]) % this.N;
|
||||
[this.S[i], this.S[j]] = [this.S[j], this.S[i]];
|
||||
}
|
||||
|
||||
// init hash base
|
||||
this.hash = 1;
|
||||
for (let i = 0; i < this.N; i++) {
|
||||
let value = this.key[i];
|
||||
|
||||
// ignore if key char is '\x00'
|
||||
if (!value) continue;
|
||||
|
||||
const next_hash = (this.hash * value) >>> 0;
|
||||
if (next_hash == 0 || next_hash <= this.hash) break;
|
||||
|
||||
this.hash = next_hash;
|
||||
}
|
||||
}
|
||||
|
||||
decrypt(buf: Uint8Array, offset: number): void {
|
||||
let toProcess = buf.length;
|
||||
let processed = 0;
|
||||
const postProcess = (len: number): boolean => {
|
||||
toProcess -= len;
|
||||
processed += len;
|
||||
offset += len;
|
||||
return toProcess == 0;
|
||||
};
|
||||
|
||||
// Initial segment
|
||||
if (offset < QmcRC4Cipher.FIRST_SEGMENT_SIZE) {
|
||||
const len_segment = Math.min(buf.length, QmcRC4Cipher.FIRST_SEGMENT_SIZE - offset);
|
||||
this.encFirstSegment(buf.subarray(0, len_segment), offset);
|
||||
if (postProcess(len_segment)) return;
|
||||
}
|
||||
|
||||
// align segment
|
||||
if (offset % QmcRC4Cipher.SEGMENT_SIZE != 0) {
|
||||
const len_segment = Math.min(QmcRC4Cipher.SEGMENT_SIZE - (offset % QmcRC4Cipher.SEGMENT_SIZE), toProcess);
|
||||
this.encASegment(buf.subarray(processed, processed + len_segment), offset);
|
||||
if (postProcess(len_segment)) return;
|
||||
}
|
||||
|
||||
// Batch process segments
|
||||
while (toProcess > QmcRC4Cipher.SEGMENT_SIZE) {
|
||||
this.encASegment(buf.subarray(processed, processed + QmcRC4Cipher.SEGMENT_SIZE), offset);
|
||||
postProcess(QmcRC4Cipher.SEGMENT_SIZE);
|
||||
}
|
||||
|
||||
// Last segment (incomplete segment)
|
||||
if (toProcess > 0) {
|
||||
this.encASegment(buf.subarray(processed), offset);
|
||||
}
|
||||
}
|
||||
|
||||
private encFirstSegment(buf: Uint8Array, offset: number) {
|
||||
for (let i = 0; i < buf.length; i++) {
|
||||
buf[i] ^= this.key[this.getSegmentKey(offset + i)];
|
||||
}
|
||||
}
|
||||
|
||||
private encASegment(buf: Uint8Array, offset: number) {
|
||||
// Initialise a new seed box
|
||||
const S = this.S.slice(0);
|
||||
|
||||
// Calculate the number of bytes to skip.
|
||||
// The initial "key" derived from segment id, plus the current offset.
|
||||
const skipLen =
|
||||
(offset % QmcRC4Cipher.SEGMENT_SIZE) + this.getSegmentKey(Math.floor(offset / QmcRC4Cipher.SEGMENT_SIZE));
|
||||
|
||||
// decrypt the block
|
||||
let j = 0;
|
||||
let k = 0;
|
||||
for (let i = -skipLen; i < buf.length; i++) {
|
||||
j = (j + 1) % this.N;
|
||||
k = (S[j] + k) % this.N;
|
||||
[S[k], S[j]] = [S[j], S[k]];
|
||||
|
||||
if (i >= 0) {
|
||||
buf[i] ^= S[(S[j] + S[k]) % this.N];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private getSegmentKey(id: number): number {
|
||||
const seed = this.key[id % this.N];
|
||||
const idx = Math.floor((this.hash / ((id + 1) * seed)) * 100.0);
|
||||
return idx % this.N;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import { QmcDeriveKey, simpleMakeKey } from '@/decrypt/qmc_key';
|
||||
import fs from 'fs';
|
||||
|
||||
test('key dec: make simple key', () => {
|
||||
expect(simpleMakeKey(106, 8)).toStrictEqual([0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b]);
|
||||
});
|
||||
|
||||
function loadTestDataKeyDecrypt(name: string): {
|
||||
cipherText: Uint8Array;
|
||||
clearText: Uint8Array;
|
||||
} {
|
||||
return {
|
||||
cipherText: fs.readFileSync(`testdata/${name}_key_raw.bin`),
|
||||
clearText: fs.readFileSync(`testdata/${name}_key.bin`),
|
||||
};
|
||||
}
|
||||
|
||||
test('key dec: real file', async () => {
|
||||
const cases = ['mflac_map', 'mgg_map', 'mflac0_rc4', 'mflac_rc4'];
|
||||
for (const name of cases) {
|
||||
const { clearText, cipherText } = loadTestDataKeyDecrypt(name);
|
||||
const buf = QmcDeriveKey(cipherText);
|
||||
|
||||
expect(buf).toStrictEqual(clearText);
|
||||
}
|
||||
});
|
|
@ -0,0 +1,103 @@
|
|||
import { TeaCipher } from '@/utils/tea';
|
||||
|
||||
const SALT_LEN = 2;
|
||||
const ZERO_LEN = 7;
|
||||
|
||||
export function QmcDeriveKey(raw: Uint8Array): Uint8Array {
|
||||
const textDec = new TextDecoder();
|
||||
const rawDec = Buffer.from(textDec.decode(raw), 'base64');
|
||||
let n = rawDec.length;
|
||||
if (n < 16) {
|
||||
throw Error('key length is too short');
|
||||
}
|
||||
|
||||
const simpleKey = simpleMakeKey(106, 8);
|
||||
let teaKey = new Uint8Array(16);
|
||||
for (let i = 0; i < 8; i++) {
|
||||
teaKey[i << 1] = simpleKey[i];
|
||||
teaKey[(i << 1) + 1] = rawDec[i];
|
||||
}
|
||||
const sub = decryptTencentTea(rawDec.subarray(8), teaKey);
|
||||
rawDec.set(sub, 8);
|
||||
return rawDec.subarray(0, 8 + sub.length);
|
||||
}
|
||||
|
||||
// simpleMakeKey exported only for unit test
|
||||
export function simpleMakeKey(salt: number, length: number): number[] {
|
||||
const keyBuf: number[] = [];
|
||||
for (let i = 0; i < length; i++) {
|
||||
const tmp = Math.tan(salt + i * 0.1);
|
||||
keyBuf[i] = 0xff & (Math.abs(tmp) * 100.0);
|
||||
}
|
||||
return keyBuf;
|
||||
}
|
||||
|
||||
function decryptTencentTea(inBuf: Uint8Array, key: Uint8Array): Uint8Array {
|
||||
if (inBuf.length % 8 != 0) {
|
||||
throw Error('inBuf size not a multiple of the block size');
|
||||
}
|
||||
if (inBuf.length < 16) {
|
||||
throw Error('inBuf size too small');
|
||||
}
|
||||
|
||||
const blk = new TeaCipher(key, 32);
|
||||
|
||||
const tmpBuf = new Uint8Array(8);
|
||||
const tmpView = new DataView(tmpBuf.buffer);
|
||||
|
||||
blk.decrypt(tmpView, new DataView(inBuf.buffer, inBuf.byteOffset, 8));
|
||||
|
||||
const nPadLen = tmpBuf[0] & 0x7; //只要最低三位
|
||||
/*密文格式:PadLen(1byte)+Padding(var,0-7byte)+Salt(2byte)+Body(var byte)+Zero(7byte)*/
|
||||
const outLen = inBuf.length - 1 /*PadLen*/ - nPadLen - SALT_LEN - ZERO_LEN;
|
||||
const outBuf = new Uint8Array(outLen);
|
||||
|
||||
let ivPrev = new Uint8Array(8);
|
||||
let ivCur = inBuf.slice(0, 8); // init iv
|
||||
let inBufPos = 8;
|
||||
|
||||
// 跳过 Padding Len 和 Padding
|
||||
let tmpIdx = 1 + nPadLen;
|
||||
|
||||
// CBC IV 处理
|
||||
const cryptBlock = () => {
|
||||
ivPrev = ivCur;
|
||||
ivCur = inBuf.slice(inBufPos, inBufPos + 8);
|
||||
for (let j = 0; j < 8; j++) {
|
||||
tmpBuf[j] ^= ivCur[j];
|
||||
}
|
||||
blk.decrypt(tmpView, tmpView);
|
||||
inBufPos += 8;
|
||||
tmpIdx = 0;
|
||||
};
|
||||
|
||||
// 跳过 Salt
|
||||
for (let i = 1; i <= SALT_LEN; ) {
|
||||
if (tmpIdx < 8) {
|
||||
tmpIdx++;
|
||||
i++;
|
||||
} else {
|
||||
cryptBlock();
|
||||
}
|
||||
}
|
||||
|
||||
// 还原明文
|
||||
let outBufPos = 0;
|
||||
while (outBufPos < outLen) {
|
||||
if (tmpIdx < 8) {
|
||||
outBuf[outBufPos] = tmpBuf[tmpIdx] ^ ivPrev[tmpIdx];
|
||||
outBufPos++;
|
||||
tmpIdx++;
|
||||
} else {
|
||||
cryptBlock();
|
||||
}
|
||||
}
|
||||
|
||||
// 校验Zero
|
||||
for (let i = 1; i <= ZERO_LEN; i++) {
|
||||
if (tmpBuf[tmpIdx] != ivPrev[tmpIdx]) {
|
||||
throw Error('zero check failed');
|
||||
}
|
||||
}
|
||||
return outBuf;
|
||||
}
|
|
@ -0,0 +1,111 @@
|
|||
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle';
|
||||
import { MergeUint8Array } from '@/utils/MergeUint8Array';
|
||||
import { QMCCrypto } from '@jixun/qmc2-crypto/QMCCrypto';
|
||||
|
||||
// 检测文件末端使用的缓冲区大小
|
||||
const DETECTION_SIZE = 40;
|
||||
|
||||
// 每次处理 2M 的数据
|
||||
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024;
|
||||
|
||||
export interface QMC2DecryptionResult {
|
||||
success: boolean;
|
||||
data: Uint8Array;
|
||||
songId: string | number;
|
||||
error: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解密一个 QMC2 加密的文件。
|
||||
*
|
||||
* 如果检测并解密成功,返回解密后的 Uint8Array 数据。
|
||||
* @param {ArrayBuffer} mggBlob 读入的文件 Blob
|
||||
*/
|
||||
export async function DecryptQMCWasm(mggBlob: ArrayBuffer): Promise<QMC2DecryptionResult> {
|
||||
const result: QMC2DecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' };
|
||||
|
||||
// 初始化模组
|
||||
let QMCCrypto: QMCCrypto;
|
||||
|
||||
try {
|
||||
QMCCrypto = await QMCCryptoModule();
|
||||
} catch (err: any) {
|
||||
result.error = err?.message || 'wasm 加载失败';
|
||||
return result;
|
||||
}
|
||||
|
||||
// 申请内存块,并文件末端数据到 WASM 的内存堆
|
||||
const detectionBuf = new Uint8Array(mggBlob.slice(-DETECTION_SIZE));
|
||||
const pDetectionBuf = QMCCrypto._malloc(detectionBuf.length);
|
||||
QMCCrypto.writeArrayToMemory(detectionBuf, pDetectionBuf);
|
||||
|
||||
// 检测结果内存块
|
||||
const pDetectionResult = QMCCrypto._malloc(QMCCrypto.sizeof_qmc_detection());
|
||||
|
||||
// 进行检测
|
||||
const detectOK = QMCCrypto.detectKeyEndPosition(pDetectionResult, pDetectionBuf, detectionBuf.length);
|
||||
|
||||
// 提取结构体内容:
|
||||
// (pos: i32; len: i32; error: char[??])
|
||||
const position = QMCCrypto.getValue(pDetectionResult, 'i32');
|
||||
const len = QMCCrypto.getValue(pDetectionResult + 4, 'i32');
|
||||
|
||||
result.success = detectOK;
|
||||
result.error = QMCCrypto.UTF8ToString(
|
||||
pDetectionResult + QMCCrypto.offsetof_error_msg(),
|
||||
QMCCrypto.sizeof_error_msg(),
|
||||
);
|
||||
const songId = QMCCrypto.UTF8ToString(pDetectionResult + QMCCrypto.offsetof_song_id(), QMCCrypto.sizeof_song_id());
|
||||
if (!songId) {
|
||||
console.debug('qmc2-wasm: songId not found');
|
||||
} else if (/^\d+$/.test(songId)) {
|
||||
result.songId = songId;
|
||||
} else {
|
||||
console.warn('qmc2-wasm: Invalid songId: %s', songId);
|
||||
}
|
||||
|
||||
// 释放内存
|
||||
QMCCrypto._free(pDetectionBuf);
|
||||
QMCCrypto._free(pDetectionResult);
|
||||
|
||||
if (!detectOK) {
|
||||
return result;
|
||||
}
|
||||
|
||||
// 计算解密后文件的大小。
|
||||
// 之前得到的 position 为相对当前检测数据起点的偏移。
|
||||
const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position;
|
||||
|
||||
// 提取嵌入到文件的 EKey
|
||||
const ekey = new Uint8Array(mggBlob.slice(decryptedSize, decryptedSize + len));
|
||||
|
||||
// 解码 UTF-8 数据到 string
|
||||
const decoder = new TextDecoder();
|
||||
const ekey_b64 = decoder.decode(ekey);
|
||||
|
||||
// 初始化加密与缓冲区
|
||||
const hCrypto = QMCCrypto.createInstWidthEKey(ekey_b64);
|
||||
const buf = QMCCrypto._malloc(DECRYPTION_BUF_SIZE);
|
||||
|
||||
const decryptedParts = [];
|
||||
let offset = 0;
|
||||
let bytesToDecrypt = decryptedSize;
|
||||
while (bytesToDecrypt > 0) {
|
||||
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
|
||||
|
||||
// 解密一些片段
|
||||
const blockData = new Uint8Array(mggBlob.slice(offset, offset + blockSize));
|
||||
QMCCrypto.writeArrayToMemory(blockData, buf);
|
||||
QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize);
|
||||
decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize));
|
||||
|
||||
offset += blockSize;
|
||||
bytesToDecrypt -= blockSize;
|
||||
}
|
||||
QMCCrypto._free(buf);
|
||||
hCrypto.delete();
|
||||
|
||||
result.data = MergeUint8Array(decryptedParts);
|
||||
|
||||
return result;
|
||||
}
|
|
@ -0,0 +1,50 @@
|
|||
import {
|
||||
AudioMimeType,
|
||||
GetArrayBuffer,
|
||||
GetCoverFromFile,
|
||||
GetMetaFromFile,
|
||||
SniffAudioExt,
|
||||
SplitFilename,
|
||||
} from '@/decrypt/utils';
|
||||
|
||||
import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc';
|
||||
|
||||
import { DecryptResult } from '@/decrypt/entity';
|
||||
|
||||
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||
|
||||
export async function Decrypt(file: Blob, raw_filename: string, _: string): Promise<DecryptResult> {
|
||||
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||
let length = buffer.length;
|
||||
for (let i = 0; i < length; i++) {
|
||||
buffer[i] ^= 0xf4;
|
||||
if (buffer[i] <= 0x3f) buffer[i] = buffer[i] * 4;
|
||||
else if (buffer[i] <= 0x7f) buffer[i] = (buffer[i] - 0x40) * 4 + 1;
|
||||
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2;
|
||||
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3;
|
||||
}
|
||||
let ext = SniffAudioExt(buffer, '');
|
||||
const newName = SplitFilename(raw_filename);
|
||||
let audioBlob: Blob;
|
||||
if (ext !== '' || newName.ext === 'mp3') {
|
||||
audioBlob = new Blob([buffer], { type: AudioMimeType[ext] });
|
||||
} else if (newName.ext in HandlerMap) {
|
||||
audioBlob = new Blob([buffer], { 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, tag.common.artist);
|
||||
|
||||
return {
|
||||
title,
|
||||
artist,
|
||||
ext,
|
||||
album: tag.common.album,
|
||||
picture: GetCoverFromFile(tag),
|
||||
file: URL.createObjectURL(audioBlob),
|
||||
blob: audioBlob,
|
||||
mime: AudioMimeType[ext],
|
||||
};
|
||||
}
|
|
@ -1,23 +0,0 @@
|
|||
const musicMetadata = require("music-metadata-browser");
|
||||
import {AudioMimeType, DetectAudioExt, GetArrayBuffer, GetMetaCoverURL, GetFileInfo} from "./util";
|
||||
|
||||
export async function Decrypt(file, raw_filename, raw_ext, detect = true) {
|
||||
let ext = raw_ext;
|
||||
if (detect) {
|
||||
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||
ext = DetectAudioExt(buffer, raw_ext);
|
||||
if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]})
|
||||
}
|
||||
const tag = await musicMetadata.parseBlob(file);
|
||||
const info = GetFileInfo(tag.common.artist, tag.common.title, raw_filename);
|
||||
return {
|
||||
status: true,
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
ext: ext,
|
||||
album: tag.common.album,
|
||||
picture: GetMetaCoverURL(tag),
|
||||
file: URL.createObjectURL(file),
|
||||
mime: AudioMimeType[ext]
|
||||
}
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
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,
|
||||
detect: boolean = true,
|
||||
): Promise<DecryptResult> {
|
||||
let ext = raw_ext;
|
||||
if (detect) {
|
||||
const buffer = new Uint8Array(await GetArrayBuffer(file));
|
||||
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, tag.common.artist);
|
||||
|
||||
return {
|
||||
title,
|
||||
artist,
|
||||
ext,
|
||||
album: tag.common.album,
|
||||
picture: GetCoverFromFile(tag),
|
||||
file: URL.createObjectURL(file),
|
||||
blob: file,
|
||||
mime: AudioMimeType[ext],
|
||||
};
|
||||
}
|
|
@ -1,14 +0,0 @@
|
|||
import {Decrypt as RawDecrypt} from "./raw";
|
||||
import {GetArrayBuffer} from "./util";
|
||||
|
||||
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
|
||||
|
||||
export async function Decrypt(file, raw_filename) {
|
||||
const fileBuffer = await GetArrayBuffer(file);
|
||||
const audioData = new Uint8Array(fileBuffer);
|
||||
for (let cur = 0; cur < 8; ++cur) {
|
||||
audioData[cur] = TM_HEADER[cur];
|
||||
}
|
||||
const musicData = new Blob([audioData], {type: "audio/mp4"});
|
||||
return await RawDecrypt(musicData, raw_filename, "m4a", false)
|
||||
}
|
|
@ -0,0 +1,14 @@
|
|||
import { Decrypt as RawDecrypt } from './raw';
|
||||
import { GetArrayBuffer } from '@/decrypt/utils';
|
||||
import { DecryptResult } from '@/decrypt/entity';
|
||||
|
||||
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
|
||||
|
||||
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> {
|
||||
const audioData = new Uint8Array(await GetArrayBuffer(file));
|
||||
for (let cur = 0; cur < 8; ++cur) {
|
||||
audioData[cur] = TM_HEADER[cur];
|
||||
}
|
||||
const musicData = new Blob([audioData], { type: 'audio/mp4' });
|
||||
return await RawDecrypt(musicData, raw_filename, 'm4a', false);
|
||||
}
|
|
@ -1,109 +0,0 @@
|
|||
const ID3Writer = require("browser-id3-writer");
|
||||
const musicMetadata = require("music-metadata-browser");
|
||||
export const FLAC_HEADER = [0x66, 0x4C, 0x61, 0x43];
|
||||
export const MP3_HEADER = [0x49, 0x44, 0x33];
|
||||
export const OGG_HEADER = [0x4F, 0x67, 0x67, 0x53];
|
||||
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
|
||||
export const WMA_HEADER = [
|
||||
0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11,
|
||||
0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C,
|
||||
]
|
||||
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]
|
||||
export const AudioMimeType = {
|
||||
mp3: "audio/mpeg",
|
||||
flac: "audio/flac",
|
||||
m4a: "audio/mp4",
|
||||
ogg: "audio/ogg",
|
||||
wma: "audio/x-ms-wma",
|
||||
wav: "audio/x-wav"
|
||||
};
|
||||
export const IXAREA_API_ENDPOINT = "https://stats.ixarea.com/apis"
|
||||
|
||||
// Also a new draft API: blob.arrayBuffer()
|
||||
export async function GetArrayBuffer(blobObject) {
|
||||
return await new Promise(resolve => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
resolve(e.target.result);
|
||||
};
|
||||
reader.readAsArrayBuffer(blobObject);
|
||||
});
|
||||
}
|
||||
|
||||
export function GetFileInfo(artist, title, filenameNoExt, separator = "-") {
|
||||
let newArtist = "", newTitle = "";
|
||||
let filenameArray = filenameNoExt.split(separator);
|
||||
if (filenameArray.length > 1) {
|
||||
newArtist = filenameArray[0].trim();
|
||||
newTitle = filenameArray[1].trim();
|
||||
} else if (filenameArray.length === 1) {
|
||||
newTitle = filenameArray[0].trim();
|
||||
}
|
||||
|
||||
if (typeof artist == "string" && artist !== "") newArtist = artist;
|
||||
if (typeof title == "string" && title !== "") newTitle = title;
|
||||
return {artist: newArtist, title: newTitle};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
export function GetMetaCoverURL(metadata) {
|
||||
let pic_url = "";
|
||||
if (metadata.common.picture !== undefined && metadata.common.picture.length > 0) {
|
||||
let pic = new Blob([metadata.common.picture[0].data], {type: metadata.common.picture[0].format});
|
||||
pic_url = URL.createObjectURL(pic);
|
||||
}
|
||||
return pic_url;
|
||||
}
|
||||
|
||||
export function IsBytesEqual(first, second) {
|
||||
// if want wholly check, should length first>=second
|
||||
return first.every((val, idx) => {
|
||||
return val === second[idx];
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @return {string}
|
||||
*/
|
||||
export function DetectAudioExt(data, fallbackExt) {
|
||||
if (IsBytesEqual(MP3_HEADER, data.slice(0, MP3_HEADER.length))) return "mp3";
|
||||
if (IsBytesEqual(FLAC_HEADER, data.slice(0, FLAC_HEADER.length))) return "flac";
|
||||
if (IsBytesEqual(OGG_HEADER, data.slice(0, OGG_HEADER.length))) return "ogg";
|
||||
if (IsBytesEqual(M4A_HEADER, data.slice(4, 4 + M4A_HEADER.length))) return "m4a";
|
||||
if (IsBytesEqual(WMA_HEADER, data.slice(0, WMA_HEADER.length))) return "wma";
|
||||
if (IsBytesEqual(WAV_HEADER, data.slice(0, WAV_HEADER.length))) return "wav";
|
||||
return fallbackExt;
|
||||
}
|
||||
|
||||
|
||||
export async function GetWebImage(pic_url) {
|
||||
try {
|
||||
let resp = await fetch(pic_url);
|
||||
let mime = resp.headers.get("Content-Type");
|
||||
if (mime.startsWith("image/")) {
|
||||
let buf = await resp.arrayBuffer();
|
||||
let objBlob = new Blob([buf], {type: mime});
|
||||
let objUrl = URL.createObjectURL(objBlob);
|
||||
return {"buffer": buf, "src": pic_url, "url": objUrl, "type": mime};
|
||||
}
|
||||
} catch (e) {
|
||||
}
|
||||
return {"buffer": null, "src": pic_url, "url": "", "type": ""}
|
||||
}
|
||||
|
||||
export async function WriteMp3Meta(audioData, artistList, title, album, pictureData = null, pictureDesc = "Cover", cover_only = true) {
|
||||
const writer = new ID3Writer(audioData);
|
||||
if (!cover_only) writer.setFrame("TPE1", artistList).setFrame("TIT2", title).setFrame("TALB", album);
|
||||
if (pictureData !== null) {
|
||||
writer.setFrame('APIC', {
|
||||
type: 3,
|
||||
data: pictureData,
|
||||
description: pictureDesc,
|
||||
})
|
||||
}
|
||||
writer.addTag();
|
||||
return writer.arrayBuffer;
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
import { IAudioMetadata } from 'music-metadata-browser';
|
||||
import ID3Writer from 'browser-id3-writer';
|
||||
import MetaFlac from 'metaflac-js';
|
||||
|
||||
export const FLAC_HEADER = [0x66, 0x4c, 0x61, 0x43];
|
||||
export const MP3_HEADER = [0x49, 0x44, 0x33];
|
||||
export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53];
|
||||
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
|
||||
//prettier-ignore
|
||||
export const WMA_HEADER = [
|
||||
0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11,
|
||||
0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c,
|
||||
];
|
||||
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46];
|
||||
export const AAC_HEADER = [0xff, 0xf1];
|
||||
export const DFF_HEADER = [0x46, 0x52, 0x4d, 0x38];
|
||||
|
||||
export const AudioMimeType: { [key: string]: string } = {
|
||||
mp3: 'audio/mpeg',
|
||||
flac: 'audio/flac',
|
||||
m4a: 'audio/mp4',
|
||||
ogg: 'audio/ogg',
|
||||
wma: 'audio/x-ms-wma',
|
||||
wav: 'audio/x-wav',
|
||||
dff: 'audio/x-dff',
|
||||
};
|
||||
|
||||
export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean {
|
||||
if (prefix.length > data.length) return false;
|
||||
return prefix.every((val, 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 {
|
||||
if (BytesHasPrefix(data, MP3_HEADER)) return 'mp3';
|
||||
if (BytesHasPrefix(data, FLAC_HEADER)) return 'flac';
|
||||
if (BytesHasPrefix(data, OGG_HEADER)) return 'ogg';
|
||||
if (data.length >= 4 + M4A_HEADER.length && BytesHasPrefix(data.slice(4), M4A_HEADER)) return 'm4a';
|
||||
if (BytesHasPrefix(data, WAV_HEADER)) return 'wav';
|
||||
if (BytesHasPrefix(data, WMA_HEADER)) return 'wma';
|
||||
if (BytesHasPrefix(data, AAC_HEADER)) return 'aac';
|
||||
if (BytesHasPrefix(data, DFF_HEADER)) return 'dff';
|
||||
return fallback_ext;
|
||||
}
|
||||
|
||||
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
|
||||
if (!!obj.arrayBuffer) return obj.arrayBuffer();
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
const rs = e.target?.result;
|
||||
if (!rs) {
|
||||
reject('read file failed');
|
||||
} else {
|
||||
resolve(rs as ArrayBuffer);
|
||||
}
|
||||
};
|
||||
reader.readAsArrayBuffer(obj);
|
||||
});
|
||||
}
|
||||
|
||||
export function GetCoverFromFile(metadata: IAudioMetadata): string {
|
||||
if (metadata.common?.picture && metadata.common.picture.length > 0) {
|
||||
return URL.createObjectURL(
|
||||
new Blob([metadata.common.picture[0].data], { type: metadata.common.picture[0].format }),
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export interface IMusicMetaBasic {
|
||||
title: string;
|
||||
artist?: string;
|
||||
}
|
||||
|
||||
export function GetMetaFromFile(
|
||||
filename: string,
|
||||
exist_title?: string,
|
||||
exist_artist?: string,
|
||||
separator = '-',
|
||||
): IMusicMetaBasic {
|
||||
const meta: IMusicMetaBasic = { title: exist_title ?? '', artist: exist_artist };
|
||||
|
||||
const items = filename.split(separator);
|
||||
if (items.length > 1) {
|
||||
if (!meta.artist) meta.artist = items[0].trim();
|
||||
if (!meta.title) meta.title = items[1].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 };
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn(e);
|
||||
}
|
||||
}
|
||||
|
||||
export interface IMusicMeta {
|
||||
title: string;
|
||||
artists?: string[];
|
||||
album?: string;
|
||||
picture?: ArrayBuffer;
|
||||
picture_desc?: string;
|
||||
}
|
||||
|
||||
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
|
||||
const writer = new ID3Writer(audioData);
|
||||
|
||||
// reserve 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') {
|
||||
try {
|
||||
writer.setFrame(frame.id, frame.value);
|
||||
} catch (e) {}
|
||||
}
|
||||
});
|
||||
|
||||
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) {
|
||||
const writer = new MetaFlac(audioData);
|
||||
const old = original.common;
|
||||
if (!old.title && !old.album && old.artists) {
|
||||
writer.setTag('TITLE=' + info.title);
|
||||
writer.setTag('ALBUM=' + info.album);
|
||||
if (info.artists) {
|
||||
writer.removeTag('ARTIST');
|
||||
info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist));
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
};
|
||||
}
|
|
@ -1,67 +0,0 @@
|
|||
import {AudioMimeType, GetArrayBuffer, GetFileInfo, GetMetaCoverURL, IsBytesEqual} from "./util";
|
||||
|
||||
import {Decrypt as RawDecrypt} from "./raw";
|
||||
|
||||
const musicMetadata = require("music-metadata-browser");
|
||||
const MagicHeader = [0x69, 0x66, 0x6D, 0x74]
|
||||
const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe]
|
||||
const FileTypeMap = {
|
||||
" WAV": ".wav",
|
||||
"FLAC": ".flac",
|
||||
" MP3": ".mp3",
|
||||
" A4M": ".m4a",
|
||||
}
|
||||
|
||||
export async function Decrypt(file, raw_filename, raw_ext) {
|
||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
||||
if (!IsBytesEqual(MagicHeader, oriData.slice(0, 4)) ||
|
||||
!IsBytesEqual(MagicHeader2, oriData.slice(8, 12))) {
|
||||
if (raw_ext === "xm") {
|
||||
return {status: false, message: "此xm文件已损坏"}
|
||||
} else {
|
||||
return await RawDecrypt(file, raw_filename, raw_ext, true)
|
||||
}
|
||||
}
|
||||
|
||||
let typeText = (new TextDecoder()).decode(oriData.slice(4, 8))
|
||||
if (!FileTypeMap.hasOwnProperty(typeText)) {
|
||||
return {status: false, message: "未知的xm文件类型"}
|
||||
}
|
||||
|
||||
let key = oriData[0xf]
|
||||
let dataOffset = oriData[0xc] | oriData[0xd] << 8 | oriData[0xe] << 16
|
||||
let audioData = oriData.slice(0x10);
|
||||
let lenAudioData = audioData.length;
|
||||
for (let cur = dataOffset; cur < lenAudioData; ++cur)
|
||||
audioData[cur] = (audioData[cur] - key) ^ 0xff;
|
||||
|
||||
const ext = FileTypeMap[typeText];
|
||||
const mime = AudioMimeType[ext];
|
||||
let musicBlob = new Blob([audioData], {type: mime});
|
||||
|
||||
const musicMeta = await musicMetadata.parseBlob(musicBlob);
|
||||
if (ext === "wav") {
|
||||
//todo:未知的编码方式
|
||||
console.log(musicMeta.common)
|
||||
musicMeta.common.album = "";
|
||||
musicMeta.common.artist = "";
|
||||
musicMeta.common.title = "";
|
||||
}
|
||||
let _sep = raw_filename.indexOf("_") === -1 ? "-" : "_"
|
||||
const info = GetFileInfo(musicMeta.common.artist, musicMeta.common.title, raw_filename, _sep);
|
||||
|
||||
const imgUrl = GetMetaCoverURL(musicMeta);
|
||||
|
||||
return {
|
||||
status: true,
|
||||
title: info.title,
|
||||
artist: info.artist,
|
||||
ext: ext,
|
||||
album: musicMeta.common.album,
|
||||
picture: imgUrl,
|
||||
file: URL.createObjectURL(musicBlob),
|
||||
mime: mime,
|
||||
rawExt: "xm"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,67 @@
|
|||
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
|
||||
import { DecryptResult } from '@/decrypt/entity';
|
||||
import { AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile } from '@/decrypt/utils';
|
||||
|
||||
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||
|
||||
const MagicHeader = [0x69, 0x66, 0x6d, 0x74];
|
||||
const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe];
|
||||
const FileTypeMap: { [key: string]: string } = {
|
||||
' WAV': '.wav',
|
||||
FLAC: '.flac',
|
||||
' MP3': '.mp3',
|
||||
' A4M': '.m4a',
|
||||
};
|
||||
|
||||
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
|
||||
const oriData = new Uint8Array(await GetArrayBuffer(file));
|
||||
if (!BytesHasPrefix(oriData, MagicHeader) || !BytesHasPrefix(oriData.slice(8, 12), MagicHeader2)) {
|
||||
if (raw_ext === 'xm') {
|
||||
throw Error('此xm文件已损坏');
|
||||
} else {
|
||||
return await RawDecrypt(file, raw_filename, raw_ext, true);
|
||||
}
|
||||
}
|
||||
|
||||
let typeText = new TextDecoder().decode(oriData.slice(4, 8));
|
||||
if (!FileTypeMap.hasOwnProperty(typeText)) {
|
||||
throw Error('未知的.xm文件类型');
|
||||
}
|
||||
|
||||
let key = oriData[0xf];
|
||||
let dataOffset = oriData[0xc] | (oriData[0xd] << 8) | (oriData[0xe] << 16);
|
||||
let audioData = oriData.slice(0x10);
|
||||
let lenAudioData = audioData.length;
|
||||
for (let cur = dataOffset; cur < lenAudioData; ++cur) audioData[cur] = (audioData[cur] - key) ^ 0xff;
|
||||
|
||||
const ext = FileTypeMap[typeText];
|
||||
const mime = AudioMimeType[ext];
|
||||
let musicBlob = new Blob([audioData], { type: mime });
|
||||
|
||||
const musicMeta = await metaParseBlob(musicBlob);
|
||||
if (ext === 'wav') {
|
||||
//todo:未知的编码方式
|
||||
console.info(musicMeta.common);
|
||||
musicMeta.common.album = '';
|
||||
musicMeta.common.artist = '';
|
||||
musicMeta.common.title = '';
|
||||
}
|
||||
const { title, artist } = GetMetaFromFile(
|
||||
raw_filename,
|
||||
musicMeta.common.title,
|
||||
musicMeta.common.artist,
|
||||
raw_filename.indexOf('_') === -1 ? '-' : '_',
|
||||
);
|
||||
|
||||
return {
|
||||
title,
|
||||
artist,
|
||||
ext,
|
||||
mime,
|
||||
album: musicMeta.common.album,
|
||||
picture: GetCoverFromFile(musicMeta),
|
||||
file: URL.createObjectURL(musicBlob),
|
||||
blob: musicBlob,
|
||||
rawExt: 'xm',
|
||||
};
|
||||
}
|
|
@ -0,0 +1,13 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
<script src="./popup.js"></script>
|
||||
<a href="./index.html" target="_blank">
|
||||
<button>立即使用</button>
|
||||
</a>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,2 @@
|
|||
const bs = chrome || browser;
|
||||
bs.tabs.create({ 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!");
|
||||
}
|
47
src/main.js
47
src/main.js
|
@ -1,47 +0,0 @@
|
|||
import Vue from 'vue'
|
||||
import App from './App.vue'
|
||||
import './registerServiceWorker'
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Container,
|
||||
Footer,
|
||||
Icon,
|
||||
Image,
|
||||
Link,
|
||||
Main,
|
||||
Notification,
|
||||
Progress,
|
||||
Radio,
|
||||
Row,
|
||||
Table,
|
||||
TableColumn,
|
||||
Tooltip,
|
||||
Upload
|
||||
} from 'element-ui';
|
||||
import 'element-ui/lib/theme-chalk/base.css';
|
||||
|
||||
Vue.use(Link);
|
||||
Vue.use(Image);
|
||||
Vue.use(Button);
|
||||
Vue.use(Table);
|
||||
Vue.use(TableColumn);
|
||||
Vue.use(Main);
|
||||
Vue.use(Footer);
|
||||
Vue.use(Container);
|
||||
Vue.use(Icon);
|
||||
Vue.use(Row);
|
||||
Vue.use(Col);
|
||||
Vue.use(Upload);
|
||||
Vue.use(Checkbox);
|
||||
Vue.use(Radio);
|
||||
Vue.use(Tooltip);
|
||||
Vue.use(Progress);
|
||||
Vue.prototype.$notify = Notification;
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
document.getElementById("loader-source").remove()
|
||||
new Vue({
|
||||
render: h => h(App),
|
||||
}).$mount('#app');
|
|
@ -0,0 +1,56 @@
|
|||
import Vue from 'vue';
|
||||
import App from '@/App.vue';
|
||||
import '@/registerServiceWorker';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Col,
|
||||
Container,
|
||||
Dialog,
|
||||
Form,
|
||||
FormItem,
|
||||
Footer,
|
||||
Icon,
|
||||
Image,
|
||||
Input,
|
||||
Link,
|
||||
Main,
|
||||
Notification,
|
||||
Progress,
|
||||
Radio,
|
||||
Row,
|
||||
Table,
|
||||
TableColumn,
|
||||
Tooltip,
|
||||
Upload,
|
||||
MessageBox,
|
||||
} from 'element-ui';
|
||||
import 'element-ui/lib/theme-chalk/base.css';
|
||||
|
||||
Vue.use(Link);
|
||||
Vue.use(Image);
|
||||
Vue.use(Button);
|
||||
Vue.use(Dialog);
|
||||
Vue.use(Form);
|
||||
Vue.use(FormItem);
|
||||
Vue.use(Input);
|
||||
Vue.use(Table);
|
||||
Vue.use(TableColumn);
|
||||
Vue.use(Main);
|
||||
Vue.use(Footer);
|
||||
Vue.use(Container);
|
||||
Vue.use(Icon);
|
||||
Vue.use(Row);
|
||||
Vue.use(Col);
|
||||
Vue.use(Upload);
|
||||
Vue.use(Checkbox);
|
||||
Vue.use(Radio);
|
||||
Vue.use(Tooltip);
|
||||
Vue.use(Progress);
|
||||
Vue.prototype.$notify = Notification;
|
||||
Vue.prototype.$confirm = MessageBox.confirm;
|
||||
|
||||
Vue.config.productionTip = false;
|
||||
new Vue({
|
||||
render: (h) => h(App),
|
||||
}).$mount('#app');
|
|
@ -1,30 +1,30 @@
|
|||
/* eslint-disable no-console */
|
||||
|
||||
import {register} from 'register-service-worker'
|
||||
import { register } from 'register-service-worker';
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||
ready() {
|
||||
console.log('App is being served from cache by a service worker.')
|
||||
},
|
||||
registered() {
|
||||
console.log('Service worker has been registered.')
|
||||
},
|
||||
cached() {
|
||||
console.log('Content has been cached for offline use.')
|
||||
},
|
||||
updatefound() {
|
||||
console.log('New content is downloading.')
|
||||
},
|
||||
updated() {
|
||||
console.log('New content is available.');
|
||||
window.location.reload();
|
||||
},
|
||||
offline() {
|
||||
console.log('No internet connection found. App is running in offline mode.')
|
||||
},
|
||||
error(error) {
|
||||
console.error('Error during service worker registration:', error)
|
||||
}
|
||||
})
|
||||
if (process.env.NODE_ENV === 'production' && window.location.protocol === 'https:') {
|
||||
register(`${process.env.BASE_URL}service-worker.js`, {
|
||||
ready() {
|
||||
console.log('App is being served from cache by a service worker.');
|
||||
},
|
||||
registered() {
|
||||
console.log('Service worker has been registered.');
|
||||
},
|
||||
cached() {
|
||||
console.log('Content has been cached for offline use.');
|
||||
},
|
||||
updatefound() {
|
||||
console.log('New content is downloading.');
|
||||
},
|
||||
updated() {
|
||||
console.log('New content is available.');
|
||||
window.location.reload();
|
||||
},
|
||||
offline() {
|
||||
console.log('No internet connection found. App is running in offline mode.');
|
||||
},
|
||||
error(error) {
|
||||
console.error('Error during service worker registration:', error);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
@ -0,0 +1,256 @@
|
|||
/*
|
||||
* 样式 - 暗黑模式
|
||||
*/
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
#app{
|
||||
color: $dark-text-info;
|
||||
}
|
||||
body{
|
||||
background-color: $dark-bg;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// LINKS
|
||||
a{
|
||||
text-decoration: none;
|
||||
color: darken($dark-color-link, 15%);
|
||||
&:hover{
|
||||
color: $dark-color-link;
|
||||
}
|
||||
}
|
||||
|
||||
// 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{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
// 自定义样式
|
||||
// 首页弹窗提示信息的 更新信息 面板
|
||||
.update-info{
|
||||
border: 1px solid $dark-btn-bg !important;
|
||||
.update-title{
|
||||
color: $dark-text-main;
|
||||
background-color: $dark-btn-bg !important;
|
||||
}
|
||||
.update-content{
|
||||
color: $dark-text-info;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,39 @@
|
|||
$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) ;
|
||||
}
|
|
@ -0,0 +1,18 @@
|
|||
/*
|
||||
* 间隔工具集
|
||||
*/
|
||||
|
||||
$gap: 5px;
|
||||
@for $item from 0 through 8 {
|
||||
.mt-#{$item} { margin-top : $gap * $item !important;}
|
||||
.mb-#{$item} { margin-bottom : $gap * $item !important;}
|
||||
.ml-#{$item} { margin-left : $gap * $item !important;}
|
||||
.mr-#{$item} { margin-right : $gap * $item !important;}
|
||||
.m-#{$item} { margin : $gap * $item !important;}
|
||||
|
||||
.pt-#{$item} { padding-top : $gap * $item !important;}
|
||||
.pb-#{$item} { padding-bottom : $gap * $item !important;}
|
||||
.pl-#{$item} { padding-left : $gap * $item !important;}
|
||||
.pr-#{$item} { padding-right : $gap * $item !important;}
|
||||
.p-#{$item} { padding : $gap * $item !important;}
|
||||
}
|
|
@ -0,0 +1,38 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
// 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{
|
||||
&:active{
|
||||
@include transform(translateY(2px))
|
||||
}
|
||||
}
|
|
@ -0,0 +1,35 @@
|
|||
// COLORS
|
||||
$blue : #409EFF;
|
||||
$red : #F56C6C;
|
||||
$green : #85ce61;
|
||||
|
||||
// TEXT
|
||||
$text-main : #2C3E50;
|
||||
$color-link: $blue;
|
||||
|
||||
$fz-main: 14px;
|
||||
$fz-mini-title: 13px;
|
||||
$fz-mini-content: 12px;
|
||||
|
||||
$font-family: "Helvetica Neue", Helvetica, "PingFang SC",
|
||||
"Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
|
||||
|
||||
|
||||
// DARK MODE
|
||||
$dark-border : lighten(black, 25%);
|
||||
$dark-border-highlight : lighten(black, 55%);
|
||||
$dark-bg : lighten(black, 10%);
|
||||
$dark-text-main : lighten(black, 90%);
|
||||
$dark-text-info : lighten(black, 60%);
|
||||
$dark-uploader-bg : lighten(black, 13%);
|
||||
$dark-dialog-bg : lighten(black, 15%);
|
||||
$dark-uploader-bg-highlight : lighten(black, 18%);
|
||||
$dark-btn-bg : lighten(black, 20%);
|
||||
$dark-btn-bg-highlight : lighten(black, 30%);
|
||||
$dark-bg-th : lighten(black, 18%);
|
||||
$dark-bg-td : lighten(black, 13%);
|
||||
$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%);
|
|
@ -0,0 +1,26 @@
|
|||
@import "variables";
|
||||
@import "utility";
|
||||
@import "gaps";
|
||||
@import "element-ui-overrite";
|
||||
|
||||
@import "normal";
|
||||
@import "dark-mode"; // dark-mode 放在 normal 后面,以获得更高优先级
|
||||
|
||||
|
||||
// 首页弹窗提示信息的 更新信息 面板
|
||||
.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: 5px 10px;
|
||||
background-color: $color-border-el;
|
||||
}
|
||||
.update-content{
|
||||
font-size: $fz-mini-content;
|
||||
line-height: 1.5;
|
||||
padding: 10px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,23 @@
|
|||
declare module 'browser-id3-writer' {
|
||||
export default class ID3Writer {
|
||||
constructor(buffer: Buffer | ArrayBuffer);
|
||||
|
||||
setFrame(name: string, value: string | object | string[]);
|
||||
|
||||
addTag(): Uint8Array;
|
||||
}
|
||||
}
|
||||
|
||||
declare module 'metaflac-js' {
|
||||
export default class Metaflac {
|
||||
constructor(buffer: Buffer);
|
||||
|
||||
setTag(field: string);
|
||||
|
||||
removeTag(name: string);
|
||||
|
||||
importPictureFromBuffer(picture: Buffer);
|
||||
|
||||
save(): Buffer;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,54 @@
|
|||
export interface FileSystemGetFileOptions {
|
||||
create?: boolean;
|
||||
}
|
||||
|
||||
interface FileSystemCreateWritableOptions {
|
||||
keepExistingData?: boolean;
|
||||
}
|
||||
|
||||
interface FileSystemRemoveOptions {
|
||||
recursive?: boolean;
|
||||
}
|
||||
|
||||
interface FileSystemFileHandle {
|
||||
getFile(): Promise<File>;
|
||||
|
||||
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>;
|
||||
}
|
||||
|
||||
enum WriteCommandType {
|
||||
write = 'write',
|
||||
seek = 'seek',
|
||||
truncate = 'truncate',
|
||||
}
|
||||
|
||||
interface WriteParams {
|
||||
type: WriteCommandType;
|
||||
size?: number;
|
||||
position?: number;
|
||||
data: BufferSource | Blob | string;
|
||||
}
|
||||
|
||||
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams;
|
||||
|
||||
interface FileSystemWritableFileStream extends WritableStream {
|
||||
write(data: FileSystemWriteChunkType): Promise<undefined>;
|
||||
|
||||
seek(position: number): Promise<undefined>;
|
||||
|
||||
truncate(size: number): Promise<undefined>;
|
||||
|
||||
close(): Promise<undefined>; // should be implemented in WritableStream
|
||||
}
|
||||
|
||||
export declare interface FileSystemDirectoryHandle {
|
||||
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>;
|
||||
|
||||
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
import Vue, { VNode } from 'vue';
|
||||
|
||||
declare global {
|
||||
namespace JSX {
|
||||
// tslint:disable no-empty-interface
|
||||
interface Element extends VNode {}
|
||||
|
||||
// tslint:disable no-empty-interface
|
||||
interface ElementClass extends Vue {}
|
||||
|
||||
interface IntrinsicElements {
|
||||
[elem: string]: any;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
declare module '*.vue' {
|
||||
import Vue from 'vue';
|
||||
export default Vue;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -0,0 +1 @@
|
|||
export const extractQQMusicMeta = jest.fn();
|
|
@ -0,0 +1,4 @@
|
|||
export const storage = {
|
||||
loadJooxUUID: jest.fn(),
|
||||
saveJooxUUID: jest.fn(),
|
||||
};
|
|
@ -0,0 +1,113 @@
|
|||
export const IXAREA_API_ENDPOINT = 'https://um-api.ixarea.com';
|
||||
|
||||
export interface UpdateInfo {
|
||||
Found: boolean;
|
||||
HttpsFound: boolean;
|
||||
Version: string;
|
||||
URL: string;
|
||||
Detail: string;
|
||||
}
|
||||
|
||||
export async function checkUpdate(version: string): Promise<UpdateInfo> {
|
||||
const resp = await fetch(IXAREA_API_ENDPOINT + '/music/app-version', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ Version: version }),
|
||||
});
|
||||
return await resp.json();
|
||||
}
|
||||
|
||||
export interface CoverInfo {
|
||||
Id: string;
|
||||
Type: number;
|
||||
}
|
||||
|
||||
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> {
|
||||
const endpoint = IXAREA_API_ENDPOINT + '/music/qq-cover';
|
||||
const params = new URLSearchParams([
|
||||
['Title', title],
|
||||
['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}`;
|
||||
}
|
|
@ -0,0 +1,147 @@
|
|||
import { IAudioMetadata, parseBlob as metaParseBlob } from 'music-metadata-browser';
|
||||
import iconv from 'iconv-lite';
|
||||
|
||||
import {
|
||||
GetCoverFromFile,
|
||||
GetImageFromURL,
|
||||
GetMetaFromFile,
|
||||
WriteMetaToFlac,
|
||||
WriteMetaToMp3,
|
||||
AudioMimeType,
|
||||
} from '@/decrypt/utils';
|
||||
import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api';
|
||||
|
||||
interface MetaResult {
|
||||
title: string;
|
||||
artist: string;
|
||||
album: string;
|
||||
imgUrl: string;
|
||||
blob: Blob;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @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 = iconv.decode(new Buffer(musicMeta.common.artist ?? ''), 'gbk');
|
||||
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ''), 'gbk');
|
||||
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ''), 'gbk');
|
||||
}
|
||||
}
|
||||
|
||||
if (id) {
|
||||
try {
|
||||
return 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(' _ '),
|
||||
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;
|
||||
}
|
|
@ -0,0 +1,3 @@
|
|||
import storageFactory from './storage/StorageFactory';
|
||||
|
||||
export const storage = storageFactory();
|
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
}
|
|
@ -0,0 +1,73 @@
|
|||
// Copyright 2021 MengYX. All rights reserved.
|
||||
//
|
||||
// Copyright 2015 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in https://go.dev/LICENSE.
|
||||
|
||||
import { TeaCipher } from '@/utils/tea';
|
||||
|
||||
test('key size', () => {
|
||||
// prettier-ignore
|
||||
const testKey = new Uint8Array([
|
||||
0x00, 0x11, 0x22, 0x33, 0x44, 0x55, 0x66, 0x77,
|
||||
0x88, 0x99, 0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff,
|
||||
0x00,
|
||||
])
|
||||
expect(() => new TeaCipher(testKey.slice(0, 16))).not.toThrow();
|
||||
|
||||
expect(() => new TeaCipher(testKey)).toThrow();
|
||||
|
||||
expect(() => new TeaCipher(testKey.slice(0, 15))).toThrow();
|
||||
});
|
||||
|
||||
// prettier-ignore
|
||||
const teaTests = [
|
||||
// These were sourced from https://github.com/froydnj/ironclad/blob/master/testing/test-vectors/tea.testvec
|
||||
{
|
||||
rounds: TeaCipher.numRounds,
|
||||
key: new Uint8Array([
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
plainText: new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
cipherText: new Uint8Array([0x41, 0xea, 0x3a, 0x0a, 0x94, 0xba, 0xa9, 0x40]),
|
||||
},
|
||||
{
|
||||
rounds: TeaCipher.numRounds,
|
||||
key: new Uint8Array([
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff,
|
||||
]),
|
||||
plainText: new Uint8Array([0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]),
|
||||
cipherText: new Uint8Array([0x31, 0x9b, 0xbe, 0xfb, 0x01, 0x6a, 0xbd, 0xb2]),
|
||||
},
|
||||
{
|
||||
rounds: 16,
|
||||
key: new Uint8Array([
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||
]),
|
||||
plainText: new Uint8Array([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]),
|
||||
cipherText: new Uint8Array([0xed, 0x28, 0x5d, 0xa1, 0x45, 0x5b, 0x33, 0xc1]),
|
||||
},
|
||||
];
|
||||
|
||||
test('rounds', () => {
|
||||
const tt = teaTests[0];
|
||||
expect(() => new TeaCipher(tt.key, tt.rounds - 1)).toThrow();
|
||||
});
|
||||
|
||||
test('encrypt & decrypt', () => {
|
||||
for (const tt of teaTests) {
|
||||
const c = new TeaCipher(tt.key, tt.rounds);
|
||||
|
||||
const buf = new Uint8Array(8);
|
||||
const bufView = new DataView(buf.buffer);
|
||||
|
||||
c.encrypt(bufView, new DataView(tt.plainText.buffer));
|
||||
expect(buf).toStrictEqual(tt.cipherText);
|
||||
|
||||
c.decrypt(bufView, new DataView(tt.cipherText.buffer));
|
||||
expect(buf).toStrictEqual(tt.plainText);
|
||||
}
|
||||
});
|
|
@ -0,0 +1,80 @@
|
|||
// Copyright 2021 MengYX. All rights reserved.
|
||||
//
|
||||
// Copyright 2015 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in https://go.dev/LICENSE.
|
||||
|
||||
// TeaCipher is a typescript port to golang.org/x/crypto/tea
|
||||
|
||||
// Package tea implements the TEA algorithm, as defined in Needham and
|
||||
// Wheeler's 1994 technical report, “TEA, a Tiny Encryption Algorithm”. See
|
||||
// http://www.cix.co.uk/~klockstone/tea.pdf for details.
|
||||
//
|
||||
// TEA is a legacy cipher and its short block size makes it vulnerable to
|
||||
// birthday bound attacks (see https://sweet32.info). It should only be used
|
||||
// where compatibility with legacy systems, not security, is the goal.
|
||||
|
||||
export class TeaCipher {
|
||||
// BlockSize is the size of a TEA block, in bytes.
|
||||
static readonly BlockSize = 8;
|
||||
|
||||
// KeySize is the size of a TEA key, in bytes.
|
||||
static readonly KeySize = 16;
|
||||
|
||||
// delta is the TEA key schedule constant.
|
||||
static readonly delta = 0x9e3779b9;
|
||||
|
||||
// numRounds 64 is the standard number of rounds in TEA.
|
||||
static readonly numRounds = 64;
|
||||
|
||||
k0: number;
|
||||
k1: number;
|
||||
k2: number;
|
||||
k3: number;
|
||||
rounds: number;
|
||||
|
||||
constructor(key: Uint8Array, rounds: number = TeaCipher.numRounds) {
|
||||
if (key.length != 16) {
|
||||
throw Error('incorrect key size');
|
||||
}
|
||||
if ((rounds & 1) != 0) {
|
||||
throw Error('odd number of rounds specified');
|
||||
}
|
||||
|
||||
const k = new DataView(key.buffer);
|
||||
this.k0 = k.getUint32(0, false);
|
||||
this.k1 = k.getUint32(4, false);
|
||||
this.k2 = k.getUint32(8, false);
|
||||
this.k3 = k.getUint32(12, false);
|
||||
this.rounds = rounds;
|
||||
}
|
||||
|
||||
encrypt(dst: DataView, src: DataView) {
|
||||
let v0 = src.getUint32(0, false);
|
||||
let v1 = src.getUint32(4, false);
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < this.rounds / 2; i++) {
|
||||
sum = sum + TeaCipher.delta;
|
||||
v0 += ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1);
|
||||
v1 += ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3);
|
||||
}
|
||||
|
||||
dst.setUint32(0, v0, false);
|
||||
dst.setUint32(4, v1, false);
|
||||
}
|
||||
|
||||
decrypt(dst: DataView, src: DataView) {
|
||||
let v0 = src.getUint32(0, false);
|
||||
let v1 = src.getUint32(4, false);
|
||||
|
||||
let sum = (TeaCipher.delta * this.rounds) / 2;
|
||||
for (let i = 0; i < this.rounds / 2; i++) {
|
||||
v1 -= ((v0 << 4) + this.k2) ^ (v0 + sum) ^ ((v0 >>> 5) + this.k3);
|
||||
v0 -= ((v1 << 4) + this.k0) ^ (v1 + sum) ^ ((v1 >>> 5) + this.k1);
|
||||
sum -= TeaCipher.delta;
|
||||
}
|
||||
dst.setUint32(0, v0, false);
|
||||
dst.setUint32(4, v1, false);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,80 @@
|
|||
import { DecryptResult } from '@/decrypt/entity';
|
||||
import { FileSystemDirectoryHandle } from '@/shims-fs';
|
||||
|
||||
export enum FilenamePolicy {
|
||||
ArtistAndTitle,
|
||||
TitleOnly,
|
||||
TitleAndArtist,
|
||||
SameAsOriginal,
|
||||
}
|
||||
|
||||
export const FilenamePolicies: { key: FilenamePolicy; text: string }[] = [
|
||||
{ key: FilenamePolicy.ArtistAndTitle, text: '歌手-歌曲名' },
|
||||
{ key: FilenamePolicy.TitleOnly, text: '歌曲名' },
|
||||
{ key: FilenamePolicy.TitleAndArtist, text: '歌曲名-歌手' },
|
||||
{ key: FilenamePolicy.SameAsOriginal, text: '同源文件名' },
|
||||
];
|
||||
|
||||
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string {
|
||||
switch (policy) {
|
||||
case FilenamePolicy.TitleOnly:
|
||||
return `${data.title}.${data.ext}`;
|
||||
case FilenamePolicy.TitleAndArtist:
|
||||
return `${data.title} - ${data.artist}.${data.ext}`;
|
||||
case FilenamePolicy.SameAsOriginal:
|
||||
return `${data.rawFilename}.${data.ext}`;
|
||||
default:
|
||||
case FilenamePolicy.ArtistAndTitle:
|
||||
return `${data.artist} - ${data.title}.${data.ext}`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) {
|
||||
let filename = GetDownloadFilename(data, policy);
|
||||
// prevent filename exist
|
||||
try {
|
||||
await dir.getFileHandle(filename);
|
||||
filename = `${new Date().getTime()} - ${filename}`;
|
||||
} catch (e) {}
|
||||
const file = await dir.getFileHandle(filename, { create: true });
|
||||
const w = await file.createWritable();
|
||||
await w.write(data.blob);
|
||||
await w.close();
|
||||
}
|
||||
|
||||
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
|
||||
const a = document.createElement('a');
|
||||
a.href = data.file;
|
||||
a.download = GetDownloadFilename(data, policy);
|
||||
document.body.append(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
}
|
||||
|
||||
export function RemoveBlobMusic(data: DecryptResult) {
|
||||
URL.revokeObjectURL(data.file);
|
||||
if (data.picture?.startsWith('blob:')) {
|
||||
URL.revokeObjectURL(data.picture);
|
||||
}
|
||||
}
|
||||
|
||||
export class DecryptQueue {
|
||||
private readonly pending: (() => Promise<void>)[];
|
||||
|
||||
constructor() {
|
||||
this.pending = [];
|
||||
}
|
||||
|
||||
queue(fn: () => Promise<void>) {
|
||||
this.pending.push(fn);
|
||||
this.consume();
|
||||
}
|
||||
|
||||
private consume() {
|
||||
const fn = this.pending.shift();
|
||||
if (fn)
|
||||
fn()
|
||||
.then(() => this.consume)
|
||||
.catch(console.error);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,4 @@
|
|||
import { expose } from 'threads/worker';
|
||||
import { Decrypt } from '@/decrypt';
|
||||
|
||||
expose(Decrypt);
|
|
@ -0,0 +1,170 @@
|
|||
<template>
|
||||
<div>
|
||||
<file-selector @error="showFail" @success="showSuccess" />
|
||||
|
||||
<div id="app-control">
|
||||
<el-row class="mb-3">
|
||||
<span>歌曲命名格式:</span>
|
||||
<el-radio v-for="k in FilenamePolicies" :key="k.key" v-model="filename_policy" :label="k.key">
|
||||
{{ k.text }}
|
||||
</el-radio>
|
||||
</el-row>
|
||||
<el-row>
|
||||
<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">
|
||||
<div slot="content">
|
||||
<span v-if="instant_save">工作模式: {{ dir ? '写入本地文件系统' : '调用浏览器下载' }}</span>
|
||||
<span v-else>
|
||||
当您使用此工具进行大量文件解锁的时候,建议开启此选项。<br />
|
||||
开启后,解锁结果将不会存留于浏览器中,防止内存不足。
|
||||
</span>
|
||||
</div>
|
||||
<el-checkbox v-model="instant_save" type="success" border class="ml-2">立即保存</el-checkbox>
|
||||
</el-tooltip>
|
||||
</el-row>
|
||||
</div>
|
||||
|
||||
<audio :autoplay="playing_auto" :src="playing_url" controls />
|
||||
|
||||
<PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @play="changePlaying" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import FileSelector from '@/component/FileSelector';
|
||||
import PreviewTable from '@/component/PreviewTable';
|
||||
import ConfigDialog from '@/component/ConfigDialog';
|
||||
|
||||
import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils';
|
||||
|
||||
export default {
|
||||
name: 'Home',
|
||||
components: {
|
||||
FileSelector,
|
||||
PreviewTable,
|
||||
ConfigDialog,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showConfigDialog: false,
|
||||
tableData: [],
|
||||
playing_url: '',
|
||||
playing_auto: false,
|
||||
filename_policy: FilenamePolicy.ArtistAndTitle,
|
||||
instant_save: false,
|
||||
FilenamePolicies,
|
||||
dir: null,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
instant_save(val) {
|
||||
if (val) this.showDirectlySave();
|
||||
},
|
||||
},
|
||||
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 = [];
|
||||
},
|
||||
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);
|
||||
},
|
||||
|
||||
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>
|
|
@ -0,0 +1 @@
|
|||
dRzX3p5ZYqAlp7lLSs9Zr0rw1iEZy23bB670x4ch2w97x14Zwpk1UXbKU4C2sOS7uZ0NB5QM7ve9GnSrr2JHxP74hVNONwVV77CdOOVb807317KvtI5Yd6h08d0c5W88rdV46C235YGDjUSZj5314YTzy0b6vgh4102P7E273r911Nl464XV83Hr00rkAHkk791iMGSJH95GztN28u2Nv5s9Xx38V69o4a8aIXxbx0g1EM0623OEtbtO9zsqCJfj6MhU7T8iVS6M3q19xhq6707E6r7wzPO6Yp4BwBmgg4F95Lfl0vyF7YO6699tb5LMnr7iFx29o98hoh3O3Rd8h9Juu8P1wG7vdnO5YtRlykhUluYQblNn7XwjBJ53HAyKVraWN5dG7pv7OMl1s0RykPh0p23qfYzAAMkZ1M422pEd07TA9OCKD1iybYxWH06xj6A8mzmcnYGT9P1a5Ytg2EF5LG3IknL2r3AUz99Y751au6Cr401mfAWK68WyEBe5
|
|
@ -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
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue