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 diff --git a/README.md b/README.md index ad54913..5f333ee 100644 --- a/README.md +++ b/README.md @@ -1,40 +1,48 @@ # 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) +![Test Build](https://github.com/unlock-music/unlock-music/workflows/Test%20Build/badge.svg) +![GitHub releases](https://img.shields.io/github/downloads/unlock-music/unlock-music/total) ![Docker Pulls](https://img.shields.io/docker/pulls/ix64/unlock-music) +[license]: https://github.com/unlock-music/unlock-music/blob/master/LICENSE + +[repo_cli]: https://github.com/unlock-music/cli + +[tg_group]: https://t.me/unlock_music_chat + +[related_projects]: https://github.com/unlock-music/unlock-music/wiki/和UnlockMusic相关的项目 + ## 特性 ### 支持的格式 -- [x] QQ音乐 (.qmc0/.qmc2/.qmc3/.qmcflac/.qmcogg/[.tkm](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] 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 (.) - [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/加密格式 ### 其他特性 - [x] 在浏览器中解锁 - [x] 拖放文件 -- [x] 在线播放 - [x] 批量解锁 -- [x] 渐进式Web应用 +- [x] 渐进式 Web 应用 (PWA) - [x] 多线程 +- [x] 写入Meta和封面图片 ## 使用方法 @@ -46,11 +54,11 @@ ### 使用已构建版本 -- 从[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(s)协议** 访问) - 解压缩后即可部署或本地使用(**请勿直接运行源代码**) -### 使用Docker镜像 +### 使用 Docker 镜像 ```shell docker run --name unlock-music -d -p 8080:80 ix64/unlock-music @@ -59,11 +67,25 @@ docker run --name unlock-music -d -p 8080:80 ix64/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 + ``` diff --git a/extension-manifest.json b/extension-manifest.json index ffcf14f..eaa9512 100644 --- a/extension-manifest.json +++ b/extension-manifest.json @@ -6,6 +6,7 @@ "128": "./img/icons/msapplication-icon-144x144.png" }, "description": "在任何设备上解锁已购的加密音乐!", + "permissions": ["storage"], "offline_enabled": true, "options_page": "./index.html", "homepage_url": "https://github.com/ix64/unlock-music", 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 23beffc..4fed9de 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/joox-crypto": "^0.0.1-R5", "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/joox-crypto": { + "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" + }, + "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/joox-crypto": { + "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" + } + }, "@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..12644ef 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/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/component/ConfigDialog.vue b/src/component/ConfigDialog.vue new file mode 100644 index 0000000..8523f02 --- /dev/null +++ b/src/component/ConfigDialog.vue @@ -0,0 +1,113 @@ + + + + + diff --git a/src/component/FileSelector.vue b/src/component/FileSelector.vue index 1ae82ac..cd78cdb 100644 --- a/src/component/FileSelector.vue +++ b/src/component/FileSelector.vue @@ -39,6 +39,7 @@ import { spawn, Worker, Pool } from 'threads'; import { CommonDecrypt } from '@/decrypt/common.ts'; import { DecryptQueue } from '@/utils/utils'; +import { storage } from '@/utils/storage'; export default { name: 'FileSelector', @@ -76,7 +77,7 @@ export default { this.queue.queue(async (dec = CommonDecrypt) => { console.log('start handling', file.name); try { - this.$emit('success', await dec(file)); + this.$emit('success', await dec(file, await storage.getAll())); } catch (e) { console.error(e); this.$emit('error', e, file.name); diff --git a/src/component/Ruby.vue b/src/component/Ruby.vue new file mode 100644 index 0000000..14ede6b --- /dev/null +++ b/src/component/Ruby.vue @@ -0,0 +1,18 @@ + + + diff --git a/src/decrypt/__test__/fixture/joox_1.bin b/src/decrypt/__test__/fixture/joox_1.bin new file mode 100644 index 0000000..5ffe376 Binary files /dev/null and b/src/decrypt/__test__/fixture/joox_1.bin differ 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/decrypt/common.ts b/src/decrypt/common.ts index c289e57..4561f55 100644 --- a/src/decrypt/common.ts +++ b/src/decrypt/common.ts @@ -5,10 +5,18 @@ import { Decrypt as KgmDecrypt } from '@/decrypt/kgm'; import { Decrypt as KwmDecrypt } from '@/decrypt/kwm'; import { Decrypt as RawDecrypt } from '@/decrypt/raw'; import { Decrypt as TmDecrypt } from '@/decrypt/tm'; +import { Decrypt as JooxDecrypt } from '@/decrypt/joox'; import { DecryptResult, FileInfo } from '@/decrypt/entity'; import { SplitFilename } from '@/decrypt/utils'; +import { storage } from '@/utils/storage'; +import InMemoryStorage from '@/utils/storage/InMemoryStorage'; + +export async function CommonDecrypt(file: FileInfo, config: Record): Promise { + // Worker thread will fallback to in-memory storage. + if (storage instanceof InMemoryStorage) { + await storage.setAll(config); + } -export async function CommonDecrypt(file: FileInfo): Promise { const raw = SplitFilename(file.name); let rt_data: DecryptResult; switch (raw.ext) { @@ -60,6 +68,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..1cbfcd1 --- /dev/null +++ b/src/decrypt/joox.ts @@ -0,0 +1,44 @@ +import jooxFactory from '@unlock-music/joox-crypto'; + +import { DecryptResult } from './entity'; +import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from './utils'; + +import { MergeUint8Array } from '@/utils/MergeUint8Array'; +import { storage } from '@/utils/storage'; +import { extractQQMusicMeta } from '@/utils/qm_meta'; + +export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise { + const uuid = await storage.loadJooxUUID(''); + if (!uuid || uuid.length !== 32) { + throw new Error('请在“解密设定”填写应用 Joox 应用的 UUID。'); + } + + const fileBuffer = new Uint8Array(await GetArrayBuffer(file)); + const decryptor = jooxFactory(fileBuffer, uuid); + if (!decryptor) { + throw new Error('不支持的 joox 加密格式'); + } + + const musicDecoded = MergeUint8Array(decryptor.decryptFile(fileBuffer)); + const ext = SniffAudioExt(musicDecoded); + const mime = AudioMimeType[ext]; + + const songId = raw_filename.match(/^(\d+)\s\[mqms\d*]$/i)?.[1]; + const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta( + new Blob([musicDecoded], { type: mime }), + raw_filename, + ext, + songId, + ); + + return { + title: title, + artist: artist, + ext: ext, + album: album, + picture: imgUrl, + file: URL.createObjectURL(blob), + blob: blob, + mime: mime, + }; +} diff --git a/src/decrypt/qmc.ts b/src/decrypt/qmc.ts index c026c35..c5cb845 100644 --- a/src/decrypt/qmc.ts +++ b/src/decrypt/qmc.ts @@ -1,21 +1,10 @@ import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher, QmcStreamCipher } from './qmc_cipher'; -import { - AudioMimeType, - GetArrayBuffer, - GetCoverFromFile, - GetImageFromURL, - GetMetaFromFile, - SniffAudioExt, - WriteMetaToFlac, - WriteMetaToMp3, -} from '@/decrypt/utils'; -import { parseBlob as metaParseBlob } from 'music-metadata-browser'; +import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils'; import { DecryptQMCWasm } from './qmc_wasm'; -import iconv from 'iconv-lite'; import { DecryptResult } from '@/decrypt/entity'; -import { queryAlbumCover } from '@/utils/api'; import { QmcDeriveKey } from '@/decrypt/qmc_key'; +import { extractQQMusicMeta } from '@/utils/qm_meta'; interface Handler { ext: string; @@ -72,68 +61,24 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string) const ext = SniffAudioExt(musicDecoded, handler.ext); const mime = AudioMimeType[ext]; - let musicBlob = new Blob([musicDecoded], { type: mime }); + const { album, artist, imgUrl, blob, title } = await extractQQMusicMeta( + new Blob([musicDecoded], { type: mime }), + raw_filename, + ext, + ); - 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, + title: title, + artist: artist, ext: ext, - album: musicMeta.common.album, + album: album, picture: imgUrl, - file: URL.createObjectURL(musicBlob), - blob: musicBlob, + file: URL.createObjectURL(blob), + blob: blob, mime: mime, }; } -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}`; - } catch (e) { - console.warn(e); - } - return ''; -} - export class QmcDecoder { private static readonly BYTE_COMMA = ','.charCodeAt(0); file: Uint8Array; 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/__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(), +}; diff --git a/src/utils/api.ts b/src/utils/api.ts index 331d832..8854543 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -32,3 +32,82 @@ 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('请求信息失败'); +} + +export function getQMImageURLFromPMID(pmid: string, type = 1): string { + return `${IXAREA_API_ENDPOINT}/music/qq-cover/${type}/${pmid}`; +} diff --git a/src/utils/qm_meta.ts b/src/utils/qm_meta.ts new file mode 100644 index 0000000..b095918 --- /dev/null +++ b/src/utils/qm_meta.ts @@ -0,0 +1,147 @@ +import { IAudioMetadata, parseBlob as metaParseBlob } from 'music-metadata-browser'; +import iconv from 'iconv-lite'; + +import { + GetCoverFromFile, + GetImageFromURL, + GetMetaFromFile, + WriteMetaToFlac, + WriteMetaToMp3, + AudioMimeType, +} from '@/decrypt/utils'; +import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api'; + +interface MetaResult { + title: string; + artist: string; + album: string; + imgUrl: string; + blob: Blob; +} + +/** + * + * @param musicBlob 音乐文件(解密后) + * @param name 文件名 + * @param ext 原始后缀名 + * @param id 曲目 ID(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; + if (musicMeta.native[metaIdx].some((item) => item.id === 'TCON' && item.value === '(12)')) { + console.warn('try using gbk encoding to decode meta'); + musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ''), 'gbk'); + musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ''), 'gbk'); + musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ''), 'gbk'); + } + } + + if (id) { + try { + return fetchMetadataFromSongId(id, ext, musicMeta, musicBlob); + } catch (e) { + console.warn('在线获取曲目信息失败,回退到本地 meta 提取', e); + } + } + + const info = GetMetaFromFile(name, musicMeta.common.title, musicMeta.common.artist); + info.artist = info.artist || ''; + + let imageURL = GetCoverFromFile(musicMeta); + if (!imageURL) { + imageURL = await getCoverImage(info.title, info.artist, musicMeta.common.album); + } + + return { + title: info.title, + artist: info.artist || '', + album: musicMeta.common.album || '', + imgUrl: imageURL, + blob: await writeMetaToAudioFile({ + title: info.title, + artists: info.artist.split(' _ '), + ext, + imageURL, + musicMeta, + blob: musicBlob, + }), + }; +} + +async function fetchMetadataFromSongId( + id: number | string, + ext: string, + musicMeta: IAudioMetadata, + blob: Blob, +): Promise { + 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 { + try { + const data = await queryAlbumCover(title, artist, album); + return getQMImageURLFromPMID(data.Id, data.Type); + } catch (e) { + console.warn(e); + } + return ''; +} + +interface NewAudioMeta { + title: string; + artists: string[]; + ext: string; + + musicMeta: IAudioMetadata; + + blob: Blob; + imageURL: string; +} + +async function writeMetaToAudioFile(info: NewAudioMeta): Promise { + 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; +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts new file mode 100644 index 0000000..4d9cac0 --- /dev/null +++ b/src/utils/storage.ts @@ -0,0 +1,3 @@ +import storageFactory from './storage/StorageFactory'; + +export const storage = storageFactory(); diff --git a/src/utils/storage/BaseStorage.ts b/src/utils/storage/BaseStorage.ts new file mode 100644 index 0000000..466efee --- /dev/null +++ b/src/utils/storage/BaseStorage.ts @@ -0,0 +1,17 @@ +export const KEY_PREFIX = 'um.conf.'; +const KEY_JOOX_UUID = `${KEY_PREFIX}joox.uuid`; + +export default abstract class BaseStorage { + protected abstract save(name: string, value: T): Promise; + protected abstract load(name: string, defaultValue: T): Promise; + public abstract getAll(): Promise>; + public abstract setAll(obj: Record): 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..4dd5c17 --- /dev/null +++ b/src/utils/storage/BrowserNativeStorage.ts @@ -0,0 +1,43 @@ +import BaseStorage, { KEY_PREFIX } from './BaseStorage'; + +export default class BrowserNativeStorage extends BaseStorage { + public static get works() { + return typeof localStorage !== 'undefined' && localStorage.getItem; + } + + protected async load(name: string, defaultValue: T): Promise { + const result = localStorage.getItem(name); + if (result === null) { + return defaultValue; + } + try { + return JSON.parse(result); + } catch { + return defaultValue; + } + } + + protected async save(name: string, value: T): Promise { + localStorage.setItem(name, JSON.stringify(value)); + } + + public async getAll(): Promise> { + const result = {}; + for (const [key, value] of Object.entries(localStorage)) { + if (key.startsWith(KEY_PREFIX)) { + try { + Object.assign(result, { [key]: JSON.parse(value) }); + } catch { + // ignored + } + } + } + return result; + } + + public async setAll(obj: Record): Promise { + for (const [key, value] of Object.entries(obj)) { + await this.save(key, value); + } + } +} diff --git a/src/utils/storage/ChromeExtensionStorage.ts b/src/utils/storage/ChromeExtensionStorage.ts new file mode 100644 index 0000000..5f2f063 --- /dev/null +++ b/src/utils/storage/ChromeExtensionStorage.ts @@ -0,0 +1,47 @@ +import BaseStorage, { KEY_PREFIX } from './BaseStorage'; + +declare var chrome: any; + +export default class ChromeExtensionStorage extends BaseStorage { + static get works(): boolean { + return typeof chrome !== 'undefined' && Boolean(chrome?.storage?.local?.set); + } + + protected async load(name: string, defaultValue: T): Promise { + return new Promise((resolve) => { + chrome.storage.local.get({ [name]: defaultValue }, (result: any) => { + if (Object.prototype.hasOwnProperty.call(result, name)) { + resolve(result[name]); + } else { + resolve(defaultValue); + } + }); + }); + } + + protected async save(name: string, value: T): Promise { + return new Promise((resolve) => { + chrome.storage.local.set({ [name]: value }, resolve); + }); + } + + public async getAll(): Promise> { + return new Promise((resolve) => { + chrome.storage.local.get(null, (obj: Record) => { + const result: Record = {}; + for (const [key, value] of Object.entries(obj)) { + if (key.startsWith(KEY_PREFIX)) { + result[key] = value; + } + } + resolve(result); + }); + }); + } + + public async setAll(obj: Record): Promise { + return new Promise((resolve) => { + chrome.storage.local.set(obj, resolve); + }); + } +} diff --git a/src/utils/storage/InMemoryStorage.ts b/src/utils/storage/InMemoryStorage.ts new file mode 100644 index 0000000..b44f26f --- /dev/null +++ b/src/utils/storage/InMemoryStorage.ts @@ -0,0 +1,32 @@ +import BaseStorage from './BaseStorage'; + +export default class InMemoryStorage extends BaseStorage { + private values = new Map(); + protected async load(name: string, defaultValue: T): Promise { + if (this.values.has(name)) { + return this.values.get(name); + } + + return defaultValue; + } + + protected async save(name: string, value: T): Promise { + this.values.set(name, value); + } + + public async getAll(): Promise> { + const result = {}; + this.values.forEach((value, key) => { + Object.assign(result, { + [key]: value, + }); + }); + return result; + } + + public async setAll(obj: Record): Promise { + for (const [key, value] of Object.entries(obj)) { + this.values.set(key, value); + } + } +} diff --git a/src/utils/storage/StorageFactory.ts b/src/utils/storage/StorageFactory.ts new file mode 100644 index 0000000..9fa9b30 --- /dev/null +++ b/src/utils/storage/StorageFactory.ts @@ -0,0 +1,13 @@ +import BaseStorage from './BaseStorage'; +import BrowserNativeStorage from './BrowserNativeStorage'; +import ChromeExtensionStorage from './ChromeExtensionStorage'; +import InMemoryStorage from './InMemoryStorage'; + +export default function storageFactory(): BaseStorage { + if (ChromeExtensionStorage.works) { + return new ChromeExtensionStorage(); + } else if (BrowserNativeStorage.works) { + return new BrowserNativeStorage(); + } + return new InMemoryStorage(); +} 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 @@