Files
everyone-can-use-english/enjoy/src/main/db/models/audio.ts
an-lee d8a8b5db1f Hotfix bugs: path issue in Windows (#331)
* fix speech not loaded

* fix path issue in windows
2024-02-20 11:48:20 +08:00

339 lines
7.8 KiB
TypeScript

import {
AfterCreate,
AfterUpdate,
AfterDestroy,
BeforeCreate,
BelongsTo,
Table,
Column,
Default,
IsUUID,
Model,
HasMany,
HasOne,
DataType,
Unique,
} from "sequelize-typescript";
import { Recording, Speech, Transcription, Video } from "@main/db/models";
import settings from "@main/settings";
import { AudioFormats, VideoFormats, WEB_API_URL } from "@/constants";
import { hashFile } from "@main/utils";
import path from "path";
import fs from "fs-extra";
import { t } from "i18next";
import mainWindow from "@main/window";
import log from "electron-log/main";
import storage from "@main/storage";
import Ffmpeg from "@main/ffmpeg";
import { Client } from "@/api";
import startCase from "lodash/startCase";
import { v5 as uuidv5 } from "uuid";
const SIZE_LIMIT = 1024 * 1024 * 50; // 50MB
const logger = log.scope("db/models/audio");
@Table({
modelName: "Audio",
tableName: "audios",
underscored: true,
timestamps: true,
})
export class Audio extends Model<Audio> {
@IsUUID("all")
@Default(DataType.UUIDV4)
@Column({ primaryKey: true, type: DataType.UUID })
id: string;
@Column(DataType.STRING)
source: string;
@Unique
@Column(DataType.STRING)
md5: string;
@Column(DataType.STRING)
name: string;
@Column(DataType.STRING)
description: string;
@Column(DataType.JSON)
metadata: any;
@Column(DataType.STRING)
coverUrl: string;
@HasMany(() => Recording, {
foreignKey: "targetId",
constraints: false,
scope: { target_type: "Audio" },
})
recordings: Recording[];
@HasOne(() => Transcription, {
foreignKey: "targetId",
constraints: false,
scope: { target_type: "Audio" },
})
transcription: Transcription;
@BelongsTo(() => Speech, "md5")
speech: Speech;
@Default(0)
@Column(DataType.INTEGER)
recordingsCount: number;
@Default(0)
@Column(DataType.INTEGER)
recordingsDuration: number;
@Column(DataType.DATE)
syncedAt: Date;
@Column(DataType.DATE)
uploadedAt: Date;
@Column(DataType.VIRTUAL)
get isSynced(): boolean {
return Boolean(this.syncedAt) && this.syncedAt >= this.updatedAt;
}
@Column(DataType.VIRTUAL)
get isUploaded(): boolean {
return Boolean(this.uploadedAt);
}
@Column(DataType.VIRTUAL)
get transcribing(): boolean {
return this.transcription?.state === "processing";
}
@Column(DataType.VIRTUAL)
get transcribed(): boolean {
return Boolean(this.transcription?.result);
}
@Column(DataType.VIRTUAL)
get src(): string {
return `enjoy://${path.posix.join(
"library",
"audios",
this.getDataValue("md5") + this.extname
)}`;
}
@Column(DataType.VIRTUAL)
get duration(): number {
return this.getDataValue("metadata").duration;
}
get extname(): string {
return (
this.getDataValue("metadata").extname ||
path.extname(this.getDataValue("source")) ||
""
);
}
get filePath(): string {
return path.join(
settings.userDataPath(),
"audios",
this.getDataValue("md5") + this.extname
);
}
async upload(force: boolean = false) {
if (this.isUploaded && !force) return;
return storage
.put(this.md5, this.filePath)
.then((result) => {
logger.debug("upload result:", result.data);
if (result.data.success) {
this.update({ uploadedAt: new Date() });
} else {
throw new Error(result.data);
}
})
.catch((err) => {
logger.error("upload failed:", err.message);
throw err;
});
}
async sync() {
if (this.isSynced) return;
const webApi = new Client({
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
accessToken: settings.getSync("user.accessToken") as string,
logger: log.scope("audio/sync"),
});
return webApi.syncAudio(this.toJSON()).then(() => {
const now = new Date();
this.update({ syncedAt: now, updatedAt: now });
});
}
@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
audio.sync().catch(() => {});
}
@AfterCreate
static notifyForCreate(audio: Audio) {
this.notify(audio, "create");
}
@AfterUpdate
static notifyForUpdate(audio: Audio) {
this.notify(audio, "update");
audio.sync().catch(() => {});
}
@AfterDestroy
static notifyForDestroy(audio: Audio) {
this.notify(audio, "destroy");
}
@AfterDestroy
static cleanupFile(audio: Audio) {
fs.remove(audio.filePath);
Recording.destroy({
where: {
targetId: audio.id,
targetType: "Audio",
},
});
const webApi = new Client({
baseUrl: process.env.WEB_API_URL || WEB_API_URL,
accessToken: settings.getSync("user.accessToken") as string,
logger: log.scope("audio/cleanupFile"),
});
webApi.deleteAudio(audio.id).catch((err) => {
logger.error("deleteAudio failed:", err.message);
});
}
static async buildFromLocalFile(
filePath: string,
params?: {
name?: string;
description?: string;
source?: string;
coverUrl?: string;
}
): Promise<Audio | Video> {
// Check if file exists
try {
fs.accessSync(filePath, fs.constants.R_OK);
} catch (error) {
throw new Error(t("models.audio.fileNotFound", { file: filePath }));
}
// Check if file format is supported
const extname = path.extname(filePath);
if (VideoFormats.includes(extname.split(".").pop() as string)) {
return Video.buildFromLocalFile(filePath, params);
} else if (!AudioFormats.includes(extname.split(".").pop() as string)) {
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
const existing = await Audio.findOne({
where: {
md5,
},
});
if (existing) {
throw new Error(t("audioAlreadyAddedToLibrary", { file: filePath }));
}
// Generate ID
const userId = settings.getSync("user.id");
const id = uuidv5(`${userId}/${md5}`, uuidv5.URL);
logger.debug("Generated ID:", id);
const destDir = path.join(settings.userDataPath(), "audios");
const destFile = path.join(destDir, `${md5}${extname}`);
// Copy file to library
try {
// Create directory if not exists
fs.ensureDirSync(destDir);
// Copy file
fs.copySync(filePath, destFile);
// Check if file copied
fs.accessSync(destFile, fs.constants.R_OK);
} catch (error) {
throw new Error(t("models.audio.failedToCopyFile", { file: filePath }));
}
const {
name = startCase(path.basename(filePath, extname)),
description,
source,
coverUrl,
} = params || {};
const record = this.build({
id,
source,
md5,
name,
description,
coverUrl,
metadata: {
extname,
},
});
return record.save().catch((err) => {
logger.error(err);
// Remove copied file
fs.removeSync(destFile);
throw err;
});
}
static notify(audio: Audio, action: "create" | "update" | "destroy") {
if (!mainWindow.win) return;
mainWindow.win.webContents.send("db-on-transaction", {
model: "Audio",
id: audio.id,
action: action,
record: audio.toJSON(),
});
}
}