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,
queue_index INTEGER,
playlist_id TEXT,
playlist_index INTEGER,
playlist_indices TEXT,
resolution TEXT,
ext TEXT,
abr REAL,
@@ -173,6 +173,7 @@ pub fn get_migrations() -> Vec<Migration> {
dynamic_range TEXT,
process_id INTEGER,
status TEXT,
item TEXT,
progress REAL,
total INTEGER,
downloaded INTEGER,
@@ -199,13 +200,17 @@ pub fn get_migrations() -> Vec<Migration> {
-- Copy all data from original table to temporary table with default values for new columns
INSERT INTO downloads_temp SELECT
id, download_id, download_status, video_id, format_id, subtitle_id,
queue_index, playlist_id, playlist_index, resolution, ext, abr, vbr,
acodec, vcodec, dynamic_range, process_id, status, progress, total,
downloaded, speed, eta, filepath, filetype, filesize,
queue_index, playlist_id,
CAST(playlist_index AS TEXT), -- Convert INTEGER playlist_index to TEXT playlist_indices
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,
embed_metadata,
embed_thumbnail,
0, -- square_crop_thumbnail
0, -- square_crop_thumbnail
sponsorblock_remove, sponsorblock_mark, use_aria2,
custom_command, queue_config, created_at, updated_at
FROM downloads;

View File

@@ -36,7 +36,7 @@ const PlaylistSelectionGroupItem = React.forwardRef<
ref={ref}
className={cn(
"relative w-full rounded-lg border-2 border-border bg-background p-2 shadow-sm transition-all",
"data-[state=checked]:border-primary data-[state=checked]:border-2 data-[state=checked]:bg-primary/10",
"data-[state=checked]:border-primary data-[state=checked]:bg-primary/10",
"hover:bg-muted/70",
"disabled:cursor-not-allowed disabled:opacity-50",
className

View File

@@ -3,7 +3,6 @@ import * as ToggleGroupPrimitive from "@radix-ui/react-toggle-group";
import { type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
import { toggleVariants } from "@/components/ui/toggle";
import { Checkbox } from "@/components/ui/checkbox";
import { AspectRatio } from "@/components/ui/aspect-ratio";
import { ProxyImage } from "@/components/custom/proxyImage";
import { Clock } from "lucide-react";
@@ -11,7 +10,6 @@ import clsx from "clsx";
import { formatDurationString } from "@/utils";
import { RawVideoInfo } from "@/types/video";
// Create a context to share toggle group props
const PlaylistToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & { toggleType?: "single" | "multiple" }
>({
@@ -20,19 +18,16 @@ const PlaylistToggleGroupContext = React.createContext<
toggleType: "multiple",
});
// Helper type for the PlaylistToggleGroup
type PlaylistToggleGroupProps =
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
VariantProps<typeof toggleVariants> & { type: "single", value?: string, onValueChange?: (value: string) => void })
| (Omit<React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>, "type"> &
VariantProps<typeof toggleVariants> & { type: "multiple", value?: string[], onValueChange?: (value: string[]) => void });
// Main PlaylistToggleGroup component with proper type handling
export const PlaylistToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentRef<typeof ToggleGroupPrimitive.Root>,
PlaylistToggleGroupProps
>(({ className, variant, size, children, type = "multiple", ...props }, ref) => {
// Pass props based on the type
if (type === "single") {
return (
<ToggleGroupPrimitive.Root
@@ -63,85 +58,27 @@ export const PlaylistToggleGroup = React.forwardRef<
});
PlaylistToggleGroup.displayName = "PlaylistToggleGroup";
// Rest of your component remains the same
// PlaylistToggleGroupItem component with checkbox and item layout
export const PlaylistToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants> & {
video: RawVideoInfo;
}
>(({ className, children, variant, size, video, value, ...props }, ref) => {
const [isHovered, setIsHovered] = React.useState(false);
const [checked, setChecked] = React.useState(false);
// Instead of a ref + useEffect approach
const [itemElement, setItemElement] = React.useState<HTMLButtonElement | null>(null);
// Handle checkbox click separately by simulating a click on the parent item
const handleCheckboxClick = (e: React.MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
// Manually trigger the item's click to toggle selection
if (itemElement) {
// This simulates a click on the toggle item itself
itemElement.click();
}
};
// Use an effect that triggers when itemElement changes
React.useEffect(() => {
if (itemElement) {
const observer = new MutationObserver((mutations) => {
mutations.forEach((mutation) => {
if (mutation.attributeName === 'data-state') {
setChecked(itemElement.getAttribute('data-state') === 'on');
}
});
});
setChecked(itemElement.getAttribute('data-state') === 'on');
observer.observe(itemElement, { attributes: true });
return () => observer.disconnect();
}
}, [itemElement]);
return (
<ToggleGroupPrimitive.Item
ref={(el) => {
// Handle both our ref and the forwarded ref
if (typeof ref === 'function') {
ref(el);
} else if (ref) {
ref.current = el;
}
setItemElement(el);
}}
ref={ref}
className={cn(
"flex w-full p-2 rounded-md transition-colors border-2 border-border",
"hover:bg-muted/50 data-[state=on]:bg-muted/70",
"flex w-full p-2 rounded-lg transition-colors border-2 border-border",
"hover:bg-muted/70 data-[state=on]:bg-primary/10",
"data-[state=on]:border-primary",
className
)}
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
value={value}
{...props}
>
<div className="flex gap-2 w-full relative">
<div className="absolute top-2 left-2 z-10">
<Checkbox
checked={checked}
onClick={handleCheckboxClick}
className={cn(
"transition-opacity",
isHovered || checked ? "opacity-100" : "opacity-0"
)}
/>
</div>
<div className="w-[7rem] xl:w-[10rem]">
<div className="w-28 xl:w-40">
<AspectRatio
ratio={16 / 9}
className={clsx(
@@ -160,9 +97,9 @@ export const PlaylistToggleGroupItem = React.forwardRef<
</AspectRatio>
</div>
<div className="flex w-[10rem] lg:w-[12rem] xl:w-[15rem] flex-col items-start text-start">
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1">{video.title}</h3>
<p className="text-xs text-muted-foreground mb-2">{video.channel || video.uploader || 'unknown'}</p>
<div className="flex w-40 lg:w-48 xl:w-60 flex-col items-start text-start">
<h3 className="text-sm text-nowrap w-full overflow-hidden text-ellipsis mb-1" title={video.title}>{video.title}</h3>
<p className="text-xs text-nowrap w-full overflow-hidden text-ellipsis text-muted-foreground mb-2" title={video.creator || video.channel || video.uploader || 'unknown'}>{video.creator || video.channel || video.uploader || 'unknown'}</p>
<div className="flex items-center">
<span className="text-xs text-muted-foreground flex items-center pr-3">
<Clock className="w-4 h-4 mr-2"/>

View File

@@ -6,7 +6,6 @@ import { formatBitrate, formatFileSize } from "@/utils";
import { Loader2, Music, Video, File, AlertCircleIcon, Settings2 } from "lucide-react";
import { useEffect, useRef } from "react";
import { RawVideoInfo, VideoFormat } from "@/types/video";
// import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
@@ -282,17 +281,21 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
const videoFormat = useSettingsPageStatesStore(state => state.settings.video_format);
const audioFormat = useSettingsPageStatesStore(state => state.settings.audio_format);
const useSponsorblock = useSettingsPageStatesStore(state => state.settings.use_sponsorblock);
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const bottomBarRef = useRef<HTMLDivElement>(null);
const isPlaylist = videoMetadata._type === 'playlist';
const isMultiplePlaylistItems = isPlaylist && selectedPlaylistVideos.length > 1;
let selectedFormatExtensionMsg = 'Auto - unknown';
if (activeDownloadModeTab === 'combine') {
if (downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
@@ -327,8 +330,8 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
let selectedFormatDynamicRangeMsg = '';
if (activeDownloadModeTab === 'combine') {
selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' ? selectedVideoFormat.dynamic_range : '';
} else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR') {
selectedFormatDynamicRangeMsg = selectedVideoFormat?.dynamic_range && selectedVideoFormat.dynamic_range !== 'SDR' && selectedVideoFormat.dynamic_range !== 'auto' ? selectedVideoFormat.dynamic_range : '';
} else if (selectedFormat?.dynamic_range && selectedFormat.dynamic_range !== 'SDR' && selectedFormat.dynamic_range !== 'auto') {
selectedFormatDynamicRangeMsg = selectedFormat.dynamic_range;
}
@@ -342,13 +345,13 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
let selectedFormatFinalMsg = '';
if (activeDownloadModeTab === 'combine') {
if (selectedCombinableVideoFormat && selectedCombinableAudioFormat) {
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} • ${selectedFormatFileSizeMsg}`;
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} ${useSponsorblock || (downloadConfiguration.sponsorblock && downloadConfiguration.sponsorblock !== 'auto') ? `• SPBLOCK` : ''} • ${selectedFormatFileSizeMsg}`;
} else {
selectedFormatFinalMsg = `Choose a video and audio stream to combine`;
}
} else {
if (selectedFormat) {
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} • ${selectedFormatFileSizeMsg}`;
selectedFormatFinalMsg = `${selectedFormatExtensionMsg} (${selectedFormatResolutionMsg}) ${selectedFormatDynamicRangeMsg} ${selectedSubtitles.length > 0 ? `• ESUB` : ''} ${useSponsorblock || (downloadConfiguration.sponsorblock && downloadConfiguration.sponsorblock !== 'auto') ? `• SPBLOCK` : ''} • ${selectedFormatFileSizeMsg}`;
} else {
selectedFormatFinalMsg = `Choose a stream to download`;
}
@@ -397,7 +400,7 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
)}
</div>
<div className="flex flex-col gap-1">
<span className="text-sm text-nowrap max-w-120 xl:max-w-200 overflow-hidden text-ellipsis">{videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].title : 'Unknown' }</span>
<span className="text-sm text-nowrap max-w-120 xl:max-w-200 overflow-hidden text-ellipsis">{videoMetadata._type === 'video' ? videoMetadata.title : videoMetadata._type === 'playlist' ? selectedPlaylistVideos.length === 1 ? videoMetadata.entries[Number(selectedPlaylistVideos[0]) - 1].title : `${selectedPlaylistVideos.length} Items` : 'Unknown' }</span>
<span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span>
</div>
</div>
@@ -410,10 +413,14 @@ export function BottomBar({ videoMetadata, selectedFormat, selectedFormatFileTyp
if (videoMetadata._type === 'playlist') {
await startDownload({
url: videoMetadata.original_url,
selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat,
selectedFormat: activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat,
downloadConfig: downloadConfiguration,
selectedSubtitles: selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
playlistItems: selectedPlaylistVideoIndex
playlistItems: selectedPlaylistVideos.sort((a, b) => Number(a) - Number(b)).join(','),
overrideOptions: isMultiplePlaylistItems ? {
filesize: activeDownloadModeTab === 'combine' ? (selectedVideoFormat?.filesize_approx && selectedAudioFormat?.filesize_approx ? selectedVideoFormat.filesize_approx + selectedAudioFormat.filesize_approx : undefined) : selectedFormat?.filesize_approx ? selectedFormat.filesize_approx : undefined,
tbr: activeDownloadModeTab === 'combine' ? (selectedVideoFormat?.tbr && selectedAudioFormat?.tbr ? selectedVideoFormat.tbr + selectedAudioFormat.tbr : undefined) : selectedFormat?.tbr ? selectedFormat.tbr : undefined,
} : undefined
});
} else if (videoMetadata._type === 'video') {
await startDownload({

View File

@@ -3,11 +3,12 @@ import { DownloadCloud, Info, ListVideo, AlertCircleIcon } from "lucide-react";
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { RawVideoInfo, VideoFormat } from "@/types/video";
// import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup";
import { PlaylistSelectionGroup, PlaylistSelectionGroupItem } from "@/components/custom/playlistSelectionGroup";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable";
import { PlaylistToggleGroup, PlaylistToggleGroupItem } from "@/components/custom/playlistToggleGroup";
import { getMergedBestFormat } from "@/utils";
import { Switch } from "@/components/ui/switch";
interface PlaylistPreviewSelectionProps {
videoMetadata: RawVideoInfo;
@@ -38,28 +39,61 @@ interface PlaylistDownloaderProps {
}
function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionProps) {
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const setSelectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideoIndex);
const setSelectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideos);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
const totalVideos = videoMetadata.entries.filter((entry) => entry).length;
const allVideoIndices = videoMetadata.entries.filter((entry) => entry).map((entry) => entry.playlist_index.toString());
return (
<div className="flex flex-col w-full pr-4">
<h3 className="text-sm mb-4 mt-2 flex items-center gap-2">
<ListVideo className="w-4 h-4" />
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
</h3>
<div className="flex items-center justify-between mb-4 mt-2">
<h3 className="text-sm flex items-center gap-2">
<ListVideo className="w-4 h-4" />
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
</h3>
<div className="flex items-center space-x-2">
<Switch
id="select-all-videos"
checked={selectedPlaylistVideos.length === totalVideos && totalVideos > 0}
onCheckedChange={(checked) => {
if (checked) {
setSelectedPlaylistVideos(allVideoIndices);
} else {
setSelectedPlaylistVideos(["1"]);
}
setSelectedDownloadFormat('best');
setSelectedSubtitles([]);
setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormat('');
resetDownloadConfiguration();
}}
disabled={totalVideos <= 1}
/>
</div>
</div>
<div className="flex flex-col overflow-y-scroll max-h-[50vh] xl:max-h-[60vh] no-scrollbar">
<h2 className="mb-1">{videoMetadata.entries[0].playlist_title ? videoMetadata.entries[0].playlist_title : 'UNTITLED'}</h2>
<p className="text-muted-foreground text-xs mb-4">{videoMetadata.entries[0].playlist_creator || videoMetadata.entries[0].playlist_channel || videoMetadata.entries[0].playlist_uploader || 'unknown'}</p>
{/* <PlaylistToggleGroup
<PlaylistToggleGroup
className="mb-2"
type="multiple"
value={selectedVideos}
onValueChange={setSelectedVideos}
value={selectedPlaylistVideos}
onValueChange={(value: string[]) => {
if (value.length > 0) {
setSelectedPlaylistVideos(value);
setSelectedDownloadFormat('best');
setSelectedSubtitles([]);
setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormat('');
resetDownloadConfiguration();
}
}}
>
{videoMetadata.entries.map((entry) => entry ? (
<PlaylistToggleGroupItem
@@ -68,27 +102,7 @@ function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionPro
video={entry}
/>
) : null)}
</PlaylistToggleGroup> */}
<PlaylistSelectionGroup
className="mb-2"
value={selectedPlaylistVideoIndex}
onValueChange={(value) => {
setSelectedPlaylistVideoIndex(value);
setSelectedDownloadFormat('best');
setSelectedSubtitles([]);
setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormat('');
resetDownloadConfiguration();
}}
>
{videoMetadata.entries.map((entry) => entry ? (
<PlaylistSelectionGroupItem
key={entry.playlist_index}
value={entry.playlist_index.toString()}
video={entry}
/>
) : null)}
</PlaylistSelectionGroup>
</PlaylistToggleGroup>
<div className="flex items-center text-muted-foreground">
<Info className="w-3 h-3 mr-2" />
<span className="text-xs">Extracted from {videoMetadata.entries[0].extractor ? videoMetadata.entries[0].extractor.charAt(0).toUpperCase() + videoMetadata.entries[0].extractor.slice(1) : 'Unknown'}</span>
@@ -102,7 +116,7 @@ function PlaylistPreviewSelection({ videoMetadata }: PlaylistPreviewSelectionPro
function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyFormats, combinedFormats, qualityPresetFormats, subtitleLanguages }: SelectivePlaylistDownloadProps) {
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
const selectedPlaylistVideos = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideos);
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
@@ -120,16 +134,23 @@ function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyF
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}>
{lang.lang}
</ToggleGroupItem>
))}
{subtitleLanguages.map((lang) => {
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
return (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}
disabled={isDisabled}>
{lang.lang}
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
)}
@@ -149,7 +170,7 @@ function SelectivePlaylistDownload({ videoMetadata, audioOnlyFormats, videoOnlyF
<FormatSelectionGroupItem
key="best"
value="best"
format={videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0]}
format={getMergedBestFormat(videoMetadata.entries, selectedPlaylistVideos) as VideoFormat}
/>
</div>
{qualityPresetFormats && qualityPresetFormats.length > 0 && (
@@ -235,16 +256,23 @@ function CombinedPlaylistDownload({ audioOnlyFormats, videoOnlyFormats, subtitle
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}>
{lang.lang}
</ToggleGroupItem>
))}
{subtitleLanguages.map((lang) => {
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
return (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}
disabled={isDisabled}>
{lang.lang}
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
)}

View File

@@ -112,16 +112,23 @@ function SelectiveVideoDownload({ videoMetadata, audioOnlyFormats, videoOnlyForm
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}>
{lang.lang}
</ToggleGroupItem>
))}
{subtitleLanguages.map((lang) => {
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
return (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}
disabled={isDisabled}>
{lang.lang}
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
)}
@@ -227,16 +234,23 @@ function CombinedVideoDownload({ audioOnlyFormats, videoOnlyFormats, subtitleLan
>
<p className="text-xs">Subtitle Languages</p>
<div className="flex gap-2 flex-wrap items-center">
{subtitleLanguages.map((lang) => (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}>
{lang.lang}
</ToggleGroupItem>
))}
{subtitleLanguages.map((lang) => {
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
return (
<ToggleGroupItem
className="text-xs text-nowrap border-2 data-[state=on]:border-2 data-[state=on]:border-primary data-[state=on]:bg-primary/10 hover:bg-muted/70"
value={lang.code}
size="sm"
aria-label={lang.lang}
key={lang.code}
disabled={isDisabled}>
{lang.lang}
</ToggleGroupItem>
);
})}
</div>
</ToggleGroup>
)}

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 { invoke } from "@tauri-apps/api/core";
import * as fs from "@tauri-apps/plugin-fs";
import { dirname } from "@tauri-apps/api/path";
import { DownloadState } from "@/types/download";
import { useQueryClient } from "@tanstack/react-query";
import { useDeleteDownloadState } from "@/services/mutations";
@@ -60,14 +61,31 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
const removeFromDownloads = async (downloadState: DownloadState, delete_file: boolean) => {
if (delete_file && downloadState.filepath) {
try {
if (await fs.exists(downloadState.filepath)) {
await fs.remove(downloadState.filepath);
} else {
console.error(`File not found: "${downloadState.filepath}"`);
const isMutilplePlaylistItems = downloadState.playlist_id !== null &&
downloadState.playlist_indices !== null &&
downloadState.playlist_indices.includes(',');
if (isMutilplePlaylistItems) {
const dirPath = await dirname(downloadState.filepath);
try {
if (await fs.exists(dirPath)) {
await fs.remove(dirPath, { recursive: true });
} else {
console.error(`Directory not found: "${dirPath}"`);
}
} catch (e) {
console.error(e);
}
} else {
try {
if (await fs.exists(downloadState.filepath)) {
await fs.remove(downloadState.filepath);
} else {
console.error(`File not found: "${downloadState.filepath}"`);
}
} catch (e) {
console.error(e);
}
} catch (e) {
console.error(e);
}
}
@@ -77,11 +95,11 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
queryClient.invalidateQueries({ queryKey: ['download-states'] });
if (delete_file && downloadState.filepath) {
toast.success("Deleted from downloads", {
description: `The download for "${downloadState.title}" has been deleted successfully.`,
description: `The download for ${isMutilplePlaylistItems ? 'playlist ' : ''}"${isMutilplePlaylistItems ? downloadState.playlist_title : downloadState.title}" has been deleted successfully.`,
});
} else {
toast.success("Removed from downloads", {
description: `The download for "${downloadState.title}" has been removed successfully.`,
description: `The download for ${isMutilplePlaylistItems ? 'playlist ' : ''}"${isMutilplePlaylistItems ? downloadState.playlist_title : downloadState.title}" has been removed successfully.`,
});
}
},
@@ -89,11 +107,11 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
console.error("Failed to delete download state:", error);
if (delete_file && downloadState.filepath) {
toast.error("Failed to delete download", {
description: `An error occurred while trying to delete the download for "${downloadState.title}".`,
description: `An error occurred while trying to delete the download for ${isMutilplePlaylistItems ? 'playlist ' : ''}"${isMutilplePlaylistItems ? downloadState.playlist_title : downloadState.title}".`,
});
} else {
toast.error("Failed to remove download", {
description: `An error occurred while trying to remove the download for "${downloadState.title}".`,
description: `An error occurred while trying to remove the download for ${isMutilplePlaylistItems ? 'playlist ' : ''}"${isMutilplePlaylistItems ? downloadState.playlist_title : downloadState.title}".`,
});
}
}
@@ -125,31 +143,60 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
isDeleteFileChecked: false,
};
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
const isMutilplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
return (
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
<div className="w-[30%] flex flex-col justify-between gap-2">
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<Video className="w-4 h-4 mr-2 stroke-primary" />
)}
{state.filetype && state.filetype === 'audio' && (
<Music className="w-4 h-4 mr-2 stroke-primary" />
)}
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
<File className="w-4 h-4 mr-2 stroke-primary" />
)}
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null}
</span>
{isMutilplePlaylistItems ? (
<div className="w-full relative flex items-center justify-center mt-2">
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
<div className="w-[95%] aspect-video absolute -top-1 rounded-lg overflow-hidden border border-border mb-2 z-10">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-xs brightness-75" />
</div>
<div className="w-[87%] aspect-video absolute -top-2 rounded-lg overflow-hidden border border-border mb-2 z-0">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-sm brightness-50" />
</div>
</div>
) : (
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
)}
{isMutilplePlaylistItems ? (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
<ListVideo className="w-4 h-4 mr-2 stroke-primary" /> Playlist ({state.playlist_indices?.split(',').length})
</span>
) : (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<Video className="w-4 h-4 mr-2 stroke-primary" />
)}
{state.filetype && state.filetype === 'audio' && (
<Music className="w-4 h-4 mr-2 stroke-primary" />
)}
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
<File className="w-4 h-4 mr-2 stroke-primary" />
)}
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null}
</span>
)}
</div>
<div className="w-full flex flex-col justify-between gap-2">
<div className="flex flex-col gap-1">
<h4 className="">{state.title}</h4>
<p className="text-xs text-muted-foreground">{state.channel ? state.channel : 'unknown'} {state.host ? <><span className="text-primary"></span> {state.host}</> : 'unknown'}</p>
<h4 className="">{isMutilplePlaylistItems ? state.playlist_title : state.title}</h4>
<p className="text-xs text-muted-foreground">{isMutilplePlaylistItems ? state.playlist_channel ?? 'unknown' : state.channel ?? 'unknown'} {state.host ? <><span className="text-primary"></span> {state.host}</> : 'unknown'}</p>
<div className="flex items-center mt-1">
<span className="text-xs text-muted-foreground flex items-center pr-3"><Clock className="w-4 h-4 mr-2"/> {state.duration_string ? formatDurationString(state.duration_string) : 'unknown'}</span>
<span className="text-xs text-muted-foreground flex items-center pr-3">
{isMutilplePlaylistItems ? (
<><ListVideo className="w-4 h-4 mr-2"/> {state.playlist_n_entries ?? 'unknown'}</>
) : (
<><Clock className="w-4 h-4 mr-2"/> {state.duration_string ? formatDurationString(state.duration_string) : 'unknown'}</>
)}
</span>
<Separator orientation="vertical" />
<span className="text-xs text-muted-foreground flex items-center px-3">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
@@ -177,21 +224,21 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
</span>
</div>
<div className="hidden xl:flex items-center mt-1 gap-2 flex-wrap text-xs">
{state.playlist_id && state.playlist_index && (
{state.playlist_id && state.playlist_indices && !isMutilplePlaylistItems && (
<span
className="border border-border py-1 px-2 rounded flex items-center cursor-pointer"
title={`${state.playlist_title ?? 'UNKNOWN PLAYLIST'}` + ' by ' + `${state.playlist_channel ?? 'UNKNOWN CHANNEL'}`}
>
<ListVideo className="w-4 h-4 mr-2" /> Playlist ({state.playlist_index} of {state.playlist_n_entries})
<ListVideo className="w-4 h-4 mr-2" /> Playlist ({state.playlist_indices} of {state.playlist_n_entries})
</span>
)}
{state.vcodec && (
{state.vcodec && !isMutilplePlaylistItems && (
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.vcodec)}</span>
)}
{state.acodec && (
{state.acodec && !isMutilplePlaylistItems && (
<span className="border border-border py-1 px-2 rounded">{formatCodec(state.acodec)}</span>
)}
{state.dynamic_range && state.dynamic_range !== 'SDR' && (
{state.dynamic_range && state.dynamic_range !== 'SDR' && !isMutilplePlaylistItems && (
<span className="border border-border py-1 px-2 rounded">{state.dynamic_range}</span>
)}
{state.subtitle_id && (
@@ -202,6 +249,22 @@ export function CompletedDownload({ state }: CompletedDownloadProps) {
ESUB
</span>
)}
{state.sponsorblock_mark && (
<span
className="border border-border py-1 px-2 rounded cursor-pointer"
title={`SPONSORBLOCK MARKED (${state.sponsorblock_mark})`}
>
SPBLOCK(M)
</span>
)}
{state.sponsorblock_remove && (
<span
className="border border-border py-1 px-2 rounded cursor-pointer"
title={`SPONSORBLOCK REMOVED (${state.sponsorblock_remove})`}
>
SPBLOCK(R)
</span>
)}
</div>
</div>
<div className="w-full flex items-center gap-2">

View File

@@ -7,7 +7,7 @@ import { toast } from "sonner";
import { useAppContext } from "@/providers/appContextProvider";
import { useDownloadActionStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { formatFileSize, formatSecToTimeString, formatSpeed } from "@/utils";
import { ArrowUpRightIcon, CircleCheck, File, Info, Loader2, Music, Pause, Play, RotateCw, Video, X } from "lucide-react";
import { ArrowUpRightIcon, CircleCheck, File, Info, ListVideo, Loader2, Music, Pause, Play, RotateCw, Video, X } from "lucide-react";
import { DownloadState } from "@/types/download";
import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "@/components/ui/empty";
import { useNavigate } from "react-router-dom";
@@ -37,13 +37,34 @@ export function IncompleteDownload({ state }: IncompleteDownloadProps) {
isDeleteFileChecked: false,
};
const isPlaylist = state.playlist_id !== null && state.playlist_indices !== null;
const isMutilplePlaylistItems = isPlaylist && state.playlist_indices && state.playlist_indices.includes(',');
return (
<div className="p-4 border border-border rounded-lg flex gap-4" key={state.download_id}>
<div className="w-[30%] flex flex-col justify-between gap-2">
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
{state.ext ? (
{isMutilplePlaylistItems ? (
<div className="w-full relative flex items-center justify-center mt-2">
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
<div className="w-[95%] aspect-video absolute -top-1 rounded-lg overflow-hidden border border-border mb-2 z-10">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-xs brightness-75" />
</div>
<div className="w-[87%] aspect-video absolute -top-2 rounded-lg overflow-hidden border border-border mb-2 z-0">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="blur-sm brightness-50" />
</div>
</div>
) : (
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
</AspectRatio>
)}
{isMutilplePlaylistItems ? (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
<ListVideo className="w-4 h-4 mr-2 stroke-primary" /> Playlist ({state.playlist_indices?.split(',').length})
</span>
) : state.ext ? (
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
<Video className="w-4 h-4 mr-2 stroke-primary" />
@@ -68,12 +89,15 @@ export function IncompleteDownload({ state }: IncompleteDownloadProps) {
</div>
<div className="w-full flex flex-col justify-between">
<div className="flex flex-col gap-1">
<h4>{state.title}</h4>
<h4>{isMutilplePlaylistItems ? state.playlist_title : state.title}</h4>
{((state.download_status === 'starting') || (state.download_status === 'downloading' && state.status === 'finished')) && (
<IndeterminateProgress indeterminate={true} className="w-full" />
)}
{(state.download_status === 'downloading' || state.download_status === 'paused' || state.download_status === 'errored') && state.progress && state.status !== 'finished' && (
<div className="w-full flex items-center gap-2">
{isMutilplePlaylistItems && state.item ? (
<span className="text-sm text-nowrap">({state.item})</span>
) : null}
<span className="text-sm text-nowrap">{state.progress}%</span>
<Progress value={state.progress} />
<span className="text-sm text-nowrap">{

View File

@@ -362,7 +362,7 @@ function AppFolderSettings() {
</div>
<div className="filename-template">
<h3 className="font-semibold">Filename Template</h3>
<p className="text-xs text-muted-foreground mb-3">Set the template for naming downloaded files (download id and file extension will be auto-appended at the end, changing template may cause paused downloads to re-start from begining)</p>
<p className="text-xs text-muted-foreground mb-3">Set the template for naming downloaded files (download id, file extension and playlist index will be auto-appended, changing template may cause paused downloads to re-start from begining)</p>
<Form {...filenameTemplateForm}>
<form onSubmit={filenameTemplateForm.handleSubmit(handleFilenameTemplateSubmit)} className="flex gap-4 w-full" autoComplete="off">
<FormField
@@ -1142,7 +1142,7 @@ function AppCommandSettings() {
<FormControl>
<Textarea
className="focus-visible:ring-0 min-h-26"
placeholder="Enter yt-dlp command line arguments (no need to start with 'yt-dlp', already passed args: url, output paths, selected formats, selected subtitles, playlist item. also, bulk downloading is not supported)"
placeholder="Enter yt-dlp command line arguments (no need to start with 'yt-dlp', already passed args: url, output paths, selected formats, selected subtitles, playlist items etc.)"
{...field}
/>
</FormControl>
@@ -1395,7 +1395,7 @@ function AppInfoSettings() {
<p className="text-xs text-muted-foreground mb-3">License and usage terms of NeoDLP</p>
<div className="license">
<p className="text-sm mb-3">NeoDLP is a Fully Open-Source Software Licensed under the MIT license. Anyone can view, modify, use (personal and commercial) or distribute it's sources without any extra permission (Just include the LICENSE file :)</p>
<p className="text-sm mb-3"><TriangleAlert className="size-4 stroke-primary inline mb-1 mr-0.5" /> DISCLAIMER: NeoDLP facilitates downloading from various Online Platforms with different Policies and Terms of Use which Users must follow. We strictly do not promote any unauthorized downloading of copyrighted content and content piracy. NeoDLP is only made for downloading content that the user holds the copyright to or has the authority for. Users must use the downloaded content wisely and solely at their own legal responsibility. The developer is not responsible for any action taken by the user, and takes zero direct or indirect liability for that matter.</p>
<p className="text-sm mb-3"><TriangleAlert className="size-4 stroke-primary inline mb-1 mr-0.5" /> DISCLAIMER: NeoDLP facilitates downloading from various Online Platforms with different Policies and Terms of Use which Users must follow. We strictly do not promote any unauthorized downloading of copyrighted content. NeoDLP is only made for downloading content that the user holds the copyright to or has the authority for. Users must use the downloaded content wisely and solely at their own legal responsibility. The developer is not responsible for any action taken by the user, and takes zero direct or indirect liability for that matter.</p>
<span className="flex items-center gap-4 flex-wrap">
<Button className="px-4" variant="outline" size="sm" asChild>
<a href={'https://github.com/' + config.appRepo + '/blob/main/LICENSE'} target="_blank" >

View File

@@ -2,10 +2,10 @@ import { DownloadState } from "@/types/download";
import { invoke } from "@tauri-apps/api/core";
import { useCallback, useRef } from "react";
import { useBasePathsStore, useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { determineFileType, generateVideoId, parseProgressLine } from "@/utils";
import { determineFileType, extractPlaylistItemProgress, generateVideoId, parseProgressLine } from "@/utils";
import { Command } from "@tauri-apps/plugin-shell";
import { RawVideoInfo } from "@/types/video";
import { useDeleteDownloadState, useSaveDownloadState, useSavePlaylistInfo, useSaveVideoInfo, useUpdateDownloadFilePath, useUpdateDownloadStatus } from "@/services/mutations";
import { useDeleteDownloadState, useSaveDownloadState, useSavePlaylistInfo, useSaveVideoInfo, useUpdateDownloadFilePath, useUpdateDownloadPlaylistItem, useUpdateDownloadStatus } from "@/services/mutations";
import { useQueryClient } from "@tanstack/react-query";
import { platform } from "@tauri-apps/plugin-os";
import { toast } from "sonner";
@@ -76,6 +76,7 @@ export default function useDownloader() {
const downloadStateSaver = useSaveDownloadState();
const downloadStatusUpdater = useUpdateDownloadStatus();
const downloadFilePathUpdater = useUpdateDownloadFilePath();
const playlistItemUpdater = useUpdateDownloadPlaylistItem();
const videoInfoSaver = useSaveVideoInfo();
const downloadStateDeleter = useDeleteDownloadState();
const playlistInfoSaver = useSavePlaylistInfo();
@@ -99,7 +100,7 @@ export default function useDownloader() {
}, 500);
const fetchVideoMetadata = async (params: FetchVideoMetadataParams): Promise<RawVideoInfo | null> => {
const { url, formatId, playlistIndex, selectedSubtitles, resumeState, downloadConfig } = params;
const { url, formatId, playlistIndices, selectedSubtitles, resumeState, downloadConfig } = params;
try {
const args = [url, '--dump-single-json', '--no-warnings'];
if (formatId) args.push('--format', formatId);
@@ -108,8 +109,8 @@ export default function useDownloader() {
if (isAutoSub) args.push('--write-auto-sub');
args.push('--embed-subs', '--sub-lang', selectedSubtitles);
}
if (playlistIndex) args.push('--playlist-items', playlistIndex);
if (PREFER_VIDEO_OVER_PLAYLIST && !playlistIndex) args.push('--no-playlist');
if (playlistIndices) args.push('--playlist-items', playlistIndices);
if (PREFER_VIDEO_OVER_PLAYLIST && !playlistIndices) args.push('--no-playlist');
if (STRICT_DOWNLOADABILITY_CHECK && !formatId) args.push('--check-all-formats');
if (STRICT_DOWNLOADABILITY_CHECK && formatId) args.push('--check-formats');
@@ -213,7 +214,7 @@ export default function useDownloader() {
};
const startDownload = async (params: StartDownloadParams) => {
const { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems } = params;
const { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems, overrideOptions } = params;
LOG.info('NEODLP', `Initiating yt-dlp download for URL: ${url}`);
console.log('Starting download:', { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems });
@@ -222,12 +223,13 @@ export default function useDownloader() {
return;
}
const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_index) ? true : false;
const playlistIndex = isPlaylist ? (resumeState?.playlist_index?.toString() || playlistItems) : null;
const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_indices) ? true : false;
const playlistIndices = isPlaylist ? (resumeState?.playlist_indices || playlistItems) : null;
const isMultiplePlaylistItems = isPlaylist && playlistIndices && typeof playlistIndices === 'string' && playlistIndices.includes(',');
let videoMetadata = await fetchVideoMetadata({
url,
formatId: selectedFormat,
playlistIndex: isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined,
formatId: (!isPlaylist || (isPlaylist && selectedFormat !== 'best')) ? selectedFormat : undefined,
playlistIndices: isPlaylist && playlistIndices && typeof playlistIndices === 'string' ? playlistIndices : undefined,
selectedSubtitles,
resumeState
});
@@ -278,14 +280,10 @@ export default function useDownloader() {
`temp:${tempDownloadDirPath}`,
'--paths',
`home:${downloadDirPath}`,
'--output',
`${FILENAME_TEMPLATE}[${downloadId}].%(ext)s`,
'--windows-filenames',
'--restrict-filenames',
'--exec',
'after_move:echo Finalpath: {}',
'--format',
selectedFormat,
'--no-mtime',
'--retries',
MAX_RETRIES.toString(),
@@ -295,6 +293,27 @@ export default function useDownloader() {
args.push('--ffmpeg-location', '/Applications/NeoDLP.app/Contents/MacOS', '--js-runtimes', 'deno:/Applications/NeoDLP.app/Contents/MacOS/deno');
}
if (isMultiplePlaylistItems) {
args.push('--output', `%(playlist_title|Unknown)s[${downloadId}]/[%(playlist_index|0)d]_${FILENAME_TEMPLATE}.%(ext)s`);
} else {
args.push('--output', `${FILENAME_TEMPLATE}[${downloadId}].%(ext)s`);
}
if (isMultiplePlaylistItems) {
const playlistLength = playlistIndices.split(',').length;
if (playlistLength > 5 && playlistLength < 100) {
args.push('--sleep-requests', '1', '--sleep-interval', '5', '--max-sleep-interval', '15');
} else if (playlistLength >= 100 && playlistLength < 500) {
args.push('--sleep-requests', '1.5', '--sleep-interval', '10', '--max-sleep-interval', '40');
} else if (playlistLength >= 500) {
args.push('--sleep-requests', '2.5', '--sleep-interval', '20', '--max-sleep-interval', '60');
}
}
if (!isPlaylist || (isPlaylist && selectedFormat !== 'best')) {
args.push('--format', selectedFormat);
}
if (DEBUG_MODE && LOG_VERBOSE) {
args.push('--verbose');
} else {
@@ -307,8 +326,8 @@ export default function useDownloader() {
args.push('--embed-subs', '--sub-lang', selectedSubtitles);
}
if (isPlaylist && playlistIndex && typeof playlistIndex === 'string') {
args.push('--playlist-items', playlistIndex);
if (isPlaylist && playlistIndices && typeof playlistIndices === 'string') {
args.push('--playlist-items', playlistIndices);
}
let customCommandArgs = null;
@@ -466,11 +485,11 @@ export default function useDownloader() {
addErroredDownload(downloadId);
});
command.stdout.on('data', line => {
command.stdout.on('data', async line => {
if (line.startsWith('status:') || line.startsWith('[#')) {
// console.log(line);
if (DEBUG_MODE && LOG_PROGRESS) LOG.progress(`YT-DLP Download ${downloadId}`, line);
const currentProgress = parseProgressLine(line);
const currentProgress = await parseProgressLine(line, downloadId);
const state: DownloadState = {
download_id: downloadId,
download_status: 'downloading',
@@ -479,7 +498,7 @@ export default function useDownloader() {
subtitle_id: selectedSubtitles || null,
queue_index: null,
playlist_id: playlistId,
playlist_index: playlistIndex ? Number(playlistIndex) : null,
playlist_indices: playlistIndices ?? null,
title: videoMetadata.title,
url: url,
host: videoMetadata.webpage_url_domain,
@@ -495,13 +514,14 @@ export default function useDownloader() {
playlist_channel: videoMetadata.playlist_channel || null,
resolution: videoMetadata.resolution || null,
ext: videoMetadata.ext || null,
abr: videoMetadata.abr || null,
vbr: videoMetadata.vbr || null,
abr: resumeState?.abr || overrideOptions?.tbr/2 || videoMetadata.abr || null,
vbr: resumeState?.vbr || overrideOptions?.tbr/2 || videoMetadata.vbr || null,
acodec: videoMetadata.acodec || null,
vcodec: videoMetadata.vcodec || null,
dynamic_range: videoMetadata.dynamic_range || null,
process_id: processPid,
status: currentProgress.status || null,
item: currentProgress.item || null,
progress: currentProgress.progress || null,
total: currentProgress.total || null,
downloaded: currentProgress.downloaded || null,
@@ -509,7 +529,7 @@ export default function useDownloader() {
eta: currentProgress.eta || null,
filepath: downloadFilePath,
filetype: determineFileType(videoMetadata.vcodec, videoMetadata.acodec) || null,
filesize: videoMetadata.filesize_approx || null,
filesize: resumeState?.filesize || overrideOptions?.filesize || videoMetadata.filesize_approx || null,
output_format: outputFormat,
embed_metadata: embedMetadata,
embed_thumbnail: embedThumbnail,
@@ -525,7 +545,67 @@ export default function useDownloader() {
// console.log(line);
if (line.trim() !== '') LOG.info(`YT-DLP Download ${downloadId}`, line);
if (line.startsWith('Finalpath: ')) {
if (isPlaylist && line.startsWith('[download] Downloading item')) {
const playlistItemProgress = extractPlaylistItemProgress(line);
setTimeout(async () => {
playlistItemUpdater.mutate({ download_id: downloadId, item: playlistItemProgress as string }, {
onSuccess: (data) => {
console.log("Playlist item progress updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
},
onError: (error) => {
console.error("Failed to update playlist item progress:", error);
}
});
}, 1500);
}
if (isPlaylist && line.startsWith('Finalpath: ')) {
downloadFilePath = line.replace('Finalpath: ', '').trim().replace(/^"|"$/g, '');
const downloadedFileExt = downloadFilePath.split('.').pop();
setTimeout(async () => {
downloadFilePathUpdater.mutate({ download_id: downloadId, filepath: downloadFilePath as string, ext: downloadedFileExt as string }, {
onSuccess: (data) => {
console.log("Download filepath updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
},
onError: (error) => {
console.error("Failed to update download filepath:", error);
}
});
}, 1500);
}
if (isPlaylist && line.startsWith('[download] Finished downloading playlist:')) {
// Update completion status after a short delay to ensure database states are propagated correctly
console.log(`Playlist download completed with ID: ${downloadId}, updating status after 2s delay...`);
setTimeout(async () => {
LOG.info('NEODLP', `yt-dlp download completed with id: ${downloadId}`);
downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'completed' }, {
onSuccess: (data) => {
console.log("Download status updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
},
onError: (error) => {
console.error("Failed to update download status:", error);
}
});
toast.success(`${isMultiplePlaylistItems ? 'Playlist ' : ''}Download Completed`, {
description: `The download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? videoMetadata.playlist_title : videoMetadata.title}" has completed successfully.`,
});
if (ENABLE_NOTIFICATIONS && DOWNLOAD_COMPLETION_NOTIFICATION) {
sendNotification({
title: `${isMultiplePlaylistItems ? 'Playlist ' : ''}Download Completed`,
body: `The download for ${isMultiplePlaylistItems ? 'playlist ' : ''}"${isMultiplePlaylistItems ? videoMetadata.playlist_title : videoMetadata.title}" has completed successfully.`,
});
}
}, 2000);
}
if (!isPlaylist && line.startsWith('Finalpath: ')) {
downloadFilePath = line.replace('Finalpath: ', '').trim().replace(/^"|"$/g, '');
const downloadedFileExt = downloadFilePath.split('.').pop();
@@ -607,7 +687,7 @@ export default function useDownloader() {
subtitle_id: selectedSubtitles || null,
queue_index: (!ongoingDownloads || ongoingDownloads && ongoingDownloads?.length < MAX_PARALLEL_DOWNLOADS) ? null : (queuedDownloads?.length || 0),
playlist_id: playlistId,
playlist_index: playlistIndex ? Number(playlistIndex) : null,
playlist_indices: playlistIndices ?? null,
title: videoMetadata.title,
url: url,
host: videoMetadata.webpage_url_domain,
@@ -623,13 +703,14 @@ export default function useDownloader() {
playlist_channel: videoMetadata.playlist_channel || null,
resolution: resumeState?.resolution || null,
ext: resumeState?.ext || null,
abr: resumeState?.abr || null,
vbr: resumeState?.vbr || null,
abr: resumeState?.abr || overrideOptions?.tbr/2 || null,
vbr: resumeState?.vbr || overrideOptions?.tbr/2 || null,
acodec: resumeState?.acodec || null,
vcodec: resumeState?.vcodec || null,
dynamic_range: resumeState?.dynamic_range || null,
process_id: resumeState?.process_id || null,
status: resumeState?.status || null,
item: resumeState?.item || null,
progress: resumeState?.progress || null,
total: resumeState?.total || null,
downloaded: resumeState?.downloaded || null,
@@ -637,7 +718,7 @@ export default function useDownloader() {
eta: resumeState?.eta || null,
filepath: downloadFilePath,
filetype: resumeState?.filetype || null,
filesize: resumeState?.filesize || null,
filesize: resumeState?.filesize || overrideOptions?.filesize || null,
output_format: resumeState?.output_format || null,
embed_metadata: resumeState?.embed_metadata || 0,
embed_thumbnail: resumeState?.embed_thumbnail || 0,
@@ -725,7 +806,7 @@ export default function useDownloader() {
try {
LOG.info('NEODLP', `Resuming yt-dlp download with id: ${downloadState.download_id} (as per user request)`);
await startDownload({
url: downloadState.playlist_id && downloadState.playlist_index ? downloadState.playlist_url : downloadState.url,
url: downloadState.playlist_id && downloadState.playlist_indices ? downloadState.playlist_url : downloadState.url,
selectedFormat: downloadState.format_id,
downloadConfig: downloadState.queue_config ? JSON.parse(downloadState.queue_config) : {
output_format: null,

View File

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

View File

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

View File

@@ -80,7 +80,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
subtitle_id,
queue_index,
playlist_id,
playlist_index,
playlist_indices,
process_id,
resolution,
ext,
@@ -90,6 +90,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
vcodec,
dynamic_range,
status,
item,
progress,
total,
downloaded,
@@ -107,7 +108,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
use_aria2,
custom_command,
queue_config
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34)
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35)
ON CONFLICT(download_id) DO UPDATE SET
download_status = $2,
video_id = $3,
@@ -115,7 +116,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
subtitle_id = $5,
queue_index = $6,
playlist_id = $7,
playlist_index = $8,
playlist_indices = $8,
process_id = $9,
resolution = $10,
ext = $11,
@@ -125,23 +126,24 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
vcodec = $15,
dynamic_range = $16,
status = $17,
progress = $18,
total = $19,
downloaded = $20,
speed = $21,
eta = $22,
filepath = $23,
filetype = $24,
filesize = $25,
output_format = $26,
embed_metadata = $27,
embed_thumbnail = $28,
square_crop_thumbnail = $29,
sponsorblock_remove = $30,
sponsorblock_mark = $31,
use_aria2 = $32,
custom_command = $33,
queue_config = $34`,
item = $18,
progress = $19,
total = $20,
downloaded = $21,
speed = $22,
eta = $23,
filepath = $24,
filetype = $25,
filesize = $26,
output_format = $27,
embed_metadata = $28,
embed_thumbnail = $29,
square_crop_thumbnail = $30,
sponsorblock_remove = $31,
sponsorblock_mark = $32,
use_aria2 = $33,
custom_command = $34,
queue_config = $35`,
[
downloadState.download_id,
downloadState.download_status,
@@ -150,7 +152,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
downloadState.subtitle_id,
downloadState.queue_index,
downloadState.playlist_id,
downloadState.playlist_index,
downloadState.playlist_indices,
downloadState.process_id,
downloadState.resolution,
downloadState.ext,
@@ -160,6 +162,7 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
downloadState.vcodec,
downloadState.dynamic_range,
downloadState.status,
downloadState.item,
downloadState.progress,
downloadState.total,
downloadState.downloaded,
@@ -197,6 +200,14 @@ export const updateDownloadFilePath = async (download_id: string, filepath: stri
)
}
export const updateDownloadPlaylistItem = async (download_id: string, item: string) => {
const db = await Database.load('sqlite:database.db')
return await db.execute(
'UPDATE downloads SET item = $2 WHERE download_id = $1',
[download_id, item]
)
}
export const deleteDownloadState = async (download_id: string) => {
const db = await Database.load('sqlite:database.db')
return await db.execute(
@@ -233,6 +244,36 @@ export const fetchAllDownloadStates = async () => {
)
}
export const fetchDownloadStateById = async (download_id: string) => {
const db = await Database.load('sqlite:database.db')
const result = await db.select<DownloadState[]>(
`SELECT
downloads.*,
video_info.title,
video_info.url,
video_info.host,
video_info.thumbnail,
video_info.channel,
video_info.duration_string,
video_info.release_date,
video_info.view_count,
video_info.like_count,
playlist_info.playlist_title,
playlist_info.playlist_url,
playlist_info.playlist_n_entries,
playlist_info.playlist_channel
FROM downloads
INNER JOIN video_info
ON downloads.video_id = video_info.video_id
LEFT JOIN playlist_info
ON downloads.playlist_id = playlist_info.playlist_id
AND downloads.playlist_id IS NOT NULL
WHERE downloads.download_id = $1`,
[download_id]
)
return result.length > 0 ? result[0] : null
}
export const fetchAllSettings = async () => {
const db = await Database.load('sqlite:database.db')
const result = await db.select<SettingsTable[]>(

View File

@@ -1,6 +1,6 @@
import { VideoInfo } from "@/types/video";
import { useMutation } from "@tanstack/react-query";
import { deleteDownloadState, deleteKvPair, resetSettings, saveDownloadState, saveKvPair, savePlaylistInfo, saveSettingsKey, saveVideoInfo, updateDownloadFilePath, updateDownloadStatus } from "@/services/database";
import { deleteDownloadState, deleteKvPair, resetSettings, saveDownloadState, saveKvPair, savePlaylistInfo, saveSettingsKey, saveVideoInfo, updateDownloadFilePath, updateDownloadPlaylistItem, updateDownloadStatus } from "@/services/database";
import { DownloadState } from "@/types/download";
import { PlaylistInfo } from "@/types/playlist";
@@ -36,6 +36,13 @@ export function useUpdateDownloadFilePath() {
})
}
export function useUpdateDownloadPlaylistItem() {
return useMutation({
mutationFn: (data: { download_id: string; item: string }) =>
updateDownloadPlaylistItem(data.download_id, data.item)
})
}
export function useDeleteDownloadState() {
return useMutation({
mutationFn: (data: string) => deleteDownloadState(data)

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,9 @@
import { RoutesObj } from "@/types/route";
import { AllRoutes } from "@/routes";
import { DownloadProgress, Paginated } from "@/types/download";
import { VideoFormat } from "@/types/video";
import { RawVideoInfo, VideoFormat, VideoSubtitle } from "@/types/video";
import * as fs from "@tauri-apps/plugin-fs";
import { fetchDownloadStateById } from "@/services/database";
export function isActive(path: string, location: string, starts_with: boolean = false): boolean {
if (starts_with) {
@@ -36,9 +37,11 @@ const convertToBytes = (value: number, unit: string): number => {
}
};
export const parseProgressLine = (line: string): DownloadProgress => {
export const parseProgressLine = async (line: string, downloadID: string): Promise<DownloadProgress> => {
const state = await fetchDownloadStateById(downloadID);
const progress: Partial<DownloadProgress> = {
status: 'downloading'
status: 'downloading',
item: state?.item || null,
};
// Check if line contains both aria2c and yt-dlp format (combined format)
@@ -151,6 +154,12 @@ export const parseProgressLine = (line: string): DownloadProgress => {
return progress as DownloadProgress;
};
export const extractPlaylistItemProgress = (line: string): string | null => {
const match = line.match(/\[download\] Downloading item (\d+) of (\d+)/);
if (match) return `${match[1]}/${match[2]}`;
return null;
}
export const formatSpeed = (bytes: number) => {
if (bytes === 0) return '0 B/s';
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
@@ -445,3 +454,236 @@ export const paginate = <T>(items: T[], currentPage: number, itemsPerPage: numbe
data
};
};
export const getCommonFormats = (
entries: RawVideoInfo[],
selectedIndices: string[]
): VideoFormat[] => {
// If no videos selected or only one video, return empty or all formats
if (!entries || entries.length === 0 || selectedIndices.length === 0) {
return [];
}
// Get the selected videos (convert 1-indexed strings to 0-indexed numbers)
const selectedVideos = selectedIndices
.map(index => entries[Number(index) - 1])
.filter(video => video && video.formats);
if (selectedVideos.length === 0) {
return [];
}
// If only one video selected, return all its formats
if (selectedVideos.length === 1) {
return selectedVideos[0].formats || [];
}
// Get format_ids from the first selected video as the base set
const firstVideoFormats = selectedVideos[0].formats || [];
const firstVideoFormatIds = new Set(firstVideoFormats.map(f => f.format_id));
// Find format_ids that exist in ALL selected videos
const commonFormatIds = [...firstVideoFormatIds].filter(formatId => {
return selectedVideos.every(video =>
video.formats?.some(f => f.format_id === formatId)
);
});
// Return the format objects with aggregated filesize_approx and tbr
return commonFormatIds.map(formatId => {
// Get the base format from the first video
const baseFormat = firstVideoFormats.find(f => f.format_id === formatId)!;
// Calculate aggregated values across all selected videos
let totalFilesizeApprox: number | null = null;
let totalTbr: number | null = null;
let allHaveFilesize = true;
let allHaveTbr = true;
for (const video of selectedVideos) {
const format = video.formats?.find(f => f.format_id === formatId);
if (format) {
if (format.filesize_approx != null) {
totalFilesizeApprox = (totalFilesizeApprox ?? 0) + format.filesize_approx;
} else {
allHaveFilesize = false;
}
if (format.tbr != null) {
totalTbr = (totalTbr ?? 0) + format.tbr;
} else {
allHaveTbr = false;
}
}
}
// Return a new format object with aggregated values
return {
...baseFormat,
filesize_approx: allHaveFilesize ? totalFilesizeApprox : null,
tbr: allHaveTbr ? totalTbr : null,
};
});
};
export const getMergedBestFormat = (
entries: RawVideoInfo[],
selectedIndices: string[]
): VideoFormat | undefined => {
// If no videos selected, return undefined
if (!entries || entries.length === 0 || selectedIndices.length === 0) {
return undefined;
}
// Get the selected videos (convert 1-indexed strings to 0-indexed numbers)
const selectedVideos = selectedIndices
.map(index => entries[Number(index) - 1])
.filter(video => video && video.requested_downloads && video.requested_downloads.length > 0);
if (selectedVideos.length === 0) {
return undefined;
}
// If only one video selected, return its requested_downloads[0]
if (selectedVideos.length === 1) {
return selectedVideos[0].requested_downloads[0];
}
// Get the base format from the first video
const baseFormat = selectedVideos[0].requested_downloads[0];
// Check if all selected videos have the same format_id
const allSameFormatId = selectedVideos.every(video =>
video.requested_downloads[0]?.format_id === baseFormat.format_id
);
// Calculate aggregated values across all selected videos
let totalFilesizeApprox: number | null = null;
let totalTbr: number | null = null;
let allHaveFilesize = true;
let allHaveTbr = true;
for (const video of selectedVideos) {
const format = video.requested_downloads[0];
if (format) {
if (format.filesize_approx != null) {
totalFilesizeApprox = (totalFilesizeApprox ?? 0) + format.filesize_approx;
} else {
allHaveFilesize = false;
}
if (format.tbr != null) {
totalTbr = (totalTbr ?? 0) + format.tbr;
} else {
allHaveTbr = false;
}
}
}
// Return a merged format object with aggregated values
// If all format_ids are the same, keep original attributes; otherwise use 'auto'
if (allSameFormatId) {
return {
...baseFormat,
filesize_approx: allHaveFilesize ? totalFilesizeApprox : null,
tbr: allHaveTbr ? totalTbr : null,
};
} else {
return {
...baseFormat,
format: 'Best Video (Automatic)',
format_id: 'best',
format_note: 'auto',
ext: 'auto',
resolution: 'auto',
dynamic_range: 'auto',
acodec: 'auto',
vcodec: 'auto',
audio_ext: 'auto',
video_ext: 'auto',
fps: null,
filesize_approx: allHaveFilesize ? totalFilesizeApprox : null,
tbr: allHaveTbr ? totalTbr : null,
} as VideoFormat;
}
};
export const getCommonSubtitles = (
entries: RawVideoInfo[],
selectedIndices: string[]
): { [subtitle_id: string]: VideoSubtitle[] } => {
// If no videos selected, return empty object
if (!entries || entries.length === 0 || selectedIndices.length === 0) {
return {};
}
// Get the selected videos (convert 1-indexed strings to 0-indexed numbers)
const selectedVideos = selectedIndices
.map(index => entries[Number(index) - 1])
.filter(video => video && video.subtitles);
if (selectedVideos.length === 0) {
return {};
}
// If only one video selected, return all its subtitles
if (selectedVideos.length === 1) {
return selectedVideos[0].subtitles || {};
}
// Get subtitle keys from the first selected video as the base set
const firstVideoSubtitles = selectedVideos[0].subtitles || {};
const firstVideoSubtitleKeys = new Set(Object.keys(firstVideoSubtitles));
// Find subtitle keys that exist in ALL selected videos
const commonSubtitleKeys = [...firstVideoSubtitleKeys].filter(subtitleKey => {
return selectedVideos.every(video =>
video.subtitles && Object.prototype.hasOwnProperty.call(video.subtitles, subtitleKey)
);
});
// Return subtitle object with only common keys (using first video's subtitle data)
return Object.fromEntries(
commonSubtitleKeys.map(key => [key, firstVideoSubtitles[key]])
);
};
export const getCommonAutoSubtitles = (
entries: RawVideoInfo[],
selectedIndices: string[]
): { [subtitle_id: string]: VideoSubtitle[] } => {
// If no videos selected, return empty object
if (!entries || entries.length === 0 || selectedIndices.length === 0) {
return {};
}
// Get the selected videos (convert 1-indexed strings to 0-indexed numbers)
const selectedVideos = selectedIndices
.map(index => entries[Number(index) - 1])
.filter(video => video && video.automatic_captions);
if (selectedVideos.length === 0) {
return {};
}
// If only one video selected, return all its automatic captions
if (selectedVideos.length === 1) {
return selectedVideos[0].automatic_captions || {};
}
// Get auto subtitle keys from the first selected video as the base set
const firstVideoAutoSubs = selectedVideos[0].automatic_captions || {};
const firstVideoAutoSubKeys = new Set(Object.keys(firstVideoAutoSubs));
// Find auto subtitle keys that exist in ALL selected videos
const commonAutoSubKeys = [...firstVideoAutoSubKeys].filter(subtitleKey => {
return selectedVideos.every(video =>
video.automatic_captions && Object.prototype.hasOwnProperty.call(video.automatic_captions, subtitleKey)
);
});
// Return auto subtitle object with only common keys (using first video's data)
return Object.fromEntries(
commonAutoSubKeys.map(key => [key, firstVideoAutoSubs[key]])
);
};