1
1
mirror of https://github.com/neosubhamoy/neodlp.git synced 2026-02-04 14:12:22 +05:30

feat: added support for full-playlist/selective-batch downloading #9

This commit is contained in:
2026-01-15 15:22:43 +05:30
Verified
parent 2b7ab9def4
commit 1eb23eb035
18 changed files with 768 additions and 290 deletions

View File

@@ -163,7 +163,7 @@ pub fn get_migrations() -> Vec<Migration> {
subtitle_id TEXT, subtitle_id TEXT,
queue_index INTEGER, queue_index INTEGER,
playlist_id TEXT, playlist_id TEXT,
playlist_index INTEGER, playlist_indices TEXT,
resolution TEXT, resolution TEXT,
ext TEXT, ext TEXT,
abr REAL, abr REAL,
@@ -173,6 +173,7 @@ pub fn get_migrations() -> Vec<Migration> {
dynamic_range TEXT, dynamic_range TEXT,
process_id INTEGER, process_id INTEGER,
status TEXT, status TEXT,
item TEXT,
progress REAL, progress REAL,
total INTEGER, total INTEGER,
downloaded INTEGER, downloaded INTEGER,
@@ -199,9 +200,13 @@ pub fn get_migrations() -> Vec<Migration> {
-- Copy all data from original table to temporary table with default values for new columns -- Copy all data from original table to temporary table with default values for new columns
INSERT INTO downloads_temp SELECT INSERT INTO downloads_temp SELECT
id, download_id, download_status, video_id, format_id, subtitle_id, id, download_id, download_status, video_id, format_id, subtitle_id,
queue_index, playlist_id, playlist_index, resolution, ext, abr, vbr, queue_index, playlist_id,
acodec, vcodec, dynamic_range, process_id, status, progress, total, CAST(playlist_index AS TEXT), -- Convert INTEGER playlist_index to TEXT playlist_indices
downloaded, speed, eta, filepath, filetype, filesize, resolution, ext, abr, vbr,
acodec, vcodec, dynamic_range, process_id, status,
CASE WHEN playlist_id IS NOT NULL THEN '1/1' ELSE NULL END, -- item
progress, total, downloaded, speed, eta,
filepath, filetype, filesize,
output_format, output_format,
embed_metadata, embed_metadata,
embed_thumbnail, embed_thumbnail,

View File

@@ -36,7 +36,7 @@ const PlaylistSelectionGroupItem = React.forwardRef<
ref={ref} ref={ref}
className={cn( className={cn(
"relative w-full rounded-lg border-2 border-border bg-background p-2 shadow-sm transition-all", "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", "hover:bg-muted/70",
"disabled:cursor-not-allowed disabled:opacity-50", "disabled:cursor-not-allowed disabled:opacity-50",
className className

View File

@@ -3,7 +3,6 @@ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority"; import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle"; import { toggleVariants } from "@/components/ui/toggle";
import { Checkbox } from "@/components/ui/checkbox";
import { AspectRatio } from "@/components/ui/aspect-ratio"; import { AspectRatio } from "@/components/ui/aspect-ratio";
import { ProxyImage } from "@/components/custom/proxyImage"; import { ProxyImage } from "@/components/custom/proxyImage";
import { Clock } from "lucide-react"; import { Clock } from "lucide-react";
@@ -11,7 +10,6 @@ import clsx from "clsx";
import { formatDurationString } from "@/utils"; import { formatDurationString } from "@/utils";
import { RawVideoInfo } from "@/types/video"; import { RawVideoInfo } from "@/types/video";
// Create a context to share toggle group props
const PlaylistToggleGroupContext = React.createContext< const PlaylistToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" } VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
>({ >({
@@ -20,19 +18,16 @@ const PlaylistToggleGroupContext = React.createContext<
toggleType: "multiple", toggleType: "multiple",
}); });
// Helper type for the PlaylistToggleGroup
type PlaylistToggleGroupProps = type PlaylistToggleGroupProps =
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> & | (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void }) 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 }); VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
// Main PlaylistToggleGroup component with proper type handling
export const PlaylistToggleGroup = React.forwardRef< export const PlaylistToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>, React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
PlaylistToggleGroupProps PlaylistToggleGroupProps
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => { >(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
// Pass props based on the type
if (type === "single") { if (type === "single") {
return ( return (
<ToggleGroupPrimitive.Root <ToggleGroupPrimitive.Root
@@ -63,85 +58,27 @@ export const PlaylistToggleGroup = React.forwardRef<
}); });
PlaylistToggleGroup.displayName = "PlaylistToggleGroup"; PlaylistToggleGroup.displayName = "PlaylistToggleGroup";
// Rest of your component remains the same
// PlaylistToggleGroupItem component with checkbox and item layout
export const PlaylistToggleGroupItem = React.forwardRef< export const PlaylistToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>, React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> & React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants> & { VariantProps<typeof toggleVariants> & {
video: RawVideoInfo; video: RawVideoInfo;
} }
>(({ className, children, variant, size, video, value, ...props }, ref) => { >(({ 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 ( return (
<ToggleGroupPrimitive.Item <ToggleGroupPrimitive.Item
ref={(el) => { ref={ref}
// Handle both our ref and the forwarded ref
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
ref.current = el;
}
setItemElement(el);
}}
className={cn( className={cn(
"flex w-full p-2 rounded-md transition-colors border-2 border-border", "flex w-full p-2 rounded-lg transition-colors border-2 border-border",
"hover:bg-muted/50 data-[state=on]:bg-muted/70", "hover:bg-muted/70 data-[state=on]:bg-primary/10",
"data-[state=on]:border-primary", "data-[state=on]:border-primary",
className className
)} )}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
value={value} value={value}
{...props} {...props}
> >
<div className="flex gap-2 w-full relative"> <div className="flex gap-2 w-full relative">
<div className="absolute top-2 left-2 z-10"> <div className="w-28 xl:w-40">
<Checkbox
checked={checked}
onClick={handleCheckboxClick}
className={cn(
"transition-opacity",
isHovered || checked ? "opacity-100" : "opacity-0"
)}
/>
</div>
<div className="w-[7rem] xl:w-[10rem]">
<AspectRatio <AspectRatio
ratio={16 / 9} ratio={16 / 9}
className={clsx( className={clsx(
@@ -160,9 +97,9 @@ export const PlaylistToggleGroupItem = React.forwardRef<
</AspectRatio> </AspectRatio>
</div> </div>
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start"> <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">{video.title}</h3> <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-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p> <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"> <div className="flex items-center">
<span className="text-xs text-muted-foreground flex items-center pr-3"> <span className="text-xs text-muted-foreground flex items-center pr-3">
<Clock className="w-4 h-4 mr-2"/> <Clock className="w-4 h-4 mr-2"/>

View File

@@ -6,7 +6,6 @@ import { formatBitrate, formatFileSize } from "@/utils";
import { Loader2, Music, Video, File, AlertCircleIcon, Settings2 } from "lucide-react"; import { Loader2, Music, Video, File, AlertCircleIcon, Settings2 } from "lucide-react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { RawVideoInfo, VideoFormat } from "@/types/video"; import { RawVideoInfo, VideoFormat } from "@/types/video";
// import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; 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 selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat); const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles); 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 downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab); const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload); const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format); const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format);
const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_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 useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const bottomBarRef = useRef<HTMLDivElement>(null); const bottomBarRef = useRef<HTMLDivElement>(null);
const isPlaylist = videoMetadata._type === 'playlist';
const isMultiplePlaylistItems = isPlaylist && selectedPlaylistVideos.length > 1;
let selectedFormatExtensionMsg = 'Auto - unknown'; let selectedFormatExtensionMsg = 'Auto - unknown';
if (activeDownloadModeTab === 'combine') { if (activeDownloadModeTab === 'combine') {
if (downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') { if (downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
@@ -327,8 +330,8 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
let selectedFormatDynamicRangeMsg = ''; let selectedFormatDynamicRangeMsg = '';
if (activeDownloadModeTab === 'combine') { if (activeDownloadModeTab === 'combine') {
selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' ? selectedVideoFormat.dynamic_range : ''; selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' && selectedVideoFormat.dynamic_range !== 'auto' ? selectedVideoFormat.dynamic_range : '';
} else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR') { } else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR' && selectedFormat.dynamic_range !== 'auto') {
selectedFormatDynamicRangeMsg = selectedFormat.dynamic_range; selectedFormatDynamicRangeMsg = selectedFormat.dynamic_range;
} }
@@ -342,13 +345,13 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
let selectedFormatFinalMsg = ''; let selectedFormatFinalMsg = '';
if (activeDownloadModeTab === 'combine') { if (activeDownloadModeTab === 'combine') {
if (selectedCombinableVideoFormat && selectedCombinableAudioFormat) { 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 { } else {
selectedFormatFinalMsg = `Choose a video and audio stream to combine`; selectedFormatFinalMsg = `Choose a video and audio stream to combine`;
} }
} else { } else {
if (selectedFormat) { 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 { } else {
selectedFormatFinalMsg = `Choose a stream to download`; selectedFormatFinalMsg = `Choose a stream to download`;
} }
@@ -397,7 +400,7 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
)} )}
</div> </div>
<div className="flex flex-col gap-1"> <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> <span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span>
</div> </div>
</div> </div>
@@ -410,10 +413,14 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
if (videoMetadata._type === 'playlist') { if (videoMetadata._type === 'playlist') {
await startDownload({ await startDownload({
url: videoMetadata.original_url, 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, downloadConfig: downloadConfiguration,
selectedSubtitles: selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null, 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') { } else if (videoMetadata._type === 'video') {
await startDownload({ await startDownload({

View File

@@ -3,11 +3,12 @@ import { DownloadCloud, Info, ListVideo, AlertCircleIcon } from "lucide-react";
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup"; import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { RawVideoInfo, VideoFormat } from "@/types/video"; 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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; 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 { interface PlaylistPreviewSelectionProps {
videoMetadata: RawVideoInfo; videoMetadata: RawVideoInfo;
@@ -38,28 +39,61 @@ interface PlaylistDownloaderProps {
} }
function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionProps) { function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionProps) {
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex); const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat); const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat); const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat); const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles); 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 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 ( return (
<div className="flex flex-col w-full pr-4"> <div className="flex flex-col w-full pr-4">
<h3 className="text-sm mb-4 mt-2 flex items-center gap-2"> <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" /> <ListVideo className="w-4 h-4" />
<span>Playlist ({videoMetadata.entries[0].n_entries})</span> <span>Playlist ({videoMetadata.entries[0].n_entries})</span>
</h3> </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"> <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> <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> <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" className="mb-2"
type="multiple" type="multiple"
value={selectedVideos} value={selectedPlaylistVideos}
onValueChange={setSelectedVideos} onValueChange={(value: string[]) => {
if (value.length > 0) {
setSelectedPlaylistVideos(value);
setSelectedDownloadFormat('best');
setSelectedSubtitles([]);
setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormat('');
resetDownloadConfiguration();
}
}}
> >
{videoMetadata.entries.map((entry) => entry ? ( {videoMetadata.entries.map((entry) => entry ? (
<PlaylistToggleGroupItem <PlaylistToggleGroupItem
@@ -68,27 +102,7 @@ function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionPro
video={entry} video={entry}
/> />
) : null)} ) : null)}
</PlaylistToggleGroup> */} </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>
<div className="flex items-center text-muted-foreground"> <div className="flex items-center text-muted-foreground">
<Info className="w-3 h-3 mr-2" /> <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> <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) { function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: SelectivePlaylistDownloadProps) {
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat); const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles); 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 setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles); const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration); const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
@@ -120,16 +134,23 @@ function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyF
> >
<p className="text-xs">Subtitle Languages</p> <p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => ( {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 <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" 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} value={lang.code}
size="sm" size="sm"
aria-label={lang.lang} aria-label={lang.lang}
key={lang.code}> key={lang.code}
disabled={isDisabled}>
{lang.lang} {lang.lang}
</ToggleGroupItem> </ToggleGroupItem>
))} );
})}
</div> </div>
</ToggleGroup> </ToggleGroup>
)} )}
@@ -149,7 +170,7 @@ function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyF
<FormatSelectionGroupItem <FormatSelectionGroupItem
key="best" key="best"
value="best" value="best"
format={videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0]} format={getMergedBestFormat(videoMetadata.entries, selectedPlaylistVideos) as VideoFormat}
/> />
</div> </div>
{qualityPresetFormats && qualityPresetFormats.length > 0 && ( {qualityPresetFormats && qualityPresetFormats.length > 0 && (
@@ -235,16 +256,23 @@ function CombinedPlaylistDownload({ audioOnlyFormats, videoOnlyFormats, subtitle
> >
<p className="text-xs">Subtitle Languages</p> <p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => ( {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 <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" 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} value={lang.code}
size="sm" size="sm"
aria-label={lang.lang} aria-label={lang.lang}
key={lang.code}> key={lang.code}
disabled={isDisabled}>
{lang.lang} {lang.lang}
</ToggleGroupItem> </ToggleGroupItem>
))} );
})}
</div> </div>
</ToggleGroup> </ToggleGroup>
)} )}

View File

@@ -112,16 +112,23 @@ function SelectiveVideoDownload({ videoMetadata, audioOnlyFormats, videoOnlyForm
> >
<p className="text-xs">Subtitle Languages</p> <p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => ( {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 <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" 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} value={lang.code}
size="sm" size="sm"
aria-label={lang.lang} aria-label={lang.lang}
key={lang.code}> key={lang.code}
disabled={isDisabled}>
{lang.lang} {lang.lang}
</ToggleGroupItem> </ToggleGroupItem>
))} );
})}
</div> </div>
</ToggleGroup> </ToggleGroup>
)} )}
@@ -227,16 +234,23 @@ function CombinedVideoDownload({ audioOnlyFormats, videoOnlyFormats, subtitleLan
> >
<p className="text-xs">Subtitle Languages</p> <p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => ( {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 <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" 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} value={lang.code}
size="sm" size="sm"
aria-label={lang.lang} aria-label={lang.lang}
key={lang.code}> key={lang.code}
disabled={isDisabled}>
{lang.lang} {lang.lang}
</ToggleGroupItem> </ToggleGroupItem>
))} );
})}
</div> </div>
</ToggleGroup> </ToggleGroup>
)} )}

View File

@@ -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 { 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 { invoke } from "@tauri-apps/api/core";
import * as fs from "@tauri-apps/plugin-fs"; import * as fs from "@tauri-apps/plugin-fs";
import { dirname } from "@tauri-apps/api/path";
import { DownloadState } from "@/types/download"; import { DownloadState } from "@/types/download";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useDeleteDownloadState } from "@/services/mutations"; import { useDeleteDownloadState } from "@/services/mutations";
@@ -60,6 +61,22 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
const removeFromDownloads = async (downloadState: DownloadState, delete_file: boolean) => { const removeFromDownloads = async (downloadState: DownloadState, delete_file: boolean) => {
if (delete_file && downloadState.filepath) { if (delete_file && 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 { try {
if (await fs.exists(downloadState.filepath)) { if (await fs.exists(downloadState.filepath)) {
await fs.remove(downloadState.filepath); await fs.remove(downloadState.filepath);
@@ -70,6 +87,7 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
console.error(e); console.error(e);
} }
} }
}
downloadStateDeleter.mutate(downloadState.download_id, { downloadStateDeleter.mutate(downloadState.download_id, {
onSuccess: (data) => { onSuccess: (data) => {
@@ -77,11 +95,11 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
queryClient.invalidateQueries({ queryKey: ['download-states'] }); queryClient.invalidateQueries({ queryKey: ['download-states'] });
if (delete_file && downloadState.filepath) { if (delete_file && downloadState.filepath) {
toast.success("Deleted from downloads", { 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 { } else {
toast.success("Removed from downloads", { 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); console.error("Failed to delete download state:", error);
if (delete_file && downloadState.filepath) { if (delete_file && downloadState.filepath) {
toast.error("Failed to delete download", { 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 { } else {
toast.error("Failed to remove download", { 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,12 +143,34 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
isDeleteFileChecked: false, isDeleteFileChecked: false,
}; };
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
const isMutilplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
return ( return (
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}> <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"> <div className="w-[30%] flex flex-col justify-between gap-2">
{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"> <AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" /> <ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio> </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"> <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') && ( {state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<Video className="w-4 h-4 mr-2 stroke-primary" /> <Video className="w-4 h-4 mr-2 stroke-primary" />
@@ -143,13 +183,20 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
)} )}
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null} {state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null}
</span> </span>
)}
</div> </div>
<div className="w-full flex flex-col justify-between gap-2"> <div className="w-full flex flex-col justify-between gap-2">
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<h4 className="">{state.title}</h4> <h4 className="">{isMutilplePlaylistItems ? state.playlist_title : 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> <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"> <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" /> <Separator orientation="vertical" />
<span className="text-xs text-muted-foreground flex items-center px-3"> <span className="text-xs text-muted-foreground flex items-center px-3">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && ( {state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
@@ -177,21 +224,21 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
</span> </span>
</div> </div>
<div className="hidden xl:flex items-center mt-1 gap-2 flex-wrap text-xs"> <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 <span
className="border border-border py-1 px-2 rounded flex items-center cursor-pointer" 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'}`} 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> </span>
)} )}
{state.vcodec && ( {state.vcodec && !isMutilplePlaylistItems && (
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.vcodec)}</span> <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> <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> <span className="border border-border py-1 px-2 rounded">{state.dynamic_range}</span>
)} )}
{state.subtitle_id && ( {state.subtitle_id && (
@@ -202,6 +249,22 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
ESUB ESUB
</span> </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> </div>
<div className="w-full flex items-center gap-2"> <div className="w-full flex items-center gap-2">

View File

@@ -7,7 +7,7 @@ import { toast } from "sonner";
import { useAppContext } from "@/providers/appContextProvider"; import { useAppContext } from "@/providers/appContextProvider";
import { useDownloadActionStatesStore, useSettingsPageStatesStore } from "@/services/store"; import { useDownloadActionStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils"; 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 { DownloadState } from "@/types/download";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty"; import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
@@ -37,13 +37,34 @@ export function IncompleteDownload({ state }: IncompleteDownloadProps) {
isDeleteFileChecked: false, isDeleteFileChecked: false,
}; };
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
const isMutilplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
return ( return (
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}> <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"> <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"> {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="" /> <ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio> </AspectRatio>
{state.ext ? ( <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"> <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') && ( {state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<Video className="w-4 h-4 mr-2 stroke-primary" /> <Video className="w-4 h-4 mr-2 stroke-primary" />
@@ -68,12 +89,15 @@ export function IncompleteDownload({ state }: IncompleteDownloadProps) {
</div> </div>
<div className="w-full flex flex-col justify-between"> <div className="w-full flex flex-col justify-between">
<div className="flex flex-col gap-1"> <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')) && ( {((state.download_status === 'starting') || (state.download_status === 'downloading' && state.status === 'finished')) && (
<IndeterminateProgress indeterminate={true} className="w-full" /> <IndeterminateProgress indeterminate={true} className="w-full" />
)} )}
{(state.download_status === 'downloading' || state.download_status === 'paused' || state.download_status === 'errored') && state.progress && state.status !== 'finished' && ( {(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"> <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> <span className="text-sm text-nowrap">{state.progress}%</span>
<Progress value={state.progress} /> <Progress value={state.progress} />
<span className="text-sm text-nowrap">{ <span className="text-sm text-nowrap">{

View File

@@ -362,7 +362,7 @@ function AppFolderSettings() {
</div> </div>
<div className="filename-template"> <div className="filename-template">
<h3 className="font-semibold">Filename Template</h3> <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 {...filenameTemplateForm}>
<form onSubmit={filenameTemplateForm.handleSubmit(handleFilenameTemplateSubmit)} className="flex gap-4 w-full" autoComplete="off"> <form onSubmit={filenameTemplateForm.handleSubmit(handleFilenameTemplateSubmit)} className="flex gap-4 w-full" autoComplete="off">
<FormField <FormField
@@ -1142,7 +1142,7 @@ function AppCommandSettings() {
<FormControl> <FormControl>
<Textarea <Textarea
className="focus-visible:ring-0 min-h-26" 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} {...field}
/> />
</FormControl> </FormControl>
@@ -1395,7 +1395,7 @@ function AppInfoSettings() {
<p className="text-xs text-muted-foreground mb-3">License and usage terms of NeoDLP</p> <p className="text-xs text-muted-foreground mb-3">License and usage terms of NeoDLP</p>
<div className="license"> <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">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"> <span className="flex items-center gap-4 flex-wrap">
<Button className="px-4" variant="outline" size="sm" asChild> <Button className="px-4" variant="outline" size="sm" asChild>
<a href={'https://github.com/' + config.appRepo + '/blob/main/LICENSE'} target="_blank" > <a href={'https://github.com/' + config.appRepo + '/blob/main/LICENSE'} target="_blank" >

View File

@@ -2,10 +2,10 @@ import { DownloadState } from "@/types/download";
import { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { useCallback, useRef } from "react"; import { useCallback, useRef } from "react";
import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store"; 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 { Command } from "@tauri-apps/plugin-shell";
import { RawVideoInfo } from "@/types/video"; 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 { useQueryClient } from "@tanstack/react-query";
import { platform } from "@tauri-apps/plugin-os"; import { platform } from "@tauri-apps/plugin-os";
import { toast } from "sonner"; import { toast } from "sonner";
@@ -76,6 +76,7 @@ export default function useDownloader() {
const downloadStateSaver = useSaveDownloadState(); const downloadStateSaver = useSaveDownloadState();
const downloadStatusUpdater = useUpdateDownloadStatus(); const downloadStatusUpdater = useUpdateDownloadStatus();
const downloadFilePathUpdater = useUpdateDownloadFilePath(); const downloadFilePathUpdater = useUpdateDownloadFilePath();
const playlistItemUpdater = useUpdateDownloadPlaylistItem();
const videoInfoSaver = useSaveVideoInfo(); const videoInfoSaver = useSaveVideoInfo();
const downloadStateDeleter = useDeleteDownloadState(); const downloadStateDeleter = useDeleteDownloadState();
const playlistInfoSaver = useSavePlaylistInfo(); const playlistInfoSaver = useSavePlaylistInfo();
@@ -99,7 +100,7 @@ export default function useDownloader() {
}, 500); }, 500);
const fetchVideoMetadata = async (params: FetchVideoMetadataParams): Promise<RawVideoInfo | null> => { 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 { try {
const args = [url, '--dump-single-json', '--no-warnings']; const args = [url, '--dump-single-json', '--no-warnings'];
if (formatId) args.push('--format', formatId); if (formatId) args.push('--format', formatId);
@@ -108,8 +109,8 @@ export default function useDownloader() {
if (isAutoSub) args.push('--write-auto-sub'); if (isAutoSub) args.push('--write-auto-sub');
args.push('--embed-subs', '--sub-lang', selectedSubtitles); args.push('--embed-subs', '--sub-lang', selectedSubtitles);
} }
if (playlistIndex) args.push('--playlist-items', playlistIndex); if (playlistIndices) args.push('--playlist-items', playlistIndices);
if (PREFER_VIDEO_OVER_PLAYLIST && !playlistIndex) args.push('--no-playlist'); 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-all-formats');
if (STRICT_DOWNLOADABILITY_CHECK && formatId) args.push('--check-formats'); if (STRICT_DOWNLOADABILITY_CHECK && formatId) args.push('--check-formats');
@@ -213,7 +214,7 @@ export default function useDownloader() {
}; };
const startDownload = async (params: StartDownloadParams) => { 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}`); LOG.info('NEODLP', `Initiating yt-dlp download for URL: ${url}`);
console.log('Starting download:', { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems }); console.log('Starting download:', { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems });
@@ -222,12 +223,13 @@ export default function useDownloader() {
return; return;
} }
const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_index) ? true : false; const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_indices) ? true : false;
const playlistIndex = isPlaylist ? (resumeState?.playlist_index?.toString() || playlistItems) : null; const playlistIndices = isPlaylist ? (resumeState?.playlist_indices || playlistItems) : null;
const isMultiplePlaylistItems = isPlaylist && playlistIndices && typeof playlistIndices === 'string' && playlistIndices.includes(',');
let videoMetadata = await fetchVideoMetadata({ let videoMetadata = await fetchVideoMetadata({
url, url,
formatId: selectedFormat, formatId: (!isPlaylist || (isPlaylist && selectedFormat !== 'best')) ? selectedFormat : undefined,
playlistIndex: isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined, playlistIndices: isPlaylist && playlistIndices && typeof playlistIndices === 'string' ? playlistIndices : undefined,
selectedSubtitles, selectedSubtitles,
resumeState resumeState
}); });
@@ -278,14 +280,10 @@ export default function useDownloader() {
`temp:${tempDownloadDirPath}`, `temp:${tempDownloadDirPath}`,
'--paths', '--paths',
`home:${downloadDirPath}`, `home:${downloadDirPath}`,
'--output',
`${FILENAME_TEMPLATE}[${downloadId}].%(ext)s`,
'--windows-filenames', '--windows-filenames',
'--restrict-filenames', '--restrict-filenames',
'--exec', '--exec',
'after_move:echo Finalpath: {}', 'after_move:echo Finalpath: {}',
'--format',
selectedFormat,
'--no-mtime', '--no-mtime',
'--retries', '--retries',
MAX_RETRIES.toString(), 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'); 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) { if (DEBUG_MODE && LOG_VERBOSE) {
args.push('--verbose'); args.push('--verbose');
} else { } else {
@@ -307,8 +326,8 @@ export default function useDownloader() {
args.push('--embed-subs', '--sub-lang', selectedSubtitles); args.push('--embed-subs', '--sub-lang', selectedSubtitles);
} }
if (isPlaylist && playlistIndex && typeof playlistIndex === 'string') { if (isPlaylist && playlistIndices && typeof playlistIndices === 'string') {
args.push('--playlist-items', playlistIndex); args.push('--playlist-items', playlistIndices);
} }
let customCommandArgs = null; let customCommandArgs = null;
@@ -466,11 +485,11 @@ export default function useDownloader() {
addErroredDownload(downloadId); addErroredDownload(downloadId);
}); });
command.stdout.on('data', line => { command.stdout.on('data', async line => {
if (line.startsWith('status:') || line.startsWith('[#')) { if (line.startsWith('status:') || line.startsWith('[#')) {
// console.log(line); // console.log(line);
if (DEBUG_MODE && LOG_PROGRESS) LOG.progress(`YT-DLP Download ${downloadId}`, 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 = { const state: DownloadState = {
download_id: downloadId, download_id: downloadId,
download_status: 'downloading', download_status: 'downloading',
@@ -479,7 +498,7 @@ export default function useDownloader() {
subtitle_id: selectedSubtitles || null, subtitle_id: selectedSubtitles || null,
queue_index: null, queue_index: null,
playlist_id: playlistId, playlist_id: playlistId,
playlist_index: playlistIndex ? Number(playlistIndex) : null, playlist_indices: playlistIndices ?? null,
title: videoMetadata.title, title: videoMetadata.title,
url: url, url: url,
host: videoMetadata.webpage_url_domain, host: videoMetadata.webpage_url_domain,
@@ -495,13 +514,14 @@ export default function useDownloader() {
playlist_channel: videoMetadata.playlist_channel || null, playlist_channel: videoMetadata.playlist_channel || null,
resolution: videoMetadata.resolution || null, resolution: videoMetadata.resolution || null,
ext: videoMetadata.ext || null, ext: videoMetadata.ext || null,
abr: videoMetadata.abr || null, abr: resumeState?.abr || overrideOptions?.tbr/2 || videoMetadata.abr || null,
vbr: videoMetadata.vbr || null, vbr: resumeState?.vbr || overrideOptions?.tbr/2 || videoMetadata.vbr || null,
acodec: videoMetadata.acodec || null, acodec: videoMetadata.acodec || null,
vcodec: videoMetadata.vcodec || null, vcodec: videoMetadata.vcodec || null,
dynamic_range: videoMetadata.dynamic_range || null, dynamic_range: videoMetadata.dynamic_range || null,
process_id: processPid, process_id: processPid,
status: currentProgress.status || null, status: currentProgress.status || null,
item: currentProgress.item || null,
progress: currentProgress.progress || null, progress: currentProgress.progress || null,
total: currentProgress.total || null, total: currentProgress.total || null,
downloaded: currentProgress.downloaded || null, downloaded: currentProgress.downloaded || null,
@@ -509,7 +529,7 @@ export default function useDownloader() {
eta: currentProgress.eta || null, eta: currentProgress.eta || null,
filepath: downloadFilePath, filepath: downloadFilePath,
filetype: determineFileType(videoMetadata.vcodec, videoMetadata.acodec) || null, filetype: determineFileType(videoMetadata.vcodec, videoMetadata.acodec) || null,
filesize: videoMetadata.filesize_approx || null, filesize: resumeState?.filesize || overrideOptions?.filesize || videoMetadata.filesize_approx || null,
output_format: outputFormat, output_format: outputFormat,
embed_metadata: embedMetadata, embed_metadata: embedMetadata,
embed_thumbnail: embedThumbnail, embed_thumbnail: embedThumbnail,
@@ -525,7 +545,67 @@ export default function useDownloader() {
// console.log(line); // console.log(line);
if (line.trim() !== '') LOG.info(`YT-DLP Download ${downloadId}`, 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, ''); downloadFilePath = line.replace('Finalpath: ', '').trim().replace(/^"|"$/g, '');
const downloadedFileExt = downloadFilePath.split('.').pop(); const downloadedFileExt = downloadFilePath.split('.').pop();
@@ -607,7 +687,7 @@ export default function useDownloader() {
subtitle_id: selectedSubtitles || null, subtitle_id: selectedSubtitles || null,
queue_index: (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? null : (queuedDownloads?.length || 0), queue_index: (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? null : (queuedDownloads?.length || 0),
playlist_id: playlistId, playlist_id: playlistId,
playlist_index: playlistIndex ? Number(playlistIndex) : null, playlist_indices: playlistIndices ?? null,
title: videoMetadata.title, title: videoMetadata.title,
url: url, url: url,
host: videoMetadata.webpage_url_domain, host: videoMetadata.webpage_url_domain,
@@ -623,13 +703,14 @@ export default function useDownloader() {
playlist_channel: videoMetadata.playlist_channel || null, playlist_channel: videoMetadata.playlist_channel || null,
resolution: resumeState?.resolution || null, resolution: resumeState?.resolution || null,
ext: resumeState?.ext || null, ext: resumeState?.ext || null,
abr: resumeState?.abr || null, abr: resumeState?.abr || overrideOptions?.tbr/2 || null,
vbr: resumeState?.vbr || null, vbr: resumeState?.vbr || overrideOptions?.tbr/2 || null,
acodec: resumeState?.acodec || null, acodec: resumeState?.acodec || null,
vcodec: resumeState?.vcodec || null, vcodec: resumeState?.vcodec || null,
dynamic_range: resumeState?.dynamic_range || null, dynamic_range: resumeState?.dynamic_range || null,
process_id: resumeState?.process_id || null, process_id: resumeState?.process_id || null,
status: resumeState?.status || null, status: resumeState?.status || null,
item: resumeState?.item || null,
progress: resumeState?.progress || null, progress: resumeState?.progress || null,
total: resumeState?.total || null, total: resumeState?.total || null,
downloaded: resumeState?.downloaded || null, downloaded: resumeState?.downloaded || null,
@@ -637,7 +718,7 @@ export default function useDownloader() {
eta: resumeState?.eta || null, eta: resumeState?.eta || null,
filepath: downloadFilePath, filepath: downloadFilePath,
filetype: resumeState?.filetype || null, filetype: resumeState?.filetype || null,
filesize: resumeState?.filesize || null, filesize: resumeState?.filesize || overrideOptions?.filesize || null,
output_format: resumeState?.output_format || null, output_format: resumeState?.output_format || null,
embed_metadata: resumeState?.embed_metadata || 0, embed_metadata: resumeState?.embed_metadata || 0,
embed_thumbnail: resumeState?.embed_thumbnail || 0, embed_thumbnail: resumeState?.embed_thumbnail || 0,
@@ -725,7 +806,7 @@ export default function useDownloader() {
try { try {
LOG.info('NEODLP', `Resuming yt-dlp download with id: ${downloadState.download_id} (as per user request)`); LOG.info('NEODLP', `Resuming yt-dlp download with id: ${downloadState.download_id} (as per user request)`);
await startDownload({ 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, selectedFormat: downloadState.format_id,
downloadConfig: downloadState.queue_config ? JSON.parse(downloadState.queue_config) : { downloadConfig: downloadState.queue_config ? JSON.parse(downloadState.queue_config) : {
output_format: null, output_format: null,

View File

@@ -4,7 +4,7 @@ import { Input } from "@/components/ui/input";
import { toast } from "sonner"; import { toast } from "sonner";
import { useAppContext } from "@/providers/appContextProvider"; import { useAppContext } from "@/providers/appContextProvider";
import { useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useSettingsPageStatesStore } from "@/services/store"; 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 { Loader2, PackageSearch, X, Clipboard } from "lucide-react";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { VideoFormat } from "@/types/video"; import { VideoFormat } from "@/types/video";
@@ -49,12 +49,12 @@ export default function DownloaderPage() {
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat); const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat); const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat); 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 setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat); const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat); const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles); 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 resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
const appTheme = useSettingsPageStatesStore(state => state.settings.theme); const appTheme = useSettingsPageStatesStore(state => state.settings.theme);
@@ -62,12 +62,21 @@ export default function DownloaderPage() {
const containerRef = useRef<HTMLDivElement>(null); 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 commonFormats = (() => {
const videoOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video'))) : []; if (videoMetadata?._type === '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'))) : []; 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 audioOnlyFormats = sortByBitrate(commonFormats.filter(fileFormatFilter('audio')));
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 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' ? const qualityPresetFormats: VideoFormat[] | undefined = videoMetadata?.webpage_url_domain === 'youtube.com' ?
av1VideoFormats && opusAudioFormats ? av1VideoFormats && opusAudioFormats ?
av1VideoFormats.map((av1Format) => { av1VideoFormats.map((av1Format) => {
@@ -98,7 +107,7 @@ export default function DownloaderPage() {
); );
} else if (videoMetadata?._type === 'playlist') { } else if (videoMetadata?._type === 'playlist') {
if (selectedDownloadFormat === 'best') { if (selectedDownloadFormat === 'best') {
return videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0]; return getMergedBestFormat(videoMetadata.entries, selectedPlaylistVideos);
} }
return allFilteredFormats.find( return allFilteredFormats.find(
(format) => format.format_id === selectedDownloadFormat (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 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 originalAutoSubtitles = Object.fromEntries(Object.entries(autoSubtitles).filter(([key]) => key.endsWith('-orig')));
const subtitleLanguages = Object.keys(filteredSubtitles).map(langCode => ({ const subtitleLanguages = Object.keys(filteredSubtitles).map(langCode => ({
@@ -165,7 +188,7 @@ export default function DownloaderPage() {
setSelectedCombinableVideoFormat(''); setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormat(''); setSelectedCombinableAudioFormat('');
setSelectedSubtitles([]); setSelectedSubtitles([]);
setSelectedPlaylistVideoIndex('1'); setSelectedPlaylistVideos(["1"]);
resetDownloadConfiguration(); resetDownloadConfiguration();
fetchVideoMetadata({ url: values.url }).then((metadata) => { fetchVideoMetadata({ url: values.url }).then((metadata) => {

View File

@@ -6,7 +6,7 @@ import { createContext, useContext } from 'react';
export interface FetchVideoMetadataParams { export interface FetchVideoMetadataParams {
url: string; url: string;
formatId?: string; formatId?: string;
playlistIndex?: string; playlistIndices?: string;
selectedSubtitles?: string | null; selectedSubtitles?: string | null;
resumeState?: DownloadState; resumeState?: DownloadState;
downloadConfig?: DownloadConfiguration; downloadConfig?: DownloadConfiguration;
@@ -19,6 +19,9 @@ export interface StartDownloadParams {
selectedSubtitles?: string | null; selectedSubtitles?: string | null;
resumeState?: DownloadState; resumeState?: DownloadState;
playlistItems?: string; playlistItems?: string;
overrideOptions?: {
[key: string]: any;
}
}; };
interface AppContextType { interface AppContextType {

View File

@@ -80,7 +80,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
subtitle_id, subtitle_id,
queue_index, queue_index,
playlist_id, playlist_id,
playlist_index, playlist_indices,
process_id, process_id,
resolution, resolution,
ext, ext,
@@ -90,6 +90,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
vcodec, vcodec,
dynamic_range, dynamic_range,
status, status,
item,
progress, progress,
total, total,
downloaded, downloaded,
@@ -107,7 +108,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
use_aria2, use_aria2,
custom_command, custom_command,
queue_config 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 ON CONFLICT(download_id) DO UPDATE SET
download_status = $2, download_status = $2,
video_id = $3, video_id = $3,
@@ -115,7 +116,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
subtitle_id = $5, subtitle_id = $5,
queue_index = $6, queue_index = $6,
playlist_id = $7, playlist_id = $7,
playlist_index = $8, playlist_indices = $8,
process_id = $9, process_id = $9,
resolution = $10, resolution = $10,
ext = $11, ext = $11,
@@ -125,23 +126,24 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
vcodec = $15, vcodec = $15,
dynamic_range = $16, dynamic_range = $16,
status = $17, status = $17,
progress = $18, item = $18,
total = $19, progress = $19,
downloaded = $20, total = $20,
speed = $21, downloaded = $21,
eta = $22, speed = $22,
filepath = $23, eta = $23,
filetype = $24, filepath = $24,
filesize = $25, filetype = $25,
output_format = $26, filesize = $26,
embed_metadata = $27, output_format = $27,
embed_thumbnail = $28, embed_metadata = $28,
square_crop_thumbnail = $29, embed_thumbnail = $29,
sponsorblock_remove = $30, square_crop_thumbnail = $30,
sponsorblock_mark = $31, sponsorblock_remove = $31,
use_aria2 = $32, sponsorblock_mark = $32,
custom_command = $33, use_aria2 = $33,
queue_config = $34`, custom_command = $34,
queue_config = $35`,
[ [
downloadState.download_id, downloadState.download_id,
downloadState.download_status, downloadState.download_status,
@@ -150,7 +152,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
downloadState.subtitle_id, downloadState.subtitle_id,
downloadState.queue_index, downloadState.queue_index,
downloadState.playlist_id, downloadState.playlist_id,
downloadState.playlist_index, downloadState.playlist_indices,
downloadState.process_id, downloadState.process_id,
downloadState.resolution, downloadState.resolution,
downloadState.ext, downloadState.ext,
@@ -160,6 +162,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
downloadState.vcodec, downloadState.vcodec,
downloadState.dynamic_range, downloadState.dynamic_range,
downloadState.status, downloadState.status,
downloadState.item,
downloadState.progress, downloadState.progress,
downloadState.total, downloadState.total,
downloadState.downloaded, 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) => { export const deleteDownloadState = async (download_id: string) => {
const db = await Database.load('sqlite:database.db') const db = await Database.load('sqlite:database.db')
return await db.execute( 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 () => { export const fetchAllSettings = async () => {
const db = await Database.load('sqlite:database.db') const db = await Database.load('sqlite:database.db')
const result = await db.select<SettingsTable[]>( const result = await db.select<SettingsTable[]>(

View File

@@ -1,6 +1,6 @@
import { VideoInfo } from "@/types/video"; import { VideoInfo } from "@/types/video";
import { useMutation } from "@tanstack/react-query"; 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 { DownloadState } from "@/types/download";
import { PlaylistInfo } from "@/types/playlist"; 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() { export function useDeleteDownloadState() {
return useMutation({ return useMutation({
mutationFn: (data: string) => deleteDownloadState(data) mutationFn: (data: string) => deleteDownloadState(data)

View File

@@ -53,7 +53,7 @@ export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((s
selectedCombinableVideoFormat: '', selectedCombinableVideoFormat: '',
selectedCombinableAudioFormat: '', selectedCombinableAudioFormat: '',
selectedSubtitles: [], selectedSubtitles: [],
selectedPlaylistVideoIndex: '1', selectedPlaylistVideos: ["1"],
downloadConfiguration: { downloadConfiguration: {
output_format: null, output_format: null,
embed_metadata: null, embed_metadata: null,
@@ -73,7 +73,7 @@ export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((s
setSelectedCombinableVideoFormat: (format) => set(() => ({ selectedCombinableVideoFormat: format })), setSelectedCombinableVideoFormat: (format) => set(() => ({ selectedCombinableVideoFormat: format })),
setSelectedCombinableAudioFormat: (format) => set(() => ({ selectedCombinableAudioFormat: format })), setSelectedCombinableAudioFormat: (format) => set(() => ({ selectedCombinableAudioFormat: format })),
setSelectedSubtitles: (subtitles) => set(() => ({ selectedSubtitles: subtitles })), setSelectedSubtitles: (subtitles) => set(() => ({ selectedSubtitles: subtitles })),
setSelectedPlaylistVideoIndex: (index) => set(() => ({ selectedPlaylistVideoIndex: index })), setSelectedPlaylistVideos: (indices) => set(() => ({ selectedPlaylistVideos: indices })),
setDownloadConfigurationKey: (key, value) => set((state) => ({ setDownloadConfigurationKey: (key, value) => set((state) => ({
downloadConfiguration: { downloadConfiguration: {
...state.downloadConfiguration, ...state.downloadConfiguration,

View File

@@ -6,7 +6,7 @@ export interface DownloadState {
subtitle_id: string | null; subtitle_id: string | null;
queue_index: number | null; queue_index: number | null;
playlist_id: string | null; playlist_id: string | null;
playlist_index: number | null; playlist_indices: string | null;
title: string; title: string;
url: string; url: string;
host: string; host: string;
@@ -29,6 +29,7 @@ export interface DownloadState {
dynamic_range: string | null; dynamic_range: string | null;
process_id: number | null; process_id: number | null;
status: string | null; status: string | null;
item: string | null;
progress: number | null; progress: number | null;
total: number | null; total: number | null;
downloaded: number | null; downloaded: number | null;
@@ -58,7 +59,7 @@ export interface Download {
subtitle_id: string | null; subtitle_id: string | null;
queue_index: number | null; queue_index: number | null;
playlist_id: string | null; playlist_id: string | null;
playlist_index: number | null; playlist_indices: string | null;
resolution: string | null; resolution: string | null;
ext: string | null; ext: string | null;
abr: number | null; abr: number | null;
@@ -68,6 +69,7 @@ export interface Download {
dynamic_range: string | null; dynamic_range: string | null;
process_id: number | null; process_id: number | null;
status: string | null; status: string | null;
item: string | null;
progress: number | null; progress: number | null;
total: number | null; total: number | null;
downloaded: number | null; downloaded: number | null;
@@ -91,6 +93,7 @@ export interface Download {
export interface DownloadProgress { export interface DownloadProgress {
status: string | null; status: string | null;
item: string | null;
progress: number | null; progress: number | null;
speed: number | null; speed: number | null;
downloaded: number | null; downloaded: number | null;

View File

@@ -43,7 +43,7 @@ export interface DownloaderPageStatesStore {
selectedCombinableVideoFormat: string; selectedCombinableVideoFormat: string;
selectedCombinableAudioFormat: string; selectedCombinableAudioFormat: string;
selectedSubtitles: string[]; selectedSubtitles: string[];
selectedPlaylistVideoIndex: string; selectedPlaylistVideos: string[];
downloadConfiguration: DownloadConfiguration; downloadConfiguration: DownloadConfiguration;
erroredDownloadIds: Set<string>; erroredDownloadIds: Set<string>;
expectedErrorDownloadIds: Set<string>; expectedErrorDownloadIds: Set<string>;
@@ -56,7 +56,7 @@ export interface DownloaderPageStatesStore {
setSelectedCombinableVideoFormat: (format: string) => void; setSelectedCombinableVideoFormat: (format: string) => void;
setSelectedCombinableAudioFormat: (format: string) => void; setSelectedCombinableAudioFormat: (format: string) => void;
setSelectedSubtitles: (subtitles: string[]) => void; setSelectedSubtitles: (subtitles: string[]) => void;
setSelectedPlaylistVideoIndex: (index: string) => void; setSelectedPlaylistVideos: (indices: string[]) => void;
setDownloadConfigurationKey: (key: string, value: unknown) => void; setDownloadConfigurationKey: (key: string, value: unknown) => void;
setDownloadConfiguration: (config: DownloadConfiguration) => void; setDownloadConfiguration: (config: DownloadConfiguration) => void;
resetDownloadConfiguration: () => void; resetDownloadConfiguration: () => void;

View File

@@ -1,8 +1,9 @@
import { RoutesObj } from "@/types/route"; import { RoutesObj } from "@/types/route";
import { AllRoutes } from "@/routes"; import { AllRoutes } from "@/routes";
import { DownloadProgress, Paginated } from "@/types/download"; 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 * as fs from "@tauri-apps/plugin-fs";
import { fetchDownloadStateById } from "@/services/database";
export function isActive(path: string, location: string, starts_with: boolean = false): boolean { export function isActive(path: string, location: string, starts_with: boolean = false): boolean {
if (starts_with) { 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> = { const progress: Partial<DownloadProgress> = {
status: 'downloading' status: 'downloading',
item: state?.item || null,
}; };
// Check if line contains both aria2c and yt-dlp format (combined format) // 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; 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) => { export const formatSpeed = (bytes: number) => {
if (bytes === 0) return '0 B/s'; if (bytes === 0) return '0 B/s';
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/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 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]])
);
};