Improve importing resources (#1120)
* compress video before import * Fix UI * compress audio before importing * fix * save recording as mp3 to save disk space
This commit is contained in:
@@ -309,7 +309,9 @@
|
||||
"welcomeBack": "Welcome back! {{name}}",
|
||||
"print": "Print",
|
||||
"download": "Download",
|
||||
"downloading": "Downloading {{file}}",
|
||||
"downloadingFile": "Downloading {{file}}",
|
||||
"downloading": "Downloading",
|
||||
"importing": "Importing",
|
||||
"downloadedSuccessfully": "Downloaded successfully",
|
||||
"downloadFailed": "Download failed",
|
||||
"chooseAIModelDependingOnYourHardware": "Choose AI Model depending on your hardware.",
|
||||
|
||||
@@ -309,7 +309,9 @@
|
||||
"welcomeBack": "欢迎回来, {{name}}",
|
||||
"print": "打印",
|
||||
"download": "下载",
|
||||
"downloading": "正在下载 {{file}}",
|
||||
"downloadingFile": "正在下载 {{file}}",
|
||||
"downloading": "正在下载",
|
||||
"importing": "正在导入",
|
||||
"downloadedSuccessfully": "下载成功",
|
||||
"downloadFailed": "下载失败",
|
||||
"chooseAIModelDependingOnYourHardware": "根据您的硬件选择合适的 AI 模型, 以便语音转文本服务正常工作",
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AfterCreate,
|
||||
AfterUpdate,
|
||||
AfterDestroy,
|
||||
BeforeCreate,
|
||||
BelongsTo,
|
||||
Table,
|
||||
Column,
|
||||
@@ -36,8 +35,6 @@ import startCase from "lodash/startCase";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import FfmpegWrapper from "@main/ffmpeg";
|
||||
|
||||
const SIZE_LIMIT = 1024 * 1024 * 50; // 50MB
|
||||
|
||||
const logger = log.scope("db/models/audio");
|
||||
|
||||
@Table({
|
||||
@@ -127,7 +124,13 @@ export class Audio extends Model<Audio> {
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get src(): string {
|
||||
if (this.filePath) {
|
||||
if (this.compressedFilePath) {
|
||||
return `enjoy://${path.posix.join(
|
||||
"library",
|
||||
"audios",
|
||||
this.getDataValue("md5") + ".compressed.mp3"
|
||||
)}`;
|
||||
} else if (this.originalFilePath) {
|
||||
return `enjoy://${path.posix.join(
|
||||
"library",
|
||||
"audios",
|
||||
@@ -162,6 +165,10 @@ export class Audio extends Model<Audio> {
|
||||
}
|
||||
|
||||
get filePath(): string {
|
||||
return this.compressedFilePath || this.originalFilePath;
|
||||
}
|
||||
|
||||
get originalFilePath(): string {
|
||||
const file = path.join(
|
||||
settings.userDataPath(),
|
||||
"audios",
|
||||
@@ -175,6 +182,20 @@ export class Audio extends Model<Audio> {
|
||||
}
|
||||
}
|
||||
|
||||
get compressedFilePath(): string {
|
||||
const file = path.join(
|
||||
settings.userDataPath(),
|
||||
"audios",
|
||||
this.getDataValue("md5") + ".compressed.mp3"
|
||||
);
|
||||
|
||||
if (fs.existsSync(file)) {
|
||||
return file;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async upload(force: boolean = false) {
|
||||
if (this.isUploaded && !force) return;
|
||||
|
||||
@@ -226,20 +247,6 @@ export class Audio extends Model<Audio> {
|
||||
return output;
|
||||
}
|
||||
|
||||
@BeforeCreate
|
||||
static async setupDefaultAttributes(audio: Audio) {
|
||||
try {
|
||||
const ffmpeg = new Ffmpeg();
|
||||
const fileMetadata = await ffmpeg.generateMetadata(audio.filePath);
|
||||
audio.metadata = Object.assign(audio.metadata || {}, {
|
||||
...fileMetadata,
|
||||
duration: fileMetadata.format.duration,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("failed to generate metadata", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static autoSync(audio: Audio) {
|
||||
// auto sync should not block the main thread
|
||||
@@ -315,11 +322,6 @@ export class Audio extends Model<Audio> {
|
||||
throw new Error(t("models.audio.fileNotSupported", { file: filePath }));
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.size > SIZE_LIMIT) {
|
||||
throw new Error(t("models.audio.fileTooLarge", { file: filePath }));
|
||||
}
|
||||
|
||||
const md5 = await hashFile(filePath, { algo: "md5" });
|
||||
|
||||
// check if file already exists
|
||||
@@ -338,15 +340,27 @@ export class Audio extends Model<Audio> {
|
||||
logger.debug("Generated ID:", id);
|
||||
|
||||
const destDir = path.join(settings.userDataPath(), "audios");
|
||||
const destFile = path.join(destDir, `${md5}${extname}`);
|
||||
const destFile = path.join(destDir, `${md5}.compressed.mp3`);
|
||||
|
||||
let metadata = {
|
||||
extname,
|
||||
};
|
||||
|
||||
// Copy file to library
|
||||
try {
|
||||
// Create directory if not exists
|
||||
fs.ensureDirSync(destDir);
|
||||
|
||||
// Copy file
|
||||
fs.copySync(filePath, destFile);
|
||||
// Generate metadata
|
||||
const ffmpeg = new Ffmpeg();
|
||||
const fileMetadata = await ffmpeg.generateMetadata(filePath);
|
||||
metadata = Object.assign(metadata, {
|
||||
...fileMetadata,
|
||||
duration: fileMetadata.format.duration,
|
||||
});
|
||||
|
||||
// Compress file
|
||||
await ffmpeg.compressAudio(filePath, destFile);
|
||||
|
||||
// Check if file copied
|
||||
fs.accessSync(destFile, fs.constants.R_OK);
|
||||
@@ -367,9 +381,7 @@ export class Audio extends Model<Audio> {
|
||||
name,
|
||||
description,
|
||||
coverUrl,
|
||||
metadata: {
|
||||
extname,
|
||||
},
|
||||
metadata,
|
||||
});
|
||||
|
||||
return record.save().catch((err) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ import echogarden from "@main/echogarden";
|
||||
import { t } from "i18next";
|
||||
import { Attributes, Transaction } from "sequelize";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import FfmpegWrapper from "@main/ffmpeg";
|
||||
|
||||
const logger = log.scope("db/models/recording");
|
||||
|
||||
@@ -299,14 +300,10 @@ export class Recording extends Model<Recording> {
|
||||
}
|
||||
|
||||
// rename file
|
||||
const filename = `${md5}.wav`;
|
||||
fs.moveSync(
|
||||
file,
|
||||
path.join(settings.userDataPath(), "recordings", filename),
|
||||
{
|
||||
overwrite: true,
|
||||
}
|
||||
);
|
||||
const filename = `${md5}.mp3`;
|
||||
const destFile = path.join(settings.userDataPath(), "recordings", filename);
|
||||
const ffmpeg = new FfmpegWrapper();
|
||||
await ffmpeg.compressAudio(file, destFile);
|
||||
|
||||
const userId = settings.getSync("user.id");
|
||||
const id = uuidv5(`${userId}/${md5}`, uuidv5.URL);
|
||||
|
||||
@@ -2,7 +2,6 @@ import {
|
||||
AfterCreate,
|
||||
AfterUpdate,
|
||||
AfterDestroy,
|
||||
BeforeCreate,
|
||||
BelongsTo,
|
||||
Table,
|
||||
Column,
|
||||
@@ -36,8 +35,6 @@ import startCase from "lodash/startCase";
|
||||
import { v5 as uuidv5 } from "uuid";
|
||||
import FfmpegWrapper from "@main/ffmpeg";
|
||||
|
||||
const SIZE_LIMIT = 1024 * 1024 * 100; // 100MB
|
||||
|
||||
const logger = log.scope("db/models/video");
|
||||
|
||||
@Table({
|
||||
@@ -127,7 +124,13 @@ export class Video extends Model<Video> {
|
||||
|
||||
@Column(DataType.VIRTUAL)
|
||||
get src(): string {
|
||||
if (this.filePath) {
|
||||
if (this.compressedFilePath) {
|
||||
return `enjoy://${path.posix.join(
|
||||
"library",
|
||||
"videos",
|
||||
this.getDataValue("md5") + ".compressed.mp4"
|
||||
)}`;
|
||||
} else if (this.originalFilePath) {
|
||||
return `enjoy://${path.posix.join(
|
||||
"library",
|
||||
"videos",
|
||||
@@ -162,6 +165,10 @@ export class Video extends Model<Video> {
|
||||
}
|
||||
|
||||
get filePath(): string {
|
||||
return this.compressedFilePath || this.originalFilePath;
|
||||
}
|
||||
|
||||
get originalFilePath(): string {
|
||||
const file = path.join(
|
||||
settings.userDataPath(),
|
||||
"videos",
|
||||
@@ -175,6 +182,20 @@ export class Video extends Model<Video> {
|
||||
}
|
||||
}
|
||||
|
||||
get compressedFilePath(): string {
|
||||
const file = path.join(
|
||||
settings.userDataPath(),
|
||||
"videos",
|
||||
`${this.getDataValue("md5")}.compressed.mp4`
|
||||
);
|
||||
|
||||
if (fs.existsSync(file)) {
|
||||
return file;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// generate cover and upload
|
||||
async generateCover() {
|
||||
if (this.coverUrl) return;
|
||||
@@ -247,20 +268,6 @@ export class Video extends Model<Video> {
|
||||
return output;
|
||||
}
|
||||
|
||||
@BeforeCreate
|
||||
static async setupDefaultAttributes(video: Video) {
|
||||
try {
|
||||
const ffmpeg = new Ffmpeg();
|
||||
const fileMetadata = await ffmpeg.generateMetadata(video.filePath);
|
||||
video.metadata = Object.assign(video.metadata || {}, {
|
||||
...fileMetadata,
|
||||
duration: fileMetadata.format.duration,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error("failed to generate metadata", err.message);
|
||||
}
|
||||
}
|
||||
|
||||
@AfterCreate
|
||||
static autoSync(video: Video) {
|
||||
// auto sync should not block the main thread
|
||||
@@ -331,11 +338,6 @@ export class Video extends Model<Video> {
|
||||
throw new Error(t("models.video.fileNotSupported", { file: filePath }));
|
||||
}
|
||||
|
||||
const stats = fs.statSync(filePath);
|
||||
if (stats.size > SIZE_LIMIT) {
|
||||
throw new Error(t("models.video.fileTooLarge", { file: filePath }));
|
||||
}
|
||||
|
||||
const md5 = await hashFile(filePath, { algo: "md5" });
|
||||
|
||||
// check if file already exists
|
||||
@@ -354,15 +356,27 @@ export class Video extends Model<Video> {
|
||||
logger.debug("Generated ID:", id);
|
||||
|
||||
const destDir = path.join(settings.userDataPath(), "videos");
|
||||
const destFile = path.join(destDir, `${md5}${extname}`);
|
||||
const destFile = path.join(destDir, `${md5}.compressed.mp4`);
|
||||
|
||||
let metadata = {
|
||||
extname,
|
||||
};
|
||||
|
||||
// Copy file to library
|
||||
try {
|
||||
// Create directory if not exists
|
||||
fs.ensureDirSync(destDir);
|
||||
|
||||
// Copy file
|
||||
fs.copySync(filePath, destFile);
|
||||
// fetch metadata
|
||||
const ffmpeg = new FfmpegWrapper();
|
||||
const fileMetadata = await ffmpeg.generateMetadata(filePath);
|
||||
metadata = Object.assign(metadata, {
|
||||
...fileMetadata,
|
||||
duration: fileMetadata.format.duration,
|
||||
});
|
||||
|
||||
// Compress file to destFile
|
||||
await ffmpeg.compressVideo(filePath, destFile);
|
||||
|
||||
// Check if file copied
|
||||
fs.accessSync(destFile, fs.constants.R_OK);
|
||||
@@ -383,9 +397,7 @@ export class Video extends Model<Video> {
|
||||
name,
|
||||
description,
|
||||
coverUrl,
|
||||
metadata: {
|
||||
extname,
|
||||
},
|
||||
metadata,
|
||||
});
|
||||
|
||||
return record.save().catch((err) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import path from "path";
|
||||
import fs from "fs";
|
||||
import mainWin from "@main/window";
|
||||
import log from "@main/logger";
|
||||
import settings from "@main/settings";
|
||||
|
||||
const logger = log.scope("downloader");
|
||||
class Downloader {
|
||||
@@ -24,6 +25,7 @@ class Downloader {
|
||||
return new Promise((resolve, _reject) => {
|
||||
webContents.downloadURL(url);
|
||||
|
||||
const cachePath = settings.cachePath();
|
||||
webContents.session.on("will-download", (_event, item, _webContents) => {
|
||||
if (savePath) {
|
||||
try {
|
||||
@@ -36,9 +38,7 @@ class Downloader {
|
||||
item.setSavePath(savePath);
|
||||
}
|
||||
} else {
|
||||
item.setSavePath(
|
||||
path.join(app.getPath("downloads"), item.getFilename())
|
||||
);
|
||||
item.setSavePath(path.join(cachePath, item.getFilename()));
|
||||
}
|
||||
|
||||
this.tasks.push(item);
|
||||
|
||||
@@ -290,6 +290,70 @@ export default class FfmpegWrapper {
|
||||
});
|
||||
}
|
||||
|
||||
compressVideo(input: string, output: string) {
|
||||
const ffmpeg = Ffmpeg();
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg
|
||||
.input(input)
|
||||
.outputOptions(
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-tag:v",
|
||||
"avc1",
|
||||
"-movflags",
|
||||
"faststart",
|
||||
"-crf",
|
||||
"30",
|
||||
"-preset",
|
||||
"superfast"
|
||||
)
|
||||
.on("start", (commandLine) => {
|
||||
logger.info("Spawned FFmpeg with command: " + commandLine);
|
||||
fs.ensureDirSync(path.dirname(output));
|
||||
})
|
||||
.on("end", () => {
|
||||
logger.info(`File "${output}" created`);
|
||||
resolve(output);
|
||||
})
|
||||
.on("error", (err) => {
|
||||
logger.error(err);
|
||||
reject(err);
|
||||
})
|
||||
.save(output);
|
||||
});
|
||||
}
|
||||
|
||||
compressAudio(input: string, output: string) {
|
||||
const ffmpeg = Ffmpeg();
|
||||
return new Promise((resolve, reject) => {
|
||||
ffmpeg
|
||||
.input(input)
|
||||
.outputOptions(
|
||||
"-ar",
|
||||
"16000",
|
||||
"-b:a",
|
||||
"32000",
|
||||
"-ac",
|
||||
"1",
|
||||
"-preset",
|
||||
"superfast"
|
||||
)
|
||||
.on("start", (commandLine) => {
|
||||
logger.info("Spawned FFmpeg with command: " + commandLine);
|
||||
fs.ensureDirSync(path.dirname(output));
|
||||
})
|
||||
.on("end", () => {
|
||||
logger.info(`File "${output}" created`);
|
||||
resolve(output);
|
||||
})
|
||||
.on("error", (err) => {
|
||||
logger.error(err);
|
||||
reject(err);
|
||||
})
|
||||
.save(output);
|
||||
});
|
||||
}
|
||||
|
||||
registerIpcHandlers() {
|
||||
ipcMain.handle("ffmpeg-check-command", async (_event) => {
|
||||
return await this.checkCommand();
|
||||
|
||||
@@ -175,7 +175,11 @@ export const AudibleBooksSegment = () => {
|
||||
{downloading && (
|
||||
<LoaderIcon className="w-4 h-4 animate-spin mr-2" />
|
||||
)}
|
||||
{t("downloadSample")}
|
||||
{downloading
|
||||
? progress < 100
|
||||
? t("downloading")
|
||||
: t("importing")
|
||||
: t("downloadSample")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
|
||||
@@ -276,7 +276,9 @@ const ChatUserMessageActions = (props: {
|
||||
savePath as string
|
||||
),
|
||||
{
|
||||
loading: t("downloading", { file: chatMessage.recording.filename }),
|
||||
loading: t("downloadingFile", {
|
||||
file: chatMessage.recording.filename,
|
||||
}),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
|
||||
@@ -251,7 +251,7 @@ export const MediaCurrentRecording = () => {
|
||||
toast.promise(
|
||||
EnjoyApp.download.start(currentRecording.src, savePath as string),
|
||||
{
|
||||
loading: t("downloading", { file: currentRecording.filename }),
|
||||
loading: t("downloadingFile", { file: currentRecording.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
|
||||
@@ -122,7 +122,7 @@ export const MediaWaveform = () => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(EnjoyApp.download.start(media.src, savePath as string), {
|
||||
loading: t("downloading", { file: media.filename }),
|
||||
loading: t("downloadingFile", { file: media.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
|
||||
@@ -78,7 +78,7 @@ export const MediaRecordings = () => {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(EnjoyApp.download.start(url, savePath as string), {
|
||||
loading: t("downloading", { file: filename }),
|
||||
loading: t("downloadingFile", { file: filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
|
||||
@@ -145,7 +145,7 @@ const TranscriptionRecordingsList = () => {
|
||||
toast.promise(
|
||||
EnjoyApp.download.start(recording.src, savePath as string),
|
||||
{
|
||||
loading: t("downloading", { file: recording.filename }),
|
||||
loading: t("downloadingFile", { file: recording.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
|
||||
@@ -80,7 +80,7 @@ export const MediaCaptionActions = (props: {
|
||||
toast.promise(
|
||||
EnjoyApp.download.start(segment.src, savePath as string),
|
||||
{
|
||||
loading: t("downloading", { file: media.filename }),
|
||||
loading: t("downloadingFile", { file: media.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
@@ -133,7 +133,7 @@ export const MediaCaptionActions = (props: {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(EnjoyApp.download.start(src, savePath as string), {
|
||||
loading: t("downloading", { file: media.filename }),
|
||||
loading: t("downloadingFile", { file: media.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
|
||||
@@ -142,7 +142,7 @@ export const AssistantMessageComponent = (props: {
|
||||
if (!savePath) return;
|
||||
|
||||
toast.promise(EnjoyApp.download.start(speech.src, savePath as string), {
|
||||
loading: t("downloading", { file: speech.filename }),
|
||||
loading: t("downloadingFile", { file: speech.filename }),
|
||||
success: () => t("downloadedSuccessfully"),
|
||||
error: t("downloadFailed"),
|
||||
position: "bottom-right",
|
||||
|
||||
@@ -161,7 +161,11 @@ export const YoutubeVideosSegment = (props: { channel: string }) => {
|
||||
{submitting && (
|
||||
<LoaderIcon className="w-4 h-4 animate-spin mr-2" />
|
||||
)}
|
||||
{t("downloadVideo")}
|
||||
{submitting
|
||||
? progress < 100
|
||||
? t("downloading")
|
||||
: t("importing")
|
||||
: t("downloadVideo")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
{submitting && (
|
||||
|
||||
@@ -19,7 +19,8 @@ export const usePronunciationAssessments = () => {
|
||||
}
|
||||
|
||||
EnjoyApp.recordings.sync(recording.id);
|
||||
const blob = await (await fetch(recording.src)).blob();
|
||||
const url = await EnjoyApp.echogarden.transcode(recording.src);
|
||||
const blob = await (await fetch(url)).blob();
|
||||
targetId = recording.id;
|
||||
targetType = "Recording";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user