diff --git a/src/i18n/resources/de.json b/src/i18n/resources/de.json index e760ae1369..4b60a3d8c7 100644 --- a/src/i18n/resources/de.json +++ b/src/i18n/resources/de.json @@ -412,7 +412,22 @@ "choose-download-folder": "Downloadordner wählen", "download-playlist": "Wiedergabeliste herunterladen", "presets": "Voreinstellungen", - "skip-existing": "Vorhandene Dateien überspringen" + "skip-existing": "Vorhandene Dateien überspringen", + "download-finish-settings": { + "label": "Song am Ende runterladen", + "submenu": { + "enabled": "Aktiviert", + "mode": "Zeitmodus", + "seconds": "Sekunden", + "percent": "Prozent", + "advanced": "Erweitert" + }, + "prompt": { + "title": "Konfiguriere wann runtergeladen werden soll", + "last-seconds": "Letzten x Sekunden", + "last-percent": "Nach x Prozent" + } + } }, "name": "Downloader", "renderer": { diff --git a/src/i18n/resources/en.json b/src/i18n/resources/en.json index e7be77c0d2..2d75479d3f 100644 --- a/src/i18n/resources/en.json +++ b/src/i18n/resources/en.json @@ -412,7 +412,22 @@ "choose-download-folder": "Choose download folder", "download-playlist": "Download playlist", "presets": "Presets", - "skip-existing": "Skip existing files" + "skip-existing": "Skip existing files", + "download-finish-settings": { + "label": "Download on finish", + "submenu": { + "enabled": "Enabled", + "mode": "Time mode", + "seconds": "Seconds", + "percent": "Percent", + "advanced": "Advanced" + }, + "prompt": { + "title": "Configure when to download", + "last-seconds": "Last x seconds", + "last-percent": "After x percent" + } + } }, "name": "Downloader", "renderer": { diff --git a/src/plugins/downloader/index.ts b/src/plugins/downloader/index.ts index bad6912258..4898f590f3 100644 --- a/src/plugins/downloader/index.ts +++ b/src/plugins/downloader/index.ts @@ -11,6 +11,13 @@ import { t } from '@/i18n'; export type DownloaderPluginConfig = { enabled: boolean; downloadFolder?: string; + downloadOnFinish?: { + enabled: boolean; + seconds: number; + percent: number; + mode: 'percent' | 'seconds'; + folder?: string; + }; selectedPreset: string; customPresetSetting: Preset; skipExisting: boolean; @@ -20,6 +27,13 @@ export type DownloaderPluginConfig = { export const defaultConfig: DownloaderPluginConfig = { enabled: false, downloadFolder: undefined, + downloadOnFinish: { + enabled: false, + seconds: 20, + percent: 10, + mode: 'seconds', + folder: undefined, + }, selectedPreset: 'mp3 (256kbps)', // Selected preset customPresetSetting: DefaultPresetList['mp3 (256kbps)'], // Presets skipExisting: false, diff --git a/src/plugins/downloader/main/index.ts b/src/plugins/downloader/main/index.ts index 093432145a..84b4f1e8cc 100644 --- a/src/plugins/downloader/main/index.ts +++ b/src/plugins/downloader/main/index.ts @@ -1,12 +1,8 @@ -import { - existsSync, - mkdirSync, - writeFileSync, -} from 'node:fs'; +import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { randomBytes } from 'node:crypto'; -import { app, BrowserWindow, dialog } from 'electron'; +import { app, BrowserWindow, dialog, ipcMain } from 'electron'; import { ClientType, Innertube, @@ -29,7 +25,12 @@ import { import { fetchFromGenius } from '@/plugins/lyrics-genius/main'; import { isEnabled } from '@/config/plugins'; -import { cleanupName, getImage, MediaType, type SongInfo } from '@/providers/song-info'; +import registerCallback, { + cleanupName, + getImage, + MediaType, + type SongInfo, +} from '@/providers/song-info'; import { getNetFetchAsFetch } from '@/plugins/utils/main'; import { t } from '@/i18n'; @@ -114,6 +115,8 @@ export const onMainLoad = async ({ ipc.handle('download-playlist-request', async (url: string) => downloadPlaylist(url), ); + + downloadSongOnFinishSetup({ ipc, getConfig }); }; export const onConfigChange = (newConfig: DownloaderPluginConfig) => { @@ -162,6 +165,48 @@ export async function downloadSongFromId( } } +function downloadSongOnFinishSetup({ + ipc, +}: Pick, 'ipc' | 'getConfig'>) { + let currentUrl: string | undefined; + let duration: number | undefined; + let time = 0; + + registerCallback((songInfo: SongInfo) => { + if ( + !songInfo.isPaused && + songInfo.url !== currentUrl && + config.downloadOnFinish?.enabled + ) { + if (typeof currentUrl === 'string' && duration && duration > 0) { + if ( + config.downloadOnFinish.mode === 'seconds' && + duration - time <= config.downloadOnFinish.seconds + ) { + downloadSong(currentUrl, config.downloadOnFinish.folder ?? config.downloadFolder); + } else if ( + config.downloadOnFinish.mode === 'percent' && + time >= duration * (config.downloadOnFinish.percent / 100) + ) { + downloadSong(currentUrl, config.downloadOnFinish.folder ?? config.downloadFolder); + } + } + + currentUrl = songInfo.url; + duration = songInfo.songDuration; + time = 0; + } + }); + + ipcMain.on('ytmd:player-api-loaded', () => { + ipc.send('ytmd:setup-time-changed-listener'); + }); + + ipcMain.on('ytmd:time-changed', (_, t: number) => { + if (t > time) time = t; + }); +} + async function downloadSongUnsafe( isId: boolean, idOrUrl: string, @@ -375,7 +420,12 @@ async function iterableStreamToProcessedUint8Array( 'writeFile', safeVideoName, Buffer.concat( - await downloadChunks(stream, contentLength, sendFeedback, increasePlaylistProgress), + await downloadChunks( + stream, + contentLength, + sendFeedback, + increasePlaylistProgress, + ), ), ); @@ -516,10 +566,11 @@ export async function downloadPlaylist(givenUrl?: string | URL) { return; } - if (!playlist || !playlist.items || playlist.items.length === 0) { + if (!playlist || !playlist.items || playlist.items.length === 0 || !playlist.header || !('title' in playlist.header)) { sendError( new Error(t('plugins.downloader.backend.feedback.playlist-is-empty')), ); + return; } const normalPlaylistTitle = playlist.header?.title?.text; diff --git a/src/plugins/downloader/main/utils.ts b/src/plugins/downloader/main/utils.ts index 10ecbfdec6..cd44e7b099 100644 --- a/src/plugins/downloader/main/utils.ts +++ b/src/plugins/downloader/main/utils.ts @@ -1,8 +1,8 @@ import { app, BrowserWindow } from 'electron'; import is from 'electron-is'; -export const getFolder = (customFolder: string) => - customFolder || app.getPath('downloads'); +export const getFolder = (customFolder?: string) => + customFolder ?? app.getPath('downloads'); export const sendFeedback = (win: BrowserWindow, message?: unknown) => { win.webContents.send('downloader-feedback', message); diff --git a/src/plugins/downloader/menu.ts b/src/plugins/downloader/menu.ts index 995e7d8f2e..e075fb0e9c 100644 --- a/src/plugins/downloader/menu.ts +++ b/src/plugins/downloader/menu.ts @@ -1,4 +1,6 @@ import { dialog } from 'electron'; +import prompt from 'custom-electron-prompt'; +import { deepmerge } from 'deepmerge-ts'; import { downloadPlaylist } from './main'; import { getFolder } from './main/utils'; @@ -6,11 +8,13 @@ import { DefaultPresetList } from './types'; import { t } from '@/i18n'; +import promptOptions from '@/providers/prompt-options'; + +import { type DownloaderPluginConfig, defaultConfig } from './index'; + import type { MenuContext } from '@/types/contexts'; import type { MenuTemplate } from '@/menu'; -import type { DownloaderPluginConfig } from './index'; - export const onMenu = async ({ getConfig, setConfig, @@ -18,6 +22,142 @@ export const onMenu = async ({ const config = await getConfig(); return [ + { + label: t('plugins.downloader.menu.download-finish-settings.label'), + type: 'submenu', + submenu: [ + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.enabled', + ), + type: 'checkbox', + checked: config.downloadOnFinish?.enabled ?? false, + click(item) { + setConfig({ + downloadOnFinish: { + ...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, + enabled: item.checked, + }, + }); + }, + }, + { + type: 'separator', + }, + { + label: t('plugins.downloader.menu.choose-download-folder'), + click() { + const result = dialog.showOpenDialogSync({ + properties: ['openDirectory', 'createDirectory'], + defaultPath: getFolder(config.downloadOnFinish?.folder ?? config.downloadFolder), + }); + if (result) { + setConfig({ + downloadOnFinish: { + ...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, + folder: result[0], + } + }); + } + }, + }, + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.mode', + ), + type: 'submenu', + submenu: [ + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.seconds', + ), + type: 'radio', + checked: config.downloadOnFinish?.mode === 'seconds', + click() { + setConfig({ + downloadOnFinish: { + ...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, + mode: 'seconds', + }, + }); + }, + }, + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.percent', + ), + type: 'radio', + checked: config.downloadOnFinish?.mode === 'percent', + click() { + setConfig({ + downloadOnFinish: { + ...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, + mode: 'percent', + }, + }); + }, + }, + ], + }, + { + label: t( + 'plugins.downloader.menu.download-finish-settings.submenu.advanced', + ), + async click() { + const res = await prompt({ + title: t( + 'plugins.downloader.menu.download-finish-settings.prompt.title', + ), + type: 'multiInput', + multiInputOptions: [ + { + label: t( + 'plugins.downloader.menu.download-finish-settings.prompt.last-seconds', + ), + inputAttrs: { + type: 'number', + required: true, + min: '0', + step: '1', + }, + value: config.downloadOnFinish?.seconds ?? defaultConfig.downloadOnFinish!.seconds, + }, + { + label: t( + 'plugins.downloader.menu.download-finish-settings.prompt.last-percent', + ), + inputAttrs: { + type: 'number', + required: true, + min: '1', + max: '100', + step: '1', + }, + value: config.downloadOnFinish?.percent ?? defaultConfig.downloadOnFinish!.percent, + }, + ], + ...promptOptions(), + height: 240, + resizable: true, + }).catch(console.error); + + if (!res) { + return undefined; + } + + setConfig({ + downloadOnFinish: { + ...deepmerge(defaultConfig.downloadOnFinish, config.downloadOnFinish)!, + seconds: Number(res[0]), + percent: Number(res[1]), + }, + }); + return; + }, + }, + ], + }, + { label: t('plugins.downloader.menu.download-playlist'), click: () => downloadPlaylist(), diff --git a/tsconfig.json b/tsconfig.json index 53705d4228..ea24384a38 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ "allowSyntheticDefaultImports": true, "esModuleInterop": true, "resolveJsonModule": true, - "moduleResolution": "bundler", + "moduleResolution": "node", "jsx": "preserve", "jsxImportSource": "solid-js", "baseUrl": ".",