mirror of
https://github.com/neosubhamoy/neodlp.git
synced 2026-03-22 16:05:50 +05:30
401 lines
20 KiB
TypeScript
401 lines
20 KiB
TypeScript
import { Button } from "@/components/ui/button";
|
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
import { Input } from "@/components/ui/input";
|
|
import { toast } from "sonner";
|
|
import { useAppContext } from "@/providers/appContextProvider";
|
|
import { useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
|
import { determineFileType, fileFormatFilter, getCommonFormats, getCommonSubtitles, getCommonAutoSubtitles, sortByBitrate, getMergedBestFormat } from "@/utils";
|
|
import { Loader2, PackageSearch, X, Clipboard } from "lucide-react";
|
|
import { useEffect, useRef } from "react";
|
|
import { VideoFormat } from "@/types/video";
|
|
import { z } from "zod";
|
|
import { useForm } from "react-hook-form";
|
|
import { zodResolver } from "@hookform/resolvers/zod"
|
|
import { Form, FormControl, FormField, FormItem, FormMessage } from "@/components/ui/form";
|
|
import { config } from "@/config";
|
|
import { invoke } from "@tauri-apps/api/core";
|
|
import { readText } from '@tauri-apps/plugin-clipboard-manager';
|
|
import { useTheme } from "@/providers/themeProvider";
|
|
import { VideoDownloader } from "@/components/pages/downloader/videoDownloader";
|
|
import { PlaylistDownloader } from "@/components/pages/downloader/playlistDownloader";
|
|
import { BottomBar } from "@/components/pages/downloader/bottomBar";
|
|
|
|
const searchFormSchema = z.object({
|
|
url: z.url({
|
|
error: (issue) => issue.input === undefined || issue.input === null || issue.input === ""
|
|
? "URL is required"
|
|
: "Invalid URL format"
|
|
}),
|
|
});
|
|
|
|
export default function DownloaderPage() {
|
|
const { fetchVideoMetadata } = useAppContext();
|
|
const { setTheme } = useTheme();
|
|
|
|
const videoUrl = useCurrentVideoMetadataStore((state) => state.videoUrl);
|
|
const videoMetadata = useCurrentVideoMetadataStore((state) => state.videoMetadata);
|
|
const isMetadataLoading = useCurrentVideoMetadataStore((state) => state.isMetadataLoading);
|
|
const requestedUrl = useCurrentVideoMetadataStore((state) => state.requestedUrl);
|
|
const autoSubmitSearch = useCurrentVideoMetadataStore((state) => state.autoSubmitSearch);
|
|
const searchPid = useCurrentVideoMetadataStore((state) => state.searchPid);
|
|
const setVideoUrl = useCurrentVideoMetadataStore((state) => state.setVideoUrl);
|
|
const setVideoMetadata = useCurrentVideoMetadataStore((state) => state.setVideoMetadata);
|
|
const setIsMetadataLoading = useCurrentVideoMetadataStore((state) => state.setIsMetadataLoading);
|
|
const setRequestedUrl = useCurrentVideoMetadataStore((state) => state.setRequestedUrl);
|
|
const setAutoSubmitSearch = useCurrentVideoMetadataStore((state) => state.setAutoSubmitSearch);
|
|
const setSearchPid = useCurrentVideoMetadataStore((state) => state.setSearchPid);
|
|
const setShowSearchError = useCurrentVideoMetadataStore((state) => state.setShowSearchError);
|
|
|
|
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
|
|
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
|
|
const selectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormats);
|
|
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
|
|
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
|
|
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
|
|
const setSelectedCombinableAudioFormats = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormats);
|
|
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
|
const setSelectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideos);
|
|
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
|
|
|
const appTheme = useSettingsPageStatesStore(state => state.settings.theme);
|
|
const appColorScheme = useSettingsPageStatesStore(state => state.settings.color_scheme);
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
const commonFormats = (() => {
|
|
if (videoMetadata?._type === 'video') {
|
|
return videoMetadata?.formats;
|
|
} else if (videoMetadata?._type === 'playlist') {
|
|
return getCommonFormats(videoMetadata.entries, selectedPlaylistVideos);
|
|
}
|
|
return [];
|
|
})();
|
|
|
|
const audioOnlyFormats = sortByBitrate(commonFormats.filter(fileFormatFilter('audio')));
|
|
const videoOnlyFormats = sortByBitrate(commonFormats.filter(fileFormatFilter('video')));
|
|
const combinedFormats = sortByBitrate(commonFormats.filter(fileFormatFilter('video+audio')));
|
|
|
|
const av1VideoFormats = videoMetadata?.webpage_url_domain === 'youtube.com' ? sortByBitrate(commonFormats.filter((format) => format.vcodec?.startsWith('av01'))) : [];
|
|
const opusAudioFormats = videoMetadata?.webpage_url_domain === 'youtube.com' ? sortByBitrate(commonFormats.filter((format) => format.acodec?.startsWith('opus'))) : [];
|
|
const qualityPresetFormats: VideoFormat[] | undefined = videoMetadata?.webpage_url_domain === 'youtube.com' ?
|
|
av1VideoFormats && opusAudioFormats ?
|
|
av1VideoFormats.map((av1Format) => {
|
|
const opusFormat = av1Format.format_note.startsWith('144p') || av1Format.format_note.startsWith('240p') ? opusAudioFormats[opusAudioFormats.length - 1] : opusAudioFormats[0]
|
|
return {
|
|
...av1Format,
|
|
format: `${av1Format.format}+${opusFormat?.format}`,
|
|
format_id: `${av1Format.format_id}+${opusFormat?.format_id}`,
|
|
format_note: `${av1Format.format_note}+${opusFormat?.format_note}`,
|
|
filesize_approx: av1Format.filesize_approx && opusFormat.filesize_approx ? av1Format.filesize_approx + opusFormat.filesize_approx : null,
|
|
acodec: opusFormat?.acodec,
|
|
audio_ext: opusFormat.audio_ext,
|
|
ext: 'webm',
|
|
tbr: av1Format.tbr && opusFormat.tbr ? av1Format.tbr + opusFormat.tbr : null,
|
|
};
|
|
})
|
|
: []
|
|
: [];
|
|
|
|
const allFilteredFormats = [...(audioOnlyFormats || []), ...(videoOnlyFormats || []), ...(combinedFormats || []), ...(qualityPresetFormats || [])];
|
|
const selectedFormat = (() => {
|
|
if (videoMetadata?._type === 'video') {
|
|
if (selectedDownloadFormat === 'best') {
|
|
return videoMetadata?.requested_downloads[0];
|
|
}
|
|
return allFilteredFormats.find(
|
|
(format) => format.format_id === selectedDownloadFormat
|
|
);
|
|
} else if (videoMetadata?._type === 'playlist') {
|
|
if (selectedDownloadFormat === 'best') {
|
|
return getMergedBestFormat(videoMetadata.entries, selectedPlaylistVideos);
|
|
}
|
|
return allFilteredFormats.find(
|
|
(format) => format.format_id === selectedDownloadFormat
|
|
);
|
|
}
|
|
})();
|
|
const selectedFormatFileType = determineFileType(selectedFormat?.vcodec, selectedFormat?.acodec);
|
|
const selectedVideoFormat = (() => {
|
|
if (videoMetadata?._type === 'video') {
|
|
return allFilteredFormats.find(
|
|
(format) => format.format_id === selectedCombinableVideoFormat
|
|
);
|
|
} else if (videoMetadata?._type === 'playlist') {
|
|
return allFilteredFormats.find(
|
|
(format) => format.format_id === selectedCombinableVideoFormat
|
|
);
|
|
}
|
|
})();
|
|
const selectedAudioFormats = (() => {
|
|
if (videoMetadata?._type === 'video') {
|
|
return allFilteredFormats.filter(
|
|
(format) => selectedCombinableAudioFormats.includes(format.format_id)
|
|
);
|
|
} else if (videoMetadata?._type === 'playlist') {
|
|
return allFilteredFormats.filter(
|
|
(format) => selectedCombinableAudioFormats.includes(format.format_id)
|
|
);
|
|
}
|
|
})();
|
|
|
|
const subtitles = (() => {
|
|
if (videoMetadata?._type === 'video') {
|
|
return videoMetadata?.subtitles || {};
|
|
} else if (videoMetadata?._type === 'playlist') {
|
|
return getCommonSubtitles(videoMetadata.entries, selectedPlaylistVideos);
|
|
}
|
|
return {};
|
|
})();
|
|
const filteredSubtitles = Object.fromEntries(Object.entries(subtitles).filter(([key]) => key !== 'live_chat'));
|
|
const autoSubtitles = (() => {
|
|
if (videoMetadata?._type === 'video') {
|
|
return videoMetadata?.automatic_captions || {};
|
|
} else if (videoMetadata?._type === 'playlist') {
|
|
return getCommonAutoSubtitles(videoMetadata.entries, selectedPlaylistVideos);
|
|
}
|
|
return {};
|
|
})();
|
|
const originalAutoSubtitles = Object.fromEntries(Object.entries(autoSubtitles).filter(([key]) => key.endsWith('-orig')));
|
|
|
|
const subtitleLanguages = Object.keys(filteredSubtitles).map(langCode => ({
|
|
code: langCode,
|
|
lang: filteredSubtitles[langCode][0].name || langCode
|
|
}));
|
|
const autoSubtitleLanguages = Object.keys(originalAutoSubtitles).map(langCode => ({
|
|
code: langCode,
|
|
lang: originalAutoSubtitles[langCode][0].name + ' [auto-generated]' || langCode + ' [auto-generated]'
|
|
}));
|
|
const allSubtitleLanguages = Array.from(new Set([...subtitleLanguages, ...autoSubtitleLanguages].map(lang => lang.code))).map(code => {
|
|
return [...subtitleLanguages, ...autoSubtitleLanguages].find(lang => lang.code === code)!;
|
|
});
|
|
|
|
const searchForm = useForm<z.infer<typeof searchFormSchema>>({
|
|
resolver: zodResolver(searchFormSchema),
|
|
defaultValues: {
|
|
url: videoUrl,
|
|
},
|
|
mode: "onChange",
|
|
})
|
|
const watchedUrl = searchForm.watch("url");
|
|
const { errors: searchFormErrors } = searchForm.formState;
|
|
|
|
function handleSearchSubmit(values: z.infer<typeof searchFormSchema>) {
|
|
setVideoMetadata(null);
|
|
setSearchPid(null);
|
|
setShowSearchError(true);
|
|
setIsMetadataLoading(true);
|
|
setSelectedDownloadFormat('best');
|
|
setSelectedCombinableVideoFormat('');
|
|
setSelectedCombinableAudioFormats([]);
|
|
setSelectedSubtitles([]);
|
|
setSelectedPlaylistVideos(["1"]);
|
|
resetDownloadConfiguration();
|
|
|
|
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) {
|
|
toast.error("Oops! No results found", {
|
|
description: "The provided URL does not contain any downloadable content or you are not connected to the internet. Please check the URL, your network connection and try again.",
|
|
});
|
|
}
|
|
}
|
|
if (metadata && (metadata._type === 'video' || metadata._type === 'playlist') && ((metadata._type === 'video' && metadata.formats.length > 0) || (metadata._type === 'playlist' && metadata.entries.length > 0))) setVideoMetadata(metadata);
|
|
if (metadata) console.log(metadata);
|
|
setIsMetadataLoading(false);
|
|
});
|
|
}
|
|
|
|
const cancelSearch = async (pid: number | null) => {
|
|
setShowSearchError(false);
|
|
if (pid) {
|
|
console.log("Killing process with PID:", pid);
|
|
await invoke('kill_all_process', { pid: pid });
|
|
}
|
|
setVideoMetadata(null);
|
|
setIsMetadataLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (watchedUrl !== videoUrl) {
|
|
setVideoUrl(watchedUrl);
|
|
}
|
|
}, [watchedUrl, videoUrl, setVideoUrl]);
|
|
|
|
useEffect(() => {
|
|
const handleAutoSubmitRequest = async () => {
|
|
// Update form and state when requestedUrl changes
|
|
if (requestedUrl && requestedUrl !== searchForm.getValues("url") && !isMetadataLoading) {
|
|
searchForm.setValue("url", requestedUrl);
|
|
setVideoUrl(requestedUrl);
|
|
}
|
|
|
|
// Auto-submit the form if the flag is set
|
|
if (autoSubmitSearch && requestedUrl) {
|
|
if (!isMetadataLoading) {
|
|
// trigger a validation check on the URL field first then get the result
|
|
await searchForm.trigger("url");
|
|
const isValidUrl = !searchForm.getFieldState("url").invalid;
|
|
|
|
if (isValidUrl) {
|
|
// Reset the flag first to prevent loops
|
|
setAutoSubmitSearch(false);
|
|
|
|
// Submit the form with a small delay to ensure UI is ready
|
|
setTimeout(() => {
|
|
handleSearchSubmit({ url: requestedUrl });
|
|
setRequestedUrl('');
|
|
}, 300);
|
|
} else {
|
|
// If URL is invalid, just reset the flag
|
|
setAutoSubmitSearch(false);
|
|
setRequestedUrl('');
|
|
toast.error("Invalid URL", {
|
|
description: "The provided URL is not valid.",
|
|
});
|
|
}
|
|
} else {
|
|
// If metadata is loading, just reset the flag
|
|
setAutoSubmitSearch(false);
|
|
setRequestedUrl('');
|
|
toast.info("Search in progress", {
|
|
description: "There's a search in progress, Please try again later.",
|
|
});
|
|
}
|
|
} else {
|
|
// If auto-submit is not set, reset the flag
|
|
setAutoSubmitSearch(false);
|
|
setRequestedUrl('');
|
|
}
|
|
}
|
|
handleAutoSubmitRequest();
|
|
}, [requestedUrl, autoSubmitSearch, isMetadataLoading]);
|
|
|
|
useEffect(() => {
|
|
const updateTheme = async () => {
|
|
setTheme(appTheme, appColorScheme);
|
|
}
|
|
updateTheme().catch(console.error);
|
|
}, [appTheme, appColorScheme]);
|
|
|
|
return (
|
|
<div className="container mx-auto p-4 space-y-4 relative" ref={containerRef}>
|
|
<Card className="gap-4">
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center"><PackageSearch className="size-5 mr-3 stroke-primary" />{config.appName} Search</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">
|
|
<Form {...searchForm}>
|
|
<form onSubmit={searchForm.handleSubmit(handleSearchSubmit)} className="flex gap-2 w-full" autoComplete="off">
|
|
<FormField
|
|
control={searchForm.control}
|
|
name="url"
|
|
disabled={isMetadataLoading}
|
|
render={({ field }) => (
|
|
<FormItem className="grow">
|
|
<FormControl>
|
|
<Input
|
|
className="focus-visible:ring-0"
|
|
placeholder="Enter Video/Playlist URL to Download"
|
|
{...field}
|
|
/>
|
|
</FormControl>
|
|
<FormMessage />
|
|
</FormItem>
|
|
)}
|
|
/>
|
|
{isMetadataLoading && (
|
|
<Button
|
|
type="button"
|
|
variant="destructive"
|
|
size="icon"
|
|
disabled={!isMetadataLoading}
|
|
onClick={() => cancelSearch(searchPid)}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
)}
|
|
{!isMetadataLoading && !videoUrl && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="border border-input"
|
|
disabled={isMetadataLoading}
|
|
onClick={async () => {
|
|
const text = await readText();
|
|
if (text) {
|
|
searchForm.setValue("url", text);
|
|
setVideoUrl(text);
|
|
}
|
|
}}
|
|
>
|
|
<Clipboard className="size-4" />
|
|
</Button>
|
|
)}
|
|
{!isMetadataLoading && videoUrl && (
|
|
<Button
|
|
type="button"
|
|
variant="ghost"
|
|
size="icon"
|
|
className="border border-input"
|
|
disabled={isMetadataLoading}
|
|
onClick={() => {
|
|
searchForm.setValue("url", '');
|
|
setVideoUrl('');
|
|
}}
|
|
>
|
|
<X className="size-4" />
|
|
</Button>
|
|
)}
|
|
<Button
|
|
type="submit"
|
|
disabled={!videoUrl || Object.keys(searchFormErrors).length > 0 || isMetadataLoading}
|
|
>
|
|
{isMetadataLoading ? (
|
|
<>
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
Searching
|
|
</>
|
|
) : (
|
|
'Search'
|
|
)}
|
|
</Button>
|
|
</form>
|
|
</Form>
|
|
</CardContent>
|
|
</Card>
|
|
{!isMetadataLoading && videoMetadata && videoMetadata._type === 'video' && (
|
|
<VideoDownloader
|
|
videoMetadata={videoMetadata}
|
|
audioOnlyFormats={audioOnlyFormats}
|
|
videoOnlyFormats={videoOnlyFormats}
|
|
combinedFormats={combinedFormats}
|
|
qualityPresetFormats={qualityPresetFormats}
|
|
subtitleLanguages={allSubtitleLanguages}
|
|
/>
|
|
)}
|
|
{!isMetadataLoading && videoMetadata && videoMetadata._type === 'playlist' && (
|
|
<PlaylistDownloader
|
|
videoMetadata={videoMetadata}
|
|
audioOnlyFormats={audioOnlyFormats}
|
|
videoOnlyFormats={videoOnlyFormats}
|
|
combinedFormats={combinedFormats}
|
|
qualityPresetFormats={qualityPresetFormats}
|
|
subtitleLanguages={allSubtitleLanguages}
|
|
/>
|
|
)}
|
|
{!isMetadataLoading && videoMetadata && selectedDownloadFormat && (
|
|
<BottomBar
|
|
videoMetadata={videoMetadata}
|
|
selectedFormat={selectedFormat}
|
|
selectedFormatFileType={selectedFormatFileType}
|
|
selectedVideoFormat={selectedVideoFormat}
|
|
selectedAudioFormats={selectedAudioFormats}
|
|
containerRef={containerRef}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|