Compare commits

..

No commits in common. "main" and "v1.9.0" have entirely different histories.
main ... v1.9.0

110 changed files with 5877 additions and 30739 deletions

View File

@ -1,25 +0,0 @@
---
kind: pipeline
type: docker
name: default
steps:
- name: build
image: node:16.18-bullseye
commands:
- apt-get update
- apt-get install -y jq zip
- npm ci
- npm run test
- ./scripts/build-and-package.sh legacy
- ./scripts/build-and-package.sh extension
- ./scripts/build-and-package.sh modern
- name: upload artifact
image: node:16.18-bullseye
environment:
DRONE_GITEA_SERVER: https://git.unlock-music.dev
GITEA_API_KEY:
from_secret: GITEA_API_KEY
commands:
- ./scripts/upload-packages.sh

39
.github/ISSUE_TEMPLATE/bug-report.md vendored Normal file
View File

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

26
.github/ISSUE_TEMPLATE/new-feature.md vendored Normal file
View File

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

76
.github/workflows/build.yml vendored Normal file
View File

@ -0,0 +1,76 @@
name: Test Build
on:
push:
paths:
- "**/*.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:
build:
runs-on: ubuntu-latest
strategy:
matrix:
build: [ legacy, modern ]
include:
- build: legacy
BUILD_ARGS: ""
BUILD_EXTENSION: true
- build: modern
BUILD_ARGS: "-- --modern"
BUILD_EXTENSION: false
steps:
- uses: actions/checkout@v2
- name: Use Node.js 14.x
uses: actions/setup-node@v2
with:
node-version: "14"
- name: Install Dependencies
run: |
npm ci
npm run fix-compatibility
- name: Build
env:
GZIP: "--best"
run: |
npm run build ${{ matrix.BUILD_ARGS }}
tar -czvf dist.tar.gz -C ./dist .
- name: Build Extension
if: ${{ matrix.BUILD_EXTENSION }}
run: |
npm run make-extension
cd dist
zip -rJ9 ../extension.zip *
cd ..
- name: Publish artifact
uses: actions/upload-artifact@v2
with:
name: unlock-music-${{ matrix.build }}.tar.gz
path: ./dist.tar.gz
- name: Publish artifact - Extension
if: ${{ matrix.BUILD_EXTENSION }}
uses: actions/upload-artifact@v2
with:
name: extension.zip
path: ./extension.zip

65
.github/workflows/post-release.yml vendored Normal file
View File

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

128
.github/workflows/release-build.yml vendored Normal file
View File

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

13
.gitignore vendored
View File

@ -1,8 +1,6 @@
.DS_Store .DS_Store
node_modules node_modules
/dist /dist
/build
/coverage
# local env files # local env files
.env.local .env.local
@ -21,14 +19,3 @@ yarn-error.log*
*.njsproj *.njsproj
*.sln *.sln
*.sw? *.sw?
/src/KgmWasm/build
/src/KgmWasm/*.js
/src/KgmWasm/*.wasm
/src/QmcWasm/build
/src/QmcWasm/*.js
/src/QmcWasm/*.wasm
*.zip
*.tar.gz
/sha256sum.txt

View File

@ -1,47 +0,0 @@
image: node:16
cache:
paths:
- node_modules/
stages:
- build
build-job:
stage: build
script: |
sed -i 's/deb.debian.org/mirrors.cloud.tencent.com/g' /etc/apt/sources.list
apt-get update
apt-get -y install zip
npm config set registry http://mirrors.cloud.tencent.com/npm/
npm ci
npm run build
tar -czf legacy.tar.gz -C ./dist .
cd dist
zip -rJ9 ../legacy.zip *
cd ..
npm run make-extension
cd dist
zip -rJ9 ../extension.zip *
cd ..
npm run build -- --modern
tar -czf modern.tar.gz -C ./dist .
cd dist
zip -rJ9 ../modern.zip *
cd ..
sha256sum *.tar.gz *.zip > sha256sum.txt
artifacts:
name: "$CI_JOB_NAME"
paths:
- legacy.zip
- legacy.tar.gz
- extension.zip
- modern.zip
- modern.tar.gz
- sha256sum.txt

View File

@ -1,76 +0,0 @@
name: 解码错误报告 (填表)
about: 遇到文件解码失败的问题请选择该项。
title: '[Bug/Crypto] '
labels:
- bug
- crypto
body:
- type: textarea
id: what-happened
attributes:
label: 错误描述
description: 请描述你所遇到的问题,以及你期待的行为。
placeholder: ''
value: ''
validations:
required: true
- type: dropdown
id: version
attributes:
label: Unlock Music 版本
description: |
能够重现错误的版本,版本号通常在页面底部。
如果不确定,请升级到最新版确认问题是否解决。
multiple: true
options:
- 1.10.5 (仓库最新)
- 1.10.3 (官方 DEMO)
- 其它(请在错误描述中指定)
validations:
required: true
- type: dropdown
id: browsers
attributes:
label: 产生错误的浏览器
multiple: true
options:
- 火狐 / Firefox
- Chrome
- Safari
- 其它基于 Chromium 的浏览器 (Edge、Brave、Opera 等)
- type: dropdown
id: music-platform
attributes:
label: 音乐平台
description: |
如果需要报告多个平台的问题,请每个平台提交一个新的 Issue。
请注意:播放器缓存文件不属于该项目支持的文件类型。
multiple: false
options:
- 其它 (请在错误描述指定)
- QQ 音乐
- Joox (QQ 音乐海外版)
- 虾米音乐
- 网易云音乐
- 酷我音乐
- 酷狗音乐
- 喜马拉雅
- 咪咕 3D
validations:
required: true
- type: textarea
id: logs
attributes:
label: 日志信息
description: 如果有请提供浏览器开发者控制台Console的错误日志
render: text
- type: checkboxes
id: terms
attributes:
label: 我已经阅读并确认下述内容
description: ''
options:
- label: 我已经检索过 Issue 列表,并确认这是一个为报告过的问题。
required: true
- label: 我有证据表明这是程序导致的问题(如不确认,可以通过 Telegram 讨论组 (https://t.me/unlock_music_chat) 进行讨论)
required: true

View File

@ -1,40 +0,0 @@
---
name: "错误报告"
about: "报告 Bug 以帮助改进程序,非填表。"
title: "[BUG] "
labels:
- bug
---
* 请按照此模板填写,否则可能立即被关闭
- [x] 我确认已经搜索过Issue不存并确认相同的Issue
- [x] 我有证据表明这是程序导致的问题(如不确认,可以通过 Telegram 讨论组 (https://t.me/unlock_music_chat) 进行讨论)
## Bug描述
简要地复述你遇到的Bug
## 复现方法
描述复现方法,必要时请提供样本文件
## 程序截图或浏览器开发者控制台Console的报错信息
如果可以请提供二者之一
## 环境信息
- 操作系统和浏览器:
- 程序版本:
- 网页版的地址(如果为非官方部署请注明):
注意:如果需要会员才能获取该资源,你可能也需要作为附件提交。
## 附加信息
如果有,请提供其他能够帮助确认问题的信息到下方:

View File

@ -1,29 +0,0 @@
---
name: "新功能"
about: "对于程序新的想法或建议"
title: "[新功能] "
labels:
- enhancement
---
<!-- ⚠ 请按照此模板填写,否则可能立即被关闭 -->
<!-- 提交前请使用【Preview】预览提交的更改 -->
## 背景和说明
<!-- 简要说明产生此想法的背景和此想法的具体内容 -->
## 实现途径
- 如果没有设计方案,请简要描述实现思路
- 如果你没有任何的实现思路,请通过 Telegram 讨论组 (https://t.me/unlock_music_chat) 进行讨论
## 附加信息
<!-- 更多你想要表达的内容 -->

1
.npmrc
View File

@ -1 +0,0 @@
@unlock-music:registry=https://git.unlock-music.dev/api/packages/um/npm/

1
.nvmrc
View File

@ -1 +0,0 @@
v16.18.1

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,6 +1,6 @@
MIT License MIT License
Copyright (c) 2019-2023 MengYX Copyright (c) 2019-2020 MengYX
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal
@ -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 AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE. SOFTWARE.

View File

@ -1,80 +1,71 @@
# Unlock Music 音乐解锁 # Unlock Music 音乐解锁
[![Build Status](https://ci.unlock-music.dev/api/badges/um/web/status.svg)](https://ci.unlock-music.dev/um/web)
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser. - 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[授权协议]。 - unlock-music项目是以学习和技术研究的初衷创建的修改、再分发时请遵循[License](https://github.com/ix64/unlock-music/blob/master/LICENSE)
- Unlock Music 的 CLI 版本可以在 [unlock-music/cli] 找到,大批量转换建议使用 CLI 版本。 - Unlock Music的CLI版本正在开发中。
- 我们新建了 Telegram 群组 [`@unlock_music_chat`] ,欢迎加入! - 我们新建了Telegram群组欢迎加入[https://t.me/unlock_music_chat](https://t.me/unlock_music_chat)
- CI 自动构建已经部署,可以在 [um-packages] 下载 - [CLI版本 Alpha](https://github.com/unlock-music/cli) 大批量转换建议使用CLI版本
- [相关的其他项目](https://github.com/ix64/unlock-music/wiki/%E5%92%8CUnlockMusic%E7%9B%B8%E5%85%B3%E7%9A%84%E9%A1%B9%E7%9B%AE)
> **WARNING** ![Test Build](https://github.com/ix64/unlock-music/workflows/Test%20Build/badge.svg)
> 在本站 fork 不会起到备份的作用,只会浪费服务器储存空间。如无必要请勿 fork 该仓库。 ![GitHub releases](https://img.shields.io/github/downloads/ix64/unlock-music/total)
![Docker Pulls](https://img.shields.io/docker/pulls/ix64/unlock-music)
[授权协议]: https://git.unlock-music.dev/um/web/src/branch/master/LICENSE
[unlock-music/cli]: https://git.unlock-music.dev/um/cli
[`@unlock_music_chat`]: https://t.me/unlock_music_chat
[um-packages]: https://git.unlock-music.dev/um/-/packages/generic/web-build/
## 特性 ## 特性
### 支持的格式 ### 支持的格式
- [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm) - [x] QQ音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/[.tkm](https://github.com/ix64/unlock-music/issues/9))
- [x] Moo 音乐格式 (.bkcmp3/.bkcflac/...) - [x] 写入封面图片
- [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6) - [x] Moo音乐格式 ([.bkcmp3/.bkcflac](https://github.com/ix64/unlock-music/issues/11))
- [x] QQ 音乐新格式 (.mflac/.mgg/.mflac0/.mgg1/.mggl) - [x] QQ音乐Tm格式 (.tm0/.tm2/.tm3/.tm6)
- [x] <ruby>QQ 音乐海外版<rt>JOOX Music</rt></ruby> (.ofl_en) - [x] QQ音乐新格式 (实验性支持)
- [x] .mflac
- [x] [.mgg](https://github.com/ix64/unlock-music/issues/3)
- [x] 网易云音乐格式 (.ncm) - [x] 网易云音乐格式 (.ncm)
- [x] 虾米音乐格式 (.xm) - [x] 补全ncm的ID3/FlacMeta信息
- [x] 酷我音乐格式 (.kwm) - [x] 虾米音乐格式 (.xm) (测试阶段)
- [x] 酷狗音乐格式 (.kgm/.vpr) - [x] 酷我音乐格式 (.kwm) (测试阶段)
- [x] Android 版喜马拉雅文件格式 (.x2m/.x3m) - [x] 酷狗音乐格式 (
- [x] 咪咕音乐格式 (.mg3d) .kgm) ([CLI版本](https://github.com/ix64/unlock-music/wiki/%E5%85%B6%E4%BB%96%E9%9F%B3%E4%B9%90%E6%A0%BC%E5%BC%8F%E5%B7%A5%E5%85%B7#%E9%85%B7%E7%8B%97%E9%9F%B3%E4%B9%90-kgmvpr%E8%A7%A3%E9%94%81%E5%B7%A5%E5%85%B7))
### 其他特性 ### 其他特性
- [x] 在浏览器中解锁 - [x] 在浏览器中解锁
- [x] 拖放文件 - [x] 拖放文件
- [x] 在线播放
- [x] 批量解锁 - [x] 批量解锁
- [x] 渐进式 Web 应用 (PWA) - [x] 渐进式Web应用
- [x] 多线程 - [x] 多线程
- [x] 写入和编辑元信息与专辑封面
## 使用方法 ## 使用方法
### 使用预构建版本 ### 安装浏览器扩展
- 从 [Release] 或 [CI 构建][um-packages] 下载预构建的版本 [![Chrome Web Store](https://storage.googleapis.com/chrome-gcs-uploader.appspot.com/image/WlD8wC6g8khYWPJUsQceQkhXSlv1/UV4C4ybeBTsZt43U4xis.png)](https://chrome.google.com/webstore/detail/gldlhhhmienbhlpkfanjpmffdjblmegd)
- :warning: 本地使用请下载`legacy版本``modern版本`只能通过 **http(s)协议** 访问) [<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/ix64/unlock-music/releases/latest)下载已构建的版本
- 本地使用请下载`legacy版本``modern版本`只能通过**http/https协议**访问)
- 解压缩后即可部署或本地使用(**请勿直接运行源代码** - 解压缩后即可部署或本地使用(**请勿直接运行源代码**
[release]: https://git.unlock-music.dev/um/web/releases/latest ### 使用Docker镜像
```shell
docker run --name unlock-music -d -p 8080:80 ix64/unlock-music
```
### 自行构建 ### 自行构建
- 环境要求 - 环境要求
- nodejs (v16.x) - nodejs
- npm - npm
1. 获取项目源代码后安装相关依赖: 1. 获取项目源代码后执行 `npm install` 安装相关依赖
2. 执行 `npm run build` 即可进行构建,构建输出为 dist 目录
```sh - `npm run serve` 可用于开发
npm install 3. 如需构建浏览器扩展build完成后还需要执行`npm run make-extension`
npm ci
```
2. 然后进行构建:
```sh
npm run build
```
- 构建后的产物可以在 `dist` 目录找到。
- 如果是用于开发,可以执行 `npm run serve`
3. 如需构建浏览器扩展,构建成功后还需要执行:
```sh
npm run make-extension
```

View File

@ -1,7 +1,6 @@
module.exports = { module.exports = {
presets: [ presets: [
'@vue/app', '@vue/app'
'@babel/preset-typescript'
], ],
plugins: [ plugins: [
["component", { ["component", {

View File

@ -1,29 +1,15 @@
{ {
"manifest_version": 3, "manifest_version": 2,
"content_security_policy": {
"extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';"
},
"name": "音乐解锁", "name": "音乐解锁",
"short_name": "音乐解锁", "short_name": "音乐解锁",
"icons": { "icons": {
"16": "img/icons/favicon-16x16.png", "128": "./img/icons/msapplication-icon-144x144.png"
"32": "img/icons/favicon-32x32.png",
"192": "img/icons/android-chrome-192x192.png",
"512": "img/icons/android-chrome-512x512.png"
}, },
"description": "在任何设备上解锁已购的加密音乐!", "description": "在任何设备上解锁已购的加密音乐!",
"permissions": ["storage"],
"offline_enabled": true, "offline_enabled": true,
"options_page": "index.html", "options_page": "./index.html",
"homepage_url": "https://git.unlock-music.dev/um/web", "homepage_url": "https://github.com/ix64/unlock-music",
"action": { "browser_action": {
"default_icon": "img/icons/favicon-32x32.png",
"default_popup": "./popup.html" "default_popup": "./popup.html"
},
"browser_specific_settings": {
"gecko": {
"id": "addon@unlock-music.dev",
"strict_min_version": "128.0"
}
} }
} }

View File

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

View File

@ -1,25 +1,23 @@
const fs = require('fs') const fs = require('fs')
const path = require('path') const path = require('path')
const src = __dirname + "/src/extension/" const src = "./src/extension/"
const dst = __dirname + "/dist" const dst = "./dist"
fs.readdirSync(src).forEach(file => { fs.readdirSync(src).forEach(file => {
let srcPath = path.join(src, file) let srcPath = path.join(src, file)
let dstPath = path.join(dst, file) let dstPath = path.join(dst, file)
fs.copyFileSync(srcPath, dstPath) fs.copyFileSync(srcPath, dstPath)
console.log(`Copy: ${srcPath} => ${dstPath}`) console.log(`Copy: ${srcPath} => ${dstPath}`)
}) })
const manifestRaw = fs.readFileSync(__dirname + "/extension-manifest.json", "utf-8") const manifestRaw = fs.readFileSync("./extension-manifest.json", "utf-8")
const manifest = JSON.parse(manifestRaw) const manifest = JSON.parse(manifestRaw)
const pkgRaw = fs.readFileSync(__dirname + "/package.json", "utf-8") const pkgRaw = fs.readFileSync("./package.json", "utf-8")
const pkg = JSON.parse(pkgRaw) const pkg = JSON.parse(pkgRaw)
verExt = pkg["version"] ver_str = pkg["version"]
if (verExt.startsWith("v")) verExt = verExt.slice(1) if (ver_str.startsWith("v")) ver_str = ver_str.slice(1)
if (verExt.includes("-")) verExt = verExt.split("-")[0] manifest["version"] = ver_str
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")
fs.writeFileSync(__dirname + "/dist/manifest.json", JSON.stringify(manifest), "utf-8")
console.log("Write: manifest.json")

30387
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,29 +1,21 @@
{ {
"name": "unlock-music", "name": "unlock-music",
"version": "1.10.8", "version": "v1.9.0",
"ext_build": 0, "updateInfo": "新增写入本地文件系统; 优化.kwm解锁; 支持.acc嗅探; 使用Typescript重构",
"updateInfo": "修正 joox 在远程获取 API 信息出错时不能正确回退到本地元信息获取的错误。",
"license": "MIT", "license": "MIT",
"description": "Unlock encrypted music file in browser.", "description": "Unlock encrypted music file in browser.",
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://git.unlock-music.dev/um/web" "url": "https://github.com/ix64/unlock-music"
}, },
"private": true, "private": true,
"scripts": { "scripts": {
"postinstall": "patch-package",
"serve": "vue-cli-service serve", "serve": "vue-cli-service serve",
"build": "vue-cli-service build", "build": "vue-cli-service build",
"test": "jest", "fix-compatibility": "node ./src/fix-compatibility.js",
"pretty": "prettier --write src/{**/*,*}.{js,ts,jsx,tsx,vue}",
"pretty:check": "prettier --check src/{**/*,*}.{js,ts,jsx,tsx,vue}",
"make-extension": "node ./make-extension.js" "make-extension": "node ./make-extension.js"
}, },
"dependencies": { "dependencies": {
"@babel/preset-typescript": "^7.16.5",
"@unlock-music/joox-crypto": "^0.0.1",
"@xhacker/kgmwasm": "^1.0.0",
"@xhacker/qmcwasm": "^1.0.0",
"base64-js": "^1.5.1", "base64-js": "^1.5.1",
"browser-id3-writer": "^4.4.0", "browser-id3-writer": "^4.4.0",
"core-js": "^3.16.0", "core-js": "^3.16.0",
@ -32,28 +24,23 @@
"iconv-lite": "^0.6.3", "iconv-lite": "^0.6.3",
"jimp": "^0.16.1", "jimp": "^0.16.1",
"metaflac-js": "^1.0.5", "metaflac-js": "^1.0.5",
"music-metadata": "7.9.0", "music-metadata-browser": "^2.4.3",
"music-metadata-browser": "2.2.7",
"register-service-worker": "^1.7.2", "register-service-worker": "^1.7.2",
"threads": "^1.6.5", "threads": "^1.6.5",
"vue": "^2.6.14" "vue": "^2.6.14"
}, },
"devDependencies": { "devDependencies": {
"@types/crypto-js": "^4.0.2", "@types/crypto-js": "^4.0.2",
"@types/jest": "^27.0.3",
"@vue/cli-plugin-babel": "^4.5.13", "@vue/cli-plugin-babel": "^4.5.13",
"@vue/cli-plugin-pwa": "^4.5.13", "@vue/cli-plugin-pwa": "^4.5.13",
"@vue/cli-plugin-typescript": "^4.5.13", "@vue/cli-plugin-typescript": "^4.5.13",
"@vue/cli-service": "^4.5.13", "@vue/cli-service": "^4.5.13",
"babel-plugin-component": "^1.1.1", "babel-plugin-component": "^1.1.1",
"jest": "^27.4.5", "node-sass": "^5.0.0",
"patch-package": "^6.4.7",
"prettier": "2.5.1",
"sass": "^1.38.1",
"sass-loader": "^10.2.0", "sass-loader": "^10.2.0",
"semver": "^7.3.5", "semver": "^7.3.5",
"threads-plugin": "^1.4.0", "threads-plugin": "^1.4.0",
"typescript": "^4.5.4", "typescript": "~4.1.6",
"vue-cli-plugin-element": "^1.0.1", "vue-cli-plugin-element": "^1.0.1",
"vue-template-compiler": "^2.6.14" "vue-template-compiler": "^2.6.14"
} }

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

View File

@ -1,89 +1,43 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8">
<meta content="webkit" name="renderer" /> <meta content="webkit" name="renderer">
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible" /> <meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
<meta content="width=device-width,initial-scale=1.0" name="viewport" /> <meta content="width=device-width,initial-scale=1.0" name="viewport">
<title>音乐解锁</title> <title>音乐解锁</title>
<meta content="音乐,解锁,ncm,qmc,mgg,mflac,qq音乐,网易云音乐,加密" name="keywords" /> <meta content="音乐,解锁,ncm,qmc,mgg,mflac,qq音乐,网易云音乐,加密" name="keywords"/>
<meta content="音乐解锁 - 在任何设备上解锁已购的加密音乐!" name="description" /> <meta content="音乐解锁 - 在任何设备上解锁已购的加密音乐!" name="description"/>
<style> <script src="./ixarea-stats.js"></script>
#loader { <!--@formatter:off-->
position: absolute; <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>
left: 50%; <!--@formatter:on-->
top: 50%; </head>
z-index: 1010; <body>
margin: -75px 0 0 -75px;
border: 16px solid #f3f3f3;
border-radius: 50%;
border-top: 16px solid #1db1ff;
width: 120px;
height: 120px;
animation: spin 2s linear infinite;
}
@keyframes spin {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
#loader-mask {
text-align: center;
position: absolute;
width: 100%;
height: 100%;
bottom: 0;
left: 0;
right: 0;
top: 0;
z-index: 1009;
background-color: rgba(242, 246, 252, 0.88);
}
@media (prefers-color-scheme: dark) {
#loader-mask {
color: #fff;
background-color: rgba(0, 0, 0, 0.85);
}
#loader-mask a {
color: #ddd;
}
#loader-mask a:hover {
color: #1db1ff;
}
}
#loader-source {
font-size: 1.5rem;
}
#loader-tips-timeout {
font-size: 1.2rem;
}
</style>
</head>
<body> <div id="loader-mask">
<div id="loader-mask"> <div id="loader"></div>
<div id="loader"></div> <noscript>
<noscript>
<h3 id="loader-js">请启用JavaScript</h3> <h3 id="loader-js">请启用JavaScript</h3>
</noscript> <img alt=""
<h3 id="loader-source">请勿直接运行源代码!</h3> src="https://stats.ixarea.com/ixarea-stats/report?rec=1&action_name=音乐解锁-NoJS&idsite=2"
<div id="loader-tips-outdated" hidden> style="border:0"/>
<h2>您可能在使用不受支持的<span style="color: #f00">过时</span>浏览器,这可能导致此应用无法正常工作。</h2> </noscript>
<h3>如果您使用双核浏览器,您可以尝试切换到 <span style="color: #f00">“极速模式”</span> 解决此问题。</h3> <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> <h3>或者,您可以尝试更换下方的几个浏览器之一。</h3>
</div> </div>
<h3 id="loader-tips-timeout" hidden> <h3 id="loader-tips-timeout" hidden>
音乐解锁采用了一些新特性!建议使用 音乐解锁采用了一些新特性!建议使用
<a href="https://www.microsoft.com/zh-cn/edge" target="_blank">Microsoft Edge Chromium</a> <a href="https://www.microsoft.com/zh-cn/edge" target="_blank">Microsoft Edge Chromium</a>
<a href="https://www.google.cn/chrome/" target="_blank">Google Chrome</a> <a href="https://www.google.cn/chrome/" target="_blank">Google Chrome</a>
<a href="https://www.firefox.com.cn/" target="_blank">Mozilla Firefox</a> <a href="https://www.firefox.com.cn/" target="_blank">Mozilla Firefox</a>
| <a href="https://git.unlock-music.dev/um/web/wiki/使用提示" target="_blank">使用提示</a> | <a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
</h3> </h3>
</div> </div>
<div id="app"></div> <div id="app"></div>
<script src="./loader.js"></script> <script src="./loader.js"></script>
</body> </body>
</html> </html>

10
public/ixarea-stats.js Normal file
View File

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

BIN
public/static/kgm.mask Normal file

Binary file not shown.

View File

@ -1,29 +0,0 @@
#!/bin/sh
set -ex
cd "$(git rev-parse --show-toplevel)"
VERSION="$(jq -r ".version" <package.json)"
DIST_NAME="um-web.$1.v${VERSION}"
case "$1" in
"modern") npm run build -- --modern ;;
"legacy") npm run build ;;
"extension") npm run make-extension ;;
*)
echo "Unknown command: $1"
exit 1
;;
esac
mv dist "${DIST_NAME}"
zip -rJ9 "${DIST_NAME}.zip" "${DIST_NAME}"
if [ "$1" = "legacy" ]; then
# For upcoming extension build
mv "${DIST_NAME}" dist
else
rm -rf "${DIST_NAME}"
fi

View File

@ -1,19 +0,0 @@
#!/bin/sh
set -ex
cd "$(git rev-parse --show-toplevel)"
if [ -z "$GITEA_API_KEY" ]; then
echo "GITEA_API_KEY is empty, skip upload."
exit 0
fi
URL_BASE="$DRONE_GITEA_SERVER/api/packages/${DRONE_REPO_NAMESPACE}/generic/${DRONE_REPO_NAME}-build"
for ZIP_NAME in *.zip; do
UPLOAD_URL="${URL_BASE}/${DRONE_BUILD_NUMBER}/${ZIP_NAME}"
sha256sum "${ZIP_NAME}"
curl -sLifu "um-release-bot:$GITEA_API_KEY" -T "${ZIP_NAME}" "${UPLOAD_URL}"
echo "Uploaded to: ${UPLOAD_URL}"
done

View File

@ -1,94 +1,85 @@
<template> <template>
<el-container id="app"> <el-container id="app">
<el-main> <el-main>
<Home /> <Home/>
</el-main> </el-main>
<el-footer id="app-footer"> <el-footer id="app-footer">
<div> <el-row>
<a href="https://git.unlock-music.dev/um/web" target="_blank">音乐解锁</a>({{ version }}) <a href="https://github.com/ix64/unlock-music" target="_blank">音乐解锁</a>({{ version }})
移除已购音乐的加密保护 移除已购音乐的加密保护
<a href="https://git.unlock-music.dev/um/web/wiki/使用提示" target="_blank">使用提示</a> <a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
</div> </el-row>
<div> <el-row>
目前支持 网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm) 目前支持网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
<a href="https://git.unlock-music.dev/um/web/src/branch/master/README.md" target="_blank">更多</a> <a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>
</div> </el-row>
<div> <el-row>
<!--如果进行二次开发此行版权信息不得移除且应明显地标注于页面上--> <!--如果进行二次开发此行版权信息不得移除且应明显地标注于页面上-->
<span>Copyright &copy; 2019 - {{ new Date().getFullYear() }} MengYX</span> <span>Copyright &copy; 2019 - {{ (new Date()).getFullYear() }} MengYX</span>
音乐解锁使用 音乐解锁使用
<a href="https://git.unlock-music.dev/um/web/src/branch/master/LICENSE" target="_blank">MIT许可协议</a> <a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a>
开放源代码 开放源代码
</div> </el-row>
</el-footer> </el-footer>
</el-container> </el-container>
</template> </template>
<script> <script>
import FileSelector from '@/component/FileSelector';
import PreviewTable from '@/component/PreviewTable'; import FileSelector from "@/component/FileSelector"
import config from '@/../package.json'; import PreviewTable from "@/component/PreviewTable"
import Home from '@/view/Home'; import config from "@/../package.json"
import { checkUpdate } from '@/utils/api'; import Home from "@/view/Home";
import {checkUpdate} from "@/utils/api";
export default { export default {
name: 'app', name: 'app',
components: { components: {
FileSelector, FileSelector,
PreviewTable, PreviewTable,
Home, Home
},
data() {
return {
version: config.version,
};
},
created() {
this.$nextTick(() => this.finishLoad());
},
methods: {
async finishLoad() {
const mask = document.getElementById('loader-mask');
if (!!mask) mask.remove();
let updateInfo;
try {
updateInfo = await checkUpdate(this.version);
} catch (e) {
console.warn('check version info failed', e);
}
if (
updateInfo &&
process.env.NODE_ENV === 'production' &&
(updateInfo.HttpsFound || (updateInfo.Found && window.location.protocol !== 'https:'))
) {
this.$notify.warning({
title: '发现更新',
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`,
dangerouslyUseHTMLString: true,
duration: 15000,
position: 'top-left',
});
} else {
this.$notify.info({
title: '离线使用',
message: `<div>
<p>我们使用 PWA 技术无网络也能使用</p>
<div class="update-info">
<div class="update-title">最近更新</div>
<div class="update-content"> ${config.updateInfo} </div>
</div>
<a target="_blank" href="https://git.unlock-music.dev/um/web/wiki/使用提示">使用提示</a>
</div>`,
dangerouslyUseHTMLString: true,
duration: 10000,
position: 'top-left',
});
}
}, },
}, data() {
}; return {
version: config.version,
}
},
created() {
this.$nextTick(() => this.finishLoad());
},
methods: {
async finishLoad() {
const mask = document.getElementById("loader-mask");
if (!!mask) mask.remove();
let updateInfo;
try {
updateInfo = await checkUpdate(this.version)
} catch (e) {
console.warn("check version info failed", e)
}
if ((updateInfo && process.env.NODE_ENV === 'production') && (updateInfo.HttpsFound ||
(updateInfo.Found && window.location.protocol !== "https:"))) {
this.$notify.warning({
title: '发现更新',
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`,
dangerouslyUseHTMLString: true,
duration: 15000,
position: 'top-left'
});
} else {
this.$notify.info({
title: '离线使用',
message: `我们使用PWA技术无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`,
dangerouslyUseHTMLString: true,
duration: 10000,
position: 'top-left'
});
}
}
},
}
</script> </script>
<style lang="scss"> <style lang="scss">
@import 'scss/unlock-music'; @import "scss/unlock-music";
</style> </style>

View File

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

View File

@ -1,105 +0,0 @@
<style scoped>
label {
cursor: pointer;
line-height: 1.2;
display: block;
}
form >>> input {
font-family: 'Courier New', Courier, monospace;
}
* >>> .um-config-dialog {
max-width: 90%;
width: 40em;
}
</style>
<template>
<el-dialog @close="cancel()" title="解密设定" :visible="show" custom-class="um-config-dialog" center>
<el-form ref="form" :rules="rules" status-icon :model="form" label-width="0">
<section>
<label>
<span>
JOOX Music ·
<Ruby caption="Unique Device Identifier">设备唯一识别码</Ruby>
</span>
<el-form-item prop="jooxUUID">
<el-input type="text" v-model="form.jooxUUID" clearable maxlength="32" show-word-limit> </el-input>
</el-form-item>
</label>
<p class="tip">
下载该加密文件的 JOOX 应用所记录的设备唯一识别码
<br />
参见
<a
href="https://git.unlock-music.dev/um/joox-crypto/wiki/%E8%8E%B7%E5%8F%96%E8%AE%BE%E5%A4%87-UUID#%E5%89%8D%E8%A8%80"
>
获取设备 UUID · unlock-music/joox-crypto Wiki</a
>
</p>
</section>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button type="primary" :loading="saving" @click="emitConfirm()"> </el-button>
</span>
</el-dialog>
</template>
<script>
import { storage } from '@/utils/storage';
import Ruby from './Ruby';
// FIXME:
function validateJooxUUID(rule, value, callback) {
if (!value || !/^[\da-fA-F]{32}$/.test(value)) {
callback(new Error('无效的 Joox UUID请参考 Wiki 获取。'));
} else {
callback();
}
}
const rules = {
jooxUUID: { validator: validateJooxUUID, trigger: 'change' },
};
export default {
components: {
Ruby,
},
props: {
show: { type: Boolean, required: true },
},
data() {
return {
rules,
saving: false,
form: {
jooxUUID: '',
},
centerDialogVisible: false,
};
},
async mounted() {
await this.resetForm();
},
methods: {
async resetForm() {
this.form.jooxUUID = await storage.loadJooxUUID();
},
async cancel() {
await this.resetForm();
this.$emit('done');
},
async emitConfirm() {
this.saving = true;
await storage.saveJooxUUID(this.form.jooxUUID);
this.saving = false;
this.$emit('done');
},
},
};
</script>

View File

@ -1,163 +0,0 @@
<style scoped>
* >>> .um-edit-dialog {
max-width: 90%;
width: 30em;
}
</style>
<template>
<el-dialog @close="cancel()" title="音乐标签编辑" :visible="show" custom-class="um-edit-dialog" center>
<el-form ref="form" status-icon :model="form" label-width="0">
<section>
<div class="music-cover">
<el-image v-show="!editPicture" :src="imgFile.url || picture">
<div slot="error" class="image-slot el-image__error">暂无封面</div>
</el-image>
<el-upload v-show="editPicture" :auto-upload="false" :on-change="addFile" :on-remove="rmvFile" :show-file-list="true" :limit="1" list-type="picture" action="" drag>
<i class="el-icon-upload" />
<div class="el-upload__text">将新图片拖到此处<em>点击选择</em><br />以替换自动匹配的图片</div>
<div slot="tip" class="el-upload__tip">
新拖到此处的图片将覆盖原始图片
</div>
</el-upload>
<i :class="{'el-icon-edit': !editPicture, 'el-icon-check': editPicture}"
@click="changeCover"></i>
</div>
<div class="edit-item">
<div class="label">标题</div>
<div class="value" v-show="!editTitle">{{title}}</div>
<el-input class="input" size="small" v-show="editTitle" v-model="title"/>
<i :class="{'el-icon-edit': !editTitle, 'el-icon-check': editTitle}"
@click="editTitle = !editTitle"/>
</div>
<div class="edit-item">
<div class="label">艺术家</div>
<div class="value" v-show="!editArtist">{{artist}}</div>
<el-input class="input" size="small" v-show="editArtist" v-model="artist"/>
<i :class="{'el-icon-edit': !editArtist, 'el-icon-check': editArtist}"
@click="editArtist = !editArtist"
/>
</div>
<div class="edit-item">
<div class="label">专辑</div>
<div class="value" v-show="!editAlbum">{{album}}</div>
<el-input class="input" size="small" v-show="editAlbum" v-model="album"/>
<i :class="{'el-icon-edit': !editAlbum, 'el-icon-check': editAlbum}"
@click="editAlbum = !editAlbum"
/>
</div>
<div class="edit-item">
<div class="label">专辑艺术家</div>
<div class="value" v-show="!editAlbumartist">{{albumartist}}</div>
<el-input class="input" size="small" v-show="editAlbumartist" v-model="albumartist"/>
<i :class="{'el-icon-edit': !editAlbumartist, 'el-icon-check': editAlbumartist}"
@click="editAlbumartist = !editAlbumartist"
/>
</div>
<div class="edit-item">
<div class="label">风格</div>
<div class="value" v-show="!editGenre">{{genre}}</div>
<el-input class="input" size="small" v-show="editGenre" v-model="genre"/>
<i :class="{'el-icon-edit': !editGenre, 'el-icon-check': editGenre}"
@click="editGenre = !editGenre"
/>
</div>
<p class="tip">
为了节省您设备的资源请在确定前充分检查避免反复修改<br />
直接关闭此对话框不会保留所作的更改
</p>
</section>
</el-form>
<span slot="footer" class="dialog-footer">
<el-button type="primary" @click="emitConfirm()"> </el-button>
</span>
</el-dialog>
</template>
<script>
import Ruby from './Ruby';
export default {
components: {
Ruby,
},
props: {
show: { type: Boolean, required: true },
picture: { type: String | undefined, required: true },
title: { type: String | undefined, required: true },
artist: { type: String | undefined, required: true },
album: { type: String | undefined, required: true },
albumartist: { type: String | undefined, required: true },
genre: { type: String | undefined, required: true },
},
data() {
return {
form: {
},
imgFile: { tmpblob: undefined, blob: undefined, url: undefined },
editPicture: false,
editTitle: false,
editArtist: false,
editAlbum: false,
editAlbumartist: false,
editGenre: false,
};
},
async mounted() {
this.refreshForm();
},
methods: {
addFile(file) {
this.imgFile.tmpblob = file.raw;
},
rmvFile() {
this.imgFile.tmpblob = undefined;
},
changeCover() {
this.editPicture = !this.editPicture;
if (!this.editPicture && this.imgFile.tmpblob) {
this.imgFile.blob = this.imgFile.tmpblob;
if (this.imgFile.url) {
URL.revokeObjectURL(this.imgFile.url);
}
this.imgFile.url = URL.createObjectURL(this.imgFile.blob);
}
},
async refreshForm() {
if (this.imgFile.url) {
URL.revokeObjectURL(this.imgFile.url);
}
this.imgFile = { tmpblob: undefined, blob: undefined, url: undefined };
this.editPicture = false;
this.editTitle = false;
this.editArtist = false;
this.editAlbum = false;
this.editAlbumartist = false;
this.editGenre = false;
},
async cancel() {
this.refreshForm();
this.$emit('cancel');
},
async emitConfirm() {
if (this.editPicture) {
this.changeCover();
}
if (this.imgFile.url) {
URL.revokeObjectURL(this.imgFile.url);
}
this.$emit('ok', {
picture: this.imgFile.blob,
title: this.title,
artist: this.artist,
album: this.album,
albumartist: this.albumartist,
genre: this.genre,
});
},
},
};
</script>

View File

@ -1,91 +1,99 @@
<template> <template>
<el-upload :auto-upload="false" :on-change="addFile" :show-file-list="false" action="" drag multiple> <el-upload
<i class="el-icon-upload" /> :auto-upload="false"
<div class="el-upload__text">将文件拖到此处 <em>点击选择</em></div> :on-change="addFile"
<div slot="tip" class="el-upload__tip"> :show-file-list="false"
<div> action=""
仅在浏览器内对文件进行解锁无需消耗流量 drag
<el-tooltip effect="dark" placement="top-start"> multiple>
<div slot="content">算法在源代码中已经提供所有运算都发生在本地</div> <i class="el-icon-upload"/>
<i class="el-icon-info" style="font-size: 12px" /> <div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
</el-tooltip> <div slot="tip" class="el-upload__tip">
</div> <div>
<div> 仅在浏览器内对文件进行解锁无需消耗流量
工作模式: {{ parallel ? '多线程 Worker' : '单线程 Queue' }} <el-tooltip effect="dark" placement="top-start">
<el-tooltip effect="dark" placement="top-start"> <div slot="content">
<div slot="content"> 算法在源代码中已经提供所有运算都发生在本地
将此工具部署在HTTPS环境下可以启用Web Worker特性<br /> </div>
从而更快的利用并行处理完成解锁 <i class="el-icon-info" style="font-size: 12px"/>
</div> </el-tooltip>
<i class="el-icon-info" style="font-size: 12px" /> </div>
</el-tooltip> <div>
</div> 工作模式: {{ parallel ? "多线程 Worker" : "单线程 Queue" }}
</div> <el-tooltip effect="dark" placement="top-start">
<transition name="el-fade-in" <div slot="content">
><!--todo: add delay to animation--> 将此工具部署在HTTPS环境下可以启用Web Worker特性<br/>
<el-progress 从而更快的利用并行处理完成解锁
v-show="progress_show" </div>
:format="progress_string" <i class="el-icon-info" style="font-size: 12px"/>
:percentage="progress_value" </el-tooltip>
:stroke-width="16" </div>
:text-inside="true" </div>
style="margin: 16px 6px 0 6px" <transition name="el-fade-in"><!--todo: add delay to animation-->
></el-progress> <el-progress
</transition> v-show="progress_show" :format="progress_string" :percentage="progress_value"
</el-upload> :stroke-width="16" :text-inside="true"
style="margin: 16px 6px 0 6px"
></el-progress>
</transition>
</el-upload>
</template> </template>
<script> <script>
import { spawn, Worker, Pool } from 'threads'; import {spawn, Worker, Pool} from "threads"
import { Decrypt } from '@/decrypt'; import {CommonDecrypt} from "@/decrypt/common.ts";
import { DecryptQueue } from '@/utils/utils'; import {DecryptQueue} from "@/utils/utils";
import { storage } from '@/utils/storage';
export default { export default {
name: 'FileSelector', name: "FileSelector",
data() { data() {
return { return {
task_all: 0, task_all: 0,
task_finished: 0, task_finished: 0,
queue: new DecryptQueue(), // for http or file protocol queue: new DecryptQueue(), // for http or file protocol
parallel: false, parallel: false
};
},
computed: {
progress_value() {
return this.task_all ? (this.task_finished / this.task_all) * 100 : 0;
},
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++;
} }
});
}, },
}, 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 = CommonDecrypt) => {
console.log("start handling", file.name)
try {
this.$emit("success", await dec(file));
} catch (e) {
console.error(e)
this.$emit("error", e, file.name)
} finally {
this.task_finished++
}
})
},
}
}
</script> </script>

View File

@ -1,66 +1,71 @@
<template> <template>
<el-table :data="tableData" style="width: 100%"> <el-table :data="tableData" style="width: 100%">
<el-table-column label="封面">
<template slot-scope="scope"> <el-table-column label="封面">
<el-image :src="scope.row.picture" style="width: 100px; height: 100px"> <template slot-scope="scope">
<div slot="error" class="image-slot el-image__error">暂无封面</div> <el-image :src="scope.row.picture" style="width: 100px; height: 100px">
</el-image> <div slot="error" class="image-slot el-image__error">
</template> 暂无封面
</el-table-column> </div>
<el-table-column label="歌曲"> </el-image>
<template #default="scope"> </template>
<p>{{ scope.row.title }}</p> </el-table-column>
</template> <el-table-column label="歌曲">
</el-table-column> <template #default="scope">
<el-table-column label="歌手"> <span>{{ scope.row.title }}</span>
<template #default="scope"> </template>
<p>{{ scope.row.artist }}</p> </el-table-column>
</template> <el-table-column label="歌手">
</el-table-column> <template #default="scope">
<el-table-column label="专辑"> <p>{{ scope.row.artist }}</p>
<template #default="scope"> </template>
<p>{{ scope.row.album }}</p> </el-table-column>
</template> <el-table-column label="专辑">
</el-table-column> <template #default="scope">
<el-table-column label="操作"> <p>{{ scope.row.album }}</p>
<template #default="scope"> </template>
<el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)"> </el-table-column>
</el-button> <el-table-column label="操作">
<el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button> <template #default="scope">
<el-button circle icon="el-icon-edit" @click="handleEdit(scope.row)"></el-button> <el-button circle
<el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)"> icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
</el-button> </el-button>
</template> <el-button circle
</el-table-column> icon="el-icon-download" @click="handleDownload(scope.row)">
</el-table> </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> </template>
<script> <script>
import { RemoveBlobMusic } from '@/utils/utils'; import {RemoveBlobMusic} from '@/utils/utils'
export default { export default {
name: 'PreviewTable', name: "PreviewTable",
props: { props: {
tableData: { type: Array, required: true }, tableData: {type: Array, required: true},
policy: { type: Number, required: true }, policy: {type: Number, required: true}
}, },
methods: { methods: {
handlePlay(index, row) { handlePlay(index, row) {
this.$emit('play', row.file); this.$emit("play", row.file);
}, },
handleDelete(index, row) { handleDelete(index, row) {
RemoveBlobMusic(row); RemoveBlobMusic(row);
this.tableData.splice(index, 1); this.tableData.splice(index, 1);
}, },
handleDownload(row) { handleDownload(row) {
this.$emit('download', row); this.$emit("download", row)
}, },
handleEdit(row) { }
this.$emit('edit', row); }
},
},
};
</script> </script>
<style scoped></style> <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://example.unlock-music.dev/',
};
});
const result = await decryptJoox(new Blob([encryptedFile1]), 'test.bin', 'bin');
const resultBuf = await result.blob.arrayBuffer();
expect(resultBuf).toEqual(Buffer.from('Hello World', 'utf-8').buffer);
});
it('should reject E!99 files', async () => {
loadJooxUUID.mockResolvedValue(TEST_UUID_ZEROS);
const input = new Blob([Buffer.from('E!99....')]);
await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('不支持的 joox 加密格式');
});
it('should reject empty uuid', async () => {
loadJooxUUID.mockResolvedValue('');
const input = new Blob([encryptedFile1]);
await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID');
});
it('should reject invalid uuid', async () => {
loadJooxUUID.mockResolvedValue('hello!');
const input = new Blob([encryptedFile1]);
await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID');
});
});

79
src/decrypt/common.ts Normal file
View File

@ -0,0 +1,79 @@
import {Decrypt as NcmDecrypt} from "@/decrypt/ncm";
import {Decrypt as NcmCacheDecrypt} from "@/decrypt/ncmcache";
import {Decrypt as XmDecrypt} from "@/decrypt/xm";
import {Decrypt as QmcDecrypt} from "@/decrypt/qmc";
import {Decrypt as QmcCacheDecrypt} from "@/decrypt/qmccache";
import {Decrypt as KgmDecrypt} from "@/decrypt/kgm";
import {Decrypt as KwmDecrypt} from "@/decrypt/kwm";
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
import {Decrypt as TmDecrypt} from "@/decrypt/tm";
import {DecryptResult, FileInfo} from "@/decrypt/entity";
import {SplitFilename} from "@/decrypt/utils";
export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
const raw = SplitFilename(file.name)
let rt_data: DecryptResult;
switch (raw.ext) {
case "ncm":// Netease Mp3/Flac
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
break;
case "uc":// Netease Cache
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case "kwm":// Kuwo Mp3/Flac
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
break
case "xm": // Xiami Wav/M4a/Mp3/Flac
case "wav":// Xiami/Raw Wav
case "mp3":// Xiami/Raw Mp3
case "flac":// Xiami/Raw Flac
case "m4a":// Xiami/Raw M4a
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
break;
case "ogg":// Raw Ogg
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
break;
case "tm0":// QQ Music IOS Mp3
case "tm3":// QQ Music IOS Mp3
rt_data = await RawDecrypt(file.raw, raw.name, "mp3");
break;
case "qmc3"://QQ Music Android Mp3
case "qmc2"://QQ Music Android Ogg
case "qmc0"://QQ Music Android Mp3
case "qmcflac"://QQ Music Android Flac
case "qmcogg"://QQ Music Android Ogg
case "tkm"://QQ Music Accompaniment M4a
case "bkcmp3"://Moo Music Mp3
case "bkcflac"://Moo Music Flac
case "mflac"://QQ Music Desktop Flac
case "mgg": //QQ Music Desktop Ogg
case "666c6163"://QQ Music Weiyun Flac
case "6d7033"://QQ Music Weiyun Mp3
case "6f6767"://QQ Music Weiyun Ogg
case "6d3461"://QQ Music Weiyun M4a
case "776176"://QQ Music Weiyun Wav
rt_data = await QmcDecrypt(file.raw, raw.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
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,25 +1,26 @@
export interface DecryptResult { export interface DecryptResult {
title: string; title: string
album?: string; album?: string
artist?: string; artist?: string
mime: string; mime: string
ext: string; ext: string
file: string; file: string
blob: Blob; blob: Blob
picture?: string; picture?: string
message?: string
rawExt?: string
rawFilename?: string
message?: string;
rawExt?: string;
rawFilename?: string;
} }
export interface FileInfo { export interface FileInfo {
status: string; status: string
name: string; name: string,
size: number; size: number,
percentage: number; percentage: number,
uid: number; uid: number,
raw: File; raw: File
} }

View File

@ -1,115 +0,0 @@
import { Decrypt as Mg3dDecrypt } from '@/decrypt/mg3d';
import { Decrypt as NcmDecrypt } from '@/decrypt/ncm';
import { Decrypt as NcmCacheDecrypt } from '@/decrypt/ncmcache';
import { Decrypt as XmDecrypt } from '@/decrypt/xm';
import { Decrypt as QmcDecrypt } from '@/decrypt/qmc';
import { Decrypt as QmcCacheDecrypt } from '@/decrypt/qmccache';
import { Decrypt as KgmDecrypt } from '@/decrypt/kgm';
import { Decrypt as KwmDecrypt } from '@/decrypt/kwm';
import { Decrypt as RawDecrypt } from '@/decrypt/raw';
import { Decrypt as TmDecrypt } from '@/decrypt/tm';
import { Decrypt as JooxDecrypt } from '@/decrypt/joox';
import { Decrypt as XimalayaDecrypt } from './ximalaya';
import { DecryptResult, FileInfo } from '@/decrypt/entity';
import { SplitFilename } from '@/decrypt/utils';
import { storage } from '@/utils/storage';
import InMemoryStorage from '@/utils/storage/InMemoryStorage';
export async function Decrypt(file: FileInfo, config: Record<string, any>): Promise<DecryptResult> {
// Worker thread will fallback to in-memory storage.
if (storage instanceof InMemoryStorage) {
await storage.setAll(config);
}
const raw = SplitFilename(file.name);
let rt_data: DecryptResult;
switch (raw.ext) {
case 'mg3d': // Migu Wav
rt_data = await Mg3dDecrypt(file.raw, raw.name);
break;
case 'ncm': // Netease Mp3/Flac
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
break;
case 'uc': // Netease Cache
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case 'kwm': // Kuwo Mp3/Flac
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
break;
case 'xm': // Xiami Wav/M4a/Mp3/Flac
case 'wav': // Xiami/Raw Wav
case 'mp3': // Xiami/Raw Mp3
case 'flac': // Xiami/Raw Flac
case 'm4a': // Xiami/Raw M4a
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
break;
case 'ogg': // Raw Ogg
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
break;
case 'tm0': // QQ Music IOS Mp3
case 'tm3': // QQ Music IOS Mp3
rt_data = await RawDecrypt(file.raw, raw.name, 'mp3');
break;
case 'qmc0': //QQ Music Android Mp3
case 'qmc3': //QQ Music Android Mp3
case 'qmc2': //QQ Music Android Ogg
case 'qmc4': //QQ Music Android Ogg
case 'qmc6': //QQ Music Android Ogg
case 'qmc8': //QQ Music Android Ogg
case 'qmcflac': //QQ Music Android Flac
case 'qmcogg': //QQ Music Android Ogg
case 'tkm': //QQ Music Accompaniment M4a
// Moo Music
case 'bkcmp3':
case 'bkcm4a':
case 'bkcflac':
case 'bkcwav':
case 'bkcape':
case 'bkcogg':
case 'bkcwma':
// QQ Music v2
case 'mggl': //QQ Music Mac
case 'mflac': //QQ Music New Flac
case 'mflac0': //QQ Music New Flac
case 'mflach': //QQ Music New Flac
case 'mgg': //QQ Music New Ogg
case 'mgg1': //QQ Music New Ogg
case 'mgg0':
case 'mmp4': // QMC MP4 Container w/ E-AC-3 JOC
case '666c6163': //QQ Music Weiyun Flac
case '6d7033': //QQ Music Weiyun Mp3
case '6f6767': //QQ Music Weiyun Ogg
case '6d3461': //QQ Music Weiyun M4a
case '776176': //QQ Music Weiyun Wav
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
break;
case 'tm2': // QQ Music IOS M4a
case 'tm6': // QQ Music IOS M4a
rt_data = await TmDecrypt(file.raw, raw.name);
break;
case 'cache': //QQ Music Cache
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case 'vpr':
case 'kgm':
case 'kgma':
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext);
break;
case 'ofl_en':
rt_data = await JooxDecrypt(file.raw, raw.name, raw.ext);
break;
case 'x2m':
case 'x3m':
rt_data = await XimalayaDecrypt(file.raw, raw.name, raw.ext);
break;
case 'mflach': //QQ Music New Flac
throw '网页版无法解锁,请使用<a target="_blank" href="https://git.unlock-music.dev/um/cli">CLI版本</a>'
default:
throw '不支持此文件格式';
}
if (!rt_data.rawExt) rt_data.rawExt = raw.ext;
if (!rt_data.rawFilename) rt_data.rawFilename = raw.name;
console.log(rt_data);
return rt_data;
}

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,57 +1,122 @@
import { import {
AudioMimeType, AudioMimeType,
BytesHasPrefix, BytesHasPrefix,
GetArrayBuffer, GetArrayBuffer,
GetCoverFromFile, GetCoverFromFile,
GetMetaFromFile, GetMetaFromFile,
SniffAudioExt, SniffAudioExt
} from '@/decrypt/utils'; } from "@/decrypt/utils.ts";
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; import {parseBlob as metaParseBlob} from "music-metadata-browser";
import { DecryptResult } from '@/decrypt/entity'; import {DecryptResult} from "@/decrypt/entity";
import { DecryptKgmWasm } from '@/decrypt/kgm_wasm'; import config from "@/../package.json"
//prettier-ignore
const VprHeader = [ const VprHeader = [
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43, 0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31 0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31]
]
//prettier-ignore
const KgmHeader = [ const KgmHeader = [
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B, 0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14 0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14]
] const VprMaskDiff = [
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E,
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11,
0x00]
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> { export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
const oriData = await GetArrayBuffer(file);
if (raw_ext === 'vpr') {
if (!BytesHasPrefix(new Uint8Array(oriData), VprHeader)) throw Error('Not a valid vpr file!');
} else {
if (!BytesHasPrefix(new Uint8Array(oriData), KgmHeader)) throw Error('Not a valid kgm(a) file!');
}
let musicDecoded = new Uint8Array();
if (globalThis.WebAssembly) {
const kgmDecrypted = await DecryptKgmWasm(oriData, raw_ext);
if (kgmDecrypted.success) {
musicDecoded = kgmDecrypted.data;
console.log('kgm wasm decoder suceeded');
} else {
throw new Error(kgmDecrypted.error || '(unknown error)');
}
}
const ext = SniffAudioExt(musicDecoded); const oriData = new Uint8Array(await GetArrayBuffer(file));
const mime = AudioMimeType[ext]; if (raw_ext === "vpr") {
let musicBlob = new Blob([musicDecoded], { type: mime }); if (!BytesHasPrefix(oriData, VprHeader)) throw Error("Not a valid vpr file!")
const musicMeta = await metaParseBlob(musicBlob); } else {
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, String(musicMeta.common.artists || musicMeta.common.artist || "")); if (!BytesHasPrefix(oriData, KgmHeader)) throw Error("Not a valid kgm(a) file!")
return { }
album: musicMeta.common.album, let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer)
picture: GetCoverFromFile(musicMeta), let headerLen = bHeaderLen.getUint32(0, true)
file: URL.createObjectURL(musicBlob),
blob: musicBlob, let audioData = oriData.slice(headerLen)
ext, let dataLen = audioData.length
mime, if (audioData.byteLength > 1 << 26) {
title, throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁")
artist, }
};
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
}
}
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,68 +0,0 @@
import { KgmCrypto } from '@xhacker/kgmwasm/KgmWasmBundle';
import KgmCryptoModule from '@xhacker/kgmwasm/KgmWasmBundle';
import { MergeUint8Array } from '@/utils/MergeUint8Array';
// 每次可以处理 2M 的数据
const DECRYPTION_BUF_SIZE = 2 *1024 * 1024;
export interface KGMDecryptionResult {
success: boolean;
data: Uint8Array;
error: string;
}
/**
* KGM
*
* Uint8Array
* @param {ArrayBuffer} kgmBlob Blob
*/
export async function DecryptKgmWasm(kgmBlob: ArrayBuffer, ext: string): Promise<KGMDecryptionResult> {
const result: KGMDecryptionResult = { success: false, data: new Uint8Array(), error: '' };
// 初始化模组
let KgmCryptoObj: KgmCrypto;
try {
KgmCryptoObj = await KgmCryptoModule();
} catch (err: any) {
result.error = err?.message || 'wasm 加载失败';
return result;
}
if (!KgmCryptoObj) {
result.error = 'wasm 加载失败';
return result;
}
// 申请内存块,并文件末端数据到 WASM 的内存堆
let kgmBuf = new Uint8Array(kgmBlob);
const pKgmBuf = KgmCryptoObj._malloc(DECRYPTION_BUF_SIZE);
const preDecDataSize = Math.min(DECRYPTION_BUF_SIZE, kgmBlob.byteLength); // 初始化缓冲区大小
KgmCryptoObj.writeArrayToMemory(kgmBuf.slice(0, preDecDataSize), pKgmBuf);
// 进行解密初始化
const headerSize = KgmCryptoObj.preDec(pKgmBuf, preDecDataSize, ext);
kgmBuf = kgmBuf.slice(headerSize);
const decryptedParts = [];
let offset = 0;
let bytesToDecrypt = kgmBuf.length;
while (bytesToDecrypt > 0) {
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
// 解密一些片段
const blockData = new Uint8Array(kgmBuf.slice(offset, offset + blockSize));
KgmCryptoObj.writeArrayToMemory(blockData, pKgmBuf);
KgmCryptoObj.decBlob(pKgmBuf, blockSize, offset);
decryptedParts.push(KgmCryptoObj.HEAPU8.slice(pKgmBuf, pKgmBuf + blockSize));
offset += blockSize;
bytesToDecrypt -= blockSize;
}
KgmCryptoObj._free(pKgmBuf);
result.data = MergeUint8Array(decryptedParts);
result.success = true;
return result;
}

View File

@ -1,78 +1,77 @@
import { import {
AudioMimeType, AudioMimeType,
BytesHasPrefix, BytesHasPrefix,
GetArrayBuffer, GetArrayBuffer,
GetCoverFromFile, GetCoverFromFile,
GetMetaFromFile, GetMetaFromFile,
SniffAudioExt, SniffAudioExt
} from '@/decrypt/utils'; } from "@/decrypt/utils.ts";
import { Decrypt as RawDecrypt } from '@/decrypt/raw'; import {Decrypt as RawDecrypt} from "@/decrypt/raw.ts";
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; import {parseBlob as metaParseBlob} from "music-metadata-browser";
import { DecryptResult } from '@/decrypt/entity'; import {DecryptResult} from "@/decrypt/entity";
//prettier-ignore
const MagicHeader = [ const MagicHeader = [
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D, 0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65, 0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65,
]; ]
const MagicHeader2 = [ const PreDefinedKey = "MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk"
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
0x6B, 0x75, 0x77, 0x6F, 0x00, 0x00, 0x00, 0x00,
];
const PreDefinedKey = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk';
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> { export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
const oriData = new Uint8Array(await GetArrayBuffer(file)); const oriData = new Uint8Array(await GetArrayBuffer(file));
if (!BytesHasPrefix(oriData, MagicHeader) && !BytesHasPrefix(oriData, MagicHeader2)) { if (!BytesHasPrefix(oriData, MagicHeader)) {
if (SniffAudioExt(oriData) === 'aac') { if (SniffAudioExt(oriData) === "aac") {
return await RawDecrypt(file, raw_filename, 'aac', false); return await RawDecrypt(file, raw_filename, "aac", false)
}
throw Error("not a valid kwm file")
} }
throw Error('not a valid kwm file');
}
let fileKey = oriData.slice(0x18, 0x20); let fileKey = oriData.slice(0x18, 0x20)
let mask = createMaskFromKey(fileKey); let mask = createMaskFromKey(fileKey)
let audioData = oriData.slice(0x400); let audioData = oriData.slice(0x400);
let lenAudioData = audioData.length; let lenAudioData = audioData.length;
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= mask[cur % 0x20]; 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 ext = SniffAudioExt(audioData);
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, String(musicMeta.common.artists || musicMeta.common.artist || "")); const mime = AudioMimeType[ext];
return { let musicBlob = new Blob([audioData], {type: mime});
album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta), const musicMeta = await metaParseBlob(musicBlob);
file: URL.createObjectURL(musicBlob), const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
blob: musicBlob, return {
mime, album: musicMeta.common.album,
title, picture: GetCoverFromFile(musicMeta),
artist, file: URL.createObjectURL(musicBlob),
ext, blob: musicBlob,
}; mime,
title,
artist,
ext
}
} }
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array { function createMaskFromKey(keyBytes: Uint8Array): Uint8Array {
let keyView = new DataView(keyBytes.buffer); let keyView = new DataView(keyBytes.buffer)
let keyStr = keyView.getBigUint64(0, true).toString(); let keyStr = keyView.getBigUint64(0, true).toString()
let keyStrTrim = trimKey(keyStr); let keyStrTrim = trimKey(keyStr)
let key = new Uint8Array(32); let key = new Uint8Array(32)
for (let i = 0; i < 32; i++) { for (let i = 0; i < 32; i++) {
key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i); key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i)
} }
return key; return key
} }
function trimKey(keyRaw: string): string { function trimKey(keyRaw: string): string {
let lenRaw = keyRaw.length; let lenRaw = keyRaw.length;
let out = keyRaw; let out = keyRaw;
if (lenRaw > 32) { if (lenRaw > 32) {
out = keyRaw.slice(0, 32); out = keyRaw.slice(0, 32)
} else if (lenRaw < 32) { } else if (lenRaw < 32) {
out = keyRaw.padEnd(32, keyRaw); out = keyRaw.padEnd(32, keyRaw)
} }
return out; return out
} }

View File

@ -1,71 +0,0 @@
import { Decrypt as RawDecrypt } from './raw';
import { GetArrayBuffer } from '@/decrypt/utils';
import { DecryptResult } from '@/decrypt/entity';
const segmentSize = 0x20;
function isPrintableAsciiChar(ch: number) {
return ch >= 0x20 && ch <= 0x7E;
}
function isUpperHexChar(ch: number) {
return (ch >= 0x30 && ch <= 0x39) || (ch >= 0x41 && ch <= 0x46);
}
/**
* @param {Buffer} data
* @param {Buffer} key
* @param {boolean} copy
* @returns Buffer
*/
function decryptSegment(data: Uint8Array, key: Uint8Array) {
for (let i = 0; i < data.byteLength; i++) {
data[i] -= key[i % segmentSize];
}
return Buffer.from(data);
}
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> {
const buf = new Uint8Array(await GetArrayBuffer(file));
// 咪咕编码的 WAV 文件有很多“空洞”内容,尝试密钥。
const header = buf.slice(0, 0x100);
const bytesRIFF = Buffer.from('RIFF', 'ascii');
const bytesWaveFormat = Buffer.from('WAVEfmt ', 'ascii');
const possibleKeys = [];
for (let i = segmentSize; i < segmentSize * 20; i += segmentSize) {
const possibleKey = buf.slice(i, i + segmentSize);
if (!possibleKey.every(isUpperHexChar)) continue;
const tempHeader = decryptSegment(header, possibleKey);
if (tempHeader.slice(0, 4).compare(bytesRIFF)) continue;
if (tempHeader.slice(8, 16).compare(bytesWaveFormat)) continue;
// fmt chunk 大小可以是 16 / 18 / 40。
const fmtChunkSize = tempHeader.readUInt32LE(0x10);
if (![16, 18, 40].includes(fmtChunkSize)) continue;
// 下一个 chunk
const firstDataChunkOffset = 0x14 + fmtChunkSize;
const chunkName = tempHeader.slice(firstDataChunkOffset, firstDataChunkOffset + 4);
if (!chunkName.every(isPrintableAsciiChar)) continue;
const secondDataChunkOffset = firstDataChunkOffset + 8 + tempHeader.readUInt32LE(firstDataChunkOffset + 4);
if (secondDataChunkOffset <= header.byteLength) {
const secondChunkName = tempHeader.slice(secondDataChunkOffset, secondDataChunkOffset + 4);
if (!secondChunkName.every(isPrintableAsciiChar)) continue;
}
possibleKeys.push(Buffer.from(possibleKey).toString('ascii'));
}
if (possibleKeys.length <= 0) {
throw new Error(`ERROR: no suitable key discovered`);
}
const decryptionKey = Buffer.from(possibleKeys[0], 'ascii');
decryptSegment(buf, decryptionKey);
const musicData = new Blob([buf], { type: 'audio/x-wav' });
return await RawDecrypt(musicData, raw_filename, 'wav', false);
}

View File

@ -1,248 +1,243 @@
import { import {
AudioMimeType, AudioMimeType,
BytesHasPrefix, BytesHasPrefix,
GetArrayBuffer, GetArrayBuffer,
GetImageFromURL, GetImageFromURL,
GetMetaFromFile, GetMetaFromFile, IMusicMeta,
IMusicMeta, SniffAudioExt,
SniffAudioExt, WriteMetaToFlac,
WriteMetaToFlac, WriteMetaToMp3
WriteMetaToMp3, } from "@/decrypt/utils.ts";
} from '@/decrypt/utils'; import {parseBlob as metaParseBlob} from "music-metadata-browser";
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
import jimp from 'jimp'; import jimp from 'jimp';
import AES from 'crypto-js/aes'; import AES from "crypto-js/aes";
import PKCS7 from 'crypto-js/pad-pkcs7'; import PKCS7 from "crypto-js/pad-pkcs7";
import ModeECB from 'crypto-js/mode-ecb'; import ModeECB from "crypto-js/mode-ecb";
import WordArray from 'crypto-js/lib-typedarrays'; import WordArray from "crypto-js/lib-typedarrays";
import Base64 from 'crypto-js/enc-base64'; import Base64 from "crypto-js/enc-base64";
import EncUTF8 from 'crypto-js/enc-utf8'; import EncUTF8 from "crypto-js/enc-utf8";
import EncHex from 'crypto-js/enc-hex'; import EncHex from "crypto-js/enc-hex";
import { DecryptResult } from '@/decrypt/entity'; import {DecryptResult} from "@/decrypt/entity";
const CORE_KEY = EncHex.parse("687a4852416d736f356b496e62617857");
const META_KEY = EncHex.parse("2331346C6A6B5F215C5D2630553C2728");
const MagicHeader = [0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D];
const CORE_KEY = EncHex.parse('687a4852416d736f356b496e62617857');
const META_KEY = EncHex.parse('2331346C6A6B5F215C5D2630553C2728');
const MagicHeader = [0x43, 0x54, 0x45, 0x4e, 0x46, 0x44, 0x41, 0x4d];
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> { export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
return new NcmDecrypt(await GetArrayBuffer(file), raw_filename).decrypt(); return (new NcmDecrypt(await GetArrayBuffer(file), raw_filename)).decrypt()
} }
interface NcmMusicMeta { interface NcmMusicMeta {
//musicId: number //musicId: number
musicName?: string; musicName?: string
artist?: Array<string | number>[]; artist?: Array<string | number>[]
format?: string; format?: string
album?: string; album?: string
albumPic?: string; albumPic?: string
} }
interface NcmDjMeta { interface NcmDjMeta {
mainMusic: NcmMusicMeta; mainMusic: NcmMusicMeta
} }
class NcmDecrypt { class NcmDecrypt {
raw: ArrayBuffer; raw: ArrayBuffer
view: DataView; view: DataView
offset: number = 0; offset: number = 0
filename: string; filename: string
format: string = ''; format: string = ""
mime: string = ''; mime: string = ""
audio?: Uint8Array; audio?: Uint8Array
blob?: Blob; blob?: Blob
oriMeta?: NcmMusicMeta; oriMeta?: NcmMusicMeta
newMeta?: IMusicMeta; newMeta?: IMusicMeta
image?: { mime: string; buffer: ArrayBuffer; url: string }; image?: { mime: string, buffer: ArrayBuffer, url: string }
constructor(buf: ArrayBuffer, filename: string) { constructor(buf: ArrayBuffer, filename: string) {
const prefix = new Uint8Array(buf, 0, 8); const prefix = new Uint8Array(buf, 0, 8)
if (!BytesHasPrefix(prefix, MagicHeader)) throw Error('此ncm文件已损坏'); if (!BytesHasPrefix(prefix, MagicHeader)) throw Error("此ncm文件已损坏")
this.offset = 10; this.offset = 10
this.raw = buf; this.raw = buf
this.view = new DataView(buf); this.view = new DataView(buf)
this.filename = filename; this.filename = filename
}
_getKeyData(): Uint8Array {
const keyLen = this.view.getUint32(this.offset, true);
this.offset += 4;
const cipherText = new Uint8Array(this.raw, this.offset, keyLen).map((uint8) => uint8 ^ 0x64);
this.offset += keyLen;
const plainText = AES.decrypt(
// @ts-ignore
{ ciphertext: WordArray.create(cipherText) },
CORE_KEY,
{ mode: ModeECB, padding: PKCS7 },
);
const result = new Uint8Array(plainText.sigBytes);
const words = plainText.words;
const sigBytes = plainText.sigBytes;
for (let i = 0; i < sigBytes; i++) {
result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
} }
return result.slice(17); _getKeyData(): Uint8Array {
} const keyLen = this.view.getUint32(this.offset, true);
this.offset += 4;
const cipherText = new Uint8Array(this.raw, this.offset, keyLen)
.map(uint8 => uint8 ^ 0x64);
this.offset += keyLen;
_getKeyBox(): Uint8Array { const plainText = AES.decrypt(
const keyData = this._getKeyData(); // @ts-ignore
const box = new Uint8Array(Array(256).keys()); {ciphertext: WordArray.create(cipherText)},
CORE_KEY,
{mode: ModeECB, padding: PKCS7}
);
const keyDataLen = keyData.length; const result = new Uint8Array(plainText.sigBytes);
let j = 0; const words = plainText.words;
const sigBytes = plainText.sigBytes;
for (let i = 0; i < 256; i++) { for (let i = 0; i < sigBytes; i++) {
j = (box[i] + j + keyData[i % keyDataLen]) & 0xff; result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
[box[i], box[j]] = [box[j], box[i]];
}
return box.map((_, i, arr) => {
i = (i + 1) & 0xff;
const si = arr[i];
const sj = arr[(i + si) & 0xff];
return arr[(si + sj) & 0xff];
});
}
_getMetaData(): NcmMusicMeta {
const metaDataLen = this.view.getUint32(this.offset, true);
this.offset += 4;
if (metaDataLen === 0) return {};
const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen).map((data) => data ^ 0x63);
this.offset += metaDataLen;
WordArray.create();
const plainText = AES.decrypt(
// @ts-ignore
{
ciphertext: Base64.parse(
// @ts-ignore
WordArray.create(cipherText.slice(22)).toString(EncUTF8),
),
},
META_KEY,
{ mode: ModeECB, padding: PKCS7 },
).toString(EncUTF8);
const labelIndex = plainText.indexOf(':');
let result: NcmMusicMeta;
if (plainText.slice(0, labelIndex) === 'dj') {
const tmp: NcmDjMeta = JSON.parse(plainText.slice(labelIndex + 1));
result = tmp.mainMusic;
} else {
result = JSON.parse(plainText.slice(labelIndex + 1));
}
if (result.albumPic) {
result.albumPic = result.albumPic.replace('http://', 'https://') + '?param=500y500';
}
return result;
}
_getAudio(keyBox: Uint8Array): Uint8Array {
this.offset += this.view.getUint32(this.offset + 5, true) + 13;
const audioData = new Uint8Array(this.raw, this.offset);
let lenAudioData = audioData.length;
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff];
return audioData;
}
async _buildMeta() {
if (!this.oriMeta) throw Error('invalid sequence');
const info = GetMetaFromFile(this.filename, this.oriMeta.musicName);
// build artists
let artists: string[] = [];
if (typeof this.oriMeta.artist === 'string') {
// v3.0: artist 现在可能是字符串了?
artists.push(this.oriMeta.artist);
} else if (Array.isArray(this.oriMeta.artist)) {
this.oriMeta.artist.forEach((artist) => {
if (typeof artist === 'string') {
artists.push(artist);
} else if (Array.isArray(artist) && artist[0] && typeof artist[0] === 'string') {
artists.push(artist[0]);
} }
});
return result.slice(17)
} }
if (artists.length === 0 && info.artist) { _getKeyBox(): Uint8Array {
artists = info.artist const keyData = this._getKeyData()
.split(',') const box = new Uint8Array(Array(256).keys());
.map((val) => val.trim())
.filter((val) => val != '');
}
if (this.oriMeta.albumPic) const keyDataLen = keyData.length;
try {
this.image = await GetImageFromURL(this.oriMeta.albumPic); let j = 0;
while (this.image && this.image.buffer.byteLength >= 1 << 24) {
let img = await jimp.read(Buffer.from(this.image.buffer)); for (let i = 0; i < 256; i++) {
await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO); j = (box[i] + j + keyData[i % keyDataLen]) & 0xff;
this.image.buffer = await img.getBufferAsync('image/jpeg'); [box[i], box[j]] = [box[j], box[i]];
} }
} catch (e) {
console.log('fetch cover image failed', e);
}
this.newMeta = { title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer }; return box.map((_, i, arr) => {
} i = (i + 1) & 0xff;
const si = arr[i];
async _writeMeta() { const sj = arr[(i + si) & 0xff];
if (!this.audio || !this.newMeta) throw Error('invalid sequence'); return arr[(si + sj) & 0xff];
});
if (!this.blob) this.blob = new Blob([this.audio], { type: this.mime });
const ori = await metaParseBlob(this.blob);
let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title;
if (shouldWrite || this.newMeta.picture) {
if (this.format === 'mp3') {
this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori);
} else if (this.format === 'flac') {
this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori);
} else {
console.info(`writing meta for ${this.format} is not being supported for now`);
return;
}
this.blob = new Blob([this.audio], { type: this.mime });
}
}
gatherResult(): DecryptResult {
if (!this.newMeta || !this.blob) throw Error('bad sequence');
return {
title: this.newMeta.title,
artist: this.newMeta.artists?.join('; '),
ext: this.format,
album: this.newMeta.album,
picture: this.image?.url,
file: URL.createObjectURL(this.blob),
blob: this.blob,
mime: this.mime,
};
}
async decrypt() {
const keyBox = this._getKeyBox();
this.oriMeta = this._getMetaData();
this.audio = this._getAudio(keyBox);
this.format = this.oriMeta.format || SniffAudioExt(this.audio);
this.mime = AudioMimeType[this.format];
try {
await this._buildMeta();
await this._writeMeta();
} catch (e) {
console.warn('build/write meta failed, skip.', e);
} }
return this.gatherResult(); _getMetaData(): NcmMusicMeta {
} const metaDataLen = this.view.getUint32(this.offset, true);
this.offset += 4;
if (metaDataLen === 0) return {};
const cipherText = new Uint8Array(this.raw, this.offset, metaDataLen)
.map(data => data ^ 0x63);
this.offset += metaDataLen;
WordArray.create()
const plainText = AES.decrypt(
// @ts-ignore
{
ciphertext: Base64.parse(
// @ts-ignore
WordArray.create(cipherText.slice(22)).toString(EncUTF8)
)
},
META_KEY,
{mode: ModeECB, padding: PKCS7}
).toString(EncUTF8);
const labelIndex = plainText.indexOf(":");
let result: NcmMusicMeta;
if (plainText.slice(0, labelIndex) === "dj") {
const tmp: NcmDjMeta = JSON.parse(plainText.slice(labelIndex + 1));
result = tmp.mainMusic;
} else {
result = JSON.parse(plainText.slice(labelIndex + 1));
}
if (!!result.albumPic) {
result.albumPic = result.albumPic.replace("http://", "https://") + "?param=500y500"
}
return result
}
_getAudio(keyBox: Uint8Array): Uint8Array {
this.offset += this.view.getUint32(this.offset + 5, true) + 13
const audioData = new Uint8Array(this.raw, this.offset)
let lenAudioData = audioData.length
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= keyBox[cur & 0xff]
return audioData
}
async _buildMeta() {
if (!this.oriMeta) throw Error("invalid sequence")
const info = GetMetaFromFile(this.filename, this.oriMeta.musicName)
// build artists
let artists: string[] = [];
if (!!this.oriMeta.artist) {
this.oriMeta.artist.forEach(arr => artists.push(<string>arr[0]));
}
if (artists.length === 0 && !!info.artist) {
artists = info.artist.split(',')
.map(val => val.trim()).filter(val => val != "");
}
if (this.oriMeta.albumPic) try {
this.image = await GetImageFromURL(this.oriMeta.albumPic)
while (this.image && this.image.buffer.byteLength >= 1 << 24) {
let img = await jimp.read(Buffer.from(this.image.buffer))
await img.resize(Math.round(img.getHeight() / 2), jimp.AUTO)
this.image.buffer = await img.getBufferAsync("image/jpeg")
}
} catch (e) {
console.log("get cover image failed", e)
}
this.newMeta = {title: info.title, artists, album: this.oriMeta.album, picture: this.image?.buffer}
}
async _writeMeta() {
if (!this.audio || !this.newMeta) throw Error("invalid sequence")
if (!this.blob) this.blob = new Blob([this.audio], {type: this.mime})
const ori = await metaParseBlob(this.blob);
let shouldWrite = !ori.common.album && !ori.common.artists && !ori.common.title
if (shouldWrite || this.newMeta.picture) {
if (this.format === "mp3") {
this.audio = WriteMetaToMp3(Buffer.from(this.audio), this.newMeta, ori)
} else if (this.format === "flac") {
this.audio = WriteMetaToFlac(Buffer.from(this.audio), this.newMeta, ori)
} else {
console.info(`writing meta for ${this.format} is not being supported for now`)
return
}
this.blob = new Blob([this.audio], {type: this.mime})
}
}
gatherResult(): DecryptResult {
if (!this.newMeta) throw Error("bad sequence")
return {
title: this.newMeta.title,
artist: this.newMeta.artists?.join("; "),
ext: this.format,
album: this.newMeta.album,
picture: this.image?.url,
file: URL.createObjectURL(this.blob),
blob: this.blob as Blob,
mime: this.mime
}
}
async decrypt() {
const keyBox = this._getKeyBox()
this.oriMeta = this._getMetaData()
this.audio = this._getAudio(keyBox)
this.format = this.oriMeta.format || SniffAudioExt(this.audio)
this.mime = AudioMimeType[this.format]
await this._buildMeta()
try {
await this._writeMeta()
} catch (e) {
console.warn("write meta data failed", e)
}
return this.gatherResult()
}
} }

View File

@ -1,28 +1,29 @@
import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils'; import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils.ts";
import { DecryptResult } from '@/decrypt/entity'; import {DecryptResult} from "@/decrypt/entity";
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; import {parseBlob as metaParseBlob} from "music-metadata-browser";
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> { export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string)
const buffer = new Uint8Array(await GetArrayBuffer(file)); : Promise<DecryptResult> {
let length = buffer.length; const buffer = new Uint8Array(await GetArrayBuffer(file));
for (let i = 0; i < length; i++) { let length = buffer.length
buffer[i] ^= 163; for (let i = 0; i < length; i++) {
} buffer[i] ^= 163
const ext = SniffAudioExt(buffer, raw_ext); }
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] }); const ext = SniffAudioExt(buffer, raw_ext);
const tag = await metaParseBlob(file); if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]})
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || "")); const tag = await metaParseBlob(file);
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist)
return { return {
title, title,
artist, artist,
ext, ext,
album: tag.common.album, album: tag.common.album,
picture: GetCoverFromFile(tag), picture: GetCoverFromFile(tag),
file: URL.createObjectURL(file), file: URL.createObjectURL(file),
blob: file, blob: file,
mime: AudioMimeType[ext], mime: AudioMimeType[ext]
}; }
} }

View File

@ -1,88 +1,141 @@
import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils'; import {QmcMask, QmcMaskDetectMflac, QmcMaskDetectMgg, QmcMaskGetDefault} from "./qmcMask";
import {toByteArray as Base64Decode} from 'base64-js'
import {
AudioMimeType,
GetArrayBuffer,
GetCoverFromFile,
GetImageFromURL,
GetMetaFromFile,
SniffAudioExt, WriteMetaToFlac, WriteMetaToMp3
} from "@/decrypt/utils.ts";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import { DecryptResult } from '@/decrypt/entity';
import { DecryptQmcWasm } from '@/decrypt/qmc_wasm'; import iconv from "iconv-lite";
import { extractQQMusicMeta } from '@/utils/qm_meta'; import {DecryptResult} from "@/decrypt/entity";
import {queryAlbumCover, queryKeyInfo, reportKeyUsage} from "@/utils/api";
interface Handler { interface Handler {
ext: string; ext: string
version: number; detect: boolean
handler(data?: Uint8Array): QmcMask | undefined
} }
export const HandlerMap: { [key: string]: Handler } = { export const HandlerMap: { [key: string]: Handler } = {
mgg: { ext: 'ogg', version: 2 }, "mgg": {handler: QmcMaskDetectMgg, ext: "ogg", detect: true},
mgg0: { ext: 'ogg', version: 2 }, "mflac": {handler: QmcMaskDetectMflac, ext: "flac", detect: true},
mggl: { ext: 'ogg', version: 2 }, "mgg.cache": {handler: QmcMaskDetectMgg, ext: "ogg", detect: false},
mgg1: { ext: 'ogg', version: 2 }, "mflac.cache": {handler: QmcMaskDetectMflac, ext: "flac", detect: false},
mflac: { ext: 'flac', version: 2 }, "qmc0": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
mflac0: { ext: 'flac', version: 2 }, "qmc2": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
mmp4: { ext: 'mp4', version: 2 }, "qmc3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"qmcogg": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
// qmcflac / qmcogg: "qmcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
// 有可能是 v2 加密但混用同一个后缀名。 "bkcmp3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
qmcflac: { ext: 'flac', version: 2 }, "bkcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
qmcogg: { ext: 'ogg', version: 2 }, "tkm": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
"666c6163": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
qmc0: { ext: 'mp3', version: 2 }, "6d7033": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
qmc2: { ext: 'ogg', version: 2 }, "6f6767": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
qmc3: { ext: 'mp3', version: 2 }, "6d3461": {handler: QmcMaskGetDefault, ext: "m4a", detect: false},
qmc4: { ext: 'ogg', version: 2 }, "776176": {handler: QmcMaskGetDefault, ext: "wav", detect: false}
qmc6: { ext: 'ogg', version: 2 },
qmc8: { ext: 'ogg', version: 2 },
bkcmp3: { ext: 'mp3', version: 1 },
bkcm4a: { ext: 'm4a', version: 1 },
bkcflac: { ext: 'flac', version: 1 },
bkcwav: { ext: 'wav', version: 1 },
bkcape: { ext: 'ape', version: 1 },
bkcogg: { ext: 'ogg', version: 1 },
bkcwma: { ext: 'wma', version: 1 },
tkm: { ext: 'm4a', version: 1 },
'666c6163': { ext: 'flac', version: 1 },
'6d7033': { ext: 'mp3', version: 1 },
'6f6767': { ext: 'ogg', version: 1 },
'6d3461': { ext: 'm4a', version: 1 },
'776176': { ext: 'wav', version: 1 },
}; };
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> { export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`; if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
const handler = HandlerMap[raw_ext]; const handler = HandlerMap[raw_ext];
let { version } = handler;
const fileBuffer = await GetArrayBuffer(file); const fileData = new Uint8Array(await GetArrayBuffer(file));
let musicDecoded = new Uint8Array(); let audioData, seed, keyData;
let musicID: number | string | undefined; if (handler.detect) {
const keyLen = new DataView(fileData.slice(fileData.length - 4).buffer).getUint32(0, true)
if (version === 2 && globalThis.WebAssembly) { const keyPos = fileData.length - 4 - keyLen;
const v2Decrypted = await DecryptQmcWasm(fileBuffer, raw_ext); audioData = fileData.slice(0, keyPos);
// 若 v2 检测失败,降级到 v1 再尝试一次 seed = handler.handler(audioData);
if (v2Decrypted.success) { keyData = fileData.slice(keyPos, keyPos + keyLen);
musicDecoded = v2Decrypted.data; if (!seed) seed = await queryKey(keyData, raw_filename, raw_ext);
musicID = v2Decrypted.songId; if (!seed) throw raw_ext + "格式仅提供实验性支持";
console.log('qmc wasm decoder suceeded');
} else { } else {
throw new Error(v2Decrypted.error || '(unknown error)'); audioData = fileData;
seed = handler.handler(audioData) as QmcMask;
if (!seed) throw raw_ext + "格式仅提供实验性支持";
} }
} let musicDecoded = seed.Decrypt(audioData);
const ext = SniffAudioExt(musicDecoded, handler.ext); const ext = SniffAudioExt(musicDecoded, handler.ext);
const mime = AudioMimeType[ext]; const mime = AudioMimeType[ext];
const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta( let musicBlob = new Blob([musicDecoded], {type: mime});
new Blob([musicDecoded], { type: mime }),
raw_filename,
ext,
musicID,
);
return { const musicMeta = await metaParseBlob(musicBlob);
title: title, for (let metaIdx in musicMeta.native) {
artist: artist, if (!musicMeta.native.hasOwnProperty(metaIdx)) continue
ext: ext, if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) {
album: album, console.warn("try using gbk encoding to decode meta")
picture: imgUrl, musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ""), "gbk");
file: URL.createObjectURL(blob), musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ""), "gbk");
blob: blob, musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ""), "gbk");
mime: mime, }
}; }
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
if (keyData) reportKeyUsage(keyData, seed.getMatrix128(),
raw_filename, raw_ext, info.title, info.artist, musicMeta.common.album).then().catch();
let imgUrl = GetCoverFromFile(musicMeta);
if (!imgUrl) {
imgUrl = await getCoverImage(info.title, info.artist, musicMeta.common.album);
if (imgUrl) {
const imageInfo = await GetImageFromURL(imgUrl);
if (imageInfo) {
imgUrl = imageInfo.url
try {
const newMeta = {picture: imageInfo.buffer, title: info.title, artists: info.artist?.split(" _ ")}
if (ext === "mp3") {
musicDecoded = WriteMetaToMp3(Buffer.from(musicDecoded), newMeta, musicMeta)
musicBlob = new Blob([musicDecoded], {type: mime});
} else if (ext === 'flac') {
musicDecoded = WriteMetaToFlac(Buffer.from(musicDecoded), newMeta, musicMeta)
musicBlob = new Blob([musicDecoded], {type: mime});
} else {
console.info("writing metadata for " + ext + " is not being supported for now")
}
} catch (e) {
console.warn("Error while appending cover image to file " + e)
}
}
}
}
return {
title: info.title,
artist: info.artist,
ext: ext,
album: musicMeta.common.album,
picture: imgUrl,
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
mime: mime
}
}
async function queryKey(keyData: Uint8Array, filename: string, format: string): Promise<QmcMask | undefined> {
try {
const data = await queryKeyInfo(keyData, filename, format)
return new QmcMask(Base64Decode(data.Matrix44));
} catch (e) {
console.warn(e);
}
}
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
const song_query_url = "https://stats.ixarea.com/apis" + "/music/qq-cover"
try {
const data = await queryAlbumCover(title, artist, album)
return `${song_query_url}/${data.Type}/${data.Id}`
} catch (e) {
console.warn(e);
}
return ""
} }

206
src/decrypt/qmcMask.ts Normal file
View File

@ -0,0 +1,206 @@
import {BytesHasPrefix, FLAC_HEADER, OGG_HEADER} from "@/decrypt/utils.ts";
const QMOggPublicHeader1 = [
0x4f, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff,
0xff, 0xff, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x1e, 0x01, 0x76, 0x6f, 0x72,
0x62, 0x69, 0x73, 0x00, 0x00, 0x00, 0x00, 0x02, 0x44, 0xac, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0xee, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0xb8, 0x01, 0x4f, 0x67, 0x67, 0x53, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xff, 0xff, 0xff, 0xff, 0x01, 0x00, 0x00, 0x00,
0xff, 0xff, 0xff, 0xff];
const QMOggPublicHeader2 = [
0x03, 0x76, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x2c, 0x00, 0x00, 0x00, 0x58, 0x69, 0x70, 0x68, 0x2e,
0x4f, 0x72, 0x67, 0x20, 0x6c, 0x69, 0x62, 0x56, 0x6f, 0x72, 0x62, 0x69, 0x73, 0x20, 0x49, 0x20,
0x32, 0x30, 0x31, 0x35, 0x30, 0x31, 0x30, 0x35, 0x20, 0x28, 0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84,
0xe2, 0x9b, 0x84, 0xe2, 0x9b, 0x84, 0x29, 0xff, 0x00, 0x00, 0x00, 0xff, 0x00, 0x00, 0x00, 0x54,
0x49, 0x54, 0x4c, 0x45, 0x3d];
const QMOggPublicConf1 = [
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 0,
0, 0, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 6, 3, 3, 3, 3, 6, 6, 6, 6,
3, 3, 3, 3, 6, 6, 6, 6, 6, 9, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 9, 0, 0, 0, 0, 9, 9, 9, 9,
0, 0, 0, 0];
const QMOggPublicConf2 = [
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 3, 3, 3, 0, 1, 3, 3, 0, 1, 3, 3, 3,
3, 3, 3, 3, 3];
const QMCDefaultMaskMatrix = [
0xde, 0x51, 0xfa, 0xc3, 0x4a, 0xd6, 0xca, 0x90,
0x7e, 0x67, 0x5e, 0xf7, 0xd5, 0x52, 0x84, 0xd8,
0x47, 0x95, 0xbb, 0xa1, 0xaa, 0xc6, 0x66, 0x23,
0x92, 0x62, 0xf3, 0x74, 0xa1, 0x9f, 0xf4, 0xa0,
0x1d, 0x3f, 0x5b, 0xf0, 0x13, 0x0e, 0x09, 0x3d,
0xf9, 0xbc, 0x00, 0x11];
const AllMapping: number[][] = [];
const Mask128to44: number[] = [];
(function () {
for (let i = 0; i < 128; i++) {
let realIdx = (i * i + 27) % 256
if (realIdx in AllMapping) {
AllMapping[realIdx].push(i)
} else {
AllMapping[realIdx] = [i]
}
}
let idx44 = 0
AllMapping.forEach(all128 => {
all128.forEach(_i128 => {
Mask128to44[_i128] = idx44
})
idx44++
})
})();
export class QmcMask {
private readonly Matrix128: number[];
constructor(matrix: number[] | Uint8Array) {
if (matrix instanceof Uint8Array) matrix = Array.from(matrix)
if (matrix.length === 44) {
this.Matrix128 = this._generate128(matrix)
} else if (matrix.length === 128) {
this.Matrix128 = matrix
} else {
throw Error("invalid mask length")
}
}
getMatrix128() {
return this.Matrix128
}
getMatrix44(): number[] {
const matrix44: number[] = []
let idxI44 = 0
AllMapping.forEach(it256 => {
let it256Len = it256.length
for (let i = 1; i < it256Len; i++) {
if (this.Matrix128[it256[0]] !== this.Matrix128[it256[i]]) {
throw "decode mask-128 to mask-44 failed"
}
}
matrix44[idxI44] = this.Matrix128[it256[0]]
idxI44++
})
return matrix44
}
Decrypt(data: Uint8Array) {
if (!this.Matrix128) throw Error("bad call sequence")
let dst = data.slice(0);
let index = -1;
let maskIdx = -1;
for (let cur = 0; cur < data.length; cur++) {
index++;
maskIdx++;
if (index === 0x8000 || (index > 0x8000 && (index + 1) % 0x8000 === 0)) {
index++;
maskIdx++;
}
if (maskIdx >= 128) maskIdx -= 128;
dst[cur] ^= this.Matrix128[maskIdx];
}
return dst;
}
private _generate128(matrix44: number[]): number[] {
const matrix128: number[] = []
let idx44 = 0
AllMapping.forEach(it256 => {
it256.forEach(m => {
matrix128[m] = matrix44[idx44]
})
idx44++
})
return matrix128
}
}
export function QmcMaskGetDefault() {
return new QmcMask(QMCDefaultMaskMatrix)
}
export function QmcMaskDetectMflac(data: Uint8Array) {
let search_len = Math.min(0x8000, data.length), mask;
for (let block_idx = 0; block_idx < search_len; block_idx += 128) {
try {
mask = new QmcMask(data.slice(block_idx, block_idx + 128));
if (BytesHasPrefix(mask.Decrypt(data.slice(0, FLAC_HEADER.length)), FLAC_HEADER)) {
break;
}
} catch (e) {
}
}
return mask;
}
export function QmcMaskDetectMgg(data: Uint8Array) {
if (data.length < 0x100) return
let matrixConfidence: { [key: number]: { [key: number]: number } } = {};
for (let i = 0; i < 44; i++) matrixConfidence[i] = {};
const page2 = data[0x54] ^ data[0xC] ^ QMOggPublicHeader1[0xC];
const spHeader = QmcGenerateOggHeader(page2)
const spConf = QmcGenerateOggConf(page2)
for (let idx128 = 0; idx128 < spHeader.length; idx128++) {
if (spConf[idx128] === 0) continue;
let idx44 = Mask128to44[idx128 % 128];
let _m = data[idx128] ^ spHeader[idx128]
let confidence = spConf[idx128];
if (_m in matrixConfidence[idx44]) {
matrixConfidence[idx44][_m] += confidence
} else {
matrixConfidence[idx44][_m] = confidence
}
}
let matrix = [];
try {
for (let i = 0; i < 44; i++)
matrix[i] = calcMaskFromConfidence(matrixConfidence[i]);
} catch (e) {
return;
}
const mask = new QmcMask(matrix);
if (!BytesHasPrefix(mask.Decrypt(data.slice(0, OGG_HEADER.length)), OGG_HEADER)) {
return;
}
return mask;
}
function calcMaskFromConfidence(confidence: { [key: number]: number }) {
const count = Object.keys(confidence).length
if (count === 0) throw "can not match at least one key";
if (count > 1) console.warn("There are 2 potential value for the mask!")
let result = ""
let conf = 0
for (let idx in confidence) {
if (confidence[idx] > conf) {
result = idx;
conf = confidence[idx];
}
}
return Number(result)
}
function QmcGenerateOggHeader(page2: number) {
let spec = [page2, 0xFF]
for (let i = 2; i < page2; i++) spec.push(0xFF)
spec.push(0xFF)
return QMOggPublicHeader1.concat(spec, QMOggPublicHeader2)
}
function QmcGenerateOggConf(page2: number) {
let specConf = [6, 0]
for (let i = 2; i < page2; i++) specConf.push(4)
specConf.push(0)
return QMOggPublicConf1.concat(specConf, QMOggPublicConf2)
}

View File

@ -1,75 +0,0 @@
import { QmcCrypto } from '@xhacker/qmcwasm/QmcWasmBundle';
import QmcCryptoModule from '@xhacker/qmcwasm/QmcWasmBundle';
import { MergeUint8Array } from '@/utils/MergeUint8Array';
// 每次可以处理 2M 的数据
const DECRYPTION_BUF_SIZE = 2 *1024 * 1024;
export interface QMCDecryptionResult {
success: boolean;
data: Uint8Array;
songId: string | number;
error: string;
}
/**
* QMC
*
* Uint8Array
* @param {ArrayBuffer} qmcBlob Blob
*/
export async function DecryptQmcWasm(qmcBlob: ArrayBuffer, ext: string): Promise<QMCDecryptionResult> {
const result: QMCDecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' };
// 初始化模组
let QmcCryptoObj: QmcCrypto;
try {
QmcCryptoObj = await QmcCryptoModule();
} catch (err: any) {
result.error = err?.message || 'wasm 加载失败';
return result;
}
if (!QmcCryptoObj) {
result.error = 'wasm 加载失败';
return result;
}
// 申请内存块,并文件末端数据到 WASM 的内存堆
const qmcBuf = new Uint8Array(qmcBlob);
const pQmcBuf = QmcCryptoObj._malloc(DECRYPTION_BUF_SIZE);
const preDecDataSize = Math.min(DECRYPTION_BUF_SIZE, qmcBlob.byteLength); // 初始化缓冲区大小
QmcCryptoObj.writeArrayToMemory(qmcBuf.slice(-preDecDataSize), pQmcBuf);
// 进行解密初始化
ext = '.' + ext;
const tailSize = QmcCryptoObj.preDec(pQmcBuf, preDecDataSize, ext);
if (tailSize == -1) {
result.error = QmcCryptoObj.getErr();
return result;
} else {
result.songId = QmcCryptoObj.getSongId();
result.songId = result.songId == "0" ? 0 : result.songId;
}
const decryptedParts = [];
let offset = 0;
let bytesToDecrypt = qmcBuf.length - tailSize;
while (bytesToDecrypt > 0) {
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
// 解密一些片段
const blockData = new Uint8Array(qmcBuf.slice(offset, offset + blockSize));
QmcCryptoObj.writeArrayToMemory(blockData, pQmcBuf);
decryptedParts.push(QmcCryptoObj.HEAPU8.slice(pQmcBuf, pQmcBuf + QmcCryptoObj.decBlob(pQmcBuf, blockSize, offset)));
offset += blockSize;
bytesToDecrypt -= blockSize;
}
QmcCryptoObj._free(pQmcBuf);
result.data = MergeUint8Array(decryptedParts);
result.success = true;
return result;
}

View File

@ -1,58 +1,51 @@
import { import {
AudioMimeType, AudioMimeType,
GetArrayBuffer, GetArrayBuffer,
GetCoverFromFile, GetCoverFromFile,
GetMetaFromFile, GetMetaFromFile,
SniffAudioExt, SniffAudioExt,
SplitFilename, SplitFilename
} from '@/decrypt/utils'; } from "@/decrypt/utils.ts";
import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc'; import {Decrypt as QmcDecrypt, HandlerMap} from "@/decrypt/qmc";
import { DecryptQmcWasm } from '@/decrypt/qmc_wasm';
import { DecryptResult } from '@/decrypt/entity'; import {DecryptResult} from "@/decrypt/entity";
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; import {parseBlob as metaParseBlob} from "music-metadata-browser";
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> { export async function Decrypt(file: Blob, raw_filename: string, _: string)
const buffer = await GetArrayBuffer(file); : Promise<DecryptResult> {
const buffer = new Uint8Array(await GetArrayBuffer(file));
let musicDecoded = new Uint8Array(); let length = buffer.length
if (globalThis.WebAssembly) { for (let i = 0; i < length; i++) {
console.log('qmc: using wasm decoder'); buffer[i] ^= 0xf4
if (buffer[i] <= 0x3f) buffer[i] = buffer[i] * 4;
const qmcDecrypted = await DecryptQmcWasm(buffer, raw_ext); else if (buffer[i] <= 0x7f) buffer[i] = (buffer[i] - 0x40) * 4 + 1;
// 若 wasm 失败,使用 js 再尝试一次 else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2;
if (qmcDecrypted.success) { else buffer[i] = (buffer[i] - 0xc0) * 4 + 3;
musicDecoded = qmcDecrypted.data;
console.log('qmc wasm decoder suceeded');
} else {
throw new Error(qmcDecrypted.error || '(unknown error)');
} }
} 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)
let ext = SniffAudioExt(musicDecoded, ''); return {
const newName = SplitFilename(raw_filename); title,
let audioBlob: Blob; artist,
if (ext !== '' || newName.ext === 'mp3') { ext,
audioBlob = new Blob([musicDecoded], { type: AudioMimeType[ext] }); album: tag.common.album,
} else if (newName.ext in HandlerMap) { picture: GetCoverFromFile(tag),
audioBlob = new Blob([musicDecoded], { type: 'application/octet-stream' }); file: URL.createObjectURL(audioBlob),
return QmcDecrypt(audioBlob, newName.name, newName.ext); blob: audioBlob,
} else { mime: AudioMimeType[ext]
throw '不支持的QQ音乐缓存格式'; }
}
const tag = await metaParseBlob(audioBlob);
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || ""));
return {
title,
artist,
ext,
album: tag.common.album,
picture: GetCoverFromFile(tag),
file: URL.createObjectURL(audioBlob),
blob: audioBlob,
mime: AudioMimeType[ext],
};
} }

View File

@ -1,32 +1,28 @@
import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils'; import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils.ts";
import { DecryptResult } from '@/decrypt/entity'; import {DecryptResult} from "@/decrypt/entity";
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; import {parseBlob as metaParseBlob} from "music-metadata-browser";
export async function Decrypt( export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string, detect: boolean = true)
file: Blob, : Promise<DecryptResult> {
raw_filename: string, let ext = raw_ext;
raw_ext: string, if (detect) {
detect: boolean = true, const buffer = new Uint8Array(await GetArrayBuffer(file));
): Promise<DecryptResult> { ext = SniffAudioExt(buffer, raw_ext);
let ext = raw_ext; if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]})
if (detect) { }
const buffer = new Uint8Array(await GetArrayBuffer(file)); const tag = await metaParseBlob(file);
ext = SniffAudioExt(buffer, raw_ext); const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist)
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] });
}
const tag = await metaParseBlob(file);
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, String(tag.common.artists || tag.common.artist || ''));
return { return {
title, title,
artist, artist,
ext, ext,
album: tag.common.album, album: tag.common.album,
picture: GetCoverFromFile(tag), picture: GetCoverFromFile(tag),
file: URL.createObjectURL(file), file: URL.createObjectURL(file),
blob: file, blob: file,
mime: AudioMimeType[ext], mime: AudioMimeType[ext]
}; }
} }

View File

@ -1,14 +1,14 @@
import { Decrypt as RawDecrypt } from './raw'; import {Decrypt as RawDecrypt} from "./raw";
import { GetArrayBuffer } from '@/decrypt/utils'; import {GetArrayBuffer} from "@/decrypt/utils.ts";
import { DecryptResult } from '@/decrypt/entity'; import {DecryptResult} from "@/decrypt/entity";
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]; const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> { export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> {
const audioData = new Uint8Array(await GetArrayBuffer(file)); const audioData = new Uint8Array(await GetArrayBuffer(file));
for (let cur = 0; cur < 8; ++cur) { for (let cur = 0; cur < 8; ++cur) {
audioData[cur] = TM_HEADER[cur]; audioData[cur] = TM_HEADER[cur];
} }
const musicData = new Blob([audioData], { type: 'audio/mp4' }); const musicData = new Blob([audioData], {type: "audio/mp4"});
return await RawDecrypt(musicData, raw_filename, 'm4a', false); return await RawDecrypt(musicData, raw_filename, "m4a", false)
} }

View File

@ -1,262 +1,170 @@
import { IAudioMetadata } from 'music-metadata-browser'; import {IAudioMetadata} from "music-metadata-browser";
import ID3Writer from 'browser-id3-writer'; import ID3Writer from "browser-id3-writer";
import MetaFlac from 'metaflac-js'; import MetaFlac from "metaflac-js";
export const split_regex = /[ ]?[,;/_、][ ]?/; export const FLAC_HEADER = [0x66, 0x4C, 0x61, 0x43];
export const FLAC_HEADER = [0x66, 0x4c, 0x61, 0x43];
export const MP3_HEADER = [0x49, 0x44, 0x33]; export const MP3_HEADER = [0x49, 0x44, 0x33];
export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53]; export const OGG_HEADER = [0x4F, 0x67, 0x67, 0x53];
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70]; export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
//prettier-ignore
export const WMA_HEADER = [ export const WMA_HEADER = [
0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11,
0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, 0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C,
]; ]
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]; export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]
export const AAC_HEADER = [0xff, 0xf1]; export const AAC_HEADER = [0xFF, 0xF1]
export const DFF_HEADER = [0x46, 0x52, 0x4d, 0x38]; export const DFF_HEADER = [0x46, 0x52, 0x4D, 0x38]
export const AudioMimeType: { [key: string]: string } = { export const AudioMimeType: { [key: string]: string } = {
mp3: 'audio/mpeg', mp3: "audio/mpeg",
flac: 'audio/flac', flac: "audio/flac",
m4a: 'audio/mp4', m4a: "audio/mp4",
ogg: 'audio/ogg', ogg: "audio/ogg",
wma: 'audio/x-ms-wma', wma: "audio/x-ms-wma",
wav: 'audio/x-wav', wav: "audio/x-wav",
dff: 'audio/x-dff', dff: "audio/x-dff"
}; };
export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean { export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean {
if (prefix.length > data.length) return false; if (prefix.length > data.length) return false
return prefix.every((val, idx) => { return prefix.every((val, idx) => {
return val === data[idx]; return val === data[idx];
}); })
} }
export function BytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false;
return a.every((val, idx) => {
return val === b[idx];
});
}
export function SniffAudioExt(data: Uint8Array, fallback_ext: string = 'mp3'): string { export function SniffAudioExt(data: Uint8Array, fallback_ext: string = "mp3"): string {
if (BytesHasPrefix(data, MP3_HEADER)) return 'mp3'; if (BytesHasPrefix(data, MP3_HEADER)) return "mp3"
if (BytesHasPrefix(data, FLAC_HEADER)) return 'flac'; if (BytesHasPrefix(data, FLAC_HEADER)) return "flac"
if (BytesHasPrefix(data, OGG_HEADER)) return 'ogg'; if (BytesHasPrefix(data, OGG_HEADER)) return "ogg"
if (data.length >= 4 + M4A_HEADER.length && BytesHasPrefix(data.slice(4), M4A_HEADER)) return 'm4a'; if (data.length >= 4 + M4A_HEADER.length &&
if (BytesHasPrefix(data, WAV_HEADER)) return 'wav'; BytesHasPrefix(data.slice(4), M4A_HEADER)) return "m4a"
if (BytesHasPrefix(data, WMA_HEADER)) return 'wma'; if (BytesHasPrefix(data, WAV_HEADER)) return "wav"
if (BytesHasPrefix(data, AAC_HEADER)) return 'aac'; if (BytesHasPrefix(data, WMA_HEADER)) return "wma"
if (BytesHasPrefix(data, DFF_HEADER)) return 'dff'; if (BytesHasPrefix(data, AAC_HEADER)) return "aac"
return fallback_ext; if (BytesHasPrefix(data, DFF_HEADER)) return "dff"
return fallback_ext;
} }
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> { export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
if (!!obj.arrayBuffer) return obj.arrayBuffer(); if (!!obj.arrayBuffer) return obj.arrayBuffer()
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (e) => { reader.onload = (e) => {
const rs = e.target?.result; const rs = e.target?.result
if (!rs) { if (!rs) {
reject('read file failed'); reject("read file failed")
} else { } else {
resolve(rs as ArrayBuffer); resolve(rs as ArrayBuffer)
} }
}; };
reader.readAsArrayBuffer(obj); reader.readAsArrayBuffer(obj);
}); });
} }
export function GetCoverFromFile(metadata: IAudioMetadata): string { export function GetCoverFromFile(metadata: IAudioMetadata): string {
if (metadata.common?.picture && metadata.common.picture.length > 0) { if (metadata.common?.picture && metadata.common.picture.length > 0) {
return URL.createObjectURL( return URL.createObjectURL(new Blob(
new Blob([metadata.common.picture[0].data], { type: metadata.common.picture[0].format }), [metadata.common.picture[0].data],
); {type: metadata.common.picture[0].format}
} ));
return ''; }
return "";
} }
export interface IMusicMetaBasic { export interface IMusicMetaBasic {
title: string; title: string
artist?: string; artist?: string
} }
export function GetMetaFromFile( export function GetMetaFromFile(filename: string, exist_title?: string, exist_artist?: string, separator = "-")
filename: string, : IMusicMetaBasic {
exist_title?: string, const meta: IMusicMetaBasic = {title: exist_title ?? "", artist: exist_artist}
exist_artist?: string,
separator = '-',
): IMusicMetaBasic {
const meta: IMusicMetaBasic = { title: exist_title ?? '', artist: exist_artist };
const items = filename.split(separator); const items = filename.split(separator);
if (items.length > 1) { if (items.length > 1) {
//由文件名和原metadata共同决定歌手tag(有时从文件名看有多个歌手而metadata只有一个) if (!meta.artist) meta.artist = items[0].trim();
if (!meta.artist || meta.artist.split(split_regex).length < items[0].trim().split(split_regex).length) meta.artist = items[0].trim(); if (!meta.title) meta.title = items[1].trim();
if (!meta.title) meta.title = items[1].trim(); } else if (items.length === 1) {
} else if (items.length === 1) { if (!meta.title) meta.title = items[0].trim();
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) { return meta
console.warn(e);
}
} }
export async function GetImageFromURL(src: string):
Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> {
try {
const resp = await fetch(src);
const mime = resp.headers.get("Content-Type");
if (mime?.startsWith("image/")) {
const buffer = await resp.arrayBuffer();
const url = URL.createObjectURL(new Blob([buffer], {type: mime}))
return {buffer, url, mime}
}
} catch (e) {
console.warn(e)
}
}
export interface IMusicMeta { export interface IMusicMeta {
title: string; title: string
artists?: string[]; artists?: string[]
album?: string; album?: string
albumartist?: string; picture?: ArrayBuffer
genre?: string[]; picture_desc?: string
picture?: ArrayBuffer;
picture_desc?: string;
} }
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new ID3Writer(audioData); const writer = new ID3Writer(audioData);
// reserve original data // reserve original data
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []; const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []
frames.forEach((frame) => { frames.forEach(frame => {
if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') { if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') {
try { try {
writer.setFrame(frame.id, frame.value); writer.setFrame(frame.id, frame.value)
} catch (e) { } catch (e) {
console.warn(`failed to write ID3 tag '${frame.id}'`); }
} }
})
const old = original.common
writer.setFrame('TPE1', old?.artists || info.artists || [])
.setFrame('TIT2', old?.title || info.title)
.setFrame('TALB', old?.album || info.album || "");
if (info.picture) {
writer.setFrame('APIC', {
type: 3,
data: info.picture,
description: info.picture_desc || "Cover",
})
} }
}); return writer.addTag();
const old = original.common;
writer
.setFrame('TPE1', old?.artists || info.artists || [])
.setFrame('TIT2', old?.title || info.title)
.setFrame('TALB', old?.album || info.album || '');
if (info.picture) {
writer.setFrame('APIC', {
type: 3,
data: info.picture,
description: info.picture_desc || '',
});
}
return writer.addTag();
} }
export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new MetaFlac(audioData); const writer = new MetaFlac(audioData)
const old = original.common; const old = original.common
if (!old.title && !old.album && old.artists) { if (!old.title && !old.album && old.artists) {
writer.setTag('TITLE=' + info.title); writer.setTag("TITLE=" + info.title)
writer.setTag('ALBUM=' + info.album); writer.setTag("ALBUM=" + info.album)
if (info.artists) { if (info.artists) {
writer.removeTag('ARTIST'); writer.removeTag("ARTIST")
info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist)); info.artists.forEach(artist => writer.setTag("ARTIST=" + artist))
}
} }
}
if (info.picture) { if (info.picture) {
writer.importPictureFromBuffer(Buffer.from(info.picture)); writer.importPictureFromBuffer(Buffer.from(info.picture))
}
return writer.save();
}
export function RewriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new ID3Writer(audioData);
// preserve original data
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || [];
frames.forEach((frame) => {
if (frame.id !== 'TPE1'
&& frame.id !== 'TIT2'
&& frame.id !== 'TALB'
&& frame.id !== 'TPE2'
&& frame.id !== 'TCON'
) {
try {
writer.setFrame(frame.id, frame.value);
} catch (e) {
throw new Error(`failed to write ID3 tag '${frame.id}'`);
}
} }
}); return writer.save()
const old = original.common;
writer
.setFrame('TPE1', info?.artists || old.artists || [])
.setFrame('TIT2', info?.title || old.title)
.setFrame('TALB', info?.album || old.album || '')
.setFrame('TPE2', info?.albumartist || old.albumartist || '')
.setFrame('TCON', info?.genre || old.genre || []);
if (info.picture) {
writer.setFrame('APIC', {
type: 3,
data: info.picture,
description: info.picture_desc || '',
});
}
return writer.addTag();
}
export function RewriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new MetaFlac(audioData);
const old = original.common;
if (info.title) {
if (old.title) {
writer.removeTag('TITLE');
}
writer.setTag('TITLE=' + info.title);
}
if (info.album) {
if (old.album) {
writer.removeTag('ALBUM');
}
writer.setTag('ALBUM=' + info.album);
}
if (info.albumartist) {
if (old.albumartist) {
writer.removeTag('ALBUMARTIST');
}
writer.setTag('ALBUMARTIST=' + info.albumartist);
}
if (info.artists) {
if (old.artists) {
writer.removeTag('ARTIST');
}
info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist));
}
if (info.genre) {
if (old.genre) {
writer.removeTag('GENRE');
}
info.genre.forEach((singlegenre) => writer.setTag('GENRE=' + singlegenre));
}
if (info.picture) {
writer.importPictureFromBuffer(Buffer.from(info.picture));
}
return writer.save();
} }
export function SplitFilename(n: string): { name: string; ext: string } { export function SplitFilename(n: string): { name: string; ext: string } {
const pos = n.lastIndexOf('.'); const pos = n.lastIndexOf(".")
return { return {
ext: n.substring(pos + 1).toLowerCase(), ext: n.substring(pos + 1).toLowerCase(),
name: n.substring(0, pos), name: n.substring(0, pos)
}; }
} }

View File

@ -1,193 +0,0 @@
import { parseBlob as metaParseBlob } from 'music-metadata-browser';
import { AudioMimeType, SniffAudioExt, GetArrayBuffer, GetMetaFromFile } from './utils';
import { DecryptResult } from '@/decrypt/entity';
const HandlerMap: Map<string, (data: Uint8Array) => Uint8Array> = new Map([
['x2m', ProcessX2M],
['x3m', ProcessX3M],
]);
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
const buffer = new Uint8Array(await GetArrayBuffer(file));
const handler = HandlerMap.get(raw_ext);
if (!handler) throw 'File type is incorrect!';
let musicDecoded: Uint8Array = handler(buffer);
const ext = SniffAudioExt(musicDecoded, 'm4a');
const mime = AudioMimeType[ext];
let musicBlob = new Blob([musicDecoded], { type: mime });
const musicMeta = await metaParseBlob(musicBlob);
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist);
return {
picture: '',
title: info.title,
artist: info.artist,
ext: ext,
album: musicMeta.common.album,
blob: musicBlob,
file: URL.createObjectURL(musicBlob),
mime: mime,
};
}
function ProcessX2M(data: Uint8Array) {
const x2mHeaderSize = 1024;
const x2mKey = [0x78, 0x6d, 0x6c, 0x79];
let encryptedHeader = data.slice(0, x2mHeaderSize);
for (let idx = 0; idx < x2mHeaderSize; idx++) {
let srcIdx = x2mScrambleTable[idx];
data[idx] = encryptedHeader[srcIdx] ^ x2mKey[idx % x2mKey.length];
}
return data;
}
function ProcessX3M(data: Uint8Array) {
const x3mHeaderSize = 1024;
//prettier-ignore: uint8, size 32(8x4)
const x3mKey = [
0x33, 0x39, 0x38, 0x39, 0x64, 0x31, 0x31, 0x31, 0x61, 0x61, 0x64, 0x35, 0x36, 0x31, 0x33, 0x39, 0x34, 0x30, 0x66,
0x34, 0x66, 0x63, 0x34, 0x34, 0x62, 0x36, 0x33, 0x39, 0x62, 0x32, 0x39, 0x32,
];
let encryptedHeader = data.slice(0, x3mHeaderSize);
for (let dstIdx = 0; dstIdx < x3mHeaderSize; dstIdx++) {
let srcIdx = x3mScrambleTable[dstIdx];
data[dstIdx] = encryptedHeader[srcIdx] ^ x3mKey[dstIdx % x3mKey.length];
}
return data;
}
//prettier-ignore: uint16, size 1024 (64x16)
const x2mScrambleTable = [
0x2a9, 0x2ab, 0x154, 0x2aa, 0x2a8, 0x2ac, 0x153, 0x2a7, 0x2ad, 0x152, 0x2a6, 0x3ff, 0x000, 0x155, 0x2ae, 0x151, 0x2a5,
0x3fe, 0x001, 0x156, 0x2af, 0x150, 0x2a4, 0x3fd, 0x002, 0x157, 0x2b0, 0x14f, 0x2a3, 0x3fc, 0x003, 0x158, 0x2b1, 0x14e,
0x2a2, 0x3fb, 0x004, 0x159, 0x2b2, 0x14d, 0x2a1, 0x3fa, 0x005, 0x15a, 0x2b3, 0x14c, 0x2a0, 0x3f9, 0x006, 0x15b, 0x2b4,
0x14b, 0x29f, 0x3f8, 0x007, 0x15c, 0x2b5, 0x14a, 0x29e, 0x3f7, 0x008, 0x15d, 0x2b6, 0x149, 0x29d, 0x3f6, 0x009, 0x15e,
0x2b7, 0x148, 0x29c, 0x3f5, 0x00a, 0x15f, 0x2b8, 0x147, 0x29b, 0x3f4, 0x00b, 0x160, 0x2b9, 0x146, 0x29a, 0x3f3, 0x00c,
0x161, 0x2ba, 0x145, 0x299, 0x3f2, 0x00d, 0x162, 0x2bb, 0x144, 0x298, 0x3f1, 0x00e, 0x163, 0x2bc, 0x143, 0x297, 0x3f0,
0x00f, 0x164, 0x2bd, 0x142, 0x296, 0x3ef, 0x010, 0x165, 0x2be, 0x141, 0x295, 0x3ee, 0x011, 0x166, 0x2bf, 0x140, 0x294,
0x3ed, 0x012, 0x167, 0x2c0, 0x13f, 0x293, 0x3ec, 0x013, 0x168, 0x2c1, 0x13e, 0x292, 0x3eb, 0x014, 0x169, 0x2c2, 0x13d,
0x291, 0x3ea, 0x015, 0x16a, 0x2c3, 0x13c, 0x290, 0x3e9, 0x016, 0x16b, 0x2c4, 0x13b, 0x28f, 0x3e8, 0x017, 0x16c, 0x2c5,
0x13a, 0x28e, 0x3e7, 0x018, 0x16d, 0x2c6, 0x139, 0x28d, 0x3e6, 0x019, 0x16e, 0x2c7, 0x138, 0x28c, 0x3e5, 0x01a, 0x16f,
0x2c8, 0x137, 0x28b, 0x3e4, 0x01b, 0x170, 0x2c9, 0x136, 0x28a, 0x3e3, 0x01c, 0x171, 0x2ca, 0x135, 0x289, 0x3e2, 0x01d,
0x172, 0x2cb, 0x134, 0x288, 0x3e1, 0x01e, 0x173, 0x2cc, 0x133, 0x287, 0x3e0, 0x01f, 0x174, 0x2cd, 0x0a9, 0x1fe, 0x357,
0x020, 0x175, 0x2ce, 0x0aa, 0x1ff, 0x358, 0x021, 0x176, 0x2cf, 0x0ab, 0x200, 0x359, 0x022, 0x177, 0x2d0, 0x0ac, 0x201,
0x35a, 0x023, 0x178, 0x2d1, 0x0ad, 0x202, 0x35b, 0x024, 0x179, 0x2d2, 0x0ae, 0x203, 0x35c, 0x025, 0x17a, 0x2d3, 0x0af,
0x204, 0x35d, 0x026, 0x17b, 0x2d4, 0x0b0, 0x205, 0x35e, 0x027, 0x17c, 0x2d5, 0x0b1, 0x206, 0x35f, 0x028, 0x17d, 0x2d6,
0x0b2, 0x207, 0x360, 0x029, 0x17e, 0x2d7, 0x0b3, 0x208, 0x361, 0x02a, 0x17f, 0x2d8, 0x0b4, 0x209, 0x362, 0x02b, 0x180,
0x2d9, 0x0b5, 0x20a, 0x363, 0x02c, 0x181, 0x2da, 0x0b6, 0x20b, 0x364, 0x02d, 0x182, 0x2db, 0x0b7, 0x20c, 0x365, 0x02e,
0x183, 0x2dc, 0x0b8, 0x20d, 0x366, 0x02f, 0x184, 0x2dd, 0x0b9, 0x20e, 0x367, 0x030, 0x185, 0x2de, 0x0ba, 0x20f, 0x368,
0x031, 0x186, 0x2df, 0x0bb, 0x210, 0x369, 0x032, 0x187, 0x2e0, 0x0bc, 0x211, 0x36a, 0x033, 0x188, 0x2e1, 0x0bd, 0x212,
0x36b, 0x034, 0x189, 0x2e2, 0x0be, 0x213, 0x36c, 0x035, 0x18a, 0x2e3, 0x0bf, 0x214, 0x36d, 0x036, 0x18b, 0x2e4, 0x0c0,
0x215, 0x36e, 0x037, 0x18c, 0x2e5, 0x0c1, 0x216, 0x36f, 0x038, 0x18d, 0x2e6, 0x0c2, 0x217, 0x370, 0x039, 0x18e, 0x2e7,
0x0c3, 0x218, 0x371, 0x03a, 0x18f, 0x2e8, 0x0c4, 0x219, 0x372, 0x03b, 0x190, 0x2e9, 0x0c5, 0x21a, 0x373, 0x03c, 0x191,
0x2ea, 0x0c6, 0x21b, 0x374, 0x03d, 0x192, 0x2eb, 0x0c7, 0x21c, 0x375, 0x03e, 0x193, 0x2ec, 0x0c8, 0x21d, 0x376, 0x03f,
0x194, 0x2ed, 0x0c9, 0x21e, 0x377, 0x040, 0x195, 0x2ee, 0x0ca, 0x21f, 0x378, 0x041, 0x196, 0x2ef, 0x0cb, 0x220, 0x379,
0x042, 0x197, 0x2f0, 0x0cc, 0x221, 0x37a, 0x043, 0x198, 0x2f1, 0x0cd, 0x222, 0x37b, 0x044, 0x199, 0x2f2, 0x0ce, 0x223,
0x37c, 0x045, 0x19a, 0x2f3, 0x0cf, 0x224, 0x37d, 0x046, 0x19b, 0x2f4, 0x0d0, 0x225, 0x37e, 0x047, 0x19c, 0x2f5, 0x0d1,
0x226, 0x37f, 0x048, 0x19d, 0x2f6, 0x0d2, 0x227, 0x380, 0x049, 0x19e, 0x2f7, 0x0d3, 0x228, 0x381, 0x04a, 0x19f, 0x2f8,
0x0d4, 0x229, 0x382, 0x04b, 0x1a0, 0x2f9, 0x0d5, 0x22a, 0x383, 0x04c, 0x1a1, 0x2fa, 0x0d6, 0x22b, 0x384, 0x04d, 0x1a2,
0x2fb, 0x0d7, 0x22c, 0x385, 0x04e, 0x1a3, 0x2fc, 0x0d8, 0x22d, 0x386, 0x04f, 0x1a4, 0x2fd, 0x0d9, 0x22e, 0x387, 0x050,
0x1a5, 0x2fe, 0x0da, 0x22f, 0x388, 0x051, 0x1a6, 0x2ff, 0x0db, 0x230, 0x389, 0x052, 0x1a7, 0x300, 0x0dc, 0x231, 0x38a,
0x053, 0x1a8, 0x301, 0x0dd, 0x232, 0x38b, 0x054, 0x1a9, 0x302, 0x0de, 0x233, 0x38c, 0x055, 0x1aa, 0x303, 0x0df, 0x234,
0x38d, 0x056, 0x1ab, 0x304, 0x0e0, 0x235, 0x38e, 0x057, 0x1ac, 0x305, 0x0e1, 0x236, 0x38f, 0x058, 0x1ad, 0x306, 0x0e2,
0x237, 0x390, 0x059, 0x1ae, 0x307, 0x0e3, 0x238, 0x391, 0x05a, 0x1af, 0x308, 0x0e4, 0x239, 0x392, 0x05b, 0x1b0, 0x309,
0x0e5, 0x23a, 0x393, 0x05c, 0x1b1, 0x30a, 0x0e6, 0x23b, 0x394, 0x05d, 0x1b2, 0x30b, 0x0e7, 0x23c, 0x395, 0x05e, 0x1b3,
0x30c, 0x0e8, 0x23d, 0x396, 0x05f, 0x1b4, 0x30d, 0x0e9, 0x23e, 0x397, 0x060, 0x1b5, 0x30e, 0x0ea, 0x23f, 0x398, 0x061,
0x1b6, 0x30f, 0x0eb, 0x240, 0x399, 0x062, 0x1b7, 0x310, 0x0ec, 0x241, 0x39a, 0x063, 0x1b8, 0x311, 0x0ed, 0x242, 0x39b,
0x064, 0x1b9, 0x312, 0x0ee, 0x243, 0x39c, 0x065, 0x1ba, 0x313, 0x0ef, 0x244, 0x39d, 0x066, 0x1bb, 0x314, 0x0f0, 0x245,
0x39e, 0x067, 0x1bc, 0x315, 0x0f1, 0x246, 0x39f, 0x068, 0x1bd, 0x316, 0x0f2, 0x247, 0x3a0, 0x069, 0x1be, 0x317, 0x0f3,
0x248, 0x3a1, 0x06a, 0x1bf, 0x318, 0x0f4, 0x249, 0x3a2, 0x06b, 0x1c0, 0x319, 0x0f5, 0x24a, 0x3a3, 0x06c, 0x1c1, 0x31a,
0x0f6, 0x24b, 0x3a4, 0x06d, 0x1c2, 0x31b, 0x0f7, 0x24c, 0x3a5, 0x06e, 0x1c3, 0x31c, 0x0f8, 0x24d, 0x3a6, 0x06f, 0x1c4,
0x31d, 0x0f9, 0x24e, 0x3a7, 0x070, 0x1c5, 0x31e, 0x0fa, 0x24f, 0x3a8, 0x071, 0x1c6, 0x31f, 0x0fb, 0x250, 0x3a9, 0x072,
0x1c7, 0x320, 0x0fc, 0x251, 0x3aa, 0x073, 0x1c8, 0x321, 0x0fd, 0x252, 0x3ab, 0x074, 0x1c9, 0x322, 0x0fe, 0x253, 0x3ac,
0x075, 0x1ca, 0x323, 0x0ff, 0x254, 0x3ad, 0x076, 0x1cb, 0x324, 0x100, 0x255, 0x3ae, 0x077, 0x1cc, 0x325, 0x101, 0x256,
0x3af, 0x078, 0x1cd, 0x326, 0x102, 0x257, 0x3b0, 0x079, 0x1ce, 0x327, 0x103, 0x258, 0x3b1, 0x07a, 0x1cf, 0x328, 0x104,
0x259, 0x3b2, 0x07b, 0x1d0, 0x329, 0x105, 0x25a, 0x3b3, 0x07c, 0x1d1, 0x32a, 0x106, 0x25b, 0x3b4, 0x07d, 0x1d2, 0x32b,
0x107, 0x25c, 0x3b5, 0x07e, 0x1d3, 0x32c, 0x108, 0x25d, 0x3b6, 0x07f, 0x1d4, 0x32d, 0x109, 0x25e, 0x3b7, 0x080, 0x1d5,
0x32e, 0x10a, 0x25f, 0x3b8, 0x081, 0x1d6, 0x32f, 0x10b, 0x260, 0x3b9, 0x082, 0x1d7, 0x330, 0x10c, 0x261, 0x3ba, 0x083,
0x1d8, 0x331, 0x10d, 0x262, 0x3bb, 0x084, 0x1d9, 0x332, 0x10e, 0x263, 0x3bc, 0x085, 0x1da, 0x333, 0x10f, 0x264, 0x3bd,
0x086, 0x1db, 0x334, 0x110, 0x265, 0x3be, 0x087, 0x1dc, 0x335, 0x111, 0x266, 0x3bf, 0x088, 0x1dd, 0x336, 0x112, 0x267,
0x3c0, 0x089, 0x1de, 0x337, 0x113, 0x268, 0x3c1, 0x08a, 0x1df, 0x338, 0x114, 0x269, 0x3c2, 0x08b, 0x1e0, 0x339, 0x115,
0x26a, 0x3c3, 0x08c, 0x1e1, 0x33a, 0x116, 0x26b, 0x3c4, 0x08d, 0x1e2, 0x33b, 0x117, 0x26c, 0x3c5, 0x08e, 0x1e3, 0x33c,
0x118, 0x26d, 0x3c6, 0x08f, 0x1e4, 0x33d, 0x119, 0x26e, 0x3c7, 0x090, 0x1e5, 0x33e, 0x11a, 0x26f, 0x3c8, 0x091, 0x1e6,
0x33f, 0x11b, 0x270, 0x3c9, 0x092, 0x1e7, 0x340, 0x11c, 0x271, 0x3ca, 0x093, 0x1e8, 0x341, 0x11d, 0x272, 0x3cb, 0x094,
0x1e9, 0x342, 0x11e, 0x273, 0x3cc, 0x095, 0x1ea, 0x343, 0x11f, 0x274, 0x3cd, 0x096, 0x1eb, 0x344, 0x120, 0x275, 0x3ce,
0x097, 0x1ec, 0x345, 0x121, 0x276, 0x3cf, 0x098, 0x1ed, 0x346, 0x122, 0x277, 0x3d0, 0x099, 0x1ee, 0x347, 0x123, 0x278,
0x3d1, 0x09a, 0x1ef, 0x348, 0x124, 0x279, 0x3d2, 0x09b, 0x1f0, 0x349, 0x125, 0x27a, 0x3d3, 0x09c, 0x1f1, 0x34a, 0x126,
0x27b, 0x3d4, 0x09d, 0x1f2, 0x34b, 0x127, 0x27c, 0x3d5, 0x09e, 0x1f3, 0x34c, 0x128, 0x27d, 0x3d6, 0x09f, 0x1f4, 0x34d,
0x129, 0x27e, 0x3d7, 0x0a0, 0x1f5, 0x34e, 0x12a, 0x27f, 0x3d8, 0x0a1, 0x1f6, 0x34f, 0x12b, 0x280, 0x3d9, 0x0a2, 0x1f7,
0x350, 0x12c, 0x281, 0x3da, 0x0a3, 0x1f8, 0x351, 0x12d, 0x282, 0x3db, 0x0a4, 0x1f9, 0x352, 0x12e, 0x283, 0x3dc, 0x0a5,
0x1fa, 0x353, 0x12f, 0x284, 0x3dd, 0x0a6, 0x1fb, 0x354, 0x130, 0x285, 0x3de, 0x0a7, 0x1fc, 0x355, 0x131, 0x286, 0x3df,
0x0a8, 0x1fd, 0x356, 0x132,
];
//prettier-ignore: uint16, size 1024 (64x16)
const x3mScrambleTable = [
0x256, 0x28d, 0x213, 0x307, 0x156, 0x39d, 0x062, 0x170, 0x3ca, 0x035, 0x0ed, 0x2a4, 0x1e4, 0x359, 0x0d3, 0x26b, 0x265,
0x274, 0x251, 0x297, 0x202, 0x322, 0x126, 0x32b, 0x117, 0x302, 0x15c, 0x3a8, 0x057, 0x148, 0x380, 0x090, 0x1f6, 0x335,
0x10c, 0x2ee, 0x175, 0x3d4, 0x02b, 0x0cc, 0x260, 0x27b, 0x23d, 0x2bb, 0x1b6, 0x3a1, 0x05e, 0x157, 0x39e, 0x061, 0x16f,
0x3c6, 0x039, 0x0f7, 0x2b9, 0x1b8, 0x39f, 0x060, 0x166, 0x3b9, 0x046, 0x122, 0x31c, 0x12f, 0x33d, 0x0fc, 0x2ca, 0x1a4,
0x3cc, 0x033, 0x0e6, 0x293, 0x209, 0x315, 0x13d, 0x358, 0x0d5, 0x26e, 0x25e, 0x27d, 0x23a, 0x2c0, 0x1b1, 0x3af, 0x050,
0x136, 0x346, 0x0ef, 0x2aa, 0x1ce, 0x376, 0x0a0, 0x210, 0x30c, 0x14c, 0x389, 0x082, 0x1db, 0x367, 0x0b9, 0x23e, 0x2ba,
0x1b7, 0x3a0, 0x05f, 0x164, 0x3b7, 0x048, 0x125, 0x326, 0x11c, 0x30a, 0x14f, 0x38f, 0x070, 0x1a8, 0x3c7, 0x038, 0x0f5,
0x2b5, 0x1bd, 0x393, 0x06c, 0x199, 0x3e1, 0x01e, 0x0b3, 0x22f, 0x2d7, 0x193, 0x3ea, 0x015, 0x09d, 0x20a, 0x314, 0x13e,
0x35a, 0x0d2, 0x26a, 0x267, 0x272, 0x253, 0x294, 0x208, 0x319, 0x137, 0x34c, 0x0e7, 0x295, 0x205, 0x31d, 0x12e, 0x33c,
0x0fe, 0x2cd, 0x1a0, 0x3d5, 0x02a, 0x0c8, 0x258, 0x286, 0x22a, 0x2dc, 0x18e, 0x3f7, 0x008, 0x07c, 0x1d3, 0x370, 0x0a7,
0x21d, 0x2f1, 0x171, 0x3cd, 0x032, 0x0e5, 0x292, 0x20b, 0x313, 0x13f, 0x35c, 0x0d0, 0x266, 0x273, 0x252, 0x296, 0x204,
0x31f, 0x12a, 0x332, 0x10f, 0x2f4, 0x16c, 0x3c3, 0x03c, 0x101, 0x2d2, 0x19a, 0x3e0, 0x01f, 0x0b5, 0x233, 0x2c9, 0x1a6,
0x3c9, 0x036, 0x0f0, 0x2ab, 0x1cb, 0x37c, 0x095, 0x1fd, 0x328, 0x11a, 0x306, 0x158, 0x3a2, 0x05d, 0x155, 0x39c, 0x063,
0x174, 0x3d3, 0x02c, 0x0cf, 0x264, 0x275, 0x24f, 0x299, 0x1fa, 0x32c, 0x115, 0x2ff, 0x15f, 0x3ab, 0x054, 0x143, 0x36c,
0x0ad, 0x225, 0x2e5, 0x181, 0x3ef, 0x010, 0x08c, 0x1f1, 0x344, 0x0f3, 0x2af, 0x1c4, 0x386, 0x088, 0x1e3, 0x35b, 0x0d1,
0x269, 0x268, 0x26d, 0x25f, 0x27c, 0x23b, 0x2bf, 0x1b2, 0x3ae, 0x051, 0x13b, 0x355, 0x0da, 0x278, 0x248, 0x2a6, 0x1dc,
0x365, 0x0c0, 0x246, 0x2a8, 0x1d6, 0x36d, 0x0ac, 0x224, 0x2e8, 0x17e, 0x3eb, 0x014, 0x09c, 0x207, 0x31a, 0x133, 0x341,
0x0f8, 0x2bc, 0x1b5, 0x3a3, 0x05c, 0x152, 0x395, 0x06a, 0x18c, 0x3f9, 0x006, 0x07a, 0x1d1, 0x373, 0x0a4, 0x217, 0x2fe,
0x160, 0x3ad, 0x052, 0x13c, 0x357, 0x0d7, 0x270, 0x25c, 0x281, 0x235, 0x2c6, 0x1aa, 0x3bc, 0x043, 0x11d, 0x30d, 0x14a,
0x384, 0x08a, 0x1e7, 0x353, 0x0dd, 0x284, 0x22e, 0x2d8, 0x192, 0x3ec, 0x013, 0x099, 0x201, 0x323, 0x124, 0x321, 0x127,
0x32d, 0x114, 0x2fd, 0x161, 0x3b0, 0x04f, 0x135, 0x343, 0x0f4, 0x2b4, 0x1be, 0x392, 0x06d, 0x19d, 0x3db, 0x024, 0x0be,
0x244, 0x2b0, 0x1c2, 0x38a, 0x080, 0x1d9, 0x369, 0x0b6, 0x234, 0x2c8, 0x1a7, 0x3c8, 0x037, 0x0f1, 0x2ad, 0x1c6, 0x383,
0x08d, 0x1f2, 0x33b, 0x100, 0x2d1, 0x19b, 0x3de, 0x021, 0x0bb, 0x240, 0x2b6, 0x1bb, 0x399, 0x066, 0x17a, 0x3df, 0x020,
0x0b8, 0x23c, 0x2bd, 0x1b4, 0x3a5, 0x05a, 0x150, 0x390, 0x06f, 0x1a5, 0x3cb, 0x034, 0x0ea, 0x29d, 0x1ee, 0x348, 0x0ec,
0x2a3, 0x1e5, 0x356, 0x0d8, 0x271, 0x257, 0x289, 0x220, 0x2ec, 0x178, 0x3d9, 0x026, 0x0c2, 0x24b, 0x2a1, 0x1ea, 0x34d,
0x0e4, 0x291, 0x20c, 0x312, 0x141, 0x360, 0x0ca, 0x25a, 0x283, 0x230, 0x2d0, 0x19c, 0x3dd, 0x022, 0x0bc, 0x241, 0x2b3,
0x1bf, 0x391, 0x06e, 0x1a2, 0x3d1, 0x02e, 0x0d6, 0x26f, 0x25d, 0x27f, 0x237, 0x2c4, 0x1ac, 0x3ba, 0x045, 0x121, 0x318,
0x138, 0x34e, 0x0e3, 0x28f, 0x211, 0x30b, 0x14d, 0x38c, 0x073, 0x1c3, 0x387, 0x084, 0x1df, 0x362, 0x0c7, 0x255, 0x28e,
0x212, 0x309, 0x153, 0x396, 0x069, 0x18b, 0x3fa, 0x005, 0x079, 0x1d0, 0x374, 0x0a2, 0x215, 0x301, 0x15d, 0x3a9, 0x056,
0x147, 0x37a, 0x098, 0x200, 0x324, 0x11f, 0x316, 0x13a, 0x352, 0x0df, 0x288, 0x223, 0x2e9, 0x17d, 0x3e9, 0x016, 0x09e,
0x20d, 0x310, 0x144, 0x372, 0x0a5, 0x219, 0x2fa, 0x165, 0x3b8, 0x047, 0x123, 0x31e, 0x12d, 0x338, 0x107, 0x2e0, 0x188,
0x3fe, 0x001, 0x075, 0x1c9, 0x37e, 0x093, 0x1f9, 0x32f, 0x112, 0x2f9, 0x167, 0x3be, 0x041, 0x10b, 0x2e7, 0x17f, 0x3ed,
0x012, 0x097, 0x1ff, 0x325, 0x11e, 0x311, 0x142, 0x366, 0x0ba, 0x23f, 0x2b8, 0x1b9, 0x39b, 0x064, 0x176, 0x3d6, 0x029,
0x0c5, 0x250, 0x298, 0x1fc, 0x329, 0x119, 0x304, 0x15a, 0x3a6, 0x059, 0x14e, 0x38e, 0x071, 0x1ad, 0x3b6, 0x049, 0x128,
0x32e, 0x113, 0x2fc, 0x162, 0x3b2, 0x04d, 0x131, 0x33f, 0x0fa, 0x2c2, 0x1af, 0x3b3, 0x04c, 0x130, 0x33e, 0x0fb, 0x2c7,
0x1a9, 0x3bd, 0x042, 0x116, 0x300, 0x15e, 0x3aa, 0x055, 0x146, 0x378, 0x09b, 0x206, 0x31b, 0x132, 0x340, 0x0f9, 0x2be,
0x1b3, 0x3ac, 0x053, 0x140, 0x35d, 0x0ce, 0x262, 0x279, 0x247, 0x2a7, 0x1d7, 0x36b, 0x0ae, 0x226, 0x2e3, 0x185, 0x3f6,
0x009, 0x07d, 0x1d4, 0x36f, 0x0a8, 0x21e, 0x2f0, 0x172, 0x3ce, 0x031, 0x0de, 0x287, 0x228, 0x2df, 0x189, 0x3fd, 0x002,
0x076, 0x1ca, 0x37d, 0x094, 0x1fb, 0x32a, 0x118, 0x303, 0x15b, 0x3a7, 0x058, 0x14b, 0x388, 0x083, 0x1dd, 0x364, 0x0c1,
0x24a, 0x2a2, 0x1e9, 0x350, 0x0e1, 0x28b, 0x21a, 0x2f8, 0x168, 0x3bf, 0x040, 0x10a, 0x2e6, 0x180, 0x3ee, 0x011, 0x091,
0x1f7, 0x334, 0x10d, 0x2ef, 0x173, 0x3cf, 0x030, 0x0dc, 0x280, 0x236, 0x2c5, 0x1ab, 0x3bb, 0x044, 0x120, 0x317, 0x139,
0x34f, 0x0e2, 0x28c, 0x218, 0x2fb, 0x163, 0x3b4, 0x04b, 0x12c, 0x337, 0x108, 0x2e2, 0x186, 0x3fc, 0x003, 0x077, 0x1cc,
0x37b, 0x096, 0x1fe, 0x327, 0x11b, 0x308, 0x154, 0x397, 0x068, 0x183, 0x3f3, 0x00c, 0x085, 0x1e0, 0x361, 0x0c9, 0x259,
0x285, 0x22c, 0x2da, 0x190, 0x3f2, 0x00d, 0x086, 0x1e1, 0x35f, 0x0cb, 0x25b, 0x282, 0x232, 0x2cc, 0x1a1, 0x3d2, 0x02d,
0x0d4, 0x26c, 0x263, 0x277, 0x249, 0x2a5, 0x1de, 0x363, 0x0c6, 0x254, 0x290, 0x20e, 0x30f, 0x145, 0x377, 0x09f, 0x20f,
0x30e, 0x149, 0x382, 0x08e, 0x1f3, 0x33a, 0x102, 0x2d3, 0x198, 0x3e2, 0x01d, 0x0b2, 0x22d, 0x2d9, 0x191, 0x3f1, 0x00e,
0x087, 0x1e2, 0x35e, 0x0cd, 0x261, 0x27a, 0x243, 0x2b1, 0x1c1, 0x38b, 0x07f, 0x1d8, 0x36a, 0x0b4, 0x231, 0x2cf, 0x19e,
0x3da, 0x025, 0x0bf, 0x245, 0x2ac, 0x1c7, 0x381, 0x08f, 0x1f5, 0x336, 0x109, 0x2e4, 0x184, 0x3f4, 0x00b, 0x081, 0x1da,
0x368, 0x0b7, 0x239, 0x2c1, 0x1b0, 0x3b1, 0x04e, 0x134, 0x342, 0x0f6, 0x2b7, 0x1ba, 0x39a, 0x065, 0x179, 0x3dc, 0x023,
0x0bd, 0x242, 0x2b2, 0x1c0, 0x38d, 0x072, 0x1bc, 0x398, 0x067, 0x182, 0x3f0, 0x00f, 0x08b, 0x1e8, 0x351, 0x0e0, 0x28a,
0x21c, 0x2f3, 0x16d, 0x3c4, 0x03b, 0x0ff, 0x2ce, 0x19f, 0x3d7, 0x028, 0x0c4, 0x24e, 0x29b, 0x1f0, 0x345, 0x0f2, 0x2ae,
0x1c5, 0x385, 0x089, 0x1e6, 0x354, 0x0db, 0x27e, 0x238, 0x2c3, 0x1ae, 0x3b5, 0x04a, 0x12b, 0x333, 0x10e, 0x2f2, 0x16e,
0x3c5, 0x03a, 0x0fd, 0x2cb, 0x1a3, 0x3d0, 0x02f, 0x0d9, 0x276, 0x24c, 0x29f, 0x1ec, 0x34a, 0x0e9, 0x29c, 0x1ef, 0x347,
0x0ee, 0x2a9, 0x1cf, 0x375, 0x0a1, 0x214, 0x305, 0x159, 0x3a4, 0x05b, 0x151, 0x394, 0x06b, 0x196, 0x3e6, 0x019, 0x0ab,
0x222, 0x2ea, 0x17c, 0x3e5, 0x01a, 0x0af, 0x227, 0x2e1, 0x187, 0x3ff, 0x000, 0x074, 0x1c8, 0x37f, 0x092, 0x1f8, 0x331,
0x110, 0x2f6, 0x16a, 0x3c1, 0x03e, 0x104, 0x2d5, 0x195, 0x3e7, 0x018, 0x0aa, 0x221, 0x2eb, 0x17b, 0x3e4, 0x01b, 0x0b0,
0x229, 0x2dd, 0x18d, 0x3f8, 0x007, 0x07b, 0x1d2, 0x371, 0x0a6, 0x21b, 0x2f5, 0x16b, 0x3c2, 0x03d, 0x103, 0x2d4, 0x197,
0x3e3, 0x01c, 0x0b1, 0x22b, 0x2db, 0x18f, 0x3f5, 0x00a, 0x07e, 0x1d5, 0x36e, 0x0a9, 0x21f, 0x2ed, 0x177, 0x3d8, 0x027,
0x0c3, 0x24d, 0x29e, 0x1ed, 0x349, 0x0eb, 0x2a0, 0x1eb, 0x34b, 0x0e8, 0x29a, 0x1f4, 0x339, 0x106, 0x2de, 0x18a, 0x3fb,
0x004, 0x078, 0x1cd, 0x379, 0x09a, 0x203, 0x320, 0x129, 0x330, 0x111, 0x2f7, 0x169, 0x3c0, 0x03f, 0x105, 0x2d6, 0x194,
0x3e8, 0x017, 0x0a3, 0x216,
];

View File

@ -1,67 +1,66 @@
import { Decrypt as RawDecrypt } from '@/decrypt/raw'; import {Decrypt as RawDecrypt} from "@/decrypt/raw";
import { DecryptResult } from '@/decrypt/entity'; import {DecryptResult} from "@/decrypt/entity";
import { AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile } from '@/decrypt/utils'; import {AudioMimeType, BytesHasPrefix, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile} from "@/decrypt/utils.ts";
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; import {parseBlob as metaParseBlob} from "music-metadata-browser";
const MagicHeader = [0x69, 0x66, 0x6d, 0x74]; const MagicHeader = [0x69, 0x66, 0x6D, 0x74]
const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe]; const MagicHeader2 = [0xfe, 0xfe, 0xfe, 0xfe]
const FileTypeMap: { [key: string]: string } = { const FileTypeMap: { [key: string]: string } = {
' WAV': '.wav', " WAV": ".wav",
FLAC: '.flac', "FLAC": ".flac",
' MP3': '.mp3', " MP3": ".mp3",
' A4M': '.m4a', " A4M": ".m4a",
}; }
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> { export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
const oriData = new Uint8Array(await GetArrayBuffer(file)); const oriData = new Uint8Array(await GetArrayBuffer(file));
if (!BytesHasPrefix(oriData, MagicHeader) || !BytesHasPrefix(oriData.slice(8, 12), MagicHeader2)) { if (!BytesHasPrefix(oriData, MagicHeader) || !BytesHasPrefix(oriData.slice(8, 12), MagicHeader2)) {
if (raw_ext === 'xm') { if (raw_ext === "xm") {
throw Error('此xm文件已损坏'); throw Error("此xm文件已损坏")
} else { } else {
return await RawDecrypt(file, raw_filename, raw_ext, true); return await RawDecrypt(file, raw_filename, raw_ext, true)
}
} }
}
let typeText = new TextDecoder().decode(oriData.slice(4, 8)); let typeText = (new TextDecoder()).decode(oriData.slice(4, 8))
if (!FileTypeMap.hasOwnProperty(typeText)) { if (!FileTypeMap.hasOwnProperty(typeText)) {
throw Error('未知的.xm文件类型'); throw Error("未知的.xm文件类型")
} }
let key = oriData[0xf]; let key = oriData[0xf]
let dataOffset = oriData[0xc] | (oriData[0xd] << 8) | (oriData[0xe] << 16); let dataOffset = oriData[0xc] | oriData[0xd] << 8 | oriData[0xe] << 16
let audioData = oriData.slice(0x10); let audioData = oriData.slice(0x10);
let lenAudioData = audioData.length; let lenAudioData = audioData.length;
for (let cur = dataOffset; cur < lenAudioData; ++cur) audioData[cur] = (audioData[cur] - key) ^ 0xff; for (let cur = dataOffset; cur < lenAudioData; ++cur)
audioData[cur] = (audioData[cur] - key) ^ 0xff;
const ext = FileTypeMap[typeText]; const ext = FileTypeMap[typeText];
const mime = AudioMimeType[ext]; const mime = AudioMimeType[ext];
let musicBlob = new Blob([audioData], { type: mime }); let musicBlob = new Blob([audioData], {type: mime});
const musicMeta = await metaParseBlob(musicBlob); const musicMeta = await metaParseBlob(musicBlob);
if (ext === 'wav') { if (ext === "wav") {
//todo:未知的编码方式 //todo:未知的编码方式
console.info(musicMeta.common); console.info(musicMeta.common)
musicMeta.common.album = ''; musicMeta.common.album = "";
musicMeta.common.artist = ''; musicMeta.common.artist = "";
musicMeta.common.title = ''; musicMeta.common.title = "";
} }
const { title, artist } = GetMetaFromFile( const {title, artist} = GetMetaFromFile(raw_filename,
raw_filename, musicMeta.common.title, musicMeta.common.artist,
musicMeta.common.title, raw_filename.indexOf("_") === -1 ? "-" : "_")
String(musicMeta.common.artists || musicMeta.common.artist || ""),
raw_filename.indexOf('_') === -1 ? '-' : '_',
);
return { return {
title, title,
artist, artist,
ext, ext,
mime, mime,
album: musicMeta.common.album, album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta), picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob), file: URL.createObjectURL(musicBlob),
blob: musicBlob, blob: musicBlob,
rawExt: 'xm', rawExt: "xm"
}; }
} }

View File

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

25
src/fix-compatibility.js Normal file
View File

@ -0,0 +1,25 @@
//TODO: Use other method to fix this
// !! Only Temporary Solution
// it seems like that @babel/plugin-proposal-object-rest-spread not working
// to fix up the compatibility for Edge 18 and some older Chromium
// now manually edit the dependency files
const fs = require('fs');
const filePath = "./node_modules/file-type/core.js";
const regReplace = /{\s*([a-zA-Z0-9:,\s]*),\s*\.\.\.([a-zA-Z0-9]*)\s*};/m;
if (fs.existsSync(filePath)) {
console.log("File Found!");
let data = fs.readFileSync(filePath).toString();
const regResult = regReplace.exec(data);
if (regResult != null) {
data = data.replace(regResult[0],
"Object.assign({ " + regResult[1] + " }, " + regResult[2] + ");"
);
fs.writeFileSync(filePath, data);
console.log("Object rest spread in file-type fixed!");
} else {
console.log("No fix needed.");
}
} else {
console.log("File Not Found!");
}

View File

@ -1,39 +1,31 @@
import Vue from 'vue'; import Vue from 'vue'
import App from '@/App.vue'; import App from '@/App.vue'
import '@/registerServiceWorker'; import '@/registerServiceWorker'
import { import {
Button, Button,
Checkbox, Checkbox,
Col, Col,
Container, Container,
Dialog, Footer,
Form, Icon,
FormItem, Image,
Footer, Link,
Icon, Main,
Image, Notification,
Input, Progress,
Link, Radio,
Main, Row,
Notification, Table,
Progress, TableColumn,
Radio, Tooltip,
Row, Upload,
Table, MessageBox
TableColumn,
Tooltip,
Upload,
MessageBox,
} from 'element-ui'; } from 'element-ui';
import 'element-ui/lib/theme-chalk/base.css'; import 'element-ui/lib/theme-chalk/base.css';
Vue.use(Link); Vue.use(Link);
Vue.use(Image); Vue.use(Image);
Vue.use(Button); Vue.use(Button);
Vue.use(Dialog);
Vue.use(Form);
Vue.use(FormItem);
Vue.use(Input);
Vue.use(Table); Vue.use(Table);
Vue.use(TableColumn); Vue.use(TableColumn);
Vue.use(Main); Vue.use(Main);
@ -52,5 +44,5 @@ Vue.prototype.$confirm = MessageBox.confirm;
Vue.config.productionTip = false; Vue.config.productionTip = false;
new Vue({ new Vue({
render: (h) => h(App), render: h => h(App),
}).$mount('#app'); }).$mount('#app');

View File

@ -1,30 +1,31 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import { register } from 'register-service-worker'; import {register} from 'register-service-worker'
if (process.env.NODE_ENV === 'production' && window.location.protocol === 'https:') { if (process.env.NODE_ENV === 'production' && window.location.protocol === "https:") {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() { register(`${process.env.BASE_URL}service-worker.js`, {
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.')
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.'); updated() {
window.location.reload(); console.log('New content is available.');
}, window.location.reload();
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,5 +1,7 @@
/* /*
* 样式 - 暗黑模式 * name: 样式 - 夜间模式
* author: @KyleBing
* date: 2020-11-24
*/ */
@media (prefers-color-scheme: dark) { @media (prefers-color-scheme: dark) {
@ -10,57 +12,155 @@
background-color: $dark-bg; background-color: $dark-bg;
} }
// 编辑歌曲信息 // FORM
.music-cover{ .el-radio{
i{ &__label{
&:hover{
color: $color-checkbox;
}
}
.el-image{
border: 1px solid $dark-border;
}
}
.edit-item{
.label{
}
.value{
}
.input{
input{
background-color: transparent !important;
border-bottom: 1px solid $dark-border;
}
}
i{
&:hover{
color: $color-checkbox;
}
}
}
// footer
#app-footer {
a {
color: lighten($text-comment, 5%);
&:hover{
color: $color-link;
}
}
}
// 自定义样式
// 首页弹窗提示信息的 更新信息 面板
.update-info{
border: 1px solid $dark-btn-bg !important;
.update-title{
color: $dark-text-main; color: $dark-text-main;
background-color: $dark-btn-bg !important;
} }
.update-content{ &__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; color: $dark-text-info;
padding: 10px;
} }
} }
} }

View File

@ -1,291 +0,0 @@
$color-checkbox: $blue;
$color-border-el: #DCDFE6;
$btn-radius: 6px;
/* FORM */
// checkbox
.el-checkbox.is-bordered{
@include border-radius($btn-radius) ;
&:hover{
border-color: $color-checkbox;
.el-checkbox__label{
color: $color-checkbox;
}
}
.el-checkbox__input.is-focus{
.el-checkbox__inner{
border-color: $color-border-el;
}
}
&.is-checked{
background-color: $color-checkbox;
.el-checkbox__label{
color: white;
}
.el-checkbox__inner{
border-color: white;
background-color: white;
&:after{
border-color: $color-checkbox;
}
}
}
}
// el-button
.el-button{
@include border-radius($btn-radius) ;
}
// upload
.el-upload-dragger{
&:hover{
background-color: transparentize($color-checkbox, 0.9);
}
}
.el-upload__tip{
text-align: center;
color: $text-comment;
}
// dialog
.el-dialog{
@include border-radius(5px);
&.el-dialog--center{
.el-dialog__body{
padding: 25px 25px 15px;
}
.el-dialog__footer{
padding: 10px 20px 30px;
}
}
}
@media (prefers-color-scheme: dark) {
// FORM
.el-radio{
&__label{
color: $dark-text-main;
}
&__input{
color: $dark-text-info;
.el-radio__inner{
border-color: $dark-border;
background-color: $dark-btn-bg;
}
}
&.is-checked{
.el-radio__inner{
background-color: $blue;
}
.el-radio__label{
font-weight: bold;
}
}
}
.el-checkbox.is-bordered{
border-color: $dark-border;
color: $dark-text-main;
background-color: $dark-btn-bg;
.el-checkbox__inner{
background-color: $dark-btn-bg-highlight;
border-color: $dark-border-highlight;
}
&:hover{
border-color: $dark-border-highlight;
.el-checkbox__inner{
background-color: $dark-btn-bg-highlight;
border-color: $dark-border-highlight;
}
.el-checkbox__label{
color: $dark-text-info;
}
}
&.is-checked{
background-color: $blue;
.el-checkbox__inner{
border-color: white;
}
.el-checkbox__label{
color: white;
font-weight: bold;
}
&:hover{
border-color: $blue;
.el-checkbox__inner{
background-color: white;
}
}
}
}
// BUTTON
.el-button{
background-color: $dark-btn-bg;
border-color: $dark-border;
color: $dark-text-main;
&:active{
transform: translateY(2px);
}
&--default{
&.is-plain {
background-color: $dark-btn-bg;
&:hover {
background-color: $blue;
border-color: $blue;
color: white;
}
}
&.is-circle {
background-color: $dark-blue;
border-color: $dark-blue;
&:hover {
background-color: $blue;
border-color: $blue;
color: white;
}
}
}
&--success{
&.is-plain {
background-color: $dark-btn-bg;
&:hover {
background-color: $green;
border-color: $green;
color: white;
}
}
&.is-circle {
background-color: $dark-green;
border-color: $dark-green;
&:hover {
background-color: $green;
border-color: $green;
color: white;
}
}
}
&--danger{
&.is-plain{
border-color: $dark-border;
background-color: $dark-btn-bg;
&:hover{
background-color: $red;
border-color: $red;
}
}
&.is-circle {
background-color: $dark-red;
border-color: $dark-red;
&:hover {
background-color: $red;
border-color: $red;
color: white;
}
}
}
}
// 文件拖放区
.el-upload__tip{
color: $dark-text-info;
}
.el-upload-dragger{
background-color: $dark-uploader-bg;
border-color: $dark-border;
.el-upload__text{
color: $dark-text-info;
}
&:hover{
background: $dark-uploader-bg-highlight;
border-color: $dark-border-highlight;
}
}
// TABLE
.el-table{
background-color: $dark-bg-td;
&:before{ // 去除表格末尾的横线
content: none;
}
&__header{
th{
border-bottom-color: $dark-border !important;
}
}
th.el-table__cell{
background-color: $dark-bg-th;
color: $dark-text-info;
}
td{
border-bottom-color: $dark-border !important;
}
tr{
background-color: $dark-bg-td;
color: $dark-text-main;
&:hover{
td{
background-color: $dark-bg-th !important;
}
}
}
}
// ALERT
.el-notification{
background-color: $dark-btn-bg-highlight;
border-color: $dark-border;
&__title{
color: white;
}
&__content{
color: $dark-text-info;
}
}
// DIALOG
.el-dialog{
background-color: $dark-dialog-bg;
.el-dialog__header{
.el-dialog__title{
color: $dark-text-main;
}
}
.el-dialog__body{
color: $dark-text-main;
.el-input{
.el-input__inner{
border-color: $dark-border;
color: $dark-text-main;
background-color: $dark-btn-bg;
}
.el-input__suffix{
.el-input__suffix-inner{
}
}
.el-input__count{
.el-input__count-inner{
background-color: transparent;
}
}
}
}
.item-desc{
color: $dark-text-info;
}
}
}

View File

@ -3,7 +3,7 @@
*/ */
$gap: 5px; $gap: 5px;
@for $item from 0 through 8 { @for $item from 1 through 7 {
.mt-#{$item} { margin-top : $gap * $item !important;} .mt-#{$item} { margin-top : $gap * $item !important;}
.mb-#{$item} { margin-bottom : $gap * $item !important;} .mb-#{$item} { margin-bottom : $gap * $item !important;}
.ml-#{$item} { margin-left : $gap * $item !important;} .ml-#{$item} { margin-left : $gap * $item !important;}
@ -15,4 +15,4 @@ $gap: 5px;
.pl-#{$item} { padding-left : $gap * $item !important;} .pl-#{$item} { padding-left : $gap * $item !important;}
.pr-#{$item} { padding-right : $gap * $item !important;} .pr-#{$item} { padding-right : $gap * $item !important;}
.p-#{$item} { padding : $gap * $item !important;} .p-#{$item} { padding : $gap * $item !important;}
} }

38
src/scss/_normal.scss Normal file
View File

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

View File

@ -1,73 +0,0 @@
// box-shadow
@mixin box-shadow($value...){
-webkit-box-shadow: $value;
-moz-box-shadow: $value;
box-shadow: $value;
}
// border-radius
@mixin border-radius($corner...){
-webkit-border-radius: $corner;
-moz-border-radius: $corner;
border-radius: $corner;
}
@mixin clearfix(){
&:after{
content: '';
display: block;
clear: both;
visibility: hidden;
}
}
@mixin transform($value){
-webkit-transform: $value;
-moz-transform: $value;
-ms-transform: $value;
-o-transform: $value;
transform: $value;
}
@mixin transition($value...){
-webkit-transition: $value;
-moz-transition: $value;
-ms-transition: $value;
-o-transition: $value;
transition: $value;
}
@mixin animation($value){
animation: $value;
-webkit-animation: $value;
}
@mixin linear-gradient($direct, $colors){
background: linear-gradient($direct, $colors);
background: -webkit-linear-gradient($direct, $colors);
background: -moz-linear-gradient($direct, $colors);
}
@mixin backdrop-filter($value){
backdrop-filter: $value ;
-webkit-backdrop-filter: $value;
}
/*
Extension
*/
.unselectable {
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
}
.btn-like{
cursor: pointer;
&:active{
@include transform(translateY(2px))
}
}

View File

@ -3,16 +3,15 @@ $blue : #409EFF;
$red : #F56C6C; $red : #F56C6C;
$green : #85ce61; $green : #85ce61;
// TEXT COLOR // TEXT
$text-main : #2C3E50; $text-main : #2C3E50;
$text-copyright : #777; $color-link: $blue;
$text-comment : #999;
$color-link : $blue;
// FONT SIZE
$fz-main: 14px; $fz-main: 14px;
$fz-mini-title: 13px;
$fz-mini-content: 12px; $font-family: "Helvetica Neue", Helvetica, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
// DARK MODE // DARK MODE
$dark-border : lighten(black, 25%); $dark-border : lighten(black, 25%);
@ -21,14 +20,9 @@ $dark-bg : lighten(black, 10%);
$dark-text-main : lighten(black, 90%); $dark-text-main : lighten(black, 90%);
$dark-text-info : lighten(black, 60%); $dark-text-info : lighten(black, 60%);
$dark-uploader-bg : lighten(black, 13%); $dark-uploader-bg : lighten(black, 13%);
$dark-dialog-bg : lighten(black, 15%);
$dark-uploader-bg-highlight : lighten(black, 18%); $dark-uploader-bg-highlight : lighten(black, 18%);
$dark-btn-bg : lighten(black, 20%); $dark-btn-bg : lighten(black, 20%);
$dark-btn-bg-highlight : lighten(black, 30%); $dark-btn-bg-highlight : lighten(black, 30%);
$dark-bg-th : lighten(black, 18%); $dark-bg-th : lighten(black, 18%);
$dark-bg-td : lighten(black, 13%); $dark-bg-td : lighten(black, 13%);
$dark-color-link : $green; $dark-color-link : $green;
$dark-blue : darken(desaturate($blue, 40%), 30%);
$dark-red : darken(desaturate($red, 50%), 30%);
$dark-green : darken(desaturate($green, 30%), 30%);

View File

@ -1,144 +1,5 @@
@import "variables"; @import "variables";
@import "utility";
@import "gaps"; @import "gaps";
@import "element-ui-overwrite";
// MAIN CONTENT @import "normal";
body{ @import "dark-mode"; // dark-mode 放在 normal 后面以获得更高优先级
margin: 0;
padding: 0;
border: 0;
box-sizing: border-box;
font-family: "PingFang SC", "微软雅黑", "Helvetica Neue", Helvetica, "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif;
font-size: $fz-main;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#app {
text-align: center;
color: $text-main;
padding: 30px;
}
// 音频文件操作
#app-control {
margin-top: 20px;
}
// 音频播放
audio{
margin-top: 20px;
}
.table-content{
margin-top: 20px;
}
// 编辑歌曲信息
.music-cover{
margin-bottom: 20px;
display: flex;
justify-content: center;
align-items: center;
flex-flow: column nowrap;
i{
margin-top: 10px;
@extend .btn-like;
&:hover{
color: $color-checkbox;
}
}
.el-image{
padding: 5px;
@include border-radius(5px);
border: 1px solid $color-border-el;
width: 150px;
height: 150px;
}
}
.edit-item{
display: flex;
justify-content: flex-start;
align-items: center;
.label{
font-weight: bold;
width: 80px;
text-align: right;
flex-shrink: 0;
}
.value{
padding: 5px 0;
height: 20px;
line-height: 20px;
margin-left: 10px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.input{
margin-left: 10px;
input{
font-family: inherit;
height: 30px;
line-height: 20px;
@include border-radius(0);
border: none;
border-bottom: 1px solid $color-border-el;
padding: 5px 5px;
}
}
i{
margin-left: 10px;
@extend .btn-like;
&:hover{
color: $color-checkbox;
}
}
}
.tip{
margin-top: 20px;
color: $text-comment;
font-size: $fz-mini-content;
a{
color: inherit;
}
}
// footer
#app-footer {
margin-top: 40px;
text-align: center;
color: $text-copyright;
line-height: 1.3;
font-size: $fz-mini-content;
a {
padding-left: 0.2rem;
padding-right: 0.2rem;
color: darken($text-copyright, 10%);
&:hover{
color: $color-link;
}
}
}
// 首页弹窗提示信息的 更新信息 面板
.update-info{
@include border-radius(8px);
overflow: hidden;
border: 1px solid $color-border-el;
margin: 10px 0;
.update-title{
font-size: $fz-mini-title;
padding: 3px 10px;
background-color: $color-border-el;
}
.update-content{
font-size: $fz-mini-content;
line-height: 1.5;
padding: 5px 8px;
}
}
@import "dark-mode"; // dark-mode 放在 normal 后面以获得更高优先级

View File

@ -1,23 +1,25 @@
declare module 'browser-id3-writer' { declare module "browser-id3-writer" {
export default class ID3Writer { export default class ID3Writer {
constructor(buffer: Buffer | ArrayBuffer); constructor(buffer: Buffer | ArrayBuffer)
setFrame(name: string, value: string | object | string[]); setFrame(name: string, value: string | object | string[])
addTag(): Uint8Array; addTag(): Uint8Array
} }
} }
declare module 'metaflac-js' { declare module "metaflac-js" {
export default class Metaflac { export default class Metaflac {
constructor(buffer: Buffer); constructor(buffer: Buffer)
setTag(field: string); setTag(field: string)
removeTag(name: string); removeTag(name: string)
importPictureFromBuffer(picture: Buffer); importPictureFromBuffer(picture: Buffer)
save(): Buffer; save(): Buffer
} }
} }

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

@ -1,54 +1,58 @@
export interface FileSystemGetFileOptions { export interface FileSystemGetFileOptions {
create?: boolean; create?: boolean
} }
interface FileSystemCreateWritableOptions { interface FileSystemCreateWritableOptions {
keepExistingData?: boolean; keepExistingData?: boolean
} }
interface FileSystemRemoveOptions { interface FileSystemRemoveOptions {
recursive?: boolean; recursive?: boolean
} }
interface FileSystemFileHandle { interface FileSystemFileHandle {
getFile(): Promise<File>; getFile(): Promise<File>;
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>; createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>
} }
enum WriteCommandType { enum WriteCommandType {
write = 'write', write = "write",
seek = 'seek', seek = "seek",
truncate = 'truncate', truncate = "truncate",
} }
interface WriteParams { interface WriteParams {
type: WriteCommandType; type: WriteCommandType
size?: number; size?: number
position?: number; position?: number
data: BufferSource | Blob | string; data: BufferSource | Blob | string
} }
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams; type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams
interface FileSystemWritableFileStream extends WritableStream { interface FileSystemWritableFileStream extends WritableStream {
write(data: FileSystemWriteChunkType): Promise<undefined>; write(data: FileSystemWriteChunkType): Promise<undefined>
seek(position: number): Promise<undefined>; seek(position: number): Promise<undefined>
truncate(size: number): Promise<undefined>; truncate(size: number): Promise<undefined>
close(): Promise<undefined>; // should be implemented in WritableStream close(): Promise<undefined> // should be implemented in WritableStream
} }
export declare interface FileSystemDirectoryHandle {
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>;
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>; export declare interface FileSystemDirectoryHandle {
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>
} }
declare global { declare global {
interface Window { interface Window {
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>;
} showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>
}
} }

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

@ -1,15 +1,17 @@
import Vue, { VNode } from 'vue'; import Vue, {VNode} from 'vue'
declare global { declare global {
namespace JSX { namespace JSX {
// tslint:disable no-empty-interface // tslint:disable no-empty-interface
interface Element extends VNode {} interface Element extends VNode {
}
// tslint:disable no-empty-interface // tslint:disable no-empty-interface
interface ElementClass extends Vue {} interface ElementClass extends Vue {
}
interface IntrinsicElements { interface IntrinsicElements {
[elem: string]: any; [elem: string]: any
} }
} }
} }

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

@ -1,4 +1,4 @@
declare module '*.vue' { declare module '*.vue' {
import Vue from 'vue'; import Vue from 'vue'
export default 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 +1,56 @@
export const IXAREA_API_ENDPOINT = 'https://um-api.ixarea.com'; import {fromByteArray as Base64Encode} from "base64-js";
export const IXAREA_API_ENDPOINT = "https://um-api.ixarea.com"
export interface UpdateInfo { export interface UpdateInfo {
Found: boolean; Found: boolean
HttpsFound: boolean; HttpsFound: boolean
Version: string; Version: string
URL: string; URL: string
Detail: string; Detail: string
} }
export async function checkUpdate(version: string): Promise<UpdateInfo> { export async function checkUpdate(version: string): Promise<UpdateInfo> {
const resp = await fetch(IXAREA_API_ENDPOINT + '/music/app-version', { const resp = await fetch(IXAREA_API_ENDPOINT + "/music/app-version", {
method: 'POST', method: "POST",
headers: { 'Content-Type': 'application/json' }, headers: {"Content-Type": "application/json"},
body: JSON.stringify({ Version: version }), body: JSON.stringify({"Version": version})
}); });
return await resp.json(); return await resp.json();
}
export function reportKeyUsage(keyData: Uint8Array, maskData: number[], filename: string, format: string, title: string, artist?: string, album?: string) {
return fetch(IXAREA_API_ENDPOINT + "/qmcmask/usage", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
Mask: Base64Encode(new Uint8Array(maskData)), Key: Base64Encode(keyData),
Artist: artist, Title: title, Album: album, Filename: filename, Format: format
}),
})
}
interface KeyInfo {
Matrix44: string
}
export async function queryKeyInfo(keyData: Uint8Array, filename: string, format: string): Promise<KeyInfo> {
const resp = await fetch(IXAREA_API_ENDPOINT + "/qmcmask/query", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({Format: format, Key: Base64Encode(keyData), Filename: filename, Type: 44}),
});
return await resp.json();
} }
export interface CoverInfo { export interface CoverInfo {
Id: string; Id: string
Type: number; Type: number
} }
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> { export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> {
const endpoint = IXAREA_API_ENDPOINT + '/music/qq-cover'; const endpoint = IXAREA_API_ENDPOINT + "/music/qq-cover"
const params = new URLSearchParams([ const params = new URLSearchParams([["Title", title], ["Artist", artist ?? ""], ["Album", album ?? ""]])
['Title', title], const resp = await fetch(`${endpoint}?${params.toString()}`)
['Artist', artist ?? ''], return await resp.json()
['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,156 +0,0 @@
import { IAudioMetadata, parseBlob as metaParseBlob } from 'music-metadata-browser';
import iconv from 'iconv-lite';
import {
GetCoverFromFile,
GetImageFromURL,
GetMetaFromFile,
WriteMetaToFlac,
WriteMetaToMp3,
AudioMimeType,
split_regex,
} from '@/decrypt/utils';
import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api';
interface MetaResult {
title: string;
artist: string;
album: string;
imgUrl: string;
blob: Blob;
}
const fromGBK = (text?: string) => iconv.decode(new Buffer(text || ''), 'gbk');
/**
*
* @param musicBlob
* @param name
* @param ext
* @param id ID<code>number</code>
* @returns Promise
*/
export async function extractQQMusicMeta(
musicBlob: Blob,
name: string,
ext: string,
id?: number | string,
): Promise<MetaResult> {
const musicMeta = await metaParseBlob(musicBlob);
for (let metaIdx in musicMeta.native) {
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue;
if (musicMeta.native[metaIdx].some((item) => item.id === 'TCON' && item.value === '(12)')) {
console.warn('try using gbk encoding to decode meta');
musicMeta.common.artist = '';
if (!musicMeta.common.artists) {
musicMeta.common.artist = fromGBK(musicMeta.common.artist);
}
else {
musicMeta.common.artist = musicMeta.common.artists.map(fromGBK).join();
}
musicMeta.common.title = fromGBK(musicMeta.common.title);
musicMeta.common.album = fromGBK(musicMeta.common.album);
}
}
if (id && id !== '0') {
try {
return await fetchMetadataFromSongId(id, ext, musicMeta, musicBlob);
} catch (e) {
console.warn('在线获取曲目信息失败,回退到本地 meta 提取', e);
}
}
const info = GetMetaFromFile(name, musicMeta.common.title, musicMeta.common.artist);
info.artist = info.artist || '';
let imageURL = GetCoverFromFile(musicMeta);
if (!imageURL) {
imageURL = await getCoverImage(info.title, info.artist, musicMeta.common.album);
}
return {
title: info.title,
artist: info.artist,
album: musicMeta.common.album || '',
imgUrl: imageURL,
blob: await writeMetaToAudioFile({
title: info.title,
artists: info.artist.split(split_regex),
ext,
imageURL,
musicMeta,
blob: musicBlob,
}),
};
}
async function fetchMetadataFromSongId(
id: number | string,
ext: string,
musicMeta: IAudioMetadata,
blob: Blob,
): Promise<MetaResult> {
const info = await querySongInfoById(id);
const imageURL = getQMImageURLFromPMID(info.track_info.album.pmid);
const artists = info.track_info.singer.map((singer) => singer.name);
return {
title: info.track_info.title,
artist: artists.join(','),
album: info.track_info.album.name,
imgUrl: imageURL,
blob: await writeMetaToAudioFile({
title: info.track_info.title,
artists,
ext,
imageURL,
musicMeta,
blob,
}),
};
}
async function getCoverImage(title: string, artist?: string, album?: string): Promise<string> {
try {
const data = await queryAlbumCover(title, artist, album);
return getQMImageURLFromPMID(data.Id, data.Type);
} catch (e) {
console.warn(e);
}
return '';
}
interface NewAudioMeta {
title: string;
artists: string[];
ext: string;
musicMeta: IAudioMetadata;
blob: Blob;
imageURL: string;
}
async function writeMetaToAudioFile(info: NewAudioMeta): Promise<Blob> {
try {
const imageInfo = await GetImageFromURL(info.imageURL);
if (!imageInfo) {
console.warn('获取图像失败');
}
const newMeta = { picture: imageInfo?.buffer, title: info.title, artists: info.artists };
const buffer = Buffer.from(await info.blob.arrayBuffer());
const mime = AudioMimeType[info.ext] || AudioMimeType.mp3;
if (info.ext === 'mp3') {
return new Blob([WriteMetaToMp3(buffer, newMeta, info.musicMeta)], { type: mime });
} else if (info.ext === 'flac') {
return new Blob([WriteMetaToFlac(buffer, newMeta, info.musicMeta)], { type: mime });
} else {
console.info('writing metadata for ' + info.ext + ' is not being supported for now');
}
} catch (e) {
console.warn('Error while appending cover image to file ' + e);
}
return info.blob;
}

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,80 +1,79 @@
import { DecryptResult } from '@/decrypt/entity'; import {DecryptResult} from "@/decrypt/entity";
import { FileSystemDirectoryHandle } from '@/shims-fs'; import {FileSystemDirectoryHandle} from "@/shims-fs";
export enum FilenamePolicy { export enum FilenamePolicy {
ArtistAndTitle, ArtistAndTitle,
TitleOnly, TitleOnly,
TitleAndArtist, TitleAndArtist,
SameAsOriginal, SameAsOriginal,
} }
export const FilenamePolicies: { key: FilenamePolicy; text: string }[] = [ export const FilenamePolicies: { key: FilenamePolicy, text: string }[] = [
{ key: FilenamePolicy.ArtistAndTitle, text: '歌手-歌曲名' }, {key: FilenamePolicy.ArtistAndTitle, text: "歌手-歌曲名"},
{ key: FilenamePolicy.TitleOnly, text: '歌曲名' }, {key: FilenamePolicy.TitleOnly, text: "歌曲名"},
{ key: FilenamePolicy.TitleAndArtist, text: '歌曲名-歌手' }, {key: FilenamePolicy.TitleAndArtist, text: "歌曲名-歌手"},
{ key: FilenamePolicy.SameAsOriginal, text: '同源文件名' }, {key: FilenamePolicy.SameAsOriginal, text: "同源文件名"},
]; ]
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string { export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string {
switch (policy) { switch (policy) {
case FilenamePolicy.TitleOnly: case FilenamePolicy.TitleOnly:
return `${data.title}.${data.ext}`; return `${data.title}.${data.ext}`;
case FilenamePolicy.TitleAndArtist: case FilenamePolicy.TitleAndArtist:
return `${data.title} - ${data.artist}.${data.ext}`; return `${data.title} - ${data.artist}.${data.ext}`;
case FilenamePolicy.SameAsOriginal: case FilenamePolicy.SameAsOriginal:
return `${data.rawFilename}.${data.ext}`; return `${data.rawFilename}.${data.ext}`;
default: default:
case FilenamePolicy.ArtistAndTitle: case FilenamePolicy.ArtistAndTitle:
return `${data.artist} - ${data.title}.${data.ext}`; return `${data.artist} - ${data.title}.${data.ext}`;
} }
} }
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) { export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) {
let filename = GetDownloadFilename(data, policy); let filename = GetDownloadFilename(data, policy)
// prevent filename exist // prevent filename exist
try { try {
await dir.getFileHandle(filename); await dir.getFileHandle(filename)
filename = `${new Date().getTime()} - ${filename}`; filename = `${new Date().getTime()} - ${filename}`
} catch (e) {} } catch (e) {
const file = await dir.getFileHandle(filename, { create: true }); }
const w = await file.createWritable(); const file = await dir.getFileHandle(filename, {create: true})
await w.write(data.blob); const w = await file.createWritable()
await w.close(); await w.write(data.blob)
await w.close()
} }
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) { export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
const a = document.createElement('a'); const a = document.createElement('a');
a.href = data.file; a.href = data.file;
a.download = GetDownloadFilename(data, policy); a.download = GetDownloadFilename(data, policy)
document.body.append(a); document.body.append(a);
a.click(); a.click();
a.remove(); a.remove();
} }
export function RemoveBlobMusic(data: DecryptResult) { export function RemoveBlobMusic(data: DecryptResult) {
URL.revokeObjectURL(data.file); URL.revokeObjectURL(data.file);
if (data.picture?.startsWith('blob:')) { if (data.picture?.startsWith("blob:")) {
URL.revokeObjectURL(data.picture); URL.revokeObjectURL(data.picture);
} }
} }
export class DecryptQueue { export class DecryptQueue {
private readonly pending: (() => Promise<void>)[]; private readonly pending: (() => Promise<void>)[];
constructor() { constructor() {
this.pending = []; this.pending = []
} }
queue(fn: () => Promise<void>) { queue(fn: () => Promise<void>) {
this.pending.push(fn); this.pending.push(fn)
this.consume(); this.consume()
} }
private consume() { private consume() {
const fn = this.pending.shift(); const fn = this.pending.shift()
if (fn) if (fn) fn().then(() => this.consume).catch(console.error)
fn() }
.then(() => this.consume)
.catch(console.error);
}
} }

View File

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

View File

@ -1,264 +1,157 @@
<template> <template>
<div> <div>
<file-selector @error="showFail" @success="showSuccess" /> <file-selector @error="showFail" @success="showSuccess"/>
<div id="app-control"> <div id="app-control">
<el-row class="mb-3"> <el-row class="mb-3">
<span>歌曲命名格式</span> <span>歌曲命名格式</span>
<el-radio v-for="k in FilenamePolicies" :key="k.key" v-model="filename_policy" :label="k.key"> <el-radio v-for="k in FilenamePolicies" :key="k.key"
{{ k.text }} v-model="filename_policy" :label="k.key">
</el-radio> {{ k.text }}
</el-row> </el-radio>
<el-row> </el-row>
<edit-dialog <el-row>
:show="showEditDialog" <el-button icon="el-icon-download" plain @click="handleDownloadAll">下载全部</el-button>
:picture="editing_data.picture" <el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button>
:title="editing_data.title"
:artist="editing_data.artist"
:album="editing_data.album"
:albumartist="editing_data.albumartist"
:genre="editing_data.genre"
@cancel="showEditDialog = false"
@ok="handleEdit"
></edit-dialog>
<config-dialog :show="showConfigDialog" @done="showConfigDialog = false"></config-dialog>
<el-tooltip class="item" effect="dark" placement="top">
<div slot="content">
<span> 部分解密方案需要设定解密参数 </span>
</div>
<el-button icon="el-icon-s-tools" plain @click="showConfigDialog = true">解密设定</el-button>
</el-tooltip>
<el-button icon="el-icon-download" plain @click="handleDownloadAll">下载全部</el-button>
<el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button>
<el-tooltip class="item" effect="dark" placement="top-start"> <el-tooltip class="item" effect="dark" placement="top-start">
<div slot="content"> <div slot="content">
<span v-if="instant_save">工作模式: {{ dir ? '写入本地文件系统' : '调用浏览器下载' }}</span> <span v-if="instant_save">工作模式: {{ dir ? "写入本地文件系统" : "调用浏览器下载" }}</span>
<span v-else> <span v-else>
当您使用此工具进行大量文件解锁的时候建议开启此选项<br /> 当您使用此工具进行大量文件解锁的时候建议开启此选项<br/>
开启后解锁结果将不会存留于浏览器中防止内存不足 开启后解锁结果将不会存留于浏览器中防止内存不足
</span> </span>
</div> </div>
<el-checkbox v-model="instant_save" type="success" border class="ml-2">立即保存</el-checkbox> <el-checkbox v-model="instant_save" border class="ml-2">立即保存</el-checkbox>
</el-tooltip> </el-tooltip>
</el-row> </el-row>
</div>
<audio :autoplay="playing_auto" :src="playing_url" controls/>
<PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @play="changePlaying"/>
</div> </div>
<audio :autoplay="playing_auto" :src="playing_url" controls />
<PreviewTable
class="table-content"
:policy="filename_policy"
:table-data="tableData"
@download="saveFile"
@edit="editFile"
@play="changePlaying" />
</div>
</template> </template>
<script> <script>
import FileSelector from '@/component/FileSelector';
import PreviewTable from '@/component/PreviewTable';
import ConfigDialog from '@/component/ConfigDialog';
import EditDialog from '@/component/EditDialog';
import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils'; import FileSelector from "@/component/FileSelector"
import { GetImageFromURL, RewriteMetaToMp3, RewriteMetaToFlac, AudioMimeType, split_regex } from '@/decrypt/utils'; import PreviewTable from "@/component/PreviewTable"
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; import {DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile} from "@/utils/utils"
export default { export default {
name: 'Home', name: 'Home',
components: { components: {
FileSelector, FileSelector,
PreviewTable, PreviewTable
ConfigDialog,
EditDialog,
},
data() {
return {
showConfigDialog: false,
showEditDialog: false,
editing_data: { picture: '', title: '', artist: '', album: '', albumartist: '', genre: '' },
tableData: [],
playing_url: '',
playing_auto: false,
filename_policy: FilenamePolicy.ArtistAndTitle,
instant_save: false,
FilenamePolicies,
dir: null,
};
},
watch: {
instant_save(val) {
if (val) this.showDirectlySave();
}, },
}, data() {
methods: { return {
async showSuccess(data) { tableData: [],
if (this.instant_save) { playing_url: "",
await this.saveFile(data); playing_auto: false,
RemoveBlobMusic(data); filename_policy: FilenamePolicy.ArtistAndTitle,
} else { instant_save: false,
this.tableData.push(data); FilenamePolicies,
this.$notify.success({ dir: null
title: '解锁成功',
message: '成功解锁 ' + data.title,
duration: 3000,
});
}
if (process.env.NODE_ENV === 'production') {
let _rp_data = [data.title, data.artist, data.album];
window._paq.push(['trackEvent', 'Unlock', data.rawExt + ',' + data.mime, JSON.stringify(_rp_data)]);
}
},
showFail(errInfo, filename) {
console.error(errInfo, filename);
this.$notify.error({
title: '出现问题',
message:
errInfo +
'' +
filename +
',参考<a target="_blank" href="https://git.unlock-music.dev/um/web/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true,
duration: 6000,
});
if (process.env.NODE_ENV === 'production') {
window._paq.push(['trackEvent', 'Error', String(errInfo), filename]);
}
},
changePlaying(url) {
this.playing_url = url;
this.playing_auto = true;
},
handleDeleteAll() {
this.tableData.forEach((value) => {
RemoveBlobMusic(value);
});
this.tableData = [];
},
handleDecryptionConfig() {
this.showConfigDialog = true;
},
handleDownloadAll() {
let index = 0;
let c = setInterval(() => {
if (index < this.tableData.length) {
this.saveFile(this.tableData[index]);
index++;
} else {
clearInterval(c);
} }
}, 300);
}, },
async handleEdit(data) { watch: {
this.showEditDialog = false; instant_save(val) {
URL.revokeObjectURL(this.editing_data.file); if (val) this.showDirectlySave()
if (data.picture) {
URL.revokeObjectURL(this.editing_data.picture);
this.editing_data.picture = URL.createObjectURL(data.picture);
}
this.editing_data.title = data.title;
this.editing_data.artist = data.artist;
this.editing_data.album = data.album;
let writeSuccess = true;
let notifyMsg = '成功修改 ' + this.editing_data.title;
try {
const musicMeta = await metaParseBlob(new Blob([this.editing_data.blob], { type: mime }));
let imageInfo = undefined;
if (this.editing_data.picture !== '') {
imageInfo = await GetImageFromURL(this.editing_data.picture);
if (!imageInfo) {
console.warn('获取图像失败', this.editing_data.picture);
}
} }
const newMeta = {
picture: imageInfo?.buffer,
title: data.title,
artists: data.artist.split(split_regex),
album: data.album,
albumartist: data.albumartist,
genre: data.genre.split(split_regex),
};
const buffer = Buffer.from(await this.editing_data.blob.arrayBuffer());
const mime = AudioMimeType[this.editing_data.ext] || AudioMimeType.mp3;
if (this.editing_data.ext === 'mp3') {
this.editing_data.blob = new Blob([RewriteMetaToMp3(buffer, newMeta, musicMeta)], { type: mime });
} else if (this.editing_data.ext === 'flac') {
this.editing_data.blob = new Blob([RewriteMetaToFlac(buffer, newMeta, musicMeta)], { type: mime });
} else {
writeSuccess = undefined;
notifyMsg = this.editing_data.ext + '类型文件暂时不支持修改音乐标签';
}
} catch (e) {
writeSuccess = false;
notifyMsg = '修改' + this.editing_data.title + '未能完成。在写入新的元数据时发生错误:' + e;
}
this.editing_data.file = URL.createObjectURL(this.editing_data.blob);
if (writeSuccess === true) {
this.$notify.success({
title: '修改成功',
message: notifyMsg,
duration: 3000,
});
} else if (writeSuccess === false) {
this.$notify.error({
title: '修改失败',
message: notifyMsg,
duration: 3000,
});
} else {
this.$notify.warning({
title: '修改取消',
message: notifyMsg,
duration: 3000,
});
}
}, },
methods: {
async showSuccess(data) {
if (this.instant_save) {
await this.saveFile(data)
RemoveBlobMusic(data);
} else {
this.tableData.push(data);
this.$notify.success({
title: '解锁成功',
message: '成功解锁 ' + data.title,
duration: 3000
});
}
if (process.env.NODE_ENV === 'production') {
let _rp_data = [data.title, data.artist, data.album];
window._paq.push(["trackEvent", "Unlock", data.rawExt + "," + data.mime, JSON.stringify(_rp_data)]);
}
},
showFail(errInfo, filename) {
console.error(errInfo, filename)
this.$notify.error({
title: '出现问题',
message: errInfo + "" + filename +
',参考<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true,
duration: 6000
});
if (process.env.NODE_ENV === 'production') {
window._paq.push(["trackEvent", "Error", String(errInfo), filename]);
}
},
changePlaying(url) {
this.playing_url = url;
this.playing_auto = true;
},
handleDeleteAll() {
this.tableData.forEach(value => {
RemoveBlobMusic(value);
});
this.tableData = [];
},
handleDownloadAll() {
let index = 0;
let c = setInterval(() => {
if (index < this.tableData.length) {
this.saveFile(this.tableData[index])
index++;
} else {
clearInterval(c);
}
}, 300);
},
async editFile(data) { async saveFile(data) {
this.editing_data = data; if (this.dir) {
const musicMeta = await metaParseBlob(this.editing_data.blob); await DirectlyWriteFile(data, this.filename_policy, this.dir)
this.editing_data.albumartist = musicMeta.common.albumartist || ''; this.$notify({
this.editing_data.genre = musicMeta.common.genre?.toString() || ''; title: "保存成功",
this.showEditDialog = true; 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)
}
}
}, },
async saveFile(data) { }
if (this.dir) {
await DirectlyWriteFile(data, this.filename_policy, this.dir);
this.$notify({
title: '保存成功',
message: data.title,
position: 'top-left',
type: 'success',
duration: 3000,
});
} else {
DownloadBlobMusic(data, this.filename_policy);
}
},
async showDirectlySave() {
if (!window.showDirectoryPicker) return;
try {
await this.$confirm('您的浏览器支持文件直接保存到磁盘,是否使用?', '新特性提示', {
confirmButtonText: '使用',
cancelButtonText: '不使用',
type: 'warning',
center: true,
});
} catch (e) {
console.log(e);
return;
}
try {
this.dir = await window.showDirectoryPicker();
const test_filename = '__unlock_music_write_test.txt';
await this.dir.getFileHandle(test_filename, { create: true });
await this.dir.removeEntry(test_filename);
} catch (e) {
console.error(e);
}
},
},
};
</script> </script>

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.

View File

@ -1 +0,0 @@
pUtyvqr0TgAvR95mNmY7DmNl386TsJNAEIz95CEcgIgJCcs28686O7llxD5E74ldn70xMtd5cG58TA5ILw09I8BOTf5EdHKd6wwPn689DUK13y3Req6H0P33my2miJ5bQ2AA22B8vp4V0NJ3hBqNtFf7cId48V6W51e1kwgu1xKKawxe9BByT92MFlqrFaKH32dB2zFgyd38l2P1outr4l2XLq48F9G17ptRz4W8Loxu28RvZgv0BzL26Ht9I2L5VCwMzzt7OeZ55iQs40Tr6k81QGraIUJj5zeBMgJRMTaSgi19hU5x5a08Qd662MbFhZZ0FjVvaDy1nbIDhrC62c1lX6wf70O45h4W42VxloBVeZ9Sef4V7cWrjrEjj3DJ5w2iu6Q9uoal2f4390kue42Um5HcDFWqv3m56k6O89bRV424PaRra1k9Cd2L56IN2zfBYqNo2WP5VC68G8w1hfflOY0O52h4WdcpoHSjZm4b35N7l47dT4dwEXj1U4J5

View File

@ -1 +0,0 @@
cFV0eXZxcjAF/IXJ9qJT1u5C3S5AgY9BoVtIQNBKfxQMt5hH7BF36ndIJGV5L6qw5h4G0IOIOOewdHmMCNfKJftHM4nv3B0iRlSdqJKdL08wO3sV0v8eZk0OiYAlxgseGcBquQWYS/0b5Lj/Ioi2NfpOthAY9vUiRPnfH3+7/2AJGudHjj4Gg1KkpPW3mXIKbsk+Ou9fhrUqs873BCdsmI6qRmVNhOkLaUcbG6Zin3XU0WkgnnjebR43S8N4bw5BTphFvhy42QvspnD7Ewb1tVZQMQ2N1s38nBjukdfCB9R6aRwITOvg2U7Lr0RjLpbrIn6A6iVilpINjK4VptuKUTlpDXQwgCjoqeHQaHNCWgYpdjB69lXn8km/BfzK7QyDbh0VgTikwAHF9tvPhin3AIDRcU0xsaWYKURRfJelX3pSN495ADlhXdEKL/+l60hVnY7t6iCMxJL3lOtdGtdUYUGUCc76PB1fX+0HTWCcfcwvXTEdczr9J1h2yTeJNqFQ5pNy8vX7Ws8k7vDQVFkw4llZjPhb0kg9aDNePTNIKSGwy/7eofrcUQlC9DI+qqqwQ5abA/93fNsPq6XU3uwawnrbBsdz8DDdjJiEDI7abkPIDIfr/uR0YzgBxW90t5bt6xAtuW+VSYAM7kGxI3RZTl7JgOT60MLyIWkYASrRhRPMGks8zL10ED/4yGTEB1nt

Binary file not shown.

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