mirror of
https://github.com/neosubhamoy/neodlp.git
synced 2026-02-04 11:52:23 +05:30
feat: added support for full-playlist/selective-batch downloading #9
This commit is contained in:
@@ -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,13 +200,17 @@ 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,
|
||||||
0, -- square_crop_thumbnail
|
0, -- square_crop_thumbnail
|
||||||
sponsorblock_remove, sponsorblock_mark, use_aria2,
|
sponsorblock_remove, sponsorblock_mark, use_aria2,
|
||||||
custom_command, queue_config, created_at, updated_at
|
custom_command, queue_config, created_at, updated_at
|
||||||
FROM downloads;
|
FROM downloads;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -47,7 +42,7 @@ export const PlaylistToggleGroup = React.forwardRef<
|
|||||||
</ToggleGroupPrimitive.Root>
|
</ToggleGroupPrimitive.Root>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToggleGroupPrimitive.Root
|
<ToggleGroupPrimitive.Root
|
||||||
ref={ref}
|
ref={ref}
|
||||||
@@ -63,106 +58,48 @@ 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
|
<AspectRatio
|
||||||
checked={checked}
|
ratio={16 / 9}
|
||||||
onClick={handleCheckboxClick}
|
|
||||||
className={cn(
|
|
||||||
"transition-opacity",
|
|
||||||
isHovered || checked ? "opacity-100" : "opacity-0"
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="w-[7rem] xl:w-[10rem]">
|
|
||||||
<AspectRatio
|
|
||||||
ratio={16 / 9}
|
|
||||||
className={clsx(
|
className={clsx(
|
||||||
"w-full rounded overflow-hidden border border-border",
|
"w-full rounded overflow-hidden border border-border",
|
||||||
video.aspect_ratio && video.aspect_ratio === 0.56 && "relative"
|
video.aspect_ratio && video.aspect_ratio === 0.56 && "relative"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ProxyImage
|
<ProxyImage
|
||||||
src={video.thumbnail}
|
src={video.thumbnail}
|
||||||
alt="thumbnail"
|
alt="thumbnail"
|
||||||
className={clsx(
|
className={clsx(
|
||||||
video.aspect_ratio && video.aspect_ratio === 0.56 &&
|
video.aspect_ratio && video.aspect_ratio === 0.56 &&
|
||||||
"absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
"absolute h-full w-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2"
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</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"/>
|
||||||
@@ -174,4 +111,4 @@ export const PlaylistToggleGroupItem = React.forwardRef<
|
|||||||
</ToggleGroupPrimitive.Item>
|
</ToggleGroupPrimitive.Item>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
PlaylistToggleGroupItem.displayName = "PlaylistToggleGroupItem";
|
PlaylistToggleGroupItem.displayName = "PlaylistToggleGroupItem";
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { formatBitrate, formatFileSize } from "@/utils";
|
|||||||
import { Loader2, Music, Video, File, AlertCircleIcon, Settings2 } from "lucide-react";
|
import { 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({
|
||||||
|
|||||||
@@ -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">
|
||||||
<ListVideo className="w-4 h-4" />
|
<h3 className="text-sm flex items-center gap-2">
|
||||||
<span>Playlist ({videoMetadata.entries[0].n_entries})</span>
|
<ListVideo className="w-4 h-4" />
|
||||||
</h3>
|
<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">
|
<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) => {
|
||||||
<ToggleGroupItem
|
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||||
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"
|
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||||
value={lang.code}
|
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||||
size="sm"
|
|
||||||
aria-label={lang.lang}
|
return (
|
||||||
key={lang.code}>
|
<ToggleGroupItem
|
||||||
{lang.lang}
|
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"
|
||||||
</ToggleGroupItem>
|
value={lang.code}
|
||||||
))}
|
size="sm"
|
||||||
|
aria-label={lang.lang}
|
||||||
|
key={lang.code}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{lang.lang}
|
||||||
|
</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) => {
|
||||||
<ToggleGroupItem
|
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||||
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"
|
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||||
value={lang.code}
|
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||||
size="sm"
|
|
||||||
aria-label={lang.lang}
|
return (
|
||||||
key={lang.code}>
|
<ToggleGroupItem
|
||||||
{lang.lang}
|
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"
|
||||||
</ToggleGroupItem>
|
value={lang.code}
|
||||||
))}
|
size="sm"
|
||||||
|
aria-label={lang.lang}
|
||||||
|
key={lang.code}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{lang.lang}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) => {
|
||||||
<ToggleGroupItem
|
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||||
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"
|
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||||
value={lang.code}
|
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||||
size="sm"
|
|
||||||
aria-label={lang.lang}
|
return (
|
||||||
key={lang.code}>
|
<ToggleGroupItem
|
||||||
{lang.lang}
|
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"
|
||||||
</ToggleGroupItem>
|
value={lang.code}
|
||||||
))}
|
size="sm"
|
||||||
|
aria-label={lang.lang}
|
||||||
|
key={lang.code}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{lang.lang}
|
||||||
|
</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) => {
|
||||||
<ToggleGroupItem
|
const hasAutoSubSelected = selectedSubtitles.some(code => code.endsWith('-orig'));
|
||||||
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"
|
const hasNormalSubSelected = selectedSubtitles.some(code => !code.endsWith('-orig'));
|
||||||
value={lang.code}
|
const isDisabled = (hasAutoSubSelected && !lang.code.endsWith('-orig')) || (hasNormalSubSelected && lang.code.endsWith('-orig'));
|
||||||
size="sm"
|
|
||||||
aria-label={lang.lang}
|
return (
|
||||||
key={lang.code}>
|
<ToggleGroupItem
|
||||||
{lang.lang}
|
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"
|
||||||
</ToggleGroupItem>
|
value={lang.code}
|
||||||
))}
|
size="sm"
|
||||||
|
aria-label={lang.lang}
|
||||||
|
key={lang.code}
|
||||||
|
disabled={isDisabled}>
|
||||||
|
{lang.lang}
|
||||||
|
</ToggleGroupItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</ToggleGroup>
|
</ToggleGroup>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { formatBitrate, formatCodec, formatDurationString, formatFileSize, pagin
|
|||||||
import { ArrowUpRightIcon, AudioLines, CircleArrowDown, Clock, File, FileAudio2, FileQuestion, FileVideo2, FolderInput, ListVideo, Music, Play, Search, Trash2, Video } from "lucide-react";
|
import { 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,14 +61,31 @@ 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) {
|
||||||
try {
|
const isMutilplePlaylistItems = downloadState.playlist_id !== null &&
|
||||||
if (await fs.exists(downloadState.filepath)) {
|
downloadState.playlist_indices !== null &&
|
||||||
await fs.remove(downloadState.filepath);
|
downloadState.playlist_indices.includes(',');
|
||||||
} else {
|
|
||||||
console.error(`File not found: "${downloadState.filepath}"`);
|
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'] });
|
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,31 +143,60 @@ 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">
|
||||||
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
|
{isMutilplePlaylistItems ? (
|
||||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
<div className="w-full relative flex items-center justify-center mt-2">
|
||||||
</AspectRatio>
|
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
|
||||||
<span className="w-full flex items-center justify-center text-xs border border-border py-1 px-2 rounded">
|
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
||||||
{state.filetype && (state.filetype === 'video' || state.filetype === 'video+audio') && (
|
</AspectRatio>
|
||||||
<Video className="w-4 h-4 mr-2 stroke-primary" />
|
<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" />
|
||||||
{state.filetype && state.filetype === 'audio' && (
|
</div>
|
||||||
<Music className="w-4 h-4 mr-2 stroke-primary" />
|
<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" />
|
||||||
{(!state.filetype) || (state.filetype && state.filetype !== 'video' && state.filetype !== 'audio' && state.filetype !== 'video+audio') && (
|
</div>
|
||||||
<File className="w-4 h-4 mr-2 stroke-primary" />
|
</div>
|
||||||
)}
|
) : (
|
||||||
{state.ext ? state.ext.toUpperCase() : 'Unknown'} {state.resolution ? `(${state.resolution})` : null}
|
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2">
|
||||||
</span>
|
<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>
|
||||||
<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">
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
<ProxyImage src={state.thumbnail || ""} alt="thumbnail" className="" />
|
<div className="w-full relative flex items-center justify-center mt-2">
|
||||||
</AspectRatio>
|
<AspectRatio ratio={16 / 9} className="w-full rounded-lg overflow-hidden border border-border mb-2 z-20">
|
||||||
{state.ext ? (
|
<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">
|
<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">{
|
||||||
|
|||||||
@@ -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" >
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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[]>(
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
248
src/utils.ts
248
src/utils.ts
@@ -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]])
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user