From 4118d6f9eec5f72065c866799c1405b7a3602440 Mon Sep 17 00:00:00 2001 From: Jixun Date: Sun, 19 Dec 2021 23:03:46 +0000 Subject: [PATCH 01/20] feat: add basic joox support --- package-lock.json | 20 ++++++++ package.json | 1 + src/component/ConfigDialog.vue | 53 +++++++++++++++++++++ src/decrypt/common.ts | 4 ++ src/decrypt/joox.ts | 34 +++++++++++++ src/decrypt/qmc_wasm.ts | 17 +------ src/main.ts | 8 ++++ src/utils/MergeUint8Array.ts | 15 ++++++ src/utils/storage.ts | 7 +++ src/utils/storage/BaseStorage.ts | 14 ++++++ src/utils/storage/BrowserNativeStorage.ts | 15 ++++++ src/utils/storage/ChromeExtensionStorage.ts | 21 ++++++++ src/view/Home.vue | 14 ++++++ 13 files changed, 207 insertions(+), 16 deletions(-) create mode 100644 src/component/ConfigDialog.vue create mode 100644 src/decrypt/joox.ts create mode 100644 src/utils/MergeUint8Array.ts create mode 100644 src/utils/storage.ts create mode 100644 src/utils/storage/BaseStorage.ts create mode 100644 src/utils/storage/BrowserNativeStorage.ts create mode 100644 src/utils/storage/ChromeExtensionStorage.ts diff --git a/package-lock.json b/package-lock.json index 23beffc..d6cd297 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "dependencies": { "@babel/preset-typescript": "^7.16.5", "@jixun/qmc2-crypto": "^0.0.5-R4", + "@unlock-music-gh/joox-crypto": "^0.0.1-R2", "base64-js": "^1.5.1", "browser-id3-writer": "^4.4.0", "core-js": "^3.16.0", @@ -3485,6 +3486,17 @@ "integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==", "dev": true }, + "node_modules/@unlock-music-gh/joox-crypto": { + "version": "0.0.1-R3", + "resolved": "https://registry.npmjs.org/@unlock-music-gh/joox-crypto/-/joox-crypto-0.0.1-R3.tgz", + "integrity": "sha512-zZRiDXKI5SxuBIcW/rsGL8jNvyWxtA5cNRfg69WcsZK2DqztY8M2q1kMe96MP1AyM+cKpNQ50jAKh77VdFv9rA==", + "dependencies": { + "crypto-js": "^4.1.1" + }, + "bin": { + "joox-decrypt": "joox-decrypt" + } + }, "node_modules/@vue/babel-helper-vue-jsx-merge-props": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.2.1.tgz", @@ -23622,6 +23634,14 @@ "integrity": "sha512-7tFImggNeNBVMsn0vLrpn1H1uPrUBdnARPTpZoitY37ZrdJREzf7I16tMrlK3hen349gr1NYh8CmZQa7CTG6Aw==", "dev": true }, + "@unlock-music-gh/joox-crypto": { + "version": "0.0.1-R3", + "resolved": "https://registry.npmjs.org/@unlock-music-gh/joox-crypto/-/joox-crypto-0.0.1-R3.tgz", + "integrity": "sha512-zZRiDXKI5SxuBIcW/rsGL8jNvyWxtA5cNRfg69WcsZK2DqztY8M2q1kMe96MP1AyM+cKpNQ50jAKh77VdFv9rA==", + "requires": { + "crypto-js": "^4.1.1" + } + }, "@vue/babel-helper-vue-jsx-merge-props": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@vue/babel-helper-vue-jsx-merge-props/-/babel-helper-vue-jsx-merge-props-1.2.1.tgz", diff --git a/package.json b/package.json index 99117a8..1431651 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,7 @@ "dependencies": { "@babel/preset-typescript": "^7.16.5", "@jixun/qmc2-crypto": "^0.0.5-R4", + "@unlock-music-gh/joox-crypto": "^0.0.1-R2", "base64-js": "^1.5.1", "browser-id3-writer": "^4.4.0", "core-js": "^3.16.0", diff --git a/src/component/ConfigDialog.vue b/src/component/ConfigDialog.vue new file mode 100644 index 0000000..e0a05ce --- /dev/null +++ b/src/component/ConfigDialog.vue @@ -0,0 +1,53 @@ + + + diff --git a/src/decrypt/common.ts b/src/decrypt/common.ts index c289e57..11e636f 100644 --- a/src/decrypt/common.ts +++ b/src/decrypt/common.ts @@ -5,6 +5,7 @@ import { Decrypt as KgmDecrypt } from '@/decrypt/kgm'; import { Decrypt as KwmDecrypt } from '@/decrypt/kwm'; import { Decrypt as RawDecrypt } from '@/decrypt/raw'; import { Decrypt as TmDecrypt } from '@/decrypt/tm'; +import { Decrypt as JooxDecrypt } from '@/decrypt/joox'; import { DecryptResult, FileInfo } from '@/decrypt/entity'; import { SplitFilename } from '@/decrypt/utils'; @@ -60,6 +61,9 @@ export async function CommonDecrypt(file: FileInfo): Promise { case 'kgma': rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext); break; + case 'ofl_en': + rt_data = await JooxDecrypt(file.raw, raw.name, raw.ext); + break; default: throw '不支持此文件格式'; } diff --git a/src/decrypt/joox.ts b/src/decrypt/joox.ts new file mode 100644 index 0000000..a5fab16 --- /dev/null +++ b/src/decrypt/joox.ts @@ -0,0 +1,34 @@ +import { DecryptResult } from './entity'; +import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from './utils'; + +import jooxFactory from '@unlock-music-gh/joox-crypto'; +import storage from '@/utils/storage'; +import { MergeUint8Array } from '@/utils/MergeUint8Array'; + +export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise { + const uuid = await storage.loadJooxUUID(''); + if (!uuid || uuid.length !== 32) { + throw new Error('请在“解密设定”填写应用 Joox 应用的 UUID。'); + } + + const fileBuffer = new Uint8Array(await GetArrayBuffer(file)); + const decryptor = jooxFactory(fileBuffer, uuid); + if (!decryptor) { + throw new Error('不支持的 joox 加密格式'); + } + + const musicDecoded = MergeUint8Array(decryptor.decryptFile(fileBuffer)); + const ext = SniffAudioExt(musicDecoded); + const mime = AudioMimeType[ext]; + const musicBlob = new Blob([musicDecoded], { type: mime }); + + return { + title: raw_filename.replace(/\.[^\.]+$/, ''), + artist: '未知', + album: '未知', + file: URL.createObjectURL(musicBlob), + blob: musicBlob, + mime: mime, + ext: ext, + }; +} diff --git a/src/decrypt/qmc_wasm.ts b/src/decrypt/qmc_wasm.ts index 22e2c31..bec030e 100644 --- a/src/decrypt/qmc_wasm.ts +++ b/src/decrypt/qmc_wasm.ts @@ -1,4 +1,5 @@ import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle'; +import { MergeUint8Array } from '@/utils/MergeUint8Array'; // 检测文件末端使用的缓冲区大小 const DETECTION_SIZE = 40; @@ -6,22 +7,6 @@ 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 加密的文件。 * diff --git a/src/main.ts b/src/main.ts index 97845b2..b046b80 100644 --- a/src/main.ts +++ b/src/main.ts @@ -6,9 +6,13 @@ import { Checkbox, Col, Container, + Dialog, + Form, + FormItem, Footer, Icon, Image, + Input, Link, Main, Notification, @@ -26,6 +30,10 @@ import 'element-ui/lib/theme-chalk/base.css'; Vue.use(Link); Vue.use(Image); Vue.use(Button); +Vue.use(Dialog); +Vue.use(Form); +Vue.use(FormItem); +Vue.use(Input); Vue.use(Table); Vue.use(TableColumn); Vue.use(Main); diff --git a/src/utils/MergeUint8Array.ts b/src/utils/MergeUint8Array.ts new file mode 100644 index 0000000..29f91e0 --- /dev/null +++ b/src/utils/MergeUint8Array.ts @@ -0,0 +1,15 @@ +export function MergeUint8Array(array: Uint8Array[]): Uint8Array { + let length = 0; + array.forEach((item) => { + length += item.length; + }); + + let mergedArray = new Uint8Array(length); + let offset = 0; + array.forEach((item) => { + mergedArray.set(item, offset); + offset += item.length; + }); + + return mergedArray; +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..74bfe1d --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,7 @@ +import BaseStorage from './storage/BaseStorage'; +import BrowserNativeStorage from './storage/BrowserNativeStorage'; +import ChromeExtensionStorage from './storage/ChromeExtensionStorage'; + +const storage: BaseStorage = ChromeExtensionStorage.works ? new ChromeExtensionStorage() : new BrowserNativeStorage(); + +export default storage; diff --git a/src/utils/storage/BaseStorage.ts b/src/utils/storage/BaseStorage.ts new file mode 100644 index 0000000..8a5dafc --- /dev/null +++ b/src/utils/storage/BaseStorage.ts @@ -0,0 +1,14 @@ +const KEY_JOOX_UUID = 'joox.uuid'; + +export default abstract class BaseStorage { + protected abstract save(name: string, value: T): Promise; + protected abstract load(name: string, defaultValue: T): Promise; + + public saveJooxUUID(uuid: string): Promise { + return this.save(KEY_JOOX_UUID, uuid); + } + + public loadJooxUUID(defaultValue: string = ''): Promise { + return this.load(KEY_JOOX_UUID, defaultValue); + } +} diff --git a/src/utils/storage/BrowserNativeStorage.ts b/src/utils/storage/BrowserNativeStorage.ts new file mode 100644 index 0000000..b17c048 --- /dev/null +++ b/src/utils/storage/BrowserNativeStorage.ts @@ -0,0 +1,15 @@ +import BaseStorage from './BaseStorage'; + +export default class BrowserNativeStorage extends BaseStorage { + protected async load(name: string, defaultValue: T): Promise { + const result = localStorage.getItem(name); + if (result === null) { + return defaultValue; + } + return JSON.parse(result); + } + + protected async save(name: string, value: T): Promise { + localStorage.setItem(name, JSON.stringify(value)); + } +} diff --git a/src/utils/storage/ChromeExtensionStorage.ts b/src/utils/storage/ChromeExtensionStorage.ts new file mode 100644 index 0000000..2338b89 --- /dev/null +++ b/src/utils/storage/ChromeExtensionStorage.ts @@ -0,0 +1,21 @@ +import BaseStorage from './BaseStorage'; + +declare var chrome: any; + +export default class ChromeExtensionStorage extends BaseStorage { + static get works(): boolean { + return Boolean(chrome?.storage?.local?.set); + } + + protected async load(name: string, defaultValue: T): Promise { + const result = await chrome.storage.local.get({ [name]: defaultValue }); + if (Object.prototype.hasOwnProperty.call(result, name)) { + return result[name]; + } + return defaultValue; + } + + protected async save(name: string, value: T): Promise { + return chrome.storage.local.set({ [name]: value }); + } +} diff --git a/src/view/Home.vue b/src/view/Home.vue index d457bc5..35b6a2f 100644 --- a/src/view/Home.vue +++ b/src/view/Home.vue @@ -10,6 +10,13 @@ + + +
+ 部分解密方案需要设定解密参数。 +
+ 解密设定 +
下载全部 清除全部 @@ -35,6 +42,8 @@ From 851e039cc54e67c351feb1ddeb0f60cd3a733c21 Mon Sep 17 00:00:00 2001 From: Jixun Wu Date: Wed, 22 Dec 2021 14:10:29 +0000 Subject: [PATCH 16/20] fix: avoid "ArtiomTr/jest-coverage-report-action" when running from a fork. --- .github/workflows/build.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5bf5722..1d3bcf9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-workflow.json + name: Test Build on: push: @@ -27,7 +29,14 @@ jobs: steps: - uses: actions/checkout@v2 - run: npm ci - - uses: ArtiomTr/jest-coverage-report-action@v2.0-rc.6 + # note: forks can not access to GITHUB_TOKEN for coverage update. + # instead, we just ran the test in this case. + - name: Test only + if: github.event_name != 'push' + run: npm test + - name: Test + Publish Coverage + uses: ArtiomTr/jest-coverage-report-action@v2.0-rc.6 + if: github.event_name == 'push' with: github-token: ${{ secrets.GITHUB_TOKEN }} annotations: none From 5c28e8a1863b8da9d7cc950d728babc504781f54 Mon Sep 17 00:00:00 2001 From: Jixun Wu Date: Thu, 23 Dec 2021 14:57:10 +0000 Subject: [PATCH 17/20] doc: reforamtted & updated content in readme --- README.md | 71 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 45 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index ad54913..fb2a794 100644 --- a/README.md +++ b/README.md @@ -1,31 +1,36 @@ # Unlock Music 音乐解锁 - 在浏览器中解锁加密的音乐文件。 Unlock encrypted music file in the browser. -- unlock-music项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循[License](https://github.com/ix64/unlock-music/blob/master/LICENSE) -- Unlock Music的CLI版本正在开发中。 -- 我们新建了Telegram群组,欢迎加入![https://t.me/unlock_music_chat](https://t.me/unlock_music_chat) -- [CLI版本 Alpha](https://github.com/unlock-music/cli) 大批量转换建议使用CLI版本 -- [相关的其他项目](https://github.com/ix64/unlock-music/wiki/%E5%92%8CUnlockMusic%E7%9B%B8%E5%85%B3%E7%9A%84%E9%A1%B9%E7%9B%AE) +- Unlock Music 项目是以学习和技术研究的初衷创建的,修改、再分发时请遵循 [License][license] +- Unlock Music 的 CLI 版本可以在 [unlock-music/cli][repo_cli] 找到,大批量转换建议使用 CLI 版本。 +- 我们新建了 Telegram 群组 [`@unlock_music_chat`][tg_group] ,欢迎加入! +- [相关的其他项目][related_projects] -![Test Build](https://github.com/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) +![Test Build](https://github.com/unlock-music/unlock-music/workflows/Test%20Build/badge.svg) +![GitHub releases](https://img.shields.io/github/downloads/unlock-music/unlock-music/total) +![Docker Pulls](https://img.shields.io/docker/pulls/unlock-music/unlock-music) + +[license]: https://github.com/unlock-music/unlock-music/blob/master/LICENSE +[repo_cli]: https://github.com/unlock-music/cli +[tg_group]: https://t.me/unlock_music_chat +[related_projects]: https://github.com/unlock-music/unlock-music/wiki/和UnlockMusic相关的项目 ## 特性 ### 支持的格式 -- [x] QQ音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/[.tkm](https://github.com/ix64/unlock-music/issues/9)) +- [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/[.tkm](https://github.com/unlock-music/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] Moo 音乐格式 ([.bkcmp3/.bkcflac](https://github.com/unlock-music/unlock-music/issues/11)) +- [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6) +- [x] QQ 音乐新格式 ([.mflac/.mgg](https://github.com/unlock-music/unlock-music/issues/3)) +- [x] QQ 音乐海外版JOOX Music (.) - [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] 酷狗音乐格式 (.kgm) ([CLI 版本][kgm_cli]) + +[kgm_cli]: https://github.com/unlock-music/unlock-music/wiki/其他音乐格式工具#酷狗音乐-kgmvpr解锁工具 +[joox_wiki]: https://github.com/unlock-music/joox-crypto/wiki/加密格式 ### 其他特性 @@ -33,7 +38,7 @@ - [x] 拖放文件 - [x] 在线播放 - [x] 批量解锁 -- [x] 渐进式Web应用 +- [x] 渐进式 Web 应用 (PWA) - [x] 多线程 ## 使用方法 @@ -46,24 +51,38 @@ ### 使用已构建版本 -- 从[GitHub Release](https://github.com/ix64/unlock-music/releases/latest)下载已构建的版本 - - 本地使用请下载`legacy版本`(`modern版本`只能通过**http/https协议**访问) +- 从[GitHub Release](https://github.com/unlock-music/unlock-music/releases/latest)下载已构建的版本 + - 本地使用请下载`legacy版本`(`modern版本`只能通过**http/https 协议**访问) - 解压缩后即可部署或本地使用(**请勿直接运行源代码**) -### 使用Docker镜像 +### 使用 Docker 镜像 ```shell -docker run --name unlock-music -d -p 8080:80 ix64/unlock-music +docker run --name unlock-music -d -p 8080:80 unlock-music/unlock-music ``` ### 自行构建 - 环境要求 - - nodejs + - nodejs (v16.x) - npm -1. 获取项目源代码后执行 `npm install` 安装相关依赖 -2. 执行 `npm run build` 即可进行构建,构建输出为 dist 目录 +1. 获取项目源代码后安装相关依赖: -- `npm run serve` 可用于开发 -3. 如需构建浏览器扩展,build完成后还需要执行`npm run make-extension` + ```sh + npm ci + ``` + +2. 然后进行构建。编译后的文件保存到 dist 目录下: + + ```sh + npm run build + ``` + + - 如果是用于开发,可以执行 `npm run serve`。 + +3. 如需构建浏览器扩展,build 完成后还需要执行: + + ```sh + npm run make-extension + ``` From 78d6de210b4346620cc008aac1b66eb1f5f61133 Mon Sep 17 00:00:00 2001 From: Jixun Wu Date: Thu, 23 Dec 2021 14:58:24 +0000 Subject: [PATCH 18/20] feat(joox): Fetch meta data from API --- src/decrypt/joox.ts | 2 + src/utils/api.ts | 80 ++++++++++++++++++++++++ src/utils/qm_meta.ts | 142 +++++++++++++++++++++++++++++++++---------- 3 files changed, 191 insertions(+), 33 deletions(-) diff --git a/src/decrypt/joox.ts b/src/decrypt/joox.ts index ce38a37..1cbfcd1 100644 --- a/src/decrypt/joox.ts +++ b/src/decrypt/joox.ts @@ -23,10 +23,12 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string) const ext = SniffAudioExt(musicDecoded); const mime = AudioMimeType[ext]; + const songId = raw_filename.match(/^(\d+)\s\[mqms\d*]$/i)?.[1]; const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta( new Blob([musicDecoded], { type: mime }), raw_filename, ext, + songId, ); return { diff --git a/src/utils/api.ts b/src/utils/api.ts index 331d832..937d487 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -32,3 +32,83 @@ export async function queryAlbumCover(title: string, artist?: string, album?: st const resp = await fetch(`${endpoint}?${params.toString()}`); return await resp.json(); } + +export interface TrackInfo { + id: number; + type: number; + mid: string; + name: string; + title: string; + subtitle: string; + singer: { + id: number; + mid: string; + name: string; + title: string; + type: number; + uin: number; + }[]; + album: { + id: number; + mid: string; + name: string; + title: string; + subtitle: string; + time_public: string; + pmid: string; + }; + interval: number; + index_cd: number; + index_album: number; +} + +export interface SongItemInfo { + title: string; + content: { + value: string; + }[]; +} + +export interface SongInfoResponse { + info: { + company: SongItemInfo; + genre: SongItemInfo; + intro: SongItemInfo; + lan: SongItemInfo; + pub_time: SongItemInfo; + }; + extras: { + name: string; + transname: string; + subtitle: string; + from: string; + wikiurl: string; + }; + track_info: TrackInfo; +} + +export interface RawQMBatchResponse { + code: number; + ts: number; + start_ts: number; + traceid: string; + req_1: { + code: number; + data: T; + }; +} + +export async function querySongInfoById(id: string | number): Promise { + const url = `${IXAREA_API_ENDPOINT}/meta/qq-music-raw/${id}`; + const result: RawQMBatchResponse = await fetch(url).then((r) => r.json()); + if (result.code === 0 && result.req_1.code === 0) { + return result.req_1.data; + } + + throw new Error('请求信息失败'); +} + +const QQ_MUSIC_COVER_URI = 'https://stats.ixarea.com/apis/music/qq-cover'; +export function getQMImageURLFromPMID(pmid: string, type = 1): string { + return `${QQ_MUSIC_COVER_URI}/${type}/${pmid}`; +} diff --git a/src/utils/qm_meta.ts b/src/utils/qm_meta.ts index 3e23f38..b095918 100644 --- a/src/utils/qm_meta.ts +++ b/src/utils/qm_meta.ts @@ -1,4 +1,4 @@ -import { parseBlob as metaParseBlob } from 'music-metadata-browser'; +import { IAudioMetadata, parseBlob as metaParseBlob } from 'music-metadata-browser'; import iconv from 'iconv-lite'; import { @@ -9,9 +9,30 @@ import { WriteMetaToMp3, AudioMimeType, } from '@/decrypt/utils'; -import { queryAlbumCover } from '@/utils/api'; +import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api'; -export async function extractQQMusicMeta(musicBlob: Blob, name: string, ext: string) { +interface MetaResult { + title: string; + artist: string; + album: string; + imgUrl: string; + blob: Blob; +} + +/** + * + * @param musicBlob 音乐文件(解密后) + * @param name 文件名 + * @param ext 原始后缀名 + * @param id 曲目 ID(number类型或纯数字组成的字符串) + * @returns Promise + */ +export async function extractQQMusicMeta( + musicBlob: Blob, + name: string, + ext: string, + id?: number | string, +): Promise { const musicMeta = await metaParseBlob(musicBlob); for (let metaIdx in musicMeta.native) { if (!musicMeta.native.hasOwnProperty(metaIdx)) continue; @@ -23,49 +44,104 @@ export async function extractQQMusicMeta(musicBlob: Blob, name: string, ext: str } } - const info = GetMetaFromFile(name, 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(' _ ') }; - const buffer = Buffer.from(await musicBlob.arrayBuffer()); - const mime = AudioMimeType[ext] || AudioMimeType.mp3; - if (ext === 'mp3') { - musicBlob = new Blob([WriteMetaToMp3(buffer, newMeta, musicMeta)], { type: mime }); - } else if (ext === 'flac') { - musicBlob = new Blob([WriteMetaToFlac(buffer, newMeta, musicMeta)], { 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); - } - } + if (id) { + try { + return fetchMetadataFromSongId(id, ext, musicMeta, musicBlob); + } catch (e) { + console.warn('在线获取曲目信息失败,回退到本地 meta 提取', e); } } + const info = GetMetaFromFile(name, musicMeta.common.title, musicMeta.common.artist); + info.artist = info.artist || ''; + + let imageURL = GetCoverFromFile(musicMeta); + if (!imageURL) { + imageURL = await getCoverImage(info.title, info.artist, musicMeta.common.album); + } + return { title: info.title, - artist: info.artist, - album: musicMeta.common.album, - imgUrl: imgUrl, - blob: musicBlob, + artist: info.artist || '', + album: musicMeta.common.album || '', + imgUrl: imageURL, + blob: await writeMetaToAudioFile({ + title: info.title, + artists: info.artist.split(' _ '), + ext, + imageURL, + musicMeta, + blob: musicBlob, + }), + }; +} + +async function fetchMetadataFromSongId( + id: number | string, + ext: string, + musicMeta: IAudioMetadata, + blob: Blob, +): Promise { + const info = await querySongInfoById(id); + const imageURL = getQMImageURLFromPMID(info.track_info.album.pmid); + const artists = info.track_info.singer.map((singer) => singer.name); + + return { + title: info.track_info.title, + artist: artists.join('、'), + album: info.track_info.album.name, + imgUrl: imageURL, + + blob: await writeMetaToAudioFile({ + title: info.track_info.title, + artists, + ext, + imageURL, + musicMeta, + blob, + }), }; } async function getCoverImage(title: string, artist?: string, album?: string): Promise { - 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}`; + return getQMImageURLFromPMID(data.Id, data.Type); } catch (e) { console.warn(e); } return ''; } + +interface NewAudioMeta { + title: string; + artists: string[]; + ext: string; + + musicMeta: IAudioMetadata; + + blob: Blob; + imageURL: string; +} + +async function writeMetaToAudioFile(info: NewAudioMeta): Promise { + try { + const imageInfo = await GetImageFromURL(info.imageURL); + if (!imageInfo) { + console.warn('获取图像失败'); + } + const newMeta = { picture: imageInfo?.buffer, title: info.title, artists: info.artists }; + const buffer = Buffer.from(await info.blob.arrayBuffer()); + const mime = AudioMimeType[info.ext] || AudioMimeType.mp3; + if (info.ext === 'mp3') { + return new Blob([WriteMetaToMp3(buffer, newMeta, info.musicMeta)], { type: mime }); + } else if (info.ext === 'flac') { + return new Blob([WriteMetaToFlac(buffer, newMeta, info.musicMeta)], { type: mime }); + } else { + console.info('writing metadata for ' + info.ext + ' is not being supported for now'); + } + } catch (e) { + console.warn('Error while appending cover image to file ' + e); + } + return info.blob; +} From a4504c66b3e72a9411a653d40fe4b3ec056e1041 Mon Sep 17 00:00:00 2001 From: Jixun Wu Date: Thu, 23 Dec 2021 23:23:32 +0000 Subject: [PATCH 19/20] test(joox): Added basic sanity test for joox encryption. --- jest.config.js | 3 ++ package-lock.json | 14 +++---- package.json | 2 +- src/__test__/setup_jest.js | 2 + src/decrypt/__test__/fixture/joox_1.bin | Bin 0 -> 28 bytes src/decrypt/__test__/joox.test.ts | 52 ++++++++++++++++++++++++ src/utils/__mocks__/qm_meta.ts | 1 + src/utils/__mocks__/storage.ts | 4 ++ 8 files changed, 70 insertions(+), 8 deletions(-) create mode 100644 src/__test__/setup_jest.js create mode 100644 src/decrypt/__test__/fixture/joox_1.bin create mode 100644 src/decrypt/__test__/joox.test.ts create mode 100644 src/utils/__mocks__/qm_meta.ts create mode 100644 src/utils/__mocks__/storage.ts diff --git a/jest.config.js b/jest.config.js index e3b34b8..0524f52 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,7 @@ module.exports = { + setupFilesAfterEnv: [ + './src/__test__/setup_jest.js' + ], moduleNameMapper: { '@/(.*)': '/src/$1' } diff --git a/package-lock.json b/package-lock.json index baed5be..4fed9de 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "dependencies": { "@babel/preset-typescript": "^7.16.5", "@jixun/qmc2-crypto": "^0.0.5-R4", - "@unlock-music/joox-crypto": "^0.0.1-R4", + "@unlock-music/joox-crypto": "^0.0.1-R5", "base64-js": "^1.5.1", "browser-id3-writer": "^4.4.0", "core-js": "^3.16.0", @@ -3487,9 +3487,9 @@ "dev": true }, "node_modules/@unlock-music/joox-crypto": { - "version": "0.0.1-R4", - "resolved": "https://registry.npmjs.org/@unlock-music/joox-crypto/-/joox-crypto-0.0.1-R4.tgz", - "integrity": "sha512-5UScjXtH9J3mAy9sRBjNn5kkEuT7dHvH3YQYKRyOfF3EKxLkWsJP0Fuw/tZtylLPL5beI3qDBzyHf5mVrpdH4A==", + "version": "0.0.1-R5", + "resolved": "https://registry.npmjs.org/@unlock-music/joox-crypto/-/joox-crypto-0.0.1-R5.tgz", + "integrity": "sha512-+FhGT4bjzfb1Q7dAwHps/XqbqXrRA6Qg7pkDPzyXfeRmQocAySQ/dekojxkaFBf7ZX5ToIAopwxkKZ5NFt5bFw==", "dependencies": { "crypto-js": "^4.1.1" }, @@ -23635,9 +23635,9 @@ "dev": true }, "@unlock-music/joox-crypto": { - "version": "0.0.1-R4", - "resolved": "https://registry.npmjs.org/@unlock-music/joox-crypto/-/joox-crypto-0.0.1-R4.tgz", - "integrity": "sha512-5UScjXtH9J3mAy9sRBjNn5kkEuT7dHvH3YQYKRyOfF3EKxLkWsJP0Fuw/tZtylLPL5beI3qDBzyHf5mVrpdH4A==", + "version": "0.0.1-R5", + "resolved": "https://registry.npmjs.org/@unlock-music/joox-crypto/-/joox-crypto-0.0.1-R5.tgz", + "integrity": "sha512-+FhGT4bjzfb1Q7dAwHps/XqbqXrRA6Qg7pkDPzyXfeRmQocAySQ/dekojxkaFBf7ZX5ToIAopwxkKZ5NFt5bFw==", "requires": { "crypto-js": "^4.1.1" } diff --git a/package.json b/package.json index f6295bb..12644ef 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "dependencies": { "@babel/preset-typescript": "^7.16.5", "@jixun/qmc2-crypto": "^0.0.5-R4", - "@unlock-music/joox-crypto": "^0.0.1-R4", + "@unlock-music/joox-crypto": "^0.0.1-R5", "base64-js": "^1.5.1", "browser-id3-writer": "^4.4.0", "core-js": "^3.16.0", diff --git a/src/__test__/setup_jest.js b/src/__test__/setup_jest.js new file mode 100644 index 0000000..c7b47bf --- /dev/null +++ b/src/__test__/setup_jest.js @@ -0,0 +1,2 @@ +// Polyfill for node. +global.Blob = global.Blob || require("node:buffer").Blob; diff --git a/src/decrypt/__test__/fixture/joox_1.bin b/src/decrypt/__test__/fixture/joox_1.bin new file mode 100644 index 0000000000000000000000000000000000000000..5ffe3764a794749cbdfb68511017c21f4a9d9011 GIT binary patch literal 28 fcmZ=&G%#U+0Pga}zpgLcJNleAKbW>KTgDLpVb}>P literal 0 HcmV?d00001 diff --git a/src/decrypt/__test__/joox.test.ts b/src/decrypt/__test__/joox.test.ts new file mode 100644 index 0000000..dd0af1d --- /dev/null +++ b/src/decrypt/__test__/joox.test.ts @@ -0,0 +1,52 @@ +import fs from 'fs'; +import { storage } from '@/utils/storage'; + +import { Decrypt as decryptJoox } from '../joox'; +import { extractQQMusicMeta as extractQQMusicMetaOrig } from '@/utils/qm_meta'; + +jest.mock('@/utils/storage'); +jest.mock('@/utils/qm_meta'); + +const loadJooxUUID = storage.loadJooxUUID as jest.MockedFunction; +const extractQQMusicMeta = extractQQMusicMetaOrig as jest.MockedFunction; + +const TEST_UUID_ZEROS = ''.padStart(32, '0'); +const encryptedFile1 = fs.readFileSync(__dirname + '/fixture/joox_1.bin'); + +describe('decrypt/joox', () => { + it('should be able to decrypt sample file (v4)', async () => { + loadJooxUUID.mockResolvedValue(TEST_UUID_ZEROS); + extractQQMusicMeta.mockImplementationOnce(async (blob: Blob) => { + return { + title: 'unused', + album: 'unused', + blob: blob, + artist: 'unused', + imgUrl: 'https://github.com/unlock-music', + }; + }); + + const result = await decryptJoox(new Blob([encryptedFile1]), 'test.bin', 'bin'); + const resultBuf = await result.blob.arrayBuffer(); + expect(resultBuf).toEqual(Buffer.from('Hello World', 'utf-8').buffer); + }); + + it('should reject E!99 files', async () => { + loadJooxUUID.mockResolvedValue(TEST_UUID_ZEROS); + + const input = new Blob([Buffer.from('E!99....')]); + await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('不支持的 joox 加密格式'); + }); + + it('should reject empty uuid', async () => { + loadJooxUUID.mockResolvedValue(''); + const input = new Blob([encryptedFile1]); + await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID'); + }); + + it('should reject invalid uuid', async () => { + loadJooxUUID.mockResolvedValue('hello!'); + const input = new Blob([encryptedFile1]); + await expect(decryptJoox(input, 'test.bin', 'bin')).rejects.toThrow('UUID'); + }); +}); diff --git a/src/utils/__mocks__/qm_meta.ts b/src/utils/__mocks__/qm_meta.ts new file mode 100644 index 0000000..99c3cec --- /dev/null +++ b/src/utils/__mocks__/qm_meta.ts @@ -0,0 +1 @@ +export const extractQQMusicMeta = jest.fn(); diff --git a/src/utils/__mocks__/storage.ts b/src/utils/__mocks__/storage.ts new file mode 100644 index 0000000..af9fd67 --- /dev/null +++ b/src/utils/__mocks__/storage.ts @@ -0,0 +1,4 @@ +export const storage = { + loadJooxUUID: jest.fn(), + saveJooxUUID: jest.fn(), +}; From 164efd0f4c3b9daca5a4976f76ac80010b75eb0b Mon Sep 17 00:00:00 2001 From: MengYX Date: Sat, 25 Dec 2021 11:46:01 +0800 Subject: [PATCH 20/20] fix: api path & docker image name --- README.md | 19 +++++++++++-------- src/utils/api.ts | 3 +-- 2 files changed, 12 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index fb2a794..5f333ee 100644 --- a/README.md +++ b/README.md @@ -8,20 +8,22 @@ ![Test Build](https://github.com/unlock-music/unlock-music/workflows/Test%20Build/badge.svg) ![GitHub releases](https://img.shields.io/github/downloads/unlock-music/unlock-music/total) -![Docker Pulls](https://img.shields.io/docker/pulls/unlock-music/unlock-music) +![Docker Pulls](https://img.shields.io/docker/pulls/ix64/unlock-music) [license]: https://github.com/unlock-music/unlock-music/blob/master/LICENSE + [repo_cli]: https://github.com/unlock-music/cli + [tg_group]: https://t.me/unlock_music_chat + [related_projects]: https://github.com/unlock-music/unlock-music/wiki/和UnlockMusic相关的项目 ## 特性 ### 支持的格式 -- [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/[.tkm](https://github.com/unlock-music/unlock-music/issues/9)) - - [x] 写入封面图片 -- [x] Moo 音乐格式 ([.bkcmp3/.bkcflac](https://github.com/unlock-music/unlock-music/issues/11)) +- [x] QQ 音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/.tkm) +- [x] Moo 音乐格式 (.bkcmp3/.bkcflac) - [x] QQ 音乐 Tm 格式 (.tm0/.tm2/.tm3/.tm6) - [x] QQ 音乐新格式 ([.mflac/.mgg](https://github.com/unlock-music/unlock-music/issues/3)) - [x] QQ 音乐海外版JOOX Music (.) @@ -30,16 +32,17 @@ - [x] 酷狗音乐格式 (.kgm) ([CLI 版本][kgm_cli]) [kgm_cli]: https://github.com/unlock-music/unlock-music/wiki/其他音乐格式工具#酷狗音乐-kgmvpr解锁工具 + [joox_wiki]: https://github.com/unlock-music/joox-crypto/wiki/加密格式 ### 其他特性 - [x] 在浏览器中解锁 - [x] 拖放文件 -- [x] 在线播放 - [x] 批量解锁 - [x] 渐进式 Web 应用 (PWA) - [x] 多线程 +- [x] 写入Meta和封面图片 ## 使用方法 @@ -52,13 +55,13 @@ ### 使用已构建版本 - 从[GitHub Release](https://github.com/unlock-music/unlock-music/releases/latest)下载已构建的版本 - - 本地使用请下载`legacy版本`(`modern版本`只能通过**http/https 协议**访问) + - 本地使用请下载`legacy版本`(`modern版本`只能通过 **http(s)协议** 访问) - 解压缩后即可部署或本地使用(**请勿直接运行源代码**) ### 使用 Docker 镜像 ```shell -docker run --name unlock-music -d -p 8080:80 unlock-music/unlock-music +docker run --name unlock-music -d -p 8080:80 ix64/unlock-music ``` ### 自行构建 @@ -79,7 +82,7 @@ docker run --name unlock-music -d -p 8080:80 unlock-music/unlock-music npm run build ``` - - 如果是用于开发,可以执行 `npm run serve`。 + - 如果是用于开发,可以执行 `npm run serve`。 3. 如需构建浏览器扩展,build 完成后还需要执行: diff --git a/src/utils/api.ts b/src/utils/api.ts index 937d487..8854543 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -108,7 +108,6 @@ export async function querySongInfoById(id: string | number): Promise