diff --git a/.eslintrc.json b/.eslintrc.json index 32de6e5..df4df67 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,8 +1,8 @@ { "parserOptions": { "sourceType": "module", - "ecmaVersion": 2019, - "project": ["./tsconfig.eslint.json"] + "ecmaVersion": 2019, + "project": ["./tsconfig.eslint.json"] }, "parser": "@typescript-eslint/parser", "plugins": [ @@ -18,16 +18,16 @@ ], "rules": { "@typescript-eslint/restrict-template-expressions": "off", - "@typescript-eslint/unbound-method": "off", - "@typescript-eslint/consistent-type-imports": "error", - "@typescript-eslint/no-unused-vars": "error", - "@next/next/no-img-element": "off", - "no-unused-vars": "off", - "no-control-regex": "off", - "import/no-duplicates": "error", - "simple-import-sort/imports": "error", - "simple-import-sort/exports": "error", - "@typescript-eslint/explicit-function-return-type": [ + "@typescript-eslint/unbound-method": "off", + "@typescript-eslint/consistent-type-imports": "error", + "@typescript-eslint/no-unused-vars": "error", + "@next/next/no-img-element": "off", + "no-unused-vars": "off", + "no-control-regex": "off", + "import/no-duplicates": "error", + "simple-import-sort/imports": "error", + "simple-import-sort/exports": "error", + "@typescript-eslint/explicit-function-return-type": [ "error", { "allowTypedFunctionExpressions": true, diff --git a/electron/ffmpeg.ts b/electron/assets.ts similarity index 87% rename from electron/ffmpeg.ts rename to electron/assets.ts index d8cd3c6..76d7563 100644 --- a/electron/ffmpeg.ts +++ b/electron/assets.ts @@ -1,6 +1,5 @@ import type { AxiosProgressEvent, AxiosResponse } from "axios"; import axios from "axios"; -import { app } from "electron"; import * as fs from "fs"; import * as os from "os"; import * as path from "path"; @@ -11,6 +10,7 @@ import { createBinaryDownloaderWindow, sendMessageToBinaryDownloader, } from "./binary-downloader-window"; +import { basePath } from "./context"; import { spawn } from "./lib/spawn"; type lib = "ffmpeg" | "ffprobe"; @@ -19,11 +19,11 @@ let target: lib[] = []; const ext = process.platform === "win32" ? ".exe" : ""; -const basePath = path.join(__dirname, app.isPackaged ? "../../../" : "", "bin"), - ffmpegPath = path.join(basePath, `ffmpeg${ext}`), - ffprobePath = path.join(basePath, `ffprobe${ext}`); +const binPath = path.join(basePath, "bin"), + ffmpegPath = path.join(binPath, `ffmpeg${ext}`), + ffprobePath = path.join(binPath, `ffprobe${ext}`); -const baseUrl = { +const assetsBaseUrl = { ffmpeg: "https://github.com/descriptinc/ffmpeg-ffprobe-static/releases/download/b4.4.0-rc.11/", }; @@ -74,20 +74,20 @@ const onStartUp = async (): Promise => { }; const downloadBinary = async (target: lib[]): Promise => { - if (!fs.existsSync(basePath)) { - await fs.promises.mkdir(basePath, { recursive: true }); + if (!fs.existsSync(binPath)) { + await fs.promises.mkdir(binPath, { recursive: true }); } if (target.includes("ffmpeg")) { await downloadFile( "ffmpeg", - `${baseUrl.ffmpeg}ffmpeg-${distro.ffmpeg}`, + `${assetsBaseUrl.ffmpeg}ffmpeg-${distro.ffmpeg}`, ffmpegPath, ); } if (target.includes("ffprobe")) { await downloadFile( "ffprobe", - `${baseUrl.ffmpeg}ffprobe-${distro.ffmpeg}`, + `${assetsBaseUrl.ffmpeg}ffprobe-${distro.ffmpeg}`, ffprobePath, ); } diff --git a/electron/context.ts b/electron/context.ts index 92bb0a3..77b6664 100644 --- a/electron/context.ts +++ b/electron/context.ts @@ -1,9 +1,12 @@ import { app } from "electron"; +import * as path from "path"; const baseUrl = app.isPackaged ? `file://${__dirname}/html/index.html` : "http://localhost:5173"; +const basePath = path.join(__dirname, app.isPackaged ? "../../../" : ""); + const appPrefix = "niconicomments-convert"; -export { appPrefix, baseUrl }; +export { appPrefix, basePath, baseUrl }; diff --git a/electron/dialog.ts b/electron/dialog.ts index 87a010e..3c86cbd 100644 --- a/electron/dialog.ts +++ b/electron/dialog.ts @@ -10,13 +10,16 @@ import type { } from "@/@types/response.controller"; import type { SpawnResult } from "@/@types/spawn"; +import { ffprobePath } from "./assets"; import { sendMessageToController } from "./controller-window"; -import { ffprobePath } from "./ffmpeg"; -import { encodeJson } from "./lib/json"; +import { encodeError } from "./lib/json"; +import { getLogger } from "./lib/log"; import { spawn } from "./lib/spawn"; import { store } from "./store"; import { identifyCommentFormat } from "./utils/niconicomments"; +const logger = getLogger("[dialog]"); + const selectFile = async ( pattern: Electron.FileFilter[], ): Promise => { @@ -59,6 +62,7 @@ const selectMovie = async (): Promise< ]).promise; } catch (e: unknown) { const error = e as SpawnResult; + logger.error("failed to execute ffprobe", "error:", error); return { type: "message", title: "動画ファイルの解析に失敗しました", @@ -68,17 +72,25 @@ const selectMovie = async (): Promise< try { metadata = JSON.parse(ffprobe.stdout) as FfprobeOutput; } catch (e) { + logger.error( + "failed to parse ffprobe output", + "error:", + e, + "stdout:", + ffprobe.stdout, + ); return { type: "message", title: "動画ファイルの解析に失敗しました", message: `ffprobeの出力のパースに失敗しました\nffprobeの出力:\n${ ffprobe.stdout - }\nエラー内容:\n${encodeJson( + }\nエラー内容:\n${encodeError( e, )}\ndialog / selectMovie / failed to parse ffprobe output`, }; } if (!metadata.streams || !Array.isArray(metadata.streams)) { + logger.error("movie source not found", "metadata:", metadata); return { type: "message", title: "動画ファイルの解析に失敗しました", @@ -101,6 +113,7 @@ const selectMovie = async (): Promise< } } if (!(height && width && duration)) { + logger.error("failed to get resolution or duration", "metadata:", metadata); return { type: "message", title: "動画ファイルの解析に失敗しました", @@ -138,6 +151,7 @@ const selectComment = async (): Promise< store.set("commentFileExt", ext); const format = identifyCommentFormat(filePath); if (!format) { + console.error("failed to identify comment format", "filePath:", filePath); sendMessageToController({ type: "message", title: "非対応のフォーマットです", diff --git a/electron/electron.ts b/electron/electron.ts index eb312e1..d33beea 100644 --- a/electron/electron.ts +++ b/electron/electron.ts @@ -1,8 +1,9 @@ import { app, BrowserWindow, globalShortcut } from "electron"; +import { onStartUp } from "./assets"; import { createControllerWindow } from "./controller-window"; -import { onStartUp } from "./ffmpeg"; import { registerListener } from "./ipc-manager"; +import { initLogger } from "./lib/log"; app.on("window-all-closed", () => { app.quit(); @@ -32,4 +33,5 @@ if (app.isPackaged) { globalShortcut.unregister("F5"); }); } +initLogger(); registerListener(); diff --git a/electron/ffmpeg-stream/stream.ts b/electron/ffmpeg-stream/stream.ts index 6a84edd..d4096e8 100644 --- a/electron/ffmpeg-stream/stream.ts +++ b/electron/ffmpeg-stream/stream.ts @@ -13,23 +13,23 @@ import type { Readable, Writable } from "stream"; import { PassThrough } from "stream"; import { promisify } from "util"; -import { sendMessageToController } from "../controller-window"; -import { ffmpegPath } from "../ffmpeg"; -import { encodeJson } from "../lib/json"; +import { ffmpegPath } from "../assets"; +import { getLogger } from "../lib/log"; + +const logger = getLogger("[ffmpeg-stream]"); -const dbg = console.log; const { FFMPEG_PATH = ffmpegPath } = process.env; const EXIT_CODES = [0, 255]; function debugStream(stream: Readable | Writable, name: string): void { stream.on("error", (err) => { - dbg(`${name} error: ${err.message}`); + logger.debug(`${name} error: ${err.message}`); }); stream.on("data", (data: string | Buffer) => { - dbg(`${name} data: ${data.length} bytes`); + logger.debug(`${name} data: ${data.length} bytes`); }); stream.on("finish", () => { - dbg(`${name} finish`); + logger.debug(`${name} finish`); }); } @@ -190,11 +190,11 @@ export class Converter { const writer = createWriteStream(file); stream.pipe(writer); stream.on("end", () => { - dbg("input buffered stream end"); + logger.debug("input buffered stream end"); resolve(); }); stream.on("error", (err) => { - dbg(`input buffered stream error: ${err.message}`); + logger.error(`input buffered stream error`, err); return reject(err); }); }); @@ -218,11 +218,11 @@ export class Converter { const reader = createReadStream(file); reader.pipe(stream); reader.on("end", () => { - dbg("output buffered stream end"); + logger.debug("output buffered stream end"); resolve(); }); reader.on("error", (err: Error) => { - dbg(`output buffered stream error: ${err.message}`); + logger.error(`output buffered stream error`, err); reject(err); }); }); @@ -236,15 +236,15 @@ export class Converter { const pipes: Pipe[] = []; try { for (const pipe of this.pipes) { - dbg(`prepare ${pipe.type}`); + logger.debug(`prepare ${pipe.type}`); await pipe.onBegin?.(); pipes.push(pipe); } const command = ["-y", "-v", "verbose", ...this.getSpawnArgs()]; const stdio = this.getStdioArg(); - dbg(`spawn: ${FFMPEG_PATH} ${command.join(" ")}`); - dbg(`spawn stdio: ${stdio.join(" ")}`); + logger.log(`spawn: ${FFMPEG_PATH} ${command.join(" ")}`); + logger.log(`spawn stdio: ${stdio.join(" ")}`); this.process = spawn(FFMPEG_PATH, command, { stdio }); const finished = this.handleProcess(); @@ -258,16 +258,15 @@ export class Converter { } await finished; + for (const pipe of pipes) { + await pipe.onFinish?.(); + } } catch (e) { - sendMessageToController({ - type: "message", - title: "変換中にエラーが発生しました", - message: `エラー内容:\n${encodeJson(e)}`, - }); - } finally { for (const pipe of pipes) { await pipe.onFinish?.(); } + logger.error(e); + throw e; } } @@ -315,7 +314,6 @@ export class Converter { private async handleProcess(): Promise { await new Promise((resolve, reject): void => { let logSectionNum = 0; - const logLines: string[] = []; if (this.process == null) return reject(Error(`Converter not started`)); @@ -331,27 +329,24 @@ export class Converter { if (/^\s/u.exec(line) == null) logSectionNum++; // only log sections following the first one if (logSectionNum > 1) { - dbg(`log: ${line}`); - logLines.push(line); + logger.log("[ffmpeg]", line); } } }); } this.process.on("error", (err) => { - dbg(`error: ${err.message}`); + logger.error("[ffmpeg]", err); return reject(err); }); this.process.on("exit", (code, signal) => { - dbg(`exit: code=${code ?? "unknown"} sig=${signal ?? "unknown"}`); - console.log( + logger.log( `exit: code=${code ?? "unknown"} sig=${signal ?? "unknown"}`, ); if (code == null) return resolve(); if (EXIT_CODES.includes(code)) return resolve(); - const log = logLines.map((line) => ` ${line}`).join("\n"); - reject(Error(`Converting failed\n${log}`)); + reject(Error(`Converting failed`)); }); }); } diff --git a/electron/ipc-manager.ts b/electron/ipc-manager.ts index 0bbfe94..940432a 100644 --- a/electron/ipc-manager.ts +++ b/electron/ipc-manager.ts @@ -3,7 +3,8 @@ import { ipcMain } from "electron"; import { sendMessageToController } from "./controller-window"; import { selectComment, selectFile, selectMovie, selectOutput } from "./dialog"; import { getAvailableProfiles } from "./lib/cookie"; -import { encodeJson } from "./lib/json"; +import { encodeError, encodeJson } from "./lib/json"; +import { getLogger } from "./lib/log"; import { getMetadata } from "./lib/niconico"; import { appendFrame, @@ -16,10 +17,12 @@ import { import { store } from "./store"; import { typeGuard } from "./type-guard"; +const logger = getLogger("[ipcManager]"); + const registerListener = (): void => { ipcMain.handle("request", async (_, args) => { + const value = (args as { data: unknown[] }).data[0]; try { - const value = (args as { data: unknown[] }).data[0]; if (typeGuard.renderer.blob(value)) { appendFrame(value.frameId, value.data); } else if (typeGuard.renderer.end(value)) { @@ -54,6 +57,7 @@ const registerListener = (): void => { } else if (typeGuard.renderer.message(value)) { sendMessageToController(value); } else { + logger.error("unknown ipc message", "ipcMessage:", value); sendMessageToController({ type: "message", title: "未知のエラーが発生しました", @@ -63,12 +67,19 @@ const registerListener = (): void => { }); } } catch (e: unknown) { + logger.error( + "failed to process ipc message", + "ipcMessage:", + value, + "error:", + e, + ); sendMessageToController({ type: "message", title: "予期しないエラーが発生しました", message: `IPCメッセージ:\n${encodeJson( args, - )}\nエラー内容:\n${encodeJson(e)}\nipcManager / catchError`, + )}\nエラー内容:\n${encodeError(e)}\nipcManager / catchError`, }); } }); diff --git a/electron/lib/json.ts b/electron/lib/json.ts index 32140d0..6a6efa5 100644 --- a/electron/lib/json.ts +++ b/electron/lib/json.ts @@ -1,4 +1,9 @@ const encodeJson = (input: unknown): string => { return JSON.stringify(input, null, "\t"); }; -export { encodeJson }; + +const encodeError = (input: unknown): string => { + return JSON.stringify(input, Object.getOwnPropertyNames(input), "\t"); +}; + +export { encodeError, encodeJson }; diff --git a/electron/lib/log.ts b/electron/lib/log.ts new file mode 100644 index 0000000..7cc9dd2 --- /dev/null +++ b/electron/lib/log.ts @@ -0,0 +1,43 @@ +import log from "electron-log/main"; +import * as fs from "fs"; +import * as path from "path"; + +import { basePath } from "../context"; + +export const initLogger = (): void => { + const logDir = path.join(basePath, "logs"); + if (!fs.existsSync(logDir)) { + fs.mkdirSync(logDir); + } + + const d = new Date(); + const prefix = + d.getFullYear() + + ("00" + (d.getMonth() + 1)).slice(-2) + + ("00" + d.getDate()).slice(-2); + log.transports.file.level = "info"; + log.transports.file.resolvePathFn = () => path.join(logDir, `${prefix}.log`); + process.on("uncaughtException", (err) => { + log.error(err); + }); +}; + +type Logger = { + debug: (...args: unknown[]) => void; + log: (...args: unknown[]) => void; + info: (...args: unknown[]) => void; + warn: (...args: unknown[]) => void; + error: (...args: unknown[]) => void; + getLogger: (...prefix: string[]) => Logger; +}; + +export const getLogger = (...prefix: string[]): Logger => { + return { + debug: (...args: unknown[]) => log.debug(...prefix, ...args), + log: (...args: unknown[]) => log.log(...prefix, ...args), + info: (...args: unknown[]) => log.info(...prefix, ...args), + warn: (...args: unknown[]) => log.warn(...prefix, ...args), + error: (...args: unknown[]) => log.error(...prefix, ...args), + getLogger: (..._prefix: string[]) => getLogger(...prefix, ..._prefix), + }; +}; diff --git a/electron/lib/niconico/_dms.ts b/electron/lib/niconico/_dms.ts deleted file mode 100644 index 4248963..0000000 --- a/electron/lib/niconico/_dms.ts +++ /dev/null @@ -1,319 +0,0 @@ -import type { AxiosResponse } from "axios"; -import axios from "axios"; -import * as fs from "fs"; -import * as path from "path"; -import type * as Stream from "stream"; - -import type { Cookies, ParsedCookie } from "@/@types/cookies"; -import type { TWatchV3Metadata } from "@/@types/niconico"; -import type { TDMSFormat } from "@/@types/queue"; -import type { AuthType } from "@/@types/setting"; -import type { SpawnResult } from "@/@types/spawn"; - -import { sendMessageToController } from "../../controller-window"; -import { ffmpegPath } from "../../ffmpeg"; -import { store } from "../../store"; -import { typeGuard } from "../../type-guard"; -import { - convertToEncodedCookie, - filterCookies, - formatCookies, - getCookies, - parseCookie, -} from "../cookie"; -import { spawn } from "../spawn"; - -let stop: (() => void) | undefined; - -const downloadDMS = async ( - metadata: TWatchV3Metadata, - format: TDMSFormat, - targetPath: string, - progress: (total: number, downloaded: number) => void, -): Promise => { - if (!typeGuard.niconico.v3DMS(metadata)) { - if (typeGuard.niconico.v3DMC(metadata)) { - sendMessageToController({ - title: "動画情報の取得に失敗しました", - message: - "DMS上に動画が見つかりませんでした\nDMCからの取得を試してみてください\nlib/niconico/dms.ts / downloadDMS / invalid server", - type: "message", - }); - return; - } - sendMessageToController({ - title: "動画情報の取得に失敗しました", - message: - "未購入の有料動画などの可能性があります\nlib/niconico/dms.ts / downloadDMS / invalid metadata", - type: "message", - }); - return; - } - - const cookie = await (async (): Promise => { - const authSetting = store.get("auth") as AuthType | undefined; - if (authSetting?.type === "browser" && authSetting.profile) { - return await getCookies(authSetting.profile); - } - return undefined; - })(); - const accessRightsReq = await fetch( - `https://nvapi.nicovideo.jp/v1/watch/${metadata.data.video.id}/access-rights/hls?actionTrackId=0_0`, - { - headers: { - Host: "nvapi.nicovideo.jp", - Cookie: cookie ? convertToEncodedCookie(cookie) : "", - "Content-Type": "application/json", - "X-Request-With": "https://www.nicovideo.jp", - "X-Access-Right-Key": metadata.data.media.domand.accessRightKey, - "X-Frontend-Id": "6", - "X-Frontend-Version": "0", - }, - body: JSON.stringify({ outputs: [format.format] }), - method: "POST", - }, - ); - const accessRights = (await accessRightsReq.json()) as unknown; - if (!typeGuard.niconico.v1AccessRightsHls(accessRights)) { - sendMessageToController({ - title: "セッションの作成に失敗しました", - message: `以下のテキストともに開発者までお問い合わせください -"${JSON.stringify(accessRights)}" -lib/niconico/dms.ts / downloadDMS / invalid accessRights`, - type: "message", - }); - return; - } - const parsedCookie = parseCookie(...accessRightsReq.headers.getSetCookie()); - - const manifestReq = await fetchWithCookie( - accessRights.data.contentUrl, - undefined, - parsedCookie, - ); - const manifestRaw = await manifestReq.text(); - const manifests = Array.from( - manifestRaw.match(/https:\/\/.+?\.nicovideo\.jp\/.+?\.m3u8[^"]+/g) ?? [], - ); - const getManifestUrl = (format: string): string | undefined => { - for (const url of manifests) { - if (url.match(`/${format}.m3u8`)) { - return url; - } - } - return undefined; - }; - - const getManifests = async ( - format: string, - ): Promise<{ segments: string[]; key: string; manifest: string }> => { - const manifestUrl = getManifestUrl(format); - if (!manifestUrl) { - throw new Error("failed to get manifest"); - } - const manifestReq = await fetchWithCookie( - manifestUrl, - undefined, - parsedCookie, - ); - const manifest = await manifestReq.text(); - return { ...getSegments(manifest), manifest }; - }; - - const downloadSegments = async ( - dir: string, - format: string, - segments: string[], - key: string, - manifest: string, - progress: () => void, - ): Promise => { - const replaceMap = new Map(); - for (const segment of segments) { - const segmentUrl = new URL(segment); - const outputPath = path.join( - dir, - segmentUrl.pathname.split("/").pop() ?? "", - ); - await downloadFile(segment, outputPath, { - Cookie: formatCookies(filterCookies(parsedCookie, segment), false).join( - ";", - ), - }); - progress(); - replaceMap.set(segment, outputPath); - } - const keyUrl = new URL(key); - const keyPath = path.join(dir, keyUrl.pathname.split("/").pop() ?? ""); - await downloadFile(key, keyPath, { - Cookie: formatCookies(filterCookies(parsedCookie, key), false).join(";"), - }); - replaceMap.set(key, keyPath); - replaceMap.forEach((value, key) => { - manifest = manifest.replace(key, value.replace(/\\/g, "\\\\")); - }); - const manifestPath = path.join(dir, format + ".m3u8"); - fs.writeFileSync(manifestPath, manifest); - return manifestPath.replace(/\\/g, "\\\\"); - }; - - let tmpDir: string = ""; - let result: SpawnResult | undefined; - let cancelled = false; - try { - tmpDir = fs.mkdtempSync( - path.join(path.dirname(targetPath), path.basename(targetPath)), - ); - const { - segments: videoSegments, - key: videoKey, - manifest: videoManifest, - } = await getManifests(format.format[0]); - const { - segments: audioSegments, - key: audioKey, - manifest: audioManifest, - } = await getManifests(format.format[1]); - const totalSegments = videoSegments.length + audioSegments.length; - let downloadedSegments = 0; - const onProgress = (): void => { - downloadedSegments++; - progress(totalSegments, downloadedSegments); - }; - const videoManifestPath = await downloadSegments( - tmpDir, - format.format[0], - videoSegments, - videoKey, - videoManifest, - onProgress, - ); - const audioManifestPath = await downloadSegments( - tmpDir, - format.format[1], - audioSegments, - audioKey, - audioManifest, - onProgress, - ); - - const _spawn = spawn( - ffmpegPath, - [ - "-allowed_extensions", - "ALL", - "-i", - videoManifestPath, - "-allowed_extensions", - "ALL", - "-i", - audioManifestPath, - "-c", - "copy", - "-map", - "0:v:0", - "-map", - "1:a:0", - targetPath, - "-y", - "-loglevel", - "debug", - ], - undefined, - (data) => console.log(data), - (data) => console.log(data), - ); - stop = () => { - cancelled = true; - _spawn.stop(); - }; - result = await _spawn.promise; - } catch { - if (!cancelled) { - sendMessageToController({ - title: "動画のダウンロードに失敗しました", - message: - "時間をおいて再度試してみてください\n解決しない場合は開発者までお問い合わせください\nlib/niconico/dms.ts / downloadDMS / failed to download", - type: "message", - }); - } - } finally { - try { - if (tmpDir) { - fs.rmSync(tmpDir, { recursive: true }); - } - } catch (e) { - console.error( - `An error has occurred while removing the temp folder at ${tmpDir}. Please remove it manually. Error: ${e}`, - ); - } - } - return result; -}; - -const interruptDMS = (): void => { - stop?.(); -}; - -const fetchWithCookie = ( - input: string, - init: RequestInit | undefined, - cookies: ParsedCookie[], -): Promise => { - return fetch(input, { - ...init, - headers: { - ...init?.headers, - Cookie: formatCookies(filterCookies(cookies, input), false).join(";"), - }, - }); -}; - -const getSegments = (manifest: string): { segments: string[]; key: string } => { - const key = manifest.match( - /https:\/\/.+?\.nicovideo\.jp\/.+?\.key[^"\n]*/g, - )?.[0]; - if (!key) { - throw new Error("failed to get key"); - } - return { - segments: Array.from( - manifest.match(/https:\/\/.+?\.nicovideo\.jp\/.+?\.cmf[av][^"\n]*/g) ?? - [], - ), - key, - }; -}; - -const downloadFile = async ( - url: string, - path: string, - headers: { [key: string]: string }, - progress?: (progress: number) => void, -): Promise => { - const file = fs.createWriteStream(path); - return axios(url, { - method: "get", - headers, - responseType: "stream", - onDownloadProgress: (status) => - progress?.(status.loaded / (status.total ?? 1)), - }).then((res: AxiosResponse) => { - return new Promise((resolve, reject) => { - res.data.pipe(file); - let error: Error; - file.on("error", (err) => { - error = err; - file.close(); - reject(err); - }); - file.on("close", () => { - if (!error) { - resolve(); - } - }); - }); - }); -}; - -export { downloadDMS, interruptDMS }; diff --git a/electron/lib/niconico/dmc.ts b/electron/lib/niconico/dmc.ts index 0b795ca..4ff87b5 100644 --- a/electron/lib/niconico/dmc.ts +++ b/electron/lib/niconico/dmc.ts @@ -3,10 +3,8 @@ import type { TDMCFormat } from "@/@types/queue"; import type { SpawnResult } from "@/@types/spawn"; import { sendMessageToController } from "../../controller-window"; -import { ffmpegPath } from "../../ffmpeg"; import { typeGuard } from "../../type-guard"; -import { time2num } from "../../utils/time"; -import { spawn } from "../spawn"; +import { DownloadM3U8 } from "../../utils/ffmpeg"; let stop: (() => void) | undefined; @@ -14,7 +12,7 @@ const downloadDMC = async ( metadata: TWatchV3Metadata, format: TDMCFormat, path: string, - progress: (total: number, downloaded: number) => void, + progress: (total: number, downloaded: number, eta: number) => void, ): Promise => { if (!typeGuard.niconico.v3DMC(metadata)) { if (typeGuard.niconico.v3DMS(metadata)) { @@ -100,31 +98,17 @@ const downloadDMC = async ( })(); }, 30 * 1000); - let total = 0, - downloaded = 0; - const onData = (data: string): void => { - let match; - if ((match = data.match(/Duration: ([0-9:.]+),/))) { - total = time2num(match[1]); - } else if ((match = data.match(/time=([0-9:.]+) /))) { - downloaded = time2num(match[1]); - } - progress(total, downloaded); - }; - const _spawn = spawn( - ffmpegPath, + const { stop: _stop, promise } = DownloadM3U8( ["-i", lastSession.session.content_uri, "-c", "copy", path, "-y"], - undefined, - onData, - onData, + progress, ); let cancelled = false; stop = () => { + _stop(); cancelled = true; - _spawn.stop(); }; try { - const result = await _spawn.promise; + const result = await promise; clearInterval(heartbeatInterval); const delReq = await fetch( `https://api.dmc.nico/api/sessions/${lastSession.session.id}?_format=json&_method=DELETE`, diff --git a/electron/lib/niconico/dms.ts b/electron/lib/niconico/dms.ts index 4634cf8..2d70f93 100644 --- a/electron/lib/niconico/dms.ts +++ b/electron/lib/niconico/dms.ts @@ -5,17 +5,15 @@ import type { AuthType } from "@/@types/setting"; import type { SpawnResult } from "@/@types/spawn"; import { sendMessageToController } from "../../controller-window"; -import { ffmpegPath } from "../../ffmpeg"; import { store } from "../../store"; import { typeGuard } from "../../type-guard"; -import { time2num } from "../../utils/time"; +import { DownloadM3U8 } from "../../utils/ffmpeg"; import { convertToEncodedCookie, formatCookies, getCookies, parseCookie, } from "../cookie"; -import { spawn } from "../spawn"; let stop: (() => void) | undefined; @@ -23,7 +21,7 @@ const downloadDMS = async ( metadata: TWatchV3Metadata, format: TDMSFormat, targetPath: string, - progress: (total: number, downloaded: number) => void, + progress: (total: number, downloaded: number, eta: number) => void, ): Promise => { if (!typeGuard.niconico.v3DMS(metadata)) { if (typeGuard.niconico.v3DMC(metadata)) { @@ -80,19 +78,7 @@ lib/niconico/dms.ts / downloadDMS / invalid accessRights`, } const parsedCookie = parseCookie(...accessRightsReq.headers.getSetCookie()); - let total = 0, - downloaded = 0; - const onData = (data: string): void => { - let match; - if ((match = data.match(/Duration: ([0-9:.]+),/))) { - total = time2num(match[1]); - } else if ((match = data.match(/time=([0-9:.]+) /))) { - downloaded = time2num(match[1]); - } - progress(total, downloaded); - }; - const _spawn = spawn( - ffmpegPath, + const { stop: _stop, promise } = DownloadM3U8( [ "-cookies", formatCookies(parsedCookie, true).join("\n"), @@ -103,17 +89,15 @@ lib/niconico/dms.ts / downloadDMS / invalid accessRights`, targetPath, "-y", ], - undefined, - onData, - onData, + progress, ); let cancelled = false; stop = () => { cancelled = true; - _spawn.stop(); + _stop(); }; try { - return await _spawn.promise; + return await promise; } catch { if (!cancelled) { sendMessageToController({ diff --git a/electron/lib/niconico/video.ts b/electron/lib/niconico/video.ts index ebdc2e0..086c56e 100644 --- a/electron/lib/niconico/video.ts +++ b/electron/lib/niconico/video.ts @@ -10,7 +10,7 @@ const download = async ( nicoId: string, format: TRemoteMovieItemFormat, path: string, - progress: (total: number, downloaded: number) => void, + progress: (total: number, downloaded: number, eta: number) => void, ): Promise => { const metadata = await getMetadata(nicoId); if (!metadata) { diff --git a/electron/lib/spawn.ts b/electron/lib/spawn.ts index c17e308..f7eb628 100644 --- a/electron/lib/spawn.ts +++ b/electron/lib/spawn.ts @@ -15,11 +15,11 @@ function spawn( p.stdout.setEncoding("utf-8"); p.stdout.on("data", (data: string) => { stdout += data; - onData && onData(data.toString().trim()); + onData?.(data.toString().trim()); }); p.stderr.on("data", (data: string) => { stderr += data; - onError && onError(data.toString().trim()); + onError?.(data.toString().trim()); }); return { promise: new Promise((resolve, reject) => { diff --git a/electron/queue.ts b/electron/queue.ts index 3f54094..6b55bf9 100644 --- a/electron/queue.ts +++ b/electron/queue.ts @@ -7,7 +7,8 @@ import type { ApiResponseLoad } from "@/@types/response.renderer"; import { sendMessageToController } from "./controller-window"; import { inputStream, interruptConverter, startConverter } from "./converter"; -import { encodeJson } from "./lib/json"; +import { encodeError } from "./lib/json"; +import { getLogger } from "./lib/log"; import { download, downloadComment, @@ -17,6 +18,8 @@ import { interruptDMC } from "./lib/niconico/dmc"; import { interruptDMS } from "./lib/niconico/dms"; import { createRendererWindow, sendMessageToRenderer } from "./renderer-window"; +const logger = getLogger("[queue]"); + const queueList: Queue[] = []; const queueLists: QueueLists = { convert: [], @@ -77,12 +80,12 @@ const startMovieDownload = async (): Promise => { ); targetQueue.status = "completed"; } catch (e) { - console.error(e); + logger.error("failed to download movie", e); targetQueue.status = "fail"; sendMessageToController({ type: "message", title: "動画のダウンロード中にエラーが発生しました", - message: `エラー内容:\n${encodeJson(e)}`, + message: `エラー内容:\n${encodeError(e)}`, }); } sendProgress(); @@ -109,11 +112,12 @@ const startCommentDownload = async (): Promise => { }); targetQueue.status = "completed"; } catch (e) { + logger.error("failed to download comment", e); targetQueue.status = "fail"; sendMessageToController({ type: "message", title: "コメントのダウンロード中にエラーが発生しました", - message: `エラー内容:\n${encodeJson(e)}`, + message: `エラー内容:\n${encodeError(e)}`, }); } sendProgress(); @@ -133,18 +137,28 @@ const startConvert = async (): Promise => { const queue = queueList.filter((i) => i.id === queueId)[0]; if (queue?.status !== "completed") return; } - processingQueue = queued[0]; - processingQueue.status = "processing"; - processingQueue.progress = 0; - lastFrame = 0; - createRendererWindow(); - sendProgress(); - await startConverter(queued[0]); + try { + processingQueue = queued[0]; + processingQueue.status = "processing"; + processingQueue.progress = 0; + lastFrame = 0; + createRendererWindow(); + sendProgress(); + await startConverter(queued[0]); + if (processingQueue.status === "processing") + processingQueue.status = "completed"; + } catch (e) { + logger.error("failed to convert", e); + processingQueue.status = "fail"; + sendMessageToController({ + type: "message", + title: "書き出し中にエラーが発生しました", + message: `エラー内容:\n${encodeError(e)}`, + }); + } sendMessageToRenderer({ type: "end", }); - if (processingQueue.status === "processing") - processingQueue.status = "completed"; sendProgress(); void startConvert(); }; diff --git a/electron/utils/ffmpeg.ts b/electron/utils/ffmpeg.ts new file mode 100644 index 0000000..2193a5a --- /dev/null +++ b/electron/utils/ffmpeg.ts @@ -0,0 +1,38 @@ +import type { SpawnResult } from "@/@types/spawn"; + +import { ffmpegPath } from "../assets"; +import { spawn } from "../lib/spawn"; +import { time2num } from "./time"; + +const DownloadM3U8 = ( + args: string[], + progress: (total: number, downloaded: number, eta: number) => void, +): { + stop: () => void; + promise: Promise; +} => { + let total = 0, + downloaded = 0, + speed = -1; + const onData = (data: string): void => { + let match; + if ((match = data.match(/Duration: ([0-9:.]+),/))) { + total = time2num(match[1]); + } + if ((match = data.match(/time=([0-9:.]+) /))) { + downloaded = time2num(match[1]); + } + if ((match = data.match(/speed=([0-9.]+)x /))) { + speed = Number(match[1]); + } + const eta = speed < 0 ? -1 : (total - downloaded) / speed; + progress(total, downloaded, eta); + }; + const _spawn = spawn(ffmpegPath, args, undefined, onData, onData); + const stop = (): void => { + _spawn.stop(); + }; + return { stop, promise: _spawn.promise }; +}; + +export { DownloadM3U8 }; diff --git a/package.json b/package.json index 7cd8fb2..02b32e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "niconicomments-convert", "private": false, - "version": "0.0.22", + "version": "0.0.23", "type": "commonjs", "license": "MIT", "main": "build/electron/electron.js", @@ -29,6 +29,7 @@ "@mui/material": "^5.14.20", "@xpadev-net/niconicomments": "^0.2.67", "axios": "^1.6.2", + "electron-log": "^5.0.2", "electron-store": "^8.1.0", "jotai": "^2.6.0", "jsdom": "^23.0.1", diff --git a/yarn.lock b/yarn.lock index ce11563..da763cc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2254,6 +2254,11 @@ electron-builder@^24.9.1: simple-update-notifier "2.0.0" yargs "^17.6.2" +electron-log@^5.0.2: + version "5.0.2" + resolved "https://registry.yarnpkg.com/electron-log/-/electron-log-5.0.2.tgz#6dae26cb7fce9feaa4605d575e02fee75da7b085" + integrity sha512-uzUXpUGZ5lJeCEIn4Hrxt6zQWiURu+EbFyTul0Y81huc6UrvWXIOMx4WxNdaGJYWPk9YLWrz2dQf3894MoZKXw== + electron-publish@24.8.1: version "24.8.1" resolved "https://registry.yarnpkg.com/electron-publish/-/electron-publish-24.8.1.tgz#4216740372bf4297a429543402a1a15ce8c3560b"