mirror of
https://github.com/neosubhamoy/neodlp.git
synced 2026-02-04 15:22:23 +05:30
feat: added support for full-playlist/selective-batch downloading #9
This commit is contained in:
@@ -36,7 +36,7 @@ const PlaylistSelectionGroupItem = React.forwardRef<
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"relative w-full rounded-lg border-2 border-border bg-background p-2 shadow-sm transition-all",
|
||||
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-primary/10",
|
||||
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10",
|
||||
"hover:bg-muted/70",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
|
||||
@@ -3,7 +3,6 @@ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
|
||||
import { type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { toggleVariants } from "@/components/ui/toggle";
|
||||
import { Checkbox } from "@/components/ui/checkbox";
|
||||
import { AspectRatio } from "@/components/ui/aspect-ratio";
|
||||
import { ProxyImage } from "@/components/custom/proxyImage";
|
||||
import { Clock } from "lucide-react";
|
||||
@@ -11,7 +10,6 @@ import clsx from "clsx";
|
||||
import { formatDurationString } from "@/utils";
|
||||
import { RawVideoInfo } from "@/types/video";
|
||||
|
||||
// Create a context to share toggle group props
|
||||
const PlaylistToggleGroupContext = React.createContext<
|
||||
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
|
||||
>({
|
||||
@@ -20,19 +18,16 @@ const PlaylistToggleGroupContext = React.createContext<
|
||||
toggleType: "multiple",
|
||||
});
|
||||
|
||||
// Helper type for the PlaylistToggleGroup
|
||||
type PlaylistToggleGroupProps =
|
||||
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||
type PlaylistToggleGroupProps =
|
||||
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||
VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void })
|
||||
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
|
||||
VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
|
||||
|
||||
// Main PlaylistToggleGroup component with proper type handling
|
||||
export const PlaylistToggleGroup = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
|
||||
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
|
||||
PlaylistToggleGroupProps
|
||||
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
|
||||
// Pass props based on the type
|
||||
if (type === "single") {
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
@@ -47,7 +42,7 @@ export const PlaylistToggleGroup = React.forwardRef<
|
||||
</ToggleGroupPrimitive.Root>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Root
|
||||
ref={ref}
|
||||
@@ -63,106 +58,48 @@ export const PlaylistToggleGroup = React.forwardRef<
|
||||
});
|
||||
PlaylistToggleGroup.displayName = "PlaylistToggleGroup";
|
||||
|
||||
// Rest of your component remains the same
|
||||
// PlaylistToggleGroupItem component with checkbox and item layout
|
||||
export const PlaylistToggleGroupItem = React.forwardRef<
|
||||
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
|
||||
VariantProps<typeof toggleVariants> & {
|
||||
video: RawVideoInfo;
|
||||
}
|
||||
>(({ className, children, variant, size, video, value, ...props }, ref) => {
|
||||
const [isHovered, setIsHovered] = React.useState(false);
|
||||
const [checked, setChecked] = React.useState(false);
|
||||
|
||||
// Instead of a ref + useEffect approach
|
||||
const [itemElement, setItemElement] = React.useState<HTMLButtonElement | null>(null);
|
||||
|
||||
// Handle checkbox click separately by simulating a click on the parent item
|
||||
const handleCheckboxClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.stopPropagation();
|
||||
|
||||
// Manually trigger the item's click to toggle selection
|
||||
if (itemElement) {
|
||||
// This simulates a click on the toggle item itself
|
||||
itemElement.click();
|
||||
}
|
||||
};
|
||||
|
||||
// Use an effect that triggers when itemElement changes
|
||||
React.useEffect(() => {
|
||||
if (itemElement) {
|
||||
const observer = new MutationObserver((mutations) => {
|
||||
mutations.forEach((mutation) => {
|
||||
if (mutation.attributeName === 'data-state') {
|
||||
setChecked(itemElement.getAttribute('data-state') === 'on');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
setChecked(itemElement.getAttribute('data-state') === 'on');
|
||||
observer.observe(itemElement, { attributes: true });
|
||||
|
||||
return () => observer.disconnect();
|
||||
}
|
||||
}, [itemElement]);
|
||||
|
||||
return (
|
||||
<ToggleGroupPrimitive.Item
|
||||
ref={(el) => {
|
||||
// Handle both our ref and the forwarded ref
|
||||
if (typeof ref === 'function') {
|
||||
ref(el);
|
||||
} else if (ref) {
|
||||
ref.current = el;
|
||||
}
|
||||
setItemElement(el);
|
||||
}}
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"flex w-full p-2 rounded-md transition-colors border-2 border-border",
|
||||
"hover:bg-muted/50 data-[state=on]:bg-muted/70",
|
||||
"flex w-full p-2 rounded-lg transition-colors border-2 border-border",
|
||||
"hover:bg-muted/70 data-[state=on]:bg-primary/10",
|
||||
"data-[state=on]:border-primary",
|
||||
className
|
||||
)}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
value={value}
|
||||
{...props}
|
||||
>
|
||||
|
||||
<div className="flex gap-2 w-full relative">
|
||||
<div className="absolute top-2 left-2 z-10">
|
||||
<Checkbox
|
||||
checked={checked}
|
||||
onClick={handleCheckboxClick}
|
||||
className={cn(
|
||||
"transition-opacity",
|
||||
isHovered || checked ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-[7rem] xl:w-[10rem]">
|
||||
<AspectRatio
|
||||
ratio={16 / 9}
|
||||
<div className="w-28 xl:w-40">
|
||||
<AspectRatio
|
||||
ratio={16 / 9}
|
||||
className={clsx(
|
||||
"w-full rounded overflow-hidden border border-border",
|
||||
"w-full rounded overflow-hidden border border-border",
|
||||
video.aspect_ratio && video.aspect_ratio === 0.56 && "relative"
|
||||
)}
|
||||
>
|
||||
<ProxyImage
|
||||
src={video.thumbnail}
|
||||
alt="thumbnail"
|
||||
<ProxyImage
|
||||
src={video.thumbnail}
|
||||
alt="thumbnail"
|
||||
className={clsx(
|
||||
video.aspect_ratio && video.aspect_ratio === 0.56 &&
|
||||
video.aspect_ratio && video.aspect_ratio === 0.56 &&
|
||||
"absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||
)}
|
||||
)}
|
||||
/>
|
||||
</AspectRatio>
|
||||
</div>
|
||||
|
||||
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start">
|
||||
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1">{video.title}</h3>
|
||||
<p className="text-xs text-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p>
|
||||
|
||||
<div className="flex w-40 lg:w-48 xl:w-60 flex-col items-start text-start">
|
||||
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1" title={video.title}>{video.title}</h3>
|
||||
<p className="text-xs text-nowrap w-full overflow-hidden text-ellipsis text-muted-foreground mb-2" title={video.creator || video.channel || video.uploader || 'unknown'}>{video.creator || video.channel || video.uploader || 'unknown'}</p>
|
||||
<div className="flex items-center">
|
||||
<span className="text-xs text-muted-foreground flex items-center pr-3">
|
||||
<Clock className="w-4 h-4 mr-2"/>
|
||||
@@ -174,4 +111,4 @@ export const PlaylistToggleGroupItem = React.forwardRef<
|
||||
</ToggleGroupPrimitive.Item>
|
||||
);
|
||||
});
|
||||
PlaylistToggleGroupItem.displayName = "PlaylistToggleGroupItem";
|
||||
PlaylistToggleGroupItem.displayName = "PlaylistToggleGroupItem";
|
||||
|
||||
@@ -6,7 +6,6 @@ import { formatBitrate, formatFileSize } from "@/utils";
|
||||
import { Loader2, Music, Video, File, AlertCircleIcon, Settings2 } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import { RawVideoInfo, VideoFormat } from "@/types/video";
|
||||
// import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||
@@ -282,17 +281,21 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
|
||||
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
|
||||
const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat);
|
||||
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
|
||||
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
|
||||
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
|
||||
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
|
||||
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
|
||||
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
|
||||
|
||||
const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format);
|
||||
const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format);
|
||||
const useSponsorblock = useSettingsPageStatesStore(state => state.settings.use_sponsorblock);
|
||||
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
|
||||
|
||||
const bottomBarRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const isPlaylist = videoMetadata._type === 'playlist';
|
||||
const isMultiplePlaylistItems = isPlaylist && selectedPlaylistVideos.length > 1;
|
||||
|
||||
let selectedFormatExtensionMsg = 'Auto - unknown';
|
||||
if (activeDownloadModeTab === 'combine') {
|
||||
if (downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
|
||||
@@ -327,8 +330,8 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
|
||||
|
||||
let selectedFormatDynamicRangeMsg = '';
|
||||
if (activeDownloadModeTab === 'combine') {
|
||||
selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' ? selectedVideoFormat.dynamic_range : '';
|
||||
} else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR') {
|
||||
selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' && selectedVideoFormat.dynamic_range !== 'auto' ? selectedVideoFormat.dynamic_range : '';
|
||||
} else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR' && selectedFormat.dynamic_range !== 'auto') {
|
||||
selectedFormatDynamicRangeMsg = selectedFormat.dynamic_range;
|
||||
}
|
||||
|
||||
@@ -342,13 +345,13 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
|
||||
let selectedFormatFinalMsg = '';
|
||||
if (activeDownloadModeTab === 'combine') {
|
||||
if (selectedCombinableVideoFormat && selectedCombinableAudioFormat) {
|
||||
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} • ${selectedFormatFileSizeMsg}`;
|
||||
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} ${useSponsorblock || (downloadConfiguration.sponsorblock && downloadConfiguration.sponsorblock !== 'auto') ? `• SPBLOCK` : ''} • ${selectedFormatFileSizeMsg}`;
|
||||
} else {
|
||||
selectedFormatFinalMsg = `Choose a video and audio stream to combine`;
|
||||
}
|
||||
} else {
|
||||
if (selectedFormat) {
|
||||
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} • ${selectedFormatFileSizeMsg}`;
|
||||
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} ${useSponsorblock || (downloadConfiguration.sponsorblock && downloadConfiguration.sponsorblock !== 'auto') ? `• SPBLOCK` : ''} • ${selectedFormatFileSizeMsg}`;
|
||||
} else {
|
||||
selectedFormatFinalMsg = `Choose a stream to download`;
|
||||
}
|
||||
@@ -397,7 +400,7 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<span className="text-sm text-nowrap max-w-120 xl:max-w-200 overflow-hidden text-ellipsis">{videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].title : 'Unknown' }</span>
|
||||
<span className="text-sm text-nowrap max-w-120 xl:max-w-200 overflow-hidden text-ellipsis">{videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? selectedPlaylistVideos.length === 1 ? videoMetadata.entries[Number(selectedPlaylistVideos[0]) - 1].title : `${selectedPlaylistVideos.length} Items` : 'Unknown' }</span>
|
||||
<span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -410,10 +413,14 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
|
||||
if (videoMetadata._type === 'playlist') {
|
||||
await startDownload({
|
||||
url: videoMetadata.original_url,
|
||||
selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat,
|
||||
selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat,
|
||||
downloadConfig: downloadConfiguration,
|
||||
selectedSubtitles: selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
|
||||
playlistItems: selectedPlaylistVideoIndex
|
||||
playlistItems: selectedPlaylistVideos.sort((a, b) => Number(a) - Number(b)).join(','),
|
||||
overrideOptions: isMultiplePlaylistItems ? {
|
||||
filesize: activeDownloadModeTab === 'combine' ? (selectedVideoFormat?.filesize_approx && selectedAudioFormat?.filesize_approx ? selectedVideoFormat.filesize_approx + selectedAudioFormat.filesize_approx : undefined) : selectedFormat?.filesize_approx ? selectedFormat.filesize_approx : undefined,
|
||||
tbr: activeDownloadModeTab === 'combine' ? (selectedVideoFormat?.tbr && selectedAudioFormat?.tbr ? selectedVideoFormat.tbr + selectedAudioFormat.tbr : undefined) : selectedFormat?.tbr ? selectedFormat.tbr : undefined,
|
||||
} : undefined
|
||||
});
|
||||
} else if (videoMetadata._type === 'video') {
|
||||
await startDownload({
|
||||
|
||||
@@ -3,11 +3,12 @@ import { DownloadCloud, Info, ListVideo, AlertCircleIcon } from "lucide-react";
|
||||
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
|
||||
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
|
||||
import { RawVideoInfo, VideoFormat } from "@/types/video";
|
||||
// import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup";
|
||||
import { PlaylistSelectionGroup, PlaylistSelectionGroupItem } from "@/components/custom/playlistSelectionGroup";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
|
||||
import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup";
|
||||
import { getMergedBestFormat } from "@/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
|
||||
interface PlaylistPreviewSelectionProps {
|
||||
videoMetadata: RawVideoInfo;
|
||||
@@ -38,28 +39,61 @@ interface PlaylistDownloaderProps {
|
||||
}
|
||||
|
||||
function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionProps) {
|
||||
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
|
||||
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
|
||||
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
|
||||
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
|
||||
const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat);
|
||||
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
||||
const setSelectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideoIndex);
|
||||
const setSelectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideos);
|
||||
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||
|
||||
const totalVideos = videoMetadata.entries.filter((entry) => entry).length;
|
||||
const allVideoIndices = videoMetadata.entries.filter((entry) => entry).map((entry) => entry.playlist_index.toString());
|
||||
|
||||
return (
|
||||
<div className="flex flex-col w-full pr-4">
|
||||
<h3 className="text-sm mb-4 mt-2 flex items-center gap-2">
|
||||
<ListVideo className="w-4 h-4" />
|
||||
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
|
||||
</h3>
|
||||
<div className="flex items-center justify-between mb-4 mt-2">
|
||||
<h3 className="text-sm flex items-center gap-2">
|
||||
<ListVideo className="w-4 h-4" />
|
||||
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
|
||||
</h3>
|
||||
<div className="flex items-center space-x-2">
|
||||
<Switch
|
||||
id="select-all-videos"
|
||||
checked={selectedPlaylistVideos.length === totalVideos && totalVideos > 0}
|
||||
onCheckedChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedPlaylistVideos(allVideoIndices);
|
||||
} else {
|
||||
setSelectedPlaylistVideos(["1"]);
|
||||
}
|
||||
setSelectedDownloadFormat('best');
|
||||
setSelectedSubtitles([]);
|
||||
setSelectedCombinableVideoFormat('');
|
||||
setSelectedCombinableAudioFormat('');
|
||||
resetDownloadConfiguration();
|
||||
}}
|
||||
disabled={totalVideos <= 1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
|
||||
<h2 className="mb-1">{videoMetadata.entries[0].playlist_title ? videoMetadata.entries[0].playlist_title : 'UNTITLED'}</h2>
|
||||
<p className="text-muted-foreground text-xs mb-4">{videoMetadata.entries[0].playlist_creator || videoMetadata.entries[0].playlist_channel || videoMetadata.entries[0].playlist_uploader || 'unknown'}</p>
|
||||
{/* <PlaylistToggleGroup
|
||||
<PlaylistToggleGroup
|
||||
className="mb-2"
|
||||
type="multiple"
|
||||
value={selectedVideos}
|
||||
onValueChange={setSelectedVideos}
|
||||
value={selectedPlaylistVideos}
|
||||
onValueChange={(value: string[]) => {
|
||||
if (value.length > 0) {
|
||||
setSelectedPlaylistVideos(value);
|
||||
setSelectedDownloadFormat('best');
|
||||
setSelectedSubtitles([]);
|
||||
setSelectedCombinableVideoFormat('');
|
||||
setSelectedCombinableAudioFormat('');
|
||||
resetDownloadConfiguration();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{videoMetadata.entries.map((entry) => entry ? (
|
||||
<PlaylistToggleGroupItem
|
||||
@@ -68,27 +102,7 @@ function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionPro
|
||||
video={entry}
|
||||
/>
|
||||
) : null)}
|
||||
</PlaylistToggleGroup> */}
|
||||
<PlaylistSelectionGroup
|
||||
className="mb-2"
|
||||
value={selectedPlaylistVideoIndex}
|
||||
onValueChange={(value) => {
|
||||
setSelectedPlaylistVideoIndex(value);
|
||||
setSelectedDownloadFormat('best');
|
||||
setSelectedSubtitles([]);
|
||||
setSelectedCombinableVideoFormat('');
|
||||
setSelectedCombinableAudioFormat('');
|
||||
resetDownloadConfiguration();
|
||||
}}
|
||||
>
|
||||
{videoMetadata.entries.map((entry) => entry ? (
|
||||
<PlaylistSelectionGroupItem
|
||||
key={entry.playlist_index}
|
||||
value={entry.playlist_index.toString()}
|
||||
video={entry}
|
||||
/>
|
||||
) : null)}
|
||||
</PlaylistSelectionGroup>
|
||||
</PlaylistToggleGroup>
|
||||
<div className="flex items-center text-muted-foreground">
|
||||
<Info className="w-3 h-3 mr-2" />
|
||||
<span className="text-xs">Extracted from {videoMetadata.entries[0].extractor ? videoMetadata.entries[0].extractor.charAt(0).toUpperCase() + videoMetadata.entries[0].extractor.slice(1) : 'Unknown'}</span>
|
||||
@@ -102,7 +116,7 @@ function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionPro
|
||||
function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: SelectivePlaylistDownloadProps) {
|
||||
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
|
||||
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
|
||||
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
|
||||
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
|
||||
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
|
||||
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
||||
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||
@@ -120,16 +134,23 @@ function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyF
|
||||
>
|
||||
<p className="text-xs">Subtitle Languages</p>
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{subtitleLanguages.map((lang) => (
|
||||
<ToggleGroupItem
|
||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||
value={lang.code}
|
||||
size="sm"
|
||||
aria-label={lang.lang}
|
||||
key={lang.code}>
|
||||
{lang.lang}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
{subtitleLanguages.map((lang) => {
|
||||
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||
value={lang.code}
|
||||
size="sm"
|
||||
aria-label={lang.lang}
|
||||
key={lang.code}
|
||||
disabled={isDisabled}>
|
||||
{lang.lang}
|
||||
</ToggleGroupItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
@@ -149,7 +170,7 @@ function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyF
|
||||
<FormatSelectionGroupItem
|
||||
key="best"
|
||||
value="best"
|
||||
format={videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0]}
|
||||
format={getMergedBestFormat(videoMetadata.entries, selectedPlaylistVideos) as VideoFormat}
|
||||
/>
|
||||
</div>
|
||||
{qualityPresetFormats && qualityPresetFormats.length > 0 && (
|
||||
@@ -235,16 +256,23 @@ function CombinedPlaylistDownload({ audioOnlyFormats, videoOnlyFormats, subtitle
|
||||
>
|
||||
<p className="text-xs">Subtitle Languages</p>
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{subtitleLanguages.map((lang) => (
|
||||
<ToggleGroupItem
|
||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||
value={lang.code}
|
||||
size="sm"
|
||||
aria-label={lang.lang}
|
||||
key={lang.code}>
|
||||
{lang.lang}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
{subtitleLanguages.map((lang) => {
|
||||
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||
value={lang.code}
|
||||
size="sm"
|
||||
aria-label={lang.lang}
|
||||
key={lang.code}
|
||||
disabled={isDisabled}>
|
||||
{lang.lang}
|
||||
</ToggleGroupItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
|
||||
@@ -112,16 +112,23 @@ function SelectiveVideoDownload({ videoMetadata, audioOnlyFormats, videoOnlyForm
|
||||
>
|
||||
<p className="text-xs">Subtitle Languages</p>
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{subtitleLanguages.map((lang) => (
|
||||
<ToggleGroupItem
|
||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||
value={lang.code}
|
||||
size="sm"
|
||||
aria-label={lang.lang}
|
||||
key={lang.code}>
|
||||
{lang.lang}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
{subtitleLanguages.map((lang) => {
|
||||
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||
value={lang.code}
|
||||
size="sm"
|
||||
aria-label={lang.lang}
|
||||
key={lang.code}
|
||||
disabled={isDisabled}>
|
||||
{lang.lang}
|
||||
</ToggleGroupItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
@@ -227,16 +234,23 @@ function CombinedVideoDownload({ audioOnlyFormats, videoOnlyFormats, subtitleLan
|
||||
>
|
||||
<p className="text-xs">Subtitle Languages</p>
|
||||
<div className="flex gap-2 flex-wrap items-center">
|
||||
{subtitleLanguages.map((lang) => (
|
||||
<ToggleGroupItem
|
||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||
value={lang.code}
|
||||
size="sm"
|
||||
aria-label={lang.lang}
|
||||
key={lang.code}>
|
||||
{lang.lang}
|
||||
</ToggleGroupItem>
|
||||
))}
|
||||
{subtitleLanguages.map((lang) => {
|
||||
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||
|
||||
return (
|
||||
<ToggleGroupItem
|
||||
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
|
||||
value={lang.code}
|
||||
size="sm"
|
||||
aria-label={lang.lang}
|
||||
key={lang.code}
|
||||
disabled={isDisabled}>
|
||||
{lang.lang}
|
||||
</ToggleGroupItem>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</ToggleGroup>
|
||||
)}
|
||||
|
||||
@@ -9,6 +9,7 @@ import { formatBitrate, formatCodec, formatDurationString, formatFileSize, pagin
|
||||
import { ArrowUpRightIcon, AudioLines, CircleArrowDown, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Music, Play, Search, Trash2, Video } from "lucide-react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import * as fs from "@tauri-apps/plugin-fs";
|
||||
import { dirname } from "@tauri-apps/api/path";
|
||||
import { DownloadState } from "@/types/download";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { useDeleteDownloadState } from "@/services/mutations";
|
||||
@@ -60,14 +61,31 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
|
||||
|
||||
const removeFromDownloads = async (downloadState: DownloadState, delete_file: boolean) => {
|
||||
if (delete_file && downloadState.filepath) {
|
||||
try {
|
||||
if (await fs.exists(downloadState.filepath)) {
|
||||
await fs.remove(downloadState.filepath);
|
||||
} else {
|
||||
console.error(`File not found: "${downloadState.filepath}"`);
|
||||
const isMutilplePlaylistItems = downloadState.playlist_id !== null &&
|
||||
downloadState.playlist_indices !== null &&
|
||||
downloadState.playlist_indices.includes(',');
|
||||
|
||||
if (isMutilplePlaylistItems) {
|
||||
const dirPath = await dirname(downloadState.filepath);
|
||||
try {
|
||||
if (await fs.exists(dirPath)) {
|
||||
await fs.remove(dirPath, { recursive: true });
|
||||
} else {
|
||||
console.error(`Directory not found: "${dirPath}"`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
if (await fs.exists(downloadState.filepath)) {
|
||||
await fs.remove(downloadState.filepath);
|
||||
} else {
|
||||
console.error(`File not found: "${downloadState.filepath}"`);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -77,11 +95,11 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
|
||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
||||
if (delete_file && downloadState.filepath) {
|
||||
toast.success("Deleted from downloads", {
|
||||
description: `The download for "${downloadState.title}" has been deleted successfully.`,
|
||||
description: `The download for ${isMutilplePlaylistItems ? 'playlist ' : ''}"${isMutilplePlaylistItems ? downloadState.playlist_title : downloadState.title}" has been deleted successfully.`,
|
||||
});
|
||||
} else {
|
||||
toast.success("Removed from downloads", {
|
||||
description: `The download for "${downloadState.title}" has been removed successfully.`,
|
||||
description: `The download for ${isMutilplePlaylistItems ? 'playlist ' : ''}"${isMutilplePlaylistItems ? downloadState.playlist_title : downloadState.title}" has been removed successfully.`,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -89,11 +107,11 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
|
||||
console.error("Failed to delete download state:", error);
|
||||
if (delete_file && downloadState.filepath) {
|
||||
toast.error("Failed to delete download", {
|
||||
description: `An error occurred while trying to delete the download for "${downloadState.title}".`,
|
||||
description: `An error occurred while trying to delete the download for ${isMutilplePlaylistItems ? 'playlist ' : ''}"${isMutilplePlaylistItems ? downloadState.playlist_title : downloadState.title}".`,
|
||||
});
|
||||
} else {
|
||||
toast.error("Failed to remove download", {
|
||||
description: `An error occurred while trying to remove the download for "${downloadState.title}".`,
|
||||
description: `An error occurred while trying to remove the download for ${isMutilplePlaylistItems ? 'playlist ' : ''}"${isMutilplePlaylistItems ? downloadState.playlist_title : downloadState.title}".`,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -125,31 +143,60 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
|
||||
isDeleteFileChecked: false,
|
||||
};
|
||||
|
||||
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
|
||||
const isMutilplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
|
||||
|
||||
return (
|
||||
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
|
||||
<div className="w-[30%] flex flex-col justify-between gap-2">
|
||||
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||
</AspectRatio>
|
||||
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
||||
<Video className="w-4 h-4 mr-2 stroke-primary" />
|
||||
)}
|
||||
{state.filetype && state.filetype === 'audio' && (
|
||||
<Music className="w-4 h-4 mr-2 stroke-primary" />
|
||||
)}
|
||||
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
||||
<File className="w-4 h-4 mr-2 stroke-primary" />
|
||||
)}
|
||||
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null}
|
||||
</span>
|
||||
{isMutilplePlaylistItems ? (
|
||||
<div className="w-full relative flex items-center justify-center mt-2">
|
||||
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||
</AspectRatio>
|
||||
<div className="w-[95%] aspect-video absolute -top-1 rounded-lg overflow-hidden border border-border mb-2 z-10">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-xs brightness-75" />
|
||||
</div>
|
||||
<div className="w-[87%] aspect-video absolute -top-2 rounded-lg overflow-hidden border border-border mb-2 z-0">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-sm brightness-50" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||
</AspectRatio>
|
||||
)}
|
||||
{isMutilplePlaylistItems ? (
|
||||
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||
<ListVideo className="w-4 h-4 mr-2 stroke-primary" /> Playlist ({state.playlist_indices?.split(',').length})
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
||||
<Video className="w-4 h-4 mr-2 stroke-primary" />
|
||||
)}
|
||||
{state.filetype && state.filetype === 'audio' && (
|
||||
<Music className="w-4 h-4 mr-2 stroke-primary" />
|
||||
)}
|
||||
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
||||
<File className="w-4 h-4 mr-2 stroke-primary" />
|
||||
)}
|
||||
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-between gap-2">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4 className="">{state.title}</h4>
|
||||
<p className="text-xs text-muted-foreground">{state.channel ? state.channel : 'unknown'} {state.host ? <><span className="text-primary">•</span> {state.host}</> : 'unknown'}</p>
|
||||
<h4 className="">{isMutilplePlaylistItems ? state.playlist_title : state.title}</h4>
|
||||
<p className="text-xs text-muted-foreground">{isMutilplePlaylistItems ? state.playlist_channel ?? 'unknown' : state.channel ?? 'unknown'} {state.host ? <><span className="text-primary">•</span> {state.host}</> : 'unknown'}</p>
|
||||
<div className="flex items-center mt-1">
|
||||
<span className="text-xs text-muted-foreground flex items-center pr-3"><Clock className="w-4 h-4 mr-2"/> {state.duration_string ? formatDurationString(state.duration_string) : 'unknown'}</span>
|
||||
<span className="text-xs text-muted-foreground flex items-center pr-3">
|
||||
{isMutilplePlaylistItems ? (
|
||||
<><ListVideo className="w-4 h-4 mr-2"/> {state.playlist_n_entries ?? 'unknown'}</>
|
||||
) : (
|
||||
<><Clock className="w-4 h-4 mr-2"/> {state.duration_string ? formatDurationString(state.duration_string) : 'unknown'}</>
|
||||
)}
|
||||
</span>
|
||||
<Separator orientation="vertical" />
|
||||
<span className="text-xs text-muted-foreground flex items-center px-3">
|
||||
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
||||
@@ -177,21 +224,21 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
|
||||
</span>
|
||||
</div>
|
||||
<div className="hidden xl:flex items-center mt-1 gap-2 flex-wrap text-xs">
|
||||
{state.playlist_id && state.playlist_index && (
|
||||
{state.playlist_id && state.playlist_indices && !isMutilplePlaylistItems && (
|
||||
<span
|
||||
className="border border-border py-1 px-2 rounded flex items-center cursor-pointer"
|
||||
title={`${state.playlist_title ?? 'UNKNOWN PLAYLIST'}` + ' by ' + `${state.playlist_channel ?? 'UNKNOWN CHANNEL'}`}
|
||||
>
|
||||
<ListVideo className="w-4 h-4 mr-2" /> Playlist ({state.playlist_index} of {state.playlist_n_entries})
|
||||
<ListVideo className="w-4 h-4 mr-2" /> Playlist ({state.playlist_indices} of {state.playlist_n_entries})
|
||||
</span>
|
||||
)}
|
||||
{state.vcodec && (
|
||||
{state.vcodec && !isMutilplePlaylistItems && (
|
||||
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.vcodec)}</span>
|
||||
)}
|
||||
{state.acodec && (
|
||||
{state.acodec && !isMutilplePlaylistItems && (
|
||||
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.acodec)}</span>
|
||||
)}
|
||||
{state.dynamic_range && state.dynamic_range !== 'SDR' && (
|
||||
{state.dynamic_range && state.dynamic_range !== 'SDR' && !isMutilplePlaylistItems && (
|
||||
<span className="border border-border py-1 px-2 rounded">{state.dynamic_range}</span>
|
||||
)}
|
||||
{state.subtitle_id && (
|
||||
@@ -202,6 +249,22 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
|
||||
ESUB
|
||||
</span>
|
||||
)}
|
||||
{state.sponsorblock_mark && (
|
||||
<span
|
||||
className="border border-border py-1 px-2 rounded cursor-pointer"
|
||||
title={`SPONSORBLOCK MARKED (${state.sponsorblock_mark})`}
|
||||
>
|
||||
SPBLOCK(M)
|
||||
</span>
|
||||
)}
|
||||
{state.sponsorblock_remove && (
|
||||
<span
|
||||
className="border border-border py-1 px-2 rounded cursor-pointer"
|
||||
title={`SPONSORBLOCK REMOVED (${state.sponsorblock_remove})`}
|
||||
>
|
||||
SPBLOCK(R)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="w-full flex items-center gap-2">
|
||||
|
||||
@@ -7,7 +7,7 @@ import { toast } from "sonner";
|
||||
import { useAppContext } from "@/providers/appContextProvider";
|
||||
import { useDownloadActionStatesStore, useSettingsPageStatesStore } from "@/services/store";
|
||||
import { formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils";
|
||||
import { ArrowUpRightIcon, CircleCheck, File, Info, Loader2, Music, Pause, Play, RotateCw, Video, X } from "lucide-react";
|
||||
import { ArrowUpRightIcon, CircleCheck, File, Info, ListVideo, Loader2, Music, Pause, Play, RotateCw, Video, X } from "lucide-react";
|
||||
import { DownloadState } from "@/types/download";
|
||||
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
@@ -37,13 +37,34 @@ export function IncompleteDownload({ state }: IncompleteDownloadProps) {
|
||||
isDeleteFileChecked: false,
|
||||
};
|
||||
|
||||
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
|
||||
const isMutilplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
|
||||
|
||||
return (
|
||||
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
|
||||
<div className="w-[30%] flex flex-col justify-between gap-2">
|
||||
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||
</AspectRatio>
|
||||
{state.ext ? (
|
||||
{isMutilplePlaylistItems ? (
|
||||
<div className="w-full relative flex items-center justify-center mt-2">
|
||||
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||
</AspectRatio>
|
||||
<div className="w-[95%] aspect-video absolute -top-1 rounded-lg overflow-hidden border border-border mb-2 z-10">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-xs brightness-75" />
|
||||
</div>
|
||||
<div className="w-[87%] aspect-video absolute -top-2 rounded-lg overflow-hidden border border-border mb-2 z-0">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-sm brightness-50" />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
|
||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||
</AspectRatio>
|
||||
)}
|
||||
{isMutilplePlaylistItems ? (
|
||||
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||
<ListVideo className="w-4 h-4 mr-2 stroke-primary" /> Playlist ({state.playlist_indices?.split(',').length})
|
||||
</span>
|
||||
) : state.ext ? (
|
||||
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
||||
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
||||
<Video className="w-4 h-4 mr-2 stroke-primary" />
|
||||
@@ -68,12 +89,15 @@ export function IncompleteDownload({ state }: IncompleteDownloadProps) {
|
||||
</div>
|
||||
<div className="w-full flex flex-col justify-between">
|
||||
<div className="flex flex-col gap-1">
|
||||
<h4>{state.title}</h4>
|
||||
<h4>{isMutilplePlaylistItems ? state.playlist_title : state.title}</h4>
|
||||
{((state.download_status === 'starting') || (state.download_status === 'downloading' && state.status === 'finished')) && (
|
||||
<IndeterminateProgress indeterminate={true} className="w-full" />
|
||||
)}
|
||||
{(state.download_status === 'downloading' || state.download_status === 'paused' || state.download_status === 'errored') && state.progress && state.status !== 'finished' && (
|
||||
<div className="w-full flex items-center gap-2">
|
||||
{isMutilplePlaylistItems && state.item ? (
|
||||
<span className="text-sm text-nowrap">({state.item})</span>
|
||||
) : null}
|
||||
<span className="text-sm text-nowrap">{state.progress}%</span>
|
||||
<Progress value={state.progress} />
|
||||
<span className="text-sm text-nowrap">{
|
||||
|
||||
@@ -362,7 +362,7 @@ function AppFolderSettings() {
|
||||
</div>
|
||||
<div className="filename-template">
|
||||
<h3 className="font-semibold">Filename Template</h3>
|
||||
<p className="text-xs text-muted-foreground mb-3">Set the template for naming downloaded files (download id and file extension will be auto-appended at the end, changing template may cause paused downloads to re-start from begining)</p>
|
||||
<p className="text-xs text-muted-foreground mb-3">Set the template for naming downloaded files (download id, file extension and playlist index will be auto-appended, changing template may cause paused downloads to re-start from begining)</p>
|
||||
<Form {...filenameTemplateForm}>
|
||||
<form onSubmit={filenameTemplateForm.handleSubmit(handleFilenameTemplateSubmit)} className="flex gap-4 w-full" autoComplete="off">
|
||||
<FormField
|
||||
@@ -1142,7 +1142,7 @@ function AppCommandSettings() {
|
||||
<FormControl>
|
||||
<Textarea
|
||||
className="focus-visible:ring-0 min-h-26"
|
||||
placeholder="Enter yt-dlp command line arguments (no need to start with 'yt-dlp', already passed args: url, output paths, selected formats, selected subtitles, playlist item. also, bulk downloading is not supported)"
|
||||
placeholder="Enter yt-dlp command line arguments (no need to start with 'yt-dlp', already passed args: url, output paths, selected formats, selected subtitles, playlist items etc.)"
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
@@ -1395,7 +1395,7 @@ function AppInfoSettings() {
|
||||
<p className="text-xs text-muted-foreground mb-3">License and usage terms of NeoDLP</p>
|
||||
<div className="license">
|
||||
<p className="text-sm mb-3">NeoDLP is a Fully Open-Source Software Licensed under the MIT license. Anyone can view, modify, use (personal and commercial) or distribute it's sources without any extra permission (Just include the LICENSE file :)</p>
|
||||
<p className="text-sm mb-3"><TriangleAlert className="size-4 stroke-primary inline mb-1 mr-0.5" /> DISCLAIMER: NeoDLP facilitates downloading from various Online Platforms with different Policies and Terms of Use which Users must follow. We strictly do not promote any unauthorized downloading of copyrighted content and content piracy. NeoDLP is only made for downloading content that the user holds the copyright to or has the authority for. Users must use the downloaded content wisely and solely at their own legal responsibility. The developer is not responsible for any action taken by the user, and takes zero direct or indirect liability for that matter.</p>
|
||||
<p className="text-sm mb-3"><TriangleAlert className="size-4 stroke-primary inline mb-1 mr-0.5" /> DISCLAIMER: NeoDLP facilitates downloading from various Online Platforms with different Policies and Terms of Use which Users must follow. We strictly do not promote any unauthorized downloading of copyrighted content. NeoDLP is only made for downloading content that the user holds the copyright to or has the authority for. Users must use the downloaded content wisely and solely at their own legal responsibility. The developer is not responsible for any action taken by the user, and takes zero direct or indirect liability for that matter.</p>
|
||||
<span className="flex items-center gap-4 flex-wrap">
|
||||
<Button className="px-4" variant="outline" size="sm" asChild>
|
||||
<a href={'https://github.com/' + config.appRepo + '/blob/main/LICENSE'} target="_blank" >
|
||||
|
||||
@@ -2,10 +2,10 @@ 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 { determineFileType, extractPlaylistItemProgress, 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 { useDeleteDownloadState, useSaveDownloadState, useSavePlaylistInfo, useSaveVideoInfo, useUpdateDownloadFilePath, useUpdateDownloadPlaylistItem, useUpdateDownloadStatus } from "@/services/mutations";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { platform } from "@tauri-apps/plugin-os";
|
||||
import { toast } from "sonner";
|
||||
@@ -76,6 +76,7 @@ export default function useDownloader() {
|
||||
const downloadStateSaver = useSaveDownloadState();
|
||||
const downloadStatusUpdater = useUpdateDownloadStatus();
|
||||
const downloadFilePathUpdater = useUpdateDownloadFilePath();
|
||||
const playlistItemUpdater = useUpdateDownloadPlaylistItem();
|
||||
const videoInfoSaver = useSaveVideoInfo();
|
||||
const downloadStateDeleter = useDeleteDownloadState();
|
||||
const playlistInfoSaver = useSavePlaylistInfo();
|
||||
@@ -99,7 +100,7 @@ export default function useDownloader() {
|
||||
}, 500);
|
||||
|
||||
const fetchVideoMetadata = async (params: FetchVideoMetadataParams): Promise<RawVideoInfo | null> => {
|
||||
const { url, formatId, playlistIndex, selectedSubtitles, resumeState, downloadConfig } = params;
|
||||
const { url, formatId, playlistIndices, selectedSubtitles, resumeState, downloadConfig } = params;
|
||||
try {
|
||||
const args = [url, '--dump-single-json', '--no-warnings'];
|
||||
if (formatId) args.push('--format', formatId);
|
||||
@@ -108,8 +109,8 @@ export default function useDownloader() {
|
||||
if (isAutoSub) args.push('--write-auto-sub');
|
||||
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 (playlistIndices) args.push('--playlist-items', playlistIndices);
|
||||
if (PREFER_VIDEO_OVER_PLAYLIST && !playlistIndices) args.push('--no-playlist');
|
||||
if (STRICT_DOWNLOADABILITY_CHECK && !formatId) args.push('--check-all-formats');
|
||||
if (STRICT_DOWNLOADABILITY_CHECK && formatId) args.push('--check-formats');
|
||||
|
||||
@@ -213,7 +214,7 @@ export default function useDownloader() {
|
||||
};
|
||||
|
||||
const startDownload = async (params: StartDownloadParams) => {
|
||||
const { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems } = params;
|
||||
const { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems, overrideOptions } = params;
|
||||
LOG.info('NEODLP', `Initiating yt-dlp download for URL: ${url}`);
|
||||
|
||||
console.log('Starting download:', { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems });
|
||||
@@ -222,12 +223,13 @@ export default function useDownloader() {
|
||||
return;
|
||||
}
|
||||
|
||||
const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_index) ? true : false;
|
||||
const playlistIndex = isPlaylist ? (resumeState?.playlist_index?.toString() || playlistItems) : null;
|
||||
const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_indices) ? true : false;
|
||||
const playlistIndices = isPlaylist ? (resumeState?.playlist_indices || playlistItems) : null;
|
||||
const isMultiplePlaylistItems = isPlaylist && playlistIndices && typeof playlistIndices === 'string' && playlistIndices.includes(',');
|
||||
let videoMetadata = await fetchVideoMetadata({
|
||||
url,
|
||||
formatId: selectedFormat,
|
||||
playlistIndex: isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined,
|
||||
formatId: (!isPlaylist || (isPlaylist && selectedFormat !== 'best')) ? selectedFormat : undefined,
|
||||
playlistIndices: isPlaylist && playlistIndices && typeof playlistIndices === 'string' ? playlistIndices : undefined,
|
||||
selectedSubtitles,
|
||||
resumeState
|
||||
});
|
||||
@@ -278,14 +280,10 @@ export default function useDownloader() {
|
||||
`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(),
|
||||
@@ -295,6 +293,27 @@ export default function useDownloader() {
|
||||
args.push('--ffmpeg-location', '/Applications/NeoDLP.app/Contents/MacOS', '--js-runtimes', 'deno:/Applications/NeoDLP.app/Contents/MacOS/deno');
|
||||
}
|
||||
|
||||
if (isMultiplePlaylistItems) {
|
||||
args.push('--output', `%(playlist_title|Unknown)s[${downloadId}]/[%(playlist_index|0)d]_${FILENAME_TEMPLATE}.%(ext)s`);
|
||||
} else {
|
||||
args.push('--output', `${FILENAME_TEMPLATE}[${downloadId}].%(ext)s`);
|
||||
}
|
||||
|
||||
if (isMultiplePlaylistItems) {
|
||||
const playlistLength = playlistIndices.split(',').length;
|
||||
if (playlistLength > 5 && playlistLength < 100) {
|
||||
args.push('--sleep-requests', '1', '--sleep-interval', '5', '--max-sleep-interval', '15');
|
||||
} else if (playlistLength >= 100 && playlistLength < 500) {
|
||||
args.push('--sleep-requests', '1.5', '--sleep-interval', '10', '--max-sleep-interval', '40');
|
||||
} else if (playlistLength >= 500) {
|
||||
args.push('--sleep-requests', '2.5', '--sleep-interval', '20', '--max-sleep-interval', '60');
|
||||
}
|
||||
}
|
||||
|
||||
if (!isPlaylist || (isPlaylist && selectedFormat !== 'best')) {
|
||||
args.push('--format', selectedFormat);
|
||||
}
|
||||
|
||||
if (DEBUG_MODE && LOG_VERBOSE) {
|
||||
args.push('--verbose');
|
||||
} else {
|
||||
@@ -307,8 +326,8 @@ export default function useDownloader() {
|
||||
args.push('--embed-subs', '--sub-lang', selectedSubtitles);
|
||||
}
|
||||
|
||||
if (isPlaylist && playlistIndex && typeof playlistIndex === 'string') {
|
||||
args.push('--playlist-items', playlistIndex);
|
||||
if (isPlaylist && playlistIndices && typeof playlistIndices === 'string') {
|
||||
args.push('--playlist-items', playlistIndices);
|
||||
}
|
||||
|
||||
let customCommandArgs = null;
|
||||
@@ -466,11 +485,11 @@ export default function useDownloader() {
|
||||
addErroredDownload(downloadId);
|
||||
});
|
||||
|
||||
command.stdout.on('data', line => {
|
||||
command.stdout.on('data', async 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 currentProgress = await parseProgressLine(line, downloadId);
|
||||
const state: DownloadState = {
|
||||
download_id: downloadId,
|
||||
download_status: 'downloading',
|
||||
@@ -479,7 +498,7 @@ export default function useDownloader() {
|
||||
subtitle_id: selectedSubtitles || null,
|
||||
queue_index: null,
|
||||
playlist_id: playlistId,
|
||||
playlist_index: playlistIndex ? Number(playlistIndex) : null,
|
||||
playlist_indices: playlistIndices ?? null,
|
||||
title: videoMetadata.title,
|
||||
url: url,
|
||||
host: videoMetadata.webpage_url_domain,
|
||||
@@ -495,13 +514,14 @@ export default function useDownloader() {
|
||||
playlist_channel: videoMetadata.playlist_channel || null,
|
||||
resolution: videoMetadata.resolution || null,
|
||||
ext: videoMetadata.ext || null,
|
||||
abr: videoMetadata.abr || null,
|
||||
vbr: videoMetadata.vbr || null,
|
||||
abr: resumeState?.abr || overrideOptions?.tbr/2 || videoMetadata.abr || null,
|
||||
vbr: resumeState?.vbr || overrideOptions?.tbr/2 || videoMetadata.vbr || null,
|
||||
acodec: videoMetadata.acodec || null,
|
||||
vcodec: videoMetadata.vcodec || null,
|
||||
dynamic_range: videoMetadata.dynamic_range || null,
|
||||
process_id: processPid,
|
||||
status: currentProgress.status || null,
|
||||
item: currentProgress.item || null,
|
||||
progress: currentProgress.progress || null,
|
||||
total: currentProgress.total || null,
|
||||
downloaded: currentProgress.downloaded || null,
|
||||
@@ -509,7 +529,7 @@ export default function useDownloader() {
|
||||
eta: currentProgress.eta || null,
|
||||
filepath: downloadFilePath,
|
||||
filetype: determineFileType(videoMetadata.vcodec, videoMetadata.acodec) || null,
|
||||
filesize: videoMetadata.filesize_approx || null,
|
||||
filesize: resumeState?.filesize || overrideOptions?.filesize || videoMetadata.filesize_approx || null,
|
||||
output_format: outputFormat,
|
||||
embed_metadata: embedMetadata,
|
||||
embed_thumbnail: embedThumbnail,
|
||||
@@ -525,7 +545,67 @@ export default function useDownloader() {
|
||||
// console.log(line);
|
||||
if (line.trim() !== '') LOG.info(`YT-DLP Download ${downloadId}`, line);
|
||||
|
||||
if (line.startsWith('Finalpath: ')) {
|
||||
if (isPlaylist && line.startsWith('[download] Downloading item')) {
|
||||
const playlistItemProgress = extractPlaylistItemProgress(line);
|
||||
setTimeout(async () => {
|
||||
playlistItemUpdater.mutate({ download_id: downloadId, item: playlistItemProgress as string }, {
|
||||
onSuccess: (data) => {
|
||||
console.log("Playlist item progress updated successfully:", data);
|
||||
queryClient.invalidateQueries({ queryKey: ['download-states'] });
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error("Failed to update playlist item progress:", error);
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
if (isPlaylist && line.startsWith('Finalpath: ')) {
|
||||
downloadFilePath = line.replace('Finalpath: ', '').trim().replace(/^"|"$/g, '');
|
||||
const downloadedFileExt = downloadFilePath.split('.').pop();
|
||||
|
||||
setTimeout(async () => {
|
||||
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);
|
||||
}
|
||||
});
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
if (isPlaylist && line.startsWith('[download] Finished downloading playlist:')) {
|
||||
// Update completion status after a short delay to ensure database states are propagated correctly
|
||||
console.log(`Playlist download completed with ID: ${downloadId}, updating status after 2s delay...`);
|
||||
setTimeout(async () => {
|
||||
LOG.info('NEODLP', `yt-dlp download completed with id: ${downloadId}`);
|
||||
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(`${isMultiplePlaylistItems ? 'Playlist ' : ''}Download Completed`, {
|
||||
description: `The download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? videoMetadata.playlist_title : videoMetadata.title}" has completed successfully.`,
|
||||
});
|
||||
|
||||
if (ENABLE_NOTIFICATIONS && DOWNLOAD_COMPLETION_NOTIFICATION) {
|
||||
sendNotification({
|
||||
title: `${isMultiplePlaylistItems ? 'Playlist ' : ''}Download Completed`,
|
||||
body: `The download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? videoMetadata.playlist_title : videoMetadata.title}" has completed successfully.`,
|
||||
});
|
||||
}
|
||||
}, 2000);
|
||||
}
|
||||
|
||||
if (!isPlaylist && line.startsWith('Finalpath: ')) {
|
||||
downloadFilePath = line.replace('Finalpath: ', '').trim().replace(/^"|"$/g, '');
|
||||
const downloadedFileExt = downloadFilePath.split('.').pop();
|
||||
|
||||
@@ -607,7 +687,7 @@ export default function useDownloader() {
|
||||
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,
|
||||
playlist_indices: playlistIndices ?? null,
|
||||
title: videoMetadata.title,
|
||||
url: url,
|
||||
host: videoMetadata.webpage_url_domain,
|
||||
@@ -623,13 +703,14 @@ export default function useDownloader() {
|
||||
playlist_channel: videoMetadata.playlist_channel || null,
|
||||
resolution: resumeState?.resolution || null,
|
||||
ext: resumeState?.ext || null,
|
||||
abr: resumeState?.abr || null,
|
||||
vbr: resumeState?.vbr || null,
|
||||
abr: resumeState?.abr || overrideOptions?.tbr/2 || null,
|
||||
vbr: resumeState?.vbr || overrideOptions?.tbr/2 || 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,
|
||||
item: resumeState?.item || null,
|
||||
progress: resumeState?.progress || null,
|
||||
total: resumeState?.total || null,
|
||||
downloaded: resumeState?.downloaded || null,
|
||||
@@ -637,7 +718,7 @@ export default function useDownloader() {
|
||||
eta: resumeState?.eta || null,
|
||||
filepath: downloadFilePath,
|
||||
filetype: resumeState?.filetype || null,
|
||||
filesize: resumeState?.filesize || null,
|
||||
filesize: resumeState?.filesize || overrideOptions?.filesize || null,
|
||||
output_format: resumeState?.output_format || null,
|
||||
embed_metadata: resumeState?.embed_metadata || 0,
|
||||
embed_thumbnail: resumeState?.embed_thumbnail || 0,
|
||||
@@ -725,7 +806,7 @@ export default function useDownloader() {
|
||||
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,
|
||||
url: downloadState.playlist_id && downloadState.playlist_indices ? downloadState.playlist_url : downloadState.url,
|
||||
selectedFormat: downloadState.format_id,
|
||||
downloadConfig: downloadState.queue_config ? JSON.parse(downloadState.queue_config) : {
|
||||
output_format: null,
|
||||
|
||||
@@ -4,7 +4,7 @@ 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, sortByBitrate } from "@/utils";
|
||||
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";
|
||||
@@ -49,12 +49,12 @@ export default function DownloaderPage() {
|
||||
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
|
||||
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
|
||||
const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat);
|
||||
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
|
||||
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
|
||||
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
|
||||
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
|
||||
const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat);
|
||||
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
|
||||
const setSelectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideoIndex);
|
||||
const setSelectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideos);
|
||||
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
|
||||
|
||||
const appTheme = useSettingsPageStatesStore(state => state.settings.theme);
|
||||
@@ -62,12 +62,21 @@ export default function DownloaderPage() {
|
||||
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const audioOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('audio'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('audio'))) : [];
|
||||
const videoOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video'))) : [];
|
||||
const combinedFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video+audio'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video+audio'))) : [];
|
||||
const commonFormats = (() => {
|
||||
if (videoMetadata?._type === 'video') {
|
||||
return videoMetadata?.formats;
|
||||
} else if (videoMetadata?._type === 'playlist') {
|
||||
return getCommonFormats(videoMetadata.entries, selectedPlaylistVideos);
|
||||
}
|
||||
return [];
|
||||
})();
|
||||
|
||||
const av1VideoFormats = videoMetadata?.webpage_url_domain === 'youtube.com' && videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter((format) => format.vcodec?.startsWith('av01'))) : videoMetadata?.webpage_url_domain === 'youtube.com' && videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter((format) => format.vcodec?.startsWith('av01'))) : [];
|
||||
const opusAudioFormats = videoMetadata?.webpage_url_domain === 'youtube.com' && videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter((format) => format.acodec?.startsWith('opus'))) : videoMetadata?.webpage_url_domain === 'youtube.com' && videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter((format) => format.acodec?.startsWith('opus'))) : [];
|
||||
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) => {
|
||||
@@ -98,7 +107,7 @@ export default function DownloaderPage() {
|
||||
);
|
||||
} else if (videoMetadata?._type === 'playlist') {
|
||||
if (selectedDownloadFormat === 'best') {
|
||||
return videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0];
|
||||
return getMergedBestFormat(videoMetadata.entries, selectedPlaylistVideos);
|
||||
}
|
||||
return allFilteredFormats.find(
|
||||
(format) => format.format_id === selectedDownloadFormat
|
||||
@@ -129,9 +138,23 @@ export default function DownloaderPage() {
|
||||
}
|
||||
})();
|
||||
|
||||
const subtitles = videoMetadata?._type === 'video' ? (videoMetadata?.subtitles || {}) : videoMetadata?._type === 'playlist' ? (videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].subtitles || {}) : {};
|
||||
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 = videoMetadata?._type === 'video' ? (videoMetadata?.automatic_captions || {}) : videoMetadata?._type === 'playlist' ? (videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].automatic_captions || {}) : {};
|
||||
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 => ({
|
||||
@@ -165,7 +188,7 @@ export default function DownloaderPage() {
|
||||
setSelectedCombinableVideoFormat('');
|
||||
setSelectedCombinableAudioFormat('');
|
||||
setSelectedSubtitles([]);
|
||||
setSelectedPlaylistVideoIndex('1');
|
||||
setSelectedPlaylistVideos(["1"]);
|
||||
resetDownloadConfiguration();
|
||||
|
||||
fetchVideoMetadata({ url: values.url }).then((metadata) => {
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createContext, useContext } from 'react';
|
||||
export interface FetchVideoMetadataParams {
|
||||
url: string;
|
||||
formatId?: string;
|
||||
playlistIndex?: string;
|
||||
playlistIndices?: string;
|
||||
selectedSubtitles?: string | null;
|
||||
resumeState?: DownloadState;
|
||||
downloadConfig?: DownloadConfiguration;
|
||||
@@ -19,6 +19,9 @@ export interface StartDownloadParams {
|
||||
selectedSubtitles?: string | null;
|
||||
resumeState?: DownloadState;
|
||||
playlistItems?: string;
|
||||
overrideOptions?: {
|
||||
[key: string]: any;
|
||||
}
|
||||
};
|
||||
|
||||
interface AppContextType {
|
||||
|
||||
@@ -80,7 +80,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
|
||||
subtitle_id,
|
||||
queue_index,
|
||||
playlist_id,
|
||||
playlist_index,
|
||||
playlist_indices,
|
||||
process_id,
|
||||
resolution,
|
||||
ext,
|
||||
@@ -90,6 +90,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
|
||||
vcodec,
|
||||
dynamic_range,
|
||||
status,
|
||||
item,
|
||||
progress,
|
||||
total,
|
||||
downloaded,
|
||||
@@ -107,7 +108,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
|
||||
use_aria2,
|
||||
custom_command,
|
||||
queue_config
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34)
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35)
|
||||
ON CONFLICT(download_id) DO UPDATE SET
|
||||
download_status = $2,
|
||||
video_id = $3,
|
||||
@@ -115,7 +116,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
|
||||
subtitle_id = $5,
|
||||
queue_index = $6,
|
||||
playlist_id = $7,
|
||||
playlist_index = $8,
|
||||
playlist_indices = $8,
|
||||
process_id = $9,
|
||||
resolution = $10,
|
||||
ext = $11,
|
||||
@@ -125,23 +126,24 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
|
||||
vcodec = $15,
|
||||
dynamic_range = $16,
|
||||
status = $17,
|
||||
progress = $18,
|
||||
total = $19,
|
||||
downloaded = $20,
|
||||
speed = $21,
|
||||
eta = $22,
|
||||
filepath = $23,
|
||||
filetype = $24,
|
||||
filesize = $25,
|
||||
output_format = $26,
|
||||
embed_metadata = $27,
|
||||
embed_thumbnail = $28,
|
||||
square_crop_thumbnail = $29,
|
||||
sponsorblock_remove = $30,
|
||||
sponsorblock_mark = $31,
|
||||
use_aria2 = $32,
|
||||
custom_command = $33,
|
||||
queue_config = $34`,
|
||||
item = $18,
|
||||
progress = $19,
|
||||
total = $20,
|
||||
downloaded = $21,
|
||||
speed = $22,
|
||||
eta = $23,
|
||||
filepath = $24,
|
||||
filetype = $25,
|
||||
filesize = $26,
|
||||
output_format = $27,
|
||||
embed_metadata = $28,
|
||||
embed_thumbnail = $29,
|
||||
square_crop_thumbnail = $30,
|
||||
sponsorblock_remove = $31,
|
||||
sponsorblock_mark = $32,
|
||||
use_aria2 = $33,
|
||||
custom_command = $34,
|
||||
queue_config = $35`,
|
||||
[
|
||||
downloadState.download_id,
|
||||
downloadState.download_status,
|
||||
@@ -150,7 +152,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
|
||||
downloadState.subtitle_id,
|
||||
downloadState.queue_index,
|
||||
downloadState.playlist_id,
|
||||
downloadState.playlist_index,
|
||||
downloadState.playlist_indices,
|
||||
downloadState.process_id,
|
||||
downloadState.resolution,
|
||||
downloadState.ext,
|
||||
@@ -160,6 +162,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
|
||||
downloadState.vcodec,
|
||||
downloadState.dynamic_range,
|
||||
downloadState.status,
|
||||
downloadState.item,
|
||||
downloadState.progress,
|
||||
downloadState.total,
|
||||
downloadState.downloaded,
|
||||
@@ -197,6 +200,14 @@ export const updateDownloadFilePath = async (download_id: string, filepath: stri
|
||||
)
|
||||
}
|
||||
|
||||
export const updateDownloadPlaylistItem = async (download_id: string, item: string) => {
|
||||
const db = await Database.load('sqlite:database.db')
|
||||
return await db.execute(
|
||||
'UPDATE downloads SET item = $2 WHERE download_id = $1',
|
||||
[download_id, item]
|
||||
)
|
||||
}
|
||||
|
||||
export const deleteDownloadState = async (download_id: string) => {
|
||||
const db = await Database.load('sqlite:database.db')
|
||||
return await db.execute(
|
||||
@@ -233,6 +244,36 @@ export const fetchAllDownloadStates = async () => {
|
||||
)
|
||||
}
|
||||
|
||||
export const fetchDownloadStateById = async (download_id: string) => {
|
||||
const db = await Database.load('sqlite:database.db')
|
||||
const result = await db.select<DownloadState[]>(
|
||||
`SELECT
|
||||
downloads.*,
|
||||
video_info.title,
|
||||
video_info.url,
|
||||
video_info.host,
|
||||
video_info.thumbnail,
|
||||
video_info.channel,
|
||||
video_info.duration_string,
|
||||
video_info.release_date,
|
||||
video_info.view_count,
|
||||
video_info.like_count,
|
||||
playlist_info.playlist_title,
|
||||
playlist_info.playlist_url,
|
||||
playlist_info.playlist_n_entries,
|
||||
playlist_info.playlist_channel
|
||||
FROM downloads
|
||||
INNER JOIN video_info
|
||||
ON downloads.video_id = video_info.video_id
|
||||
LEFT JOIN playlist_info
|
||||
ON downloads.playlist_id = playlist_info.playlist_id
|
||||
AND downloads.playlist_id IS NOT NULL
|
||||
WHERE downloads.download_id = $1`,
|
||||
[download_id]
|
||||
)
|
||||
return result.length > 0 ? result[0] : null
|
||||
}
|
||||
|
||||
export const fetchAllSettings = async () => {
|
||||
const db = await Database.load('sqlite:database.db')
|
||||
const result = await db.select<SettingsTable[]>(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { VideoInfo } from "@/types/video";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { deleteDownloadState, deleteKvPair, resetSettings, saveDownloadState, saveKvPair, savePlaylistInfo, saveSettingsKey, saveVideoInfo, updateDownloadFilePath, updateDownloadStatus } from "@/services/database";
|
||||
import { deleteDownloadState, deleteKvPair, resetSettings, saveDownloadState, saveKvPair, savePlaylistInfo, saveSettingsKey, saveVideoInfo, updateDownloadFilePath, updateDownloadPlaylistItem, updateDownloadStatus } from "@/services/database";
|
||||
import { DownloadState } from "@/types/download";
|
||||
import { PlaylistInfo } from "@/types/playlist";
|
||||
|
||||
@@ -36,6 +36,13 @@ export function useUpdateDownloadFilePath() {
|
||||
})
|
||||
}
|
||||
|
||||
export function useUpdateDownloadPlaylistItem() {
|
||||
return useMutation({
|
||||
mutationFn: (data: { download_id: string; item: string }) =>
|
||||
updateDownloadPlaylistItem(data.download_id, data.item)
|
||||
})
|
||||
}
|
||||
|
||||
export function useDeleteDownloadState() {
|
||||
return useMutation({
|
||||
mutationFn: (data: string) => deleteDownloadState(data)
|
||||
|
||||
@@ -53,7 +53,7 @@ export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((s
|
||||
selectedCombinableVideoFormat: '',
|
||||
selectedCombinableAudioFormat: '',
|
||||
selectedSubtitles: [],
|
||||
selectedPlaylistVideoIndex: '1',
|
||||
selectedPlaylistVideos: ["1"],
|
||||
downloadConfiguration: {
|
||||
output_format: null,
|
||||
embed_metadata: null,
|
||||
@@ -73,7 +73,7 @@ export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((s
|
||||
setSelectedCombinableVideoFormat: (format) => set(() => ({ selectedCombinableVideoFormat: format })),
|
||||
setSelectedCombinableAudioFormat: (format) => set(() => ({ selectedCombinableAudioFormat: format })),
|
||||
setSelectedSubtitles: (subtitles) => set(() => ({ selectedSubtitles: subtitles })),
|
||||
setSelectedPlaylistVideoIndex: (index) => set(() => ({ selectedPlaylistVideoIndex: index })),
|
||||
setSelectedPlaylistVideos: (indices) => set(() => ({ selectedPlaylistVideos: indices })),
|
||||
setDownloadConfigurationKey: (key, value) => set((state) => ({
|
||||
downloadConfiguration: {
|
||||
...state.downloadConfiguration,
|
||||
|
||||
@@ -6,7 +6,7 @@ export interface DownloadState {
|
||||
subtitle_id: string | null;
|
||||
queue_index: number | null;
|
||||
playlist_id: string | null;
|
||||
playlist_index: number | null;
|
||||
playlist_indices: string | null;
|
||||
title: string;
|
||||
url: string;
|
||||
host: string;
|
||||
@@ -29,6 +29,7 @@ export interface DownloadState {
|
||||
dynamic_range: string | null;
|
||||
process_id: number | null;
|
||||
status: string | null;
|
||||
item: string | null;
|
||||
progress: number | null;
|
||||
total: number | null;
|
||||
downloaded: number | null;
|
||||
@@ -58,7 +59,7 @@ export interface Download {
|
||||
subtitle_id: string | null;
|
||||
queue_index: number | null;
|
||||
playlist_id: string | null;
|
||||
playlist_index: number | null;
|
||||
playlist_indices: string | null;
|
||||
resolution: string | null;
|
||||
ext: string | null;
|
||||
abr: number | null;
|
||||
@@ -68,6 +69,7 @@ export interface Download {
|
||||
dynamic_range: string | null;
|
||||
process_id: number | null;
|
||||
status: string | null;
|
||||
item: string | null;
|
||||
progress: number | null;
|
||||
total: number | null;
|
||||
downloaded: number | null;
|
||||
@@ -91,6 +93,7 @@ export interface Download {
|
||||
|
||||
export interface DownloadProgress {
|
||||
status: string | null;
|
||||
item: string | null;
|
||||
progress: number | null;
|
||||
speed: number | null;
|
||||
downloaded: number | null;
|
||||
|
||||
@@ -43,7 +43,7 @@ export interface DownloaderPageStatesStore {
|
||||
selectedCombinableVideoFormat: string;
|
||||
selectedCombinableAudioFormat: string;
|
||||
selectedSubtitles: string[];
|
||||
selectedPlaylistVideoIndex: string;
|
||||
selectedPlaylistVideos: string[];
|
||||
downloadConfiguration: DownloadConfiguration;
|
||||
erroredDownloadIds: Set<string>;
|
||||
expectedErrorDownloadIds: Set<string>;
|
||||
@@ -56,7 +56,7 @@ export interface DownloaderPageStatesStore {
|
||||
setSelectedCombinableVideoFormat: (format: string) => void;
|
||||
setSelectedCombinableAudioFormat: (format: string) => void;
|
||||
setSelectedSubtitles: (subtitles: string[]) => void;
|
||||
setSelectedPlaylistVideoIndex: (index: string) => void;
|
||||
setSelectedPlaylistVideos: (indices: string[]) => void;
|
||||
setDownloadConfigurationKey: (key: string, value: unknown) => void;
|
||||
setDownloadConfiguration: (config: DownloadConfiguration) => void;
|
||||
resetDownloadConfiguration: () => void;
|
||||
|
||||
248
src/utils.ts
248
src/utils.ts
@@ -1,8 +1,9 @@
|
||||
import { RoutesObj } from "@/types/route";
|
||||
import { AllRoutes } from "@/routes";
|
||||
import { DownloadProgress, Paginated } from "@/types/download";
|
||||
import { VideoFormat } from "@/types/video";
|
||||
import { RawVideoInfo, VideoFormat, VideoSubtitle } from "@/types/video";
|
||||
import * as fs from "@tauri-apps/plugin-fs";
|
||||
import { fetchDownloadStateById } from "@/services/database";
|
||||
|
||||
export function isActive(path: string, location: string, starts_with: boolean = false): boolean {
|
||||
if (starts_with) {
|
||||
@@ -36,9 +37,11 @@ const convertToBytes = (value: number, unit: string): number => {
|
||||
}
|
||||
};
|
||||
|
||||
export const parseProgressLine = (line: string): DownloadProgress => {
|
||||
export const parseProgressLine = async (line: string, downloadID: string): Promise<DownloadProgress> => {
|
||||
const state = await fetchDownloadStateById(downloadID);
|
||||
const progress: Partial<DownloadProgress> = {
|
||||
status: 'downloading'
|
||||
status: 'downloading',
|
||||
item: state?.item || null,
|
||||
};
|
||||
|
||||
// Check if line contains both aria2c and yt-dlp format (combined format)
|
||||
@@ -151,6 +154,12 @@ export const parseProgressLine = (line: string): DownloadProgress => {
|
||||
return progress as DownloadProgress;
|
||||
};
|
||||
|
||||
export const extractPlaylistItemProgress = (line: string): string | null => {
|
||||
const match = line.match(/\[download\] Downloading item (\d+) of (\d+)/);
|
||||
if (match) return `${match[1]}/${match[2]}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
export const formatSpeed = (bytes: number) => {
|
||||
if (bytes === 0) return '0 B/s';
|
||||
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
|
||||
@@ -445,3 +454,236 @@ export const paginate = <T>(items: T[], currentPage: number, itemsPerPage: numbe
|
||||
data
|
||||
};
|
||||
};
|
||||
|
||||
export const getCommonFormats = (
|
||||
entries: RawVideoInfo[],
|
||||
selectedIndices: string[]
|
||||
): VideoFormat[] => {
|
||||
// If no videos selected or only one video, return empty or all formats
|
||||
if (!entries || entries.length === 0 || selectedIndices.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Get the selected videos (convert 1-indexed strings to 0-indexed numbers)
|
||||
const selectedVideos = selectedIndices
|
||||
.map(index => entries[Number(index) - 1])
|
||||
.filter(video => video && video.formats);
|
||||
|
||||
if (selectedVideos.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// If only one video selected, return all its formats
|
||||
if (selectedVideos.length === 1) {
|
||||
return selectedVideos[0].formats || [];
|
||||
}
|
||||
|
||||
// Get format_ids from the first selected video as the base set
|
||||
const firstVideoFormats = selectedVideos[0].formats || [];
|
||||
const firstVideoFormatIds = new Set(firstVideoFormats.map(f => f.format_id));
|
||||
|
||||
// Find format_ids that exist in ALL selected videos
|
||||
const commonFormatIds = [...firstVideoFormatIds].filter(formatId => {
|
||||
return selectedVideos.every(video =>
|
||||
video.formats?.some(f => f.format_id === formatId)
|
||||
);
|
||||
});
|
||||
|
||||
// Return the format objects with aggregated filesize_approx and tbr
|
||||
return commonFormatIds.map(formatId => {
|
||||
// Get the base format from the first video
|
||||
const baseFormat = firstVideoFormats.find(f => f.format_id === formatId)!;
|
||||
|
||||
// Calculate aggregated values across all selected videos
|
||||
let totalFilesizeApprox: number | null = null;
|
||||
let totalTbr: number | null = null;
|
||||
let allHaveFilesize = true;
|
||||
let allHaveTbr = true;
|
||||
|
||||
for (const video of selectedVideos) {
|
||||
const format = video.formats?.find(f => f.format_id === formatId);
|
||||
if (format) {
|
||||
if (format.filesize_approx != null) {
|
||||
totalFilesizeApprox = (totalFilesizeApprox ?? 0) + format.filesize_approx;
|
||||
} else {
|
||||
allHaveFilesize = false;
|
||||
}
|
||||
|
||||
if (format.tbr != null) {
|
||||
totalTbr = (totalTbr ?? 0) + format.tbr;
|
||||
} else {
|
||||
allHaveTbr = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a new format object with aggregated values
|
||||
return {
|
||||
...baseFormat,
|
||||
filesize_approx: allHaveFilesize ? totalFilesizeApprox : null,
|
||||
tbr: allHaveTbr ? totalTbr : null,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getMergedBestFormat = (
|
||||
entries: RawVideoInfo[],
|
||||
selectedIndices: string[]
|
||||
): VideoFormat | undefined => {
|
||||
// If no videos selected, return undefined
|
||||
if (!entries || entries.length === 0 || selectedIndices.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Get the selected videos (convert 1-indexed strings to 0-indexed numbers)
|
||||
const selectedVideos = selectedIndices
|
||||
.map(index => entries[Number(index) - 1])
|
||||
.filter(video => video && video.requested_downloads && video.requested_downloads.length > 0);
|
||||
|
||||
if (selectedVideos.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// If only one video selected, return its requested_downloads[0]
|
||||
if (selectedVideos.length === 1) {
|
||||
return selectedVideos[0].requested_downloads[0];
|
||||
}
|
||||
|
||||
// Get the base format from the first video
|
||||
const baseFormat = selectedVideos[0].requested_downloads[0];
|
||||
|
||||
// Check if all selected videos have the same format_id
|
||||
const allSameFormatId = selectedVideos.every(video =>
|
||||
video.requested_downloads[0]?.format_id === baseFormat.format_id
|
||||
);
|
||||
|
||||
// Calculate aggregated values across all selected videos
|
||||
let totalFilesizeApprox: number | null = null;
|
||||
let totalTbr: number | null = null;
|
||||
let allHaveFilesize = true;
|
||||
let allHaveTbr = true;
|
||||
|
||||
for (const video of selectedVideos) {
|
||||
const format = video.requested_downloads[0];
|
||||
if (format) {
|
||||
if (format.filesize_approx != null) {
|
||||
totalFilesizeApprox = (totalFilesizeApprox ?? 0) + format.filesize_approx;
|
||||
} else {
|
||||
allHaveFilesize = false;
|
||||
}
|
||||
|
||||
if (format.tbr != null) {
|
||||
totalTbr = (totalTbr ?? 0) + format.tbr;
|
||||
} else {
|
||||
allHaveTbr = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Return a merged format object with aggregated values
|
||||
// If all format_ids are the same, keep original attributes; otherwise use 'auto'
|
||||
if (allSameFormatId) {
|
||||
return {
|
||||
...baseFormat,
|
||||
filesize_approx: allHaveFilesize ? totalFilesizeApprox : null,
|
||||
tbr: allHaveTbr ? totalTbr : null,
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
...baseFormat,
|
||||
format: 'Best Video (Automatic)',
|
||||
format_id: 'best',
|
||||
format_note: 'auto',
|
||||
ext: 'auto',
|
||||
resolution: 'auto',
|
||||
dynamic_range: 'auto',
|
||||
acodec: 'auto',
|
||||
vcodec: 'auto',
|
||||
audio_ext: 'auto',
|
||||
video_ext: 'auto',
|
||||
fps: null,
|
||||
filesize_approx: allHaveFilesize ? totalFilesizeApprox : null,
|
||||
tbr: allHaveTbr ? totalTbr : null,
|
||||
} as VideoFormat;
|
||||
}
|
||||
};
|
||||
|
||||
export const getCommonSubtitles = (
|
||||
entries: RawVideoInfo[],
|
||||
selectedIndices: string[]
|
||||
): { [subtitle_id: string]: VideoSubtitle[] } => {
|
||||
// If no videos selected, return empty object
|
||||
if (!entries || entries.length === 0 || selectedIndices.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Get the selected videos (convert 1-indexed strings to 0-indexed numbers)
|
||||
const selectedVideos = selectedIndices
|
||||
.map(index => entries[Number(index) - 1])
|
||||
.filter(video => video && video.subtitles);
|
||||
|
||||
if (selectedVideos.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// If only one video selected, return all its subtitles
|
||||
if (selectedVideos.length === 1) {
|
||||
return selectedVideos[0].subtitles || {};
|
||||
}
|
||||
|
||||
// Get subtitle keys from the first selected video as the base set
|
||||
const firstVideoSubtitles = selectedVideos[0].subtitles || {};
|
||||
const firstVideoSubtitleKeys = new Set(Object.keys(firstVideoSubtitles));
|
||||
|
||||
// Find subtitle keys that exist in ALL selected videos
|
||||
const commonSubtitleKeys = [...firstVideoSubtitleKeys].filter(subtitleKey => {
|
||||
return selectedVideos.every(video =>
|
||||
video.subtitles && Object.prototype.hasOwnProperty.call(video.subtitles, subtitleKey)
|
||||
);
|
||||
});
|
||||
|
||||
// Return subtitle object with only common keys (using first video's subtitle data)
|
||||
return Object.fromEntries(
|
||||
commonSubtitleKeys.map(key => [key, firstVideoSubtitles[key]])
|
||||
);
|
||||
};
|
||||
|
||||
export const getCommonAutoSubtitles = (
|
||||
entries: RawVideoInfo[],
|
||||
selectedIndices: string[]
|
||||
): { [subtitle_id: string]: VideoSubtitle[] } => {
|
||||
// If no videos selected, return empty object
|
||||
if (!entries || entries.length === 0 || selectedIndices.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Get the selected videos (convert 1-indexed strings to 0-indexed numbers)
|
||||
const selectedVideos = selectedIndices
|
||||
.map(index => entries[Number(index) - 1])
|
||||
.filter(video => video && video.automatic_captions);
|
||||
|
||||
if (selectedVideos.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// If only one video selected, return all its automatic captions
|
||||
if (selectedVideos.length === 1) {
|
||||
return selectedVideos[0].automatic_captions || {};
|
||||
}
|
||||
|
||||
// Get auto subtitle keys from the first selected video as the base set
|
||||
const firstVideoAutoSubs = selectedVideos[0].automatic_captions || {};
|
||||
const firstVideoAutoSubKeys = new Set(Object.keys(firstVideoAutoSubs));
|
||||
|
||||
// Find auto subtitle keys that exist in ALL selected videos
|
||||
const commonAutoSubKeys = [...firstVideoAutoSubKeys].filter(subtitleKey => {
|
||||
return selectedVideos.every(video =>
|
||||
video.automatic_captions && Object.prototype.hasOwnProperty.call(video.automatic_captions, subtitleKey)
|
||||
);
|
||||
});
|
||||
|
||||
// Return auto subtitle object with only common keys (using first video's data)
|
||||
return Object.fromEntries(
|
||||
commonAutoSubKeys.map(key => [key, firstVideoAutoSubs[key]])
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user