Browse Source

feature: directly write to fs

20230320
Emmm Monster 4 years ago
parent
commit
21d5ae305c
No known key found for this signature in database GPG Key ID: C98279C83FB50DB9
  1. 26
      src/component/FileSelector.vue
  2. 4
      src/component/PreviewTable.vue
  3. 1
      src/decrypt/entity.ts
  4. 1
      src/decrypt/kgm.ts
  5. 1
      src/decrypt/kwm.ts
  6. 1
      src/decrypt/ncm.ts
  7. 1
      src/decrypt/qmc.ts
  8. 1
      src/decrypt/raw.ts
  9. 1
      src/decrypt/xm.ts
  10. 4
      src/main.ts
  11. 51
      src/shims-fs.d.ts
  12. 43
      src/utils/utils.ts
  13. 63
      src/view/Home.vue

26
src/component/FileSelector.vue

@ -8,7 +8,27 @@
multiple> multiple>
<i class="el-icon-upload"/> <i class="el-icon-upload"/>
<div class="el-upload__text">将文件拖到此处<em>点击选择</em></div> <div class="el-upload__text">将文件拖到此处<em>点击选择</em></div>
<div slot="tip" class="el-upload__tip">本工具仅在浏览器内对文件进行解锁无需消耗流量</div> <div slot="tip" class="el-upload__tip">
<div>
仅在浏览器内对文件进行解锁无需消耗流量
<el-tooltip effect="dark" placement="top-start">
<div slot="content">
算法在源代码中已经提供所有运算都发生在本地
</div>
<i class="el-icon-info" style="font-size: 12px"/>
</el-tooltip>
</div>
<div>
工作模式: {{ parallel ? "多线程 Worker" : "单线程 Queue" }}
<el-tooltip effect="dark" placement="top-start">
<div slot="content">
将此工具部署在HTTPS环境下可以启用Web Worker特性<br/>
从而更快的利用并行处理完成解锁
</div>
<i class="el-icon-info" style="font-size: 12px"/>
</el-tooltip>
</div>
</div>
<transition name="el-fade-in"><!--todo: add delay to animation--> <transition name="el-fade-in"><!--todo: add delay to animation-->
<el-progress <el-progress
v-show="progress_show" :format="progress_string" :percentage="progress_value" v-show="progress_show" :format="progress_string" :percentage="progress_value"
@ -30,7 +50,8 @@ export default {
return { return {
task_all: 0, task_all: 0,
task_finished: 0, task_finished: 0,
queue: new DecryptQueue() // for http or file protocol queue: new DecryptQueue(), // for http or file protocol
parallel: false
} }
}, },
computed: { computed: {
@ -48,6 +69,7 @@ export default {
() => spawn(new Worker('@/utils/worker.ts')), () => spawn(new Worker('@/utils/worker.ts')),
navigator.hardwareConcurrency || 1 navigator.hardwareConcurrency || 1
) )
this.parallel = true
} else { } else {
console.log("Using Queue in Main Thread") console.log("Using Queue in Main Thread")
} }

4
src/component/PreviewTable.vue

@ -42,7 +42,7 @@
</template> </template>
<script> <script>
import {DownloadBlobMusic, RemoveBlobMusic} from '@/utils/utils' import {RemoveBlobMusic} from '@/utils/utils'
export default { export default {
name: "PreviewTable", name: "PreviewTable",
@ -60,7 +60,7 @@ export default {
this.tableData.splice(index, 1); this.tableData.splice(index, 1);
}, },
handleDownload(row) { handleDownload(row) {
DownloadBlobMusic(row, this.policy) this.$emit("download", row)
}, },
} }
} }

1
src/decrypt/entity.ts

@ -7,6 +7,7 @@ export interface DecryptResult {
ext: string ext: string
file: string file: string
blob: Blob
picture?: string picture?: string
message?: string message?: string

1
src/decrypt/kgm.ts

@ -68,6 +68,7 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string)
album: musicMeta.common.album, album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta), picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob), file: URL.createObjectURL(musicBlob),
blob: musicBlob,
ext, ext,
mime, mime,
title, title,

1
src/decrypt/kwm.ts

@ -44,6 +44,7 @@ export async function Decrypt(file: File, raw_filename: string, _: string): Prom
album: musicMeta.common.album, album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta), picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob), file: URL.createObjectURL(musicBlob),
blob: musicBlob,
mime, mime,
title, title,
artist, artist,

1
src/decrypt/ncm.ts

@ -209,6 +209,7 @@ class NcmDecrypt {
album: this.newMeta.album, album: this.newMeta.album,
picture: this.image?.url, picture: this.image?.url,
file: URL.createObjectURL(this.blob), file: URL.createObjectURL(this.blob),
blob: this.blob as Blob,
mime: this.mime mime: this.mime
} }
} }

1
src/decrypt/qmc.ts

@ -111,6 +111,7 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string)
album: musicMeta.common.album, album: musicMeta.common.album,
picture: imgUrl, picture: imgUrl,
file: URL.createObjectURL(musicBlob), file: URL.createObjectURL(musicBlob),
blob: musicBlob,
mime: mime mime: mime
} }
} }

1
src/decrypt/raw.ts

@ -22,6 +22,7 @@ export async function Decrypt(file: Blob, raw_filename: string, raw_ext: string,
album: tag.common.album, album: tag.common.album,
picture: GetCoverFromFile(tag), picture: GetCoverFromFile(tag),
file: URL.createObjectURL(file), file: URL.createObjectURL(file),
blob: file,
mime: AudioMimeType[ext] mime: AudioMimeType[ext]
} }
} }

1
src/decrypt/xm.ts

@ -59,6 +59,7 @@ export async function Decrypt(file: File, raw_filename: string, raw_ext: string)
album: musicMeta.common.album, album: musicMeta.common.album,
picture: GetCoverFromFile(musicMeta), picture: GetCoverFromFile(musicMeta),
file: URL.createObjectURL(musicBlob), file: URL.createObjectURL(musicBlob),
blob: musicBlob,
rawExt: "xm" rawExt: "xm"
} }
} }

4
src/main.ts

@ -18,7 +18,8 @@ import {
Table, Table,
TableColumn, TableColumn,
Tooltip, Tooltip,
Upload Upload,
MessageBox
} from 'element-ui'; } from 'element-ui';
import 'element-ui/lib/theme-chalk/base.css'; import 'element-ui/lib/theme-chalk/base.css';
@ -39,6 +40,7 @@ Vue.use(Radio);
Vue.use(Tooltip); Vue.use(Tooltip);
Vue.use(Progress); Vue.use(Progress);
Vue.prototype.$notify = Notification; Vue.prototype.$notify = Notification;
Vue.prototype.$confirm = MessageBox.confirm;
Vue.config.productionTip = false; Vue.config.productionTip = false;
new Vue({ new Vue({

51
src/shims-fs.d.ts

@ -0,0 +1,51 @@
export interface FileSystemGetFileOptions {
create?: boolean
}
interface FileSystemCreateWritableOptions {
keepExistingData?: boolean
}
interface FileSystemFileHandle {
getFile(): Promise<File>;
createWritable(options?: FileSystemCreateWritableOptions): Promise<FileSystemWritableFileStream>
}
enum WriteCommandType {
write = "write",
seek = "seek",
truncate = "truncate",
}
interface WriteParams {
type: WriteCommandType
size?: number
position?: number
data: BufferSource | Blob | string
}
type FileSystemWriteChunkType = BufferSource | Blob | string | WriteParams
interface FileSystemWritableFileStream extends WritableStream {
write(data: FileSystemWriteChunkType): Promise<undefined>
seek(position: number): Promise<undefined>
truncate(size: number): Promise<undefined>
close(): Promise<undefined> // should be implemented in WritableStream
}
export declare interface FileSystemDirectoryHandle {
getFileHandle(name: string, options?: FileSystemGetFileOptions): Promise<FileSystemFileHandle>
}
declare global {
interface Window {
FileSystemDirectoryHandle
showDirectoryPicker?(): Promise<FileSystemDirectoryHandle>
}
}

43
src/utils/utils.ts

@ -1,4 +1,5 @@
import {DecryptResult} from "@/decrypt/entity"; import {DecryptResult} from "@/decrypt/entity";
import {FileSystemDirectoryHandle} from "@/shims-fs";
export enum FilenamePolicy { export enum FilenamePolicy {
ArtistAndTitle, ArtistAndTitle,
@ -14,25 +15,39 @@ export const FilenamePolicies: { key: FilenamePolicy, text: string }[] = [
{key: FilenamePolicy.SameAsOriginal, text: "同源文件名"}, {key: FilenamePolicy.SameAsOriginal, text: "同源文件名"},
] ]
export function GetDownloadFilename(data: DecryptResult, policy: FilenamePolicy): string {
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
const a = document.createElement('a');
a.href = data.file;
switch (policy) { switch (policy) {
default:
case FilenamePolicy.ArtistAndTitle:
a.download = data.artist + " - " + data.title + "." + data.ext;
break;
case FilenamePolicy.TitleOnly: case FilenamePolicy.TitleOnly:
a.download = data.title + "." + data.ext; return `${data.title}.${data.ext}`;
break;
case FilenamePolicy.TitleAndArtist: case FilenamePolicy.TitleAndArtist:
a.download = data.title + " - " + data.artist + "." + data.ext; return `${data.title} - ${data.artist}.${data.ext}`;
break;
case FilenamePolicy.SameAsOriginal: case FilenamePolicy.SameAsOriginal:
a.download = data.rawFilename + "." + data.ext; return `${data.rawFilename}.${data.ext}`;
break; default:
case FilenamePolicy.ArtistAndTitle:
return `${data.artist} - ${data.title}.${data.ext}`;
}
} }
export async function DirectlyWriteFile(data: DecryptResult, policy: FilenamePolicy, dir: FileSystemDirectoryHandle) {
let filename = GetDownloadFilename(data, policy)
// prevent filename exist
try {
await dir.getFileHandle(filename)
filename = `${new Date().getTime()} - ${filename}`
} catch (e) {
}
const file = await dir.getFileHandle(filename, {create: true})
const w = await file.createWritable()
await w.write(data.blob)
await w.close()
}
export function DownloadBlobMusic(data: DecryptResult, policy: FilenamePolicy) {
const a = document.createElement('a');
a.href = data.file;
a.download = GetDownloadFilename(data, policy)
document.body.append(a); document.body.append(a);
a.click(); a.click();
a.remove(); a.remove();

63
src/view/Home.vue

@ -26,15 +26,15 @@
<audio :autoplay="playing_auto" :src="playing_url" controls/> <audio :autoplay="playing_auto" :src="playing_url" controls/>
<PreviewTable :policy="filename_policy" :table-data="tableData" @play="changePlaying"/> <PreviewTable :policy="filename_policy" :table-data="tableData" @download="saveFile" @play="changePlaying"/>
</div> </div>
</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 {DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic} from "@/utils/utils" import {DownloadBlobMusic, FilenamePolicy, FilenamePolicies, RemoveBlobMusic, DirectlyWriteFile} from "@/utils/utils"
export default { export default {
name: 'Home', name: 'Home',
@ -44,19 +44,24 @@ export default {
}, },
data() { data() {
return { return {
activeIndex: '1',
tableData: [], tableData: [],
playing_url: "", playing_url: "",
playing_auto: false, playing_auto: false,
filename_policy: FilenamePolicy.ArtistAndTitle, filename_policy: FilenamePolicy.ArtistAndTitle,
instant_download: false, instant_download: false,
FilenamePolicies FilenamePolicies,
dir: null
}
},
watch: {
instant_download(val) {
if (val) this.showDirectlySave()
} }
}, },
methods: { methods: {
showSuccess(data) { async showSuccess(data) {
if (this.instant_download) { if (this.instant_download) {
DownloadBlobMusic(data, this.filename_policy); await this.saveFile(data)
RemoveBlobMusic(data); RemoveBlobMusic(data);
} else { } else {
this.tableData.push(data); this.tableData.push(data);
@ -81,7 +86,7 @@ export default {
duration: 6000 duration: 6000
}); });
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
window._paq.push(["trackEvent", "Error", errInfo, filename]); window._paq.push(["trackEvent", "Error", String(errInfo), filename]);
} }
}, },
changePlaying(url) { changePlaying(url) {
@ -98,12 +103,50 @@ export default {
let index = 0; let index = 0;
let c = setInterval(() => { let c = setInterval(() => {
if (index < this.tableData.length) { if (index < this.tableData.length) {
DownloadBlobMusic(this.tableData[index], this.filename_policy); this.saveFile(this.tableData[index])
index++; index++;
} else { } else {
clearInterval(c); clearInterval(c);
} }
}, 300); }, 300);
},
async saveFile(data) {
if (this.dir) {
await DirectlyWriteFile(data, this.filename_policy, this.dir)
this.$notify({
title: "保存成功",
message: data.title,
position: "top-left",
type: "success",
duration: 3000
})
} else {
DownloadBlobMusic(data, this.filename_policy)
}
},
async showDirectlySave() {
if (!window.showDirectoryPicker) return
try {
await this.$confirm("您的浏览器支持文件直接保存到磁盘,是否使用?",
"新特性提示", {
confirmButtonText: "使用",
cancelButtonText: "不使用",
type: "warning",
center: true
})
} catch (e) {
console.log(e)
return
}
try {
this.dir = await window.showDirectoryPicker()
window.dir = this.dir
window.f = await this.dir.getFileHandle("write-test.txt", {create: true})
} catch (e) {
console.error(e)
}
} }
}, },
} }

Loading…
Cancel
Save