22 Commits

Author SHA1 Message Date
MengYX
538705187a Add CI 2019-11-10 17:25:01 +08:00
MengYX
04be04204a Use new window to open link 2019-11-10 01:30:11 +08:00
MengYX
d99cd23e0c Update Dependencies 2019-11-10 01:16:08 +08:00
MengYX
c9770bdd59 Add .qmcogg (without test)
Add Tips
2019-11-10 00:59:13 +08:00
MengYX
757d4d4847 Fix Display Bugs In Edge and Safari 2019-09-21 23:32:21 +08:00
MengYX
0913337612 Enhanced ease of use 2019-09-18 00:21:18 +08:00
MengYX
23ff9cdec1 Add Analytics
Fix an error statement
2019-09-14 20:48:57 +08:00
MengYX
0b0b19163b Complete ID3 for ncm 2019-09-12 15:51:10 +08:00
MengYX
382a637a2c Fix an icon error 2019-09-08 15:06:08 +08:00
MengYX
82e4ec6312 Merge pull request #2 from ix64/pull/1
Fix bugs after using music-metadata-browser
2019-09-08 14:50:39 +08:00
MengYX
0e59843944 Merge branch 'master' into pull/1 2019-09-08 14:49:03 +08:00
MengYX
c7ed517ede Merge pull request #1 from Borewit/music-metadata-browser
Maybe try music-metadata-browser?
2019-09-08 14:45:23 +08:00
Borewit
bd377db39b Fix parsing picture in metadata 2019-09-08 08:28:25 +02:00
MengYX
32128ed425 Fix bugs after using music-metadata-browser 2019-09-08 13:40:32 +08:00
Borewit
9416ded167 use music-metadata-browser 2019-09-07 19:50:04 +02:00
MengYX
1ba40d1fc2 Change icon 2019-08-25 15:55:36 +08:00
MengYX
76c0577185 Downgrade jsmediatag to avoid bug
Rename project
2019-08-25 15:16:20 +08:00
MengYX
3ceb56900d Update page footer 2019-08-25 14:44:27 +08:00
MengYX
e0ffd3f477 Update readme 2019-08-25 14:31:38 +08:00
MengYX
94136ec2e6 Update readme 2019-08-25 14:27:11 +08:00
MengYX
428a4505ad Merge branch 'master' of https://git.ixarea.com/MusicCrack/music-crack 2019-08-25 14:12:48 +08:00
MengYX
6ed0291e54 Update dependencies to fix CVE-2019-10744 2019-08-25 14:10:33 +08:00
115 changed files with 5685 additions and 35370 deletions

33
.drone.yml Normal file
View File

@@ -0,0 +1,33 @@
---
kind: pipeline
type: docker
name: default
clone:
depth: 1
steps:
- name: installDependencies
image: node:lts
commands:
- npm config set registry http://registry.npm.taobao.org
- npm install
- name: build
image: node:lts
commands:
- npm run build
- tar -czf dist.tar.gz ./dist/*
- name: release
image: plugins/gitea-release
settings:
base_url: https://git.ixarea.com
files:
- dist.tar.gz
api_key:
from_secret: gitea_token
checksum:
- sha256
when:
event: [tag]

View File

@@ -1,39 +0,0 @@
---
name: Bug报告
about: 报告Bug以帮助改进程序
title: ''
labels: bug
assignees: ''
---
* 请按照此模板填写,否则可能立即被关闭
- [x] 我确认已经搜索过Issue不存并确认相同的Issue
- [x] 我有证据表明这是程序导致的问题(如不确认,可以在[Discussions](https://github.com/ix64/unlock-music/discussions)内提出)
**Bug描述**
简要地复述你遇到的Bug
**复现方法**
描述复现方法,必要时请提供样本文件
**程序截图或者Console报错信息**
如果可以请提供二者之一
**环境信息:**
- 操作系统和浏览器:
- 程序版本:
- 获取音乐文件所使用的客户端及其版本信息:
**附加信息**
其他能够帮助确认问题的信息

View File

@@ -1,26 +0,0 @@
---
name: 新功能
about: 对于程序新的想法或建议
title: ''
labels: enhancement
assignees: ''
---
- 请按照此模板填写,否则可能立即被关闭
**背景和说明**
简要说明产生此想法的背景和此想法的具体内容
**实现途径**
- 如果没有设计方案,请简要描述实现思路
- 如果你没有任何的实现思路,请通过[Discussions](https://github.com/ix64/unlock-music/discussions)或者Telegram进行讨论
**附加信息**
更多你想要表达的内容

View File

@@ -1,84 +0,0 @@
# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json
name: Test Build
on:
push:
paths:
- ".github/workflows/*"
- "**/*.js"
- "**/*.ts"
- "**/*.vue"
- "public/**/*"
- "package-lock.json"
- "package.json"
pull_request:
branches: [ master ]
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:
matrix:
build: [ legacy, modern ]
include:
- build: legacy
BUILD_ARGS: ""
BUILD_EXTENSION: true
- build: modern
BUILD_ARGS: "-- --modern"
BUILD_EXTENSION: false
steps:
- uses: actions/checkout@v2
- name: Use Node.js 16.x
uses: actions/setup-node@v2
with:
node-version: "16"
- name: Install Dependencies
run: npm ci
- name: Build
run: npm run build ${{ matrix.BUILD_ARGS }}
- name: Publish artifact
uses: actions/upload-artifact@v2
with:
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

View File

@@ -1,65 +0,0 @@
name: Post Release
on:
release:
types: [ published ]
jobs:
release-docker:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Setup vars
id: vars
env:
RELEASE_REF: ${{ github.ref }}
run: echo "::set-output name=tag::${RELEASE_REF#refs/tags/}"
- name: Download release content
run: |
echo "https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.tag }}/modern.tar.gz"
wget -O modern.tar.gz "https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.tag }}/modern.tar.gz"
mkdir ./dist
tar zxf modern.tar.gz -C ./dist
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build docker and push (on modern)
uses: docker/build-push-action@v2
with:
context: .
file: ./Dockerfile
platforms: linux/amd64,linux/arm64,linux/386
push: true
tags: |
ix64/unlock-music:latest
ix64/unlock-music:${{ steps.vars.outputs.tag }}
gh-pages:
runs-on: ubuntu-latest
steps:
- name: Setup vars
id: vars
env:
RELEASE_REF: ${{ github.ref }}
run: echo "::set-output name=tag::${RELEASE_REF#refs/tags/}"
- name: Download release content
run: |
echo "https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.tag }}/modern.tar.gz"
wget -O modern.tar.gz "https://github.com/${{ github.repository }}/releases/download/${{ steps.vars.outputs.tag }}/modern.tar.gz"
mkdir ./dist
tar zxf modern.tar.gz -C ./dist
- name: Deploy
uses: peaceiris/actions-gh-pages@v3
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: ./dist

View File

@@ -1,126 +0,0 @@
name: Build Release
on:
push:
tags:
- "v*"
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Use Node.js 16.x
uses: actions/setup-node@v2
with:
node-version: "16"
- name: Install Dependencies
run: npm ci
- name: Build Legacy
env:
GZIP: "--best"
run: |
npm run build
tar -czf legacy.tar.gz -C ./dist .
cd dist
zip -rJ9 ../legacy.zip *
cd ..
- name: Build Extension (on legacy)
env:
GZIP: "--best"
run: |
npm run make-extension
cd dist
zip -rJ9 ../extension.zip *
cd ..
- name: Build Modern
env:
GZIP: "--best"
run: |
npm run build -- --modern
tar -czf modern.tar.gz -C ./dist .
cd dist
zip -rJ9 ../modern.zip *
cd ..
- name: Checksum
run: sha256sum *.tar.gz *.zip > sha256sum.txt
- name: Get current time
id: date
run: echo "::set-output name=date::$(date +'%Y/%m/%d')"
- name: Create a Release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
tag_name: ${{ github.ref }}
release_name: "Build ${{ steps.date.outputs.date }}"
draft: true
- name: Upload Release Assets - legacy.tar.gz
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./legacy.tar.gz
asset_name: legacy.tar.gz
asset_content_type: application/gzip
- name: Upload Release Assets - legacy.zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./legacy.zip
asset_name: legacy.zip
asset_content_type: application/zip
- name: Upload Release Assets - modern.tar.gz
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./modern.tar.gz
asset_name: modern.tar.gz
asset_content_type: application/gzip
- name: Upload Release Assets - modern.zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./modern.zip
asset_name: modern.zip
asset_content_type: application/zip
- name: Upload Release Assets - extension.zip
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./extension.zip
asset_name: extension.zip
asset_content_type: application/zip
- name: Upload Release Assets - sha256sum.txt
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ steps.create_release.outputs.upload_url }}
asset_path: ./sha256sum.txt
asset_name: sha256sum.txt
asset_content_type: text/plain

1
.gitignore vendored
View File

@@ -1,7 +1,6 @@
.DS_Store
node_modules
/dist
/coverage
# local env files
.env.local

View File

@@ -1,42 +0,0 @@
// .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',
};

View File

@@ -1,10 +0,0 @@
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

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019-2021 MengYX
Copyright (c) [2019] [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.

109
README.md
View File

@@ -1,91 +1,32 @@
# Unlock Music 音乐解锁
- Unlock encrypted music file in browser.
- 在浏览器中解锁加密的音乐文件。
- [Online Demo](https://tool.ixarea.com/music)
- 在浏览器中解锁加密的音乐文件。 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]
[![Build Status](https://ci.ixarea.com/api/badges/ix64/unlock-music/status.svg)](https://ci.ixarea.com/ix64/unlock-music)
![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)
## Features
- [x] Unlock in browser 在浏览器中解锁
- [x] QQMusic File QQ音乐文件 (.qmc0/.qmc3/.qmcflac)
- [x] Netease File 网易云音乐文件 (.ncm)
- [x] Drag and Drop 拖放文件
- [x] Play instantly 在线播放
- [x] Batch unlocking 批量解锁
- [x] Progressive Web App 渐进式Web应用
- [x] Complete ID3 for ncm 补全ncm的ID3信息
- [ ] Multi-language 多语言
[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] 虾米音乐格式 (.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] 渐进式 Web 应用 (PWA)
- [x] 多线程
- [x] 写入Meta和封面图片
## 使用方法
### 安装浏览器扩展
[![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)协议** 访问)
- 解压缩后即可部署或本地使用(**请勿直接运行源代码**
### 使用 Docker 镜像
```shell
docker run --name unlock-music -d -p 8080:80 ix64/unlock-music
## Project setup
```
npm install
```
### 自行构建
### Compiles and hot-reloads for development
```
npm run serve
```
- 环境要求
- nodejs (v16.x)
- npm
1. 获取项目源代码后安装相关依赖:
```sh
npm ci
```
2. 然后进行构建。编译后的文件保存到 dist 目录下:
```sh
npm run build
```
- 如果是用于开发,可以执行 `npm run serve`。
3. 如需构建浏览器扩展build 完成后还需要执行:
```sh
npm run make-extension
```
### Compiles and minifies for production
```
npm run build
```

View File

@@ -1,12 +1,5 @@
module.exports = {
presets: [
'@vue/app',
'@babel/preset-typescript'
],
plugins: [
["component", {
"libraryName": "element-ui",
"styleLibraryName": "theme-chalk"
}]
]
};
presets: [
'@vue/app'
]
}

View File

@@ -1,16 +0,0 @@
{
"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"
}
}

View File

@@ -1,8 +0,0 @@
module.exports = {
setupFilesAfterEnv: [
'./src/__test__/setup_jest.js'
],
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1'
}
};

View File

@@ -1,25 +0,0 @@
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")

36336
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,59 +1,26 @@
{
"name": "unlock-music",
"version": "v1.10.0",
"ext_build": 0,
"updateInfo": "重写QMC解锁完全支持.mflac*/.mgg*; 支持JOOX解锁",
"license": "MIT",
"description": "Unlock encrypted music file in browser.",
"repository": {
"type": "git",
"url": "https://github.com/ix64/unlock-music"
},
"version": "0.2.0",
"private": true,
"scripts": {
"postinstall": "patch-package",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"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"
"build": "vue-cli-service build"
},
"dependencies": {
"@babel/preset-typescript": "^7.16.5",
"@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.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": "7.9.0",
"music-metadata-browser": "2.2.7",
"register-service-worker": "^1.7.2",
"threads": "^1.6.5",
"vue": "^2.6.14"
"browser-id3-writer": "^4.2.0",
"core-js": "^2.6.10",
"crypto-js": "^3.1.9-1",
"element-ui": "^2.11.1",
"music-metadata-browser": "^1.6.3",
"register-service-worker": "^1.6.2",
"vue": "^2.6.10"
},
"devDependencies": {
"@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",
"@vue/cli-plugin-babel": "^3.11.0",
"@vue/cli-plugin-pwa": "^3.11.0",
"@vue/cli-service": "^3.11.0",
"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.14"
"vue-template-compiler": "^2.6.10"
}
}

View File

@@ -1,11 +0,0 @@
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

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

View File

@@ -2,42 +2,69 @@
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<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">
<title>音乐解锁</title>
<meta content="音乐,解锁,qmc,mgg,mflac,qq音乐,加密" name="keywords"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<script>var _paq = window._paq || [];
_paq.push(['trackPageView'], ['enableLinkTracking'], ['setSiteId', '2'],
['setTrackerUrl', 'https://stats.ixarea.com/ixarea-stats/report']);
</script>
<script async src="https://stats.ixarea.com/ixarea-stats.js"></script>
<title>音乐解锁 - By IXarea</title>
<meta content="音乐,解锁,ncm,qmc,qmc0,qmc3,qmcflac,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 #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-->
<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>
</head>
<body>
<div id="loader-mask">
<div id="loader"></div>
<noscript>
<h3 id="loader-js">请启用JavaScript</h3>
<img alt=""
src="https://stats.ixarea.com/ixarea-stats/report?rec=1&action_name=音乐解锁-NoJS&idsite=2"
src="https://stats.ixarea.com/ixarea-stats/report?idsite=2&rec=1&action_name=音乐解锁+-+By+IXarea"
style="border:0"/>
</noscript>
<h3 id="loader-source"> 请勿直接运行源代码! </h3>
<div id="loader-tips-outdated" hidden>
<h2>您可能在使用不受支持的<span style="color:#f00;">过时</span>浏览器,这可能导致此应用无法正常工作。</h2>
<h3>如果您使用双核浏览器,您可以尝试切换到 <span style="color:#f00;">“极速模式”</span> 解决此问题。</h3>
<h3>或者,您可以尝试更换下方的几个浏览器之一。</h3>
</div>
<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>
<a href="https://www.firefox.com.cn/" target="_blank">Mozilla Firefox</a>
| <a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
</h3>
<strong>音乐解锁采用了一些新特性!建议使用
<a target="_blank" href="https://www.google.cn/chrome/">Google Chrome</a>
<a target="_blank" href="https://www.firefox.com.cn/">Mozilla Firefox</a>
| <a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>
</strong>
</div>
<div id="app"></div>
<script src="./loader.js"></script>
</body>
</html>

View File

@@ -1,10 +0,0 @@
var _paq = window._paq || [];
_paq.push(["setRequestMethod", "POST"], ["trackPageView"], ["enableLinkTracking"],
["setSiteId", "2"], ["setTrackerUrl", "https://stats.ixarea.com/ixarea-stats/report"]);
var tag = document.createElement('script');
tag.type = 'text/javascript';
tag.async = true;
tag.src = 'https://stats.ixarea.com/ixarea-stats.js';
var s = document.getElementsByTagName('script')[0];
s.parentNode.insertBefore(tag, s);

View File

@@ -1,25 +0,0 @@
(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;
}
})();

21
public/manifest.json Normal file
View File

@@ -0,0 +1,21 @@
{
"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.

View File

@@ -1,87 +1,241 @@
<template>
<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>
目前支持 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 &copy; 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>
<div id="app">
<el-container>
<el-main>
<el-upload
:auto-upload="false"
:on-change="handleFile"
:show-file-list="false"
accept=".ncm,.qmc0,.qmc3,.qmcflac,.qmcogg"
action=""
drag
multiple>
<i class="el-icon-upload"></i>
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
<div class="el-upload__tip" slot="tip">本工具仅在浏览器内对文件进行解锁无需消耗流量</div>
</el-upload>
<el-row id="app-control">
<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-row>
<audio :autoplay="playing_auto" :src="playing_url" controls></audio>
<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"></el-image>
</template>
</el-table-column>
<el-table-column label="歌曲" sortable>
<template slot-scope="scope">
<span style="margin-left: 10px">{{ scope.row.title }}</span>
</template>
</el-table-column>
<el-table-column label="歌手" sortable>
<template slot-scope="scope">
<p>{{ scope.row.artist }}</p>
</template>
</el-table-column>
<el-table-column label="专辑" sortable>
<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 circle>
<el-link :download="scope.row.filename" :href="scope.row.file"
:underline="false" icon="el-icon-download">
</el-link>
</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>
</el-main>
<el-footer id="app-footer">
<el-row>
音乐解锁移除已购音乐的加密保护
目前支持网易云音乐(ncm)和QQ音乐(qmc0, qmc3, qmcflac)
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
</el-row>
<el-row>
<span>Copyright &copy; 2019</span>
<a href="https://github.com/ix64" target="_blank">MengYX</a>
音乐解锁使用
<a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a>
开放
<a href="https://github.com/ix64/unlock-music" target="_blank">源代码</a>
</el-row>
</el-footer>
</el-container>
</div>
</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';
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: `我们使用PWA技术无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`,
dangerouslyUseHTMLString: true,
duration: 10000,
position: 'top-left',
});
}
},
},
};
const NcmDecrypt = require("./plugins/ncm");
const QmcDecrypt = require("./plugins/qmc");
const RawDecrypt = require("./plugins/raw");
export default {
name: 'app',
components: {},
data() {
return {
activeIndex: '1',
tableData: [],
playing_url: "",
playing_auto: false,
}
},
mounted() {
this.$nextTick(function () {
this.finishLoad();
});
},
methods: {
finishLoad() {
document.getElementById("loader-mask").remove();
this.$notify.info({
title: '离线使用',
message: '我们使用PWA技术添加到桌面或收藏夹无网络也能使用。点击查看<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true,
duration: 30000,
position: 'top-left'
});
},
handleFile(file) {
let ext = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length).toLowerCase();
(async () => {
let data = null;
switch (ext) {
case "ncm":
data = await NcmDecrypt.Decrypt(file.raw);
break;
case "mp3":
case "flac":
data = await RawDecrypt.Decrypt(file.raw);
break;
case "qmc3":
case "qmc0":
case "qmcflac":
case "qmcogg":
data = await QmcDecrypt.Decrypt(file.raw);
break;
default:
break;
}
if (null != data) {
this.tableData.push(data);
this.$notify.success({
title: '解锁成功',
message: '成功解锁 ' + data.title
});
let _rp_data = {
original: file.name,
title: data.title,
album: data.album,
artist: data.artist,
mime: data.mime
};
console.log(data);
window._paq.push(["trackEvent", "Unlock", "Success", JSON.stringify(_rp_data)]);
} else {
this.$notify.error({
title: '错误',
message: '解析此文件时出现问题,请查看<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true
});
window._paq.push(["trackEvent", "Unlock", "Error", file.name]);
}
})();
},
handlePlay(index, row) {
this.playing_url = row.file;
this.playing_auto = true;
},
handleDelete(index, row) {
console.log(index);
URL.revokeObjectURL(row.file);
URL.revokeObjectURL(row.picture);
this.tableData.splice(index, 1);
},
handleDeleteAll() {
this.tableData.forEach(value => {
URL.revokeObjectURL(value.file);
URL.revokeObjectURL(value.picture);
});
this.tableData = [];
},
handleDownloadAll() {
let index = 0;
let c = setInterval(() => {
if (index < this.tableData.length) {
let a = document.createElement('a');
a.href = this.tableData[index].file;
a.download = this.tableData[index].filename;
document.body.append(a);
a.click();
a.remove();
index++;
} else {
clearInterval(c);
}
}, 1000);
}
}
}
</script>
<style lang="scss">
@import 'scss/unlock-music';
<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.5em;
padding-right: 0.5em;
}
#app-footer {
text-align: center;
font-size: small;
}
.el-upload-dragger {
width: 80vw !important;
}
#app-control {
padding-top: 1em;
padding-bottom: 1em;
}
</style>

View File

@@ -1,2 +0,0 @@
// Polyfill for node.
global.Blob = global.Blob || require("node:buffer").Blob;

BIN
src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

View File

@@ -1,113 +0,0 @@
<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>

View File

@@ -1,91 +0,0 @@
<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>

View File

@@ -1,62 +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 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>

View File

@@ -1,18 +0,0 @@
<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>

View File

@@ -1,52 +0,0 @@
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');
});
});

View File

@@ -1,25 +0,0 @@
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;
}

View File

@@ -1,90 +0,0 @@
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 '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 '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;
}

View File

@@ -1,44 +0,0 @@
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,
};
}

View File

@@ -1,144 +0,0 @@
import {
AudioMimeType,
BytesHasPrefix,
GetArrayBuffer,
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt,
} from '@/decrypt/utils';
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
import { DecryptResult } from '@/decrypt/entity';
import config from '@/../package.json';
//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
]
//prettier-ignore
const VprMaskDiff = [
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E,
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11,
0x00
]
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
const oriData = new Uint8Array(await GetArrayBuffer(file));
if (raw_ext === 'vpr') {
if (!BytesHasPrefix(oriData, VprHeader)) throw Error('Not a valid vpr file!');
} else {
if (!BytesHasPrefix(oriData, KgmHeader)) throw Error('Not a valid kgm(a) file!');
}
let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer);
let headerLen = bHeaderLen.getUint32(0, true);
let audioData = oriData.slice(headerLen);
let dataLen = audioData.length;
if (audioData.byteLength > 1 << 26) {
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁");
}
let key1 = new Uint8Array(17);
key1.set(oriData.slice(0x1c, 0x2c), 0);
if (MaskV2.length === 0) {
if (!(await LoadMaskV2())) throw Error('加载Kgm/Vpr Mask数据失败');
}
for (let i = 0; i < dataLen; i++) {
let med8 = key1[i % 17] ^ audioData[i];
med8 ^= (med8 & 0xf) << 4;
let msk8 = GetMask(i);
msk8 ^= (msk8 & 0xf) << 4;
audioData[i] = med8 ^ msk8;
}
if (raw_ext === 'vpr') {
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17];
}
const ext = SniffAudioExt(audioData);
const mime = AudioMimeType[ext];
let musicBlob = new Blob([audioData], { type: mime });
const musicMeta = await metaParseBlob(musicBlob);
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist);
return {
album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
ext,
mime,
title,
artist,
};
}
function GetMask(pos: number) {
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4];
}
let MaskV2: Uint8Array = new Uint8Array(0);
async function LoadMaskV2(): Promise<boolean> {
let mask_url = `https://cdn.jsdelivr.net/gh/unlock-music/unlock-music@${config.version}/public/static/kgm.mask`;
if (['http:', 'https:'].some((v) => v == self.location.protocol)) {
if (!!self.document) {
// using Web Worker
mask_url = './static/kgm.mask';
} else {
// using Main thread
mask_url = '../static/kgm.mask';
}
}
try {
const resp = await fetch(mask_url, { method: 'GET' });
MaskV2 = new Uint8Array(await resp.arrayBuffer());
return true;
} catch (e) {
console.error(e);
return false;
}
}
//prettier-ignore
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,
];

View File

@@ -1,74 +0,0 @@
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;
}

View File

@@ -1,29 +0,0 @@
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();
}
});

View File

@@ -1,171 +0,0 @@
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 },
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);
}
}
}

View File

@@ -1,117 +0,0 @@
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));
}
});

View File

@@ -1,199 +0,0 @@
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;
}
}

View File

@@ -1,26 +0,0 @@
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);
}
});

View File

@@ -1,103 +0,0 @@
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;
}

View File

@@ -1,111 +0,0 @@
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;
}

View File

@@ -1,50 +0,0 @@
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],
};
}

View File

@@ -1,32 +0,0 @@
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],
};
}

View File

@@ -1,14 +0,0 @@
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);
}

View File

@@ -1,178 +0,0 @@
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),
};
}

View File

@@ -1,67 +0,0 @@
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',
};
}

View File

@@ -1,13 +0,0 @@
<!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>

View File

@@ -1,2 +0,0 @@
const bs = chrome || browser;
bs.tabs.create({ url: bs.runtime.getURL('./index.html') }, (tab) => console.log(tab));

11
src/main.js Normal file
View File

@@ -0,0 +1,11 @@
import Vue from 'vue'
import App from './App.vue'
import './registerServiceWorker'
import './plugins/element.js'
// only if your build system can import css, otherwise import it wherever you would import your css.
Vue.config.productionTip = false;
new Vue({
render: h => h(App),
}).$mount('#app');

View File

@@ -1,56 +0,0 @@
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');

33
src/plugins/element.js Normal file
View File

@@ -0,0 +1,33 @@
import Vue from 'vue'
import {
Image,
Button,
Table,
TableColumn,
Main,
Footer,
Container,
Icon,
Row,
Col,
Upload,
Notification,
Link
} from 'element-ui';
import 'element-ui/lib/theme-chalk/index.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.prototype.$notify = Notification;

184
src/plugins/ncm.js Normal file
View File

@@ -0,0 +1,184 @@
const CryptoJS = require("crypto-js");
const ID3Writer = require("browser-id3-writer");
const CORE_KEY = CryptoJS.enc.Hex.parse("687a4852416d736f356b496e62617857");
const META_KEY = CryptoJS.enc.Hex.parse("2331346C6A6B5F215C5D2630553C2728");
const audio_mime_type = {
mp3: "audio/mpeg",
flac: "audio/flac"
};
export {Decrypt};
async function Decrypt(file) {
const fileBuffer = await new Promise(reslove => {
const reader = new FileReader();
reader.onload = (e) => {
reslove(e.target.result);
};
reader.readAsArrayBuffer(file);
});
const dataView = new DataView(fileBuffer);
if (dataView.getUint32(0, true) !== 0x4e455443 ||
dataView.getUint32(4, true) !== 0x4d414446
) {
console.log({type: "error", data: "not ncm file"});
return;
}
let offset = 10;
const keyData = (() => {
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 result.slice(17);
})();
const keyBox = (() => {
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
*/
/** @type {MusicMetaType|undefined} */
const musicMeta = (() => {
const metaDataLen = dataView.getUint32(offset, true);
offset += 4;
if (metaDataLen === 0) {
return {};
}
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}
);
const result = JSON.parse(plainText.toString(CryptoJS.enc.Utf8).slice(6));
result.albumPic = result.albumPic.replace("http:", "https:");
return result;
})();
offset += dataView.getUint32(offset + 5, true) + 13;
let audioData = new Uint8Array(fileBuffer, offset);
let audioDataLen = audioData.length;
for (let cur = 0; cur < audioDataLen; ++cur) {
audioData[cur] ^= keyBox[cur & 0xff];
}
if (musicMeta.format === undefined) {
musicMeta.format = (() => {
const [f, L, a, C] = audioData;
if (f === 0x66 && L === 0x4c && a === 0x61 && C === 0x43) {
return "flac";
}
return "mp3";
})();
}
const mime = audio_mime_type[musicMeta.format];
const artists = [];
musicMeta.artist.forEach(arr => {
artists.push(arr[0]);
});
if (musicMeta.format === "mp3") {
const writer = new ID3Writer(audioData);
writer.setFrame("TPE1", artists)
.setFrame("TIT2", musicMeta.musicName)
.setFrame("TALB", musicMeta.album);
if (musicMeta.albumPic !== "") {
try {
const img = await (await fetch(musicMeta.albumPic)).arrayBuffer();
writer.setFrame('APIC', {
type: 3,
data: img,
description: 'Cover'
})
} catch (e) {
console.log("Fail to write cover image!");
}
}
writer.addTag();
audioData = writer.arrayBuffer;
}
const musicData = new Blob([audioData], {
type: mime
});
const musicUrl = URL.createObjectURL(musicData);
const filename = artists.join(" & ") + " - " + musicMeta.musicName + "." + musicMeta.format;
return {
filename: filename,
title: musicMeta.musicName,
artist: artists.join(" & "),
album: musicMeta.album,
picture: musicMeta.albumPic,
file: musicUrl,
mime: mime
};
}

120
src/plugins/qmc.js Normal file
View File

@@ -0,0 +1,120 @@
const musicMetadata = require("music-metadata-browser");
export {Decrypt}
const SEED_MAP = [
[0x4a, 0xd6, 0xca, 0x90, 0x67, 0xf7, 0x52],
[0x5e, 0x95, 0x23, 0x9f, 0x13, 0x11, 0x7e],
[0x47, 0x74, 0x3d, 0x90, 0xaa, 0x3f, 0x51],
[0xc6, 0x09, 0xd5, 0x9f, 0xfa, 0x66, 0xf9],
[0xf3, 0xd6, 0xa1, 0x90, 0xa0, 0xf7, 0xf0],
[0x1d, 0x95, 0xde, 0x9f, 0x84, 0x11, 0xf4],
[0x0e, 0x74, 0xbb, 0x90, 0xbc, 0x3f, 0x92],
[0x00, 0x09, 0x5b, 0x9f, 0x62, 0x66, 0xa1]];
const audio_mime_type = {
mp3: "audio/mpeg",
flac: "audio/flac"
};
async function Decrypt(file) {
// 获取扩展名
let filename_ext = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length).toLowerCase();
let new_ext;
switch (filename_ext) {
case "qmc0":
case "qmc3":
case "qmcogg":
new_ext = "mp3";
break;
case "qmcflac":
new_ext = "flac";
break;
default:
return;
}
const mime = audio_mime_type[new_ext];
// 读取文件
const fileBuffer = await new Promise(resolve => {
const reader = new FileReader();
reader.onload = (e) => {
resolve(e.target.result);
};
reader.readAsArrayBuffer(file);
});
const audioData = new Uint8Array(fileBuffer);
const audioDataLen = audioData.length;
// 转换数据
const seed = new Mask();
for (let cur = 0; cur < audioDataLen; ++cur) {
audioData[cur] ^= seed.NextMask();
}
// 导出
const musicData = new Blob([audioData], {
type: mime
});
const musicUrl = URL.createObjectURL(musicData);
// 读取Meta
let tag = await musicMetadata.parseBlob(musicData);
// 处理无标题歌手
let filename_array = file.name.substring(0, file.name.lastIndexOf(".")).split("-");
let title = tag.common.title;
let artist = tag.common.artist;
if (filename_array.length > 1) {
if (artist === undefined) artist = filename_array[0].trim();
if (title === undefined) title = filename_array[1].trim();
} else if (filename_array.length === 1) {
if (title === undefined) title = filename_array[0].trim();
}
const filename = artist + " - " + title + "." + new_ext;
// 处理无封面
let pic_url = "";
if (tag.common.picture !== undefined && tag.common.picture.length >= 1) {
const picture = tag.common.picture[0];
const blobPic = new Blob([picture.data], {type: picture.format});
pic_url = URL.createObjectURL(blobPic);
}
// 返回
return {
filename: filename,
title: title,
artist: artist,
album: tag.common.album,
picture: pic_url,
file: musicUrl,
mime: mime
}
}
class Mask {
constructor() {
this.x = -1;
this.y = 8;
this.dx = 1;
this.index = -1;
}
NextMask() {
let ret;
this.index++;
if (this.x < 0) {
this.dx = 1;
this.y = (8 - this.y) % 8;
ret = 0xc3
} else if (this.x > 6) {
this.dx = -1;
this.y = 7 - this.y;
ret = 0xd8
} else {
ret = SEED_MAP[this.y][this.x]
}
this.x += this.dx;
if (this.index === 0x8000 || (this.index > 0x8000 && (this.index + 1) % 0x8000 === 0)) {
return this.NextMask()
}
return ret
}
}

43
src/plugins/raw.js Normal file
View File

@@ -0,0 +1,43 @@
const musicMetadata = require("music-metadata-browser");
export {Decrypt}
const audio_mime_type = {
mp3: "audio/mpeg",
flac: "audio/flac"
};
async function Decrypt(file) {
let tag = await musicMetadata.parseBlob(file);
let pic_url = "";
if (tag.common.picture !== undefined && tag.common.picture.length > 0) {
let pic = new Blob([tag.common.picture[0].data], {type: tag.common.picture[0].format});
pic_url = URL.createObjectURL(pic);
}
let file_url = URL.createObjectURL(file);
let filename_no_ext = file.name.substring(0, file.name.lastIndexOf("."));
let filename_array = filename_no_ext.split("-");
let filename_ext = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length).toLowerCase();
const mime = audio_mime_type[filename_ext];
let title = tag.common.title;
let artist = tag.common.artist;
if (filename_array.length > 1) {
if (artist === undefined) artist = filename_array[0].trim();
if (title === undefined) title = filename_array[1].trim();
} else if (filename_array.length === 1) {
if (title === undefined) title = filename_array[0].trim();
}
const filename = artist + " - " + title + "." + filename_ext;
return {
filename: filename,
title: title,
artist: artist,
album: tag.common.album,
picture: pic_url,
file: file_url,
mime: mime
}
}

View File

@@ -1,30 +1,32 @@
/* eslint-disable no-console */
import { register } from 'register-service-worker';
import { register } from 'register-service-worker'
if (process.env.NODE_ENV === 'production' && window.location.protocol === 'https:') {
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() {
console.log('App is being served from cache by a service worker.');
ready () {
console.log(
'App is being served from cache by a service worker.\n' +
'For more details, visit https://goo.gl/AFskqB'
)
},
registered() {
console.log('Service worker has been registered.');
registered () {
console.log('Service worker has been registered.')
},
cached() {
console.log('Content has been cached for offline use.');
cached () {
console.log('Content has been cached for offline use.')
},
updatefound() {
console.log('New content is downloading.');
updatefound () {
console.log('New content is downloading.')
},
updated() {
console.log('New content is available.');
window.location.reload();
updated () {
console.log('New content is available; please refresh.')
},
offline() {
console.log('No internet connection found. App is running in offline mode.');
offline () {
console.log('No internet connection found. App is running in offline mode.')
},
error(error) {
console.error('Error during service worker registration:', error);
},
});
error (error) {
console.error('Error during service worker registration:', error)
}
})
}

View File

@@ -1,166 +0,0 @@
/*
* name: 样式 - 夜间模式
* author: @KyleBing
* date: 2020-11-24
*/
@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;
.el-checkbox__inner{
background-color: $dark-btn-bg;
border-color: $dark-border;
}
&:hover{
border-color: $dark-border-highlight;
.el-checkbox__inner{
background-color: $dark-btn-bg-highlight;
border-color: $dark-border-highlight;
}
.el-checkbox__label{
color: $dark-text-info;
}
}
&.is-checked{
background-color: $blue;
.el-checkbox__inner{
border-color: $dark-btn-bg-highlight;
}
.el-checkbox__label{
color: white;
font-weight: bold;
}
}
}
// BUTTON
.el-button{
background-color: $dark-btn-bg;
border-color: $dark-border;
color: $dark-text-main;
&:active{
transform: translateY(2px);
}
&--default{
&.is-plain {
background-color: $dark-btn-bg;
&:hover {
background-color: $blue;
border-color: $blue;
color: white;
}
}
}
&--danger{
&.is-plain{
border-color: $dark-border;
background-color: $dark-btn-bg;
&:hover{
background-color: $red;
border-color: $red;
}
}
}
}
// 文件拖放区
.el-upload__tip{
color: $dark-text-info;
}
.el-upload-dragger{
background-color: $dark-uploader-bg;
border-color: $dark-border;
.el-upload__text{
color: $dark-text-info;
}
&:hover{
background: $dark-uploader-bg-highlight;
border-color: $dark-border-highlight;
}
}
//TABLE
.el-table{
background-color: $dark-bg-td;
&:before{ // 去除表格末尾的横线
content: none;
}
&__header{
th{
border-bottom-color: $dark-border !important;
}
}
th{
background-color: $dark-bg-th;
color: $dark-text-info;
}
td{
border-bottom-color: $dark-border !important;
}
tr{
background-color: $dark-bg-td;
color: $dark-text-main;
&:hover{
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;
}
}
}

View File

@@ -1,18 +0,0 @@
/*
* 间隔工具集
*/
$gap: 5px;
@for $item from 1 through 7 {
.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;}
}

View File

@@ -1,38 +0,0 @@
body{
font-family: $font-family;
font-size: $fz-main;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
text-align: center;
color: $text-main;
padding-top: 30px;
}
#app-footer a {
padding-left: 0.2em;
padding-right: 0.2em;
}
#app-footer {
text-align: center;
font-size: small;
}
#app-control {
padding-top: 1em;
padding-bottom: 1em;
}
audio{
margin-bottom: 15px; // 播放控件与表格间隔
}
a{
color: darken($color-link, 15%);
&:hover{
color: $color-link;
}
}

View File

@@ -1,28 +0,0 @@
// COLORS
$blue : #409EFF;
$red : #F56C6C;
$green : #85ce61;
// TEXT
$text-main : #2C3E50;
$color-link: $blue;
$fz-main: 14px;
$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-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;

View File

@@ -1,5 +0,0 @@
@import "variables";
@import "gaps";
@import "normal";
@import "dark-mode"; // dark-mode 放在 normal 后面以获得更高优先级

View File

@@ -1,23 +0,0 @@
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;
}
}

54
src/shims-fs.d.ts vendored
View File

@@ -1,54 +0,0 @@
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>;
}
}

15
src/shims-tsx.d.ts vendored
View File

@@ -1,15 +0,0 @@
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;
}
}
}

4
src/shims-vue.d.ts vendored
View File

@@ -1,4 +0,0 @@
declare module '*.vue' {
import Vue from 'vue';
export default Vue;
}

View File

@@ -1,15 +0,0 @@
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;
}

View File

@@ -1 +0,0 @@
export const extractQQMusicMeta = jest.fn();

View File

@@ -1,4 +0,0 @@
export const storage = {
loadJooxUUID: jest.fn(),
saveJooxUUID: jest.fn(),
};

View File

@@ -1,113 +0,0 @@
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}`;
}

View File

@@ -1,147 +0,0 @@
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;
}

View File

@@ -1,3 +0,0 @@
import storageFactory from './storage/StorageFactory';
export const storage = storageFactory();

View File

@@ -1,17 +0,0 @@
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);
}
}

View File

@@ -1,43 +0,0 @@
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);
}
}
}

View File

@@ -1,47 +0,0 @@
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);
});
}
}

View File

@@ -1,32 +0,0 @@
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);
}
}
}

View File

@@ -1,13 +0,0 @@
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();
}

View File

@@ -1,73 +0,0 @@
// 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);
}
});

View File

@@ -1,80 +0,0 @@
// 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);
}
}

View File

@@ -1,80 +0,0 @@
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);
}
}

View File

@@ -1,4 +0,0 @@
import { expose } from 'threads/worker';
import { Decrypt } from '@/decrypt';
expose(Decrypt);

View File

@@ -1,170 +0,0 @@
<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" 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>

View File

@@ -1 +0,0 @@
dRzX3p5ZYqAlp7lLSs9Zr0rw1iEZy23bB670x4ch2w97x14Zwpk1UXbKU4C2sOS7uZ0NB5QM7ve9GnSrr2JHxP74hVNONwVV77CdOOVb807317KvtI5Yd6h08d0c5W88rdV46C235YGDjUSZj5314YTzy0b6vgh4102P7E273r911Nl464XV83Hr00rkAHkk791iMGSJH95GztN28u2Nv5s9Xx38V69o4a8aIXxbx0g1EM0623OEtbtO9zsqCJfj6MhU7T8iVS6M3q19xhq6707E6r7wzPO6Yp4BwBmgg4F95Lfl0vyF7YO6699tb5LMnr7iFx29o98hoh3O3Rd8h9Juu8P1wG7vdnO5YtRlykhUluYQblNn7XwjBJ53HAyKVraWN5dG7pv7OMl1s0RykPh0p23qfYzAAMkZ1M422pEd07TA9OCKD1iybYxWH06xj6A8mzmcnYGT9P1a5Ytg2EF5LG3IknL2r3AUz99Y751au6Cr401mfAWK68WyEBe5

View File

@@ -1 +0,0 @@
ZFJ6WDNwNVrjEJZB1o6QjkQV2ZbHSw/2Eb00q1+4z9SVWYyFWO1PcSQrJ5326ubLklmk2ab3AEyIKNUu8DFoAoAc9dpzpTmc+pdkBHjM/bW2jWx+dCyC8vMTHE+DHwaK14UEEGW47ZXMDi7PRCQ2Jpm/oXVdHTIlyrc+bRmKfMith0L2lFQ+nW8CCjV6ao5ydwkZhhNOmRdrCDcUXSJH9PveYwra9/wAmGKWSs9nemuMWKnbjp1PkcxNQexicirVTlLX7PVgRyFyzNyUXgu+R2S4WTmLwjd8UsOyW/dc2mEoYt+vY2lq1X4hFBtcQGOAZDeC+mxrN0EcW8tjS6P4TjOjiOKNMxIfMGSWkSKL3H7z5K7nR1AThW20H2bP/LcpsdaL0uZ/js1wFGpdIfFx9rnLC78itL0WwDleIqp9TBMX/NwakGgIPIbjBwfgyD8d8XKYuLEscIH0ZGdjsadB5XjybgdE3ppfeFEcQiqpnodlTaQRm3KDIF9ATClP0mTl8XlsSojsZ468xseS1Ib2iinx/0SkK3UtJDwp8DH3/+ELisgXd69Bf0pve7wbrQzzMUs9/Ogvvo6ULsIkQfApJ8cSegDYklzGXiLNH7hZYnXDLLSNejD7NvQouULSmGsBbGzhZ5If0NP/6AhSbpzqWLDlabTDgeWWnFeZpBnlK6SMxo+YFFk1Y0XLKsd69+jj

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -1 +0,0 @@
yw7xWOyNQ8585Jwx3hjB49QLPKi38F89awnrQ0fq66NT9TDq1ppHNrFqhaDrU5AFk916D58I53h86304GqOFCCyFzBem68DqiXJ81bILEQwG3P3MOnoNzM820kNW9Lv9IJGNn9Xo497p82BLTm4hAX8JLBs0T2pilKvT429sK9jfg508GSk4d047Jxdz5Fou4aa33OkyFRBU3x430mgNBn04Lc9BzXUI2IGYXv3FGa9qE4Vb54kSjVv8ogbg47j3

View File

@@ -1 +0,0 @@
eXc3eFdPeU6+3f7GVeF35bMpIEIQj5JWOWt7G+jsR68Hx3BUFBavkTQ8dpPdP0XBIwPe+OfdsnTGVQqPyg3GCtQSrkgA0mwSQdr4DPzKLkEZFX+Cf1V6ChyipOuC6KT37eAxWMdV1UHf9/OCvydr1dc6SWK1ijRUcP6IAHQhiB+mZLay7XXrSPo32WjdBkn9c9sa2SLtI48atj5kfZ4oOq6QGeld2JA3Z+3wwCe6uTHthKaEHY8ufDYodEe3qqrjYpzkdx55pCtxCQa1JiNqFmJigWm4m3CDzhuJ7YqnjbD+mXxLi7BP1+z4L6nccE2h+DGHVqpGjR9+4LBpe4WHB4DrAzVp2qQRRQJxeHd1v88=

Binary file not shown.

Binary file not shown.

Binary file not shown.

Some files were not shown because too many files have changed in this diff Show More