MengYX
3 years ago
34 changed files with 1656 additions and 1709 deletions
@ -1,85 +1,87 @@ |
|||||
<template> |
<template> |
||||
<el-container id="app"> |
<el-container id="app"> |
||||
<el-main> |
<el-main> |
||||
<Home/> |
<Home /> |
||||
</el-main> |
</el-main> |
||||
<el-footer id="app-footer"> |
<el-footer id="app-footer"> |
||||
<el-row> |
<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>({{ version }}) |
||||
:移除已购音乐的加密保护。 |
:移除已购音乐的加密保护。 |
||||
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a> |
<a href="https://github.com/ix64/unlock-music/wiki/使用提示" target="_blank">使用提示</a> |
||||
</el-row> |
</el-row> |
||||
<el-row> |
<el-row> |
||||
目前支持网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm) |
目前支持 网易云音乐(ncm), QQ音乐(qmc, mflac, mgg), 酷狗音乐(kgm), 虾米音乐(xm), 酷我音乐(.kwm) |
||||
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>。 |
<a href="https://github.com/ix64/unlock-music/blob/master/README.md" target="_blank">更多</a>。 |
||||
</el-row> |
</el-row> |
||||
<el-row> |
<el-row> |
||||
<!--如果进行二次开发,此行版权信息不得移除且应明显地标注于页面上--> |
<!--如果进行二次开发,此行版权信息不得移除且应明显地标注于页面上--> |
||||
<span>Copyright © 2019 - {{ (new Date()).getFullYear() }} MengYX</span> |
<span>Copyright © 2019 - {{ new Date().getFullYear() }} MengYX</span> |
||||
音乐解锁使用 |
音乐解锁使用 |
||||
<a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a> |
<a href="https://github.com/ix64/unlock-music/blob/master/LICENSE" target="_blank">MIT许可协议</a> |
||||
开放源代码 |
开放源代码 |
||||
</el-row> |
</el-row> |
||||
</el-footer> |
</el-footer> |
||||
</el-container> |
</el-container> |
||||
</template> |
</template> |
||||
|
|
||||
<script> |
<script> |
||||
|
import FileSelector from '@/component/FileSelector'; |
||||
import FileSelector from "@/component/FileSelector" |
import PreviewTable from '@/component/PreviewTable'; |
||||
import PreviewTable from "@/component/PreviewTable" |
import config from '@/../package.json'; |
||||
import config from "@/../package.json" |
import Home from '@/view/Home'; |
||||
import Home from "@/view/Home"; |
import { checkUpdate } from '@/utils/api'; |
||||
import {checkUpdate} from "@/utils/api"; |
|
||||
|
|
||||
export default { |
export default { |
||||
name: 'app', |
name: 'app', |
||||
components: { |
components: { |
||||
FileSelector, |
FileSelector, |
||||
PreviewTable, |
PreviewTable, |
||||
Home |
Home, |
||||
}, |
}, |
||||
data() { |
data() { |
||||
return { |
return { |
||||
version: config.version, |
version: config.version, |
||||
} |
}; |
||||
}, |
}, |
||||
created() { |
created() { |
||||
this.$nextTick(() => this.finishLoad()); |
this.$nextTick(() => this.finishLoad()); |
||||
}, |
}, |
||||
methods: { |
methods: { |
||||
async finishLoad() { |
async finishLoad() { |
||||
const mask = document.getElementById("loader-mask"); |
const mask = document.getElementById('loader-mask'); |
||||
if (!!mask) mask.remove(); |
if (!!mask) mask.remove(); |
||||
let updateInfo; |
let updateInfo; |
||||
try { |
try { |
||||
updateInfo = await checkUpdate(this.version) |
updateInfo = await checkUpdate(this.version); |
||||
} catch (e) { |
} catch (e) { |
||||
console.warn("check version info failed", e) |
console.warn('check version info failed', e); |
||||
} |
} |
||||
if ((updateInfo && process.env.NODE_ENV === 'production') && (updateInfo.HttpsFound || |
if ( |
||||
(updateInfo.Found && window.location.protocol !== "https:"))) { |
updateInfo && |
||||
this.$notify.warning({ |
process.env.NODE_ENV === 'production' && |
||||
title: '发现更新', |
(updateInfo.HttpsFound || (updateInfo.Found && window.location.protocol !== 'https:')) |
||||
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`, |
) { |
||||
dangerouslyUseHTMLString: true, |
this.$notify.warning({ |
||||
duration: 15000, |
title: '发现更新', |
||||
position: 'top-left' |
message: `发现新版本 v${updateInfo.Version}<br/>更新详情:${updateInfo.Detail}<br/> <a target="_blank" href="${updateInfo.URL}">获取更新</a>`, |
||||
}); |
dangerouslyUseHTMLString: true, |
||||
} else { |
duration: 15000, |
||||
this.$notify.info({ |
position: 'top-left', |
||||
title: '离线使用', |
}); |
||||
message: `我们使用PWA技术,无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`, |
} else { |
||||
dangerouslyUseHTMLString: true, |
this.$notify.info({ |
||||
duration: 10000, |
title: '离线使用', |
||||
position: 'top-left' |
message: `我们使用PWA技术,无网络也能使用<br/>最近更新:${config.updateInfo}<br/><a target="_blank" href="https://github.com/ix64/unlock-music/wiki/使用提示">使用提示</a>`, |
||||
}); |
dangerouslyUseHTMLString: true, |
||||
} |
duration: 10000, |
||||
} |
position: 'top-left', |
||||
|
}); |
||||
|
} |
||||
}, |
}, |
||||
} |
}, |
||||
|
}; |
||||
</script> |
</script> |
||||
|
|
||||
<style lang="scss"> |
<style lang="scss"> |
||||
@import "scss/unlock-music"; |
@import 'scss/unlock-music'; |
||||
</style> |
</style> |
||||
|
@ -1,71 +1,62 @@ |
|||||
<template> |
<template> |
||||
<el-table :data="tableData" style="width: 100%"> |
<el-table :data="tableData" style="width: 100%"> |
||||
|
<el-table-column label="封面"> |
||||
<el-table-column label="封面"> |
<template slot-scope="scope"> |
||||
<template slot-scope="scope"> |
<el-image :src="scope.row.picture" style="width: 100px; height: 100px"> |
||||
<el-image :src="scope.row.picture" style="width: 100px; height: 100px"> |
<div slot="error" class="image-slot el-image__error">暂无封面</div> |
||||
<div slot="error" class="image-slot el-image__error"> |
</el-image> |
||||
暂无封面 |
</template> |
||||
</div> |
</el-table-column> |
||||
</el-image> |
<el-table-column label="歌曲"> |
||||
</template> |
<template #default="scope"> |
||||
</el-table-column> |
<span>{{ scope.row.title }}</span> |
||||
<el-table-column label="歌曲"> |
</template> |
||||
<template #default="scope"> |
</el-table-column> |
||||
<span>{{ scope.row.title }}</span> |
<el-table-column label="歌手"> |
||||
</template> |
<template #default="scope"> |
||||
</el-table-column> |
<p>{{ scope.row.artist }}</p> |
||||
<el-table-column label="歌手"> |
</template> |
||||
<template #default="scope"> |
</el-table-column> |
||||
<p>{{ scope.row.artist }}</p> |
<el-table-column label="专辑"> |
||||
</template> |
<template #default="scope"> |
||||
</el-table-column> |
<p>{{ scope.row.album }}</p> |
||||
<el-table-column label="专辑"> |
</template> |
||||
<template #default="scope"> |
</el-table-column> |
||||
<p>{{ scope.row.album }}</p> |
<el-table-column label="操作"> |
||||
</template> |
<template #default="scope"> |
||||
</el-table-column> |
<el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)"> |
||||
<el-table-column label="操作"> |
</el-button> |
||||
<template #default="scope"> |
<el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button> |
||||
<el-button circle |
<el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)"> |
||||
icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)"> |
</el-button> |
||||
</el-button> |
</template> |
||||
<el-button circle |
</el-table-column> |
||||
icon="el-icon-download" @click="handleDownload(scope.row)"> |
</el-table> |
||||
</el-button> |
|
||||
<el-button circle |
|
||||
icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)"> |
|
||||
</el-button> |
|
||||
</template> |
|
||||
</el-table-column> |
|
||||
</el-table> |
|
||||
</template> |
</template> |
||||
|
|
||||
<script> |
<script> |
||||
import {RemoveBlobMusic} from '@/utils/utils' |
import { RemoveBlobMusic } from '@/utils/utils'; |
||||
|
|
||||
export default { |
export default { |
||||
name: "PreviewTable", |
name: 'PreviewTable', |
||||
props: { |
props: { |
||||
tableData: {type: Array, required: true}, |
tableData: { type: Array, required: true }, |
||||
policy: {type: Number, required: true} |
policy: { type: Number, required: true }, |
||||
}, |
}, |
||||
|
|
||||
methods: { |
methods: { |
||||
handlePlay(index, row) { |
handlePlay(index, row) { |
||||
this.$emit("play", row.file); |
this.$emit('play', row.file); |
||||
}, |
}, |
||||
handleDelete(index, row) { |
handleDelete(index, row) { |
||||
RemoveBlobMusic(row); |
RemoveBlobMusic(row); |
||||
this.tableData.splice(index, 1); |
this.tableData.splice(index, 1); |
||||
}, |
}, |
||||
handleDownload(row) { |
handleDownload(row) { |
||||
this.$emit("download", row) |
this.$emit('download', row); |
||||
}, |
}, |
||||
} |
}, |
||||
} |
}; |
||||
</script> |
</script> |
||||
|
|
||||
<style scoped> |
<style scoped></style> |
||||
|
|
||||
</style> |
|
||||
|
@ -1,26 +1,25 @@ |
|||||
export interface DecryptResult { |
export interface DecryptResult { |
||||
title: string |
title: string; |
||||
album?: string |
album?: string; |
||||
artist?: string |
artist?: string; |
||||
|
|
||||
mime: string |
mime: string; |
||||
ext: string |
ext: string; |
||||
|
|
||||
file: string |
file: string; |
||||
blob: Blob |
blob: Blob; |
||||
picture?: string |
picture?: string; |
||||
|
|
||||
message?: string |
|
||||
rawExt?: string |
|
||||
rawFilename?: string |
|
||||
|
|
||||
|
message?: string; |
||||
|
rawExt?: string; |
||||
|
rawFilename?: string; |
||||
} |
} |
||||
|
|
||||
export interface FileInfo { |
export interface FileInfo { |
||||
status: string |
status: string; |
||||
name: string, |
name: string; |
||||
size: number, |
size: number; |
||||
percentage: number, |
percentage: number; |
||||
uid: number, |
uid: number; |
||||
raw: File |
raw: File; |
||||
} |
} |
||||
|
@ -1,122 +1,125 @@ |
|||||
import { |
import { |
||||
AudioMimeType, |
AudioMimeType, |
||||
BytesHasPrefix, |
BytesHasPrefix, |
||||
GetArrayBuffer, |
GetArrayBuffer, |
||||
GetCoverFromFile, |
GetCoverFromFile, |
||||
GetMetaFromFile, |
GetMetaFromFile, |
||||
SniffAudioExt |
SniffAudioExt, |
||||
} from "@/decrypt/utils"; |
} from '@/decrypt/utils'; |
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser"; |
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; |
||||
import {DecryptResult} from "@/decrypt/entity"; |
import { DecryptResult } from '@/decrypt/entity'; |
||||
import config from "@/../package.json" |
import config from '@/../package.json'; |
||||
|
|
||||
|
//prettier-ignore
|
||||
const VprHeader = [ |
const VprHeader = [ |
||||
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43, |
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43, |
||||
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31] |
0x91, 0xAA, 0xBD, 0xD0, 0x7A, 0xF5, 0x36, 0x31 |
||||
|
] |
||||
|
//prettier-ignore
|
||||
const KgmHeader = [ |
const KgmHeader = [ |
||||
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B, |
0x7C, 0xD5, 0x32, 0xEB, 0x86, 0x02, 0x7F, 0x4B, |
||||
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14] |
0xA8, 0xAF, 0xA6, 0x8E, 0x0F, 0xFF, 0x99, 0x14 |
||||
|
] |
||||
|
//prettier-ignore
|
||||
const VprMaskDiff = [ |
const VprMaskDiff = [ |
||||
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E, |
0x25, 0xDF, 0xE8, 0xA6, 0x75, 0x1E, 0x75, 0x0E, |
||||
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11, |
0x2F, 0x80, 0xF3, 0x2D, 0xB8, 0xB6, 0xE3, 0x11, |
||||
0x00] |
0x00 |
||||
|
] |
||||
|
|
||||
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> { |
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> { |
||||
|
const oriData = 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); |
||||
|
|
||||
const oriData = new Uint8Array(await GetArrayBuffer(file)); |
let audioData = oriData.slice(headerLen); |
||||
if (raw_ext === "vpr") { |
let dataLen = audioData.length; |
||||
if (!BytesHasPrefix(oriData, VprHeader)) throw Error("Not a valid vpr file!") |
if (audioData.byteLength > 1 << 26) { |
||||
} else { |
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁"); |
||||
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 key1 = new Uint8Array(17); |
||||
let dataLen = audioData.length |
key1.set(oriData.slice(0x1c, 0x2c), 0); |
||||
if (audioData.byteLength > 1 << 26) { |
if (MaskV2.length === 0) { |
||||
throw Error("文件过大,请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁") |
if (!(await LoadMaskV2())) throw Error('加载Kgm/Vpr Mask数据失败'); |
||||
} |
} |
||||
|
|
||||
let key1 = new Uint8Array(17) |
for (let i = 0; i < dataLen; i++) { |
||||
key1.set(oriData.slice(0x1c, 0x2c), 0) |
let med8 = key1[i % 17] ^ audioData[i]; |
||||
if (MaskV2.length === 0) { |
med8 ^= (med8 & 0xf) << 4; |
||||
if (!await LoadMaskV2()) throw Error("加载Kgm/Vpr Mask数据失败") |
|
||||
} |
|
||||
|
|
||||
for (let i = 0; i < dataLen; i++) { |
let msk8 = GetMask(i); |
||||
let med8 = key1[i % 17] ^ audioData[i] |
msk8 ^= (msk8 & 0xf) << 4; |
||||
med8 ^= (med8 & 0xf) << 4 |
audioData[i] = med8 ^ msk8; |
||||
|
} |
||||
|
if (raw_ext === 'vpr') { |
||||
|
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17]; |
||||
|
} |
||||
|
|
||||
let msk8 = GetMask(i) |
const ext = SniffAudioExt(audioData); |
||||
msk8 ^= (msk8 & 0xf) << 4 |
const mime = AudioMimeType[ext]; |
||||
audioData[i] = med8 ^ msk8 |
let musicBlob = new Blob([audioData], { type: mime }); |
||||
} |
const musicMeta = await metaParseBlob(musicBlob); |
||||
if (raw_ext === "vpr") { |
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist); |
||||
for (let i = 0; i < dataLen; i++) audioData[i] ^= VprMaskDiff[i % 17] |
return { |
||||
} |
album: musicMeta.common.album, |
||||
|
picture: GetCoverFromFile(musicMeta), |
||||
const ext = SniffAudioExt(audioData); |
file: URL.createObjectURL(musicBlob), |
||||
const mime = AudioMimeType[ext]; |
blob: musicBlob, |
||||
let musicBlob = new Blob([audioData], {type: mime}); |
ext, |
||||
const musicMeta = await metaParseBlob(musicBlob); |
mime, |
||||
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist) |
title, |
||||
return { |
artist, |
||||
album: musicMeta.common.album, |
}; |
||||
picture: GetCoverFromFile(musicMeta), |
|
||||
file: URL.createObjectURL(musicBlob), |
|
||||
blob: musicBlob, |
|
||||
ext, |
|
||||
mime, |
|
||||
title, |
|
||||
artist |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
|
|
||||
function GetMask(pos: number) { |
function GetMask(pos: number) { |
||||
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4] |
return MaskV2PreDef[pos % 272] ^ MaskV2[pos >> 4]; |
||||
} |
} |
||||
|
|
||||
let MaskV2: Uint8Array = new Uint8Array(0); |
let MaskV2: Uint8Array = new Uint8Array(0); |
||||
|
|
||||
async function LoadMaskV2(): Promise<boolean> { |
async function LoadMaskV2(): Promise<boolean> { |
||||
let mask_url = `https://cdn.jsdelivr.net/gh/unlock-music/unlock-music@${config.version}/public/static/kgm.mask` |
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 (['http:', 'https:'].some((v) => v == self.location.protocol)) { |
||||
if (!!self.document) {// using Web Worker
|
if (!!self.document) { |
||||
mask_url = "./static/kgm.mask" |
// using Web Worker
|
||||
} else {// using Main thread
|
mask_url = './static/kgm.mask'; |
||||
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 |
|
||||
} |
} |
||||
|
} |
||||
|
try { |
||||
|
const resp = await fetch(mask_url, { method: 'GET' }); |
||||
|
MaskV2 = new Uint8Array(await resp.arrayBuffer()); |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
console.error(e); |
||||
|
return false; |
||||
|
} |
||||
} |
} |
||||
|
|
||||
|
//prettier-ignore
|
||||
const MaskV2PreDef = [ |
const MaskV2PreDef = [ |
||||
0xB8, 0xD5, 0x3D, 0xB2, 0xE9, 0xAF, 0x78, 0x8C, 0x83, 0x33, 0x71, 0x51, 0x76, 0xA0, 0xCD, 0x37, |
0xb8, 0xd5, 0x3d, 0xb2, 0xe9, 0xaf, 0x78, 0x8c, 0x83, 0x33, 0x71, 0x51, 0x76, 0xa0, 0xcd, 0x37, 0x2f, 0x3e, 0x35, |
||||
0x2F, 0x3E, 0x35, 0x8D, 0xA9, 0xBE, 0x98, 0xB7, 0xE7, 0x8C, 0x22, 0xCE, 0x5A, 0x61, 0xDF, 0x68, |
0x8d, 0xa9, 0xbe, 0x98, 0xb7, 0xe7, 0x8c, 0x22, 0xce, 0x5a, 0x61, 0xdf, 0x68, 0x69, 0x89, 0xfe, 0xa5, 0xb6, 0xde, |
||||
0x69, 0x89, 0xFE, 0xA5, 0xB6, 0xDE, 0xA9, 0x77, 0xFC, 0xC8, 0xBD, 0xBD, 0xE5, 0x6D, 0x3E, 0x5A, |
0xa9, 0x77, 0xfc, 0xc8, 0xbd, 0xbd, 0xe5, 0x6d, 0x3e, 0x5a, 0x36, 0xef, 0x69, 0x4e, 0xbe, 0xe1, 0xe9, 0x66, 0x1c, |
||||
0x36, 0xEF, 0x69, 0x4E, 0xBE, 0xE1, 0xE9, 0x66, 0x1C, 0xF3, 0xD9, 0x02, 0xB6, 0xF2, 0x12, 0x9B, |
0xf3, 0xd9, 0x02, 0xb6, 0xf2, 0x12, 0x9b, 0x44, 0xd0, 0x6f, 0xb9, 0x35, 0x89, 0xb6, 0x46, 0x6d, 0x73, 0x82, 0x06, |
||||
0x44, 0xD0, 0x6F, 0xB9, 0x35, 0x89, 0xB6, 0x46, 0x6D, 0x73, 0x82, 0x06, 0x69, 0xC1, 0xED, 0xD7, |
0x69, 0xc1, 0xed, 0xd7, 0x85, 0xc2, 0x30, 0xdf, 0xa2, 0x62, 0xbe, 0x79, 0x2d, 0x62, 0x62, 0x3d, 0x0d, 0x7e, 0xbe, |
||||
0x85, 0xC2, 0x30, 0xDF, 0xA2, 0x62, 0xBE, 0x79, 0x2D, 0x62, 0x62, 0x3D, 0x0D, 0x7E, 0xBE, 0x48, |
0x48, 0x89, 0x23, 0x02, 0xa0, 0xe4, 0xd5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xfd, 0x16, 0x3a, 0x21, 0x3b, 0x16, 0x0f, |
||||
0x89, 0x23, 0x02, 0xA0, 0xE4, 0xD5, 0x75, 0x51, 0x32, 0x02, 0x53, 0xFD, 0x16, 0x3A, 0x21, 0x3B, |
0xc3, 0xb2, 0xbb, 0xb3, 0xe2, 0xba, 0x3a, 0x3d, 0x13, 0xec, 0xf6, 0x01, 0x45, 0x84, 0xa5, 0x70, 0x0f, 0x93, 0x49, |
||||
0x16, 0x0F, 0xC3, 0xB2, 0xBB, 0xB3, 0xE2, 0xBA, 0x3A, 0x3D, 0x13, 0xEC, 0xF6, 0x01, 0x45, 0x84, |
0x0c, 0x64, 0xcd, 0x31, 0xd5, 0xcc, 0x4c, 0x07, 0x01, 0x9e, 0x00, 0x1a, 0x23, 0x90, 0xbf, 0x88, 0x1e, 0x3b, 0xab, |
||||
0xA5, 0x70, 0x0F, 0x93, 0x49, 0x0C, 0x64, 0xCD, 0x31, 0xD5, 0xCC, 0x4C, 0x07, 0x01, 0x9E, 0x00, |
0xa6, 0x3e, 0xc4, 0x73, 0x47, 0x10, 0x7e, 0x3b, 0x5e, 0xbc, 0xe3, 0x00, 0x84, 0xff, 0x09, 0xd4, 0xe0, 0x89, 0x0f, |
||||
0x1A, 0x23, 0x90, 0xBF, 0x88, 0x1E, 0x3B, 0xAB, 0xA6, 0x3E, 0xC4, 0x73, 0x47, 0x10, 0x7E, 0x3B, |
0x5b, 0x58, 0x70, 0x4f, 0xfb, 0x65, 0xd8, 0x5c, 0x53, 0x1b, 0xd3, 0xc8, 0xc6, 0xbf, 0xef, 0x98, 0xb0, 0x50, 0x4f, |
||||
0x5E, 0xBC, 0xE3, 0x00, 0x84, 0xFF, 0x09, 0xD4, 0xE0, 0x89, 0x0F, 0x5B, 0x58, 0x70, 0x4F, 0xFB, |
0x0f, 0xea, 0xe5, 0x83, 0x58, 0x8c, 0x28, 0x2c, 0x84, 0x67, 0xcd, 0xd0, 0x9e, 0x47, 0xdb, 0x27, 0x50, 0xca, 0xf4, |
||||
0x65, 0xD8, 0x5C, 0x53, 0x1B, 0xD3, 0xC8, 0xC6, 0xBF, 0xEF, 0x98, 0xB0, 0x50, 0x4F, 0x0F, 0xEA, |
0x63, 0x63, 0xe8, 0x97, 0x7f, 0x1b, 0x4b, 0x0c, 0xc2, 0xc1, 0x21, 0x4c, 0xcc, 0x58, 0xf5, 0x94, 0x52, 0xa3, 0xf3, |
||||
0xE5, 0x83, 0x58, 0x8C, 0x28, 0x2C, 0x84, 0x67, 0xCD, 0xD0, 0x9E, 0x47, 0xDB, 0x27, 0x50, 0xCA, |
0xd3, 0xe0, 0x68, 0xf4, 0x00, 0x23, 0xf3, 0x5e, 0x0a, 0x7b, 0x93, 0xdd, 0xab, 0x12, 0xb2, 0x13, 0xe8, 0x84, 0xd7, |
||||
0xF4, 0x63, 0x63, 0xE8, 0x97, 0x7F, 0x1B, 0x4B, 0x0C, 0xC2, 0xC1, 0x21, 0x4C, 0xCC, 0x58, 0xF5, |
0xa7, 0x9f, 0x0f, 0x32, 0x4c, 0x55, 0x1d, 0x04, 0x36, 0x52, 0xdc, 0x03, 0xf3, 0xf9, 0x4e, 0x42, 0xe9, 0x3d, 0x61, |
||||
0x94, 0x52, 0xA3, 0xF3, 0xD3, 0xE0, 0x68, 0xF4, 0x00, 0x23, 0xF3, 0x5E, 0x0A, 0x7B, 0x93, 0xDD, |
0xef, 0x7c, 0xb6, 0xb3, 0x93, 0x50, |
||||
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, |
|
||||
] |
|
||||
|
|
||||
|
@ -1,77 +1,74 @@ |
|||||
import { |
import { |
||||
AudioMimeType, |
AudioMimeType, |
||||
BytesHasPrefix, |
BytesHasPrefix, |
||||
GetArrayBuffer, |
GetArrayBuffer, |
||||
GetCoverFromFile, |
GetCoverFromFile, |
||||
GetMetaFromFile, |
GetMetaFromFile, |
||||
SniffAudioExt |
SniffAudioExt, |
||||
} from "@/decrypt/utils"; |
} from '@/decrypt/utils'; |
||||
import {Decrypt as RawDecrypt} from "@/decrypt/raw"; |
import { Decrypt as RawDecrypt } from '@/decrypt/raw'; |
||||
|
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser"; |
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; |
||||
import {DecryptResult} from "@/decrypt/entity"; |
import { DecryptResult } from '@/decrypt/entity'; |
||||
|
|
||||
|
//prettier-ignore
|
||||
const MagicHeader = [ |
const MagicHeader = [ |
||||
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D, |
0x79, 0x65, 0x65, 0x6C, 0x69, 0x6F, 0x6E, 0x2D, |
||||
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65, |
0x6B, 0x75, 0x77, 0x6F, 0x2D, 0x74, 0x6D, 0x65, |
||||
] |
] |
||||
const PreDefinedKey = "MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk" |
const PreDefinedKey = 'MoOtOiTvINGwd2E6n0E1i7L5t2IoOoNk'; |
||||
|
|
||||
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> { |
export async function Decrypt(file: File, raw_filename: string, _: string): Promise<DecryptResult> { |
||||
const oriData = new Uint8Array(await GetArrayBuffer(file)); |
const oriData = new Uint8Array(await GetArrayBuffer(file)); |
||||
if (!BytesHasPrefix(oriData, MagicHeader)) { |
if (!BytesHasPrefix(oriData, MagicHeader)) { |
||||
if (SniffAudioExt(oriData) === "aac") { |
if (SniffAudioExt(oriData) === 'aac') { |
||||
return await RawDecrypt(file, raw_filename, "aac", false) |
return await RawDecrypt(file, raw_filename, 'aac', false); |
||||
} |
|
||||
throw Error("not a valid kwm file") |
|
||||
} |
} |
||||
|
throw Error('not a valid kwm file'); |
||||
|
} |
||||
|
|
||||
let fileKey = oriData.slice(0x18, 0x20) |
let fileKey = oriData.slice(0x18, 0x20); |
||||
let mask = createMaskFromKey(fileKey) |
let mask = createMaskFromKey(fileKey); |
||||
let audioData = oriData.slice(0x400); |
let audioData = oriData.slice(0x400); |
||||
let lenAudioData = audioData.length; |
let lenAudioData = audioData.length; |
||||
for (let cur = 0; cur < lenAudioData; ++cur) |
for (let cur = 0; cur < lenAudioData; ++cur) audioData[cur] ^= mask[cur % 0x20]; |
||||
audioData[cur] ^= mask[cur % 0x20]; |
|
||||
|
|
||||
|
const ext = SniffAudioExt(audioData); |
||||
|
const mime = AudioMimeType[ext]; |
||||
|
let musicBlob = new Blob([audioData], { type: mime }); |
||||
|
|
||||
const ext = SniffAudioExt(audioData); |
const musicMeta = await metaParseBlob(musicBlob); |
||||
const mime = AudioMimeType[ext]; |
const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist); |
||||
let musicBlob = new Blob([audioData], {type: mime}); |
return { |
||||
|
album: musicMeta.common.album, |
||||
const musicMeta = await metaParseBlob(musicBlob); |
picture: GetCoverFromFile(musicMeta), |
||||
const {title, artist} = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist) |
file: URL.createObjectURL(musicBlob), |
||||
return { |
blob: musicBlob, |
||||
album: musicMeta.common.album, |
mime, |
||||
picture: GetCoverFromFile(musicMeta), |
title, |
||||
file: URL.createObjectURL(musicBlob), |
artist, |
||||
blob: musicBlob, |
ext, |
||||
mime, |
}; |
||||
title, |
|
||||
artist, |
|
||||
ext |
|
||||
} |
|
||||
} |
} |
||||
|
|
||||
|
|
||||
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array { |
function createMaskFromKey(keyBytes: Uint8Array): Uint8Array { |
||||
let keyView = new DataView(keyBytes.buffer) |
let keyView = new DataView(keyBytes.buffer); |
||||
let keyStr = keyView.getBigUint64(0, true).toString() |
let keyStr = keyView.getBigUint64(0, true).toString(); |
||||
let keyStrTrim = trimKey(keyStr) |
let keyStrTrim = trimKey(keyStr); |
||||
let key = new Uint8Array(32) |
let key = new Uint8Array(32); |
||||
for (let i = 0; i < 32; i++) { |
for (let i = 0; i < 32; i++) { |
||||
key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i) |
key[i] = PreDefinedKey.charCodeAt(i) ^ keyStrTrim.charCodeAt(i); |
||||
} |
} |
||||
return key |
return key; |
||||
} |
} |
||||
|
|
||||
|
|
||||
function trimKey(keyRaw: string): string { |
function trimKey(keyRaw: string): string { |
||||
let lenRaw = keyRaw.length; |
let lenRaw = keyRaw.length; |
||||
let out = keyRaw; |
let out = keyRaw; |
||||
if (lenRaw > 32) { |
if (lenRaw > 32) { |
||||
out = keyRaw.slice(0, 32) |
out = keyRaw.slice(0, 32); |
||||
} else if (lenRaw < 32) { |
} else if (lenRaw < 32) { |
||||
out = keyRaw.padEnd(32, keyRaw) |
out = keyRaw.padEnd(32, keyRaw); |
||||
} |
} |
||||
return out |
return out; |
||||
} |
} |
||||
|
@ -1,29 +1,28 @@ |
|||||
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils"; |
import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils'; |
||||
|
|
||||
import {DecryptResult} from "@/decrypt/entity"; |
import { DecryptResult } from '@/decrypt/entity'; |
||||
|
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser"; |
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; |
||||
|
|
||||
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string) |
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> { |
||||
: Promise<DecryptResult> { |
const buffer = new Uint8Array(await GetArrayBuffer(file)); |
||||
const buffer = new Uint8Array(await GetArrayBuffer(file)); |
let length = buffer.length; |
||||
let length = buffer.length |
for (let i = 0; i < length; i++) { |
||||
for (let i = 0; i < length; i++) { |
buffer[i] ^= 163; |
||||
buffer[i] ^= 163 |
} |
||||
} |
const ext = SniffAudioExt(buffer, raw_ext); |
||||
const ext = SniffAudioExt(buffer, raw_ext); |
if (ext !== raw_ext) file = new Blob([buffer], { type: AudioMimeType[ext] }); |
||||
if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]}) |
const tag = await metaParseBlob(file); |
||||
const tag = await metaParseBlob(file); |
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist); |
||||
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist) |
|
||||
|
|
||||
return { |
return { |
||||
title, |
title, |
||||
artist, |
artist, |
||||
ext, |
ext, |
||||
album: tag.common.album, |
album: tag.common.album, |
||||
picture: GetCoverFromFile(tag), |
picture: GetCoverFromFile(tag), |
||||
file: URL.createObjectURL(file), |
file: URL.createObjectURL(file), |
||||
blob: file, |
blob: file, |
||||
mime: AudioMimeType[ext] |
mime: AudioMimeType[ext], |
||||
} |
}; |
||||
} |
} |
||||
|
@ -1,115 +1,117 @@ |
|||||
import {QmcMapCipher, QmcRC4Cipher, QmcStaticCipher} from "@/decrypt/qmc_cipher"; |
import { QmcMapCipher, QmcRC4Cipher, QmcStaticCipher } from '@/decrypt/qmc_cipher'; |
||||
import fs from 'fs' |
import fs from 'fs'; |
||||
|
|
||||
test("static cipher [0x7ff8,0x8000) ", () => { |
test('static cipher [0x7ff8,0x8000) ', () => { |
||||
|
//prettier-ignore
|
||||
const expected = new Uint8Array([ |
const expected = new Uint8Array([ |
||||
0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A, |
0xD8, 0x52, 0xF7, 0x67, 0x90, 0xCA, 0xD6, 0x4A, |
||||
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8, |
0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, 0xD8, |
||||
]) |
]) |
||||
|
|
||||
const c = new QmcStaticCipher() |
const c = new QmcStaticCipher(); |
||||
const buf = new Uint8Array(16) |
const buf = new Uint8Array(16); |
||||
c.decrypt(buf, 0x7ff8) |
c.decrypt(buf, 0x7ff8); |
||||
|
|
||||
expect(buf).toStrictEqual(expected) |
expect(buf).toStrictEqual(expected); |
||||
}) |
}); |
||||
|
|
||||
test("static cipher [0,0x10) ", () => { |
test('static cipher [0,0x10) ', () => { |
||||
|
//prettier-ignore
|
||||
const expected = new Uint8Array([ |
const expected = new Uint8Array([ |
||||
0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, |
0xC3, 0x4A, 0xD6, 0xCA, 0x90, 0x67, 0xF7, 0x52, |
||||
0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00, |
0xD8, 0xA1, 0x66, 0x62, 0x9F, 0x5B, 0x09, 0x00, |
||||
]) |
]) |
||||
|
|
||||
const c = new QmcStaticCipher() |
const c = new QmcStaticCipher(); |
||||
const buf = new Uint8Array(16) |
const buf = new Uint8Array(16); |
||||
c.decrypt(buf, 0) |
c.decrypt(buf, 0); |
||||
|
|
||||
expect(buf).toStrictEqual(expected) |
expect(buf).toStrictEqual(expected); |
||||
}) |
}); |
||||
|
|
||||
|
test('map cipher: get mask', () => { |
||||
test("map cipher: get mask", () => { |
//prettier-ignore
|
||||
const expected = new Uint8Array([ |
const expected = new Uint8Array([ |
||||
0xBB, 0x7D, 0x80, 0xBE, 0xFF, 0x38, 0x81, 0xFB, |
0xBB, 0x7D, 0x80, 0xBE, 0xFF, 0x38, 0x81, 0xFB, |
||||
0xBB, 0xFF, 0x82, 0x3C, 0xFF, 0xBA, 0x83, 0x79, |
0xBB, 0xFF, 0x82, 0x3C, 0xFF, 0xBA, 0x83, 0x79, |
||||
]) |
]) |
||||
const key = new Uint8Array(256) |
const key = new Uint8Array(256); |
||||
for (let i = 0; i < 256; i++) key[i] = i |
for (let i = 0; i < 256; i++) key[i] = i; |
||||
const buf = new Uint8Array(16) |
const buf = new Uint8Array(16); |
||||
|
|
||||
const c = new QmcMapCipher(key) |
const c = new QmcMapCipher(key); |
||||
c.decrypt(buf, 0) |
c.decrypt(buf, 0); |
||||
expect(buf).toStrictEqual(expected) |
expect(buf).toStrictEqual(expected); |
||||
}) |
}); |
||||
|
|
||||
function loadTestDataCipher(name: string): { |
function loadTestDataCipher(name: string): { |
||||
key: Uint8Array, |
key: Uint8Array; |
||||
cipherText: Uint8Array, |
cipherText: Uint8Array; |
||||
clearText: Uint8Array |
clearText: Uint8Array; |
||||
} { |
} { |
||||
return { |
return { |
||||
key: fs.readFileSync(`testdata/${name}_key.bin`), |
key: fs.readFileSync(`testdata/${name}_key.bin`), |
||||
cipherText: fs.readFileSync(`testdata/${name}_raw.bin`), |
cipherText: fs.readFileSync(`testdata/${name}_raw.bin`), |
||||
clearText: fs.readFileSync(`testdata/${name}_target.bin`) |
clearText: fs.readFileSync(`testdata/${name}_target.bin`), |
||||
} |
}; |
||||
} |
} |
||||
|
|
||||
test("map cipher: real file", async () => { |
test('map cipher: real file', async () => { |
||||
const cases = ["mflac_map", "mgg_map"] |
const cases = ['mflac_map', 'mgg_map']; |
||||
for (const name of cases) { |
for (const name of cases) { |
||||
const {key, clearText, cipherText} = loadTestDataCipher(name) |
const { key, clearText, cipherText } = loadTestDataCipher(name); |
||||
const c = new QmcMapCipher(key) |
const c = new QmcMapCipher(key); |
||||
|
|
||||
c.decrypt(cipherText, 0) |
c.decrypt(cipherText, 0); |
||||
|
|
||||
expect(cipherText).toStrictEqual(clearText) |
expect(cipherText).toStrictEqual(clearText); |
||||
} |
} |
||||
}) |
}); |
||||
|
|
||||
test("rc4 cipher: real file", async () => { |
test('rc4 cipher: real file', async () => { |
||||
const cases = ["mflac0_rc4"] |
const cases = ['mflac0_rc4']; |
||||
for (const name of cases) { |
for (const name of cases) { |
||||
const {key, clearText, cipherText} = loadTestDataCipher(name) |
const { key, clearText, cipherText } = loadTestDataCipher(name); |
||||
const c = new QmcRC4Cipher(key) |
const c = new QmcRC4Cipher(key); |
||||
|
|
||||
c.decrypt(cipherText, 0) |
c.decrypt(cipherText, 0); |
||||
|
|
||||
expect(cipherText).toStrictEqual(clearText) |
expect(cipherText).toStrictEqual(clearText); |
||||
} |
} |
||||
}) |
}); |
||||
|
|
||||
test("rc4 cipher: first segment", async () => { |
test('rc4 cipher: first segment', async () => { |
||||
const cases = ["mflac0_rc4"] |
const cases = ['mflac0_rc4']; |
||||
for (const name of cases) { |
for (const name of cases) { |
||||
const {key, clearText, cipherText} = loadTestDataCipher(name) |
const { key, clearText, cipherText } = loadTestDataCipher(name); |
||||
const c = new QmcRC4Cipher(key) |
const c = new QmcRC4Cipher(key); |
||||
|
|
||||
const buf = cipherText.slice(0, 128) |
const buf = cipherText.slice(0, 128); |
||||
c.decrypt(buf, 0) |
c.decrypt(buf, 0); |
||||
expect(buf).toStrictEqual(clearText.slice(0, 128)) |
expect(buf).toStrictEqual(clearText.slice(0, 128)); |
||||
} |
} |
||||
}) |
}); |
||||
|
|
||||
test("rc4 cipher: align block (128~5120)", async () => { |
test('rc4 cipher: align block (128~5120)', async () => { |
||||
const cases = ["mflac0_rc4"] |
const cases = ['mflac0_rc4']; |
||||
for (const name of cases) { |
for (const name of cases) { |
||||
const {key, clearText, cipherText} = loadTestDataCipher(name) |
const { key, clearText, cipherText } = loadTestDataCipher(name); |
||||
const c = new QmcRC4Cipher(key) |
const c = new QmcRC4Cipher(key); |
||||
|
|
||||
const buf = cipherText.slice(128, 5120) |
const buf = cipherText.slice(128, 5120); |
||||
c.decrypt(buf, 128) |
c.decrypt(buf, 128); |
||||
expect(buf).toStrictEqual(clearText.slice(128, 5120)) |
expect(buf).toStrictEqual(clearText.slice(128, 5120)); |
||||
} |
} |
||||
}) |
}); |
||||
|
|
||||
test("rc4 cipher: simple block (5120~10240)", async () => { |
test('rc4 cipher: simple block (5120~10240)', async () => { |
||||
const cases = ["mflac0_rc4"] |
const cases = ['mflac0_rc4']; |
||||
for (const name of cases) { |
for (const name of cases) { |
||||
const {key, clearText, cipherText} = loadTestDataCipher(name) |
const { key, clearText, cipherText } = loadTestDataCipher(name); |
||||
const c = new QmcRC4Cipher(key) |
const c = new QmcRC4Cipher(key); |
||||
|
|
||||
const buf = cipherText.slice(5120, 10240) |
const buf = cipherText.slice(5120, 10240); |
||||
c.decrypt(buf, 5120) |
c.decrypt(buf, 5120); |
||||
expect(buf).toStrictEqual(clearText.slice(5120, 10240)) |
expect(buf).toStrictEqual(clearText.slice(5120, 10240)); |
||||
} |
} |
||||
}) |
}); |
||||
|
@ -1,30 +1,26 @@ |
|||||
import {QmcDeriveKey, simpleMakeKey} from "@/decrypt/qmc_key"; |
import { QmcDeriveKey, simpleMakeKey } from '@/decrypt/qmc_key'; |
||||
import fs from "fs"; |
import fs from 'fs'; |
||||
|
|
||||
test("key dec: make simple key", () => { |
test('key dec: make simple key', () => { |
||||
expect( |
expect(simpleMakeKey(106, 8)).toStrictEqual([0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b]); |
||||
simpleMakeKey(106, 8) |
}); |
||||
).toStrictEqual( |
|
||||
[0x69, 0x56, 0x46, 0x38, 0x2b, 0x20, 0x15, 0x0b] |
|
||||
) |
|
||||
}) |
|
||||
|
|
||||
function loadTestDataKeyDecrypt(name: string): { |
function loadTestDataKeyDecrypt(name: string): { |
||||
cipherText: Uint8Array, |
cipherText: Uint8Array; |
||||
clearText: Uint8Array |
clearText: Uint8Array; |
||||
} { |
} { |
||||
return { |
return { |
||||
cipherText: fs.readFileSync(`testdata/${name}_key_raw.bin`), |
cipherText: fs.readFileSync(`testdata/${name}_key_raw.bin`), |
||||
clearText: fs.readFileSync(`testdata/${name}_key.bin`) |
clearText: fs.readFileSync(`testdata/${name}_key.bin`), |
||||
} |
}; |
||||
} |
} |
||||
|
|
||||
test("key dec: real file", async () => { |
test('key dec: real file', async () => { |
||||
const cases = ["mflac_map", "mgg_map", "mflac0_rc4"] |
const cases = ['mflac_map', 'mgg_map', 'mflac0_rc4']; |
||||
for (const name of cases) { |
for (const name of cases) { |
||||
const {clearText, cipherText} = loadTestDataKeyDecrypt(name) |
const { clearText, cipherText } = loadTestDataKeyDecrypt(name); |
||||
const buf = QmcDeriveKey(cipherText) |
const buf = QmcDeriveKey(cipherText); |
||||
|
|
||||
expect(buf).toStrictEqual(clearText) |
expect(buf).toStrictEqual(clearText); |
||||
} |
} |
||||
}) |
}); |
||||
|
@ -1,51 +1,50 @@ |
|||||
import { |
import { |
||||
AudioMimeType, |
AudioMimeType, |
||||
GetArrayBuffer, |
GetArrayBuffer, |
||||
GetCoverFromFile, |
GetCoverFromFile, |
||||
GetMetaFromFile, |
GetMetaFromFile, |
||||
SniffAudioExt, |
SniffAudioExt, |
||||
SplitFilename |
SplitFilename, |
||||
} from "@/decrypt/utils"; |
} from '@/decrypt/utils'; |
||||
|
|
||||
import {Decrypt as QmcDecrypt, HandlerMap} from "@/decrypt/qmc"; |
import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc'; |
||||
|
|
||||
import {DecryptResult} from "@/decrypt/entity"; |
import { DecryptResult } from '@/decrypt/entity'; |
||||
|
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser"; |
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; |
||||
|
|
||||
export async function Decrypt(file: Blob, raw_filename: string, _: string) |
export async function Decrypt(file: Blob, raw_filename: string, _: string): Promise<DecryptResult> { |
||||
: Promise<DecryptResult> { |
const buffer = new Uint8Array(await GetArrayBuffer(file)); |
||||
const buffer = new Uint8Array(await GetArrayBuffer(file)); |
let length = buffer.length; |
||||
let length = buffer.length |
for (let i = 0; i < length; i++) { |
||||
for (let i = 0; i < length; i++) { |
buffer[i] ^= 0xf4; |
||||
buffer[i] ^= 0xf4 |
if (buffer[i] <= 0x3f) buffer[i] = buffer[i] * 4; |
||||
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] <= 0x7f) buffer[i] = (buffer[i] - 0x40) * 4 + 1; |
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2; |
||||
else if (buffer[i] <= 0xbf) buffer[i] = (buffer[i] - 0x80) * 4 + 2; |
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3; |
||||
else buffer[i] = (buffer[i] - 0xc0) * 4 + 3; |
} |
||||
} |
let ext = SniffAudioExt(buffer, ''); |
||||
let ext = SniffAudioExt(buffer, ""); |
const newName = SplitFilename(raw_filename); |
||||
const newName = SplitFilename(raw_filename) |
let audioBlob: Blob; |
||||
let audioBlob: Blob |
if (ext !== '' || newName.ext === 'mp3') { |
||||
if (ext !== "" || newName.ext === "mp3") { |
audioBlob = new Blob([buffer], { type: AudioMimeType[ext] }); |
||||
audioBlob = new Blob([buffer], {type: AudioMimeType[ext]}) |
} else if (newName.ext in HandlerMap) { |
||||
} else if (newName.ext in HandlerMap) { |
audioBlob = new Blob([buffer], { type: 'application/octet-stream' }); |
||||
audioBlob = new Blob([buffer], {type: "application/octet-stream"}) |
return QmcDecrypt(audioBlob, newName.name, newName.ext); |
||||
return QmcDecrypt(audioBlob, newName.name, newName.ext); |
} else { |
||||
} else { |
throw '不支持的QQ音乐缓存格式'; |
||||
throw "不支持的QQ音乐缓存格式" |
} |
||||
} |
const tag = await metaParseBlob(audioBlob); |
||||
const tag = await metaParseBlob(audioBlob); |
const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist); |
||||
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist) |
|
||||
|
|
||||
return { |
return { |
||||
title, |
title, |
||||
artist, |
artist, |
||||
ext, |
ext, |
||||
album: tag.common.album, |
album: tag.common.album, |
||||
picture: GetCoverFromFile(tag), |
picture: GetCoverFromFile(tag), |
||||
file: URL.createObjectURL(audioBlob), |
file: URL.createObjectURL(audioBlob), |
||||
blob: audioBlob, |
blob: audioBlob, |
||||
mime: AudioMimeType[ext] |
mime: AudioMimeType[ext], |
||||
} |
}; |
||||
} |
} |
||||
|
@ -1,28 +1,32 @@ |
|||||
import {AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt} from "@/decrypt/utils"; |
import { AudioMimeType, GetArrayBuffer, GetCoverFromFile, GetMetaFromFile, SniffAudioExt } from '@/decrypt/utils'; |
||||
|
|
||||
import {DecryptResult} from "@/decrypt/entity"; |
import { DecryptResult } from '@/decrypt/entity'; |
||||
|
|
||||
import {parseBlob as metaParseBlob} from "music-metadata-browser"; |
import { parseBlob as metaParseBlob } from 'music-metadata-browser'; |
||||
|
|
||||
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string, detect: boolean = true) |
export async function Decrypt( |
||||
: Promise<DecryptResult> { |
file: Blob, |
||||
let ext = raw_ext; |
raw_filename: string, |
||||
if (detect) { |
raw_ext: string, |
||||
const buffer = new Uint8Array(await GetArrayBuffer(file)); |
detect: boolean = true, |
||||
ext = SniffAudioExt(buffer, raw_ext); |
): Promise<DecryptResult> { |
||||
if (ext !== raw_ext) file = new Blob([buffer], {type: AudioMimeType[ext]}) |
let ext = raw_ext; |
||||
} |
if (detect) { |
||||
const tag = await metaParseBlob(file); |
const buffer = new Uint8Array(await GetArrayBuffer(file)); |
||||
const {title, artist} = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artist) |
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 { |
return { |
||||
title, |
title, |
||||
artist, |
artist, |
||||
ext, |
ext, |
||||
album: tag.common.album, |
album: tag.common.album, |
||||
picture: GetCoverFromFile(tag), |
picture: GetCoverFromFile(tag), |
||||
file: URL.createObjectURL(file), |
file: URL.createObjectURL(file), |
||||
blob: file, |
blob: file, |
||||
mime: AudioMimeType[ext] |
mime: AudioMimeType[ext], |
||||
} |
}; |
||||
} |
} |
||||
|
@ -1,14 +1,14 @@ |
|||||
import {Decrypt as RawDecrypt} from "./raw"; |
import { Decrypt as RawDecrypt } from './raw'; |
||||
import {GetArrayBuffer} from "@/decrypt/utils"; |
import { GetArrayBuffer } from '@/decrypt/utils'; |
||||
import {DecryptResult} from "@/decrypt/entity"; |
import { DecryptResult } from '@/decrypt/entity'; |
||||
|
|
||||
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]; |
const TM_HEADER = [0x00, 0x00, 0x00, 0x20, 0x66, 0x74, 0x79, 0x70]; |
||||
|
|
||||
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> { |
export async function Decrypt(file: File, raw_filename: string): Promise<DecryptResult> { |
||||
const audioData = new Uint8Array(await GetArrayBuffer(file)); |
const audioData = new Uint8Array(await GetArrayBuffer(file)); |
||||
for (let cur = 0; cur < 8; ++cur) { |
for (let cur = 0; cur < 8; ++cur) { |
||||
audioData[cur] = TM_HEADER[cur]; |
audioData[cur] = TM_HEADER[cur]; |
||||
} |
} |
||||
const musicData = new Blob([audioData], {type: "audio/mp4"}); |
const musicData = new Blob([audioData], { type: 'audio/mp4' }); |
||||
return await RawDecrypt(musicData, raw_filename, "m4a", false) |
return await RawDecrypt(musicData, raw_filename, 'm4a', false); |
||||
} |
} |
||||
|
@ -1,177 +1,176 @@ |
|||||
import {IAudioMetadata} from "music-metadata-browser"; |
import { IAudioMetadata } from 'music-metadata-browser'; |
||||
import ID3Writer from "browser-id3-writer"; |
import ID3Writer from 'browser-id3-writer'; |
||||
import MetaFlac from "metaflac-js"; |
import MetaFlac from 'metaflac-js'; |
||||
|
|
||||
export const FLAC_HEADER = [0x66, 0x4C, 0x61, 0x43]; |
export const FLAC_HEADER = [0x66, 0x4c, 0x61, 0x43]; |
||||
export const MP3_HEADER = [0x49, 0x44, 0x33]; |
export const MP3_HEADER = [0x49, 0x44, 0x33]; |
||||
export const OGG_HEADER = [0x4F, 0x67, 0x67, 0x53]; |
export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53]; |
||||
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70]; |
export const M4A_HEADER = [0x66, 0x74, 0x79, 0x70]; |
||||
export const WMA_HEADER = [ |
export const WMA_HEADER = [ |
||||
0x30, 0x26, 0xB2, 0x75, 0x8E, 0x66, 0xCF, 0x11, |
0x30, 0x26, 0xb2, 0x75, 0x8e, 0x66, 0xcf, 0x11, 0xa6, 0xd9, 0x00, 0xaa, 0x00, 0x62, 0xce, 0x6c, |
||||
0xA6, 0xD9, 0x00, 0xAA, 0x00, 0x62, 0xCE, 0x6C, |
]; |
||||
] |
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]; |
||||
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46] |
export const AAC_HEADER = [0xff, 0xf1]; |
||||
export const AAC_HEADER = [0xFF, 0xF1] |
export const DFF_HEADER = [0x46, 0x52, 0x4d, 0x38]; |
||||
export const DFF_HEADER = [0x46, 0x52, 0x4D, 0x38] |
|
||||
|
|
||||
export const AudioMimeType: { [key: string]: string } = { |
export const AudioMimeType: { [key: string]: string } = { |
||||
mp3: "audio/mpeg", |
mp3: 'audio/mpeg', |
||||
flac: "audio/flac", |
flac: 'audio/flac', |
||||
m4a: "audio/mp4", |
m4a: 'audio/mp4', |
||||
ogg: "audio/ogg", |
ogg: 'audio/ogg', |
||||
wma: "audio/x-ms-wma", |
wma: 'audio/x-ms-wma', |
||||
wav: "audio/x-wav", |
wav: 'audio/x-wav', |
||||
dff: "audio/x-dff" |
dff: 'audio/x-dff', |
||||
}; |
}; |
||||
|
|
||||
|
|
||||
export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean { |
export function BytesHasPrefix(data: Uint8Array, prefix: number[]): boolean { |
||||
if (prefix.length > data.length) return false |
if (prefix.length > data.length) return false; |
||||
return prefix.every((val, idx) => { |
return prefix.every((val, idx) => { |
||||
return val === data[idx]; |
return val === data[idx]; |
||||
}) |
}); |
||||
} |
} |
||||
|
|
||||
export function BytesEqual(a: Uint8Array, b: Uint8Array,): boolean { |
export function BytesEqual(a: Uint8Array, b: Uint8Array): boolean { |
||||
if (a.length !== b.length) return false |
if (a.length !== b.length) return false; |
||||
return a.every((val, idx) => { |
return a.every((val, idx) => { |
||||
return val === b[idx]; |
return val === b[idx]; |
||||
}) |
}); |
||||
} |
} |
||||
|
|
||||
|
export function SniffAudioExt(data: Uint8Array, fallback_ext: string = 'mp3'): string { |
||||
export function SniffAudioExt(data: Uint8Array, fallback_ext: string = "mp3"): string { |
if (BytesHasPrefix(data, MP3_HEADER)) return 'mp3'; |
||||
if (BytesHasPrefix(data, MP3_HEADER)) return "mp3" |
if (BytesHasPrefix(data, FLAC_HEADER)) return 'flac'; |
||||
if (BytesHasPrefix(data, FLAC_HEADER)) return "flac" |
if (BytesHasPrefix(data, OGG_HEADER)) return 'ogg'; |
||||
if (BytesHasPrefix(data, OGG_HEADER)) return "ogg" |
if (data.length >= 4 + M4A_HEADER.length && BytesHasPrefix(data.slice(4), M4A_HEADER)) return 'm4a'; |
||||
if (data.length >= 4 + M4A_HEADER.length && |
if (BytesHasPrefix(data, WAV_HEADER)) return 'wav'; |
||||
BytesHasPrefix(data.slice(4), M4A_HEADER)) return "m4a" |
if (BytesHasPrefix(data, WMA_HEADER)) return 'wma'; |
||||
if (BytesHasPrefix(data, WAV_HEADER)) return "wav" |
if (BytesHasPrefix(data, AAC_HEADER)) return 'aac'; |
||||
if (BytesHasPrefix(data, WMA_HEADER)) return "wma" |
if (BytesHasPrefix(data, DFF_HEADER)) return 'dff'; |
||||
if (BytesHasPrefix(data, AAC_HEADER)) return "aac" |
return fallback_ext; |
||||
if (BytesHasPrefix(data, DFF_HEADER)) return "dff" |
|
||||
return fallback_ext; |
|
||||
} |
} |
||||
|
|
||||
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> { |
export function GetArrayBuffer(obj: Blob): Promise<ArrayBuffer> { |
||||
if (!!obj.arrayBuffer) return obj.arrayBuffer() |
if (!!obj.arrayBuffer) return obj.arrayBuffer(); |
||||
return new Promise((resolve, reject) => { |
return new Promise((resolve, reject) => { |
||||
const reader = new FileReader(); |
const reader = new FileReader(); |
||||
reader.onload = (e) => { |
reader.onload = (e) => { |
||||
const rs = e.target?.result |
const rs = e.target?.result; |
||||
if (!rs) { |
if (!rs) { |
||||
reject("read file failed") |
reject('read file failed'); |
||||
} else { |
} else { |
||||
resolve(rs as ArrayBuffer) |
resolve(rs as ArrayBuffer); |
||||
} |
} |
||||
}; |
}; |
||||
reader.readAsArrayBuffer(obj); |
reader.readAsArrayBuffer(obj); |
||||
}); |
}); |
||||
} |
} |
||||
|
|
||||
export function GetCoverFromFile(metadata: IAudioMetadata): string { |
export function GetCoverFromFile(metadata: IAudioMetadata): string { |
||||
if (metadata.common?.picture && metadata.common.picture.length > 0) { |
if (metadata.common?.picture && metadata.common.picture.length > 0) { |
||||
return URL.createObjectURL(new Blob( |
return URL.createObjectURL( |
||||
[metadata.common.picture[0].data], |
new Blob([metadata.common.picture[0].data], { type: metadata.common.picture[0].format }), |
||||
{type: metadata.common.picture[0].format} |
); |
||||
)); |
} |
||||
} |
return ''; |
||||
return ""; |
|
||||
} |
} |
||||
|
|
||||
export interface IMusicMetaBasic { |
export interface IMusicMetaBasic { |
||||
title: string |
title: string; |
||||
artist?: string |
artist?: string; |
||||
} |
} |
||||
|
|
||||
export function GetMetaFromFile(filename: string, exist_title?: string, exist_artist?: string, separator = "-") |
export function GetMetaFromFile( |
||||
: IMusicMetaBasic { |
filename: string, |
||||
const meta: IMusicMetaBasic = {title: exist_title ?? "", artist: exist_artist} |
exist_title?: string, |
||||
|
exist_artist?: string, |
||||
const items = filename.split(separator); |
separator = '-', |
||||
if (items.length > 1) { |
): IMusicMetaBasic { |
||||
if (!meta.artist) meta.artist = items[0].trim(); |
const meta: IMusicMetaBasic = { title: exist_title ?? '', artist: exist_artist }; |
||||
if (!meta.title) meta.title = items[1].trim(); |
|
||||
} else if (items.length === 1) { |
const items = filename.split(separator); |
||||
if (!meta.title) meta.title = items[0].trim(); |
if (items.length > 1) { |
||||
} |
if (!meta.artist) meta.artist = items[0].trim(); |
||||
return meta |
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): |
export async function GetImageFromURL( |
||||
Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> { |
src: string, |
||||
try { |
): Promise<{ mime: string; buffer: ArrayBuffer; url: string } | undefined> { |
||||
const resp = await fetch(src); |
try { |
||||
const mime = resp.headers.get("Content-Type"); |
const resp = await fetch(src); |
||||
if (mime?.startsWith("image/")) { |
const mime = resp.headers.get('Content-Type'); |
||||
const buffer = await resp.arrayBuffer(); |
if (mime?.startsWith('image/')) { |
||||
const url = URL.createObjectURL(new Blob([buffer], {type: mime})) |
const buffer = await resp.arrayBuffer(); |
||||
return {buffer, url, mime} |
const url = URL.createObjectURL(new Blob([buffer], { type: mime })); |
||||
} |
return { buffer, url, mime }; |
||||
} catch (e) { |
|
||||
console.warn(e) |
|
||||
} |
} |
||||
|
} catch (e) { |
||||
|
console.warn(e); |
||||
|
} |
||||
} |
} |
||||
|
|
||||
|
|
||||
export interface IMusicMeta { |
export interface IMusicMeta { |
||||
title: string |
title: string; |
||||
artists?: string[] |
artists?: string[]; |
||||
album?: string |
album?: string; |
||||
picture?: ArrayBuffer |
picture?: ArrayBuffer; |
||||
picture_desc?: string |
picture_desc?: string; |
||||
} |
} |
||||
|
|
||||
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { |
export function WriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { |
||||
const writer = new ID3Writer(audioData); |
const writer = new ID3Writer(audioData); |
||||
|
|
||||
// reserve original data
|
// reserve original data
|
||||
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || [] |
const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []; |
||||
frames.forEach(frame => { |
frames.forEach((frame) => { |
||||
if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') { |
if (frame.id !== 'TPE1' && frame.id !== 'TIT2' && frame.id !== 'TALB') { |
||||
try { |
try { |
||||
writer.setFrame(frame.id, frame.value) |
writer.setFrame(frame.id, frame.value); |
||||
} catch (e) { |
} catch (e) {} |
||||
} |
|
||||
} |
|
||||
}) |
|
||||
|
|
||||
const old = original.common |
|
||||
writer.setFrame('TPE1', old?.artists || info.artists || []) |
|
||||
.setFrame('TIT2', old?.title || info.title) |
|
||||
.setFrame('TALB', old?.album || info.album || ""); |
|
||||
if (info.picture) { |
|
||||
writer.setFrame('APIC', { |
|
||||
type: 3, |
|
||||
data: info.picture, |
|
||||
description: info.picture_desc || "Cover", |
|
||||
}) |
|
||||
} |
} |
||||
return writer.addTag(); |
}); |
||||
|
|
||||
|
const old = original.common; |
||||
|
writer |
||||
|
.setFrame('TPE1', old?.artists || info.artists || []) |
||||
|
.setFrame('TIT2', old?.title || info.title) |
||||
|
.setFrame('TALB', old?.album || info.album || ''); |
||||
|
if (info.picture) { |
||||
|
writer.setFrame('APIC', { |
||||
|
type: 3, |
||||
|
data: info.picture, |
||||
|
description: info.picture_desc || 'Cover', |
||||
|
}); |
||||
|
} |
||||
|
return writer.addTag(); |
||||
} |
} |
||||
|
|
||||
export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { |
export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { |
||||
const writer = new MetaFlac(audioData) |
const writer = new MetaFlac(audioData); |
||||
const old = original.common |
const old = original.common; |
||||
if (!old.title && !old.album && old.artists) { |
if (!old.title && !old.album && old.artists) { |
||||
writer.setTag("TITLE=" + info.title) |
writer.setTag('TITLE=' + info.title); |
||||
writer.setTag("ALBUM=" + info.album) |
writer.setTag('ALBUM=' + info.album); |
||||
if (info.artists) { |
if (info.artists) { |
||||
writer.removeTag("ARTIST") |
writer.removeTag('ARTIST'); |
||||
info.artists.forEach(artist => writer.setTag("ARTIST=" + artist)) |
info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist)); |
||||
} |
|
||||
} |
} |
||||
|
} |
||||
|
|
||||
if (info.picture) { |
if (info.picture) { |
||||
writer.importPictureFromBuffer(Buffer.from(info.picture)) |
writer.importPictureFromBuffer(Buffer.from(info.picture)); |
||||
} |
} |
||||
return writer.save() |
return writer.save(); |
||||
} |
} |
||||
|
|
||||
export function SplitFilename(n: string): { name: string; ext: string } { |
export function SplitFilename(n: string): { name: string; ext: string } { |
||||
const pos = n.lastIndexOf(".") |
const pos = n.lastIndexOf('.'); |
||||
return { |
return { |
||||
ext: n.substring(pos + 1).toLowerCase(), |
ext: n.substring(pos + 1).toLowerCase(), |
||||
name: n.substring(0, pos) |
name: n.substring(0, pos), |
||||
} |
}; |
||||
} |
} |
||||
|
@ -1,5 +1,2 @@ |
|||||
const bs = chrome || browser |
const bs = chrome || browser; |
||||
bs.tabs.create({ |
bs.tabs.create({ url: bs.runtime.getURL('./index.html') }, (tab) => console.log(tab)); |
||||
url: bs.runtime.getURL('./index.html') |
|
||||
}, tab => console.log(tab)) |
|
||||
|
|
||||
|
@ -1,31 +1,30 @@ |
|||||
/* eslint-disable no-console */ |
/* eslint-disable no-console */ |
||||
|
|
||||
import {register} from 'register-service-worker' |
import { register } from 'register-service-worker'; |
||||
|
|
||||
if (process.env.NODE_ENV === 'production' && window.location.protocol === "https:") { |
if (process.env.NODE_ENV === 'production' && window.location.protocol === 'https:') { |
||||
|
register(`${process.env.BASE_URL}service-worker.js`, { |
||||
register(`${process.env.BASE_URL}service-worker.js`, { |
ready() { |
||||
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.') |
}, |
||||
}, |
registered() { |
||||
registered() { |
console.log('Service worker has been registered.'); |
||||
console.log('Service worker has been registered.') |
}, |
||||
}, |
cached() { |
||||
cached() { |
console.log('Content has been cached for offline use.'); |
||||
console.log('Content has been cached for offline use.') |
}, |
||||
}, |
updatefound() { |
||||
updatefound() { |
console.log('New content is downloading.'); |
||||
console.log('New content is downloading.') |
}, |
||||
}, |
updated() { |
||||
updated() { |
console.log('New content is available.'); |
||||
console.log('New content is available.'); |
window.location.reload(); |
||||
window.location.reload(); |
}, |
||||
}, |
offline() { |
||||
offline() { |
console.log('No internet connection found. App is running in offline mode.'); |
||||
console.log('No internet connection found. App is running in offline mode.') |
}, |
||||
}, |
error(error) { |
||||
error(error) { |
console.error('Error during service worker registration:', error); |
||||
console.error('Error during service worker registration:', error) |
}, |
||||
} |
}); |
||||
}) |
|
||||
} |
} |
||||
|
@ -1,17 +1,15 @@ |
|||||
import Vue, {VNode} from 'vue' |
import Vue, { VNode } from 'vue'; |
||||
|
|
||||
declare global { |
declare global { |
||||
namespace JSX { |
namespace JSX { |
||||
// tslint:disable no-empty-interface
|
// tslint:disable no-empty-interface
|
||||
interface Element extends VNode { |
interface Element extends VNode {} |
||||
} |
|
||||
|
|
||||
// tslint:disable no-empty-interface
|
// tslint:disable no-empty-interface
|
||||
interface ElementClass extends Vue { |
interface ElementClass extends Vue {} |
||||
} |
|
||||
|
|
||||
interface IntrinsicElements { |
interface IntrinsicElements { |
||||
[elem: string]: any |
[elem: string]: any; |
||||
} |
} |
||||
} |
} |
||||
} |
} |
||||
|
@ -1,4 +1,4 @@ |
|||||
declare module '*.vue' { |
declare module '*.vue' { |
||||
import Vue from 'vue' |
import Vue from 'vue'; |
||||
export default Vue |
export default Vue; |
||||
} |
} |
||||
|
@ -1,56 +1,73 @@ |
|||||
import {fromByteArray as Base64Encode} from "base64-js"; |
import { fromByteArray as Base64Encode } from 'base64-js'; |
||||
|
|
||||
export const IXAREA_API_ENDPOINT = "https://um-api.ixarea.com" |
export const IXAREA_API_ENDPOINT = 'https://um-api.ixarea.com'; |
||||
|
|
||||
export interface UpdateInfo { |
export interface UpdateInfo { |
||||
Found: boolean |
Found: boolean; |
||||
HttpsFound: boolean |
HttpsFound: boolean; |
||||
Version: string |
Version: string; |
||||
URL: string |
URL: string; |
||||
Detail: string |
Detail: string; |
||||
} |
} |
||||
|
|
||||
export async function checkUpdate(version: string): Promise<UpdateInfo> { |
export async function checkUpdate(version: string): Promise<UpdateInfo> { |
||||
const resp = await fetch(IXAREA_API_ENDPOINT + "/music/app-version", { |
const resp = await fetch(IXAREA_API_ENDPOINT + '/music/app-version', { |
||||
method: "POST", |
method: 'POST', |
||||
headers: {"Content-Type": "application/json"}, |
headers: { 'Content-Type': 'application/json' }, |
||||
body: JSON.stringify({"Version": version}) |
body: JSON.stringify({ Version: version }), |
||||
}); |
}); |
||||
return await resp.json(); |
return await resp.json(); |
||||
} |
} |
||||
|
|
||||
export function reportKeyUsage(keyData: Uint8Array, maskData: number[], filename: string, format: string, title: string, artist?: string, album?: string) { |
export function reportKeyUsage( |
||||
return fetch(IXAREA_API_ENDPOINT + "/qmcmask/usage", { |
keyData: Uint8Array, |
||||
method: "POST", |
maskData: number[], |
||||
headers: {"Content-Type": "application/json"}, |
filename: string, |
||||
body: JSON.stringify({ |
format: string, |
||||
Mask: Base64Encode(new Uint8Array(maskData)), Key: Base64Encode(keyData), |
title: string, |
||||
Artist: artist, Title: title, Album: album, Filename: filename, Format: format |
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 { |
interface KeyInfo { |
||||
Matrix44: string |
Matrix44: string; |
||||
} |
} |
||||
|
|
||||
export async function queryKeyInfo(keyData: Uint8Array, filename: string, format: string): Promise<KeyInfo> { |
export async function queryKeyInfo(keyData: Uint8Array, filename: string, format: string): Promise<KeyInfo> { |
||||
const resp = await fetch(IXAREA_API_ENDPOINT + "/qmcmask/query", { |
const resp = await fetch(IXAREA_API_ENDPOINT + '/qmcmask/query', { |
||||
method: "POST", |
method: 'POST', |
||||
headers: {"Content-Type": "application/json"}, |
headers: { 'Content-Type': 'application/json' }, |
||||
body: JSON.stringify({Format: format, Key: Base64Encode(keyData), Filename: filename, Type: 44}), |
body: JSON.stringify({ Format: format, Key: Base64Encode(keyData), Filename: filename, Type: 44 }), |
||||
}); |
}); |
||||
return await resp.json(); |
return await resp.json(); |
||||
} |
} |
||||
|
|
||||
export interface CoverInfo { |
export interface CoverInfo { |
||||
Id: string |
Id: string; |
||||
Type: number |
Type: number; |
||||
} |
} |
||||
|
|
||||
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> { |
export async function queryAlbumCover(title: string, artist?: string, album?: string): Promise<CoverInfo> { |
||||
const endpoint = IXAREA_API_ENDPOINT + "/music/qq-cover" |
const endpoint = IXAREA_API_ENDPOINT + '/music/qq-cover'; |
||||
const params = new URLSearchParams([["Title", title], ["Artist", artist ?? ""], ["Album", album ?? ""]]) |
const params = new URLSearchParams([ |
||||
const resp = await fetch(`${endpoint}?${params.toString()}`) |
['Title', title], |
||||
return await resp.json() |
['Artist', artist ?? ''], |
||||
|
['Album', album ?? ''], |
||||
|
]); |
||||
|
const resp = await fetch(`${endpoint}?${params.toString()}`); |
||||
|
return await resp.json(); |
||||
} |
} |
||||
|
@ -1,79 +1,80 @@ |
|||||
import {DecryptResult} from "@/decrypt/entity"; |
import { DecryptResult } from '@/decrypt/entity'; |
||||
import {FileSystemDirectoryHandle} from "@/shims-fs"; |
import { FileSystemDirectoryHandle } from '@/shims-fs'; |
||||
|
|
||||
export enum FilenamePolicy { |
export enum FilenamePolicy { |
||||
ArtistAndTitle, |
ArtistAndTitle, |
||||
TitleOnly, |
TitleOnly, |
||||
TitleAndArtist, |
TitleAndArtist, |
||||
SameAsOriginal, |
SameAsOriginal, |
||||
} |
} |
||||
|
|
||||
export const FilenamePolicies: { key: FilenamePolicy, text: string }[] = [ |
export const FilenamePolicies: { key: FilenamePolicy; text: string }[] = [ |
||||
{key: FilenamePolicy.ArtistAndTitle, text: "歌手-歌曲名"}, |
{ key: FilenamePolicy.ArtistAndTitle, text: '歌手-歌曲名' }, |
||||
{key: FilenamePolicy.TitleOnly, text: "歌曲名"}, |
{ key: FilenamePolicy.TitleOnly, text: '歌曲名' }, |
||||
{key: FilenamePolicy.TitleAndArtist, text: "歌曲名-歌手"}, |
{ key: FilenamePolicy.TitleAndArtist, text: '歌曲名-歌手' }, |
||||
{key: FilenamePolicy.SameAsOriginal, text: "同源文件名"}, |
{ key: FilenamePolicy.SameAsOriginal, text: '同源文件名' }, |
||||
] |
]; |
||||
|
|
||||
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string { |
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string { |
||||
switch (policy) { |
switch (policy) { |
||||
case FilenamePolicy.TitleOnly: |
case FilenamePolicy.TitleOnly: |
||||
return `${data.title}.${data.ext}`; |
return `${data.title}.${data.ext}`; |
||||
case FilenamePolicy.TitleAndArtist: |
case FilenamePolicy.TitleAndArtist: |
||||
return `${data.title} - ${data.artist}.${data.ext}`; |
return `${data.title} - ${data.artist}.${data.ext}`; |
||||
case FilenamePolicy.SameAsOriginal: |
case FilenamePolicy.SameAsOriginal: |
||||
return `${data.rawFilename}.${data.ext}`; |
return `${data.rawFilename}.${data.ext}`; |
||||
default: |
default: |
||||
case FilenamePolicy.ArtistAndTitle: |
case FilenamePolicy.ArtistAndTitle: |
||||
return `${data.artist} - ${data.title}.${data.ext}`; |
return `${data.artist} - ${data.title}.${data.ext}`; |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) { |
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) { |
||||
let filename = GetDownloadFilename(data, policy) |
let filename = GetDownloadFilename(data, policy); |
||||
// prevent filename exist
|
// prevent filename exist
|
||||
try { |
try { |
||||
await dir.getFileHandle(filename) |
await dir.getFileHandle(filename); |
||||
filename = `${new Date().getTime()} - ${filename}` |
filename = `${new Date().getTime()} - ${filename}`; |
||||
} catch (e) { |
} catch (e) {} |
||||
} |
const file = await dir.getFileHandle(filename, { create: true }); |
||||
const file = await dir.getFileHandle(filename, {create: true}) |
const w = await file.createWritable(); |
||||
const w = await file.createWritable() |
await w.write(data.blob); |
||||
await w.write(data.blob) |
await w.close(); |
||||
await w.close() |
|
||||
|
|
||||
} |
} |
||||
|
|
||||
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) { |
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) { |
||||
const a = document.createElement('a'); |
const a = document.createElement('a'); |
||||
a.href = data.file; |
a.href = data.file; |
||||
a.download = GetDownloadFilename(data, policy) |
a.download = GetDownloadFilename(data, policy); |
||||
document.body.append(a); |
document.body.append(a); |
||||
a.click(); |
a.click(); |
||||
a.remove(); |
a.remove(); |
||||
} |
} |
||||
|
|
||||
export function RemoveBlobMusic(data: DecryptResult) { |
export function RemoveBlobMusic(data: DecryptResult) { |
||||
URL.revokeObjectURL(data.file); |
URL.revokeObjectURL(data.file); |
||||
if (data.picture?.startsWith("blob:")) { |
if (data.picture?.startsWith('blob:')) { |
||||
URL.revokeObjectURL(data.picture); |
URL.revokeObjectURL(data.picture); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
export class DecryptQueue { |
export class DecryptQueue { |
||||
private readonly pending: (() => Promise<void>)[]; |
private readonly pending: (() => Promise<void>)[]; |
||||
|
|
||||
constructor() { |
constructor() { |
||||
this.pending = [] |
this.pending = []; |
||||
} |
} |
||||
|
|
||||
queue(fn: () => Promise<void>) { |
queue(fn: () => Promise<void>) { |
||||
this.pending.push(fn) |
this.pending.push(fn); |
||||
this.consume() |
this.consume(); |
||||
} |
} |
||||
|
|
||||
private consume() { |
private consume() { |
||||
const fn = this.pending.shift() |
const fn = this.pending.shift(); |
||||
if (fn) fn().then(() => this.consume).catch(console.error) |
if (fn) |
||||
} |
fn() |
||||
|
.then(() => this.consume) |
||||
|
.catch(console.error); |
||||
|
} |
||||
} |
} |
||||
|
@ -1,4 +1,4 @@ |
|||||
import {expose} from "threads/worker"; |
import { expose } from 'threads/worker'; |
||||
import {CommonDecrypt} from "@/decrypt/common"; |
import { CommonDecrypt } from '@/decrypt/common'; |
||||
|
|
||||
expose(CommonDecrypt) |
expose(CommonDecrypt); |
||||
|
Loading…
Reference in new issue