15 Commits

Author SHA1 Message Date
Emmm Monster
d3898161b9 chore: bump version & update deps 2021-08-08 08:08:56 +08:00
MengYX
5a7a9e3add change ixarea api endpoint 2021-08-08 07:56:28 +08:00
MengYX
652bb1fc32 optimize: imports 2021-08-08 07:51:42 +08:00
MengYX
6737e8c11b optimize: .kgm mask loading 2021-08-08 07:47:28 +08:00
sunhao03
71862538b7 fetch mask file fix on production 2021-08-07 22:13:00 +08:00
Emmm Monster
4251b94b1f chore: update deps & fix audit 2021-07-01 01:33:28 +08:00
Emmm Monster
8fdda048f6 fix: avoid using worker in file protocol 2021-07-01 01:29:04 +08:00
Emmm Monster
39c7294996 chore(ci): build after *.ts changes 2021-06-03 13:15:04 +08:00
Emmm Monster
48f879cb58 simplify: decrypt/ncm-cache & decrypt/common 2021-06-03 13:09:14 +08:00
Emmm Monster
f0875ad175 fix: decrypt/qmc-cache
adapt: decrypt/qmc for qmc-cache
2021-06-03 13:09:02 +08:00
qq1010903229
02a146e069 增加对网易云音乐.uc缓存格式和QQ音乐.cache缓存格式的支持 (#161)
* Update common.ts

* Create ncmcache.ts

* Create qmccache.ts
2021-06-03 13:00:35 +08:00
Emmm Monster
2e31853ffb chore: update deps 2021-05-27 19:53:19 +08:00
Emmm Monster
a7aaf246ae fix: .vpr/.kgm fail in worker 2021-05-27 19:38:42 +08:00
Emmm Monster
4bc0a10c09 feature(sniffer): support .dff 2021-05-25 12:27:19 +08:00
Emmm Monster
3645dd7d01 fix: remove test file 2021-05-25 04:36:32 +08:00
15 changed files with 2284 additions and 2102 deletions

View File

@@ -3,6 +3,7 @@ on:
push:
paths:
- "**/*.js"
- "**/*.ts"
- "**/*.vue"
- "public/**/*"
- "package-lock.json"
@@ -12,6 +13,7 @@ on:
types: [ opened, synchronize, reopened ]
paths:
- "**/*.js"
- "**/*.ts"
- "**/*.vue"
- "public/**/*"
- "package-lock.json"

4157
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{
"name": "unlock-music",
"version": "v1.9.0-beta",
"version": "v1.9.0",
"updateInfo": "新增写入本地文件系统; 优化.kwm解锁; 支持.acc嗅探; 使用Typescript重构",
"license": "MIT",
"description": "Unlock encrypted music file in browser.",
@@ -18,19 +18,19 @@
"dependencies": {
"base64-js": "^1.5.1",
"browser-id3-writer": "^4.4.0",
"core-js": "^3.12.1",
"crypto-js": "^4.0.0",
"element-ui": "^2.15.1",
"core-js": "^3.16.0",
"crypto-js": "^4.1.1",
"element-ui": "^2.15.5",
"iconv-lite": "^0.6.3",
"jimp": "^0.16.1",
"metaflac-js": "^1.0.5",
"music-metadata-browser": "^2.2.6",
"music-metadata-browser": "^2.4.3",
"register-service-worker": "^1.7.2",
"threads": "^1.6.4",
"vue": "^2.6.12"
"threads": "^1.6.5",
"vue": "^2.6.14"
},
"devDependencies": {
"@types/crypto-js": "^4.0.1",
"@types/crypto-js": "^4.0.2",
"@vue/cli-plugin-babel": "^4.5.13",
"@vue/cli-plugin-pwa": "^4.5.13",
"@vue/cli-plugin-typescript": "^4.5.13",
@@ -40,8 +40,8 @@
"sass-loader": "^10.2.0",
"semver": "^7.3.5",
"threads-plugin": "^1.4.0",
"typescript": "~4.1.5",
"typescript": "~4.1.6",
"vue-cli-plugin-element": "^1.0.1",
"vue-template-compiler": "^2.6.12"
"vue-template-compiler": "^2.6.14"
}
}

View File

@@ -63,7 +63,7 @@ export default {
}
},
mounted() {
if (window.Worker && process.env.NODE_ENV === 'production') {
if (window.Worker && window.location.protocol !== "file:" && process.env.NODE_ENV === 'production') {
console.log("Using Worker Pool")
this.queue = Pool(
() => spawn(new Worker('@/utils/worker.ts')),

View File

@@ -1,37 +1,42 @@
import {Decrypt as NcmDecrypt} from "@/decrypt/ncm";
import {Decrypt as NcmCacheDecrypt} from "@/decrypt/ncmcache";
import {Decrypt as XmDecrypt} from "@/decrypt/xm";
import {Decrypt as QmcDecrypt} from "@/decrypt/qmc";
import {Decrypt as QmcCacheDecrypt} from "@/decrypt/qmccache";
import {Decrypt as KgmDecrypt} from "@/decrypt/kgm";
import {Decrypt as KwmDecrypt} from "@/decrypt/kwm";
import {Decrypt as RawDecrypt} from "@/decrypt/raw";
import {Decrypt as TmDecrypt} from "@/decrypt/tm";
import {DecryptResult, FileInfo} from "@/decrypt/entity";
import {SplitFilename} from "@/decrypt/utils";
export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
let raw_ext = file.name.substring(file.name.lastIndexOf(".") + 1, file.name.length).toLowerCase();
let raw_filename = file.name.substring(0, file.name.lastIndexOf("."));
const raw = SplitFilename(file.name)
let rt_data: DecryptResult;
switch (raw_ext) {
switch (raw.ext) {
case "ncm":// Netease Mp3/Flac
rt_data = await NcmDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await NcmDecrypt(file.raw, raw.name, raw.ext);
break;
case "uc":// Netease Cache
rt_data = await NcmCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case "kwm":// Kuwo Mp3/Flac
rt_data = await KwmDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await KwmDecrypt(file.raw, raw.name, raw.ext);
break
case "xm": // Xiami Wav/M4a/Mp3/Flac
case "wav":// Xiami/Raw Wav
case "mp3":// Xiami/Raw Mp3
case "flac":// Xiami/Raw Flac
case "m4a":// Xiami/Raw M4a
rt_data = await XmDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await XmDecrypt(file.raw, raw.name, raw.ext);
break;
case "ogg":// Raw Ogg
rt_data = await RawDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await RawDecrypt(file.raw, raw.name, raw.ext);
break;
case "tm0":// QQ Music IOS Mp3
case "tm3":// QQ Music IOS Mp3
rt_data = await RawDecrypt(file.raw, raw_filename, "mp3");
rt_data = await RawDecrypt(file.raw, raw.name, "mp3");
break;
case "qmc3"://QQ Music Android Mp3
case "qmc2"://QQ Music Android Ogg
@@ -48,23 +53,26 @@ export async function CommonDecrypt(file: FileInfo): Promise<DecryptResult> {
case "6f6767"://QQ Music Weiyun Ogg
case "6d3461"://QQ Music Weiyun M4a
case "776176"://QQ Music Weiyun Wav
rt_data = await QmcDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await QmcDecrypt(file.raw, raw.name, raw.ext);
break;
case "tm2":// QQ Music IOS M4a
case "tm6":// QQ Music IOS M4a
rt_data = await TmDecrypt(file.raw, raw_filename);
rt_data = await TmDecrypt(file.raw, raw.name);
break;
case "cache"://QQ Music Cache
rt_data = await QmcCacheDecrypt(file.raw, raw.name, raw.ext);
break;
case "vpr":
case "kgm":
case "kgma":
rt_data = await KgmDecrypt(file.raw, raw_filename, raw_ext);
rt_data = await KgmDecrypt(file.raw, raw.name, raw.ext);
break
default:
throw "不支持此文件格式"
}
if (!rt_data.rawExt) rt_data.rawExt = raw_ext;
if (!rt_data.rawFilename) rt_data.rawFilename = raw_filename;
if (!rt_data.rawExt) rt_data.rawExt = raw.ext;
if (!rt_data.rawFilename) rt_data.rawFilename = raw.name;
console.log(rt_data);
return rt_data;
}

View File

@@ -8,6 +8,7 @@ import {
} from "@/decrypt/utils.ts";
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import {DecryptResult} from "@/decrypt/entity";
import config from "@/../package.json"
const VprHeader = [
0x05, 0x28, 0xBC, 0x96, 0xE9, 0xE4, 0x5A, 0x43,
@@ -22,9 +23,6 @@ const VprMaskDiff = [
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
if (window?.location?.protocol === "file:") {
throw Error("请使用 <a target='_blank' href='https://github.com/unlock-music/cli'>CLI版本</a> 进行解锁")
}
const oriData = new Uint8Array(await GetArrayBuffer(file));
if (raw_ext === "vpr") {
@@ -84,8 +82,16 @@ function GetMask(pos: number) {
let MaskV2: Uint8Array = new Uint8Array(0);
async function LoadMaskV2(): Promise<boolean> {
let mask_url = `https://cdn.jsdelivr.net/gh/unlock-music/unlock-music@${config.version}/public/static/kgm.mask`
if (["http:", "https:"].some(v => v == self.location.protocol)) {
if (!!self.document) {// using Web Worker
mask_url = "./static/kgm.mask"
} else {// using Main thread
mask_url = "../static/kgm.mask"
}
}
try {
const resp = await fetch("./static/kgm.mask", {method: "GET"})
const resp = await fetch(mask_url, {method: "GET"})
MaskV2 = new Uint8Array(await resp.arrayBuffer());
return true
} catch (e) {

View File

@@ -11,11 +11,18 @@ import {
import {parseBlob as metaParseBlob} from "music-metadata-browser";
import jimp from 'jimp';
import CryptoJS from "crypto-js";
import AES from "crypto-js/aes";
import PKCS7 from "crypto-js/pad-pkcs7";
import ModeECB from "crypto-js/mode-ecb";
import WordArray from "crypto-js/lib-typedarrays";
import Base64 from "crypto-js/enc-base64";
import EncUTF8 from "crypto-js/enc-utf8";
import EncHex from "crypto-js/enc-hex";
import {DecryptResult} from "@/decrypt/entity";
const CORE_KEY = CryptoJS.enc.Hex.parse("687a4852416d736f356b496e62617857");
const META_KEY = CryptoJS.enc.Hex.parse("2331346C6A6B5F215C5D2630553C2728");
const CORE_KEY = EncHex.parse("687a4852416d736f356b496e62617857");
const META_KEY = EncHex.parse("2331346C6A6B5F215C5D2630553C2728");
const MagicHeader = [0x43, 0x54, 0x45, 0x4E, 0x46, 0x44, 0x41, 0x4D];
@@ -67,11 +74,11 @@ class NcmDecrypt {
.map(uint8 => uint8 ^ 0x64);
this.offset += keyLen;
const plainText = CryptoJS.AES.decrypt(
const plainText = AES.decrypt(
// @ts-ignore
{ciphertext: CryptoJS.lib.WordArray.create(cipherText)},
{ciphertext: WordArray.create(cipherText)},
CORE_KEY,
{mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}
{mode: ModeECB, padding: PKCS7}
);
const result = new Uint8Array(plainText.sigBytes);
@@ -115,17 +122,18 @@ class NcmDecrypt {
.map(data => data ^ 0x63);
this.offset += metaDataLen;
const plainText = CryptoJS.AES.decrypt(
//@ts-ignore
WordArray.create()
const plainText = AES.decrypt(
// @ts-ignore
{
ciphertext: CryptoJS.enc.Base64.parse(
//@ts-ignore
CryptoJS.lib.WordArray.create(cipherText.slice(22)).toString(CryptoJS.enc.Utf8)
ciphertext: Base64.parse(
// @ts-ignore
WordArray.create(cipherText.slice(22)).toString(EncUTF8)
)
},
META_KEY,
{mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7}
).toString(CryptoJS.enc.Utf8);
{mode: ModeECB, padding: PKCS7}
).toString(EncUTF8);
const labelIndex = plainText.indexOf(":");
let result: NcmMusicMeta;

29
src/decrypt/ncmcache.ts Normal file
View File

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

View File

@@ -22,9 +22,11 @@ interface Handler {
handler(data?: Uint8Array): QmcMask | undefined
}
const HandlerMap: { [key: string]: Handler } = {
export const HandlerMap: { [key: string]: Handler } = {
"mgg": {handler: QmcMaskDetectMgg, ext: "ogg", detect: true},
"mflac": {handler: QmcMaskDetectMflac, ext: "flac", detect: true},
"mgg.cache": {handler: QmcMaskDetectMgg, ext: "ogg", detect: false},
"mflac.cache": {handler: QmcMaskDetectMflac, ext: "flac", detect: false},
"qmc0": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
"qmc2": {handler: QmcMaskGetDefault, ext: "ogg", detect: false},
"qmc3": {handler: QmcMaskGetDefault, ext: "mp3", detect: false},
@@ -40,8 +42,8 @@ const HandlerMap: { [key: string]: Handler } = {
"776176": {handler: QmcMaskGetDefault, ext: "wav", detect: false}
};
export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
if (!(raw_ext in HandlerMap)) throw "File type is incorrect!";
export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> {
if (!(raw_ext in HandlerMap)) throw `Qmc cannot handle type: ${raw_ext}`;
const handler = HandlerMap[raw_ext];
const fileData = new Uint8Array(await GetArrayBuffer(file));
@@ -57,6 +59,7 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string)
} else {
audioData = fileData;
seed = handler.handler(audioData) as QmcMask;
if (!seed) throw raw_ext + "格式仅提供实验性支持";
}
let musicDecoded = seed.Decrypt(audioData);

51
src/decrypt/qmccache.ts Normal file
View File

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

View File

@@ -12,13 +12,16 @@ export const WMA_HEADER = [
]
export const WAV_HEADER = [0x52, 0x49, 0x46, 0x46]
export const AAC_HEADER = [0xFF, 0xF1]
export const DFF_HEADER = [0x46, 0x52, 0x4D, 0x38]
export const AudioMimeType: { [key: string]: string } = {
mp3: "audio/mpeg",
flac: "audio/flac",
m4a: "audio/mp4",
ogg: "audio/ogg",
wma: "audio/x-ms-wma",
wav: "audio/x-wav"
wav: "audio/x-wav",
dff: "audio/x-dff"
};
@@ -39,6 +42,7 @@ export function SniffAudioExt(data: Uint8Array, fallback_ext: string = "mp3"): s
if (BytesHasPrefix(data, WAV_HEADER)) return "wav"
if (BytesHasPrefix(data, WMA_HEADER)) return "wma"
if (BytesHasPrefix(data, AAC_HEADER)) return "aac"
if (BytesHasPrefix(data, DFF_HEADER)) return "dff"
return fallback_ext;
}
@@ -156,3 +160,11 @@ export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: I
}
return writer.save()
}
export function SplitFilename(n: string): { name: string; ext: string } {
const pos = n.lastIndexOf(".")
return {
ext: n.substring(pos + 1).toLowerCase(),
name: n.substring(0, pos)
}
}

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

@@ -6,6 +6,10 @@ interface FileSystemCreateWritableOptions {
keepExistingData?: boolean
}
interface FileSystemRemoveOptions {
recursive?: boolean
}
interface FileSystemFileHandle {
getFile(): Promise<File>;
@@ -37,13 +41,16 @@ interface FileSystemWritableFileStream extends WritableStream {
close(): Promise<undefined> // should be implemented in WritableStream
}
export declare interface FileSystemDirectoryHandle {
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>
removeEntry(name: string, options?: FileSystemRemoveOptions): Promise<undefined>
}
declare global {
interface Window {
FileSystemDirectoryHandle
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>
}

View File

@@ -1,6 +1,6 @@
import {fromByteArray as Base64Encode} from "base64-js";
export const IXAREA_API_ENDPOINT = "https://stats.ixarea.com/apis"
export const IXAREA_API_ENDPOINT = "https://um-api.ixarea.com"
export interface UpdateInfo {
Found: boolean

View File

@@ -144,9 +144,9 @@ export default {
}
try {
this.dir = await window.showDirectoryPicker()
window.dir = this.dir
window.f = await this.dir.getFileHandle("write-test.txt", {create: true})
const test_filename = "__unlock_music_write_test.txt"
await this.dir.getFileHandle(test_filename, {create: true})
await this.dir.removeEntry(test_filename)
} catch (e) {
console.error(e)
}

View File

@@ -26,7 +26,8 @@
"dom",
"dom.iterable",
"scripthost"
]
],
"resolveJsonModule": true
},
"include": [
"src/**/*.ts",