mirror of
				https://git.unlock-music.dev/um/web.git
				synced 2025-10-31 12:23:29 +08:00 
			
		
		
		
	Add Tag Edit Function & Wasm for Qmc & Kgm
This commit is contained in:
		
							parent
							
								
									97cd7afc44
								
							
						
					
					
						commit
						de14ccb0b3
					
				| @ -1,8 +1,8 @@ | ||||
| { | ||||
|   "name": "unlock-music", | ||||
|   "version": "v1.10.0", | ||||
|   "version": "v1.10.3", | ||||
|   "ext_build": 0, | ||||
|   "updateInfo": "重写QMC解锁,完全支持.mflac*/.mgg*; 支持JOOX解锁", | ||||
|   "updateInfo": "完善音乐标签编辑功能,支持编辑更多标签", | ||||
|   "license": "MIT", | ||||
|   "description": "Unlock encrypted music file in browser.", | ||||
|   "repository": { | ||||
| @ -22,7 +22,6 @@ | ||||
|   "dependencies": { | ||||
|     "@babel/preset-typescript": "^7.16.5", | ||||
|     "@jixun/kugou-crypto": "^1.0.3", | ||||
|     "@jixun/qmc2-crypto": "^0.0.6-R1", | ||||
|     "@unlock-music/joox-crypto": "^0.0.1-R5", | ||||
|     "base64-js": "^1.5.1", | ||||
|     "browser-id3-writer": "^4.4.0", | ||||
|  | ||||
							
								
								
									
										34
									
								
								src/KgmWasm/KgmLegacy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/KgmWasm/KgmLegacy.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										21
									
								
								src/KgmWasm/KgmWasm.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/KgmWasm/KgmWasm.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/KgmWasm/KgmWasm.wasm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/KgmWasm/KgmWasm.wasm
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										21
									
								
								src/KgmWasm/KgmWasmBundle.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/KgmWasm/KgmWasmBundle.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										34
									
								
								src/QmcWasm/QmcLegacy.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										34
									
								
								src/QmcWasm/QmcLegacy.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										21
									
								
								src/QmcWasm/QmcWasm.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/QmcWasm/QmcWasm.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										
											BIN
										
									
								
								src/QmcWasm/QmcWasm.wasm
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								src/QmcWasm/QmcWasm.wasm
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							
							
								
								
									
										21
									
								
								src/QmcWasm/QmcWasmBundle.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/QmcWasm/QmcWasmBundle.js
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										178
									
								
								src/component/EditDialog.vue
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										178
									
								
								src/component/EditDialog.vue
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,178 @@ | ||||
| <style scoped> | ||||
| label { | ||||
|   cursor: pointer; | ||||
|   line-height: 1.2; | ||||
|   display: block; | ||||
| } | ||||
| .item-desc { | ||||
|   color: #aaa; | ||||
|   font-size: small; | ||||
|   display: block; | ||||
|   line-height: 1.2; | ||||
|   margin-top: 0.2em; | ||||
| } | ||||
| .item-desc a { | ||||
|   color: #aaa; | ||||
| } | ||||
| 
 | ||||
| form >>> input { | ||||
|   font-family: 'Courier New', Courier, monospace; | ||||
| } | ||||
| 
 | ||||
| * >>> .um-edit-dialog { | ||||
|   max-width: 90%; | ||||
|   width: 30em; | ||||
| } | ||||
| </style> | ||||
| 
 | ||||
| <template> | ||||
|   <el-dialog @close="cancel()" title="音乐标签编辑" :visible="show" custom-class="um-edit-dialog" center> | ||||
|     <el-form ref="form" status-icon :model="form" label-width="0"> | ||||
|       <section> | ||||
|         <el-image v-show="!editPicture" :src="imgFile.url || picture" style="width: 100px; height: 100px"> | ||||
|           <div slot="error" class="image-slot el-image__error">暂无封面</div> | ||||
|         </el-image> | ||||
|         <el-upload v-show="editPicture" :auto-upload="false" :on-change="addFile" :on-remove="rmvFile" :show-file-list="true" :limit="1" list-type="picture" action="" drag> | ||||
|           <i class="el-icon-upload" /> | ||||
|           <div class="el-upload__text">将新图片拖到此处,或<em>点击选择</em><br />以替换自动匹配的图片</div> | ||||
|           <div slot="tip" class="el-upload__tip"> | ||||
|             新拖到此处的图片将覆盖原始图片 | ||||
|           </div> | ||||
|         </el-upload>  | ||||
|         <i | ||||
|           :class="{'el-icon-edit': !editPicture, 'el-icon-check': editPicture}" | ||||
|           @click="changeCover" | ||||
|         ></i><br /> | ||||
|         标题:  | ||||
|         <span v-show="!editTitle">{{title}}</span> | ||||
|         <el-input v-show="editTitle" v-model="title"></el-input> | ||||
|         <i | ||||
|           :class="{'el-icon-edit': !editTitle, 'el-icon-check': editTitle}" | ||||
|           @click="editTitle = !editTitle" | ||||
|         ></i><br /> | ||||
|         艺术家:  | ||||
|         <span v-show="!editArtist">{{artist}}</span> | ||||
|         <el-input v-show="editArtist" v-model="artist"></el-input> | ||||
|         <i | ||||
|           :class="{'el-icon-edit': !editArtist, 'el-icon-check': editArtist}" | ||||
|           @click="editArtist = !editArtist" | ||||
|         ></i><br /> | ||||
|         专辑:  | ||||
|         <span v-show="!editAlbum">{{album}}</span> | ||||
|         <el-input v-show="editAlbum" v-model="album"></el-input> | ||||
|         <i | ||||
|           :class="{'el-icon-edit': !editAlbum, 'el-icon-check': editAlbum}" | ||||
|           @click="editAlbum = !editAlbum" | ||||
|         ></i><br /> | ||||
|         专辑艺术家:  | ||||
|         <span v-show="!editAlbumartist">{{albumartist}}</span> | ||||
|         <el-input v-show="editAlbumartist" v-model="albumartist"></el-input> | ||||
|         <i | ||||
|           :class="{'el-icon-edit': !editAlbumartist, 'el-icon-check': editAlbumartist}" | ||||
|           @click="editAlbumartist = !editAlbumartist" | ||||
|         ></i><br /> | ||||
|         风格:  | ||||
|         <span v-show="!editGenre">{{genre}}</span> | ||||
|         <el-input v-show="editGenre" v-model="genre"></el-input> | ||||
|         <i | ||||
|           :class="{'el-icon-edit': !editGenre, 'el-icon-check': editGenre}" | ||||
|           @click="editGenre = !editGenre" | ||||
|         ></i><br /> | ||||
| 
 | ||||
|         <p class="item-desc"> | ||||
|           为了节省您设备的资源,请在确定前充分检查,避免反复修改。<br /> | ||||
|           直接关闭此对话框不会保留所作的更改。 | ||||
|         </p> | ||||
|       </section> | ||||
|     </el-form> | ||||
|     <span slot="footer" class="dialog-footer"> | ||||
|       <el-button type="primary" @click="emitConfirm()">确 定</el-button> | ||||
|     </span> | ||||
|   </el-dialog> | ||||
| </template> | ||||
| 
 | ||||
| <script> | ||||
| import Ruby from './Ruby'; | ||||
| 
 | ||||
| export default { | ||||
|   components: { | ||||
|     Ruby, | ||||
|   }, | ||||
|   props: { | ||||
|     show: { type: Boolean, required: true }, | ||||
|     picture: { type: String | undefined, required: true }, | ||||
|     title: { type: String | undefined, required: true }, | ||||
|     artist: { type: String | undefined, required: true }, | ||||
|     album: { type: String | undefined, required: true }, | ||||
|     albumartist: { type: String | undefined, required: true }, | ||||
|     genre: { type: String | undefined, required: true }, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       form: { | ||||
|       }, | ||||
|       imgFile: { tmpblob: undefined, blob: undefined, url: undefined }, | ||||
|       editPicture: false, | ||||
|       editTitle: false, | ||||
|       editArtist: false, | ||||
|       editAlbum: false, | ||||
|       editAlbumartist: false, | ||||
|       editGenre: false, | ||||
|     }; | ||||
|   }, | ||||
|   async mounted() { | ||||
|     this.refreshForm(); | ||||
|   }, | ||||
|   methods: { | ||||
|     addFile(file) { | ||||
|       this.imgFile.tmpblob = file.raw; | ||||
|     }, | ||||
|     rmvFile() { | ||||
|       this.imgFile.tmpblob = undefined; | ||||
|     }, | ||||
|     changeCover() { | ||||
|       this.editPicture = !this.editPicture; | ||||
|       if (!this.editPicture && this.imgFile.tmpblob) { | ||||
|         this.imgFile.blob = this.imgFile.tmpblob; | ||||
|         if (this.imgFile.url) { | ||||
|           URL.revokeObjectURL(this.imgFile.url); | ||||
|         } | ||||
|         this.imgFile.url = URL.createObjectURL(this.imgFile.blob); | ||||
|       } | ||||
|     }, | ||||
| 
 | ||||
|     async refreshForm() { | ||||
|       if (this.imgFile.url) { | ||||
|         URL.revokeObjectURL(this.imgFile.url); | ||||
|       } | ||||
|       this.imgFile = { tmpblob: undefined, blob: undefined, url: undefined }; | ||||
|       this.editPicture = false; | ||||
|       this.editTitle = false; | ||||
|       this.editArtist = false; | ||||
|       this.editAlbum = false; | ||||
|       this.editAlbumartist = false; | ||||
|       this.editGenre = false; | ||||
|     }, | ||||
|     async cancel() { | ||||
|       this.refreshForm(); | ||||
|       this.$emit('cancel'); | ||||
|     }, | ||||
|     async emitConfirm() { | ||||
|       if (this.editPicture) { | ||||
|         this.changeCover(); | ||||
|       } | ||||
|       if (this.imgFile.url) { | ||||
|         URL.revokeObjectURL(this.imgFile.url); | ||||
|       } | ||||
|       this.$emit('ok', { | ||||
|         picture: this.imgFile.blob, | ||||
|         title: this.title, | ||||
|         artist: this.artist, | ||||
|         album: this.album, | ||||
|         albumartist: this.albumartist, | ||||
|         genre: this.genre, | ||||
|       }); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
| @ -27,6 +27,7 @@ | ||||
|         <el-button circle icon="el-icon-video-play" type="success" @click="handlePlay(scope.$index, scope.row)"> | ||||
|         </el-button> | ||||
|         <el-button circle icon="el-icon-download" @click="handleDownload(scope.row)"></el-button> | ||||
|         <el-button circle icon="el-icon-edit" @click="handleEdit(scope.row)"></el-button> | ||||
|         <el-button circle icon="el-icon-delete" type="danger" @click="handleDelete(scope.$index, scope.row)"> | ||||
|         </el-button> | ||||
|       </template> | ||||
| @ -55,6 +56,9 @@ export default { | ||||
|     handleDownload(row) { | ||||
|       this.$emit('download', row); | ||||
|     }, | ||||
|     handleEdit(row) { | ||||
|       this.$emit('edit', row); | ||||
|     }, | ||||
|   }, | ||||
| }; | ||||
| </script> | ||||
|  | ||||
| @ -8,6 +8,7 @@ import { | ||||
| } from '@/decrypt/utils'; | ||||
| import { parseBlob as metaParseBlob } from 'music-metadata-browser'; | ||||
| import { DecryptResult } from '@/decrypt/entity'; | ||||
| import { DecryptKgmWasm } from '@/decrypt/kgm_wasm'; | ||||
| import { decryptKgmByteAtOffsetV2, decryptVprByteAtOffset } from '@jixun/kugou-crypto/dist/utils/decryptionHelper'; | ||||
| 
 | ||||
| //prettier-ignore
 | ||||
| @ -22,31 +23,48 @@ const KgmHeader = [ | ||||
| ] | ||||
| 
 | ||||
| export async function Decrypt(file: File, raw_filename: string, raw_ext: string): Promise<DecryptResult> { | ||||
|   const oriData = new Uint8Array(await GetArrayBuffer(file)); | ||||
|   const oriData = await GetArrayBuffer(file); | ||||
|   if (raw_ext === 'vpr') { | ||||
|     if (!BytesHasPrefix(oriData, VprHeader)) throw Error('Not a valid vpr file!'); | ||||
|     if (!BytesHasPrefix(new Uint8Array(oriData), VprHeader)) throw Error('Not a valid vpr file!'); | ||||
|   } else { | ||||
|     if (!BytesHasPrefix(oriData, KgmHeader)) throw Error('Not a valid kgm(a) file!'); | ||||
|     if (!BytesHasPrefix(new Uint8Array(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 musicDecoded: Uint8Array | undefined; | ||||
|   if (globalThis.WebAssembly) { | ||||
|     console.log('kgm: using wasm decoder'); | ||||
| 
 | ||||
|   let audioData = oriData.slice(headerLen); | ||||
|   let dataLen = audioData.length; | ||||
| 
 | ||||
|   let key1 = Array.from(oriData.slice(0x1c, 0x2c)); | ||||
|   key1.push(0); | ||||
| 
 | ||||
|   const decryptByte = raw_ext === 'vpr' ? decryptVprByteAtOffset : decryptKgmByteAtOffsetV2; | ||||
|   for (let i = 0; i < dataLen; i++) { | ||||
|     audioData[i] = decryptByte(audioData[i], key1, i); | ||||
|     const kgmDecrypted = await DecryptKgmWasm(oriData, raw_ext); | ||||
|     // 若 v2 检测失败,降级到 v1 再尝试一次
 | ||||
|     if (kgmDecrypted.success) { | ||||
|       musicDecoded = kgmDecrypted.data; | ||||
|       console.log('kgm wasm decoder suceeded'); | ||||
|     } else { | ||||
|       console.warn('KgmWasm failed with error %s', kgmDecrypted.error || '(no error)'); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const ext = SniffAudioExt(audioData); | ||||
|   if (!musicDecoded) { | ||||
|     musicDecoded = new Uint8Array(oriData); | ||||
|     let bHeaderLen = new DataView(musicDecoded.slice(0x10, 0x14).buffer); | ||||
|     let headerLen = bHeaderLen.getUint32(0, true); | ||||
| 
 | ||||
|     let key1 = Array.from(musicDecoded.slice(0x1c, 0x2c)); | ||||
|     key1.push(0); | ||||
| 
 | ||||
|     musicDecoded = musicDecoded.slice(headerLen); | ||||
|     let dataLen = musicDecoded.length; | ||||
| 
 | ||||
|     const decryptByte = raw_ext === 'vpr' ? decryptVprByteAtOffset : decryptKgmByteAtOffsetV2; | ||||
|     for (let i = 0; i < dataLen; i++) { | ||||
|       musicDecoded[i] = decryptByte(musicDecoded[i], key1, i); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   const ext = SniffAudioExt(musicDecoded); | ||||
|   const mime = AudioMimeType[ext]; | ||||
|   let musicBlob = new Blob([audioData], { type: mime }); | ||||
|   let musicBlob = new Blob([musicDecoded], { type: mime }); | ||||
|   const musicMeta = await metaParseBlob(musicBlob); | ||||
|   const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist); | ||||
|   const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artists == undefined ? musicMeta.common.artist : musicMeta.common.artists.toString()); | ||||
|   return { | ||||
|     album: musicMeta.common.album, | ||||
|     picture: GetCoverFromFile(musicMeta), | ||||
|  | ||||
							
								
								
									
										67
									
								
								src/decrypt/kgm_wasm.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								src/decrypt/kgm_wasm.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,67 @@ | ||||
| import KgmCryptoModule from '@/KgmWasm/KgmWasmBundle'; | ||||
| import { MergeUint8Array } from '@/utils/MergeUint8Array'; | ||||
| 
 | ||||
| // 每次处理 2M 的数据
 | ||||
| const DECRYPTION_BUF_SIZE = 2 *1024 * 1024; | ||||
| 
 | ||||
| export interface KGMDecryptionResult { | ||||
|   success: boolean; | ||||
|   data: Uint8Array; | ||||
|   error: string; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 解密一个 KGM 加密的文件。 | ||||
|  * | ||||
|  * 如果检测并解密成功,返回解密后的 Uint8Array 数据。 | ||||
|  * @param  {ArrayBuffer} kgmBlob 读入的文件 Blob | ||||
|  */ | ||||
| export async function DecryptKgmWasm(kgmBlob: ArrayBuffer, ext: string): Promise<KGMDecryptionResult> { | ||||
|   const result: KGMDecryptionResult = { success: false, data: new Uint8Array(), error: '' }; | ||||
| 
 | ||||
|   // 初始化模组
 | ||||
|   let KgmCrypto: any; | ||||
| 
 | ||||
|   try { | ||||
|     KgmCrypto = await KgmCryptoModule(); | ||||
|   } catch (err: any) { | ||||
|     result.error = err?.message || 'wasm 加载失败'; | ||||
|     return result; | ||||
|   } | ||||
|   if (!KgmCrypto) { | ||||
|     result.error = 'wasm 加载失败'; | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|   // 申请内存块,并文件末端数据到 WASM 的内存堆
 | ||||
|   let kgmBuf = new Uint8Array(kgmBlob); | ||||
|   const pQmcBuf = KgmCrypto._malloc(DECRYPTION_BUF_SIZE); | ||||
|   KgmCrypto.writeArrayToMemory(kgmBuf.slice(0, DECRYPTION_BUF_SIZE), pQmcBuf); | ||||
| 
 | ||||
|   // 进行解密初始化
 | ||||
|   const headerSize = KgmCrypto.preDec(pQmcBuf, DECRYPTION_BUF_SIZE, ext); | ||||
|   console.log(headerSize); | ||||
|   kgmBuf = kgmBuf.slice(headerSize); | ||||
| 
 | ||||
|   const decryptedParts = []; | ||||
|   let offset = 0; | ||||
|   let bytesToDecrypt = kgmBuf.length; | ||||
|   while (bytesToDecrypt > 0) { | ||||
|     const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE); | ||||
| 
 | ||||
|     // 解密一些片段
 | ||||
|     const blockData = new Uint8Array(kgmBuf.slice(offset, offset + blockSize)); | ||||
|     KgmCrypto.writeArrayToMemory(blockData, pQmcBuf); | ||||
|     KgmCrypto.decBlob(pQmcBuf, blockSize, offset); | ||||
|     decryptedParts.push(KgmCrypto.HEAPU8.slice(pQmcBuf, pQmcBuf + blockSize)); | ||||
| 
 | ||||
|     offset += blockSize; | ||||
|     bytesToDecrypt -= blockSize; | ||||
|   } | ||||
|   KgmCrypto._free(pQmcBuf); | ||||
| 
 | ||||
|   result.data = MergeUint8Array(decryptedParts); | ||||
|   result.success = true; | ||||
| 
 | ||||
|   return result; | ||||
| } | ||||
| @ -38,7 +38,7 @@ export async function Decrypt(file: File, raw_filename: string, _: string): Prom | ||||
|   let musicBlob = new Blob([audioData], { type: mime }); | ||||
| 
 | ||||
|   const musicMeta = await metaParseBlob(musicBlob); | ||||
|   const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artist); | ||||
|   const { title, artist } = GetMetaFromFile(raw_filename, musicMeta.common.title, musicMeta.common.artists == undefined ? musicMeta.common.artist : musicMeta.common.artists.toString()); | ||||
|   return { | ||||
|     album: musicMeta.common.album, | ||||
|     picture: GetCoverFromFile(musicMeta), | ||||
|  | ||||
| @ -13,7 +13,7 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string) | ||||
|   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); | ||||
|   const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artists == undefined ? tag.common.artist : tag.common.artists.toString()); | ||||
| 
 | ||||
|   return { | ||||
|     title, | ||||
|  | ||||
| @ -3,7 +3,7 @@ import { AudioMimeType, GetArrayBuffer, SniffAudioExt } from '@/decrypt/utils'; | ||||
| 
 | ||||
| import { DecryptResult } from '@/decrypt/entity'; | ||||
| import { QmcDeriveKey } from '@/decrypt/qmc_key'; | ||||
| import { DecryptQMCWasm } from '@/decrypt/qmc_wasm'; | ||||
| import { DecryptQmcWasm } from '@/decrypt/qmc_wasm'; | ||||
| import { extractQQMusicMeta } from '@/utils/qm_meta'; | ||||
| 
 | ||||
| interface Handler { | ||||
| @ -24,9 +24,9 @@ export const HandlerMap: { [key: string]: Handler } = { | ||||
|   qmcflac: { ext: 'flac', version: 2 }, | ||||
|   qmcogg: { ext: 'ogg', version: 2 }, | ||||
| 
 | ||||
|   qmc0: { ext: 'mp3', version: 1 }, | ||||
|   qmc2: { ext: 'ogg', version: 1 }, | ||||
|   qmc3: { ext: 'mp3', version: 1 }, | ||||
|   qmc0: { ext: 'mp3', version: 2 }, | ||||
|   qmc2: { ext: 'ogg', version: 2 }, | ||||
|   qmc3: { ext: 'mp3', version: 2 }, | ||||
|   bkcmp3: { ext: 'mp3', version: 1 }, | ||||
|   bkcflac: { ext: 'flac', version: 1 }, | ||||
|   tkm: { ext: 'm4a', version: 1 }, | ||||
| @ -49,13 +49,14 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string) | ||||
|   if (version === 2 && globalThis.WebAssembly) { | ||||
|     console.log('qmc: using wasm decoder'); | ||||
| 
 | ||||
|     const v2Decrypted = await DecryptQMCWasm(fileBuffer); | ||||
|     const v2Decrypted = await DecryptQmcWasm(fileBuffer, raw_ext); | ||||
|     // 若 v2 检测失败,降级到 v1 再尝试一次
 | ||||
|     if (v2Decrypted.success) { | ||||
|       musicDecoded = v2Decrypted.data; | ||||
|       musicID = v2Decrypted.songId; | ||||
|       console.log('qmc wasm decoder suceeded'); | ||||
|     } else { | ||||
|       console.warn('qmc2-wasm failed with error %s', v2Decrypted.error || '(no error)'); | ||||
|       console.warn('QmcWasm failed with error %s', v2Decrypted.error || '(no error)'); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
| @ -151,7 +152,7 @@ export class QmcDecoder { | ||||
|     } else { | ||||
|       const sizeView = new DataView(last4Byte.buffer, last4Byte.byteOffset); | ||||
|       const keySize = sizeView.getUint32(0, true); | ||||
|       if (keySize < 0x300) { | ||||
|       if (keySize < 0x400) { | ||||
|         this.audioSize = this.size - keySize - 4; | ||||
|         const rawKey = this.file.subarray(this.audioSize, this.size - 4); | ||||
|         this.setCipher(rawKey); | ||||
|  | ||||
| @ -5,12 +5,14 @@ const ZERO_LEN = 7; | ||||
| 
 | ||||
| export function QmcDeriveKey(raw: Uint8Array): Uint8Array { | ||||
|   const textDec = new TextDecoder(); | ||||
|   const rawDec = Buffer.from(textDec.decode(raw), 'base64'); | ||||
|   let rawDec = Buffer.from(textDec.decode(raw), 'base64'); | ||||
|   let n = rawDec.length; | ||||
|   if (n < 16) { | ||||
|     throw Error('key length is too short'); | ||||
|   } | ||||
| 
 | ||||
|   rawDec = decryptV2Key(rawDec); | ||||
| 
 | ||||
|   const simpleKey = simpleMakeKey(106, 8); | ||||
|   let teaKey = new Uint8Array(16); | ||||
|   for (let i = 0; i < 8; i++) { | ||||
| @ -32,6 +34,30 @@ export function simpleMakeKey(salt: number, length: number): number[] { | ||||
|   return keyBuf; | ||||
| } | ||||
| 
 | ||||
| const mixKey1: Uint8Array = new Uint8Array([ 0x33, 0x38, 0x36, 0x5A, 0x4A, 0x59, 0x21, 0x40, 0x23, 0x2A, 0x24, 0x25, 0x5E, 0x26, 0x29, 0x28 ]) | ||||
| const mixKey2: Uint8Array = new Uint8Array([ 0x2A, 0x2A, 0x23, 0x21, 0x28, 0x23, 0x24, 0x25, 0x26, 0x5E, 0x61, 0x31, 0x63, 0x5A, 0x2C, 0x54 ]) | ||||
| 
 | ||||
| const v2KeyPrefix: Uint8Array = new Uint8Array([ 0x51, 0x51, 0x4D, 0x75, 0x73, 0x69, 0x63, 0x20, 0x45, 0x6E, 0x63, 0x56, 0x32, 0x2C, 0x4B, 0x65, 0x79, 0x3A ]) | ||||
| 
 | ||||
| function decryptV2Key(key: Buffer): Buffer | ||||
| { | ||||
|   const textEnc = new TextDecoder(); | ||||
|   if (key.length < 18 || textEnc.decode(key.slice(0, 18)) !== 'QQMusic EncV2,Key:') { | ||||
|     return key; | ||||
|   } | ||||
| 
 | ||||
|   let out = decryptTencentTea(key.slice(18), mixKey1); | ||||
|   out = decryptTencentTea(out, mixKey2); | ||||
|   const textDec = new TextDecoder(); | ||||
|   const keyDec = Buffer.from(textDec.decode(out), 'base64'); | ||||
|   let n = keyDec.length; | ||||
|   if (n < 16) { | ||||
|     throw Error('EncV2 key decode failed'); | ||||
|   } | ||||
| 
 | ||||
|   return keyDec; | ||||
| } | ||||
| 
 | ||||
| function decryptTencentTea(inBuf: Uint8Array, key: Uint8Array): Uint8Array { | ||||
|   if (inBuf.length % 8 != 0) { | ||||
|     throw Error('inBuf size not a multiple of the block size'); | ||||
|  | ||||
| @ -1,14 +1,10 @@ | ||||
| import QMCCryptoModule from '@jixun/qmc2-crypto/QMC2-wasm-bundle'; | ||||
| import QmcCryptoModule from '@/QmcWasm/QmcWasmBundle'; | ||||
| import { MergeUint8Array } from '@/utils/MergeUint8Array'; | ||||
| import { QMCCrypto } from '@jixun/qmc2-crypto/QMCCrypto'; | ||||
| 
 | ||||
| // 检测文件末端使用的缓冲区大小
 | ||||
| const DETECTION_SIZE = 40; | ||||
| 
 | ||||
| // 每次处理 2M 的数据
 | ||||
| const DECRYPTION_BUF_SIZE = 2 * 1024 * 1024; | ||||
| const DECRYPTION_BUF_SIZE = 2 *1024 * 1024; | ||||
| 
 | ||||
| export interface QMC2DecryptionResult { | ||||
| export interface QMCDecryptionResult { | ||||
|   success: boolean; | ||||
|   data: Uint8Array; | ||||
|   songId: string | number; | ||||
| @ -16,96 +12,62 @@ export interface QMC2DecryptionResult { | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * 解密一个 QMC2 加密的文件。 | ||||
|  * 解密一个 QMC 加密的文件。 | ||||
|  * | ||||
|  * 如果检测并解密成功,返回解密后的 Uint8Array 数据。 | ||||
|  * @param  {ArrayBuffer} mggBlob 读入的文件 Blob | ||||
|  * @param  {ArrayBuffer} qmcBlob 读入的文件 Blob | ||||
|  */ | ||||
| export async function DecryptQMCWasm(mggBlob: ArrayBuffer): Promise<QMC2DecryptionResult> { | ||||
|   const result: QMC2DecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' }; | ||||
| export async function DecryptQmcWasm(qmcBlob: ArrayBuffer, ext: string): Promise<QMCDecryptionResult> { | ||||
|   const result: QMCDecryptionResult = { success: false, data: new Uint8Array(), songId: 0, error: '' }; | ||||
| 
 | ||||
|   // 初始化模组
 | ||||
|   let QMCCrypto: QMCCrypto; | ||||
|   let QmcCrypto: any; | ||||
| 
 | ||||
|   try { | ||||
|     QMCCrypto = await QMCCryptoModule(); | ||||
|     QmcCrypto = await QmcCryptoModule(); | ||||
|   } catch (err: any) { | ||||
|     result.error = err?.message || 'wasm 加载失败'; | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|   // 申请内存块,并文件末端数据到 WASM 的内存堆
 | ||||
|   const detectionBuf = new Uint8Array(mggBlob.slice(-DETECTION_SIZE)); | ||||
|   const pDetectionBuf = QMCCrypto._malloc(detectionBuf.length); | ||||
|   QMCCrypto.writeArrayToMemory(detectionBuf, pDetectionBuf); | ||||
| 
 | ||||
|   // 检测结果内存块
 | ||||
|   const pDetectionResult = QMCCrypto._malloc(QMCCrypto.sizeof_qmc_detection()); | ||||
| 
 | ||||
|   // 进行检测
 | ||||
|   const detectOK = QMCCrypto.detectKeyEndPosition(pDetectionResult, pDetectionBuf, detectionBuf.length); | ||||
| 
 | ||||
|   // 提取结构体内容:
 | ||||
|   // (pos: i32; len: i32; error: char[??])
 | ||||
|   const position = QMCCrypto.getValue(pDetectionResult, 'i32'); | ||||
|   const len = QMCCrypto.getValue(pDetectionResult + 4, 'i32'); | ||||
| 
 | ||||
|   result.success = detectOK; | ||||
|   result.error = QMCCrypto.UTF8ToString( | ||||
|     pDetectionResult + QMCCrypto.offsetof_error_msg(), | ||||
|     QMCCrypto.sizeof_error_msg(), | ||||
|   ); | ||||
|   const songId = QMCCrypto.UTF8ToString(pDetectionResult + QMCCrypto.offsetof_song_id(), QMCCrypto.sizeof_song_id()); | ||||
|   if (!songId) { | ||||
|     console.debug('qmc2-wasm: songId not found'); | ||||
|   } else if (/^\d+$/.test(songId)) { | ||||
|     result.songId = songId; | ||||
|   } else { | ||||
|     console.warn('qmc2-wasm: Invalid songId: %s', songId); | ||||
|   } | ||||
| 
 | ||||
|   // 释放内存
 | ||||
|   QMCCrypto._free(pDetectionBuf); | ||||
|   QMCCrypto._free(pDetectionResult); | ||||
| 
 | ||||
|   if (!detectOK) { | ||||
|   if (!QmcCrypto) { | ||||
|     result.error = 'wasm 加载失败'; | ||||
|     return result; | ||||
|   } | ||||
| 
 | ||||
|   // 计算解密后文件的大小。
 | ||||
|   // 之前得到的 position 为相对当前检测数据起点的偏移。
 | ||||
|   const decryptedSize = mggBlob.byteLength - DETECTION_SIZE + position; | ||||
|   // 申请内存块,并文件末端数据到 WASM 的内存堆
 | ||||
|   const qmcBuf = new Uint8Array(qmcBlob); | ||||
|   const pQmcBuf = QmcCrypto._malloc(DECRYPTION_BUF_SIZE); | ||||
|   QmcCrypto.writeArrayToMemory(qmcBuf.slice(-DECRYPTION_BUF_SIZE), pQmcBuf); | ||||
| 
 | ||||
|   // 提取嵌入到文件的 EKey
 | ||||
|   const ekey = new Uint8Array(mggBlob.slice(decryptedSize, decryptedSize + len)); | ||||
| 
 | ||||
|   // 解码 UTF-8 数据到 string
 | ||||
|   const decoder = new TextDecoder(); | ||||
|   const ekey_b64 = decoder.decode(ekey); | ||||
| 
 | ||||
|   // 初始化加密与缓冲区
 | ||||
|   const hCrypto = QMCCrypto.createInstWidthEKey(ekey_b64); | ||||
|   const buf = QMCCrypto._malloc(DECRYPTION_BUF_SIZE); | ||||
|   // 进行解密初始化
 | ||||
|   ext = '.' + ext; | ||||
|   const tailSize = QmcCrypto.preDec(pQmcBuf, DECRYPTION_BUF_SIZE, ext); | ||||
|   if (tailSize == -1) { | ||||
|     result.error = QmcCrypto.getError(); | ||||
|     return result; | ||||
|   } else { | ||||
|     result.songId = QmcCrypto.getSongId(); | ||||
|     result.songId = result.songId == "0" ? 0 : result.songId; | ||||
|   } | ||||
| 
 | ||||
|   const decryptedParts = []; | ||||
|   let offset = 0; | ||||
|   let bytesToDecrypt = decryptedSize; | ||||
|   let bytesToDecrypt = qmcBuf.length - tailSize; | ||||
|   while (bytesToDecrypt > 0) { | ||||
|     const blockSize = Math.min(bytesToDecrypt, DECRYPTION_BUF_SIZE); | ||||
| 
 | ||||
|     // 解密一些片段
 | ||||
|     const blockData = new Uint8Array(mggBlob.slice(offset, offset + blockSize)); | ||||
|     QMCCrypto.writeArrayToMemory(blockData, buf); | ||||
|     QMCCrypto.decryptStream(hCrypto, buf, offset, blockSize); | ||||
|     decryptedParts.push(QMCCrypto.HEAPU8.slice(buf, buf + blockSize)); | ||||
|     const blockData = new Uint8Array(qmcBuf.slice(offset, offset + blockSize)); | ||||
|     QmcCrypto.writeArrayToMemory(blockData, pQmcBuf); | ||||
|     decryptedParts.push(QmcCrypto.HEAPU8.slice(pQmcBuf, pQmcBuf + QmcCrypto.decBlob(pQmcBuf, blockSize, offset))); | ||||
| 
 | ||||
|     offset += blockSize; | ||||
|     bytesToDecrypt -= blockSize; | ||||
|   } | ||||
|   QMCCrypto._free(buf); | ||||
|   hCrypto.delete(); | ||||
|   QmcCrypto._free(pQmcBuf); | ||||
| 
 | ||||
|   result.data = MergeUint8Array(decryptedParts); | ||||
|   result.success = true; | ||||
| 
 | ||||
|   return result; | ||||
| } | ||||
|  | ||||
| @ -8,34 +8,53 @@ import { | ||||
| } from '@/decrypt/utils'; | ||||
| 
 | ||||
| import { Decrypt as QmcDecrypt, HandlerMap } from '@/decrypt/qmc'; | ||||
| import { DecryptQmcWasm } from '@/decrypt/qmc_wasm'; | ||||
| 
 | ||||
| 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; | ||||
| export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string): Promise<DecryptResult> { | ||||
|   const buffer = await GetArrayBuffer(file); | ||||
| 
 | ||||
|   let musicDecoded: Uint8Array | undefined; | ||||
|   if (globalThis.WebAssembly) { | ||||
|     console.log('qmc: using wasm decoder'); | ||||
| 
 | ||||
|     const qmcDecrypted = await DecryptQmcWasm(buffer, raw_ext); | ||||
|     // 若 qmc 检测失败,降级到 v1 再尝试一次
 | ||||
|     if (qmcDecrypted.success) { | ||||
|       musicDecoded = qmcDecrypted.data; | ||||
|       console.log('qmc wasm decoder suceeded'); | ||||
|     } else { | ||||
|       console.warn('QmcWasm failed with error %s', qmcDecrypted.error || '(no error)'); | ||||
|     } | ||||
|   } | ||||
|   let ext = SniffAudioExt(buffer, ''); | ||||
| 
 | ||||
|   if (!musicDecoded) { | ||||
|     musicDecoded = new Uint8Array(buffer); | ||||
|     let length = musicDecoded.length; | ||||
|     for (let i = 0; i < length; i++) { | ||||
|       musicDecoded[i] ^= 0xf4; | ||||
|       if (musicDecoded[i] <= 0x3f) musicDecoded[i] = musicDecoded[i] * 4; | ||||
|       else if (musicDecoded[i] <= 0x7f) musicDecoded[i] = (musicDecoded[i] - 0x40) * 4 + 1; | ||||
|       else if (musicDecoded[i] <= 0xbf) musicDecoded[i] = (musicDecoded[i] - 0x80) * 4 + 2; | ||||
|       else musicDecoded[i] = (musicDecoded[i] - 0xc0) * 4 + 3; | ||||
|     } | ||||
|   } | ||||
|   let ext = SniffAudioExt(musicDecoded, ''); | ||||
|   const newName = SplitFilename(raw_filename); | ||||
|   let audioBlob: Blob; | ||||
|   if (ext !== '' || newName.ext === 'mp3') { | ||||
|     audioBlob = new Blob([buffer], { type: AudioMimeType[ext] }); | ||||
|     audioBlob = new Blob([musicDecoded], { type: AudioMimeType[ext] }); | ||||
|   } else if (newName.ext in HandlerMap) { | ||||
|     audioBlob = new Blob([buffer], { type: 'application/octet-stream' }); | ||||
|     audioBlob = new Blob([musicDecoded], { 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); | ||||
|   const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artists == undefined ? tag.common.artist : tag.common.artists.toString()); | ||||
| 
 | ||||
|   return { | ||||
|     title, | ||||
|  | ||||
| @ -17,7 +17,7 @@ export async function Decrypt( | ||||
|     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); | ||||
|   const { title, artist } = GetMetaFromFile(raw_filename, tag.common.title, tag.common.artists == undefined ? tag.common.artist : tag.common.artists.toString()); | ||||
| 
 | ||||
|   return { | ||||
|     title, | ||||
|  | ||||
| @ -2,6 +2,8 @@ import { IAudioMetadata } from 'music-metadata-browser'; | ||||
| import ID3Writer from 'browser-id3-writer'; | ||||
| import MetaFlac from 'metaflac-js'; | ||||
| 
 | ||||
| export const split_regex = /[ ]?[,;/_、][ ]?/; | ||||
| 
 | ||||
| export const FLAC_HEADER = [0x66, 0x4c, 0x61, 0x43]; | ||||
| export const MP3_HEADER = [0x49, 0x44, 0x33]; | ||||
| export const OGG_HEADER = [0x4f, 0x67, 0x67, 0x53]; | ||||
| @ -91,7 +93,7 @@ export function GetMetaFromFile( | ||||
| 
 | ||||
|   const items = filename.split(separator); | ||||
|   if (items.length > 1) { | ||||
|     if (!meta.artist) meta.artist = items[0].trim(); | ||||
|     if (!meta.artist || meta.artist.split(split_regex).length < items[0].trim().split(split_regex).length) meta.artist = items[0].trim(); | ||||
|     if (!meta.title) meta.title = items[1].trim(); | ||||
|   } else if (items.length === 1) { | ||||
|     if (!meta.title) meta.title = items[0].trim(); | ||||
| @ -119,6 +121,8 @@ export interface IMusicMeta { | ||||
|   title: string; | ||||
|   artists?: string[]; | ||||
|   album?: string; | ||||
|   albumartist?: string; | ||||
|   genre?: string[]; | ||||
|   picture?: ArrayBuffer; | ||||
|   picture_desc?: string; | ||||
| } | ||||
| @ -169,6 +173,83 @@ export function WriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: I | ||||
|   return writer.save(); | ||||
| } | ||||
| 
 | ||||
| export function RewriteMetaToMp3(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { | ||||
|   const writer = new ID3Writer(audioData); | ||||
| 
 | ||||
|   // reserve original data
 | ||||
|   const frames = original.native['ID3v2.4'] || original.native['ID3v2.3'] || original.native['ID3v2.2'] || []; | ||||
|   frames.forEach((frame) => { | ||||
|     if (frame.id !== 'TPE1' | ||||
|       && frame.id !== 'TIT2' | ||||
|       && frame.id !== 'TALB' | ||||
|       && frame.id !== 'TPE2' | ||||
|       && frame.id !== 'TCON' | ||||
|     ) { | ||||
|       try { | ||||
|         writer.setFrame(frame.id, frame.value); | ||||
|       } catch (e) { | ||||
|         throw new Error('write unknown mp3 frame failed'); | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| 
 | ||||
|   const old = original.common; | ||||
|   writer | ||||
|     .setFrame('TPE1', info?.artists || old.artists || []) | ||||
|     .setFrame('TIT2', info?.title || old.title) | ||||
|     .setFrame('TALB', info?.album || old.album || '') | ||||
|     .setFrame('TPE2', info?.albumartist || old.albumartist || '') | ||||
|     .setFrame('TCON', info?.genre || old.genre || []); | ||||
|   if (info.picture) { | ||||
|     writer.setFrame('APIC', { | ||||
|       type: 3, | ||||
|       data: info.picture, | ||||
|       description: info.picture_desc || '', | ||||
|     }); | ||||
|   } | ||||
|   return writer.addTag(); | ||||
| } | ||||
| 
 | ||||
| export function RewriteMetaToFlac(audioData: Buffer, info: IMusicMeta, original: IAudioMetadata) { | ||||
|   const writer = new MetaFlac(audioData); | ||||
|   const old = original.common; | ||||
|   if (info.title) { | ||||
|     if (old.title) { | ||||
|       writer.removeTag('TITLE'); | ||||
|     } | ||||
|     writer.setTag('TITLE=' + info.title); | ||||
|   } | ||||
|   if (info.album) { | ||||
|     if (old.album) { | ||||
|       writer.removeTag('ALBUM'); | ||||
|     } | ||||
|     writer.setTag('ALBUM=' + info.album); | ||||
|   } | ||||
|   if (info.albumartist) { | ||||
|     if (old.albumartist) { | ||||
|       writer.removeTag('ALBUMARTIST'); | ||||
|     } | ||||
|     writer.setTag('ALBUMARTIST=' + info.albumartist); | ||||
|   } | ||||
|   if (info.artists) { | ||||
|     if (old.artists) { | ||||
|       writer.removeTag('ARTIST'); | ||||
|     } | ||||
|     info.artists.forEach((artist) => writer.setTag('ARTIST=' + artist)); | ||||
|   } | ||||
|   if (info.genre) { | ||||
|     if (old.genre) { | ||||
|       writer.removeTag('GENRE'); | ||||
|     } | ||||
|     info.genre.forEach((singlegenre) => writer.setTag('GENRE=' + singlegenre)); | ||||
|   } | ||||
| 
 | ||||
|   if (info.picture) { | ||||
|     writer.importPictureFromBuffer(Buffer.from(info.picture)); | ||||
|   } | ||||
|   return writer.save(); | ||||
| } | ||||
| 
 | ||||
| export function SplitFilename(n: string): { name: string; ext: string } { | ||||
|   const pos = n.lastIndexOf('.'); | ||||
|   return { | ||||
|  | ||||
| @ -49,7 +49,7 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string) | ||||
|   const { title, artist } = GetMetaFromFile( | ||||
|     raw_filename, | ||||
|     musicMeta.common.title, | ||||
|     musicMeta.common.artist, | ||||
|     musicMeta.common.artists == undefined ? musicMeta.common.artist : musicMeta.common.artists.toString(), | ||||
|     raw_filename.indexOf('_') === -1 ? '-' : '_', | ||||
|   ); | ||||
| 
 | ||||
|  | ||||
| @ -8,6 +8,7 @@ import { | ||||
|   WriteMetaToFlac, | ||||
|   WriteMetaToMp3, | ||||
|   AudioMimeType, | ||||
|   split_regex, | ||||
| } from '@/decrypt/utils'; | ||||
| import { getQMImageURLFromPMID, queryAlbumCover, querySongInfoById } from '@/utils/api'; | ||||
| 
 | ||||
| @ -38,13 +39,20 @@ export async function extractQQMusicMeta( | ||||
|     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.artist = ''; | ||||
|       if (musicMeta.common.artists == undefined) { | ||||
|         musicMeta.common.artist = iconv.decode(new Buffer(musicMeta.common.artist ?? ''), 'gbk'); | ||||
|       } | ||||
|       else { | ||||
|         musicMeta.common.artists.forEach((artist) => artist = iconv.decode(new Buffer(artist ?? ''), 'gbk')); | ||||
|         musicMeta.common.artist = musicMeta.common.artists.toString(); | ||||
|       } | ||||
|       musicMeta.common.title = iconv.decode(new Buffer(musicMeta.common.title ?? ''), 'gbk'); | ||||
|       musicMeta.common.album = iconv.decode(new Buffer(musicMeta.common.album ?? ''), 'gbk'); | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   if (id) { | ||||
|   if (id && id !== '0') { | ||||
|     try { | ||||
|       return fetchMetadataFromSongId(id, ext, musicMeta, musicBlob); | ||||
|     } catch (e) { | ||||
| @ -67,7 +75,7 @@ export async function extractQQMusicMeta( | ||||
|     imgUrl: imageURL, | ||||
|     blob: await writeMetaToAudioFile({ | ||||
|       title: info.title, | ||||
|       artists: info.artist.split(' _ '), | ||||
|       artists: info.artist.split(split_regex), | ||||
|       ext, | ||||
|       imageURL, | ||||
|       musicMeta, | ||||
| @ -88,7 +96,7 @@ async function fetchMetadataFromSongId( | ||||
| 
 | ||||
|   return { | ||||
|     title: info.track_info.title, | ||||
|     artist: artists.join('、'), | ||||
|     artist: artists.join(','), | ||||
|     album: info.track_info.album.name, | ||||
|     imgUrl: imageURL, | ||||
| 
 | ||||
|  | ||||
| @ -10,6 +10,15 @@ | ||||
|         </el-radio> | ||||
|       </el-row> | ||||
|       <el-row> | ||||
|         <edit-dialog | ||||
|           :show="showEditDialog" | ||||
|           :picture="editing_data.picture" | ||||
|           :title="editing_data.title" | ||||
|           :artist="editing_data.artist" | ||||
|           :album="editing_data.album" | ||||
|           :albumartist="editing_data.albumartist" | ||||
|           :genre="editing_data.genre" | ||||
|           @cancel="showEditDialog = false" @ok="handleEdit"></edit-dialog> | ||||
|         <config-dialog :show="showConfigDialog" @done="showConfigDialog = false"></config-dialog> | ||||
|         <el-tooltip class="item" effect="dark" placement="top"> | ||||
|           <div slot="content"> | ||||
| @ -35,7 +44,7 @@ | ||||
| 
 | ||||
|     <audio :autoplay="playing_auto" :src="playing_url" controls /> | ||||
| 
 | ||||
|     <PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @play="changePlaying" /> | ||||
|     <PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @edit="editFile" @play="changePlaying" /> | ||||
|   </div> | ||||
| </template> | ||||
| 
 | ||||
| @ -43,8 +52,11 @@ | ||||
| import FileSelector from '@/component/FileSelector'; | ||||
| import PreviewTable from '@/component/PreviewTable'; | ||||
| import ConfigDialog from '@/component/ConfigDialog'; | ||||
| import EditDialog from '@/component/EditDialog'; | ||||
| 
 | ||||
| import { DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile } from '@/utils/utils'; | ||||
| import { GetImageFromURL, RewriteMetaToMp3, RewriteMetaToFlac, AudioMimeType, split_regex } from '@/decrypt/utils'; | ||||
| import { parseBlob as metaParseBlob } from 'music-metadata-browser'; | ||||
| 
 | ||||
| export default { | ||||
|   name: 'Home', | ||||
| @ -52,10 +64,13 @@ export default { | ||||
|     FileSelector, | ||||
|     PreviewTable, | ||||
|     ConfigDialog, | ||||
|     EditDialog, | ||||
|   }, | ||||
|   data() { | ||||
|     return { | ||||
|       showConfigDialog: false, | ||||
|       showEditDialog: false, | ||||
|       editing_data: { picture: '', title: '', artist: '', album: '', albumartist: '', genre: '', }, | ||||
|       tableData: [], | ||||
|       playing_url: '', | ||||
|       playing_auto: false, | ||||
| @ -128,7 +143,56 @@ export default { | ||||
|         } | ||||
|       }, 300); | ||||
|     }, | ||||
|     async handleEdit(data) { | ||||
|       this.showEditDialog = false; | ||||
|       URL.revokeObjectURL(this.editing_data.file); | ||||
|       if (data.picture) { | ||||
|         URL.revokeObjectURL(this.editing_data.picture); | ||||
|         this.editing_data.picture = URL.createObjectURL(data.picture); | ||||
|       } | ||||
|       this.editing_data.title = data.title; | ||||
|       this.editing_data.artist = data.artist; | ||||
|       this.editing_data.album = data.album; | ||||
|       try { | ||||
|         const musicMeta = await metaParseBlob(new Blob([this.editing_data.blob], { type: mime })); | ||||
|         const imageInfo = await GetImageFromURL(this.editing_data.picture); | ||||
|         if (!imageInfo) { | ||||
|           console.warn('获取图像失败', this.editing_data.picture); | ||||
|         } | ||||
|         const newMeta = { picture: imageInfo?.buffer, | ||||
|           title: data.title, | ||||
|           artists: data.artist.split(split_regex), | ||||
|           album: data.album, | ||||
|           albumartist: data.albumartist, | ||||
|           genre: data.genre.split(split_regex) | ||||
|         }; | ||||
|         const buffer = Buffer.from(await this.editing_data.blob.arrayBuffer()); | ||||
|         const mime = AudioMimeType[this.editing_data.ext] || AudioMimeType.mp3; | ||||
|         if (this.editing_data.ext === 'mp3') { | ||||
|           this.editing_data.blob = new Blob([RewriteMetaToMp3(buffer, newMeta, musicMeta)], { type: mime }); | ||||
|         } else if (this.editing_data.ext === 'flac') { | ||||
|           this.editing_data.blob = new Blob([RewriteMetaToFlac(buffer, newMeta, 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); | ||||
|       } | ||||
|       this.editing_data.file = URL.createObjectURL(this.editing_data.blob);/**/ | ||||
|       this.$notify.success({ | ||||
|         title: '修改成功', | ||||
|         message: '成功修改 ' + this.editing_data.title, | ||||
|         duration: 3000, | ||||
|       }); | ||||
|     }, | ||||
| 
 | ||||
|     async editFile(data) { | ||||
|       this.editing_data = data; | ||||
|       const musicMeta = await metaParseBlob(this.editing_data.blob); | ||||
|       this.editing_data.albumartist = musicMeta.common.albumartist || ''; | ||||
|       this.editing_data.genre = musicMeta.common.genre?.toString() || ''; | ||||
|       this.showEditDialog = true; | ||||
|     }, | ||||
|     async saveFile(data) { | ||||
|       if (this.dir) { | ||||
|         await DirectlyWriteFile(data, this.filename_policy, this.dir); | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user
	 xhacker-zzz
						xhacker-zzz