From 45cfdc00de6945472b31ee2eca08b3ad8db2eb51 Mon Sep 17 00:00:00 2001 From: Subhamoy Biswas Date: Sun, 16 Nov 2025 23:11:17 +0530 Subject: [PATCH] refactor: separated download functions --- package-lock.json | 1 + package.json | 1 + src/App.tsx | 1456 ++++++-------------------- src/helpers/use-app-updater.ts | 5 +- src/helpers/use-downloader.ts | 858 +++++++++++++++ src/pages/downloader.tsx | 29 +- src/providers/appContextProvider.tsx | 38 +- src/utils.ts | 16 +- 8 files changed, 1231 insertions(+), 1173 deletions(-) create mode 100644 src/helpers/use-downloader.ts diff --git a/package-lock.json b/package-lock.json index 2495eb4..e35960a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,6 +54,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "es-toolkit": "^1.41.0", "input-otp": "^1.4.2", "lucide-react": "^0.553.0", "next-themes": "^0.4.6", diff --git a/package.json b/package.json index abcd624..2229e09 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "cmdk": "^1.1.1", "date-fns": "^4.1.0", "embla-carousel-react": "^8.6.0", + "es-toolkit": "^1.41.0", "input-otp": "^1.4.2", "lucide-react": "^0.553.0", "next-themes": "^0.4.6", diff --git a/src/App.tsx b/src/App.tsx index a0ebb08..59844da 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,16 +1,13 @@ import { ThemeProvider } from "@/providers/themeProvider"; import { TooltipProvider } from "@/components/ui/tooltip"; import { AppContext } from "@/providers/appContextProvider"; -import { DownloadState } from "@/types/download"; -import { invoke } from "@tauri-apps/api/core"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { arch, exeExtension } from "@tauri-apps/plugin-os"; import { downloadDir, join, resourceDir, tempDir } from "@tauri-apps/api/path"; import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store"; -import { determineFileType, generateVideoId, isObjEmpty, parseProgressLine } from "@/utils"; +import { isObjEmpty} from "@/utils"; import { Command } from "@tauri-apps/plugin-shell"; -import { RawVideoInfo } from "@/types/video"; -import { useDeleteDownloadState, useSaveDownloadState, useSavePlaylistInfo, useSaveVideoInfo, useUpdateDownloadFilePath, useUpdateDownloadStatus } from "@/services/mutations"; +import { useUpdateDownloadStatus } from "@/services/mutations"; import { useQueryClient } from "@tanstack/react-query"; import { useFetchAllDownloadStates, useFetchAllkVPairs, useFetchAllSettings } from "@/services/queries"; import { config } from "@/config"; @@ -27,546 +24,316 @@ import useAppUpdater from "@/helpers/use-app-updater"; import { Toaster as Sonner } from "@/components/ui/sonner"; import { toast } from "sonner"; import { useLogger } from "@/helpers/use-logger"; -import { DownloadConfiguration } from "@/types/settings"; -import { ulid } from "ulid"; -import { sendNotification } from '@tauri-apps/plugin-notification'; +import useDownloader from "@/helpers/use-downloader"; export default function App({ children }: { children: React.ReactNode }) { - const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates(); - const { data: settings, isSuccess: isSuccessFetchingSettings } = useFetchAllSettings(); - const { data: kvPairs, isSuccess: isSuccessFetchingKvPairs } = useFetchAllkVPairs(); - const [isSettingsStatePropagated, setIsSettingsStatePropagated] = useState(false); - const [isKvPairsStatePropagated, setIsKvPairsStatePropagated] = useState(false); - const globalDownloadStates = useDownloadStatesStore((state) => state.downloadStates); - const setDownloadStates = useDownloadStatesStore((state) => state.setDownloadStates); - const setPath = useBasePathsStore((state) => state.setPath); + const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates(); + const { data: settings, isSuccess: isSuccessFetchingSettings } = useFetchAllSettings(); + const { data: kvPairs, isSuccess: isSuccessFetchingKvPairs } = useFetchAllkVPairs(); + const [isSettingsStatePropagated, setIsSettingsStatePropagated] = useState(false); + const [isKvPairsStatePropagated, setIsKvPairsStatePropagated] = useState(false); + const globalDownloadStates = useDownloadStatesStore((state) => state.downloadStates); + const setDownloadStates = useDownloadStatesStore((state) => state.setDownloadStates); + const setPath = useBasePathsStore((state) => state.setPath); - const ffmpegPath = useBasePathsStore((state) => state.ffmpegPath); - const tempDownloadDirPath = useBasePathsStore((state) => state.tempDownloadDirPath); - const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath); + const setIsUsingDefaultSettings = useSettingsPageStatesStore((state) => state.setIsUsingDefaultSettings); + const setSettingsKey = useSettingsPageStatesStore((state) => state.setSettingsKey); + const appVersion = useSettingsPageStatesStore(state => state.appVersion); + const ytDlpVersion = useSettingsPageStatesStore(state => state.ytDlpVersion); + const setYtDlpVersion = useSettingsPageStatesStore((state) => state.setYtDlpVersion); + const setIsFetchingYtDlpVersion = useSettingsPageStatesStore((state) => state.setIsFetchingYtDlpVersion); + const setAppVersion = useSettingsPageStatesStore((state) => state.setAppVersion); + const setIsFetchingAppVersion = useSettingsPageStatesStore((state) => state.setIsFetchingAppVersion); + const { + ytdlp_auto_update: YTDLP_AUTO_UPDATE, + ytdlp_update_channel: YTDLP_UPDATE_CHANNEL, + download_dir: DOWNLOAD_DIR, + theme: APP_THEME, + color_scheme: APP_COLOR_SCHEME, + } = useSettingsPageStatesStore(state => state.settings); - const setSearchPid = useCurrentVideoMetadataStore((state) => state.setSearchPid); + const isErrored = useDownloaderPageStatesStore((state) => state.isErrored); + const isErrorExpected = useDownloaderPageStatesStore((state) => state.isErrorExpected); + const erroredDownloadId = useDownloaderPageStatesStore((state) => state.erroredDownloadId); + const setIsErrored = useDownloaderPageStatesStore((state) => state.setIsErrored); + const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected); + const setErroredDownloadId = useDownloaderPageStatesStore((state) => state.setErroredDownloadId); - const setIsUsingDefaultSettings = useSettingsPageStatesStore((state) => state.setIsUsingDefaultSettings); - const setSettingsKey = useSettingsPageStatesStore((state) => state.setSettingsKey); - const appVersion = useSettingsPageStatesStore(state => state.appVersion); - const ytDlpVersion = useSettingsPageStatesStore(state => state.ytDlpVersion); - const setYtDlpVersion = useSettingsPageStatesStore((state) => state.setYtDlpVersion); - const setIsFetchingYtDlpVersion = useSettingsPageStatesStore((state) => state.setIsFetchingYtDlpVersion); - const setAppVersion = useSettingsPageStatesStore((state) => state.setAppVersion); - const setIsFetchingAppVersion = useSettingsPageStatesStore((state) => state.setIsFetchingAppVersion); - const YTDLP_AUTO_UPDATE = useSettingsPageStatesStore(state => state.settings.ytdlp_auto_update); - const YTDLP_UPDATE_CHANNEL = useSettingsPageStatesStore(state => state.settings.ytdlp_update_channel); - const APP_THEME = useSettingsPageStatesStore(state => state.settings.theme); - const APP_COLOR_SCHEME = useSettingsPageStatesStore(state => state.settings.color_scheme); - const MAX_PARALLEL_DOWNLOADS = useSettingsPageStatesStore(state => state.settings.max_parallel_downloads); - const MAX_RETRIES = useSettingsPageStatesStore(state => state.settings.max_retries); - const DOWNLOAD_DIR = useSettingsPageStatesStore(state => state.settings.download_dir); - const PREFER_VIDEO_OVER_PLAYLIST = useSettingsPageStatesStore(state => state.settings.prefer_video_over_playlist); - const STRICT_DOWNLOADABILITY_CHECK = useSettingsPageStatesStore(state => state.settings.strict_downloadablity_check); - const USE_PROXY = useSettingsPageStatesStore(state => state.settings.use_proxy); - const PROXY_URL = useSettingsPageStatesStore(state => state.settings.proxy_url); - const USE_RATE_LIMIT = useSettingsPageStatesStore(state => state.settings.use_rate_limit); - const RATE_LIMIT = useSettingsPageStatesStore(state => state.settings.rate_limit); - const VIDEO_FORMAT = useSettingsPageStatesStore(state => state.settings.video_format); - const AUDIO_FORMAT = useSettingsPageStatesStore(state => state.settings.audio_format); - const ALWAYS_REENCODE_VIDEO = useSettingsPageStatesStore(state => state.settings.always_reencode_video); - const EMBED_VIDEO_METADATA = useSettingsPageStatesStore(state => state.settings.embed_video_metadata); - const EMBED_AUDIO_METADATA = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata); - const EMBED_VIDEO_THUMBNAIL = useSettingsPageStatesStore(state => state.settings.embed_video_thumbnail); - const EMBED_AUDIO_THUMBNAIL = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail); - const USE_COOKIES = useSettingsPageStatesStore(state => state.settings.use_cookies); - const IMPORT_COOKIES_FROM = useSettingsPageStatesStore(state => state.settings.import_cookies_from); - const COOKIES_BROWSER = useSettingsPageStatesStore(state => state.settings.cookies_browser); - const COOKIES_FILE = useSettingsPageStatesStore(state => state.settings.cookies_file); - const USE_SPONSORBLOCK = useSettingsPageStatesStore(state => state.settings.use_sponsorblock); - const SPONSORBLOCK_MODE = useSettingsPageStatesStore(state => state.settings.sponsorblock_mode); - const SPONSORBLOCK_REMOVE = useSettingsPageStatesStore(state => state.settings.sponsorblock_remove); - const SPONSORBLOCK_MARK = useSettingsPageStatesStore(state => state.settings.sponsorblock_mark); - const SPONSORBLOCK_REMOVE_CATEGORIES = useSettingsPageStatesStore(state => state.settings.sponsorblock_remove_categories); - const SPONSORBLOCK_MARK_CATEGORIES = useSettingsPageStatesStore(state => state.settings.sponsorblock_mark_categories); - const USE_ARIA2 = useSettingsPageStatesStore(state => state.settings.use_aria2); - const USE_FORCE_INTERNET_PROTOCOL = useSettingsPageStatesStore(state => state.settings.use_force_internet_protocol); - const FORCE_INTERNET_PROTOCOL = useSettingsPageStatesStore(state => state.settings.force_internet_protocol); - const USE_CUSTOM_COMMANDS = useSettingsPageStatesStore(state => state.settings.use_custom_commands); - const CUSTOM_COMMANDS = useSettingsPageStatesStore(state => state.settings.custom_commands); - const FILENAME_TEMPLATE = useSettingsPageStatesStore(state => state.settings.filename_template); - const DEBUG_MODE = useSettingsPageStatesStore(state => state.settings.debug_mode); - const LOG_VERBOSE = useSettingsPageStatesStore(state => state.settings.log_verbose); - const LOG_WARNING = useSettingsPageStatesStore(state => state.settings.log_warning); - const LOG_PROGRESS = useSettingsPageStatesStore(state => state.settings.log_progress); - const ENABLE_NOTIFICATIONS = useSettingsPageStatesStore(state => state.settings.enable_notifications); - const DOWNLOAD_COMPLETION_NOTIFICATION = useSettingsPageStatesStore(state => state.settings.download_completion_notification); + const appWindow = getCurrentWebviewWindow() + const navigate = useNavigate(); + const LOG = useLogger(); + const currentPlatform = platform(); + const { updateYtDlp } = useYtDlpUpdater(); + const { registerToMac } = useMacOsRegisterer(); + const { checkForAppUpdate } = useAppUpdater(); + const setKvPairsKey = useKvPairsStatesStore((state) => state.setKvPairsKey); + const ytDlpUpdateLastCheck = useKvPairsStatesStore(state => state.kvPairs.ytdlp_update_last_check); + const macOsRegisteredVersion = useKvPairsStatesStore(state => state.kvPairs.macos_registered_version); - const isErrored = useDownloaderPageStatesStore((state) => state.isErrored); - const isErrorExpected = useDownloaderPageStatesStore((state) => state.isErrorExpected); - const erroredDownloadId = useDownloaderPageStatesStore((state) => state.erroredDownloadId); - const setIsErrored = useDownloaderPageStatesStore((state) => state.setIsErrored); - const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected); - const setErroredDownloadId = useDownloaderPageStatesStore((state) => state.setErroredDownloadId); + const queryClient = useQueryClient(); + const downloadStatusUpdater = useUpdateDownloadStatus(); - const appWindow = getCurrentWebviewWindow() - const navigate = useNavigate(); - const LOG = useLogger(); - const currentPlatform = platform(); - const { updateYtDlp } = useYtDlpUpdater(); - const { registerToMac } = useMacOsRegisterer(); - const { checkForAppUpdate } = useAppUpdater(); - const setKvPairsKey = useKvPairsStatesStore((state) => state.setKvPairsKey); - const ytDlpUpdateLastCheck = useKvPairsStatesStore(state => state.kvPairs.ytdlp_update_last_check); - const macOsRegisteredVersion = useKvPairsStatesStore(state => state.kvPairs.macos_registered_version); + const ongoingDownloads = globalDownloadStates.filter(state => state.download_status === 'downloading' || state.download_status === 'starting'); + const queuedDownloads = globalDownloadStates.filter(state => state.download_status === 'queued').sort((a, b) => a.queue_index! - b.queue_index!); - const queryClient = useQueryClient(); - const downloadStateSaver = useSaveDownloadState(); - const downloadStatusUpdater = useUpdateDownloadStatus(); - const downloadFilePathUpdater = useUpdateDownloadFilePath(); - const videoInfoSaver = useSaveVideoInfo(); - const downloadStateDeleter = useDeleteDownloadState(); - const playlistInfoSaver = useSavePlaylistInfo(); + const hasRunYtDlpAutoUpdateRef = useRef(false); + const hasRunAppUpdateCheckRef = useRef(false); + const isRegisteredToMacOsRef = useRef(false); - const ongoingDownloads = globalDownloadStates.filter(state => state.download_status === 'downloading' || state.download_status === 'starting'); - const queuedDownloads = globalDownloadStates.filter(state => state.download_status === 'queued').sort((a, b) => a.queue_index! - b.queue_index!); + const { fetchVideoMetadata, startDownload, pauseDownload, resumeDownload, cancelDownload, processQueuedDownloads } = useDownloader(); - const isProcessingQueueRef = useRef(false); - const lastProcessedDownloadIdRef = useRef(null); - const hasRunYtDlpAutoUpdateRef = useRef(false); - const hasRunAppUpdateCheckRef = useRef(false); - const isRegisteredToMacOsRef = useRef(false); - - const fetchVideoMetadata = async (url: string, formatId?: string, playlistIndex?: string, selectedSubtitles?: string | null, resumeState?: DownloadState, downloadConfig?: DownloadConfiguration): Promise => { - try { - const args = [url, '--dump-single-json', '--no-warnings']; - if (formatId) args.push('--format', formatId); - if (selectedSubtitles) args.push('--embed-subs', '--sub-lang', selectedSubtitles); - if (playlistIndex) args.push('--playlist-items', playlistIndex); - if (PREFER_VIDEO_OVER_PLAYLIST && !playlistIndex) args.push('--no-playlist'); - if (STRICT_DOWNLOADABILITY_CHECK && !formatId) args.push('--check-all-formats'); - if (STRICT_DOWNLOADABILITY_CHECK && formatId) args.push('--check-formats'); - - if ((USE_CUSTOM_COMMANDS && CUSTOM_COMMANDS && downloadConfig?.custom_command) || resumeState?.custom_command) { - let customCommandArgs = null; - if (resumeState?.custom_command) { - customCommandArgs = resumeState.custom_command; - } else if (CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig?.custom_command)) { - let customCommand = CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig?.custom_command); - customCommandArgs = customCommand ? customCommand.args : ''; - } - if (customCommandArgs && customCommandArgs.trim() !== '') args.push(...customCommandArgs.split(' ')); - } - - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_PROXY && PROXY_URL) args.push('--proxy', PROXY_URL); - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) { - if (FORCE_INTERNET_PROTOCOL === 'ipv4') { - args.push('--force-ipv4'); - } else if (FORCE_INTERNET_PROTOCOL === 'ipv6') { - args.push('--force-ipv6'); - } - } - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) { - if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) { - args.push('--cookies-from-browser', COOKIES_BROWSER); - } else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) { - args.push('--cookies', COOKIES_FILE); - } - } - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_SPONSORBLOCK || (resumeState?.sponsorblock_remove || resumeState?.sponsorblock_mark))) { - if (SPONSORBLOCK_MODE === 'remove' || resumeState?.sponsorblock_remove) { - let sponsorblockRemove = resumeState?.sponsorblock_remove || (SPONSORBLOCK_REMOVE === 'custom' ? ( - SPONSORBLOCK_REMOVE_CATEGORIES.length > 0 ? SPONSORBLOCK_REMOVE_CATEGORIES.join(',') : 'default' - ) : (SPONSORBLOCK_REMOVE)); - args.push('--sponsorblock-remove', sponsorblockRemove); - } else if (SPONSORBLOCK_MODE === 'mark' || resumeState?.sponsorblock_mark) { - let sponsorblockMark = resumeState?.sponsorblock_mark || (SPONSORBLOCK_MARK === 'custom' ? ( - SPONSORBLOCK_MARK_CATEGORIES.length > 0 ? SPONSORBLOCK_MARK_CATEGORIES.join(',') : 'default' - ) : (SPONSORBLOCK_MARK)); - args.push('--sponsorblock-mark', sponsorblockMark); - } - }; - const command = Command.sidecar('binaries/yt-dlp', args); - - let jsonOutput = ''; - - return new Promise((resolve) => { - command.stdout.on('data', line => { - jsonOutput += line; - }); - - command.on('close', async (data) => { - if (data.code !== 0) { - console.error(`yt-dlp failed to fetch metadata with code ${data.code}`); - LOG.error('NEODLP', `yt-dlp exited with code ${data.code} while fetching metadata for URL: ${url} (ignore if you manually cancelled)`); - resolve(null); - } else { - try { - const matchedJson = jsonOutput.match(/{.*}/); - if (!matchedJson) { - console.error(`Failed to match JSON: ${jsonOutput}`); - LOG.error('NEODLP', `Failed to parse metadata JSON for URL: ${url})`); - resolve(null); - return; - } - const parsedJson: RawVideoInfo = JSON.parse(matchedJson[0]); - resolve(parsedJson); - } - catch (e) { - console.error(`Failed to parse JSON: ${e}`); - LOG.error('NEODLP', `Failed to parse metadata JSON for URL: ${url}) with error: ${e}`); - resolve(null); - } - } - }); - - command.on('error', error => { - console.error(`Error fetching metadata: ${error}`); - LOG.error('NEODLP', `Error occurred while fetching metadata for URL: ${url} : ${error}`); - resolve(null); - }); - - LOG.info('NEODLP', `Fetching metadata for URL: ${url}, with args: ${args.join(' ')}`); - command.spawn().then(child => { - setSearchPid(child.pid); - }).catch(e => { - console.error(`Failed to spawn command: ${e}`); - LOG.error('NEODLP', `Failed to spawn yt-dlp process for fetching metadata for URL: ${url} : ${e}`); - resolve(null); - }); - }); - } catch (e) { - console.error(`Failed to fetch metadata: ${e}`); - LOG.error('NEODLP', `Failed to fetch metadata for URL: ${url} : ${e}`); - return null; - } - }; - - const startDownload = async (url: string, selectedFormat: string, downloadConfig: DownloadConfiguration, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => { - LOG.info('NEODLP', `Initiating yt-dlp download for URL: ${url}`); - // set error states to default - setIsErrored(false); - setIsErrorExpected(false); - setErroredDownloadId(null); - - console.log('Starting download:', { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems }); - if (!ffmpegPath || !tempDownloadDirPath || !downloadDirPath) { - console.error('FFmpeg or download paths not found'); - return; - } - - const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_index) ? true : false; - const playlistIndex = isPlaylist ? (resumeState?.playlist_index?.toString() || playlistItems) : null; - let videoMetadata = await fetchVideoMetadata(url, selectedFormat, isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined, selectedSubtitles, resumeState); - if (!videoMetadata) { - console.error('Failed to fetch video metadata'); - toast.error("Download Failed", { - description: "yt-dlp failed to fetch video metadata. Please try again later.", - }); - return; - } - - console.log('Video Metadata:', videoMetadata); - videoMetadata = isPlaylist ? videoMetadata.entries[0] : videoMetadata; - - const fileType = determineFileType(videoMetadata.vcodec, videoMetadata.acodec); - - if (fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) { - if (VIDEO_FORMAT !== 'auto' && (fileType === 'video+audio' || fileType === 'video')) videoMetadata.ext = VIDEO_FORMAT; - if (AUDIO_FORMAT !== 'auto' && fileType === 'audio') videoMetadata.ext = AUDIO_FORMAT; - } - - let configOutputFormat = null; - if (downloadConfig.output_format && downloadConfig.output_format !== 'auto') { - videoMetadata.ext = downloadConfig.output_format; - configOutputFormat = downloadConfig.output_format; - } - if (resumeState && resumeState.output_format) videoMetadata.ext = resumeState.output_format; - - const videoId = resumeState?.video_id || generateVideoId(videoMetadata.id, videoMetadata.webpage_url_domain); - const playlistId = isPlaylist ? (resumeState?.playlist_id || generateVideoId(videoMetadata.playlist_id, videoMetadata.webpage_url_domain)) : null; - const downloadId = resumeState?.download_id || ulid() /*generateDownloadId(videoMetadata.id, videoMetadata.webpage_url_domain)*/; - // const tempDownloadPathForYtdlp = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.%(ext)s`); - // const tempDownloadPath = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.${videoMetadata.ext}`); - // let downloadFilePath = resumeState?.filepath || await join(downloadDirPath, sanitizeFilename(`${videoMetadata.title}_${videoMetadata.resolution || 'unknown'}[${videoMetadata.id}].${videoMetadata.ext}`)); - let downloadFilePath: string | null = null; - let processPid: number | null = null; - const args = [ - url, - '--newline', - '--progress-template', - 'status:%(progress.status)s,progress:%(progress._percent_str)s,speed:%(progress.speed)f,downloaded:%(progress.downloaded_bytes)d,total:%(progress.total_bytes)d,eta:%(progress.eta)d', - '--paths', - `temp:${tempDownloadDirPath}`, - '--paths', - `home:${downloadDirPath}`, - '--output', - `${FILENAME_TEMPLATE}[${downloadId}].%(ext)s`, - '--windows-filenames', - '--restrict-filenames', - '--exec', - 'after_move:echo Finalpath: {}', - '--format', - selectedFormat, - '--no-mtime', - '--retries', - MAX_RETRIES.toString(), - ]; - - if (currentPlatform === 'macos') { - args.push('--ffmpeg-location', '/Applications/NeoDLP.app/Contents/MacOS', '--js-runtimes', 'deno:/Applications/NeoDLP.app/Contents/MacOS/deno'); - } - - if (!DEBUG_MODE || (DEBUG_MODE && !LOG_WARNING)) { - args.push('--no-warnings'); - } - - if (DEBUG_MODE && LOG_VERBOSE) { - args.push('--verbose'); - } - - if (selectedSubtitles) { - args.push('--embed-subs', '--sub-lang', selectedSubtitles); - } - - if (isPlaylist && playlistIndex && typeof playlistIndex === 'string') { - args.push('--playlist-items', playlistIndex); - } - - let customCommandArgs = null; - if ((USE_CUSTOM_COMMANDS && CUSTOM_COMMANDS && downloadConfig.custom_command) || resumeState?.custom_command) { - if (resumeState?.custom_command) { - customCommandArgs = resumeState.custom_command; - } else if (CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig.custom_command)) { - let customCommand = CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig.custom_command); - customCommandArgs = customCommand ? customCommand.args : ''; - } - - if (customCommandArgs && customCommandArgs.trim() !== '') args.push(...customCommandArgs.split(' ')); - } - - let outputFormat = null; - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && ((fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) || configOutputFormat || resumeState?.output_format)) { - const format = resumeState?.output_format || configOutputFormat; - - if (format) { - outputFormat = format; - } else if (fileType === 'audio' && AUDIO_FORMAT !== 'auto') { - outputFormat = AUDIO_FORMAT; - } else if ((fileType === 'video' || fileType === 'video+audio') && VIDEO_FORMAT !== 'auto') { - outputFormat = VIDEO_FORMAT; - } - - const recodeOrRemux = ALWAYS_REENCODE_VIDEO ? '--recode-video' : '--remux-video'; - const formatToUse = format || VIDEO_FORMAT; - - // Handle video+audio - if (fileType === 'video+audio' && (VIDEO_FORMAT !== 'auto' || format)) { - args.push(ALWAYS_REENCODE_VIDEO ? '--recode-video' : '--merge-output-format', formatToUse); - } - // Handle video only - else if (fileType === 'video' && (VIDEO_FORMAT !== 'auto' || format)) { - args.push(recodeOrRemux, formatToUse); - } - // Handle audio only - else if (fileType === 'audio' && (AUDIO_FORMAT !== 'auto' || format)) { - args.push('--extract-audio', '--audio-format', format || AUDIO_FORMAT); - } - // Handle unknown filetype - else if (fileType === 'unknown' && format) { - if (['mkv', 'mp4', 'webm'].includes(format)) { - args.push(recodeOrRemux, formatToUse); - } else if (['mp3', 'm4a', 'opus'].includes(format)) { - args.push('--extract-audio', '--audio-format', format); - } + // Prevent right click context menu in production + if (!import.meta.env.DEV) { + document.oncontextmenu = (event) => { + event.preventDefault() } } - let embedMetadata = 0; - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (downloadConfig.embed_metadata || resumeState?.embed_metadata || EMBED_VIDEO_METADATA || EMBED_AUDIO_METADATA)) { - const shouldEmbedMetaForVideo = (fileType === 'video+audio' || fileType === 'video') && (downloadConfig.embed_metadata || resumeState?.embed_metadata || (EMBED_VIDEO_METADATA && downloadConfig.embed_metadata === null)); - const shouldEmbedMetaForAudio = fileType === 'audio' && (downloadConfig.embed_metadata || resumeState?.embed_metadata || (EMBED_AUDIO_METADATA && downloadConfig.embed_metadata === null)); - const shouldEmbedMetaForUnknown = fileType === 'unknown' && (downloadConfig.embed_metadata || resumeState?.embed_metadata); - - if (shouldEmbedMetaForUnknown || shouldEmbedMetaForVideo || shouldEmbedMetaForAudio) { - embedMetadata = 1; - args.push('--embed-metadata'); - } - } - - let embedThumbnail = 0; - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (downloadConfig.embed_thumbnail || resumeState?.embed_thumbnail || EMBED_VIDEO_THUMBNAIL || EMBED_AUDIO_THUMBNAIL)) { - const shouldEmbedThumbForVideo = (fileType === 'video+audio' || fileType === 'video') && (downloadConfig.embed_thumbnail || resumeState?.embed_thumbnail || (EMBED_VIDEO_THUMBNAIL && downloadConfig.embed_thumbnail === null)); - const shouldEmbedThumbForAudio = fileType === 'audio' && (downloadConfig.embed_thumbnail || resumeState?.embed_thumbnail || (EMBED_AUDIO_THUMBNAIL && downloadConfig.embed_thumbnail === null)); - const shouldEmbedThumbForUnknown = fileType === 'unknown' && (downloadConfig.embed_thumbnail || resumeState?.embed_thumbnail); - - if (shouldEmbedThumbForUnknown || shouldEmbedThumbForVideo || shouldEmbedThumbForAudio) { - embedThumbnail = 1; - args.push('--embed-thumbnail'); - } - } - - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_PROXY && PROXY_URL) { - args.push('--proxy', PROXY_URL); - } - - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_RATE_LIMIT && RATE_LIMIT) { - args.push('--limit-rate', `${RATE_LIMIT}`); - } - - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) { - if (FORCE_INTERNET_PROTOCOL === 'ipv4') { - args.push('--force-ipv4'); - } else if (FORCE_INTERNET_PROTOCOL === 'ipv6') { - args.push('--force-ipv6'); - } - } - - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) { - if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) { - args.push('--cookies-from-browser', COOKIES_BROWSER); - } else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) { - args.push('--cookies', COOKIES_FILE); - } - } - - let sponsorblockRemove = null; - let sponsorblockMark = null; - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && ((downloadConfig.sponsorblock && downloadConfig.sponsorblock !== 'auto') || resumeState?.sponsorblock_remove || resumeState?.sponsorblock_mark || USE_SPONSORBLOCK)) { - if (downloadConfig?.sponsorblock === 'remove' || resumeState?.sponsorblock_remove || (SPONSORBLOCK_MODE === 'remove' && !downloadConfig.sponsorblock)) { - sponsorblockRemove = resumeState?.sponsorblock_remove || (SPONSORBLOCK_REMOVE === 'custom' ? ( - SPONSORBLOCK_REMOVE_CATEGORIES.length > 0 ? SPONSORBLOCK_REMOVE_CATEGORIES.join(',') : 'default' - ) : (SPONSORBLOCK_REMOVE)); - args.push('--sponsorblock-remove', sponsorblockRemove); - } else if (downloadConfig?.sponsorblock === 'mark' || resumeState?.sponsorblock_mark || (SPONSORBLOCK_MODE === 'mark' && !downloadConfig.sponsorblock)) { - sponsorblockMark = resumeState?.sponsorblock_mark || (SPONSORBLOCK_MARK === 'custom' ? ( - SPONSORBLOCK_MARK_CATEGORIES.length > 0 ? SPONSORBLOCK_MARK_CATEGORIES.join(',') : 'default' - ) : (SPONSORBLOCK_MARK)); - args.push('--sponsorblock-mark', sponsorblockMark); - } - } - - let useAria2 = 0; - if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_ARIA2 || resumeState?.use_aria2)) { - useAria2 = 1; - args.push( - '--downloader', 'aria2c', - '--downloader', 'dash,m3u8:native', - '--downloader-args', 'aria2c:-c -j 16 -x 16 -s 16 -k 1M --check-certificate=false' - ); - LOG.warning('NEODLP', `Looks like you are using aria2 for this yt-dlp download: ${downloadId}. Make sure aria2 is installed on your system if you are on macOS for this to work. Also, pause/resume might not work as expected especially on windows (using aria2 is not recommended for most downloads).`); - } - - if (resumeState || (!USE_CUSTOM_COMMANDS && USE_ARIA2)) { - args.push('--continue'); - } else { - args.push('--no-continue'); - } - - console.log('Starting download with args:', args); - const command = Command.sidecar('binaries/yt-dlp', args); - - command.on('close', async (data) => { - if (data.code !== 0) { - console.error(`Download failed with code ${data.code}`); - LOG.error(`YT-DLP Download ${downloadId}`, `yt-dlp exited with code ${data.code} (ignore if you manually paused or cancelled the download)`); - if (!isErrorExpected) { - setIsErrored(true); - setErroredDownloadId(downloadId); - } - } else { - LOG.info(`YT-DLP Download ${downloadId}`, `yt-dlp exited with code ${data.code}`); - } - }); - - command.on('error', error => { - console.error(`Error: ${error}`); - LOG.error(`YT-DLP Download ${downloadId}`, `Error occurred: ${error}`); - setIsErrored(true); - setErroredDownloadId(downloadId); - }); - - command.stdout.on('data', line => { - if (line.startsWith('status:') || line.startsWith('[#')) { - // console.log(line); - if (DEBUG_MODE && LOG_PROGRESS) LOG.progress(`YT-DLP Download ${downloadId}`, line); - const currentProgress = parseProgressLine(line); - const state: DownloadState = { - download_id: downloadId, - download_status: 'downloading', - video_id: videoId, - format_id: selectedFormat, - subtitle_id: selectedSubtitles || null, - queue_index: null, - playlist_id: playlistId, - playlist_index: playlistIndex ? Number(playlistIndex) : null, - title: videoMetadata.title, - url: url, - host: videoMetadata.webpage_url_domain, - thumbnail: videoMetadata.thumbnail || null, - channel: videoMetadata.channel || null, - duration_string: videoMetadata.duration_string || null, - release_date: videoMetadata.release_date || null, - view_count: videoMetadata.view_count || null, - like_count: videoMetadata.like_count || null, - playlist_title: videoMetadata.playlist_title, - playlist_url: videoMetadata.playlist_webpage_url, - playlist_n_entries: videoMetadata.playlist_count || videoMetadata.n_entries, - playlist_channel: videoMetadata.playlist_channel || null, - resolution: videoMetadata.resolution || null, - ext: videoMetadata.ext || null, - abr: videoMetadata.abr || null, - vbr: videoMetadata.vbr || null, - acodec: videoMetadata.acodec || null, - vcodec: videoMetadata.vcodec || null, - dynamic_range: videoMetadata.dynamic_range || null, - process_id: processPid, - status: currentProgress.status || null, - progress: currentProgress.progress || null, - total: currentProgress.total || null, - downloaded: currentProgress.downloaded || null, - speed: currentProgress.speed || null, - eta: currentProgress.eta || null, - filepath: downloadFilePath, - filetype: determineFileType(videoMetadata.vcodec, videoMetadata.acodec) || null, - filesize: videoMetadata.filesize_approx || null, - output_format: outputFormat, - embed_metadata: embedMetadata, - embed_thumbnail: embedThumbnail, - sponsorblock_remove: sponsorblockRemove, - sponsorblock_mark: sponsorblockMark, - use_aria2: useAria2, - custom_command: customCommandArgs, - queue_config: null + // Prevent app from closing + useEffect(() => { + const handleCloseRequested = (event: any) => { + event.preventDefault(); + appWindow.hide(); }; - downloadStateSaver.mutate(state, { - onSuccess: (data) => { - console.log("Download State saved successfully:", data); - queryClient.invalidateQueries({ queryKey: ['download-states'] }); - }, - onError: (error) => { - console.error("Failed to save download state:", error); - } - }) - } else { - // console.log(line); - if (line.trim() !== '') LOG.info(`YT-DLP Download ${downloadId}`, line); - if (line.startsWith('Finalpath: ')) { - downloadFilePath = line.replace('Finalpath: ', '').trim().replace(/^"|"$/g, ''); - const downloadedFileExt = downloadFilePath.split('.').pop(); + appWindow.onCloseRequested(handleCloseRequested); + }, []); - // Update completion status after a short delay to ensure database states are propagated correctly - console.log(`Download completed with ID: ${downloadId}, updating filepath and status after 1.5s delay...`); - setTimeout(async () => { - LOG.info('NEODLP', `yt-dlp download completed with id: ${downloadId}`); - downloadFilePathUpdater.mutate({ download_id: downloadId, filepath: downloadFilePath as string, ext: downloadedFileExt as string }, { - onSuccess: (data) => { - console.log("Download filepath updated successfully:", data); - queryClient.invalidateQueries({ queryKey: ['download-states'] }); - }, - onError: (error) => { - console.error("Failed to update download filepath:", error); - } + // Listen for websocket messages + useEffect(() => { + const unlisten = listen('websocket-message', (event) => { + if(event.payload.command === 'download') { + const handleDownload = async () => { + appWindow.show(); + appWindow.setFocus(); + navigate('/'); + if (event.payload.url) { + LOG.info('NEODLP', `Received download request from neodlp browser extension for URL: ${event.payload.url}`); + const { setRequestedUrl, setAutoSubmitSearch } = useCurrentVideoMetadataStore.getState(); + setRequestedUrl(event.payload.url); + setAutoSubmitSearch(true); + } + } + handleDownload().catch((error) => { + console.error("Error handling download:", error); + }); + } + }); + + return () => { + unlisten.then(f => f()); + }; + }, []); + + // Fetch download states from database and sync with state + useEffect(() => { + if (isSuccessFetchingSettings && settings) { + console.log("Settings fetched successfully:", settings); + if (!isObjEmpty(settings)) { + setIsUsingDefaultSettings(false); + Object.keys(settings).forEach((key) => { + setSettingsKey(key, settings[key]); }); + } + else { + setIsUsingDefaultSettings(true); + } + setIsSettingsStatePropagated(true); + } + }, [settings, isSuccessFetchingSettings]); - downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'completed' }, { + // Fetch KV pairs from database and sync with state + useEffect(() => { + if (isSuccessFetchingKvPairs && kvPairs) { + console.log("KvPairs fetched successfully:", kvPairs); + if (!isObjEmpty(kvPairs)) { + Object.keys(kvPairs).forEach((key) => { + setKvPairsKey(key, kvPairs[key]); + }); + } + setIsKvPairsStatePropagated(true); + } + }, [kvPairs, isSuccessFetchingKvPairs]); + + // Initiate/Resolve base app paths + useEffect(() => { + const initPaths = async () => { + try { + const currentArch = arch(); + const currentExeExtension = exeExtension(); + const downloadDirPath = await downloadDir(); + const tempDirPath = await tempDir(); + const resourceDirPath = await resourceDir(); + + const ffmpegPath = await join(resourceDirPath, 'binaries', `ffmpeg-${currentArch}${currentExeExtension ? '.' + currentExeExtension : ''}`); + const tempDownloadDirPath = await join(tempDirPath, config.appPkgName, 'downloads'); + const appDownloadDirPath = await join(downloadDirPath, config.appName); + + if (!await fs.exists(tempDownloadDirPath)) fs.mkdir(tempDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${tempDownloadDirPath}`) }); + + setPath('ffmpegPath', ffmpegPath); + setPath('tempDownloadDirPath', tempDownloadDirPath); + if (DOWNLOAD_DIR) { + setPath('downloadDirPath', DOWNLOAD_DIR); + } else { + if(!await fs.exists(appDownloadDirPath)) fs.mkdir(appDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${appDownloadDirPath}`) }); + setPath('downloadDirPath', appDownloadDirPath); + } + console.log('Paths initialized:', { ffmpegPath, tempDownloadDirPath, downloadDirPath: DOWNLOAD_DIR || appDownloadDirPath }); + } catch (e) { + console.error('Failed to fetch paths:', e); + } + }; + initPaths(); + }, [DOWNLOAD_DIR, setPath]); + + // Fetch app version + useEffect(() => { + const fetchAppVersion = async () => { + setIsFetchingAppVersion(true); + try { + const version = await getVersion(); + console.log("App version fetched successfully:", version); + setAppVersion(version); + } catch (e) { + console.error('Failed to fetch app version:', e); + } finally { + setIsFetchingAppVersion(false); + } + } + fetchAppVersion(); + }, []); + + // Fetch yt-dlp version + useEffect(() => { + const fetchYtDlpVersion = async () => { + setIsFetchingYtDlpVersion(true); + try { + const command = Command.sidecar('binaries/yt-dlp', ['--version']); + const output = await command.execute(); + if (output.code === 0) { + const version = output.stdout.trim(); + console.log("yt-dlp version fetched successfully:", version); + setYtDlpVersion(version); + } else { + console.error("Failed to fetch yt-dlp version:", output.stderr); + } + } catch (e) { + console.error('Failed to fetch yt-dlp version:', e); + } finally { + setIsFetchingYtDlpVersion(false); + } + }; + fetchYtDlpVersion(); + }, [ytDlpVersion, setYtDlpVersion]); + + // Check for app update + useEffect(() => { + // Only run once when both settings and KV pairs are loaded + if (!isSettingsStatePropagated || !isKvPairsStatePropagated) { + console.log("Skipping app update check, waiting for configs to load..."); + return; + } + // Skip if we've already run the update check once + if (hasRunAppUpdateCheckRef.current) { + console.log("App update check already performed in this session, skipping"); + return; + } + hasRunAppUpdateCheckRef.current = true; + checkForAppUpdate().catch((error) => { + console.error("Error checking for app update:", error); + }); + }, [isSettingsStatePropagated, isKvPairsStatePropagated]); + + // Check for yt-dlp auto-update + useEffect(() => { + // Only run once when both settings and KV pairs are loaded + if (!isSettingsStatePropagated || !isKvPairsStatePropagated) { + console.log("Skipping yt-dlp auto-update check, waiting for configs to load..."); + return; + } + // Skip if we've already run the auto-update once + if (hasRunYtDlpAutoUpdateRef.current) { + console.log("Auto-update check already performed in this session, skipping"); + return; + } + hasRunYtDlpAutoUpdateRef.current = true; + console.log("Checking yt-dlp auto-update with loaded config values:", { + autoUpdate: YTDLP_AUTO_UPDATE, + updateChannel: YTDLP_UPDATE_CHANNEL, + lastCheck: ytDlpUpdateLastCheck + }); + const currentTimestamp = Date.now() + const YTDLP_UPDATE_INTERVAL = 86400000 // 24H; + if (YTDLP_AUTO_UPDATE && (ytDlpUpdateLastCheck === null || currentTimestamp - ytDlpUpdateLastCheck > YTDLP_UPDATE_INTERVAL)) { + console.log("Running auto-update for yt-dlp..."); + updateYtDlp(); + } else { + console.log("Skipping yt-dlp auto-update, either disabled or recently updated."); + } + }, [isSettingsStatePropagated, isKvPairsStatePropagated]); + + // Check for MacOS auto-registration + useEffect(() => { + // Only run once when both settings and KV pairs are loaded + if (!isSettingsStatePropagated || !isKvPairsStatePropagated) { + console.log("Skipping MacOS auto registration, waiting for configs to load..."); + return; + } + // Skip if we've already run the macos auto-registration once + if (isRegisteredToMacOsRef.current) { + console.log("MacOS auto registration check already performed in this session, skipping"); + return; + } + isRegisteredToMacOsRef.current = true; + console.log("Checking MacOS auto registration with loaded config values:", { + appVersion: appVersion, + registeredVersion: macOsRegisteredVersion + }); + if (currentPlatform === 'macos' && (!macOsRegisteredVersion || macOsRegisteredVersion !== appVersion)) { + console.log("Running MacOS auto registration..."); + LOG.info('NEODLP', 'Running macOS registration'); + registerToMac().then((result: { success: boolean, message: string }) => { + if (result.success) { + console.log("MacOS registration successful:", result.message); + LOG.info('NEODLP', 'macOS registration successful'); + } else { + console.error("MacOS registration failed:", result.message); + LOG.error('NEODLP', `macOS registration failed: ${result.message}`); + } + }).catch((error) => { + console.error("Error during macOS registration:", error); + LOG.error('NEODLP', `Error during macOS registration: ${error}`); + }); + } + }, [isSettingsStatePropagated, isKvPairsStatePropagated]); + + useEffect(() => { + if (isSuccessFetchingDownloadStates && downloadStates) { + console.log("Download States fetched successfully:", downloadStates); + setDownloadStates(downloadStates); + } + }, [downloadStates, isSuccessFetchingDownloadStates, setDownloadStates]); + + // Process queued downloads + useEffect(() => { + // Safely process the queue when dependencies change + const timeoutId = setTimeout(() => { + processQueuedDownloads(); + }, 500); + + // Cleanup timeout if component unmounts or dependencies change + return () => clearTimeout(timeoutId); + }, [processQueuedDownloads, ongoingDownloads, queuedDownloads]); + + // show a toast and pause the download when yt-dlp exits unexpectedly + useEffect(() => { + if (isErrored && !isErrorExpected) { + toast.error("Download Failed", { + description: "yt-dlp exited unexpectedly. Please try again later", + }); + if (erroredDownloadId) { + downloadStatusUpdater.mutate({ download_id: erroredDownloadId, download_status: 'paused' }, { onSuccess: (data) => { console.log("Download status updated successfully:", data); queryClient.invalidateQueries({ queryKey: ['download-states'] }); @@ -574,617 +341,34 @@ export default function App({ children }: { children: React.ReactNode }) { onError: (error) => { console.error("Failed to update download status:", error); } - }); - - toast.success("Download Completed", { - description: `The download for "${videoMetadata.title}" has completed successfully.`, - }); - - if (ENABLE_NOTIFICATIONS && DOWNLOAD_COMPLETION_NOTIFICATION) { - sendNotification({ - title: "Download Completed", - body: `The download for "${videoMetadata.title}" has completed successfully.`, - }); - } - }, 1500); - } - } - }); - - try { - videoInfoSaver.mutate({ - video_id: videoId, - title: videoMetadata.title, - url: url, - host: videoMetadata.webpage_url_domain, - thumbnail: videoMetadata.thumbnail || null, - channel: videoMetadata.channel || videoMetadata.uploader || null, - duration_string: videoMetadata.duration_string || null, - release_date: videoMetadata.release_date || null, - view_count: videoMetadata.view_count || null, - like_count: videoMetadata.like_count || null - }, { - onSuccess: (data) => { - console.log("Video Info saved successfully:", data); - if (isPlaylist) { - playlistInfoSaver.mutate({ - playlist_id: playlistId ? playlistId : '', - playlist_title: videoMetadata.playlist_title, - playlist_url: videoMetadata.playlist_webpage_url, - playlist_n_entries: videoMetadata.playlist_count || videoMetadata.n_entries, - playlist_channel: videoMetadata.playlist_channel || null - }, { - onSuccess: (data) => { - console.log("Playlist Info saved successfully:", data); - }, - onError: (error) => { - console.error("Failed to save playlist info:", error); - } - }) - } - const state: DownloadState = { - download_id: downloadId, - download_status: (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? 'starting' : 'queued', - video_id: videoId, - format_id: selectedFormat, - subtitle_id: selectedSubtitles || null, - queue_index: (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? null : (queuedDownloads?.length || 0), - playlist_id: playlistId, - playlist_index: playlistIndex ? Number(playlistIndex) : null, - title: videoMetadata.title, - url: url, - host: videoMetadata.webpage_url_domain, - thumbnail: videoMetadata.thumbnail || null, - channel: videoMetadata.channel || null, - duration_string: videoMetadata.duration_string || null, - release_date: videoMetadata.release_date || null, - view_count: videoMetadata.view_count || null, - like_count: videoMetadata.like_count || null, - playlist_title: videoMetadata.playlist_title, - playlist_url: videoMetadata.playlist_webpage_url, - playlist_n_entries: videoMetadata.playlist_count || videoMetadata.n_entries, - playlist_channel: videoMetadata.playlist_channel || null, - resolution: resumeState?.resolution || null, - ext: resumeState?.ext || null, - abr: resumeState?.abr || null, - vbr: resumeState?.vbr || null, - acodec: resumeState?.acodec || null, - vcodec: resumeState?.vcodec || null, - dynamic_range: resumeState?.dynamic_range || null, - process_id: resumeState?.process_id || null, - status: resumeState?.status || null, - progress: resumeState?.progress || null, - total: resumeState?.total || null, - downloaded: resumeState?.downloaded || null, - speed: resumeState?.speed || null, - eta: resumeState?.eta || null, - filepath: downloadFilePath, - filetype: resumeState?.filetype || null, - filesize: resumeState?.filesize || null, - output_format: resumeState?.output_format || null, - embed_metadata: resumeState?.embed_metadata || 0, - embed_thumbnail: resumeState?.embed_thumbnail || 0, - sponsorblock_remove: resumeState?.sponsorblock_remove || null, - sponsorblock_mark: resumeState?.sponsorblock_mark || null, - use_aria2: resumeState?.use_aria2 || 0, - custom_command: resumeState?.custom_command || null, - queue_config: resumeState?.queue_config || ((!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? null : JSON.stringify(downloadConfig)) - } - downloadStateSaver.mutate(state, { - onSuccess: (data) => { - console.log("Download State saved successfully:", data); - queryClient.invalidateQueries({ queryKey: ['download-states'] }); - }, - onError: (error) => { - console.error("Failed to save download state:", error); + }) + setErroredDownloadId(null); } - }) - }, - onError: (error) => { - console.error("Failed to save video info:", error); + setIsErrored(false); + setIsErrorExpected(false); } - }) + }, [isErrored, isErrorExpected, erroredDownloadId, setIsErrored, setIsErrorExpected, setErroredDownloadId]); - if (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) { - LOG.info('NEODLP', `Starting yt-dlp download with args: ${args.join(' ')}`); - if(!DEBUG_MODE || (DEBUG_MODE && !LOG_PROGRESS)) LOG.warning('NEODLP', `Progress logs are hidden. Enable 'Debug Mode > Log Progress' in Settings to unhide.`); - const child = await command.spawn(); - processPid = child.pid; - return Promise.resolve(); - } else { - console.log("Download is queued, not starting immediately."); - LOG.info('NEODLP', `Download queued with id: ${downloadId}`); - return Promise.resolve(); - } - } catch (e) { - console.error(`Failed to start download: ${e}`); - LOG.error('NEODLP', `Failed to start download for URL: ${url} with error: ${e}`); - throw e; - } - }; - - const pauseDownload = async (downloadState: DownloadState) => { - try { - LOG.info('NEODLP', `Pausing yt-dlp download with id: ${downloadState.download_id} (as per user request)`); - if ((downloadState.download_status === 'downloading' && downloadState.process_id) || (downloadState.download_status === 'starting' && downloadState.process_id)) { - setIsErrorExpected(true); // Set error expected to true to handle UI state - console.log("Killing process with PID:", downloadState.process_id); - await invoke('kill_all_process', { pid: downloadState.process_id }); - } - downloadStatusUpdater.mutate({ download_id: downloadState.download_id, download_status: 'paused' }, { - onSuccess: (data) => { - console.log("Download status updated successfully:", data); - queryClient.invalidateQueries({ queryKey: ['download-states'] }); - - /* re-check if the download is properly paused (if not try again after a small delay) - as the pause opertion happens within high throughput of operations and have a high chgance of failure. - */ - if (isSuccessFetchingDownloadStates && downloadStates.find(state => state.download_id === downloadState.download_id)?.download_status !== 'paused') { - console.log("Download status not updated to paused yet, retrying..."); - setTimeout(() => { - downloadStatusUpdater.mutate({ download_id: downloadState.download_id, download_status: 'paused' }, { - onSuccess: (data) => { - console.log("Download status updated successfully on retry:", data); - queryClient.invalidateQueries({ queryKey: ['download-states'] }); - }, - onError: (error) => { - console.error("Failed to update download status:", error); - } - }); - }, 200); - } - - // Reset the processing flag to ensure queue can be processed - isProcessingQueueRef.current = false; - - // Process the queue after a short delay to ensure state is updated - setTimeout(() => { - processQueuedDownloads(); - }, 1000); - }, - onError: (error) => { - console.error("Failed to update download status:", error); + // auto reset error states after 3 seconds of expecting an error + useEffect(() => { + if (isErrorExpected) { + const timeoutId = setTimeout(() => { + setIsErrored(false); + setIsErrorExpected(false); + setErroredDownloadId(null); + }, 3000); + return () => clearTimeout(timeoutId); } - }) - return Promise.resolve(); - } catch (e) { - console.error(`Failed to pause download: ${e}`); - LOG.error('NEODLP', `Failed to pause download with id: ${downloadState.download_id} with error: ${e}`); - isProcessingQueueRef.current = false; - throw e; - } - }; + }, [isErrorExpected, setIsErrorExpected]); - const resumeDownload = async (downloadState: DownloadState) => { - try { - LOG.info('NEODLP', `Resuming yt-dlp download with id: ${downloadState.download_id} (as per user request)`); - await startDownload( - downloadState.playlist_id && downloadState.playlist_index ? downloadState.playlist_url : downloadState.url, - downloadState.format_id, - downloadState.queue_config ? JSON.parse(downloadState.queue_config) : { - output_format: null, - embed_metadata: null, - embed_thumbnail: null, - sponsorblock: null, - custom_command: null - }, - downloadState.subtitle_id, - downloadState - ); - return Promise.resolve(); - } catch (e) { - console.error(`Failed to resume download: ${e}`); - LOG.error('NEODLP', `Failed to resume download with id: ${downloadState.download_id} with error: ${e}`); - throw e; - } - }; - - const cancelDownload = async (downloadState: DownloadState) => { - try { - LOG.info('NEODLP', `Cancelling yt-dlp download with id: ${downloadState.download_id} (as per user request)`); - if ((downloadState.download_status === 'downloading' && downloadState.process_id) || (downloadState.download_status === 'starting' && downloadState.process_id)) { - setIsErrorExpected(true); // Set error expected to true to handle UI state - console.log("Killing process with PID:", downloadState.process_id); - await invoke('kill_all_process', { pid: downloadState.process_id }); - } - downloadStateDeleter.mutate(downloadState.download_id, { - onSuccess: (data) => { - console.log("Download State deleted successfully:", data); - queryClient.invalidateQueries({ queryKey: ['download-states'] }); - // Reset processing flag and trigger queue processing - isProcessingQueueRef.current = false; - - // Process the queue after a short delay - setTimeout(() => { - processQueuedDownloads(); - }, 1000); - }, - onError: (error) => { - console.error("Failed to delete download state:", error); - isProcessingQueueRef.current = false; - } - }) - return Promise.resolve(); - } catch (e) { - console.error(`Failed to cancel download: ${e}`); - LOG.error('NEODLP', `Failed to cancel download with id: ${downloadState.download_id} with error: ${e}`); - throw e; - } - } - - const processQueuedDownloads = useCallback(async () => { - // Prevent concurrent processing - if (isProcessingQueueRef.current) { - console.log("Queue processing already in progress, skipping..."); - return; - } - - // Check if we can process more downloads - if (!queuedDownloads?.length || ongoingDownloads?.length >= MAX_PARALLEL_DOWNLOADS) { - return; - } - - try { - isProcessingQueueRef.current = true; - console.log("Processing download queue..."); - - // Get the first download in queue - const downloadToStart = queuedDownloads[0]; - - // Skip if we just processed this download to prevent loops - if (lastProcessedDownloadIdRef.current === downloadToStart.download_id) { - console.log("Skipping recently processed download:", downloadToStart.download_id); - return; - } - - // Double-check current state from global state - const currentState = globalDownloadStates.find( - state => state.download_id === downloadToStart.download_id - ); - - if (!currentState || currentState.download_status !== 'queued') { - console.log("Download no longer in queued state:", downloadToStart.download_id); - return; - } - - console.log("Starting queued download:", downloadToStart.download_id); - LOG.info('NEODLP', `Starting queued download with id: ${downloadToStart.download_id}`); - lastProcessedDownloadIdRef.current = downloadToStart.download_id; - - // Update status to 'starting' first - await downloadStatusUpdater.mutateAsync({ - download_id: downloadToStart.download_id, - download_status: 'starting' - }); - - // Fetch latest state after status update - await queryClient.invalidateQueries({ queryKey: ['download-states'] }); - - // Start the download - await startDownload( - downloadToStart.url, - downloadToStart.format_id, - downloadToStart.queue_config ? JSON.parse(downloadToStart.queue_config) : { - output_format: null, - embed_metadata: null, - embed_thumbnail: null, - sponsorblock: null, - custom_command: null - }, - downloadToStart.subtitle_id, - downloadToStart - ); - - } catch (error) { - console.error("Error processing download queue:", error); - LOG.error('NEODLP', `Error processing download queue: ${error}`); - } finally { - // Important: reset the processing flag - setTimeout(() => { - isProcessingQueueRef.current = false; - console.log("Queue processor released lock"); - }, 1000); // Small delay to prevent rapid re-processing - } - }, [queuedDownloads, ongoingDownloads, globalDownloadStates, queryClient]); - - // Prevent right click context menu in production - if (!import.meta.env.DEV) { - document.oncontextmenu = (event) => { - event.preventDefault() - } - } - - // Prevent app from closing - useEffect(() => { - const handleCloseRequested = (event: any) => { - event.preventDefault(); - appWindow.hide(); - }; - - appWindow.onCloseRequested(handleCloseRequested); - }, []); - - // Listen for websocket messages - useEffect(() => { - const unlisten = listen('websocket-message', (event) => { - if(event.payload.command === 'download') { - const handleDownload = async () => { - appWindow.show(); - appWindow.setFocus(); - navigate('/'); - if (event.payload.url) { - LOG.info('NEODLP', `Received download request from neodlp browser extension for URL: ${event.payload.url}`); - const { setRequestedUrl, setAutoSubmitSearch } = useCurrentVideoMetadataStore.getState(); - setRequestedUrl(event.payload.url); - setAutoSubmitSearch(true); - } - } - handleDownload().catch((error) => { - console.error("Error handling download:", error); - }); - } - }); - - return () => { - unlisten.then(f => f()); - }; - }, []); - - // useEffect(() => { - // const fetchConfigPath = async () => { - // const configPath = await invoke('get_config_file_path'); - // console.log("Config path fetched successfully:", configPath); - // }; - - // fetchConfigPath().catch((error) => { - // console.error("Error fetching config path:", error); - // }); - // }, []); - - // Fetch download states from database and sync with state - useEffect(() => { - if (isSuccessFetchingSettings && settings) { - console.log("Settings fetched successfully:", settings); - if (!isObjEmpty(settings)) { - setIsUsingDefaultSettings(false); - Object.keys(settings).forEach((key) => { - setSettingsKey(key, settings[key]); - }); - } - else { - setIsUsingDefaultSettings(true); - } - setIsSettingsStatePropagated(true); - } - }, [settings, isSuccessFetchingSettings]); - - // Fetch KV pairs from database and sync with state - useEffect(() => { - if (isSuccessFetchingKvPairs && kvPairs) { - console.log("KvPairs fetched successfully:", kvPairs); - if (!isObjEmpty(kvPairs)) { - Object.keys(kvPairs).forEach((key) => { - setKvPairsKey(key, kvPairs[key]); - }); - } - setIsKvPairsStatePropagated(true); - } - }, [kvPairs, isSuccessFetchingKvPairs]); - - // Initiate/Resolve base app paths - useEffect(() => { - const initPaths = async () => { - try { - const currentArch = arch(); - const currentExeExtension = exeExtension(); - const downloadDirPath = await downloadDir(); - const tempDirPath = await tempDir(); - const resourceDirPath = await resourceDir(); - - const ffmpegPath = await join(resourceDirPath, 'binaries', `ffmpeg-${currentArch}${currentExeExtension ? '.' + currentExeExtension : ''}`); - const tempDownloadDirPath = await join(tempDirPath, config.appPkgName, 'downloads'); - const appDownloadDirPath = await join(downloadDirPath, config.appName); - - if(!await fs.exists(tempDownloadDirPath)) fs.mkdir(tempDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${tempDownloadDirPath}`) }); - - setPath('ffmpegPath', ffmpegPath); - setPath('tempDownloadDirPath', tempDownloadDirPath); - if (DOWNLOAD_DIR) { - setPath('downloadDirPath', DOWNLOAD_DIR); - } else { - if(!await fs.exists(appDownloadDirPath)) fs.mkdir(appDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${appDownloadDirPath}`) }); - setPath('downloadDirPath', appDownloadDirPath); - } - console.log('Paths initialized:', { ffmpegPath, tempDownloadDirPath, downloadDirPath: DOWNLOAD_DIR || appDownloadDirPath }); - } catch (e) { - console.error('Failed to fetch paths:', e); - } - }; - initPaths(); - }, [DOWNLOAD_DIR, setPath]); - - // Fetch app version - useEffect(() => { - const fetchAppVersion = async () => { - setIsFetchingAppVersion(true); - try { - const version = await getVersion(); - console.log("App version fetched successfully:", version); - setAppVersion(version); - } catch (e) { - console.error('Failed to fetch app version:', e); - } finally { - setIsFetchingAppVersion(false); - } - } - fetchAppVersion(); - }, []); - - // Fetch yt-dlp version - useEffect(() => { - const fetchYtDlpVersion = async () => { - setIsFetchingYtDlpVersion(true); - try { - const command = Command.sidecar('binaries/yt-dlp', ['--version']); - const output = await command.execute(); - if (output.code === 0) { - const version = output.stdout.trim(); - console.log("yt-dlp version fetched successfully:", version); - setYtDlpVersion(version); - } else { - console.error("Failed to fetch yt-dlp version:", output.stderr); - } - } catch (e) { - console.error('Failed to fetch yt-dlp version:', e); - } finally { - setIsFetchingYtDlpVersion(false); - } - }; - fetchYtDlpVersion(); - }, [ytDlpVersion, setYtDlpVersion]); - - // Check for app update - useEffect(() => { - // Only run once when both settings and KV pairs are loaded - if (!isSettingsStatePropagated || !isKvPairsStatePropagated) { - console.log("Skipping app update check, waiting for configs to load..."); - return; - } - // Skip if we've already run the update check once - if (hasRunAppUpdateCheckRef.current) { - console.log("App update check already performed in this session, skipping"); - return; - } - hasRunAppUpdateCheckRef.current = true; - checkForAppUpdate().catch((error) => { - console.error("Error checking for app update:", error); - }); - }, [isSettingsStatePropagated, isKvPairsStatePropagated]); - - // Check for yt-dlp auto-update - useEffect(() => { - // Only run once when both settings and KV pairs are loaded - if (!isSettingsStatePropagated || !isKvPairsStatePropagated) { - console.log("Skipping yt-dlp auto-update check, waiting for configs to load..."); - return; - } - // Skip if we've already run the auto-update once - if (hasRunYtDlpAutoUpdateRef.current) { - console.log("Auto-update check already performed in this session, skipping"); - return; - } - hasRunYtDlpAutoUpdateRef.current = true; - console.log("Checking yt-dlp auto-update with loaded config values:", { - autoUpdate: YTDLP_AUTO_UPDATE, - updateChannel: YTDLP_UPDATE_CHANNEL, - lastCheck: ytDlpUpdateLastCheck - }); - const currentTimestamp = Date.now() - const YTDLP_UPDATE_INTERVAL = 86400000 // 24H; - if (YTDLP_AUTO_UPDATE && (ytDlpUpdateLastCheck === null || currentTimestamp - ytDlpUpdateLastCheck > YTDLP_UPDATE_INTERVAL)) { - console.log("Running auto-update for yt-dlp..."); - updateYtDlp(); - } else { - console.log("Skipping yt-dlp auto-update, either disabled or recently updated."); - } - }, [isSettingsStatePropagated, isKvPairsStatePropagated]); - - // Check for MacOS auto-registration - useEffect(() => { - // Only run once when both settings and KV pairs are loaded - if (!isSettingsStatePropagated || !isKvPairsStatePropagated) { - console.log("Skipping MacOS auto registration, waiting for configs to load..."); - return; - } - // Skip if we've already run the macos auto-registration once - if (isRegisteredToMacOsRef.current) { - console.log("MacOS auto registration check already performed in this session, skipping"); - return; - } - isRegisteredToMacOsRef.current = true; - console.log("Checking MacOS auto registration with loaded config values:", { - appVersion: appVersion, - registeredVersion: macOsRegisteredVersion - }); - if (currentPlatform === 'macos' && (!macOsRegisteredVersion || macOsRegisteredVersion !== appVersion)) { - console.log("Running MacOS auto registration..."); - LOG.info('NEODLP', 'Running macOS registration'); - registerToMac().then((result: { success: boolean, message: string }) => { - if (result.success) { - console.log("MacOS registration successful:", result.message); - LOG.info('NEODLP', 'macOS registration successful'); - } else { - console.error("MacOS registration failed:", result.message); - LOG.error('NEODLP', `macOS registration failed: ${result.message}`); - } - }).catch((error) => { - console.error("Error during macOS registration:", error); - LOG.error('NEODLP', `Error during macOS registration: ${error}`); - }); - } - }, [isSettingsStatePropagated, isKvPairsStatePropagated]); - - useEffect(() => { - if (isSuccessFetchingDownloadStates && downloadStates) { - console.log("Download States fetched successfully:", downloadStates); - setDownloadStates(downloadStates); - } - }, [downloadStates, isSuccessFetchingDownloadStates, setDownloadStates]); - - // Process queued downloads - useEffect(() => { - // Safely process the queue when dependencies change - const timeoutId = setTimeout(() => { - processQueuedDownloads(); - }, 500); - - // Cleanup timeout if component unmounts or dependencies change - return () => clearTimeout(timeoutId); - }, [processQueuedDownloads, ongoingDownloads, queuedDownloads]); - - // show a toast and pause the download when yt-dlp exits unexpectedly - useEffect(() => { - if (isErrored && !isErrorExpected) { - toast.error("Download Failed", { - description: "yt-dlp exited unexpectedly. Please try again later", - }); - if (erroredDownloadId) { - downloadStatusUpdater.mutate({ download_id: erroredDownloadId, download_status: 'paused' }, { - onSuccess: (data) => { - console.log("Download status updated successfully:", data); - queryClient.invalidateQueries({ queryKey: ['download-states'] }); - }, - onError: (error) => { - console.error("Failed to update download status:", error); - } - }) - setErroredDownloadId(null); - } - setIsErrored(false); - setIsErrorExpected(false); - } - }, [isErrored, isErrorExpected, erroredDownloadId, setIsErrored, setIsErrorExpected, setErroredDownloadId]); - - // auto reset error states after 3 seconds of expecting an error - useEffect(() => { - if (isErrorExpected) { - const timeoutId = setTimeout(() => { - setIsErrored(false); - setIsErrorExpected(false); - setErroredDownloadId(null); - }, 3000); - return () => clearTimeout(timeoutId); - } - }, [isErrorExpected, setIsErrorExpected]); - - return ( - - - - {children} - - - - - ); + return ( + + + + {children} + + + + + ); } diff --git a/src/helpers/use-app-updater.ts b/src/helpers/use-app-updater.ts index a0122b6..8a6a866 100644 --- a/src/helpers/use-app-updater.ts +++ b/src/helpers/use-app-updater.ts @@ -62,8 +62,5 @@ export default function useAppUpdater() { await relaunchApp(); } - return { - checkForAppUpdate, - downloadAndInstallAppUpdate - } + return { checkForAppUpdate, downloadAndInstallAppUpdate }; } diff --git a/src/helpers/use-downloader.ts b/src/helpers/use-downloader.ts new file mode 100644 index 0000000..68e2fb6 --- /dev/null +++ b/src/helpers/use-downloader.ts @@ -0,0 +1,858 @@ +import { DownloadState } from "@/types/download"; +import { invoke } from "@tauri-apps/api/core"; +import { useCallback, useRef } from "react"; +import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store"; +import { determineFileType, generateVideoId, parseProgressLine } from "@/utils"; +import { Command } from "@tauri-apps/plugin-shell"; +import { RawVideoInfo } from "@/types/video"; +import { useDeleteDownloadState, useSaveDownloadState, useSavePlaylistInfo, useSaveVideoInfo, useUpdateDownloadFilePath, useUpdateDownloadStatus } from "@/services/mutations"; +import { useQueryClient } from "@tanstack/react-query"; +import { useFetchAllDownloadStates } from "@/services/queries"; +import { platform } from "@tauri-apps/plugin-os"; +import { toast } from "sonner"; +import { useLogger } from "@/helpers/use-logger"; +import { ulid } from "ulid"; +import { sendNotification } from '@tauri-apps/plugin-notification'; +import { FetchVideoMetadataParams, StartDownloadParams } from "@/providers/appContextProvider"; +import { debounce } from "es-toolkit"; + +export default function useDownloader() { + const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates(); + const globalDownloadStates = useDownloadStatesStore((state) => state.downloadStates); + + const ffmpegPath = useBasePathsStore((state) => state.ffmpegPath); + const tempDownloadDirPath = useBasePathsStore((state) => state.tempDownloadDirPath); + const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath); + + const setSearchPid = useCurrentVideoMetadataStore((state) => state.setSearchPid); + + const { + max_parallel_downloads: MAX_PARALLEL_DOWNLOADS, + max_retries: MAX_RETRIES, + prefer_video_over_playlist: PREFER_VIDEO_OVER_PLAYLIST, + strict_downloadablity_check: STRICT_DOWNLOADABILITY_CHECK, + use_proxy: USE_PROXY, + proxy_url: PROXY_URL, + use_rate_limit: USE_RATE_LIMIT, + rate_limit: RATE_LIMIT, + video_format: VIDEO_FORMAT, + audio_format: AUDIO_FORMAT, + always_reencode_video: ALWAYS_REENCODE_VIDEO, + embed_video_metadata: EMBED_VIDEO_METADATA, + embed_audio_metadata: EMBED_AUDIO_METADATA, + embed_video_thumbnail: EMBED_VIDEO_THUMBNAIL, + embed_audio_thumbnail: EMBED_AUDIO_THUMBNAIL, + use_cookies: USE_COOKIES, + import_cookies_from: IMPORT_COOKIES_FROM, + cookies_browser: COOKIES_BROWSER, + cookies_file: COOKIES_FILE, + use_sponsorblock: USE_SPONSORBLOCK, + sponsorblock_mode: SPONSORBLOCK_MODE, + sponsorblock_remove: SPONSORBLOCK_REMOVE, + sponsorblock_mark: SPONSORBLOCK_MARK, + sponsorblock_remove_categories: SPONSORBLOCK_REMOVE_CATEGORIES, + sponsorblock_mark_categories: SPONSORBLOCK_MARK_CATEGORIES, + use_aria2: USE_ARIA2, + use_force_internet_protocol: USE_FORCE_INTERNET_PROTOCOL, + force_internet_protocol: FORCE_INTERNET_PROTOCOL, + use_custom_commands: USE_CUSTOM_COMMANDS, + custom_commands: CUSTOM_COMMANDS, + filename_template: FILENAME_TEMPLATE, + debug_mode: DEBUG_MODE, + log_verbose: LOG_VERBOSE, + log_warning: LOG_WARNING, + log_progress: LOG_PROGRESS, + enable_notifications: ENABLE_NOTIFICATIONS, + download_completion_notification: DOWNLOAD_COMPLETION_NOTIFICATION + } = useSettingsPageStatesStore(state => state.settings); + + const isErrorExpected = useDownloaderPageStatesStore((state) => state.isErrorExpected); + const setIsErrored = useDownloaderPageStatesStore((state) => state.setIsErrored); + const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected); + const setErroredDownloadId = useDownloaderPageStatesStore((state) => state.setErroredDownloadId); + + const LOG = useLogger(); + const currentPlatform = platform(); + + const queryClient = useQueryClient(); + const downloadStateSaver = useSaveDownloadState(); + const downloadStatusUpdater = useUpdateDownloadStatus(); + const downloadFilePathUpdater = useUpdateDownloadFilePath(); + const videoInfoSaver = useSaveVideoInfo(); + const downloadStateDeleter = useDeleteDownloadState(); + const playlistInfoSaver = useSavePlaylistInfo(); + + const ongoingDownloads = globalDownloadStates.filter(state => state.download_status === 'downloading' || state.download_status === 'starting'); + const queuedDownloads = globalDownloadStates.filter(state => state.download_status === 'queued').sort((a, b) => a.queue_index! - b.queue_index!); + + const isProcessingQueueRef = useRef(false); + const lastProcessedDownloadIdRef = useRef(null); + + const updateDownloadState = debounce((state: DownloadState) => { + downloadStateSaver.mutate(state, { + onSuccess: (data) => { + console.log("Download State saved successfully:", data); + queryClient.invalidateQueries({ queryKey: ['download-states'] }); + }, + onError: (error) => { + console.error("Failed to save download state:", error); + } + }); + }, 500); + + const fetchVideoMetadata = async (params: FetchVideoMetadataParams): Promise => { + const { url, formatId, playlistIndex, selectedSubtitles, resumeState, downloadConfig } = params; + try { + const args = [url, '--dump-single-json', '--no-warnings']; + if (formatId) args.push('--format', formatId); + if (selectedSubtitles) args.push('--embed-subs', '--sub-lang', selectedSubtitles); + if (playlistIndex) args.push('--playlist-items', playlistIndex); + if (PREFER_VIDEO_OVER_PLAYLIST && !playlistIndex) args.push('--no-playlist'); + if (STRICT_DOWNLOADABILITY_CHECK && !formatId) args.push('--check-all-formats'); + if (STRICT_DOWNLOADABILITY_CHECK && formatId) args.push('--check-formats'); + + if (currentPlatform === 'macos') { + args.push('--ffmpeg-location', '/Applications/NeoDLP.app/Contents/MacOS', '--js-runtimes', 'deno:/Applications/NeoDLP.app/Contents/MacOS/deno'); + } + + if ((USE_CUSTOM_COMMANDS && CUSTOM_COMMANDS && downloadConfig?.custom_command) || resumeState?.custom_command) { + let customCommandArgs = null; + if (resumeState?.custom_command) { + customCommandArgs = resumeState.custom_command; + } else if (CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig?.custom_command)) { + let customCommand = CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig?.custom_command); + customCommandArgs = customCommand ? customCommand.args : ''; + } + if (customCommandArgs && customCommandArgs.trim() !== '') args.push(...customCommandArgs.split(' ')); + } + + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_PROXY && PROXY_URL) args.push('--proxy', PROXY_URL); + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) { + if (FORCE_INTERNET_PROTOCOL === 'ipv4') { + args.push('--force-ipv4'); + } else if (FORCE_INTERNET_PROTOCOL === 'ipv6') { + args.push('--force-ipv6'); + } + } + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) { + if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) { + args.push('--cookies-from-browser', COOKIES_BROWSER); + } else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) { + args.push('--cookies', COOKIES_FILE); + } + } + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_SPONSORBLOCK || (resumeState?.sponsorblock_remove || resumeState?.sponsorblock_mark))) { + if (SPONSORBLOCK_MODE === 'remove' || resumeState?.sponsorblock_remove) { + let sponsorblockRemove = resumeState?.sponsorblock_remove || (SPONSORBLOCK_REMOVE === 'custom' ? ( + SPONSORBLOCK_REMOVE_CATEGORIES.length > 0 ? SPONSORBLOCK_REMOVE_CATEGORIES.join(',') : 'default' + ) : (SPONSORBLOCK_REMOVE)); + args.push('--sponsorblock-remove', sponsorblockRemove); + } else if (SPONSORBLOCK_MODE === 'mark' || resumeState?.sponsorblock_mark) { + let sponsorblockMark = resumeState?.sponsorblock_mark || (SPONSORBLOCK_MARK === 'custom' ? ( + SPONSORBLOCK_MARK_CATEGORIES.length > 0 ? SPONSORBLOCK_MARK_CATEGORIES.join(',') : 'default' + ) : (SPONSORBLOCK_MARK)); + args.push('--sponsorblock-mark', sponsorblockMark); + } + }; + const command = Command.sidecar('binaries/yt-dlp', args); + + let jsonOutput = ''; + + return new Promise((resolve) => { + command.stdout.on('data', line => { + jsonOutput += line; + }); + + command.on('close', async (data) => { + if (data.code !== 0) { + console.error(`yt-dlp failed to fetch metadata with code ${data.code}`); + LOG.error('NEODLP', `yt-dlp exited with code ${data.code} while fetching metadata for URL: ${url} (ignore if you manually cancelled)`); + resolve(null); + } else { + try { + const matchedJson = jsonOutput.match(/{.*}/); + if (!matchedJson) { + console.error(`Failed to match JSON: ${jsonOutput}`); + LOG.error('NEODLP', `Failed to parse metadata JSON for URL: ${url})`); + resolve(null); + return; + } + const parsedJson: RawVideoInfo = JSON.parse(matchedJson[0]); + resolve(parsedJson); + } + catch (e) { + console.error(`Failed to parse JSON: ${e}`); + LOG.error('NEODLP', `Failed to parse metadata JSON for URL: ${url}) with error: ${e}`); + resolve(null); + } + } + }); + + command.on('error', error => { + console.error(`Error fetching metadata: ${error}`); + LOG.error('NEODLP', `Error occurred while fetching metadata for URL: ${url} : ${error}`); + resolve(null); + }); + + LOG.info('NEODLP', `Fetching metadata for URL: ${url}, with args: ${args.join(' ')}`); + command.spawn().then(child => { + setSearchPid(child.pid); + }).catch(e => { + console.error(`Failed to spawn command: ${e}`); + LOG.error('NEODLP', `Failed to spawn yt-dlp process for fetching metadata for URL: ${url} : ${e}`); + resolve(null); + }); + }); + } catch (e) { + console.error(`Failed to fetch metadata: ${e}`); + LOG.error('NEODLP', `Failed to fetch metadata for URL: ${url} : ${e}`); + return null; + } + }; + + const startDownload = async (params: StartDownloadParams) => { + const { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems } = params; + LOG.info('NEODLP', `Initiating yt-dlp download for URL: ${url}`); + // set error states to default + setIsErrored(false); + setIsErrorExpected(false); + setErroredDownloadId(null); + + console.log('Starting download:', { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems }); + if (!ffmpegPath || !tempDownloadDirPath || !downloadDirPath) { + console.error('FFmpeg or download paths not found'); + return; + } + + const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_index) ? true : false; + const playlistIndex = isPlaylist ? (resumeState?.playlist_index?.toString() || playlistItems) : null; + let videoMetadata = await fetchVideoMetadata({ + url, + formatId: selectedFormat, + playlistIndex: isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined, + selectedSubtitles, + resumeState + }); + if (!videoMetadata) { + console.error('Failed to fetch video metadata'); + toast.error("Download Failed", { + description: "yt-dlp failed to fetch video metadata. Please try again later.", + }); + return; + } + + console.log('Video Metadata:', videoMetadata); + videoMetadata = isPlaylist ? videoMetadata.entries[0] : videoMetadata; + + const fileType = determineFileType(videoMetadata.vcodec, videoMetadata.acodec); + + if (fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) { + if (VIDEO_FORMAT !== 'auto' && (fileType === 'video+audio' || fileType === 'video')) videoMetadata.ext = VIDEO_FORMAT; + if (AUDIO_FORMAT !== 'auto' && fileType === 'audio') videoMetadata.ext = AUDIO_FORMAT; + } + + let configOutputFormat = null; + if (downloadConfig.output_format && downloadConfig.output_format !== 'auto') { + videoMetadata.ext = downloadConfig.output_format; + configOutputFormat = downloadConfig.output_format; + } + if (resumeState && resumeState.output_format) videoMetadata.ext = resumeState.output_format; + + const videoId = resumeState?.video_id || generateVideoId(videoMetadata.id, videoMetadata.webpage_url_domain); + const playlistId = isPlaylist ? (resumeState?.playlist_id || generateVideoId(videoMetadata.playlist_id, videoMetadata.webpage_url_domain)) : null; + const downloadId = resumeState?.download_id || ulid() /*generateDownloadId(videoMetadata.id, videoMetadata.webpage_url_domain)*/; + // const tempDownloadPathForYtdlp = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.%(ext)s`); + // const tempDownloadPath = await join(tempDownloadDirPath, `${downloadId}_${selectedFormat}.${videoMetadata.ext}`); + // let downloadFilePath = resumeState?.filepath || await join(downloadDirPath, sanitizeFilename(`${videoMetadata.title}_${videoMetadata.resolution || 'unknown'}[${videoMetadata.id}].${videoMetadata.ext}`)); + let downloadFilePath: string | null = null; + let processPid: number | null = null; + const args = [ + url, + '--newline', + '--progress-template', + 'status:%(progress.status)s,progress:%(progress._percent_str)s,speed:%(progress.speed)f,downloaded:%(progress.downloaded_bytes)d,total:%(progress.total_bytes)d,eta:%(progress.eta)d', + '--paths', + `temp:${tempDownloadDirPath}`, + '--paths', + `home:${downloadDirPath}`, + '--output', + `${FILENAME_TEMPLATE}[${downloadId}].%(ext)s`, + '--windows-filenames', + '--restrict-filenames', + '--exec', + 'after_move:echo Finalpath: {}', + '--format', + selectedFormat, + '--no-mtime', + '--retries', + MAX_RETRIES.toString(), + ]; + + if (currentPlatform === 'macos') { + args.push('--ffmpeg-location', '/Applications/NeoDLP.app/Contents/MacOS', '--js-runtimes', 'deno:/Applications/NeoDLP.app/Contents/MacOS/deno'); + } + + if (!DEBUG_MODE || (DEBUG_MODE && !LOG_WARNING)) { + args.push('--no-warnings'); + } + + if (DEBUG_MODE && LOG_VERBOSE) { + args.push('--verbose'); + } + + if (selectedSubtitles) { + args.push('--embed-subs', '--sub-lang', selectedSubtitles); + } + + if (isPlaylist && playlistIndex && typeof playlistIndex === 'string') { + args.push('--playlist-items', playlistIndex); + } + + let customCommandArgs = null; + if ((USE_CUSTOM_COMMANDS && CUSTOM_COMMANDS && downloadConfig.custom_command) || resumeState?.custom_command) { + if (resumeState?.custom_command) { + customCommandArgs = resumeState.custom_command; + } else if (CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig.custom_command)) { + let customCommand = CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfig.custom_command); + customCommandArgs = customCommand ? customCommand.args : ''; + } + + if (customCommandArgs && customCommandArgs.trim() !== '') args.push(...customCommandArgs.split(' ')); + } + + let outputFormat = null; + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && ((fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) || configOutputFormat || resumeState?.output_format)) { + const format = resumeState?.output_format || configOutputFormat; + + if (format) { + outputFormat = format; + } else if (fileType === 'audio' && AUDIO_FORMAT !== 'auto') { + outputFormat = AUDIO_FORMAT; + } else if ((fileType === 'video' || fileType === 'video+audio') && VIDEO_FORMAT !== 'auto') { + outputFormat = VIDEO_FORMAT; + } + + const recodeOrRemux = ALWAYS_REENCODE_VIDEO ? '--recode-video' : '--remux-video'; + const formatToUse = format || VIDEO_FORMAT; + + // Handle video+audio + if (fileType === 'video+audio' && (VIDEO_FORMAT !== 'auto' || format)) { + args.push(ALWAYS_REENCODE_VIDEO ? '--recode-video' : '--merge-output-format', formatToUse); + } + // Handle video only + else if (fileType === 'video' && (VIDEO_FORMAT !== 'auto' || format)) { + args.push(recodeOrRemux, formatToUse); + } + // Handle audio only + else if (fileType === 'audio' && (AUDIO_FORMAT !== 'auto' || format)) { + args.push('--extract-audio', '--audio-format', format || AUDIO_FORMAT); + } + // Handle unknown filetype + else if (fileType === 'unknown' && format) { + if (['mkv', 'mp4', 'webm'].includes(format)) { + args.push(recodeOrRemux, formatToUse); + } else if (['mp3', 'm4a', 'opus'].includes(format)) { + args.push('--extract-audio', '--audio-format', format); + } + } + } + + let embedMetadata = 0; + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (downloadConfig.embed_metadata || resumeState?.embed_metadata || EMBED_VIDEO_METADATA || EMBED_AUDIO_METADATA)) { + const shouldEmbedMetaForVideo = (fileType === 'video+audio' || fileType === 'video') && (downloadConfig.embed_metadata || resumeState?.embed_metadata || (EMBED_VIDEO_METADATA && downloadConfig.embed_metadata === null)); + const shouldEmbedMetaForAudio = fileType === 'audio' && (downloadConfig.embed_metadata || resumeState?.embed_metadata || (EMBED_AUDIO_METADATA && downloadConfig.embed_metadata === null)); + const shouldEmbedMetaForUnknown = fileType === 'unknown' && (downloadConfig.embed_metadata || resumeState?.embed_metadata); + + if (shouldEmbedMetaForUnknown || shouldEmbedMetaForVideo || shouldEmbedMetaForAudio) { + embedMetadata = 1; + args.push('--embed-metadata'); + } + } + + let embedThumbnail = 0; + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (downloadConfig.embed_thumbnail || resumeState?.embed_thumbnail || EMBED_VIDEO_THUMBNAIL || EMBED_AUDIO_THUMBNAIL)) { + const shouldEmbedThumbForVideo = (fileType === 'video+audio' || fileType === 'video') && (downloadConfig.embed_thumbnail || resumeState?.embed_thumbnail || (EMBED_VIDEO_THUMBNAIL && downloadConfig.embed_thumbnail === null)); + const shouldEmbedThumbForAudio = fileType === 'audio' && (downloadConfig.embed_thumbnail || resumeState?.embed_thumbnail || (EMBED_AUDIO_THUMBNAIL && downloadConfig.embed_thumbnail === null)); + const shouldEmbedThumbForUnknown = fileType === 'unknown' && (downloadConfig.embed_thumbnail || resumeState?.embed_thumbnail); + + if (shouldEmbedThumbForUnknown || shouldEmbedThumbForVideo || shouldEmbedThumbForAudio) { + embedThumbnail = 1; + args.push('--embed-thumbnail'); + } + } + + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_PROXY && PROXY_URL) { + args.push('--proxy', PROXY_URL); + } + + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_RATE_LIMIT && RATE_LIMIT) { + args.push('--limit-rate', `${RATE_LIMIT}`); + } + + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) { + if (FORCE_INTERNET_PROTOCOL === 'ipv4') { + args.push('--force-ipv4'); + } else if (FORCE_INTERNET_PROTOCOL === 'ipv6') { + args.push('--force-ipv6'); + } + } + + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) { + if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) { + args.push('--cookies-from-browser', COOKIES_BROWSER); + } else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) { + args.push('--cookies', COOKIES_FILE); + } + } + + let sponsorblockRemove = null; + let sponsorblockMark = null; + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && ((downloadConfig.sponsorblock && downloadConfig.sponsorblock !== 'auto') || resumeState?.sponsorblock_remove || resumeState?.sponsorblock_mark || USE_SPONSORBLOCK)) { + if (downloadConfig?.sponsorblock === 'remove' || resumeState?.sponsorblock_remove || (SPONSORBLOCK_MODE === 'remove' && !downloadConfig.sponsorblock)) { + sponsorblockRemove = resumeState?.sponsorblock_remove || (SPONSORBLOCK_REMOVE === 'custom' ? ( + SPONSORBLOCK_REMOVE_CATEGORIES.length > 0 ? SPONSORBLOCK_REMOVE_CATEGORIES.join(',') : 'default' + ) : (SPONSORBLOCK_REMOVE)); + args.push('--sponsorblock-remove', sponsorblockRemove); + } else if (downloadConfig?.sponsorblock === 'mark' || resumeState?.sponsorblock_mark || (SPONSORBLOCK_MODE === 'mark' && !downloadConfig.sponsorblock)) { + sponsorblockMark = resumeState?.sponsorblock_mark || (SPONSORBLOCK_MARK === 'custom' ? ( + SPONSORBLOCK_MARK_CATEGORIES.length > 0 ? SPONSORBLOCK_MARK_CATEGORIES.join(',') : 'default' + ) : (SPONSORBLOCK_MARK)); + args.push('--sponsorblock-mark', sponsorblockMark); + } + } + + let useAria2 = 0; + if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_ARIA2 || resumeState?.use_aria2)) { + useAria2 = 1; + args.push( + '--downloader', 'aria2c', + '--downloader', 'dash,m3u8:native', + '--downloader-args', 'aria2c:-c -j 16 -x 16 -s 16 -k 1M --check-certificate=false' + ); + LOG.warning('NEODLP', `Looks like you are using aria2 for this yt-dlp download: ${downloadId}. Make sure aria2 is installed on your system if you are on macOS for this to work. Also, pause/resume might not work as expected especially on windows (using aria2 is not recommended for most downloads).`); + } + + if (resumeState || (!USE_CUSTOM_COMMANDS && USE_ARIA2)) { + args.push('--continue'); + } else { + args.push('--no-continue'); + } + + console.log('Starting download with args:', args); + const command = Command.sidecar('binaries/yt-dlp', args); + + command.on('close', async (data) => { + if (data.code !== 0) { + console.error(`Download failed with code ${data.code}`); + LOG.error(`YT-DLP Download ${downloadId}`, `yt-dlp exited with code ${data.code} (ignore if you manually paused or cancelled the download)`); + if (!isErrorExpected) { + setIsErrored(true); + setErroredDownloadId(downloadId); + } + } else { + LOG.info(`YT-DLP Download ${downloadId}`, `yt-dlp exited with code ${data.code}`); + } + }); + + command.on('error', error => { + console.error(`Error: ${error}`); + LOG.error(`YT-DLP Download ${downloadId}`, `Error occurred: ${error}`); + setIsErrored(true); + setErroredDownloadId(downloadId); + }); + + command.stdout.on('data', line => { + if (line.startsWith('status:') || line.startsWith('[#')) { + // console.log(line); + if (DEBUG_MODE && LOG_PROGRESS) LOG.progress(`YT-DLP Download ${downloadId}`, line); + const currentProgress = parseProgressLine(line); + const state: DownloadState = { + download_id: downloadId, + download_status: 'downloading', + video_id: videoId, + format_id: selectedFormat, + subtitle_id: selectedSubtitles || null, + queue_index: null, + playlist_id: playlistId, + playlist_index: playlistIndex ? Number(playlistIndex) : null, + title: videoMetadata.title, + url: url, + host: videoMetadata.webpage_url_domain, + thumbnail: videoMetadata.thumbnail || null, + channel: videoMetadata.channel || null, + duration_string: videoMetadata.duration_string || null, + release_date: videoMetadata.release_date || null, + view_count: videoMetadata.view_count || null, + like_count: videoMetadata.like_count || null, + playlist_title: videoMetadata.playlist_title, + playlist_url: videoMetadata.playlist_webpage_url, + playlist_n_entries: videoMetadata.playlist_count || videoMetadata.n_entries, + playlist_channel: videoMetadata.playlist_channel || null, + resolution: videoMetadata.resolution || null, + ext: videoMetadata.ext || null, + abr: videoMetadata.abr || null, + vbr: videoMetadata.vbr || null, + acodec: videoMetadata.acodec || null, + vcodec: videoMetadata.vcodec || null, + dynamic_range: videoMetadata.dynamic_range || null, + process_id: processPid, + status: currentProgress.status || null, + progress: currentProgress.progress || null, + total: currentProgress.total || null, + downloaded: currentProgress.downloaded || null, + speed: currentProgress.speed || null, + eta: currentProgress.eta || null, + filepath: downloadFilePath, + filetype: determineFileType(videoMetadata.vcodec, videoMetadata.acodec) || null, + filesize: videoMetadata.filesize_approx || null, + output_format: outputFormat, + embed_metadata: embedMetadata, + embed_thumbnail: embedThumbnail, + sponsorblock_remove: sponsorblockRemove, + sponsorblock_mark: sponsorblockMark, + use_aria2: useAria2, + custom_command: customCommandArgs, + queue_config: null + }; + updateDownloadState(state); + } else { + // console.log(line); + if (line.trim() !== '') LOG.info(`YT-DLP Download ${downloadId}`, line); + + if (line.startsWith('Finalpath: ')) { + downloadFilePath = line.replace('Finalpath: ', '').trim().replace(/^"|"$/g, ''); + const downloadedFileExt = downloadFilePath.split('.').pop(); + + // Update completion status after a short delay to ensure database states are propagated correctly + console.log(`Download completed with ID: ${downloadId}, updating filepath and status after 2s delay...`); + setTimeout(async () => { + LOG.info('NEODLP', `yt-dlp download completed with id: ${downloadId}`); + downloadFilePathUpdater.mutate({ download_id: downloadId, filepath: downloadFilePath as string, ext: downloadedFileExt as string }, { + onSuccess: (data) => { + console.log("Download filepath updated successfully:", data); + queryClient.invalidateQueries({ queryKey: ['download-states'] }); + }, + onError: (error) => { + console.error("Failed to update download filepath:", error); + } + }); + + downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'completed' }, { + onSuccess: (data) => { + console.log("Download status updated successfully:", data); + queryClient.invalidateQueries({ queryKey: ['download-states'] }); + }, + onError: (error) => { + console.error("Failed to update download status:", error); + } + }); + + toast.success("Download Completed", { + description: `The download for "${videoMetadata.title}" has completed successfully.`, + }); + + if (ENABLE_NOTIFICATIONS && DOWNLOAD_COMPLETION_NOTIFICATION) { + sendNotification({ + title: "Download Completed", + body: `The download for "${videoMetadata.title}" has completed successfully.`, + }); + } + }, 2000); + } + } + }); + + try { + videoInfoSaver.mutate({ + video_id: videoId, + title: videoMetadata.title, + url: url, + host: videoMetadata.webpage_url_domain, + thumbnail: videoMetadata.thumbnail || null, + channel: videoMetadata.channel || videoMetadata.uploader || null, + duration_string: videoMetadata.duration_string || null, + release_date: videoMetadata.release_date || null, + view_count: videoMetadata.view_count || null, + like_count: videoMetadata.like_count || null + }, { + onSuccess: (data) => { + console.log("Video Info saved successfully:", data); + if (isPlaylist) { + playlistInfoSaver.mutate({ + playlist_id: playlistId ? playlistId : '', + playlist_title: videoMetadata.playlist_title, + playlist_url: videoMetadata.playlist_webpage_url, + playlist_n_entries: videoMetadata.playlist_count || videoMetadata.n_entries, + playlist_channel: videoMetadata.playlist_channel || null + }, { + onSuccess: (data) => { + console.log("Playlist Info saved successfully:", data); + }, + onError: (error) => { + console.error("Failed to save playlist info:", error); + } + }); + } + const state: DownloadState = { + download_id: downloadId, + download_status: (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? 'starting' : 'queued', + video_id: videoId, + format_id: selectedFormat, + subtitle_id: selectedSubtitles || null, + queue_index: (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? null : (queuedDownloads?.length || 0), + playlist_id: playlistId, + playlist_index: playlistIndex ? Number(playlistIndex) : null, + title: videoMetadata.title, + url: url, + host: videoMetadata.webpage_url_domain, + thumbnail: videoMetadata.thumbnail || null, + channel: videoMetadata.channel || null, + duration_string: videoMetadata.duration_string || null, + release_date: videoMetadata.release_date || null, + view_count: videoMetadata.view_count || null, + like_count: videoMetadata.like_count || null, + playlist_title: videoMetadata.playlist_title, + playlist_url: videoMetadata.playlist_webpage_url, + playlist_n_entries: videoMetadata.playlist_count || videoMetadata.n_entries, + playlist_channel: videoMetadata.playlist_channel || null, + resolution: resumeState?.resolution || null, + ext: resumeState?.ext || null, + abr: resumeState?.abr || null, + vbr: resumeState?.vbr || null, + acodec: resumeState?.acodec || null, + vcodec: resumeState?.vcodec || null, + dynamic_range: resumeState?.dynamic_range || null, + process_id: resumeState?.process_id || null, + status: resumeState?.status || null, + progress: resumeState?.progress || null, + total: resumeState?.total || null, + downloaded: resumeState?.downloaded || null, + speed: resumeState?.speed || null, + eta: resumeState?.eta || null, + filepath: downloadFilePath, + filetype: resumeState?.filetype || null, + filesize: resumeState?.filesize || null, + output_format: resumeState?.output_format || null, + embed_metadata: resumeState?.embed_metadata || 0, + embed_thumbnail: resumeState?.embed_thumbnail || 0, + sponsorblock_remove: resumeState?.sponsorblock_remove || null, + sponsorblock_mark: resumeState?.sponsorblock_mark || null, + use_aria2: resumeState?.use_aria2 || 0, + custom_command: resumeState?.custom_command || null, + queue_config: resumeState?.queue_config || ((!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? null : JSON.stringify(downloadConfig)) + } + downloadStateSaver.mutate(state, { + onSuccess: (data) => { + console.log("Download State saved successfully:", data); + queryClient.invalidateQueries({ queryKey: ['download-states'] }); + }, + onError: (error) => { + console.error("Failed to save download state:", error); + } + }); + }, + onError: (error) => { + console.error("Failed to save video info:", error); + } + }); + + if (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) { + LOG.info('NEODLP', `Starting yt-dlp download with args: ${args.join(' ')}`); + if (!DEBUG_MODE || (DEBUG_MODE && !LOG_PROGRESS)) LOG.warning('NEODLP', `Progress logs are hidden. Enable 'Debug Mode > Log Progress' in Settings to unhide.`); + const child = await command.spawn(); + processPid = child.pid; + return Promise.resolve(); + } else { + console.log("Download is queued, not starting immediately."); + LOG.info('NEODLP', `Download queued with id: ${downloadId}`); + return Promise.resolve(); + } + } catch (e) { + console.error(`Failed to start download: ${e}`); + LOG.error('NEODLP', `Failed to start download for URL: ${url} with error: ${e}`); + throw e; + } + }; + + const pauseDownload = async (downloadState: DownloadState) => { + try { + LOG.info('NEODLP', `Pausing yt-dlp download with id: ${downloadState.download_id} (as per user request)`); + if ((downloadState.download_status === 'downloading' && downloadState.process_id) || (downloadState.download_status === 'starting' && downloadState.process_id)) { + setIsErrorExpected(true); // Set error expected to true to handle UI state + console.log("Killing process with PID:", downloadState.process_id); + await invoke('kill_all_process', { pid: downloadState.process_id }); + } + downloadStatusUpdater.mutate({ download_id: downloadState.download_id, download_status: 'paused' }, { + onSuccess: (data) => { + console.log("Download status updated successfully:", data); + queryClient.invalidateQueries({ queryKey: ['download-states'] }); + + /* re-check if the download is properly paused (if not try again after a small delay) + as the pause opertion happens within high throughput of operations and have a high chgance of failure. + */ + if (isSuccessFetchingDownloadStates && downloadStates.find(state => state.download_id === downloadState.download_id)?.download_status !== 'paused') { + console.log("Download status not updated to paused yet, retrying..."); + setTimeout(() => { + downloadStatusUpdater.mutate({ download_id: downloadState.download_id, download_status: 'paused' }, { + onSuccess: (data) => { + console.log("Download status updated successfully on retry:", data); + queryClient.invalidateQueries({ queryKey: ['download-states'] }); + }, + onError: (error) => { + console.error("Failed to update download status:", error); + } + }); + }, 200); + } + + // Reset the processing flag to ensure queue can be processed + isProcessingQueueRef.current = false; + + // Process the queue after a short delay to ensure state is updated + setTimeout(() => { + processQueuedDownloads(); + }, 1000); + }, + onError: (error) => { + console.error("Failed to update download status:", error); + } + }); + return Promise.resolve(); + } catch (e) { + console.error(`Failed to pause download: ${e}`); + LOG.error('NEODLP', `Failed to pause download with id: ${downloadState.download_id} with error: ${e}`); + isProcessingQueueRef.current = false; + throw e; + } + }; + + const resumeDownload = async (downloadState: DownloadState) => { + try { + LOG.info('NEODLP', `Resuming yt-dlp download with id: ${downloadState.download_id} (as per user request)`); + await startDownload({ + url: downloadState.playlist_id && downloadState.playlist_index ? downloadState.playlist_url : downloadState.url, + selectedFormat: downloadState.format_id, + downloadConfig: downloadState.queue_config ? JSON.parse(downloadState.queue_config) : { + output_format: null, + embed_metadata: null, + embed_thumbnail: null, + sponsorblock: null, + custom_command: null + }, + selectedSubtitles: downloadState.subtitle_id, + resumeState: downloadState + }); + return Promise.resolve(); + } catch (e) { + console.error(`Failed to resume download: ${e}`); + LOG.error('NEODLP', `Failed to resume download with id: ${downloadState.download_id} with error: ${e}`); + throw e; + } + }; + + const cancelDownload = async (downloadState: DownloadState) => { + try { + LOG.info('NEODLP', `Cancelling yt-dlp download with id: ${downloadState.download_id} (as per user request)`); + if ((downloadState.download_status === 'downloading' && downloadState.process_id) || (downloadState.download_status === 'starting' && downloadState.process_id)) { + setIsErrorExpected(true); // Set error expected to true to handle UI state + console.log("Killing process with PID:", downloadState.process_id); + await invoke('kill_all_process', { pid: downloadState.process_id }); + } + downloadStateDeleter.mutate(downloadState.download_id, { + onSuccess: (data) => { + console.log("Download State deleted successfully:", data); + queryClient.invalidateQueries({ queryKey: ['download-states'] }); + // Reset processing flag and trigger queue processing + isProcessingQueueRef.current = false; + + // Process the queue after a short delay + setTimeout(() => { + processQueuedDownloads(); + }, 1000); + }, + onError: (error) => { + console.error("Failed to delete download state:", error); + isProcessingQueueRef.current = false; + } + }); + return Promise.resolve(); + } catch (e) { + console.error(`Failed to cancel download: ${e}`); + LOG.error('NEODLP', `Failed to cancel download with id: ${downloadState.download_id} with error: ${e}`); + throw e; + } + } + + const processQueuedDownloads = useCallback(async () => { + // Prevent concurrent processing + if (isProcessingQueueRef.current) { + console.log("Queue processing already in progress, skipping..."); + return; + } + + // Check if we can process more downloads + if (!queuedDownloads?.length || ongoingDownloads?.length >= MAX_PARALLEL_DOWNLOADS) { + return; + } + + try { + isProcessingQueueRef.current = true; + console.log("Processing download queue..."); + + // Get the first download in queue + const downloadToStart = queuedDownloads[0]; + + // Skip if we just processed this download to prevent loops + if (lastProcessedDownloadIdRef.current === downloadToStart.download_id) { + console.log("Skipping recently processed download:", downloadToStart.download_id); + return; + } + + // Double-check current state from global state + const currentState = globalDownloadStates.find( + state => state.download_id === downloadToStart.download_id + ); + + if (!currentState || currentState.download_status !== 'queued') { + console.log("Download no longer in queued state:", downloadToStart.download_id); + return; + } + + console.log("Starting queued download:", downloadToStart.download_id); + LOG.info('NEODLP', `Starting queued download with id: ${downloadToStart.download_id}`); + lastProcessedDownloadIdRef.current = downloadToStart.download_id; + + await downloadStatusUpdater.mutateAsync({ + download_id: downloadToStart.download_id, + download_status: 'starting' + }); + + await queryClient.invalidateQueries({ queryKey: ['download-states'] }); + + await startDownload({ + url: downloadToStart.url, + selectedFormat: downloadToStart.format_id, + downloadConfig: downloadToStart.queue_config ? JSON.parse(downloadToStart.queue_config) : { + output_format: null, + embed_metadata: null, + embed_thumbnail: null, + sponsorblock: null, + custom_command: null + }, + selectedSubtitles: downloadToStart.subtitle_id, + resumeState: downloadToStart + }); + } catch (error) { + console.error("Error processing download queue:", error); + LOG.error('NEODLP', `Error processing download queue: ${error}`); + } finally { + // Important: reset the processing flag + setTimeout(() => { + isProcessingQueueRef.current = false; + console.log("Queue processor released lock"); + }, 1000); + } + }, [queuedDownloads, ongoingDownloads, globalDownloadStates, queryClient]); + + return { fetchVideoMetadata, startDownload, pauseDownload, resumeDownload, cancelDownload, processQueuedDownloads }; +} diff --git a/src/pages/downloader.tsx b/src/pages/downloader.tsx index a4231ff..fcd1272 100644 --- a/src/pages/downloader.tsx +++ b/src/pages/downloader.tsx @@ -244,7 +244,7 @@ export default function DownloaderPage() { setSelectedPlaylistVideoIndex('1'); resetDownloadConfiguration(); - fetchVideoMetadata(values.url).then((metadata) => { + fetchVideoMetadata({ url: values.url }).then((metadata) => { if (!metadata || (metadata._type !== 'video' && metadata._type !== 'playlist') || (metadata && metadata._type === 'video' && metadata.formats.length <= 0) || (metadata && metadata._type === 'playlist' && metadata.entries.length <= 0)) { const showSearchError = useCurrentVideoMetadataStore.getState().showSearchError; if (showSearchError) { @@ -1206,21 +1206,20 @@ export default function DownloaderPage() { setIsStartingDownload(true); try { if (videoMetadata._type === 'playlist') { - await startDownload( - videoMetadata.original_url, - activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat, - downloadConfiguration, - selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null, - undefined, - selectedPlaylistVideoIndex - ); + await startDownload({ + url: videoMetadata.original_url, + selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat, + downloadConfig: downloadConfiguration, + selectedSubtitles: selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null, + playlistItems: selectedPlaylistVideoIndex + }); } else if (videoMetadata._type === 'video') { - await startDownload( - videoMetadata.webpage_url, - activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat, - downloadConfiguration, - selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null - ); + await startDownload({ + url: videoMetadata.webpage_url, + selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat, + downloadConfig: downloadConfiguration, + selectedSubtitles: selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null + }); } // toast({ // title: 'Download Initiated', diff --git a/src/providers/appContextProvider.tsx b/src/providers/appContextProvider.tsx index b825c44..994bf8d 100644 --- a/src/providers/appContextProvider.tsx +++ b/src/providers/appContextProvider.tsx @@ -3,20 +3,38 @@ import { DownloadConfiguration } from '@/types/settings'; import { RawVideoInfo } from '@/types/video'; import { createContext, useContext } from 'react'; +export interface FetchVideoMetadataParams { + url: string; + formatId?: string; + playlistIndex?: string; + selectedSubtitles?: string | null; + resumeState?: DownloadState; + downloadConfig?: DownloadConfiguration; +}; + +export interface StartDownloadParams { + url: string; + selectedFormat: string; + downloadConfig: DownloadConfiguration; + selectedSubtitles?: string | null; + resumeState?: DownloadState; + playlistItems?: string; +}; + interface AppContextType { - fetchVideoMetadata: (url: string, formatId?: string, playlistIndex?: string, selectedSubtitles?: string | null, resumeState?: DownloadState, downloadConfig?: DownloadConfiguration) => Promise; - startDownload: (url: string, selectedFormat: string, downloadConfig: DownloadConfiguration, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => Promise; - pauseDownload: (state: DownloadState) => Promise; - resumeDownload: (state: DownloadState) => Promise; - cancelDownload: (state: DownloadState) => Promise; + fetchVideoMetadata: (params: FetchVideoMetadataParams) => Promise; + startDownload: (params: StartDownloadParams) => Promise; + pauseDownload: (state: DownloadState) => Promise; + resumeDownload: (state: DownloadState) => Promise; + cancelDownload: (state: DownloadState) => Promise; } export const AppContext = createContext({ - fetchVideoMetadata: async () => (null), - startDownload: async () => {}, - pauseDownload: async () => {}, - resumeDownload: async () => {}, - cancelDownload: async () => {} + fetchVideoMetadata: async () => (null), + startDownload: async () => {}, + pauseDownload: async () => {}, + resumeDownload: async () => {}, + cancelDownload: async () => {} }); export const useAppContext = () => useContext(AppContext); diff --git a/src/utils.ts b/src/utils.ts index 3e75911..db7cae1 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -82,7 +82,7 @@ export const parseProgressLine = (line: string): DownloadProgress => { // Check if line is aria2c format only if (line.startsWith('[#') && line.includes('MiB') && line.includes('%')) { // Parse aria2c format: [#99f72b 2.5MiB/3.4MiB(75%) CN:1 DL:503KiB ETA:1s] - + // Extract progress percentage const progressMatch = line.match(/\((\d+(?:\.\d+)?)%\)/); if (progressMatch) { @@ -118,7 +118,7 @@ export const parseProgressLine = (line: string): DownloadProgress => { return progress as DownloadProgress; } - + // Original yt-dlp format: status:downloading,progress: 75.1%,speed:1022692.427018,downloaded:30289474,total:40331784,eta:9 line.split(',').forEach(pair => { const [key, value] = pair.split(':'); @@ -337,14 +337,14 @@ export const determineFileType = ( ) => { const videoCodec = (vcodec || '').toLowerCase(); const audioCodec = (acodec || '').toLowerCase(); - + const isNone = (str: string): boolean => { return ['none', 'n/a', '-', ''].includes(str); }; - + const hasVideo = !isNone(videoCodec); const hasAudio = !isNone(audioCodec); - + if (hasVideo && hasAudio) { return 'video+audio'; } else if (hasVideo) { @@ -362,14 +362,14 @@ export const fileFormatFilter = (filterType: 'video+audio' | 'video' | 'audio' | const audioExt = (format.audio_ext || '').toLowerCase(); const videoCodec = (format.vcodec || '').toLowerCase(); const audioCodec = (format.acodec || '').toLowerCase(); - + const isNone = (str: string): boolean => { return ['none', 'n/a', '-', ''].includes(str); }; const subtitleExts = ['srt', 'vtt', 'ass', 'ssa', 'sub', 'sbv']; const isSubtitle = subtitleExts.includes(format.ext); - + const hasVideo = !isNone(videoExt) || !isNone(videoCodec); const hasAudio = !isNone(audioExt) || !isNone(audioCodec); @@ -401,4 +401,4 @@ export const sortByBitrate = (formats: VideoFormat[] | undefined) => { // If neither has tbr, maintain original order return 0; }); -}; \ No newline at end of file +};