97 Commits

Author SHA1 Message Date
MengYX
97ca09dbae Bump Version 2020-03-04 09:30:48 +08:00
MengYX
e78118b4d0 Add fix-compatibility npm command 2020-03-04 09:29:05 +08:00
MengYX
37c60caa06 Update Dependencies 2020-03-04 08:37:54 +08:00
MengYX
fba020969b Update Babel config 2020-03-04 08:37:39 +08:00
MengYX
790715726a Add temporary solution to fix compatibility for Edge 18 2020-03-04 08:37:16 +08:00
MengYX
4ca45e1233 Merge branch 'pull/22' 2020-03-03 20:01:42 +08:00
MengYX
cfacb77d15 Optimize loader tips for outdated browser 2020-03-03 20:00:44 +08:00
任宝硕
5f7461e8aa Update index.html 2020-03-03 16:41:45 +08:00
MengYX
aa98ec422c Reformat Code 2020-03-01 23:36:16 +08:00
MengYX
d2ec667c19 Fix Update Check 2020-03-01 23:34:24 +08:00
任宝硕
94ee8eb3bd Update index.html 2020-03-01 16:16:34 +08:00
任宝硕
c13f7fcced 对部分老旧浏览器进行显示调整 + 部分内容修复 2020-03-01 16:14:43 +08:00
MengYX
1d83898f08 #20 Add Support For Netease DJ Files 2020-02-29 19:52:41 +08:00
MengYX
98ca5bc8ff Bump Version 2020-02-23 13:52:03 +08:00
MengYX
984b549448 #19 Add Download Type: Origin Filename 2020-02-23 13:45:30 +08:00
MengYX
9fd7177ebb Add Update Check 2020-02-23 13:44:19 +08:00
MengYX
f7d19e62fd Immediately Load Latest App 2020-02-23 13:42:50 +08:00
MengYX
86d59f4e6f Update Dependencies 2020-02-23 13:42:20 +08:00
MengYX
3906572723 Optimize UI 2020-02-23 13:41:39 +08:00
MengYX
179f72687a Fix Decrypt Mflac Error 2020-02-12 23:04:48 +08:00
MengYX
ce251e3c9e Fix Decrypt Algorithm Error 2020-02-11 17:03:46 +08:00
MengYX
cc8e818142 Optimize Loading 2020-02-11 16:35:45 +08:00
MengYX
f99e885d9f Fix Mgg Mask Detect Bug 2020-02-11 16:23:50 +08:00
MengYX
58d9039960 Update README and Bump Version 2020-02-11 16:00:34 +08:00
MengYX
c314a251c2 Add Detect Media Type by File 2020-02-11 15:52:22 +08:00
MengYX
10f09958c4 Optimize Import 2020-02-11 14:48:27 +08:00
MengYX
41a45176be Use Universal Decoder for Qmc,Mgg,Mflac 2020-02-11 14:35:17 +08:00
MengYX
47cea6eae9 Use Universal Mask for Qmc,Mgg,Mflac
Add Local Experimental Support For Mgg
2020-02-11 00:34:26 +08:00
MengYX
2fc9368a92 Add Experimental Support For Mgg 2020-02-10 20:10:48 +08:00
MengYX
4e1bfb0b55 Bump Version 2020-02-09 15:16:23 +08:00
MengYX
27b74ea5dd Better Way to Detect Mflac Mask 2020-02-09 14:05:40 +08:00
MengYX
9aab7a7713 Adjust for Debugging 2020-02-07 20:17:45 +08:00
MengYX
dcde0d3fbb Fix Babel Config 2020-02-07 14:41:21 +08:00
MengYX
0c0299d63a Remove Useless Information 2020-02-06 16:18:40 +08:00
MengYX
3ee9f5d2d1 Split App.vue 2020-02-06 16:01:35 +08:00
MengYX
e3ca175258 Optimize Bundle Size 2020-02-06 14:05:46 +08:00
MengYX
05cdd7b896 Merge branch 'ix-master' 2020-02-05 02:08:08 +08:00
MengYX
91ba19d878 Bump Version 2020-02-05 01:58:53 +08:00
MengYX
a7c7b6cbfa Add instant download to avoid memory occupation 2020-02-05 01:53:58 +08:00
MengYX
50fbb69394 Merge branch 'pull/17'
# Conflicts:
#	src/App.vue
2020-02-05 01:38:34 +08:00
MengYX
402fb184f7 Add Web Worker 2020-02-05 00:30:44 +08:00
MengYX
0766e2fcb0 Merge pull request #16 from smtop/dev
增加歌曲命名格式选项
2020-02-04 19:41:01 +08:00
1519715742@qq.com
92bd0f6be3 Performance improvement in multiple files 2020-02-04 19:12:44 +08:00
smdev
9c6af8ff9c 增加歌曲命名格式选项 2020-02-04 18:24:53 +08:00
MengYX
8094f3ad58 Update CI 2020-02-01 12:05:00 +08:00
MengYX
e6a81f8546 Edit index.html Upgrade Dependencies 2020-01-31 11:47:16 +08:00
MengYX
211b4e0206 #9 Add QQ Music tkm Format 2020-01-27 18:02:39 +08:00
MengYX
4cd5b45986 Fix QMC filename error 2020-01-27 16:43:40 +08:00
MengYX
60445b7ed9 #11 Add Moo Music Format 2020-01-27 14:06:45 +08:00
MengYX
4e499b2deb Merge branch 'master' of github.com:ix64/unlock-music 2020-01-21 20:00:20 +08:00
MengYX
0cddb98612 Update CI 2020-01-21 19:52:45 +08:00
MengYX
ca3f4c1aa4 Update Readme [CI SKIP] 2020-01-21 19:31:30 +08:00
MengYX
eec5bd0fb8 Add Support For: tm0/2/3/6 2020-01-21 19:21:17 +08:00
MengYX
bddde78fcd Reconstruct 2020-01-21 19:20:46 +08:00
MengYX
bc138c4078 Update Deps 2020-01-21 19:01:52 +08:00
MengYX
51a5a8a44f Update README.md 2019-12-23 19:09:24 +08:00
MengYX
2fb5aecdb2 Update README.md 2019-12-16 19:01:56 +08:00
MengYX
8014c33538 Revert: Favicon 2019-12-08 19:12:15 +08:00
MengYX
e6bce501bb Upgrade Vue Cli 2019-12-07 15:09:09 +08:00
MengYX
de37519f8c Remove unused icon 2019-12-07 14:44:32 +08:00
MengYX
a483594c4b Change build mode 2019-12-07 14:32:28 +08:00
MengYX
014fe5ae26 Fix accept in uploader 2019-12-07 12:23:18 +08:00
MengYX
73f3959094 Use Post 2019-12-01 22:52:29 +08:00
MengYX
48658701a2 Fix link error in README [SKIP CI] 2019-12-01 21:04:51 +08:00
MengYX
c4e9fb0dcc Update README and Dependencies 2019-11-30 21:10:40 +08:00
MengYX
d51d7ec773 Change Notification 2019-11-24 19:33:11 +08:00
MengYX
683f58964c Report Error Type 2019-11-23 19:22:32 +08:00
MengYX
0ca830e896 Fix No Status Error 2019-11-23 19:11:40 +08:00
MengYX
2266ca2cf1 Fix Download Button 2019-11-23 18:56:45 +08:00
MengYX
c71cb8ee85 Show Detail Info While Error Occurred 2019-11-23 18:30:59 +08:00
MengYX
b68efea15b Update Dependencies 2019-11-23 18:14:15 +08:00
MengYX
591c1a5312 Add Partial Support For .mflac 2019-11-23 18:09:33 +08:00
MengYX
95de3e8cc5 Reformat Code [SKIP CI] 2019-11-23 15:10:08 +08:00
MengYX
d91f48aa70 Add tips for qmcogg 2019-11-23 15:03:45 +08:00
MengYX
497a63486d CI: Auto Deploy and Use Cache 2019-11-10 22:38:43 +08:00
MengYX
538705187a Add CI 2019-11-10 17:25:01 +08:00
MengYX
04be04204a Use new window to open link 2019-11-10 01:30:11 +08:00
MengYX
d99cd23e0c Update Dependencies 2019-11-10 01:16:08 +08:00
MengYX
c9770bdd59 Add .qmcogg (without test)
Add Tips
2019-11-10 00:59:13 +08:00
MengYX
757d4d4847 Fix Display Bugs In Edge and Safari 2019-09-21 23:32:21 +08:00
MengYX
0913337612 Enhanced ease of use 2019-09-18 00:21:18 +08:00
MengYX
23ff9cdec1 Add Analytics
Fix an error statement
2019-09-14 20:48:57 +08:00
MengYX
0b0b19163b Complete ID3 for ncm 2019-09-12 15:51:10 +08:00
MengYX
382a637a2c Fix an icon error 2019-09-08 15:06:08 +08:00
MengYX
82e4ec6312 Merge pull request #2 from ix64/pull/1
Fix bugs after using music-metadata-browser
2019-09-08 14:50:39 +08:00
MengYX
0e59843944 Merge branch 'master' into pull/1 2019-09-08 14:49:03 +08:00
MengYX
c7ed517ede Merge pull request #1 from Borewit/music-metadata-browser
Maybe try music-metadata-browser?
2019-09-08 14:45:23 +08:00
Borewit
bd377db39b Fix parsing picture in metadata 2019-09-08 08:28:25 +02:00
MengYX
32128ed425 Fix bugs after using music-metadata-browser 2019-09-08 13:40:32 +08:00
Borewit
9416ded167 use music-metadata-browser 2019-09-07 19:50:04 +02:00
MengYX
1ba40d1fc2 Change icon 2019-08-25 15:55:36 +08:00
MengYX
76c0577185 Downgrade jsmediatag to avoid bug
Rename project
2019-08-25 15:16:20 +08:00
MengYX
3ceb56900d Update page footer 2019-08-25 14:44:27 +08:00
MengYX
e0ffd3f477 Update readme 2019-08-25 14:31:38 +08:00
MengYX
94136ec2e6 Update readme 2019-08-25 14:27:11 +08:00
MengYX
428a4505ad Merge branch 'master' of https://git.ixarea.com/MusicCrack/music-crack 2019-08-25 14:12:48 +08:00
MengYX
6ed0291e54 Update dependencies to fix CVE-2019-10744 2019-08-25 14:10:33 +08:00
68 changed files with 5898 additions and 33372 deletions

65
.drone.yml Normal file
View File

@@ -0,0 +1,65 @@
---
kind: pipeline
type: docker
name: default
clone:
depth: 1
steps:
- name: installDependencies
image: node:lts
commands:
- npm ci --verbose --registry=https://registry.npm.taobao.org
- name: build
image: node:lts
commands:
- npm run fix-compatibility
- npm run build
- tar -czf legacy.tar.gz -C ./dist .
- npm run build -- --modern
- tar -czf morden.tar.gz -C ./dist .
- name: release
image: plugins/gitea-release
settings:
base_url: https://git.ixarea.com
files:
- morden.tar.gz
- legacy.tar.gz
api_key:
from_secret: gitea_token
checksum:
- sha256
when:
event: [tag]
- name: deploy
image: plugins/s3
settings:
bucket: unlock-music
access_key:
from_secret: aws_access_key_id
secret_key:
from_secret: aws_secret_access_key
source: dist/**/*
strip_prefix: dist/
target: /public
path_style: true
endpoint: https://fs.sz2.ixarea.com
- name: upload
image: plugins/s3
settings:
bucket: unlock-music
access_key:
from_secret: aws_access_key_id
secret_key:
from_secret: aws_secret_access_key
source: ./*.tar.gz
target: /build/${DRONE_BUILD_NUMBER}
path_style: true
endpoint: https://fs.sz2.ixarea.com

View File

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

View File

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

View File

@@ -1,72 +0,0 @@
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:
test-coverage:
runs-on: ubuntu-latest
steps:
- name: Test Coverage
uses: ArtiomTr/jest-coverage-report-action@v2.0-rc.6
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
build:
runs-on: ubuntu-latest
strategy:
matrix:
build: [ legacy, modern ]
include:
- build: legacy
BUILD_ARGS: ""
BUILD_EXTENSION: true
- build: modern
BUILD_ARGS: "-- --modern"
BUILD_EXTENSION: false
steps:
- uses: actions/checkout@v2
- name: Use Node.js 16.x
uses: actions/setup-node@v2
with:
node-version: "16"
- name: Install Dependencies
run: npm ci
- name: Build
run: npm run build ${{ matrix.BUILD_ARGS }}
- name: Publish artifact
uses: actions/upload-artifact@v2
with:
name: ${{ matrix.build }}
path: ./dist
- name: Build Extension
if: ${{ matrix.BUILD_EXTENSION }}
run: npm run make-extension
- name: Publish artifact - Extension
if: ${{ matrix.BUILD_EXTENSION }}
uses: actions/upload-artifact@v2
with:
name: extension
path: ./dist

View File

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

View File

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

1
.gitignore vendored
View File

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

View File

@@ -1,10 +0,0 @@
FROM --platform=$TARGETPLATFORM nginx:stable-alpine
LABEL org.opencontainers.image.title="Unlock Music"
LABEL org.opencontainers.image.description="Unlock encrypted music file in browser"
LABEL org.opencontainers.image.authors="MengYX"
LABEL org.opencontainers.image.source="https://github.com/ix64/unlock-music"
LABEL org.opencontainers.image.licenses="MIT"
LABEL maintainer="MengYX"
COPY ./dist /usr/share/nginx/html

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2019-2021 MengYX
Copyright (c) [2019] [MengYX]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,69 +1,35 @@
# Unlock Music 音乐解锁
- Unlock encrypted music file in browser.
- 在浏览器中解锁加密的音乐文件。
- unlock-music项目是以学习和技术研究的初衷创建的。
- 由于存在可能的法律风险以及滥用风险不再提供Demo服务。
- 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser.
- unlock-music项目是以学习和技术研究的初衷创建的修改、再分发时请遵循[License](https://github.com/ix64/unlock-music/blob/master/LICENSE)
- Unlock Music的CLI版本正在开发中。
- 我们新建了Telegram群组欢迎加入[https://t.me/unlock_music_chat](https://t.me/unlock_music_chat)
- [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)
[![Build Status](https://ci.ixarea.com/api/badges/ix64/unlock-music/status.svg)](https://ci.ixarea.com/ix64/unlock-music)
![Test Build](https://github.com/ix64/unlock-music/workflows/Test%20Build/badge.svg)
![GitHub releases](https://img.shields.io/github/downloads/ix64/unlock-music/total)
![Docker Pulls](https://img.shields.io/docker/pulls/ix64/unlock-music)
# Features
- [x] Unlock in browser 在浏览器中解锁
- [x] QQMusic Format QQ音乐格式 (.qmc0/.qmc3/.qmcflac/.qmcogg/.tkm)
- [x] MooMusic Format Moo音乐格式 ([.bkcmp3/.bkcflac](https://github.com/ix64/unlock-music/issues/11))
- [x] QQMusic Tm Format QQ音乐Tm格式 (.tm0/.tm2/.tm3/.tm6)
- [x] QQMusic New Format QQ音乐新格式 (Experimental 实验性支持)
- [x] .mflac
- [x] .mgg
- [x] Netease Format 网易云音乐格式 (.ncm)
- [x] Drag and Drop 拖放文件
- [x] Play instantly 在线播放
- [x] Batch unlocking 批量解锁
- [x] Progressive Web App 渐进式Web应用
- [x] Complete ID3 for ncm 补全ncm的ID3信息
- [x] Multi-threads 多线程
## 特性
### 支持的格式
- [x] QQ音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/[.tkm](https://github.com/ix64/unlock-music/issues/9))
- [x] 写入封面图片
- [x] Moo音乐格式 ([.bkcmp3/.bkcflac](https://github.com/ix64/unlock-music/issues/11))
- [x] QQ音乐Tm格式 (.tm0/.tm2/.tm3/.tm6)
- [x] QQ音乐新格式 (实验性支持)
- [x] .mflac
- [x] [.mgg](https://github.com/ix64/unlock-music/issues/3)
- [x] 虾米音乐格式 (.xm) (测试阶段)
- [x] 酷我音乐格式 (.kwm) (测试阶段)
- [x] 酷狗音乐格式 (
.kgm) ([CLI版本](https://github.com/ix64/unlock-music/wiki/%E5%85%B6%E4%BB%96%E9%9F%B3%E4%B9%90%E6%A0%BC%E5%BC%8F%E5%B7%A5%E5%85%B7#%E9%85%B7%E7%8B%97%E9%9F%B3%E4%B9%90-kgmvpr%E8%A7%A3%E9%94%81%E5%B7%A5%E5%85%B7))
### 其他特性
- [x] 在浏览器中解锁
- [x] 拖放文件
- [x] 在线播放
- [x] 批量解锁
- [x] 渐进式Web应用
- [x] 多线程
## 使用方法
### 安装浏览器扩展
[![Chrome Web Store](https://storage.googleapis.com/chrome-gcs-uploader.appspot.com/image/WlD8wC6g8khYWPJUsQceQkhXSlv1/UV4C4ybeBTsZt43U4xis.png)](https://chrome.google.com/webstore/detail/gldlhhhmienbhlpkfanjpmffdjblmegd)
[<img src="https://developer.microsoft.com/en-us/store/badges/images/Chinese_Simplified_get-it-from-MS.png" height="60" alt="Microsoft Edge Addons"/>](https://microsoftedge.microsoft.com/addons/detail/ggafoipegcmodfhakdkalpdpcdkiljmd)
[![Firefox Browser Addons](https://ffp4g1ylyit3jdyti1hqcvtb-wpengine.netdna-ssl.com/addons/files/2015/11/get-the-addon.png)](https://addons.mozilla.org/zh-CN/firefox/addon/unlock-music/)
### 使用已构建版本
- 从[GitHub Release](https://github.com/ix64/unlock-music/releases/latest)下载已构建的版本
- 本地使用请下载`legacy版本``modern版本`只能通过**http/https协议**访问)
- 解压缩后即可部署或本地使用(**请勿直接运行源代码**
### 使用Docker镜像
```shell
docker run --name unlock-music -d -p 8080:80 ix64/unlock-music
```
### 自行构建
# 使用方法
## 下载已构建版本
- 已构建的版本发布在 [GitHub Release](https://github.com/ix64/unlock-music/releases/latest), 下载解压缩后即可部署或本地使用
## 自行构建
- 环境要求
- nodejs
- npm
- nodejs
- npm
1. 获取项目源代码后执行 `npm install` 安装相关依赖
2. 执行 `npm run build` 即可进行构建,构建输出为 dist 目录
- `npm run serve` 可用于开发
3. 如需构建浏览器扩展build完成后还需要执行`npm run make-extension`

View File

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

View File

@@ -1,15 +0,0 @@
{
"manifest_version": 2,
"name": "音乐解锁",
"short_name": "音乐解锁",
"icons": {
"128": "./img/icons/msapplication-icon-144x144.png"
},
"description": "在任何设备上解锁已购的加密音乐!",
"offline_enabled": true,
"options_page": "./index.html",
"homepage_url": "https://github.com/ix64/unlock-music",
"browser_action": {
"default_popup": "./popup.html"
}
}

View File

@@ -1,5 +0,0 @@
module.exports = {
moduleNameMapper: {
'@/(.*)': '<rootDir>/src/$1'
}
};

View File

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

35651
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "unlock-music",
"version": "v1.10.0-beta.1",
"updateInfo": "新增写入本地文件系统; 优化.kwm解锁; 支持.acc嗅探; 使用Typescript重构",
"version": "1.3.3",
"updateInfo": "支持网易云音乐DJ类型ncm优化旧浏览器的支持",
"license": "MIT",
"description": "Unlock encrypted music file in browser.",
"repository": {
@@ -10,45 +10,26 @@
},
"private": true,
"scripts": {
"postinstall": "patch-package",
"serve": "vue-cli-service serve",
"build": "vue-cli-service build",
"test": "jest",
"make-extension": "node ./make-extension.js"
"fix-compatibility": "node ./src/fix-compatibility.js"
},
"dependencies": {
"@babel/preset-typescript": "^7.16.5",
"@jixun/qmc2-crypto": "^0.0.5-R4",
"base64-js": "^1.5.1",
"browser-id3-writer": "^4.4.0",
"core-js": "^3.16.0",
"crypto-js": "^4.1.1",
"element-ui": "^2.15.5",
"iconv-lite": "^0.6.3",
"jimp": "^0.16.1",
"metaflac-js": "^1.0.5",
"music-metadata": "7.9.0",
"music-metadata-browser": "2.2.7",
"register-service-worker": "^1.7.2",
"threads": "^1.6.5",
"vue": "^2.6.14"
"core-js": "^3.6.4",
"crypto-js": "^4.0.0",
"element-ui": "^2.13.0",
"music-metadata-browser": "^2.0.4",
"register-service-worker": "^1.6.2",
"vue": "^2.6.11"
},
"devDependencies": {
"@types/crypto-js": "^4.0.2",
"@types/jest": "^27.0.3",
"@vue/cli-plugin-babel": "^4.5.13",
"@vue/cli-plugin-pwa": "^4.5.13",
"@vue/cli-plugin-typescript": "^4.5.13",
"@vue/cli-service": "^4.5.13",
"@vue/cli-plugin-babel": "^4.2.3",
"@vue/cli-plugin-pwa": "^4.2.3",
"@vue/cli-service": "^4.2.3",
"babel-plugin-component": "^1.1.1",
"jest": "^27.4.5",
"patch-package": "^6.4.7",
"sass": "^1.38.1",
"sass-loader": "^10.2.0",
"semver": "^7.3.5",
"threads-plugin": "^1.4.0",
"typescript": "^4.5.4",
"vue-cli-plugin-element": "^1.0.1",
"vue-template-compiler": "^2.6.14"
"vue-template-compiler": "^2.6.11",
"workerize-loader": "^1.1.0"
}
}

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

@@ -5,12 +5,15 @@
<meta content="webkit" name="renderer">
<meta content="IE=edge,chrome=1" http-equiv="X-UA-Compatible">
<meta content="width=device-width,initial-scale=1.0" name="viewport">
<title>音乐解锁</title>
<meta content="音乐,解锁,qmc,mgg,mflac,qq音乐,加密" name="keywords"/>
<meta content="音乐解锁 - 在任何设备上解锁已购的加密音乐!" name="description"/>
<script src="./ixarea-stats.js"></script>
<!--@formatter:off-->
<style>#loader{position:absolute;left:50%;top:50%;z-index:1010;margin:-75px 0 0 -75px;border:16px solid #f3f3f3;border-radius:50%;border-top:16px solid #1db1ff;width:120px;height:120px;animation:spin 2s linear infinite}@keyframes spin{0%{transform:rotate(0)}100%{transform:rotate(360deg)}}#loader-mask{text-align:center;position:absolute;width:100%;height:100%;bottom:0;left:0;right:0;top:0;z-index:1009;background-color:rgba(242,246,252,.88)}@media (prefers-color-scheme:dark){#loader-mask{color:#fff;background-color:rgba(0,0,0,.85)}#loader-mask a{color:#ddd}#loader-mask a:hover{color:#1db1ff}}#loader-source{font-size:1.5rem}#loader-tips-timeout{font-size:1.2rem}</style>
<script>var _paq=window._paq||[];_paq.push(["setRequestMethod","POST"],["trackPageView"],["enableLinkTracking"],["setSiteId","2"],["setTrackerUrl","https://stats.ixarea.com/ixarea-stats/report"]);</script>
<!--@formatter:on-->
<script async src="https://stats.ixarea.com/ixarea-stats.js"></script>
<title>音乐解锁 - By IXarea</title>
<meta content="音乐,解锁,ncm,qmc,qmc0,qmc3,qmcflac,qmcogg,mflac,qq音乐,网易云音乐,加密" name="keywords"/>
<meta content="音乐解锁 - 在任何设备上解锁已购的加密音乐!" name="description"/>
<!--@formatter:off-->
<style>#loader{position:absolute;left:50%;top:50%;z-index:1010;margin:-75px 0 0 -75px;border:16px solid #f3f3f3;border-radius:50%;border-top:16px solid #3498db;width:120px;height:120px;animation:spin 2s linear infinite}@keyframes spin{0%{transform:rotate(0deg)}100%{transform:rotate(360deg)}}#loader-mask{position:absolute;width:100%;height:100%;bottom:0;left:0;right:0;top:0;z-index:1009;background-color:rgba(242,246,252,0.88)}</style>
<!--@formatter:on-->
</head>
<body>
@@ -18,26 +21,50 @@
<div id="loader-mask">
<div id="loader"></div>
<noscript>
<h3 id="loader-js">请启用JavaScript</h3>
<img alt=""
src="https://stats.ixarea.com/ixarea-stats/report?rec=1&action_name=音乐解锁-NoJS&idsite=2"
src="https://stats.ixarea.com/ixarea-stats/report?idsite=2&rec=1&action_name=音乐解锁+-+By+IXarea"
style="border:0"/>
</noscript>
<h3 id="loader-source"> 请勿直接运行源代码! </h3>
<div id="loader-tips-outdated" hidden>
<div hidden id="loader-tips-outdated">
<h2>您可能在使用不受支持的<span style="color:#f00;">过时</span>浏览器,这可能导致此应用无法正常工作。</h2>
<h3>如果您使用双核浏览器,您可以尝试切换<span style="color:#f00;">“极速模式”</span> 解决此问题。</h3>
<h3>如果您使用双核浏览器,您可以尝试切换<span style="color:#f00;">“极速模式”</span>解决此问题。</h3>
<h3>或者,您可以尝试更换下方的几个浏览器之一。</h3>
</div>
<h3 id="loader-tips-timeout" hidden>
<h3 hidden id="loader-tips-timeout">
音乐解锁采用了一些新特性!建议使用
<a href="https://www.microsoft.com/zh-cn/edge" target="_blank">Microsoft Edge Chromium</a>
<a href="https://www.microsoftedgeinsider.com/zh-cn/download" target="_blank">Microsoft Edge Chromium</a>
<a href="https://www.google.cn/chrome/" target="_blank">Google Chrome</a>
<a href="https://www.firefox.com.cn/" target="_blank">Mozilla Firefox</a>
| <a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
</h3>
</div>
<div id="app"></div>
<script src="./loader.js"></script>
<script>
(function () {
setTimeout(function () {
var ele = document.getElementById("loader-tips-timeout");
if (ele != null) {
ele.hidden = false;
}
}, 2000);
var ua = navigator && navigator.userAgent;
var detected = (function () {
var m;
if (!ua) return true;
if (/MSIE |Trident\//.exec(ua)) return true; // no IE
m = /Edge\/([\d.]+)/.exec(ua); // Edge >= 16
if (m && Number(m[1]) < 16) return true;
m = /Chrome\/([\d.]+)/.exec(ua); // Chrome >= 50
if (m && Number(m[1]) < 50) return true;
m = /Firefox\/([\d.]+)/.exec(ua); // Firefox >= 38
return m && Number(m[1]) < 38;
})();
if (detected) {
document.getElementById('loader-tips-outdated').hidden = false;
document.getElementById("loader-tips-timeout").hidden = false;
}
})();
</script>
</body>
</html>

View File

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

View File

@@ -1,25 +0,0 @@
(function () {
setTimeout(function () {
var ele = document.getElementById("loader-tips-timeout");
if (ele != null) {
ele.hidden = false;
}
}, 2000);
var ua = navigator && navigator.userAgent;
var detected = (function () {
var m;
if (!ua) return true;
if (/MSIE |Trident\//.exec(ua)) return true; // no IE
m = /Edge\/([\d.]+)/.exec(ua); // Edge >= 17
if (m && Number(m[1]) < 17) return true;
m = /Chrome\/([\d.]+)/.exec(ua); // Chrome >= 58
if (m && Number(m[1]) < 58) return true;
m = /Firefox\/([\d.]+)/.exec(ua); // Firefox >= 45
return m && Number(m[1]) < 45;
})();
if (detected) {
document.getElementById('loader-tips-outdated').hidden = false;
document.getElementById("loader-tips-timeout").hidden = false;
}
})();

21
public/manifest.json Normal file
View File

@@ -0,0 +1,21 @@
{
"name": "音乐解锁 - By IXarea",
"short_name": "音乐解锁",
"description": "在任何设备上解锁已购的加密音乐支持QQ音乐与网易云音乐",
"icons": [
{
"src": "./img/icons/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
},
{
"src": "./img/icons/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
}
],
"start_url": "./index.html",
"display": "standalone",
"background_color": "#000000",
"theme_color": "#4DBA87"
}

Binary file not shown.

View File

@@ -1,85 +1,199 @@
<template>
<el-container id="app">
<el-main>
<Home/>
<x-upload v-on:handle_error="showFail" v-on:handle_finish="showSuccess"></x-upload>
<el-row id="app-control">
<el-row style="padding-bottom: 1em; font-size: 14px">
歌曲命名格式
<el-radio label="1" name="format" v-model="download_format">歌手-歌曲名</el-radio>
<el-radio label="2" name="format" v-model="download_format">歌曲名</el-radio>
<el-radio label="3" name="format" v-model="download_format">歌曲名-歌手</el-radio>
<el-radio label="4" name="format" v-model="download_format">同原文件名</el-radio>
</el-row>
<el-row>
<el-button @click="handleDownloadAll" icon="el-icon-download" plain>下载全部</el-button>
<el-button @click="handleDeleteAll" icon="el-icon-delete" plain type="danger">删除全部</el-button>
<el-checkbox border style="margin-left: 1em" v-model="instant_download">立即保存</el-checkbox>
</el-row>
</el-row>
<audio :autoplay="playing_auto" :src="playing_url" controls/>
<x-preview :download_format="download_format" :table-data="tableData"
v-on:music_changed="changePlaying"></x-preview>
</el-main>
<el-footer id="app-footer">
<el-row>
<a href="https://github.com/ix64/unlock-music" target="_blank">音乐解锁</a>({{ version }})
移除已购音乐的加密保护
<a href="https://github.com/ix64/unlock-music" target="_blank">音乐解锁</a>(v<span
v-text="version"></span>)移除已购音乐的加密保护
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a>
</el-row>
<el-row>
目前支持 QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm)
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>
目前支持网易云音乐(ncm)QQ音乐(qmc, mflac, mgg, tkm)以及
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">其他格式</a>
</el-row>
<el-row>
<!--如果进行二次开发此行版权信息不得移除且应明显地标注于页面上-->
<span>Copyright &copy; 2019 - {{ (new Date()).getFullYear() }} MengYX</span>
<span>Copyright &copy; 2019</span> MengYX
音乐解锁使用
<a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a>
开放源代码
</el-row>
</el-footer>
</el-container>
</template>
<script>
import FileSelector from "@/component/FileSelector"
import PreviewTable from "@/component/PreviewTable"
import config from "@/../package.json"
import Home from "@/view/Home";
import {checkUpdate} from "@/utils/api";
import upload from "./component/upload"
import preview from "./component/preview"
import {DownloadBlobMusic, RemoveBlobMusic} from "./component/util"
import config from "../package"
export default {
name: 'app',
components: {
FileSelector,
PreviewTable,
Home
},
data() {
return {
version: config.version,
}
},
created() {
this.$nextTick(() => this.finishLoad());
},
methods: {
async finishLoad() {
const mask = document.getElementById("loader-mask");
if (!!mask) mask.remove();
let updateInfo;
try {
updateInfo = await checkUpdate(this.version)
} catch (e) {
console.warn("check version info failed", e)
export default {
name: 'app',
components: {
xUpload: upload,
xPreview: preview
},
data() {
return {
version: config.version,
activeIndex: '1',
tableData: [],
playing_url: "",
playing_auto: false,
download_format: '1',
instant_download: false,
}
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>`,
},
created() {
this.$nextTick(function () {
this.finishLoad();
});
},
methods: {
async finishLoad() {
const mask = document.getElementById("loader-mask");
if (!!mask) mask.remove();
let updateInfo;
try {
const resp = await fetch("https://stats.ixarea.com/collect/music/app-version", {
method: "POST", headers: {"Content-Type": "application/json"},
body: JSON.stringify({Version: this.version})
});
updateInfo = await resp.json();
} catch (e) {
}
if (!!updateInfo && !!updateInfo.Found) {
this.$notify.warning({
title: '发现更新',
message: '发现新版本 v' + updateInfo.Version +
'<br/>更新详情:' + updateInfo.Detail +
'<br/><a target="_blank" href="' + updateInfo.URL + '">获取更新</a>',
dangerouslyUseHTMLString: true,
duration: 15000,
position: 'top-left'
});
} else {
this.$notify.info({
title: '离线使用',
message: '我们使用PWA技术无网络也能使用' +
'<br/>最近更新:' + config.updateInfo +
'<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true,
duration: 10000,
position: 'top-left'
});
}
},
showSuccess(data) {
if (data.status) {
if (this.instant_download) {
DownloadBlobMusic(data, this.download_format);
RemoveBlobMusic(data);
} else {
this.tableData.push(data);
this.$notify.success({
title: '解锁成功',
message: '成功解锁 ' + data.title,
duration: 3000
});
}
if (process.env.NODE_ENV === 'production') {
let _rp_data = [data.title, data.artist, data.album];
window._paq.push(["trackEvent", "Unlock", data.rawExt + "," + data.mime, JSON.stringify(_rp_data)]);
}
} else {
this.showFail(data.message, data.rawFilename + "." + data.rawExt)
}
},
showFail(errInfo, filename) {
this.$notify.error({
title: '出现问题',
message: errInfo + "" + filename +
',参考<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true,
duration: 15000,
position: 'top-left'
duration: 6000
});
} 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'
if (process.env.NODE_ENV === 'production') {
window._paq.push(["trackEvent", "Error", errInfo, filename]);
console.error(errInfo, filename);
}
},
changePlaying(url) {
this.playing_url = url;
this.playing_auto = true;
},
handleDeleteAll() {
this.tableData.forEach(value => {
RemoveBlobMusic(value);
});
this.tableData = [];
},
handleDownloadAll() {
let index = 0;
let c = setInterval(() => {
if (index < this.tableData.length) {
DownloadBlobMusic(this.tableData[index], this.download_format);
index++;
} else {
clearInterval(c);
}
}, 300);
}
}
},
}
},
}
</script>
<style lang="scss">
@import "scss/unlock-music";
<style>
#app {
font-family: "Helvetica Neue", Helvetica, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
padding-top: 30px;
}
#app-footer a {
padding-left: 0.2em;
padding-right: 0.2em;
}
#app-footer {
text-align: center;
font-size: small;
}
#app-control {
padding-top: 1em;
padding-bottom: 1em;
}
</style>

View File

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

View File

@@ -1,71 +0,0 @@
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column label="封面">
<template slot-scope="scope">
<el-image :src="scope.row.picture" style="width: 100px; height: 100px">
<div slot="error" class="image-slot el-image__error">
暂无封面
</div>
</el-image>
</template>
</el-table-column>
<el-table-column label="歌曲">
<template #default="scope">
<span>{{ scope.row.title }}</span>
</template>
</el-table-column>
<el-table-column label="歌手">
<template #default="scope">
<p>{{ scope.row.artist }}</p>
</template>
</el-table-column>
<el-table-column label="专辑">
<template #default="scope">
<p>{{ scope.row.album }}</p>
</template>
</el-table-column>
<el-table-column label="操作">
<template #default="scope">
<el-button circle
icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)">
</el-button>
<el-button circle
icon="el-icon-download" @click="handleDownload(scope.row)">
</el-button>
<el-button circle
icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)">
</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script>
import {RemoveBlobMusic} from '@/utils/utils'
export default {
name: "PreviewTable",
props: {
tableData: {type: Array, required: true},
policy: {type: Number, required: true}
},
methods: {
handlePlay(index, row) {
this.$emit("play", row.file);
},
handleDelete(index, row) {
RemoveBlobMusic(row);
this.tableData.splice(index, 1);
},
handleDownload(row) {
this.$emit("download", row)
},
}
}
</script>
<style scoped>
</style>

71
src/component/preview.vue Normal file
View File

@@ -0,0 +1,71 @@
<template>
<el-table :data="tableData" style="width: 100%">
<el-table-column label="封面">
<template slot-scope="scope">
<el-image :src="scope.row.picture" style="width: 100px; height: 100px">
<div class="image-slot el-image__error" slot="error">
暂无封面
</div>
</el-image>
</template>
</el-table-column>
<el-table-column label="歌曲">
<template slot-scope="scope">
<span style="margin-left: 10px">{{ scope.row.title }}</span>
</template>
</el-table-column>
<el-table-column label="歌手">
<template slot-scope="scope">
<p>{{ scope.row.artist }}</p>
</template>
</el-table-column>
<el-table-column label="专辑">
<template slot-scope="scope">
<p>{{ scope.row.album }}</p>
</template>
</el-table-column>
<el-table-column label="操作">
<template slot-scope="scope">
<el-button @click="handlePlay(scope.$index, scope.row)"
circle icon="el-icon-video-play" type="success">
</el-button>
<el-button @click="handleDownload(scope.row)"
circle icon="el-icon-download">
</el-button>
<el-button @click="handleDelete(scope.$index, scope.row)"
circle icon="el-icon-delete" type="danger">
</el-button>
</template>
</el-table-column>
</el-table>
</template>
<script>
import {DownloadBlobMusic, RemoveBlobMusic} from './util'
export default {
name: "preview",
props: {
tableData: {type: Array, required: true},
download_format: {type: String, required: true}
},
methods: {
handlePlay(index, row) {
this.$emit("music_changed", row.file);
},
handleDelete(index, row) {
RemoveBlobMusic(row);
this.tableData.splice(index, 1);
},
handleDownload(row) {
DownloadBlobMusic(row, this.download_format)
},
}
}
</script>
<style scoped>
</style>

87
src/component/upload.vue Normal file
View File

@@ -0,0 +1,87 @@
<template>
<el-upload
:auto-upload="false"
:on-change="handleFile"
:show-file-list="false"
action=""
drag
multiple>
<i class="el-icon-upload"/>
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
<div class="el-upload__tip" slot="tip">本工具仅在浏览器内对文件进行解锁无需消耗流量</div>
</el-upload>
</template>
<script>
"use strict";// 严格模式 用于尾调用优化
export default {
name: "upload",
data() {
return {
cacheQueue: [],
workers: [],
idle_workers: [],
thread_num: 1
}
},
mounted() {
if (document.location.host !== "" && process.env.NODE_ENV === 'production') {
this.thread_num = navigator.hardwareConcurrency || 1;
const worker = require("workerize-loader!../decrypt/common");
// noinspection JSValidateTypes,JSUnresolvedVariable
this.workers.push(worker().CommonDecrypt);
this.idle_workers.push(0);
// delay to optimize for first loading
setTimeout(() => {
for (let i = 1; i < this.thread_num; i++) {
// noinspection JSValidateTypes,JSUnresolvedVariable
this.workers.push(worker().CommonDecrypt);
this.idle_workers.push(i);
}
}, 1000);
} else {
const dec = require('../decrypt/common');
this.workers.push(dec.CommonDecrypt);
this.idle_workers.push(0)
}
},
methods: {
handleFile(file) {
// 有空闲worker 立刻处理文件
if (this.idle_workers.length > 0) {
this.handleDoFile(file, this.idle_workers.shift());
}
// 无空闲worker 则放入缓存队列
else {
this.cacheQueue.push(file);
}
},
handleCacheQueue(worker_id) {
// 调用方法消费缓存队列中的数据
if (this.cacheQueue.length === 0) {
this.idle_workers.push(worker_id);
return
}
this.handleDoFile(this.cacheQueue.shift(), worker_id);
},
handleDoFile(file, worker_id) {
this.workers[worker_id](file).then(data => {
this.$emit("handle_finish", data);
// 完成之后 执行新任务 todo: 可能导致call stack过长
this.handleCacheQueue(worker_id);
}).catch(err => {
this.$emit("handle_error", err, file.name);
this.handleCacheQueue(worker_id);
})
},
}
}
</script>
<style scoped>
/*noinspection CssUnusedSymbol*/
.el-upload-dragger {
width: 80vw !important;
}
</style>

27
src/component/util.js Normal file
View File

@@ -0,0 +1,27 @@
export function DownloadBlobMusic(data, format) {
const a = document.createElement('a');
a.href = data.file;
switch (format) {
default:
case "1":
a.download = data.artist + " - " + data.title + "." + data.ext;
break;
case "2":
a.download = data.title + "." + data.ext;
break;
case "3":
a.download = data.title + " - " + data.artist + "." + data.ext;
break;
case "4":
a.download = data.rawFilename + "." + data.ext;
break;
}
document.body.append(a);
a.click();
a.remove();
}
export function RemoveBlobMusic(data) {
URL.revokeObjectURL(data.file);
URL.revokeObjectURL(data.picture);
}

47
src/decrypt/common.js Normal file
View File

@@ -0,0 +1,47 @@
const NcmDecrypt = require("./ncm");
const QmcDecrypt = require("./qmc");
const RawDecrypt = require("./raw");
const TmDecrypt = require("./tm");
export async function CommonDecrypt(file) {
let raw_ext = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length).toLowerCase();
let raw_filename = file.name.substring(0, file.name.lastIndexOf("."));
let rt_data;
switch (raw_ext) {
case "ncm":// Netease Mp3/Flac
rt_data = await NcmDecrypt.Decrypt(file.raw);
break;
case "mp3":// Raw Mp3
case "flac"://Raw Flac
case "m4a":// Raw M4a
case "ogg":// Raw Ogg
rt_data = await RawDecrypt.Decrypt(file.raw, raw_filename, raw_ext);
break;
case "tm0":// QQ Music IOS Mp3
case "tm3":// QQ Music IOS Mp3
rt_data = await RawDecrypt.Decrypt(file.raw, raw_filename, "mp3");
break;
case "qmc3"://QQ Music Android Mp3
case "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
rt_data = await QmcDecrypt.Decrypt(file.raw, raw_filename, raw_ext);
break;
case "tm2":// QQ Music IOS M4a
case "tm6":// QQ Music IOS M4a
rt_data = await TmDecrypt.Decrypt(file.raw, raw_filename);
break;
default:
rt_data = {status: false, message: "不支持此文件格式",}
}
rt_data.rawExt = raw_ext;
rt_data.rawFilename = raw_filename;
return rt_data;
}

View File

@@ -1,73 +0,0 @@
import {Decrypt as XmDecrypt} from "@/decrypt/xm";
import {Decrypt as QmcDecrypt} from "@/decrypt/qmc";
import {Decrypt as QmcCacheDecrypt} from "@/decrypt/qmccache";
import {Decrypt as KgmDecrypt} from "@/decrypt/kgm";
import {Decrypt as KwmDecrypt} from "@/decrypt/kwm";
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
import {Decrypt as TmDecrypt} from "@/decrypt/tm";
import {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 "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 New Flac
case "mflac0"://QQ Music New Flac
case "mgg": //QQ Music New Ogg
case "mgg1": //QQ Music New Ogg
case "666c6163"://QQ Music Weiyun Flac
case "6d7033"://QQ Music Weiyun Mp3
case "6f6767"://QQ Music Weiyun Ogg
case "6d3461"://QQ Music Weiyun M4a
case "776176"://QQ Music Weiyun Wav
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
break;
case "tm2":// QQ Music IOS M4a
case "tm6":// QQ Music IOS M4a
rt_data = await TmDecrypt(file.raw, raw.name);
break;
case "cache"://QQ Music Cache
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case "vpr":
case "kgm":
case "kgma":
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext);
break
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,26 +0,0 @@
export interface DecryptResult {
title: string
album?: string
artist?: string
mime: string
ext: string
file: string
blob: Blob
picture?: string
message?: string
rawExt?: string
rawFilename?: string
}
export interface FileInfo {
status: string
name: string,
size: number,
percentage: number,
uid: number,
raw: File
}

View File

@@ -1,122 +0,0 @@
import {
AudioMimeType,
BytesHasPrefix,
GetArrayBuffer,
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt
} from "@/decrypt/utils";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import {DecryptResult} from "@/decrypt/entity";
import config from "@/../package.json"
const VprHeader = [
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31]
const KgmHeader = [
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B,
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14]
const VprMaskDiff = [
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E,
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11,
0x00]
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
const oriData = new Uint8Array(await GetArrayBuffer(file));
if (raw_ext === "vpr") {
if (!BytesHasPrefix(oriData, VprHeader)) throw Error("Not a valid vpr file!")
} else {
if (!BytesHasPrefix(oriData, KgmHeader)) throw Error("Not a valid kgm(a) file!")
}
let bHeaderLen = new DataView(oriData.slice(0x10, 0x14).buffer)
let headerLen = bHeaderLen.getUint32(0, true)
let audioData = oriData.slice(headerLen)
let dataLen = audioData.length
if (audioData.byteLength > 1 << 26) {
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁")
}
let key1 = new Uint8Array(17)
key1.set(oriData.slice(0x1c, 0x2c), 0)
if (MaskV2.length === 0) {
if (!await LoadMaskV2()) throw Error("加载Kgm/Vpr Mask数据失败")
}
for (let i = 0; i < dataLen; i++) {
let med8 = key1[i % 17] ^ audioData[i]
med8 ^= (med8 & 0xf) << 4
let msk8 = GetMask(i)
msk8 ^= (msk8 & 0xf) << 4
audioData[i] = med8 ^ msk8
}
if (raw_ext === "vpr") {
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17]
}
const ext = SniffAudioExt(audioData);
const mime = AudioMimeType[ext];
let musicBlob = new Blob([audioData], {type: mime});
const musicMeta = await metaParseBlob(musicBlob);
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
return {
album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
ext,
mime,
title,
artist
}
}
function GetMask(pos: number) {
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4]
}
let MaskV2: Uint8Array = new Uint8Array(0);
async function LoadMaskV2(): Promise<boolean> {
let mask_url = `https://cdn.jsdelivr.net/gh/unlock-music/unlock-music@${config.version}/public/static/kgm.mask`
if (["http:", "https:"].some(v => v == self.location.protocol)) {
if (!!self.document) {// using Web Worker
mask_url = "./static/kgm.mask"
} else {// using Main thread
mask_url = "../static/kgm.mask"
}
}
try {
const resp = await fetch(mask_url, {method: "GET"})
MaskV2 = new Uint8Array(await resp.arrayBuffer());
return true
} catch (e) {
console.error(e)
return false
}
}
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,77 +0,0 @@
import {
AudioMimeType,
BytesHasPrefix,
GetArrayBuffer,
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt
} from "@/decrypt/utils";
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import {DecryptResult} from "@/decrypt/entity";
const MagicHeader = [
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D,
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65,
]
const PreDefinedKey = "MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk"
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> {
const oriData = new Uint8Array(await GetArrayBuffer(file));
if (!BytesHasPrefix(oriData, MagicHeader)) {
if (SniffAudioExt(oriData) === "aac") {
return await RawDecrypt(file, raw_filename, "aac", false)
}
throw Error("not a valid kwm file")
}
let fileKey = oriData.slice(0x18, 0x20)
let mask = createMaskFromKey(fileKey)
let audioData = oriData.slice(0x400);
let lenAudioData = audioData.length;
for (let cur = 0; cur < lenAudioData; ++cur)
audioData[cur] ^= mask[cur % 0x20];
const ext = SniffAudioExt(audioData);
const mime = AudioMimeType[ext];
let musicBlob = new Blob([audioData], {type: mime});
const musicMeta = await metaParseBlob(musicBlob);
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
return {
album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob),
blob: musicBlob,
mime,
title,
artist,
ext
}
}
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array {
let keyView = new DataView(keyBytes.buffer)
let keyStr = keyView.getBigUint64(0, true).toString()
let keyStrTrim = trimKey(keyStr)
let key = new Uint8Array(32)
for (let i = 0; i < 32; i++) {
key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i)
}
return key
}
function trimKey(keyRaw: string): string {
let lenRaw = keyRaw.length;
let out = keyRaw;
if (lenRaw > 32) {
out = keyRaw.slice(0, 32)
} else if (lenRaw < 32) {
out = keyRaw.padEnd(32, keyRaw)
}
return out
}

158
src/decrypt/ncm.js Normal file
View File

@@ -0,0 +1,158 @@
const CryptoJS = require("crypto-js");
const ID3Writer = require("browser-id3-writer");
const CORE_KEY = CryptoJS.enc.Hex.parse("687a4852416d736f356b496e62617857");
const META_KEY = CryptoJS.enc.Hex.parse("2331346C6A6B5F215C5D2630553C2728");
import {AudioMimeType, DetectAudioExt, GetArrayBuffer} from "./util"
export async function Decrypt(file) {
const fileBuffer = await GetArrayBuffer(file);
const dataView = new DataView(fileBuffer);
if (dataView.getUint32(0, true) !== 0x4e455443 ||
dataView.getUint32(4, true) !== 0x4d414446)
return {status: false, message: "此ncm文件已损坏"};
const keyDataObj = getKeyData(dataView, fileBuffer, 10);
const keyBox = getKeyBox(keyDataObj.data);
const musicMetaObj = getMetaData(dataView, fileBuffer, keyDataObj.offset);
const musicMeta = musicMetaObj.data;
let audioOffset = musicMetaObj.offset + dataView.getUint32(musicMetaObj.offset + 5, true) + 13;
let audioData = new Uint8Array(fileBuffer, audioOffset);
for (let cur = 0; cur < audioData.length; ++cur) audioData[cur] ^= keyBox[cur & 0xff];
if (musicMeta.format === undefined) musicMeta.format = DetectAudioExt(audioData, "mp3");
const mime = AudioMimeType[musicMeta.format];
const artists = [];
musicMeta.artist.forEach(arr => artists.push(arr[0]));
if (musicMeta.format === "mp3")
audioData = await writeID3(audioData, artists, musicMeta.musicName, musicMeta.album, musicMeta.albumPic);
const musicData = new Blob([audioData], {type: mime});
return {
status: true,
title: musicMeta.musicName,
artist: artists.join(" & "),
ext: musicMeta.format,
album: musicMeta.album,
picture: musicMeta.albumPic,
file: URL.createObjectURL(musicData),
mime: mime
};
}
async function writeID3(audioData, artistList, title, album, picture) {
const writer = new ID3Writer(audioData);
writer.setFrame("TPE1", artistList)
.setFrame("TIT2", title)
.setFrame("TALB", album);
if (picture !== "") {
try {
const img = await (await fetch(picture)).arrayBuffer();
writer.setFrame('APIC', {
type: 3,
data: img,
description: 'Cover'
})
} catch (e) {
console.log("Fail to write cover image!");
}
}
writer.addTag();
return writer.arrayBuffer;
}
function getKeyData(dataView, fileBuffer, offset) {
const keyLen = dataView.getUint32(offset, true);
offset += 4;
const cipherText = new Uint8Array(fileBuffer, offset, keyLen).map(
uint8 => uint8 ^ 0x64
);
offset += keyLen;
const plainText = CryptoJS.AES.decrypt(
{ciphertext: CryptoJS.lib.WordArray.create(cipherText)},
CORE_KEY,
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7
}
);
const result = new Uint8Array(plainText.sigBytes);
const words = plainText.words;
const sigBytes = plainText.sigBytes;
for (let i = 0; i < sigBytes; i++) {
result[i] = (words[i >>> 2] >>> (24 - (i % 4) * 8)) & 0xff;
}
return {offset: offset, data: result.slice(17)};
}
function getKeyBox(keyData) {
const box = new Uint8Array(Array(256).keys());
const keyDataLen = keyData.length;
let j = 0;
for (let i = 0; i < 256; i++) {
j = (box[i] + j + keyData[i % keyDataLen]) & 0xff;
[box[i], box[j]] = [box[j], box[i]];
}
return box.map((_, i, arr) => {
i = (i + 1) & 0xff;
const si = arr[i];
const sj = arr[(i + si) & 0xff];
return arr[(si + sj) & 0xff];
});
}
/**
* @typedef {Object} MusicMetaType
* @property {Number} musicId
* @property {String} musicName
* @property {[[String, Number]]} artist
* @property {String} album
* @property {"flac"|"mp3"} format
* @property {String} albumPic
*/
function getMetaData(dataView, fileBuffer, offset) {
const metaDataLen = dataView.getUint32(offset, true);
offset += 4;
if (metaDataLen === 0) {
return {};
}
const cipherText = new Uint8Array(fileBuffer, offset, metaDataLen).map(
data => data ^ 0x63
);
offset += metaDataLen;
const plainText = CryptoJS.AES.decrypt({
ciphertext: CryptoJS.enc.Base64.parse(
CryptoJS.lib.WordArray.create(cipherText.slice(22)).toString(CryptoJS.enc.Utf8)
)
},
META_KEY,
{mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}
).toString(CryptoJS.enc.Utf8);
const labelIndex = plainText.indexOf(":");
let result = JSON.parse(plainText.slice(labelIndex + 1));
if (plainText.slice(0, labelIndex) === "dj") {
result = result.mainMusic;
}
result.albumPic = result.albumPic.replace("http:", "https:");
return {data: result, offset: offset};
}

79
src/decrypt/qmc.js Normal file
View File

@@ -0,0 +1,79 @@
import {AudioMimeType, DetectAudioExt, GetArrayBuffer, GetCoverURL, GetFileInfo} from "./util";
import {QmcMaskCreate58, QmcMaskDetectMflac, QmcMaskDetectMgg, QmcMaskGetDefault} from "./qmcMask";
const musicMetadata = require("music-metadata-browser");
const HandlerMap = {
"mgg": {handler: QmcMaskDetectMgg, ext: "ogg", detect: true},
"mflac": {handler: QmcMaskDetectMflac, ext: "flac", detect: true},
"qmc0": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"qmc3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"qmcogg": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
"qmcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
"bkcmp3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"bkcflac": {handler: QmcMaskGetDefault, ext: "flac", detect: false},
"tkm": {handler: QmcMaskGetDefault, ext: "m4a", detect: false}
};
export async function Decrypt(file, raw_filename, raw_ext) {
if (!(raw_ext in HandlerMap)) return {status: false, message: "File type is incorrect!"};
const handler = HandlerMap[raw_ext];
const fileData = new Uint8Array(await GetArrayBuffer(file));
let audioData, seed, keyData;
if (handler.detect) {
audioData = fileData.slice(0, -0x170);
seed = handler.handler(audioData);
keyData = fileData.slice(-0x170);
if (seed === undefined) seed = await queryKeyInfo(keyData, raw_filename, raw_ext);
if (seed === undefined) return {status: false, message: raw_ext + "格式仅提供实验性支持!"};
} else {
audioData = fileData;
seed = handler.handler(audioData);
}
const dec = seed.Decrypt(audioData);
const ext = DetectAudioExt(dec, handler.ext);
const mime = AudioMimeType[ext];
const musicData = new Blob([dec], {type: mime});
const tag = await musicMetadata.parseBlob(musicData);
const info = GetFileInfo(tag.common.artist, tag.common.title, raw_filename);
if (handler.detect) reportKeyUsage(keyData, seed.Matrix128,
info.artist, info.title, tag.common.album, raw_filename, raw_ext);
return {
status: true,
title: info.title,
artist: info.artist,
ext: ext,
album: tag.common.album,
picture: GetCoverURL(tag),
file: URL.createObjectURL(musicData),
mime: mime
}
}
function reportKeyUsage(keyData, maskData, artist, title, album, filename, format) {
fetch("https://stats.ixarea.com/collect/qmcmask/usage", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({
Mask: Array.from(maskData), Key: Array.from(keyData),
Artist: artist, Title: title, Album: album, Filename: filename, Format: format
}),
}).then().catch()
}
async function queryKeyInfo(keyData, filename, format) {
try {
const resp = await fetch("https://stats.ixarea.com/collect/qmcmask/query", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({Format: format, Key: Array.from(keyData), Filename: filename}),
});
let data = await resp.json();
return QmcMaskCreate58(data.Matrix58, data.Super58A, data.Super58B);
} catch (e) {
}
}

View File

@@ -1,139 +0,0 @@
import {QmcStaticCipher} from "./qmc_cipher";
import {
AudioMimeType,
GetArrayBuffer,
GetCoverFromFile,
GetImageFromURL,
GetMetaFromFile,
SniffAudioExt,
WriteMetaToFlac,
WriteMetaToMp3
} from "@/decrypt/utils";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import {DecryptQMCv2} from "./qmcv2";
import iconv from "iconv-lite";
import {DecryptResult} from "@/decrypt/entity";
import {queryAlbumCover} from "@/utils/api";
interface Handler {
ext: string
version: number
}
export const HandlerMap: { [key: string]: Handler } = {
"mgg": {ext: "ogg", version: 2},
"mgg1": {ext: "ogg", version: 2},
"mflac": {ext: "flac", version: 2},
"mflac0": {ext: "flac", version: 2},
// qmcflac / qmcogg:
// 有可能是 v2 加密但混用同一个后缀名。
"qmcflac": {ext: "flac", version: 2},
"qmcogg": {ext: "ogg", version: 2},
"qmc0": {ext: "mp3", version: 1},
"qmc2": {ext: "ogg", version: 1},
"qmc3": {ext: "mp3", version: 1},
"bkcmp3": {ext: "mp3", version: 1},
"bkcflac": {ext: "flac", version: 1},
"tkm": {ext: "m4a", version: 1},
"666c6163": {ext: "flac", version: 1},
"6d7033": {ext: "mp3", version: 1},
"6f6767": {ext: "ogg", version: 1},
"6d3461": {ext: "m4a", version: 1},
"776176": {ext: "wav", version: 1}
};
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
const handler = HandlerMap[raw_ext];
let {version} = handler;
const fileBuffer = await GetArrayBuffer(file);
let musicDecoded: Uint8Array | undefined;
if (version === 2) {
const v2Decrypted = await DecryptQMCv2(fileBuffer);
// 如果 v2 检测失败,降级到 v1 再尝试一次
if (v2Decrypted) {
musicDecoded = v2Decrypted;
} else {
version = 1;
}
}
if (version === 1) {
const seed = new QmcStaticCipher();
musicDecoded = new Uint8Array(fileBuffer)
seed.decrypt(musicDecoded, 0);
} else if (!musicDecoded) {
throw new Error(`解密失败: ${raw_ext}`);
}
const ext = SniffAudioExt(musicDecoded, handler.ext);
const mime = AudioMimeType[ext];
let musicBlob = new Blob([musicDecoded], {type: mime});
const musicMeta = await metaParseBlob(musicBlob);
for (let metaIdx in musicMeta.native) {
if (!musicMeta.native.hasOwnProperty(metaIdx)) continue
if (musicMeta.native[metaIdx].some(item => item.id === "TCON" && item.value === "(12)")) {
console.warn("try using gbk encoding to decode meta")
musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ""), "gbk");
musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ""), "gbk");
musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ""), "gbk");
}
}
const info = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist)
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 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 ""
}

202
src/decrypt/qmcMask.js Normal file
View File

@@ -0,0 +1,202 @@
import {FLAC_HEADER, IsBytesEqual} from "./util"
const QMOggConstHeader = [
0x4F, 0x67, 0x67, 0x53, 0x00, 0x02, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 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, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x10, 0x00, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF,
0xFF, 0xFF, 0xFF, 0xFF, 0x00, 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, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00, 0x00, 0x54, 0x49, 0x54, 0x4C, 0x45, 0x3D];
const QMOggConstHeaderConfidence = [
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, 6, 0, 3, 3, 3, 3, 3, 3, 3, 3, 3, 3,
3, 3, 3, 3, 0, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9,
9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 0, 1, 9, 9,
0, 1, 9, 9, 9, 9, 9, 9, 9, 9];
const QMCDefaultMaskMatrix = [
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0x5E,
0x95, 0x23, 0x9F, 0x13, 0x11, 0x7E, 0x47, 0x74,
0x3D, 0x90, 0xAA, 0x3F, 0x51, 0xC6, 0x09, 0xD5,
0x9F, 0xFA, 0x66, 0xF9, 0xF3, 0xD6, 0xA1, 0x90,
0xA0, 0xF7, 0xF0, 0x1D, 0x95, 0xDE, 0x9F, 0x84,
0x11, 0xF4, 0x0E, 0x74, 0xBB, 0x90, 0xBC, 0x3F,
0x92, 0x00, 0x09, 0x5B, 0x9F, 0x62, 0x66, 0xA1];
const QMCDefaultMaskSuperA = 0xC3;
const QMCDefaultMaskSuperB = 0xD8;
class QmcMask {
constructor(matrix, superA, superB) {
if (superA === undefined || superB === undefined) {
this.Matrix128 = matrix;
this.generateMask58from128()
} else {
this.Matrix58 = matrix;
this.Super58A = superA;
this.Super58B = superB;
this.generateMask128from58();
}
}
generateMask128from58() {
if (this.Matrix58.length !== 56) throw "incorrect mask58 matrix length";
let matrix128 = [];
for (let rowIdx = 0; rowIdx < 8; rowIdx += 1) {
matrix128 = matrix128.concat(
[this.Super58A],
this.Matrix58.slice(7 * rowIdx, 7 * rowIdx + 7),
[this.Super58B],
this.Matrix58.slice(56 - 7 - 7 * rowIdx, 56 - 7 * rowIdx).reverse()
);
}
this.Matrix128 = matrix128;
}
generateMask58from128() {
if (this.Matrix128.length !== 128) throw "incorrect mask128 length";
const superA = this.Matrix128[0], superB = this.Matrix128[8];
let matrix58 = [];
for (let rowIdx = 0; rowIdx < 8; rowIdx += 1) {
let lenStart = 16 * rowIdx;
let lenRightStart = 120 - lenStart;
if (this.Matrix128[lenStart] !== superA || this.Matrix128[lenStart + 8] !== superB) {
throw "decode mask-128 to mask-58 failed"
}
let rowLeft = this.Matrix128.slice(lenStart + 1, lenStart + 8);
let rowRight = this.Matrix128.slice(lenRightStart + 1, lenRightStart + 8).reverse();
if (IsBytesEqual(rowLeft, rowRight)) {
matrix58 = matrix58.concat(rowLeft);
} else {
throw "decode mask-128 to mask-58 failed"
}
}
this.Matrix58 = matrix58;
this.Super58A = superA;
this.Super58B = superB;
}
Decrypt(data) {
let dst = data.slice(0);
let index = -1;
let maskIdx = -1;
for (let cur = 0; cur < data.length; cur++) {
index++;
maskIdx++;
if (index === 0x8000 || (index > 0x8000 && (index + 1) % 0x8000 === 0)) {
index++;
maskIdx++;
}
if (maskIdx >= 128) maskIdx -= 128;
dst[cur] ^= this.Matrix128[maskIdx];
}
return dst;
}
}
export function QmcMaskGetDefault() {
return new QmcMask(QMCDefaultMaskMatrix, QMCDefaultMaskSuperA, QMCDefaultMaskSuperB)
}
export function QmcMaskDetectMflac(data) {
let search_len = Math.min(0x8000, data.length), mask;
for (let block_idx = 0; block_idx < search_len; block_idx += 128) {
try {
mask = new QmcMask(data.slice(block_idx, block_idx + 128));
if (IsBytesEqual(FLAC_HEADER, mask.Decrypt(data.slice(0, FLAC_HEADER.length)))) break;
} catch (e) {
}
}
return mask
}
export function QmcMaskDetectMgg(input) {
if (input.length < QMOggConstHeader.length) return;
let matrixConfidence = {};
for (let i = 0; i < 58; i++) matrixConfidence[i] = {};
for (let idx128 = 0; idx128 < QMOggConstHeader.length; idx128++) {
if (QMOggConstHeaderConfidence[idx128] === 0) continue;
let idx58 = GetMask58Index(idx128);
let mask = input[idx128] ^ QMOggConstHeader[idx128];
let confidence = QMOggConstHeaderConfidence[idx128];
if (mask in matrixConfidence[idx58]) {
matrixConfidence[idx58][mask] += confidence
} else {
matrixConfidence[idx58][mask] = confidence
}
}
let matrix = [], superA, superB;
try {
for (let i = 0; i < 56; i++) matrix[i] = getMaskConfidenceResult(matrixConfidence[i]);
superA = getMaskConfidenceResult(matrixConfidence[56]);
superB = getMaskConfidenceResult(matrixConfidence[57]);
} catch (e) {
return;
}
return new QmcMask(matrix, superA, superB);
}
export function QmcMaskCreate128(mask128) {
return new QmcMask(mask128)
}
export function QmcMaskCreate58(matrix, superA, superB) {
return new QmcMask(matrix, superA, superB)
}
/**
* @param confidence {{}}
* @returns {number}
*/
function getMaskConfidenceResult(confidence) {
if (confidence.length === 0) throw "can not match at least one key";
let result, conf = 0;
for (let idx in confidence) {
if (confidence[idx] > conf) {
result = idx;
conf = confidence[idx];
}
}
return parseInt(result)
}
/**
* @return {number}
*/
function GetMask58Index(idx128) {
if (idx128 > 127) idx128 = idx128 % 128;
let col = idx128 % 16;
let row = (idx128 - col) / 16;
switch (col) {
case 0://Super 1
row = 8;
col = 0;
break;
case 8://Super 2
row = 8;
col = 1;
break;
default:
if (col > 7) {
row = 7 - row;
col = 15 - col;
} else {
col -= 1;
}
break;
}
return row * 7 + col
}

View File

@@ -1,27 +0,0 @@
import {QmcStaticCipher} from "@/decrypt/qmc_cipher";
test("static cipher [0x7ff8,0x8000) ", () => {
const expected = new Uint8Array([
0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A,
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8,
])
const c = new QmcStaticCipher()
const buf = new Uint8Array(16)
c.decrypt(buf, 0x7ff8)
expect(buf).toStrictEqual(expected)
})
test("static cipher [0,0x10) ", () => {
const expected = new Uint8Array([
0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52,
0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00,
])
const c = new QmcStaticCipher()
const buf = new Uint8Array(16)
c.decrypt(buf, 0)
expect(buf).toStrictEqual(expected)
})

View File

@@ -1,53 +0,0 @@
const staticCipherBox = new Uint8Array([
0x77, 0x48, 0x32, 0x73, 0xDE, 0xF2, 0xC0, 0xC8, //0x00
0x95, 0xEC, 0x30, 0xB2, 0x51, 0xC3, 0xE1, 0xA0, //0x08
0x9E, 0xE6, 0x9D, 0xCF, 0xFA, 0x7F, 0x14, 0xD1, //0x10
0xCE, 0xB8, 0xDC, 0xC3, 0x4A, 0x67, 0x93, 0xD6, //0x18
0x28, 0xC2, 0x91, 0x70, 0xCA, 0x8D, 0xA2, 0xA4, //0x20
0xF0, 0x08, 0x61, 0x90, 0x7E, 0x6F, 0xA2, 0xE0, //0x28
0xEB, 0xAE, 0x3E, 0xB6, 0x67, 0xC7, 0x92, 0xF4, //0x30
0x91, 0xB5, 0xF6, 0x6C, 0x5E, 0x84, 0x40, 0xF7, //0x38
0xF3, 0x1B, 0x02, 0x7F, 0xD5, 0xAB, 0x41, 0x89, //0x40
0x28, 0xF4, 0x25, 0xCC, 0x52, 0x11, 0xAD, 0x43, //0x48
0x68, 0xA6, 0x41, 0x8B, 0x84, 0xB5, 0xFF, 0x2C, //0x50
0x92, 0x4A, 0x26, 0xD8, 0x47, 0x6A, 0x7C, 0x95, //0x58
0x61, 0xCC, 0xE6, 0xCB, 0xBB, 0x3F, 0x47, 0x58, //0x60
0x89, 0x75, 0xC3, 0x75, 0xA1, 0xD9, 0xAF, 0xCC, //0x68
0x08, 0x73, 0x17, 0xDC, 0xAA, 0x9A, 0xA2, 0x16, //0x70
0x41, 0xD8, 0xA2, 0x06, 0xC6, 0x8B, 0xFC, 0x66, //0x78
0x34, 0x9F, 0xCF, 0x18, 0x23, 0xA0, 0x0A, 0x74, //0x80
0xE7, 0x2B, 0x27, 0x70, 0x92, 0xE9, 0xAF, 0x37, //0x88
0xE6, 0x8C, 0xA7, 0xBC, 0x62, 0x65, 0x9C, 0xC2, //0x90
0x08, 0xC9, 0x88, 0xB3, 0xF3, 0x43, 0xAC, 0x74, //0x98
0x2C, 0x0F, 0xD4, 0xAF, 0xA1, 0xC3, 0x01, 0x64, //0xA0
0x95, 0x4E, 0x48, 0x9F, 0xF4, 0x35, 0x78, 0x95, //0xA8
0x7A, 0x39, 0xD6, 0x6A, 0xA0, 0x6D, 0x40, 0xE8, //0xB0
0x4F, 0xA8, 0xEF, 0x11, 0x1D, 0xF3, 0x1B, 0x3F, //0xB8
0x3F, 0x07, 0xDD, 0x6F, 0x5B, 0x19, 0x30, 0x19, //0xC0
0xFB, 0xEF, 0x0E, 0x37, 0xF0, 0x0E, 0xCD, 0x16, //0xC8
0x49, 0xFE, 0x53, 0x47, 0x13, 0x1A, 0xBD, 0xA4, //0xD0
0xF1, 0x40, 0x19, 0x60, 0x0E, 0xED, 0x68, 0x09, //0xD8
0x06, 0x5F, 0x4D, 0xCF, 0x3D, 0x1A, 0xFE, 0x20, //0xE0
0x77, 0xE4, 0xD9, 0xDA, 0xF9, 0xA4, 0x2B, 0x76, //0xE8
0x1C, 0x71, 0xDB, 0x00, 0xBC, 0xFD, 0x0C, 0x6C, //0xF0
0xA5, 0x47, 0xF7, 0xF6, 0x00, 0x79, 0x4A, 0x11, //0xF8
])
interface streamCipher {
decrypt(buf: Uint8Array, offset: number): void
}
export class QmcStaticCipher implements streamCipher {
public getMask(offset: number) {
if (offset > 0x7FFF) offset %= 0x7FFF
return staticCipherBox[(offset * offset + 27) & 0xff]
}
public decrypt(buf: Uint8Array, offset: number) {
for (let i = 0; i < buf.length; i++) {
buf[i] ^= this.getMask(offset + i)
}
}
}

View File

@@ -1,51 +0,0 @@
import {
AudioMimeType,
GetArrayBuffer,
GetCoverFromFile,
GetMetaFromFile,
SniffAudioExt,
SplitFilename
} from "@/decrypt/utils";
import {Decrypt as QmcDecrypt, HandlerMap} from "@/decrypt/qmc";
import {DecryptResult} from "@/decrypt/entity";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
export async function Decrypt(file: Blob, raw_filename: string, _: string)
: Promise<DecryptResult> {
const buffer = new Uint8Array(await GetArrayBuffer(file));
let length = buffer.length
for (let i = 0; i < length; i++) {
buffer[i] ^= 0xf4
if (buffer[i] <= 0x3f) buffer[i] = buffer[i] * 4;
else if (buffer[i] <= 0x7f) buffer[i] = (buffer[i] - 0x40) * 4 + 1;
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2;
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3;
}
let ext = SniffAudioExt(buffer, "");
const newName = SplitFilename(raw_filename)
let audioBlob: Blob
if (ext !== "" || newName.ext === "mp3") {
audioBlob = new Blob([buffer], {type: AudioMimeType[ext]})
} else if (newName.ext in HandlerMap) {
audioBlob = new Blob([buffer], {type: "application/octet-stream"})
return QmcDecrypt(audioBlob, newName.name, newName.ext);
} else {
throw "不支持的QQ音乐缓存格式"
}
const tag = await metaParseBlob(audioBlob);
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist)
return {
title,
artist,
ext,
album: tag.common.album,
picture: GetCoverFromFile(tag),
file: URL.createObjectURL(audioBlob),
blob: audioBlob,
mime: AudioMimeType[ext]
}
}

View File

@@ -1,102 +0,0 @@
import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle';
// 检测文件末端使用的缓冲区大小
const DETECTION_SIZE = 40;
// 每次处理 2M 的数据
const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024;
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;
}
/**
* 解密一个 QMC2 加密的文件。
*
* 如果检测并解密成功,返回解密后的 Uint8Array 数据。
* @param {ArrayBuffer} mggBlob 读入的文件 Blob
* @return {Promise<Uint8Array|false>}
*/
export async function DecryptQMCv2(mggBlob: ArrayBuffer) {
// 初始化模组
const QMCCrypto = await QMCCryptoModule();
// 申请内存块,并文件末端数据到 WASM 的内存堆
const detectionBuf = new Uint8Array(mggBlob.slice(-DETECTION_SIZE));
const pDetectionBuf = QMCCrypto._malloc(detectionBuf.length);
QMCCrypto.writeArrayToMemory(detectionBuf, pDetectionBuf);
// 检测结果内存块
const pDetectionResult = QMCCrypto._malloc(QMCCrypto.sizeof_qmc_detection());
// 进行检测
const detectOK = QMCCrypto.detectKeyEndPosition(
pDetectionResult,
pDetectionBuf,
detectionBuf.length
);
// 提取结构体内容:
// (pos: i32; len: i32; error: char[??])
const position = QMCCrypto.getValue(pDetectionResult, "i32");
const len = QMCCrypto.getValue(pDetectionResult + 4, "i32");
// 释放内存
QMCCrypto._free(pDetectionBuf);
QMCCrypto._free(pDetectionResult);
if (!detectOK) {
return false;
}
// 计算解密后文件的大小。
// 之前得到的 position 为相对当前检测数据起点的偏移。
const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position;
// 提取嵌入到文件的 EKey
const ekey = new Uint8Array(
mggBlob.slice(decryptedSize, decryptedSize + len)
);
// 解码 UTF-8 数据到 string
const decoder = new TextDecoder();
const ekey_b64 = decoder.decode(ekey);
// 初始化加密与缓冲区
const hCrypto = QMCCrypto.createInstWidthEKey(ekey_b64);
const buf = QMCCrypto._malloc(DECRYPTION_BUF_SIZE);
const decryptedParts = [];
let offset = 0;
let bytesToDecrypt = decryptedSize;
while (bytesToDecrypt > 0) {
const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE);
// 解密一些片段
const blockData = new Uint8Array(
mggBlob.slice(offset, offset + blockSize)
);
QMCCrypto.writeArrayToMemory(blockData, buf);
QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize);
decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize));
offset += blockSize;
bytesToDecrypt -= blockSize;
}
QMCCrypto._free(buf);
hCrypto.delete();
return MergeUint8Array(decryptedParts);
}

23
src/decrypt/raw.js Normal file
View File

@@ -0,0 +1,23 @@
const musicMetadata = require("music-metadata-browser");
import {AudioMimeType, DetectAudioExt, GetArrayBuffer, GetCoverURL, GetFileInfo} from "./util";
export async function Decrypt(file, raw_filename, raw_ext, detect = true) {
let ext = raw_ext;
if (detect) {
const buffer = new Uint8Array(await GetArrayBuffer(file));
ext = DetectAudioExt(buffer, raw_ext);
if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]})
}
const tag = await musicMetadata.parseBlob(file);
const info = GetFileInfo(tag.common.artist, tag.common.title, raw_filename);
return {
status: true,
title: info.title,
artist: info.artist,
ext: ext,
album: tag.common.album,
picture: GetCoverURL(tag),
file: URL.createObjectURL(file),
mime: AudioMimeType[ext]
}
}

View File

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

View File

@@ -1,11 +1,11 @@
import {Decrypt as RawDecrypt} from "./raw";
import {GetArrayBuffer} from "@/decrypt/utils";
import {DecryptResult} from "@/decrypt/entity";
import {GetArrayBuffer} from "./util";
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70];
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> {
const audioData = new Uint8Array(await GetArrayBuffer(file));
export async function Decrypt(file, raw_filename) {
const fileBuffer = await GetArrayBuffer(file);
const audioData = new Uint8Array(fileBuffer);
for (let cur = 0; cur < 8; ++cur) {
audioData[cur] = TM_HEADER[cur];
}

66
src/decrypt/util.js Normal file
View File

@@ -0,0 +1,66 @@
export const FLAC_HEADER = [0x66, 0x4C, 0x61, 0x43];
export const MP3_HEADER = [0x49, 0x44, 0x33];
export const OGG_HEADER = [0x4F, 0x67, 0x67, 0x53];
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
export const AudioMimeType = {
mp3: "audio/mpeg",
flac: "audio/flac",
m4a: "audio/mp4",
ogg: "audio/ogg"
};
// Also a new draft API: blob.arrayBuffer()
export async function GetArrayBuffer(blobObject) {
return await new Promise(resolve => {
const reader = new FileReader();
reader.onload = (e) => {
resolve(e.target.result);
};
reader.readAsArrayBuffer(blobObject);
});
}
export function GetFileInfo(artist, title, filenameNoExt) {
let newArtist = "", newTitle = "";
let filenameArray = filenameNoExt.split("-");
if (filenameArray.length > 1) {
newArtist = filenameArray[0].trim();
newTitle = filenameArray[1].trim();
} else if (filenameArray.length === 1) {
newTitle = filenameArray[0].trim();
}
if (typeof artist == "string" && artist !== "") newArtist = artist;
if (typeof title == "string" && title !== "") newTitle = title;
return {artist: newArtist, title: newTitle};
}
/**
* @return {string}
*/
export function GetCoverURL(metadata) {
let pic_url = "";
if (metadata.common.picture !== undefined && metadata.common.picture.length > 0) {
let pic = new Blob([metadata.common.picture[0].data], {type: metadata.common.picture[0].format});
pic_url = URL.createObjectURL(pic);
}
return pic_url;
}
export function IsBytesEqual(first, second) {
// if want wholly check, should length first>=second
return first.every((val, idx) => {
return val === second[idx];
})
}
/**
* @return {string}
*/
export function DetectAudioExt(data, fallbackExt) {
if (IsBytesEqual(MP3_HEADER, data.slice(0, MP3_HEADER.length))) return "mp3";
if (IsBytesEqual(FLAC_HEADER, data.slice(0, FLAC_HEADER.length))) return "flac";
if (IsBytesEqual(OGG_HEADER, data.slice(0, OGG_HEADER.length))) return "ogg";
if (IsBytesEqual(M4A_HEADER, data.slice(4, 8))) return "m4a";
return fallbackExt;
}

View File

@@ -1,170 +0,0 @@
import {IAudioMetadata} from "music-metadata-browser";
import ID3Writer from "browser-id3-writer";
import MetaFlac from "metaflac-js";
export const FLAC_HEADER = [0x66, 0x4C, 0x61, 0x43];
export const MP3_HEADER = [0x49, 0x44, 0x33];
export const OGG_HEADER = [0x4F, 0x67, 0x67, 0x53];
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70];
export const WMA_HEADER = [
0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11,
0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C,
]
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]
export const AAC_HEADER = [0xFF, 0xF1]
export const DFF_HEADER = [0x46, 0x52, 0x4D, 0x38]
export const AudioMimeType: { [key: string]: string } = {
mp3: "audio/mpeg",
flac: "audio/flac",
m4a: "audio/mp4",
ogg: "audio/ogg",
wma: "audio/x-ms-wma",
wav: "audio/x-wav",
dff: "audio/x-dff"
};
export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean {
if (prefix.length > data.length) return false
return prefix.every((val, idx) => {
return val === data[idx];
})
}
export function SniffAudioExt(data: Uint8Array, fallback_ext: string = "mp3"): string {
if (BytesHasPrefix(data, MP3_HEADER)) return "mp3"
if (BytesHasPrefix(data, FLAC_HEADER)) return "flac"
if (BytesHasPrefix(data, OGG_HEADER)) return "ogg"
if (data.length >= 4 + M4A_HEADER.length &&
BytesHasPrefix(data.slice(4), M4A_HEADER)) return "m4a"
if (BytesHasPrefix(data, WAV_HEADER)) return "wav"
if (BytesHasPrefix(data, WMA_HEADER)) return "wma"
if (BytesHasPrefix(data, AAC_HEADER)) return "aac"
if (BytesHasPrefix(data, DFF_HEADER)) return "dff"
return fallback_ext;
}
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> {
if (!!obj.arrayBuffer) return obj.arrayBuffer()
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (e) => {
const rs = e.target?.result
if (!rs) {
reject("read file failed")
} else {
resolve(rs as ArrayBuffer)
}
};
reader.readAsArrayBuffer(obj);
});
}
export function GetCoverFromFile(metadata: IAudioMetadata): string {
if (metadata.common?.picture && metadata.common.picture.length > 0) {
return URL.createObjectURL(new Blob(
[metadata.common.picture[0].data],
{type: metadata.common.picture[0].format}
));
}
return "";
}
export interface IMusicMetaBasic {
title: string
artist?: string
}
export function GetMetaFromFile(filename: string, exist_title?: string, exist_artist?: string, separator = "-")
: IMusicMetaBasic {
const meta: IMusicMetaBasic = {title: exist_title ?? "", artist: exist_artist}
const items = filename.split(separator);
if (items.length > 1) {
if (!meta.artist) meta.artist = items[0].trim();
if (!meta.title) meta.title = items[1].trim();
} else if (items.length === 1) {
if (!meta.title) meta.title = items[0].trim();
}
return meta
}
export async function GetImageFromURL(src: string):
Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> {
try {
const resp = await fetch(src);
const mime = resp.headers.get("Content-Type");
if (mime?.startsWith("image/")) {
const buffer = await resp.arrayBuffer();
const url = URL.createObjectURL(new Blob([buffer], {type: mime}))
return {buffer, url, mime}
}
} catch (e) {
console.warn(e)
}
}
export interface IMusicMeta {
title: string
artists?: string[]
album?: string
picture?: ArrayBuffer
picture_desc?: string
}
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new ID3Writer(audioData);
// reserve original data
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []
frames.forEach(frame => {
if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') {
try {
writer.setFrame(frame.id, frame.value)
} catch (e) {
}
}
})
const old = original.common
writer.setFrame('TPE1', old?.artists || info.artists || [])
.setFrame('TIT2', old?.title || info.title)
.setFrame('TALB', old?.album || info.album || "");
if (info.picture) {
writer.setFrame('APIC', {
type: 3,
data: info.picture,
description: info.picture_desc || "Cover",
})
}
return writer.addTag();
}
export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) {
const writer = new MetaFlac(audioData)
const old = original.common
if (!old.title && !old.album && old.artists) {
writer.setTag("TITLE=" + info.title)
writer.setTag("ALBUM=" + info.album)
if (info.artists) {
writer.removeTag("ARTIST")
info.artists.forEach(artist => writer.setTag("ARTIST=" + artist))
}
}
if (info.picture) {
writer.importPictureFromBuffer(Buffer.from(info.picture))
}
return writer.save()
}
export function SplitFilename(n: string): { name: string; ext: string } {
const pos = n.lastIndexOf(".")
return {
ext: n.substring(pos + 1).toLowerCase(),
name: n.substring(0, pos)
}
}

View File

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

View File

@@ -1,13 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
</head>
<body>
<script src="./popup.js"></script>
<a href="./index.html" target="_blank">
<button>立即使用</button>
</a>
</body>
</html>

View File

@@ -1,5 +0,0 @@
const bs = chrome || browser
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,27 +1,12 @@
import Vue from 'vue'
import App from '@/App.vue'
import '@/registerServiceWorker'
import App from './App.vue'
import './registerServiceWorker'
import {
Button,
Checkbox,
Col,
Container,
Footer,
Icon,
Image,
Link,
Main,
Button, Col, Container, Footer, Icon, Image, Link, Main,
Row, Table, TableColumn, Upload, Radio, Checkbox,
Notification,
Progress,
Radio,
Row,
Table,
TableColumn,
Tooltip,
Upload,
MessageBox
} from 'element-ui';
import 'element-ui/lib/theme-chalk/base.css';
import 'element-ui/lib/theme-chalk/index.css'
Vue.use(Link);
Vue.use(Image);
@@ -37,12 +22,10 @@ Vue.use(Col);
Vue.use(Upload);
Vue.use(Checkbox);
Vue.use(Radio);
Vue.use(Tooltip);
Vue.use(Progress);
Vue.prototype.$notify = Notification;
Vue.prototype.$confirm = MessageBox.confirm;
Vue.config.productionTip = false;
new Vue({
render: h => h(App),
}).$mount('#app');

View File

@@ -2,11 +2,13 @@
import {register} from 'register-service-worker'
if (process.env.NODE_ENV === 'production' && window.location.protocol === "https:") {
if (process.env.NODE_ENV === 'production') {
register(`${process.env.BASE_URL}service-worker.js`, {
ready() {
console.log('App is being served from cache by a service worker.')
console.log(
'App is being served from cache by a service worker.\n' +
'For more details, visit https://goo.gl/AFskqB'
)
},
registered() {
console.log('Service worker has been registered.')

View File

@@ -1,166 +0,0 @@
/*
* name: 样式 - 夜间模式
* author: @KyleBing
* date: 2020-11-24
*/
@media (prefers-color-scheme: dark) {
#app{
color: $dark-text-info;
}
body{
background-color: $dark-bg;
}
// FORM
.el-radio{
&__label{
color: $dark-text-main;
}
&__input{
color: $dark-text-info;
.el-radio__inner{
border-color: $dark-border;
background-color: $dark-btn-bg;
}
}
&.is-checked{
.el-radio__inner{
background-color: $blue;
}
.el-radio__label{
font-weight: bold;
}
}
}
.el-checkbox.is-bordered{
border-color: $dark-border;
.el-checkbox__inner{
background-color: $dark-btn-bg;
border-color: $dark-border;
}
&:hover{
border-color: $dark-border-highlight;
.el-checkbox__inner{
background-color: $dark-btn-bg-highlight;
border-color: $dark-border-highlight;
}
.el-checkbox__label{
color: $dark-text-info;
}
}
&.is-checked{
background-color: $blue;
.el-checkbox__inner{
border-color: $dark-btn-bg-highlight;
}
.el-checkbox__label{
color: white;
font-weight: bold;
}
}
}
// BUTTON
.el-button{
background-color: $dark-btn-bg;
border-color: $dark-border;
color: $dark-text-main;
&:active{
transform: translateY(2px);
}
&--default{
&.is-plain {
background-color: $dark-btn-bg;
&:hover {
background-color: $blue;
border-color: $blue;
color: white;
}
}
}
&--danger{
&.is-plain{
border-color: $dark-border;
background-color: $dark-btn-bg;
&:hover{
background-color: $red;
border-color: $red;
}
}
}
}
// 文件拖放区
.el-upload__tip{
color: $dark-text-info;
}
.el-upload-dragger{
background-color: $dark-uploader-bg;
border-color: $dark-border;
.el-upload__text{
color: $dark-text-info;
}
&:hover{
background: $dark-uploader-bg-highlight;
border-color: $dark-border-highlight;
}
}
//TABLE
.el-table{
background-color: $dark-bg-td;
&:before{ // 去除表格末尾的横线
content: none;
}
&__header{
th{
border-bottom-color: $dark-border !important;
}
}
th{
background-color: $dark-bg-th;
color: $dark-text-info;
}
td{
border-bottom-color: $dark-border !important;
}
tr{
background-color: $dark-bg-td;
color: $dark-text-main;
&:hover{
td{
background-color: $dark-bg-th !important;
}
}
}
}
// LINKS
a{
text-decoration: none;
color: darken($dark-color-link, 15%);
&:hover{
color: $dark-color-link;
}
}
// ALERT
.el-notification{
background-color: $dark-btn-bg-highlight;
border-color: $dark-border;
&__title{
color: white;
}
&__content{
color: $dark-text-info;
}
}
}

View File

@@ -1,18 +0,0 @@
/*
* 间隔工具集
*/
$gap: 5px;
@for $item from 1 through 7 {
.mt-#{$item} { margin-top : $gap * $item !important;}
.mb-#{$item} { margin-bottom : $gap * $item !important;}
.ml-#{$item} { margin-left : $gap * $item !important;}
.mr-#{$item} { margin-right : $gap * $item !important;}
.m-#{$item} { margin : $gap * $item !important;}
.pt-#{$item} { padding-top : $gap * $item !important;}
.pb-#{$item} { padding-bottom : $gap * $item !important;}
.pl-#{$item} { padding-left : $gap * $item !important;}
.pr-#{$item} { padding-right : $gap * $item !important;}
.p-#{$item} { padding : $gap * $item !important;}
}

View File

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

View File

@@ -1,28 +0,0 @@
// COLORS
$blue : #409EFF;
$red : #F56C6C;
$green : #85ce61;
// TEXT
$text-main : #2C3E50;
$color-link: $blue;
$fz-main: 14px;
$font-family: "Helvetica Neue", Helvetica, "PingFang SC",
"Hiragino Sans GB", "Microsoft YaHei", "微软雅黑", Arial, sans-serif;
// DARK MODE
$dark-border : lighten(black, 25%);
$dark-border-highlight : lighten(black, 55%);
$dark-bg : lighten(black, 10%);
$dark-text-main : lighten(black, 90%);
$dark-text-info : lighten(black, 60%);
$dark-uploader-bg : lighten(black, 13%);
$dark-uploader-bg-highlight : lighten(black, 18%);
$dark-btn-bg : lighten(black, 20%);
$dark-btn-bg-highlight : lighten(black, 30%);
$dark-bg-th : lighten(black, 18%);
$dark-bg-td : lighten(black, 13%);
$dark-color-link : $green;

View File

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

View File

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

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

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

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

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

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

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

View File

@@ -1,56 +0,0 @@
import {fromByteArray as Base64Encode} from "base64-js";
export const IXAREA_API_ENDPOINT = "https://um-api.ixarea.com"
export interface UpdateInfo {
Found: boolean
HttpsFound: boolean
Version: string
URL: string
Detail: string
}
export async function checkUpdate(version: string): Promise<UpdateInfo> {
const resp = await fetch(IXAREA_API_ENDPOINT + "/music/app-version", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({"Version": version})
});
return await resp.json();
}
export 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 {
Id: string
Type: number
}
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> {
const endpoint = IXAREA_API_ENDPOINT + "/music/qq-cover"
const params = new URLSearchParams([["Title", title], ["Artist", artist ?? ""], ["Album", album ?? ""]])
const resp = await fetch(`${endpoint}?${params.toString()}`)
return await resp.json()
}

View File

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

View File

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

View File

@@ -1,157 +0,0 @@
<template>
<div>
<file-selector @error="showFail" @success="showSuccess"/>
<div id="app-control">
<el-row class="mb-3">
<span>歌曲命名格式</span>
<el-radio v-for="k in FilenamePolicies" :key="k.key"
v-model="filename_policy" :label="k.key">
{{ k.text }}
</el-radio>
</el-row>
<el-row>
<el-button icon="el-icon-download" plain @click="handleDownloadAll">下载全部</el-button>
<el-button icon="el-icon-delete" plain type="danger" @click="handleDeleteAll">清除全部</el-button>
<el-tooltip class="item" effect="dark" placement="top-start">
<div slot="content">
<span v-if="instant_save">工作模式: {{ dir ? "写入本地文件系统" : "调用浏览器下载" }}</span>
<span v-else>
当您使用此工具进行大量文件解锁的时候建议开启此选项<br/>
开启后解锁结果将不会存留于浏览器中防止内存不足
</span>
</div>
<el-checkbox v-model="instant_save" border class="ml-2">立即保存</el-checkbox>
</el-tooltip>
</el-row>
</div>
<audio :autoplay="playing_auto" :src="playing_url" controls/>
<PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @play="changePlaying"/>
</div>
</template>
<script>
import FileSelector from "@/component/FileSelector"
import PreviewTable from "@/component/PreviewTable"
import {DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile} from "@/utils/utils"
export default {
name: 'Home',
components: {
FileSelector,
PreviewTable
},
data() {
return {
tableData: [],
playing_url: "",
playing_auto: false,
filename_policy: FilenamePolicy.ArtistAndTitle,
instant_save: false,
FilenamePolicies,
dir: null
}
},
watch: {
instant_save(val) {
if (val) this.showDirectlySave()
}
},
methods: {
async showSuccess(data) {
if (this.instant_save) {
await this.saveFile(data)
RemoveBlobMusic(data);
} else {
this.tableData.push(data);
this.$notify.success({
title: '解锁成功',
message: '成功解锁 ' + data.title,
duration: 3000
});
}
if (process.env.NODE_ENV === 'production') {
let _rp_data = [data.title, data.artist, data.album];
window._paq.push(["trackEvent", "Unlock", data.rawExt + "," + data.mime, JSON.stringify(_rp_data)]);
}
},
showFail(errInfo, filename) {
console.error(errInfo, filename)
this.$notify.error({
title: '出现问题',
message: errInfo + "" + filename +
',参考<a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>',
dangerouslyUseHTMLString: true,
duration: 6000
});
if (process.env.NODE_ENV === 'production') {
window._paq.push(["trackEvent", "Error", String(errInfo), filename]);
}
},
changePlaying(url) {
this.playing_url = url;
this.playing_auto = true;
},
handleDeleteAll() {
this.tableData.forEach(value => {
RemoveBlobMusic(value);
});
this.tableData = [];
},
handleDownloadAll() {
let index = 0;
let c = setInterval(() => {
if (index < this.tableData.length) {
this.saveFile(this.tableData[index])
index++;
} else {
clearInterval(c);
}
}, 300);
},
async saveFile(data) {
if (this.dir) {
await DirectlyWriteFile(data, this.filename_policy, this.dir)
this.$notify({
title: "保存成功",
message: data.title,
position: "top-left",
type: "success",
duration: 3000
})
} else {
DownloadBlobMusic(data, this.filename_policy)
}
},
async showDirectlySave() {
if (!window.showDirectoryPicker) return
try {
await this.$confirm("您的浏览器支持文件直接保存到磁盘,是否使用?",
"新特性提示", {
confirmButtonText: "使用",
cancelButtonText: "不使用",
type: "warning",
center: true
})
} catch (e) {
console.log(e)
return
}
try {
this.dir = await window.showDirectoryPicker()
const test_filename = "__unlock_music_write_test.txt"
await this.dir.getFileHandle(test_filename, {create: true})
await this.dir.removeEntry(test_filename)
} catch (e) {
console.error(e)
}
}
},
}
</script>

View File

@@ -1,43 +0,0 @@
{
"compilerOptions": {
"target": "es5",
"module": "esnext",
"strict": true,
"jsx": "preserve",
"importHelpers": true,
"moduleResolution": "node",
"experimentalDecorators": true,
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"sourceMap": true,
"baseUrl": ".",
"types": [
"webpack-env",
"jest"
],
"paths": {
"@/*": [
"src/*"
]
},
"lib": [
"esnext",
"dom",
"dom.iterable",
"scripthost"
],
"resolveJsonModule": true
},
"include": [
"src/**/*.ts",
"src/**/*.tsx",
"src/**/*.vue",
"tests/**/*.ts",
"tests/**/*.tsx"
],
"exclude": [
"node_modules"
]
}

View File

@@ -1,43 +1,9 @@
const ThreadsPlugin = require('threads-plugin');
module.exports = {
publicPath: '',
productionSourceMap: false,
productionSourceMap: true,
pwa: {
manifestPath: "web-manifest.json",
name: "音乐解锁",
themeColor: "#4DBA87",
msTileColor: "#000000",
manifestOptions: {
start_url: "./index.html",
description: "在任何设备上解锁已购的加密音乐!",
icons: [
{
'src': './img/icons/android-chrome-192x192.png',
'sizes': '192x192',
'type': 'image/png'
},
{
'src': './img/icons/android-chrome-512x512.png',
'sizes': '512x512',
'type': 'image/png'
}
]
},
appleMobileWebAppCapable: 'yes',
iconPaths: {
faviconSVG: './img/icons/safari-pinned-tab.svg',
favicon32: './img/icons/favicon-32x32.png',
favicon16: './img/icons/favicon-16x16.png',
appleTouchIcon: './img/icons/apple-touch-icon-152x152.png',
maskIcon: './img/icons/safari-pinned-tab.svg',
msTileImage: './img/icons/msapplication-icon-144x144.png'
},
workboxPluginMode: "GenerateSW",
workboxOptions: {
skipWaiting: true
}
},
configureWebpack: {
plugins: [new ThreadsPlugin()]
}
};