From 097839d919daf747abaef8581519253ac5efcb76 Mon Sep 17 00:00:00 2001 From: Subhamoy Biswas Date: Mon, 14 Jul 2025 13:39:59 +0530 Subject: [PATCH] (feat): added speed rate limit option in settings, improved download error handling and improved remove from library dialog --- src/App.tsx | 69 ++++++++++++++++++++- src/pages/library.tsx | 9 ++- src/pages/settings.tsx | 137 ++++++++++++++++++++++++++++++++--------- src/services/store.ts | 12 +++- src/types/settings.ts | 2 + src/types/store.ts | 6 ++ 6 files changed, 201 insertions(+), 34 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 0a5495c..f711e85 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,7 +7,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, 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, useDownloadStatesStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store"; +import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useKvPairsStatesStore, useSettingsPageStatesStore } from "@/services/store"; import { determineFileType, generateDownloadId, generateSafeFilePath, generateVideoId, isObjEmpty, parseProgressLine, sanitizeFilename } from "@/utils"; import { Command } from "@tauri-apps/plugin-shell"; import { RawVideoInfo } from "@/types/video"; @@ -25,8 +25,11 @@ import { useNavigate } from "react-router-dom"; import { platform } from "@tauri-apps/plugin-os"; import { useMacOsRegisterer } from "@/helpers/use-macos-registerer"; import useAppUpdater from "@/helpers/use-app-updater"; +import { useToast } from "@/hooks/use-toast"; export default function App({ children }: { children: React.ReactNode }) { + const { toast } = useToast(); + const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates(); const { data: settings, isSuccess: isSuccessFetchingSettings } = useFetchAllSettings(); const { data: kvPairs, isSuccess: isSuccessFetchingKvPairs } = useFetchAllkVPairs(); @@ -58,6 +61,8 @@ export default function App({ children }: { children: React.ReactNode }) { 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); @@ -65,6 +70,13 @@ export default function App({ children }: { children: React.ReactNode }) { const EMBED_AUDIO_METADATA = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata); const EMBED_AUDIO_THUMBNAIL = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail); + 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 appWindow = getCurrentWebviewWindow() const navigate = useNavigate(); const { updateYtDlp } = useYtDlpUpdater(); @@ -136,6 +148,11 @@ export default function App({ children }: { children: React.ReactNode }) { }; const startDownload = async (url: string, selectedFormat: string, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => { + // set error states to default + setIsErrored(false); + setIsErrorExpected(false); + setErroredDownloadId(null); + console.log('Starting download:', { url, selectedFormat, selectedSubtitles, resumeState, playlistItems }); if (!ffmpegPath || !tempDownloadDirPath || !downloadDirPath) { console.error('FFmpeg or download paths not found'); @@ -147,6 +164,11 @@ export default function App({ children }: { children: React.ReactNode }) { let videoMetadata = await fetchVideoMetadata(url, selectedFormat, isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined); if (!videoMetadata) { console.error('Failed to fetch video metadata'); + toast({ + title: 'Download Failed', + description: 'yt-dlp failed to fetch video metadata. Please try again later.', + variant: 'destructive', + }); return; } @@ -233,12 +255,20 @@ export default function App({ children }: { children: React.ReactNode }) { args.push('--proxy', PROXY_URL); } + if (USE_RATE_LIMIT && RATE_LIMIT) { + args.push('--limit-rate', `${RATE_LIMIT}`); + } + 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}`); + if (!isErrorExpected) { + setIsErrored(true); + setErroredDownloadId(downloadId); + } } else { downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'completed' }, { onSuccess: (data) => { @@ -430,6 +460,7 @@ export default function App({ children }: { children: React.ReactNode }) { const pauseDownload = async (downloadState: DownloadState) => { try { + 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' }, { @@ -474,6 +505,7 @@ export default function App({ children }: { children: React.ReactNode }) { const cancelDownload = async (downloadState: DownloadState) => { try { 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 }); } @@ -807,6 +839,41 @@ export default function App({ children }: { children: React.ReactNode }) { return () => clearTimeout(timeoutId); }, [processQueuedDownloads, ongoingDownloads, queuedDownloads]); + // show a toast and pause the download when yt-dlp exits unexpectedly + useEffect(() => { + if (isErrored && !isErrorExpected) { + toast({ + title: "Download Failed", + description: "yt-dlp exited unexpectedly. Please try again later", + variant: "destructive", + }); + 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 isErrorExpected state after 3 seconds + useEffect(() => { + if (isErrorExpected) { + const timeoutId = setTimeout(() => { + setIsErrorExpected(false); + }, 3000); + return () => clearTimeout(timeoutId); + } + }, [isErrorExpected, setIsErrorExpected]); + return ( diff --git a/src/pages/library.tsx b/src/pages/library.tsx index cc75348..3d85a35 100644 --- a/src/pages/library.tsx +++ b/src/pages/library.tsx @@ -6,7 +6,7 @@ import { Progress } from "@/components/ui/progress"; import { Separator } from "@/components/ui/separator"; import { useToast } from "@/hooks/use-toast"; import { useAppContext } from "@/providers/appContextProvider"; -import { useDownloadActionStatesStore, useDownloadStatesStore, useLibraryPageStatesStore } from "@/services/store"; +import { useDownloadActionStatesStore, useDownloaderPageStatesStore, useDownloadStatesStore, useLibraryPageStatesStore } from "@/services/store"; import { formatBitrate, formatCodec, formatDurationString, formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils"; import { AudioLines, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Loader2, Music, Pause, Play, Square, Trash2, Video, X } from "lucide-react"; import { invoke } from "@tauri-apps/api/core"; @@ -33,6 +33,8 @@ export default function LibraryPage() { const setIsCancelingDownload = useDownloadActionStatesStore(state => state.setIsCancelingDownload); const setIsDeleteFileChecked = useDownloadActionStatesStore(state => state.setIsDeleteFileChecked); + const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected); + const { pauseDownload, resumeDownload, cancelDownload } = useAppContext() const { toast } = useToast(); @@ -106,6 +108,7 @@ export default function LibraryPage() { const stopOngoingDownloads = async () => { if (ongoingDownloads.length > 0) { + setIsErrorExpected(true); // Set error expected to true to handle UI state try { await invoke('pause_ongoing_downloads').then(() => { queryClient.invalidateQueries({ queryKey: ['download-states'] }); @@ -275,9 +278,9 @@ export default function LibraryPage() { - Are you absolutely sure? + Remove from library? - This action cannot be undone! it will permanently remove this from downloads. + Are you sure you want to remove this download from the library? You can also delete the downloaded file by cheking the box below. This action cannot be undone.
{setIsDeleteFileChecked(state.download_id, !itemActionStates.isDeleteFileChecked)}} /> diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 8e2e9a2..4b1f2ed 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -26,6 +26,7 @@ import { SlidingButton } from "@/components/custom/slidingButton"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import * as fs from "@tauri-apps/plugin-fs"; import { join } from "@tauri-apps/api/path"; +import { formatSpeed } from "@/utils"; const websocketPortSchema = z.object({ port: z.coerce.number({ @@ -42,6 +43,17 @@ const proxyUrlSchema = z.object({ url: z.string().min(1, { message: "Proxy URL is required" }).url({ message: "Invalid URL format" }) }); +const rateLimitSchema = z.object({ + rate_limit: z.coerce.number({ + required_error: "Rate Limit is required", + invalid_type_error: "Rate Limit must be a valid number", + }).min(1024, { + message: "Rate Limit must be at least 1024 bytes/s (1 KB/s)" + }).max(104857600, { + message: "Rate Limit must be at most 104857600 bytes/s (100 MB/s)" + }), +}); + export default function SettingsPage() { const { toast } = useToast(); const { setTheme } = useTheme(); @@ -65,6 +77,8 @@ export default function SettingsPage() { const strictDownloadabilityCheck = useSettingsPageStatesStore(state => state.settings.strict_downloadablity_check); const useProxy = useSettingsPageStatesStore(state => state.settings.use_proxy); const proxyUrl = useSettingsPageStatesStore(state => state.settings.proxy_url); + const useRateLimit = useSettingsPageStatesStore(state => state.settings.use_rate_limit); + const rateLimit = useSettingsPageStatesStore(state => state.settings.rate_limit); const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format); const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format); const alwaysReencodeVideo = useSettingsPageStatesStore(state => state.settings.always_reencode_video); @@ -169,6 +183,33 @@ export default function SettingsPage() { } } + const rateLimitForm = useForm>({ + resolver: zodResolver(rateLimitSchema), + defaultValues: { + rate_limit: rateLimit, + }, + mode: "onChange", + }); + const watchedRateLimit = rateLimitForm.watch("rate_limit"); + const { errors: rateLimitFormErrors } = rateLimitForm.formState; + + function handleRateLimitSubmit(values: z.infer) { + try { + saveSettingsKey('rate_limit', values.rate_limit); + toast({ + title: "Rate Limit updated", + description: `Rate Limit changed to ${values.rate_limit} bytes/s`, + }); + } catch (error) { + console.error("Error changing rate limit:", error); + toast({ + title: "Failed to change rate limit", + description: "Please try again.", + variant: "destructive", + }); + } + } + interface Config { port: number; } @@ -577,36 +618,74 @@ export default function SettingsPage() { />
-
-
- - ( - - - - - - - - )} - /> - - - +
+ + ( + + + + + + + + )} + /> + + + +
+
+

Rate Limit

+

Limit download speed to prevent network congestion. Rate limit is applied per-download basis (not in the whole app)

+
+ saveSettingsKey('use_rate_limit', checked)} + /> +
+
+ + ( + + + + + + + + )} + /> + + +
diff --git a/src/services/store.ts b/src/services/store.ts index d1e09e4..e86b92a 100644 --- a/src/services/store.ts +++ b/src/services/store.ts @@ -49,13 +49,19 @@ export const useDownloaderPageStatesStore = create((s selectedCombinableAudioFormat: '', selectedSubtitles: [], selectedPlaylistVideoIndex: '1', + isErrored: false, + isErrorExpected: false, + erroredDownloadId: null, setActiveDownloadModeTab: (tab) => set(() => ({ activeDownloadModeTab: tab })), setIsStartingDownload: (isStarting) => set(() => ({ isStartingDownload: isStarting })), setSelectedDownloadFormat: (format) => set(() => ({ selectedDownloadFormat: format })), setSelectedCombinableVideoFormat: (format) => set(() => ({ selectedCombinableVideoFormat: format })), setSelectedCombinableAudioFormat: (format) => set(() => ({ selectedCombinableAudioFormat: format })), setSelectedSubtitles: (subtitles) => set(() => ({ selectedSubtitles: subtitles })), - setSelectedPlaylistVideoIndex: (index) => set(() => ({ selectedPlaylistVideoIndex: index })) + setSelectedPlaylistVideoIndex: (index) => set(() => ({ selectedPlaylistVideoIndex: index })), + setIsErrored: (isErrored) => set(() => ({ isErrored: isErrored })), + setIsErrorExpected: (isErrorExpected) => set(() => ({ isErrorExpected: isErrorExpected })), + setErroredDownloadId: (downloadId) => set(() => ({ erroredDownloadId: downloadId })), })); export const useLibraryPageStatesStore = create((set) => ({ @@ -122,6 +128,8 @@ export const useSettingsPageStatesStore = create((set) max_parallel_downloads: 2, use_proxy: false, proxy_url: '', + use_rate_limit: false, + rate_limit: 1048576, // 1 MB/s video_format: 'auto', audio_format: 'auto', always_reencode_video: false, @@ -163,6 +171,8 @@ export const useSettingsPageStatesStore = create((set) max_parallel_downloads: 2, use_proxy: false, proxy_url: '', + use_rate_limit: false, + rate_limit: 1048576, // 1 MB/s video_format: 'auto', audio_format: 'auto', always_reencode_video: false, diff --git a/src/types/settings.ts b/src/types/settings.ts index bdf41fd..2721334 100644 --- a/src/types/settings.ts +++ b/src/types/settings.ts @@ -13,6 +13,8 @@ export interface Settings { strict_downloadablity_check: boolean; use_proxy: boolean; proxy_url: string; + use_rate_limit: boolean; + rate_limit: number; video_format: string; audio_format: string; always_reencode_video: boolean; diff --git a/src/types/store.ts b/src/types/store.ts index 55c504e..ef012fa 100644 --- a/src/types/store.ts +++ b/src/types/store.ts @@ -38,6 +38,9 @@ export interface DownloaderPageStatesStore { selectedCombinableAudioFormat: string; selectedSubtitles: string[]; selectedPlaylistVideoIndex: string; + isErrored: boolean; + isErrorExpected: boolean; + erroredDownloadId: string | null; setActiveDownloadModeTab: (tab: string) => void; setIsStartingDownload: (isStarting: boolean) => void; setSelectedDownloadFormat: (format: string) => void; @@ -45,6 +48,9 @@ export interface DownloaderPageStatesStore { setSelectedCombinableAudioFormat: (format: string) => void; setSelectedSubtitles: (subtitles: string[]) => void; setSelectedPlaylistVideoIndex: (index: string) => void; + setIsErrored: (isErrored: boolean) => void; + setIsErrorExpected: (isErrorExpected: boolean) => void; + setErroredDownloadId: (downloadId: string | null) => void; } export interface LibraryPageStatesStore {