feat: added custom commands, per-download configs and other minor improvements

This commit is contained in:
2025-10-05 21:21:26 +05:30
parent 9498464fa2
commit 3046daffd8
12 changed files with 772 additions and 173 deletions

View File

@@ -180,5 +180,80 @@ pub fn get_migrations() -> Vec<Migration> {
END; END;
", ",
kind: MigrationKind::Up, kind: MigrationKind::Up,
},
Migration {
version: 4,
description: "add_custom_command_column_to_downloads",
sql: "
-- Create temporary table with the new column in the correct position
CREATE TABLE downloads_temp (
id INTEGER PRIMARY KEY NOT NULL,
download_id TEXT UNIQUE NOT NULL,
download_status TEXT NOT NULL,
video_id TEXT NOT NULL,
format_id TEXT NOT NULL,
subtitle_id TEXT,
queue_index INTEGER,
playlist_id TEXT,
playlist_index INTEGER,
resolution TEXT,
ext TEXT,
abr REAL,
vbr REAL,
acodec TEXT,
vcodec TEXT,
dynamic_range TEXT,
process_id INTEGER,
status TEXT,
progress REAL,
total INTEGER,
downloaded INTEGER,
speed REAL,
eta INTEGER,
filepath TEXT,
filetype TEXT,
filesize INTEGER,
output_format TEXT,
embed_metadata INTEGER NOT NULL DEFAULT 0,
embed_thumbnail INTEGER NOT NULL DEFAULT 0,
sponsorblock_remove TEXT,
sponsorblock_mark TEXT,
use_aria2 INTEGER NOT NULL DEFAULT 0,
custom_command TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (video_id) REFERENCES video_info (video_id),
FOREIGN KEY (playlist_id) REFERENCES playlist_info (playlist_id)
);
-- Copy all data from original table to temporary table
INSERT INTO downloads_temp SELECT
id, download_id, download_status, video_id, format_id, subtitle_id,
queue_index, playlist_id, playlist_index, resolution, ext, abr, vbr,
acodec, vcodec, dynamic_range, process_id, status, progress, total,
downloaded, speed, eta, filepath, filetype, filesize, output_format,
embed_metadata, embed_thumbnail, sponsorblock_remove, sponsorblock_mark,
use_aria2, NULL, -- custom_command default value
created_at, updated_at
FROM downloads;
-- Drop existing triggers for the original table
DROP TRIGGER IF EXISTS update_downloads_updated_at;
-- Drop the original table
DROP TABLE downloads;
-- Rename temporary table to original name
ALTER TABLE downloads_temp RENAME TO downloads;
-- Re-Create the update trigger
CREATE TRIGGER IF NOT EXISTS update_downloads_updated_at
AFTER UPDATE ON downloads
FOR EACH ROW
BEGIN
UPDATE downloads SET updated_at = CURRENT_TIMESTAMP WHERE id = NEW.id;
END;
",
kind: MigrationKind::Up,
}] }]
} }

View File

@@ -27,6 +27,7 @@ import useAppUpdater from "@/helpers/use-app-updater";
import { Toaster as Sonner } from "@/components/ui/sonner"; import { Toaster as Sonner } from "@/components/ui/sonner";
import { toast } from "sonner"; import { toast } from "sonner";
import { useLogger } from "@/helpers/use-logger"; import { useLogger } from "@/helpers/use-logger";
import { DownloadConfiguration } from "./types/settings";
export default function App({ children }: { children: React.ReactNode }) { export default function App({ children }: { children: React.ReactNode }) {
const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates(); const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates();
@@ -84,10 +85,13 @@ export default function App({ children }: { children: React.ReactNode }) {
const USE_ARIA2 = useSettingsPageStatesStore(state => state.settings.use_aria2); const USE_ARIA2 = useSettingsPageStatesStore(state => state.settings.use_aria2);
const USE_FORCE_INTERNET_PROTOCOL = useSettingsPageStatesStore(state => state.settings.use_force_internet_protocol); const USE_FORCE_INTERNET_PROTOCOL = useSettingsPageStatesStore(state => state.settings.use_force_internet_protocol);
const FORCE_INTERNET_PROTOCOL = useSettingsPageStatesStore(state => state.settings.force_internet_protocol); const FORCE_INTERNET_PROTOCOL = useSettingsPageStatesStore(state => state.settings.force_internet_protocol);
const USE_CUSTOM_COMMANDS = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const CUSTOM_COMMANDS = useSettingsPageStatesStore(state => state.settings.custom_commands);
const isErrored = useDownloaderPageStatesStore((state) => state.isErrored); const isErrored = useDownloaderPageStatesStore((state) => state.isErrored);
const isErrorExpected = useDownloaderPageStatesStore((state) => state.isErrorExpected); const isErrorExpected = useDownloaderPageStatesStore((state) => state.isErrorExpected);
const erroredDownloadId = useDownloaderPageStatesStore((state) => state.erroredDownloadId); const erroredDownloadId = useDownloaderPageStatesStore((state) => state.erroredDownloadId);
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
const setIsErrored = useDownloaderPageStatesStore((state) => state.setIsErrored); const setIsErrored = useDownloaderPageStatesStore((state) => state.setIsErrored);
const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected); const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected);
const setErroredDownloadId = useDownloaderPageStatesStore((state) => state.setErroredDownloadId); const setErroredDownloadId = useDownloaderPageStatesStore((state) => state.setErroredDownloadId);
@@ -118,23 +122,25 @@ export default function App({ children }: { children: React.ReactNode }) {
const hasRunYtDlpAutoUpdateRef = useRef(false); const hasRunYtDlpAutoUpdateRef = useRef(false);
const isRegisteredToMacOsRef = useRef(false); const isRegisteredToMacOsRef = useRef(false);
const fetchVideoMetadata = async (url: string, formatId?: string, playlistIndex?: string): Promise<RawVideoInfo | null> => { const fetchVideoMetadata = async (url: string, formatId?: string, playlistIndex?: string, selectedSubtitles?: string | null, resumeState?: DownloadState): Promise<RawVideoInfo | null> => {
try { try {
const args = [url, '--dump-single-json', '--no-warnings']; const args = [url, '--dump-single-json', '--no-warnings'];
if (formatId) args.push('-f', formatId); if (formatId) args.push('-f', formatId);
if (selectedSubtitles) args.push('--embed-subs', '--sub-lang', selectedSubtitles);
if (playlistIndex) args.push('--playlist-items', playlistIndex); if (playlistIndex) args.push('--playlist-items', playlistIndex);
if (PREFER_VIDEO_OVER_PLAYLIST) args.push('--no-playlist'); if (PREFER_VIDEO_OVER_PLAYLIST && !playlistIndex) 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');
if (USE_PROXY && PROXY_URL) args.push('--proxy', PROXY_URL);
if (USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_PROXY && PROXY_URL) args.push('--proxy', PROXY_URL);
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) {
if (FORCE_INTERNET_PROTOCOL === 'ipv4') { if (FORCE_INTERNET_PROTOCOL === 'ipv4') {
args.push('--force-ipv4'); args.push('--force-ipv4');
} else if (FORCE_INTERNET_PROTOCOL === 'ipv6') { } else if (FORCE_INTERNET_PROTOCOL === 'ipv6') {
args.push('--force-ipv6'); args.push('--force-ipv6');
} }
} }
if (USE_COOKIES) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) {
if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) { if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) {
args.push('--cookies-from-browser', COOKIES_BROWSER); args.push('--cookies-from-browser', COOKIES_BROWSER);
} else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) { } else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) {
@@ -197,14 +203,14 @@ export default function App({ children }: { children: React.ReactNode }) {
} }
}; };
const startDownload = async (url: string, selectedFormat: string, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => { const startDownload = async (url: string, selectedFormat: string, downloadConfig: DownloadConfiguration, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => {
LOG.info('NEODLP', `Initiating yt-dlp download for URL: ${url}`); LOG.info('NEODLP', `Initiating yt-dlp download for URL: ${url}`);
// set error states to default // set error states to default
setIsErrored(false); setIsErrored(false);
setIsErrorExpected(false); setIsErrorExpected(false);
setErroredDownloadId(null); setErroredDownloadId(null);
console.log('Starting download:', { url, selectedFormat, selectedSubtitles, resumeState, playlistItems }); console.log('Starting download:', { url, selectedFormat, downloadConfig, selectedSubtitles, resumeState, playlistItems });
if (!ffmpegPath || !tempDownloadDirPath || !downloadDirPath) { if (!ffmpegPath || !tempDownloadDirPath || !downloadDirPath) {
console.error('FFmpeg or download paths not found'); console.error('FFmpeg or download paths not found');
return; return;
@@ -212,7 +218,7 @@ export default function App({ children }: { children: React.ReactNode }) {
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_index) ? true : false;
const playlistIndex = isPlaylist ? (resumeState?.playlist_index?.toString() || playlistItems) : null; const playlistIndex = isPlaylist ? (resumeState?.playlist_index?.toString() || playlistItems) : null;
let videoMetadata = await fetchVideoMetadata(url, selectedFormat, isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined); let videoMetadata = await fetchVideoMetadata(url, selectedFormat, isPlaylist && playlistIndex && typeof playlistIndex === 'string' ? playlistIndex : undefined, selectedSubtitles, resumeState);
if (!videoMetadata) { if (!videoMetadata) {
console.error('Failed to fetch video metadata'); console.error('Failed to fetch video metadata');
toast.error("Download Failed", { toast.error("Download Failed", {
@@ -231,6 +237,11 @@ export default function App({ children }: { children: React.ReactNode }) {
if (AUDIO_FORMAT !== 'auto' && fileType === 'audio') videoMetadata.ext = AUDIO_FORMAT; if (AUDIO_FORMAT !== 'auto' && fileType === 'audio') videoMetadata.ext = AUDIO_FORMAT;
} }
let configOutputFormat = null;
if (downloadConfig.output_format && downloadConfig.output_format !== 'auto') {
videoMetadata.ext = downloadConfig.output_format;
configOutputFormat = downloadConfig.output_format;
}
if (resumeState && resumeState.output_format) videoMetadata.ext = resumeState.output_format; if (resumeState && resumeState.output_format) videoMetadata.ext = resumeState.output_format;
const videoId = resumeState?.video_id || generateVideoId(videoMetadata.id, videoMetadata.webpage_url_domain); const videoId = resumeState?.video_id || generateVideoId(videoMetadata.id, videoMetadata.webpage_url_domain);
@@ -265,55 +276,82 @@ export default function App({ children }: { children: React.ReactNode }) {
args.push('--playlist-items', playlistIndex); args.push('--playlist-items', playlistIndex);
} }
let customCommandArgs = null;
if ((USE_CUSTOM_COMMANDS && CUSTOM_COMMANDS && downloadConfiguration.custom_command) || resumeState?.custom_command) {
if (resumeState?.custom_command) {
customCommandArgs = resumeState.custom_command;
} else if (CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfiguration.custom_command)) {
let customCommand = CUSTOM_COMMANDS.find(cmd => cmd.id === downloadConfiguration.custom_command);
customCommandArgs = customCommand ? customCommand.args : '';
}
if (customCommandArgs && customCommandArgs.trim() !== '') args.push(...customCommandArgs.split(' '));
}
let outputFormat = null; let outputFormat = null;
if (fileType !== 'unknown' && ((VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto') || resumeState?.output_format)) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && ((fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) || configOutputFormat || resumeState?.output_format)) {
outputFormat = resumeState?.output_format || (fileType === 'video+audio' && VIDEO_FORMAT !== 'auto' ? VIDEO_FORMAT : (fileType === 'video' && VIDEO_FORMAT !== 'auto' ? VIDEO_FORMAT : (fileType === 'audio' && AUDIO_FORMAT !== 'auto' ? AUDIO_FORMAT : null))); const format = resumeState?.output_format || configOutputFormat;
if ((VIDEO_FORMAT !== 'auto' && fileType === 'video+audio') || (resumeState?.output_format && fileType === 'video+audio')) {
if (ALWAYS_REENCODE_VIDEO) { if (format) {
args.push('--recode-video', resumeState?.output_format || VIDEO_FORMAT); outputFormat = format;
} else { } else if (fileType === 'audio' && AUDIO_FORMAT !== 'auto') {
args.push('--merge-output-format', resumeState?.output_format || VIDEO_FORMAT); outputFormat = AUDIO_FORMAT;
} else if ((fileType === 'video' || fileType === 'video+audio') && VIDEO_FORMAT !== 'auto') {
outputFormat = VIDEO_FORMAT;
} }
const recodeOrRemux = ALWAYS_REENCODE_VIDEO ? '--recode-video' : '--remux-video';
const formatToUse = format || VIDEO_FORMAT;
// Handle video+audio
if (fileType === 'video+audio' && (VIDEO_FORMAT !== 'auto' || format)) {
args.push(ALWAYS_REENCODE_VIDEO ? '--recode-video' : '--merge-output-format', formatToUse);
} }
if ((VIDEO_FORMAT !== 'auto' && fileType === 'video') || (resumeState?.output_format && fileType === 'video')) { // Handle video only
if (ALWAYS_REENCODE_VIDEO) { else if (fileType === 'video' && (VIDEO_FORMAT !== 'auto' || format)) {
args.push('--recode-video', resumeState?.output_format || VIDEO_FORMAT); args.push(recodeOrRemux, formatToUse);
} else {
args.push('--remux-video', resumeState?.output_format || VIDEO_FORMAT);
} }
// Handle audio only
else if (fileType === 'audio' && (AUDIO_FORMAT !== 'auto' || format)) {
args.push('--extract-audio', '--audio-format', format || AUDIO_FORMAT);
}
// Handle unknown filetype
else if (fileType === 'unknown' && format) {
if (['mkv', 'mp4', 'webm'].includes(format)) {
args.push(recodeOrRemux, formatToUse);
} else if (['mp3', 'm4a', 'opus'].includes(format)) {
args.push('--extract-audio', '--audio-format', format);
} }
if ((AUDIO_FORMAT !== 'auto' && fileType === 'audio') || (resumeState?.output_format && fileType === 'audio')) {
args.push('--extract-audio', '--audio-format', resumeState?.output_format || AUDIO_FORMAT);
} }
} }
let embedMetadata = 0; let embedMetadata = 0;
if (fileType !== 'unknown' && ((EMBED_VIDEO_METADATA || EMBED_AUDIO_METADATA) || resumeState?.embed_metadata)) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (downloadConfiguration.embed_metadata || resumeState?.embed_metadata || EMBED_VIDEO_METADATA || EMBED_AUDIO_METADATA)) {
if ((EMBED_VIDEO_METADATA || resumeState?.embed_metadata) && (fileType === 'video+audio' || fileType === 'video')) { const shouldEmbedForVideo = (fileType === 'video+audio' || fileType === 'video') && (downloadConfiguration.embed_metadata || resumeState?.embed_metadata || (EMBED_VIDEO_METADATA && downloadConfiguration.embed_metadata === null));
embedMetadata = 1; const shouldEmbedForAudio = fileType === 'audio' && (downloadConfiguration.embed_metadata || resumeState?.embed_metadata || (EMBED_AUDIO_METADATA && downloadConfiguration.embed_metadata === null));
args.push('--embed-metadata'); const shouldEmbedForUnknown = fileType === 'unknown' && (downloadConfiguration.embed_metadata || resumeState?.embed_metadata);
}
if ((EMBED_AUDIO_METADATA || resumeState?.embed_metadata) && fileType === 'audio') { if (shouldEmbedForUnknown || shouldEmbedForVideo || shouldEmbedForAudio) {
embedMetadata = 1; embedMetadata = 1;
args.push('--embed-metadata'); args.push('--embed-metadata');
} }
} }
let embedThumbnail = 0; let embedThumbnail = 0;
if (fileType === 'audio' && (EMBED_AUDIO_THUMBNAIL || resumeState?.embed_thumbnail)) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (downloadConfiguration.embed_thumbnail || resumeState?.embed_thumbnail || (fileType === 'audio' && EMBED_AUDIO_THUMBNAIL && downloadConfiguration.embed_thumbnail === null))) {
embedThumbnail = 1; embedThumbnail = 1;
args.push('--embed-thumbnail'); args.push('--embed-thumbnail');
} }
if (USE_PROXY && PROXY_URL) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_PROXY && PROXY_URL) {
args.push('--proxy', PROXY_URL); args.push('--proxy', PROXY_URL);
} }
if (USE_RATE_LIMIT && RATE_LIMIT) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_RATE_LIMIT && RATE_LIMIT) {
args.push('--limit-rate', `${RATE_LIMIT}`); args.push('--limit-rate', `${RATE_LIMIT}`);
} }
if (USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_FORCE_INTERNET_PROTOCOL && FORCE_INTERNET_PROTOCOL) {
if (FORCE_INTERNET_PROTOCOL === 'ipv4') { if (FORCE_INTERNET_PROTOCOL === 'ipv4') {
args.push('--force-ipv4'); args.push('--force-ipv4');
} else if (FORCE_INTERNET_PROTOCOL === 'ipv6') { } else if (FORCE_INTERNET_PROTOCOL === 'ipv6') {
@@ -321,7 +359,7 @@ export default function App({ children }: { children: React.ReactNode }) {
} }
} }
if (USE_COOKIES) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) {
if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) { if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) {
args.push('--cookies-from-browser', COOKIES_BROWSER); args.push('--cookies-from-browser', COOKIES_BROWSER);
} else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) { } else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) {
@@ -331,7 +369,7 @@ export default function App({ children }: { children: React.ReactNode }) {
let sponsorblockRemove = null; let sponsorblockRemove = null;
let sponsorblockMark = null; let sponsorblockMark = null;
if (USE_SPONSORBLOCK || (resumeState?.sponsorblock_remove || resumeState?.sponsorblock_mark)) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_SPONSORBLOCK || (resumeState?.sponsorblock_remove || resumeState?.sponsorblock_mark))) {
if (SPONSORBLOCK_MODE === 'remove' || resumeState?.sponsorblock_remove) { if (SPONSORBLOCK_MODE === 'remove' || resumeState?.sponsorblock_remove) {
sponsorblockRemove = resumeState?.sponsorblock_remove || (SPONSORBLOCK_REMOVE === 'custom' ? ( sponsorblockRemove = resumeState?.sponsorblock_remove || (SPONSORBLOCK_REMOVE === 'custom' ? (
SPONSORBLOCK_REMOVE_CATEGORIES.length > 0 ? SPONSORBLOCK_REMOVE_CATEGORIES.join(',') : 'default' SPONSORBLOCK_REMOVE_CATEGORIES.length > 0 ? SPONSORBLOCK_REMOVE_CATEGORIES.join(',') : 'default'
@@ -346,7 +384,7 @@ export default function App({ children }: { children: React.ReactNode }) {
} }
let useAria2 = 0; let useAria2 = 0;
if (USE_ARIA2 || resumeState?.use_aria2) { if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_ARIA2 || resumeState?.use_aria2)) {
useAria2 = 1; useAria2 = 1;
args.push( args.push(
'--downloader', 'aria2c', '--downloader', 'aria2c',
@@ -356,7 +394,7 @@ export default function App({ children }: { children: React.ReactNode }) {
LOG.warning('NEODLP', `Looks like you are using aria2 for this yt-dlp download: ${downloadId}. Make sure aria2 is installed on your system if you are on macOS for this to work. Also, pause/resume might not work as expected especially on windows (using aria2 is not recommended for most downloads).`); LOG.warning('NEODLP', `Looks like you are using aria2 for this yt-dlp download: ${downloadId}. Make sure aria2 is installed on your system if you are on macOS for this to work. Also, pause/resume might not work as expected especially on windows (using aria2 is not recommended for most downloads).`);
} }
if (resumeState || USE_ARIA2) { if (resumeState || (!USE_CUSTOM_COMMANDS && USE_ARIA2)) {
args.push('--continue'); args.push('--continue');
} else { } else {
args.push('--no-continue'); args.push('--no-continue');
@@ -459,7 +497,8 @@ export default function App({ children }: { children: React.ReactNode }) {
embed_thumbnail: embedThumbnail, embed_thumbnail: embedThumbnail,
sponsorblock_remove: sponsorblockRemove, sponsorblock_remove: sponsorblockRemove,
sponsorblock_mark: sponsorblockMark, sponsorblock_mark: sponsorblockMark,
use_aria2: useAria2 use_aria2: useAria2,
custom_command: customCommandArgs,
}; };
downloadStateSaver.mutate(state, { downloadStateSaver.mutate(state, {
onSuccess: (data) => { onSuccess: (data) => {
@@ -551,7 +590,8 @@ export default function App({ children }: { children: React.ReactNode }) {
embed_thumbnail: resumeState?.embed_thumbnail || 0, embed_thumbnail: resumeState?.embed_thumbnail || 0,
sponsorblock_remove: resumeState?.sponsorblock_remove || null, sponsorblock_remove: resumeState?.sponsorblock_remove || null,
sponsorblock_mark: resumeState?.sponsorblock_mark || null, sponsorblock_mark: resumeState?.sponsorblock_mark || null,
use_aria2: resumeState?.use_aria2 || 0 use_aria2: resumeState?.use_aria2 || 0,
custom_command: resumeState?.custom_command || null
} }
downloadStateSaver.mutate(state, { downloadStateSaver.mutate(state, {
onSuccess: (data) => { onSuccess: (data) => {
@@ -643,6 +683,12 @@ export default function App({ children }: { children: React.ReactNode }) {
await startDownload( await startDownload(
downloadState.playlist_id && downloadState.playlist_index ? downloadState.playlist_url : downloadState.url, downloadState.playlist_id && downloadState.playlist_index ? downloadState.playlist_url : downloadState.url,
downloadState.format_id, downloadState.format_id,
{
output_format: null,
embed_metadata: null,
embed_thumbnail: null,
custom_command: null
},
downloadState.subtitle_id, downloadState.subtitle_id,
downloadState downloadState
); );
@@ -739,6 +785,7 @@ export default function App({ children }: { children: React.ReactNode }) {
await startDownload( await startDownload(
downloadToStart.url, downloadToStart.url,
downloadToStart.format_id, downloadToStart.format_id,
downloadConfiguration,
downloadToStart.subtitle_id, downloadToStart.subtitle_id,
downloadToStart downloadToStart
); );

View File

@@ -34,7 +34,7 @@ export default function Navbar() {
<DialogContent className="sm:max-w-[600px]"> <DialogContent className="sm:max-w-[600px]">
<DialogHeader> <DialogHeader>
<DialogTitle>Log Viewer</DialogTitle> <DialogTitle>Log Viewer</DialogTitle>
<DialogDescription>Monitor real-time neodlp logs for the current session</DialogDescription> <DialogDescription>Monitor real-time app session logs (latest on top)</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="flex flex-col gap-2 p-2 max-h-[300px] overflow-y-scroll overflow-x-hidden bg-muted"> <div className="flex flex-col gap-2 p-2 max-h-[300px] overflow-y-scroll overflow-x-hidden bg-muted">
{logs.length === 0 ? ( {logs.length === 0 ? (

View File

@@ -9,7 +9,7 @@ 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, formatBitrate, formatDurationString, formatFileSize, formatReleaseDate, formatYtStyleCount, isObjEmpty, sortByBitrate } from "@/utils"; import { determineFileType, fileFormatFilter, formatBitrate, formatDurationString, formatFileSize, formatReleaseDate, formatYtStyleCount, isObjEmpty, sortByBitrate } from "@/utils";
import { Calendar, Clock, DownloadCloud, Eye, Info, Loader2, Music, ThumbsUp, Video, File, ListVideo, PackageSearch, AlertCircleIcon, X } from "lucide-react"; import { Calendar, Clock, DownloadCloud, Eye, Info, Loader2, Music, ThumbsUp, Video, File, ListVideo, PackageSearch, AlertCircleIcon, X, Settings2 } from "lucide-react";
import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup"; import { FormatSelectionGroup, FormatSelectionGroupItem } from "@/components/custom/formatSelectionGroup";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { ToggleGroup, ToggleGroupItem } from "@/components/custom/legacyToggleGroup"; import { ToggleGroup, ToggleGroupItem } from "@/components/custom/legacyToggleGroup";
@@ -24,6 +24,11 @@ import { config } from "@/config";
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 { invoke } from "@tauri-apps/api/core"; import { invoke } from "@tauri-apps/api/core";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
const searchFormSchema = z.object({ const searchFormSchema = z.object({
url: z.url({ url: z.url({
@@ -51,22 +56,32 @@ export default function DownloaderPage() {
const setShowSearchError = useCurrentVideoMetadataStore((state) => state.setShowSearchError); const setShowSearchError = useCurrentVideoMetadataStore((state) => state.setShowSearchError);
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab); const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
const activeDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.activeDownloadConfigurationTab);
const isStartingDownload = useDownloaderPageStatesStore((state) => state.isStartingDownload); const isStartingDownload = useDownloaderPageStatesStore((state) => state.isStartingDownload);
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 selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles); const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex); const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab); const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab);
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload); const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
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 setSelectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideoIndex);
const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
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 embedVideoMetadata = useSettingsPageStatesStore(state => state.settings.embed_video_metadata);
const embedAudioMetadata = useSettingsPageStatesStore(state => state.settings.embed_audio_metadata);
const embedAudioThumbnail = useSettingsPageStatesStore(state => state.settings.embed_audio_thumbnail);
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const customCommands = useSettingsPageStatesStore(state => state.settings.custom_commands);
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 audioOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('audio'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('audio'))) : [];
const videoOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video'))) : []; const videoOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video'))) : [];
@@ -146,7 +161,10 @@ export default function DownloaderPage() {
let selectedFormatExtensionMsg = 'Auto - unknown'; let selectedFormatExtensionMsg = 'Auto - unknown';
if (activeDownloadModeTab === 'combine') { if (activeDownloadModeTab === 'combine') {
if (videoFormat !== 'auto') { if (downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
selectedFormatExtensionMsg = `Combined - ${downloadConfiguration.output_format.toUpperCase()}`;
}
else if (videoFormat !== 'auto') {
selectedFormatExtensionMsg = `Combined - ${videoFormat.toUpperCase()}`; selectedFormatExtensionMsg = `Combined - ${videoFormat.toUpperCase()}`;
} }
else if (selectedAudioFormat?.ext && selectedVideoFormat?.ext) { else if (selectedAudioFormat?.ext && selectedVideoFormat?.ext) {
@@ -155,10 +173,12 @@ export default function DownloaderPage() {
selectedFormatExtensionMsg = `Combined - unknown`; selectedFormatExtensionMsg = `Combined - unknown`;
} }
} else if (selectedFormat?.ext) { } else if (selectedFormat?.ext) {
if ((selectedFormatFileType === 'video+audio' || selectedFormatFileType === 'video') && videoFormat !== 'auto') { if ((selectedFormatFileType === 'video+audio' || selectedFormatFileType === 'video') && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || videoFormat !== 'auto')) {
selectedFormatExtensionMsg = `Forced - ${videoFormat.toUpperCase()}`; selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : videoFormat.toUpperCase()}`;
} else if (selectedFormatFileType === 'audio' && audioFormat !== 'auto') { } else if (selectedFormatFileType === 'audio' && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || audioFormat !== 'auto')) {
selectedFormatExtensionMsg = `Forced - ${audioFormat.toUpperCase()}`; selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : audioFormat.toUpperCase()}`;
} else if (selectedFormatFileType === 'unknown' && downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') {
selectedFormatExtensionMsg = `Forced - ${downloadConfiguration.output_format.toUpperCase()}`;
} else { } else {
selectedFormatExtensionMsg = `Auto - ${selectedFormat.ext.toUpperCase()}`; selectedFormatExtensionMsg = `Auto - ${selectedFormat.ext.toUpperCase()}`;
} }
@@ -220,6 +240,7 @@ export default function DownloaderPage() {
setSelectedCombinableAudioFormat(''); setSelectedCombinableAudioFormat('');
setSelectedSubtitles([]); setSelectedSubtitles([]);
setSelectedPlaylistVideoIndex('1'); setSelectedPlaylistVideoIndex('1');
resetDownloadConfiguration();
fetchVideoMetadata(values.url).then((metadata) => { fetchVideoMetadata(values.url).then((metadata) => {
if (!metadata || (metadata._type !== 'video' && metadata._type !== 'playlist') || (metadata && metadata._type === 'video' && metadata.formats.length <= 0) || (metadata && metadata._type === 'playlist' && metadata.entries.length <= 0)) { if (!metadata || (metadata._type !== 'video' && metadata._type !== 'playlist') || (metadata && metadata._type === 'video' && metadata.formats.length <= 0) || (metadata && metadata._type === 'playlist' && metadata.entries.length <= 0)) {
@@ -270,6 +291,10 @@ export default function DownloaderPage() {
}; };
}, []); }, []);
useEffect(() => {
useCustomCommands ? setActiveDownloadConfigurationTab('commands') : setActiveDownloadConfigurationTab('options');
}, []);
useEffect(() => { useEffect(() => {
if (watchedUrl !== videoUrl) { if (watchedUrl !== videoUrl) {
setVideoUrl(watchedUrl); setVideoUrl(watchedUrl);
@@ -435,7 +460,12 @@ export default function DownloaderPage() {
<Tabs <Tabs
className="" className=""
value={activeDownloadModeTab} value={activeDownloadModeTab}
onValueChange={(tab) => setActiveDownloadModeTab(tab)} onValueChange={(tab) => {
setActiveDownloadModeTab(tab)
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}}
> >
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-sm flex items-center gap-2"> <h3 className="text-sm flex items-center gap-2">
@@ -481,6 +511,9 @@ export default function DownloaderPage() {
// if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') { // if (currentlySelectedFormat?.ext !== 'mp4' && currentlySelectedFormat?.ext !== 'mkv' && currentlySelectedFormat?.ext !== 'webm') {
// setSelectedSubtitles([]); // setSelectedSubtitles([]);
// } // }
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}} }}
> >
<p className="text-xs">Suggested</p> <p className="text-xs">Suggested</p>
@@ -581,6 +614,9 @@ export default function DownloaderPage() {
value={selectedCombinableAudioFormat} value={selectedCombinableAudioFormat}
onValueChange={(value) => { onValueChange={(value) => {
setSelectedCombinableAudioFormat(value); setSelectedCombinableAudioFormat(value);
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}} }}
> >
{videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && ( {videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && (
@@ -602,6 +638,9 @@ export default function DownloaderPage() {
value={selectedCombinableVideoFormat} value={selectedCombinableVideoFormat}
onValueChange={(value) => { onValueChange={(value) => {
setSelectedCombinableVideoFormat(value); setSelectedCombinableVideoFormat(value);
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}} }}
> >
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && ( {audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
@@ -668,6 +707,7 @@ export default function DownloaderPage() {
setSelectedSubtitles([]); setSelectedSubtitles([]);
setSelectedCombinableVideoFormat(''); setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormat(''); setSelectedCombinableAudioFormat('');
resetDownloadConfiguration();
}} }}
> >
{videoMetadata.entries.map((entry) => entry ? ( {videoMetadata.entries.map((entry) => entry ? (
@@ -909,6 +949,198 @@ export default function DownloaderPage() {
<span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span> <span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<Dialog>
<Tooltip>
<TooltipTrigger asChild>
<DialogTrigger asChild>
<Button
variant="outline"
size="icon"
disabled={!selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !selectedCombinableAudioFormat))}
>
<Settings2 className="size-4" />
</Button>
</DialogTrigger>
</TooltipTrigger>
<TooltipContent>
<p>Configurations</p>
</TooltipContent>
</Tooltip>
<DialogContent className="sm:max-w-[450px]">
<DialogHeader>
<DialogTitle>Configurations</DialogTitle>
<DialogDescription>Tweak this download's configurations</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-2 max-h-[300px] overflow-y-scroll overflow-x-hidden no-scrollbar">
<Tabs
className=""
value={activeDownloadConfigurationTab}
onValueChange={(tab) => setActiveDownloadConfigurationTab(tab)}
>
<TabsList>
<TabsTrigger value="options">Options</TabsTrigger>
<TabsTrigger value="commands">Commands</TabsTrigger>
</TabsList>
<TabsContent value="options">
{useCustomCommands ? (
<Alert className="mt-2 mb-3">
<AlertCircleIcon />
<AlertTitle className="text-sm">Options Unavailable!</AlertTitle>
<AlertDescription className="text-xs">
You cannot use these options when custom commands are enabled. To use these options, disable custom commands from Settings.
</AlertDescription>
</Alert>
) : null}
<div className="video-format">
<Label className="text-xs mb-3 mt-2">Output Format ({(selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? 'Video' : selectedFormatFileType && selectedFormatFileType === 'audio' ? 'Audio' : 'Unknown'})</Label>
{(selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? (
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap"
value={downloadConfiguration.output_format ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="v-auto" />
<Label htmlFor="v-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp4" id="v-mp4" />
<Label htmlFor="v-mp4">MP4</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="webm" id="v-webm" />
<Label htmlFor="v-webm">WEBM</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mkv" id="v-mkv" />
<Label htmlFor="v-mkv">MKV</Label>
</div>
</RadioGroup>
) : selectedFormatFileType && selectedFormatFileType === 'audio' ? (
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap"
value={downloadConfiguration.output_format ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="a-auto" />
<Label htmlFor="a-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="m4a" id="a-m4a" />
<Label htmlFor="a-m4a">M4A</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="opus" id="a-opus" />
<Label htmlFor="a-opus">OPUS</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp3" id="a-mp3" />
<Label htmlFor="a-mp3">MP3</Label>
</div>
</RadioGroup>
) : (
<RadioGroup
orientation="horizontal"
className="flex items-center gap-4 flex-wrap"
value={downloadConfiguration.output_format ?? 'auto'}
onValueChange={(value) => setDownloadConfigurationKey('output_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="u-auto" />
<Label htmlFor="u-auto">Follow Settings</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp4" id="u-mp4" />
<Label htmlFor="u-mp4">MP4</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="webm" id="u-webm" />
<Label htmlFor="u-webm">WEBM</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mkv" id="u-mkv" />
<Label htmlFor="u-mkv">MKV</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="m4a" id="u-m4a" />
<Label htmlFor="u-m4a">M4A</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="opus" id="u-opus" />
<Label htmlFor="u-opus">OPUS</Label>
</div>
<div className="flex items-center gap-3">
<RadioGroupItem value="mp3" id="u-mp3" />
<Label htmlFor="u-mp3">MP3</Label>
</div>
</RadioGroup>
)}
</div>
<div className="embeding-options">
<Label className="text-xs my-3">Embeding Options</Label>
<div className="flex items-center space-x-2 mt-3">
<Switch
id="embed-metadata"
checked={downloadConfiguration.embed_metadata !== null ? downloadConfiguration.embed_metadata : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? embedVideoMetadata : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioMetadata : false}
onCheckedChange={(checked) => setDownloadConfigurationKey('embed_metadata', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="embed-metadata">Embed Metadata</Label>
</div>
<div className="flex items-center space-x-2 mt-3">
<Switch
id="embed-thumbnail"
checked={downloadConfiguration.embed_thumbnail !== null ? downloadConfiguration.embed_thumbnail : (selectedFormatFileType && (selectedFormatFileType === 'video' || selectedFormatFileType === 'video+audio')) || activeDownloadModeTab === 'combine' ? false : selectedFormatFileType && selectedFormatFileType === 'audio' ? embedAudioThumbnail : false}
onCheckedChange={(checked) => setDownloadConfigurationKey('embed_thumbnail', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="embed-thumbnail">Embed Thumbnail</Label>
</div>
</div>
</TabsContent>
<TabsContent value="commands">
{!useCustomCommands ? (
<Alert className="mt-2 mb-3">
<AlertCircleIcon />
<AlertTitle className="text-sm">Enable Custom Commands!</AlertTitle>
<AlertDescription className="text-xs">
To run custom commands for downloads, please enable it from the Settings.
</AlertDescription>
</Alert>
) : null}
<div className="custom-commands">
<Label className="text-xs mb-3 mt-2">Run Custom Command</Label>
{customCommands.length === 0 ? (
<p className="text-sm text-muted-foreground">NO CUSTOM COMMAND TEMPLATE ADDED YET!</p>
) : (
<RadioGroup
orientation="vertical"
className="flex flex-col gap-2"
disabled={!useCustomCommands}
value={downloadConfiguration.custom_command}
onValueChange={(value) => setDownloadConfigurationKey('custom_command', value)}
>
{customCommands.map((command) => (
<div className="flex items-center gap-3" key={command.id}>
<RadioGroupItem value={command.id} id={`cmd-${command.id}`} />
<Label htmlFor={`cmd-${command.id}`}>{command.label}</Label>
</div>
))}
</RadioGroup>
)}
</div>
</TabsContent>
</Tabs>
</div>
</DialogContent>
</Dialog>
<Button <Button
onClick={async () => { onClick={async () => {
setIsStartingDownload(true); setIsStartingDownload(true);
@@ -917,6 +1149,7 @@ export default function DownloaderPage() {
await startDownload( await startDownload(
videoMetadata.original_url, videoMetadata.original_url,
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat, activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat,
downloadConfiguration,
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null, selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
undefined, undefined,
selectedPlaylistVideoIndex selectedPlaylistVideoIndex
@@ -925,6 +1158,7 @@ export default function DownloaderPage() {
await startDownload( await startDownload(
videoMetadata.webpage_url, videoMetadata.webpage_url,
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat, activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat,
downloadConfiguration,
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null
); );
} }
@@ -941,7 +1175,7 @@ export default function DownloaderPage() {
setIsStartingDownload(false); setIsStartingDownload(false);
} }
}} }}
disabled={isStartingDownload || !selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !selectedCombinableAudioFormat))} disabled={isStartingDownload || !selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !selectedCombinableAudioFormat)) || (useCustomCommands && !downloadConfiguration.custom_command)}
> >
{isStartingDownload ? ( {isStartingDownload ? (
<> <>
@@ -953,6 +1187,7 @@ export default function DownloaderPage() {
)} )}
</Button> </Button>
</div> </div>
</div>
)} )}
</div> </div>
); );

View File

@@ -1,13 +1,13 @@
import Heading from "@/components/heading"; import Heading from "@/components/heading";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBasePathsStore, useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store"; import { useBasePathsStore, useDownloaderPageStatesStore, useDownloadStatesStore, useSettingsPageStatesStore } from "@/services/store";
import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner"; import { toast } from "sonner";
import { ArrowDownToLine, ArrowRight, BrushCleaning, Cookie, EthernetPort, ExternalLink, FileVideo, Folder, FolderOpen, Info, Loader2, LucideIcon, Monitor, Moon, Radio, RotateCcw, RotateCw, ShieldMinus, Sun, Terminal, WandSparkles, Wifi, Wrench } from "lucide-react"; import { ArrowDownToLine, ArrowRight, BrushCleaning, Cookie, EthernetPort, ExternalLink, FileVideo, Folder, FolderOpen, Info, Loader2, LucideIcon, Monitor, Moon, Radio, RotateCcw, RotateCw, ShieldMinus, SquareTerminal, Sun, Terminal, Trash, TriangleAlert, WandSparkles, Wifi, Wrench } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useEffect } from "react"; import { useEffect } from "react";
import { useTheme } from "@/providers/themeProvider"; import { useTheme } from "@/providers/themeProvider";
@@ -26,8 +26,10 @@ import { SlidingButton } from "@/components/custom/slidingButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"; import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import * as fs from "@tauri-apps/plugin-fs"; import * as fs from "@tauri-apps/plugin-fs";
import { join } from "@tauri-apps/api/path"; import { join } from "@tauri-apps/api/path";
import { formatSpeed } from "@/utils"; import { formatSpeed, generateID } from "@/utils";
import { ToggleGroup, ToggleGroupItem } from "@/components/custom/legacyToggleGroup"; import { ToggleGroup, ToggleGroupItem } from "@/components/custom/legacyToggleGroup";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
const websocketPortSchema = z.object({ const websocketPortSchema = z.object({
port: z.coerce.number<number>({ port: z.coerce.number<number>({
@@ -65,6 +67,11 @@ const rateLimitSchema = z.object({
}), }),
}); });
const addCustomCommandSchema = z.object({
label: z.string().min(1, { message: "Label is required" }),
args: z.string().min(1, { message: "Arguments are required" }),
});
export default function SettingsPage() { export default function SettingsPage() {
const { setTheme } = useTheme(); const { setTheme } = useTheme();
@@ -109,6 +116,8 @@ export default function SettingsPage() {
const useAria2 = useSettingsPageStatesStore(state => state.settings.use_aria2); const useAria2 = useSettingsPageStatesStore(state => state.settings.use_aria2);
const useForceInternetProtocol = useSettingsPageStatesStore(state => state.settings.use_force_internet_protocol); const useForceInternetProtocol = useSettingsPageStatesStore(state => state.settings.use_force_internet_protocol);
const forceInternetProtocol = useSettingsPageStatesStore(state => state.settings.force_internet_protocol); const forceInternetProtocol = useSettingsPageStatesStore(state => state.settings.force_internet_protocol);
const useCustomCommands = useSettingsPageStatesStore(state => state.settings.use_custom_commands);
const customCommands = useSettingsPageStatesStore(state => state.settings.custom_commands);
const websocketPort = useSettingsPageStatesStore(state => state.settings.websocket_port); const websocketPort = useSettingsPageStatesStore(state => state.settings.websocket_port);
const isChangingWebSocketPort = useSettingsPageStatesStore(state => state.isChangingWebSocketPort); const isChangingWebSocketPort = useSettingsPageStatesStore(state => state.isChangingWebSocketPort);
@@ -116,6 +125,9 @@ export default function SettingsPage() {
const isRestartingWebSocketServer = useSettingsPageStatesStore(state => state.isRestartingWebSocketServer); const isRestartingWebSocketServer = useSettingsPageStatesStore(state => state.isRestartingWebSocketServer);
const setIsRestartingWebSocketServer = useSettingsPageStatesStore(state => state.setIsRestartingWebSocketServer); const setIsRestartingWebSocketServer = useSettingsPageStatesStore(state => state.setIsRestartingWebSocketServer);
const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
const downloadStates = useDownloadStatesStore(state => state.downloadStates); const downloadStates = useDownloadStatesStore(state => state.downloadStates);
const ongoingDownloads = downloadStates.filter(state => const ongoingDownloads = downloadStates.filter(state =>
['starting', 'downloading', 'queued'].includes(state.download_status) ['starting', 'downloading', 'queued'].includes(state.download_status)
@@ -235,6 +247,56 @@ export default function SettingsPage() {
} }
} }
const addCustomCommandForm = useForm<z.infer<typeof addCustomCommandSchema>>({
resolver: zodResolver(addCustomCommandSchema),
defaultValues: {
label: '',
args: '',
},
mode: "onChange",
});
const watchedLabel = addCustomCommandForm.watch("label");
const watchedArgs = addCustomCommandForm.watch("args");
const { errors: addCustomCommandFormErrors } = addCustomCommandForm.formState;
function handleAddCustomCommandSubmit(values: z.infer<typeof addCustomCommandSchema>) {
try {
const newCommand = {
id: generateID(),
label: values.label,
args: values.args,
};
const updatedCommands = [...customCommands, newCommand];
saveSettingsKey('custom_commands', updatedCommands);
toast.success("Custom Command added", {
description: `Custom Command "${values.label}" added.`,
});
addCustomCommandForm.reset();
} catch (error) {
console.error("Error adding custom command:", error);
toast.error("Failed to add custom command", {
description: "An error occurred while trying to add the custom command. Please try again.",
});
}
}
function handleRemoveCustomCommandSubmit(commandId: string) {
try {
const removedCommand = customCommands.find(command => command.id === commandId);
const updatedCommands = customCommands.filter(command => command.id !== commandId);
saveSettingsKey('custom_commands', updatedCommands);
setDownloadConfigurationKey('custom_command', null);
toast.success("Custom Command removed", {
description: `Custom Command "${removedCommand?.label}" removed.`,
});
} catch (error) {
console.error("Error removing custom command:", error);
toast.error("Failed to remove custom command", {
description: "An error occurred while trying to remove the custom command. Please try again.",
});
}
}
interface Config { interface Config {
port: number; port: number;
} }
@@ -422,9 +484,14 @@ export default function SettingsPage() {
value="sponsorblock" value="sponsorblock"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2" className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
><ShieldMinus className="size-4" /> Sponsorblock</TabsTrigger> ><ShieldMinus className="size-4" /> Sponsorblock</TabsTrigger>
<TabsTrigger
key="commands"
value="commands"
className="data-[state=active]:bg-primary data-[state=active]:text-primary-foreground justify-start px-3 py-1.5 gap-2"
><SquareTerminal className="size-4" /> Commands</TabsTrigger>
</TabsList> </TabsList>
<div className="min-h-full flex flex-col max-w-[55%] w-full border-l border-border pl-4"> <div className="min-h-full flex flex-col max-w-[55%] w-full border-l border-border pl-4">
<TabsContent key="general" value="general" className="flex flex-col gap-4 min-h-[310px]"> <TabsContent key="general" value="general" className="flex flex-col gap-4 min-h-[350px]">
<div className="max-parallel-downloads"> <div className="max-parallel-downloads">
<h3 className="font-semibold">Max Parallel Downloads</h3> <h3 className="font-semibold">Max Parallel Downloads</h3>
<p className="text-xs text-muted-foreground mb-3">Set maximum number of allowed parallel downloads</p> <p className="text-xs text-muted-foreground mb-3">Set maximum number of allowed parallel downloads</p>
@@ -476,10 +543,11 @@ export default function SettingsPage() {
id="aria2" id="aria2"
checked={useAria2} checked={useAria2}
onCheckedChange={(checked) => saveSettingsKey('use_aria2', checked)} onCheckedChange={(checked) => saveSettingsKey('use_aria2', checked)}
disabled={useCustomCommands}
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent key="appearance" value="appearance" className="flex flex-col gap-4 min-h-[310px]"> <TabsContent key="appearance" value="appearance" className="flex flex-col gap-4 min-h-[350px]">
<div className="app-theme"> <div className="app-theme">
<h3 className="font-semibold">Theme</h3> <h3 className="font-semibold">Theme</h3>
<p className="text-xs text-muted-foreground mb-3">Choose app interface theme</p> <p className="text-xs text-muted-foreground mb-3">Choose app interface theme</p>
@@ -502,7 +570,7 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent key="folders" value="folders" className="flex flex-col gap-4 min-h-[310px]"> <TabsContent key="folders" value="folders" className="flex flex-col gap-4 min-h-[350px]">
<div className="download-dir"> <div className="download-dir">
<h3 className="font-semibold">Download Folder</h3> <h3 className="font-semibold">Download Folder</h3>
<p className="text-xs text-muted-foreground mb-3">Set default download folder (directory)</p> <p className="text-xs text-muted-foreground mb-3">Set default download folder (directory)</p>
@@ -562,7 +630,7 @@ export default function SettingsPage() {
</div> </div>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent key="formats" value="formats" className="flex flex-col gap-4 min-h-[310px]"> <TabsContent key="formats" value="formats" className="flex flex-col gap-4 min-h-[350px]">
<div className="video-format"> <div className="video-format">
<h3 className="font-semibold">Video Format</h3> <h3 className="font-semibold">Video Format</h3>
<p className="text-xs text-muted-foreground mb-3">Choose in which format the final video file will be saved</p> <p className="text-xs text-muted-foreground mb-3">Choose in which format the final video file will be saved</p>
@@ -571,6 +639,7 @@ export default function SettingsPage() {
className="flex items-center gap-4" className="flex items-center gap-4"
value={videoFormat} value={videoFormat}
onValueChange={(value) => saveSettingsKey('video_format', value)} onValueChange={(value) => saveSettingsKey('video_format', value)}
disabled={useCustomCommands}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="v-auto" /> <RadioGroupItem value="auto" id="v-auto" />
@@ -598,6 +667,7 @@ export default function SettingsPage() {
className="flex items-center gap-4" className="flex items-center gap-4"
value={audioFormat} value={audioFormat}
onValueChange={(value) => saveSettingsKey('audio_format', value)} onValueChange={(value) => saveSettingsKey('audio_format', value)}
disabled={useCustomCommands}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="a-auto" /> <RadioGroupItem value="auto" id="a-auto" />
@@ -624,10 +694,11 @@ export default function SettingsPage() {
id="always-reencode-video" id="always-reencode-video"
checked={alwaysReencodeVideo} checked={alwaysReencodeVideo}
onCheckedChange={(checked) => saveSettingsKey('always_reencode_video', checked)} onCheckedChange={(checked) => saveSettingsKey('always_reencode_video', checked)}
disabled={useCustomCommands}
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent key="metadata" value="metadata" className="flex flex-col gap-4 min-h-[310px]"> <TabsContent key="metadata" value="metadata" className="flex flex-col gap-4 min-h-[350px]">
<div className="embed-video-metadata"> <div className="embed-video-metadata">
<h3 className="font-semibold">Embed Metadata</h3> <h3 className="font-semibold">Embed Metadata</h3>
<p className="text-xs text-muted-foreground mb-3">Wheather to embed metadata in video/audio files (info, chapters)</p> <p className="text-xs text-muted-foreground mb-3">Wheather to embed metadata in video/audio files (info, chapters)</p>
@@ -636,6 +707,7 @@ export default function SettingsPage() {
id="embed-video-metadata" id="embed-video-metadata"
checked={embedVideoMetadata} checked={embedVideoMetadata}
onCheckedChange={(checked) => saveSettingsKey('embed_video_metadata', checked)} onCheckedChange={(checked) => saveSettingsKey('embed_video_metadata', checked)}
disabled={useCustomCommands}
/> />
<Label htmlFor="embed-video-metadata">Video</Label> <Label htmlFor="embed-video-metadata">Video</Label>
</div> </div>
@@ -644,6 +716,7 @@ export default function SettingsPage() {
id="embed-audio-metadata" id="embed-audio-metadata"
checked={embedAudioMetadata} checked={embedAudioMetadata}
onCheckedChange={(checked) => saveSettingsKey('embed_audio_metadata', checked)} onCheckedChange={(checked) => saveSettingsKey('embed_audio_metadata', checked)}
disabled={useCustomCommands}
/> />
<Label htmlFor="embed-audio-metadata">Audio</Label> <Label htmlFor="embed-audio-metadata">Audio</Label>
</div> </div>
@@ -655,10 +728,11 @@ export default function SettingsPage() {
id="embed-audio-thumbnail" id="embed-audio-thumbnail"
checked={embedAudioThumbnail} checked={embedAudioThumbnail}
onCheckedChange={(checked) => saveSettingsKey('embed_audio_thumbnail', checked)} onCheckedChange={(checked) => saveSettingsKey('embed_audio_thumbnail', checked)}
disabled={useCustomCommands}
/> />
</div> </div>
</TabsContent> </TabsContent>
<TabsContent key="network" value="network" className="flex flex-col gap-4 min-h-[310px]"> <TabsContent key="network" value="network" className="flex flex-col gap-4 min-h-[350px]">
<div className="proxy"> <div className="proxy">
<h3 className="font-semibold">Proxy</h3> <h3 className="font-semibold">Proxy</h3>
<p className="text-xs text-muted-foreground mb-3">Use proxy for downloads, Unblocks blocked sites in your region (download speed may affect, some sites may not work)</p> <p className="text-xs text-muted-foreground mb-3">Use proxy for downloads, Unblocks blocked sites in your region (download speed may affect, some sites may not work)</p>
@@ -667,6 +741,7 @@ export default function SettingsPage() {
id="use-proxy" id="use-proxy"
checked={useProxy} checked={useProxy}
onCheckedChange={(checked) => saveSettingsKey('use_proxy', checked)} onCheckedChange={(checked) => saveSettingsKey('use_proxy', checked)}
disabled={useCustomCommands}
/> />
<Label htmlFor="use-proxy">Use Proxy</Label> <Label htmlFor="use-proxy">Use Proxy</Label>
</div> </div>
@@ -682,10 +757,11 @@ export default function SettingsPage() {
<Input <Input
className="focus-visible:ring-0" className="focus-visible:ring-0"
placeholder="Enter proxy URL" placeholder="Enter proxy URL"
readOnly={useCustomCommands}
{...field} {...field}
/> />
</FormControl> </FormControl>
<Label htmlFor="url" className="text-xs text-muted-foreground">(Configured: {proxyUrl ? 'Yes' : 'No'}, Status: {useProxy ? 'Enabled' : 'Disabled'})</Label> <Label htmlFor="url" className="text-xs text-muted-foreground">(Configured: {proxyUrl ? 'Yes' : 'No'}, Status: {useProxy && !useCustomCommands ? 'Enabled' : 'Disabled'})</Label>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -707,6 +783,7 @@ export default function SettingsPage() {
id="use-rate-limit" id="use-rate-limit"
checked={useRateLimit} checked={useRateLimit}
onCheckedChange={(checked) => saveSettingsKey('use_rate_limit', checked)} onCheckedChange={(checked) => saveSettingsKey('use_rate_limit', checked)}
disabled={useCustomCommands}
/> />
<Label htmlFor="use-rate-limit">Use Rate Limit</Label> <Label htmlFor="use-rate-limit">Use Rate Limit</Label>
</div> </div>
@@ -722,10 +799,11 @@ export default function SettingsPage() {
<Input <Input
className="focus-visible:ring-0" className="focus-visible:ring-0"
placeholder="Enter rate limit in bytes/s" placeholder="Enter rate limit in bytes/s"
readOnly={useCustomCommands}
{...field} {...field}
/> />
</FormControl> </FormControl>
<Label htmlFor="rate_limit" className="text-xs text-muted-foreground">(Configured: {rateLimit ? `${rateLimit} = ${formatSpeed(rateLimit)}` : 'No'}, Status: {useRateLimit ? 'Enabled' : 'Disabled'}) (Default: 1048576, Range: 1024-104857600)</Label> <Label htmlFor="rate_limit" className="text-xs text-muted-foreground">(Configured: {rateLimit ? `${rateLimit} = ${formatSpeed(rateLimit)}` : 'No'}, Status: {useRateLimit && !useCustomCommands ? 'Enabled' : 'Disabled'}) (Default: 1048576, Range: 1024-104857600)</Label>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>
)} )}
@@ -741,12 +819,13 @@ export default function SettingsPage() {
</div> </div>
<div className="force-internet-protocol"> <div className="force-internet-protocol">
<h3 className="font-semibold">Force Internet Protocol</h3> <h3 className="font-semibold">Force Internet Protocol</h3>
<p className="text-xs text-muted-foreground mb-3">Force using a specific internet protocol (ipv4/ipv6) for all downloads, useful if your network supports only one (some sites may not work)</p> <p className="text-xs text-muted-foreground mb-3">Force use a specific internet protocol (ipv4/ipv6) for all downloads, useful if your network supports only one (some sites may not work)</p>
<div className="flex items-center space-x-2 mb-4"> <div className="flex items-center space-x-2 mb-4">
<Switch <Switch
id="use-force-internet-protocol" id="use-force-internet-protocol"
checked={useForceInternetProtocol} checked={useForceInternetProtocol}
onCheckedChange={(checked) => saveSettingsKey('use_force_internet_protocol', checked)} onCheckedChange={(checked) => saveSettingsKey('use_force_internet_protocol', checked)}
disabled={useCustomCommands}
/> />
<Label htmlFor="use-force-internet-protocol">Force</Label> <Label htmlFor="use-force-internet-protocol">Force</Label>
</div> </div>
@@ -755,7 +834,7 @@ export default function SettingsPage() {
className="flex items-center gap-4 mb-2" className="flex items-center gap-4 mb-2"
value={forceInternetProtocol} value={forceInternetProtocol}
onValueChange={(value) => saveSettingsKey('force_internet_protocol', value)} onValueChange={(value) => saveSettingsKey('force_internet_protocol', value)}
disabled={!useForceInternetProtocol} disabled={!useForceInternetProtocol || useCustomCommands}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RadioGroupItem value="ipv4" id="force-ipv4" /> <RadioGroupItem value="ipv4" id="force-ipv4" />
@@ -766,10 +845,10 @@ export default function SettingsPage() {
<Label htmlFor="force-ipv6">Use IPv6 Only</Label> <Label htmlFor="force-ipv6">Use IPv6 Only</Label>
</div> </div>
</RadioGroup> </RadioGroup>
<Label className="text-xs text-muted-foreground">(Forced: {forceInternetProtocol === "ipv4" ? 'IPv4' : 'IPv6'}, Status: {useForceInternetProtocol ? 'Enabled' : 'Disabled'})</Label> <Label className="text-xs text-muted-foreground">(Forced: {forceInternetProtocol === "ipv4" ? 'IPv4' : 'IPv6'}, Status: {useForceInternetProtocol && !useCustomCommands ? 'Enabled' : 'Disabled'})</Label>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent key="cookies" value="cookies" className="flex flex-col gap-4 min-h-[310px]"> <TabsContent key="cookies" value="cookies" className="flex flex-col gap-4 min-h-[350px]">
<div className="cookies"> <div className="cookies">
<h3 className="font-semibold">Cookies</h3> <h3 className="font-semibold">Cookies</h3>
<p className="text-xs text-muted-foreground mb-3">Use cookies to access exclusive/private (login-protected) contents from sites (use wisely, over-use can even block/ban your account)</p> <p className="text-xs text-muted-foreground mb-3">Use cookies to access exclusive/private (login-protected) contents from sites (use wisely, over-use can even block/ban your account)</p>
@@ -778,6 +857,7 @@ export default function SettingsPage() {
id="use-cookies" id="use-cookies"
checked={useCookies} checked={useCookies}
onCheckedChange={(checked) => saveSettingsKey('use_cookies', checked)} onCheckedChange={(checked) => saveSettingsKey('use_cookies', checked)}
disabled={useCustomCommands}
/> />
<Label htmlFor="use-cookies">Use Cookies</Label> <Label htmlFor="use-cookies">Use Cookies</Label>
</div> </div>
@@ -786,7 +866,7 @@ export default function SettingsPage() {
className="flex items-center gap-4" className="flex items-center gap-4"
value={importCookiesFrom} value={importCookiesFrom}
onValueChange={(value) => saveSettingsKey('import_cookies_from', value)} onValueChange={(value) => saveSettingsKey('import_cookies_from', value)}
disabled={!useCookies} disabled={!useCookies || useCustomCommands}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RadioGroupItem value="browser" id="cookies-browser" /> <RadioGroupItem value="browser" id="cookies-browser" />
@@ -802,7 +882,7 @@ export default function SettingsPage() {
<Select <Select
value={cookiesBrowser} value={cookiesBrowser}
onValueChange={(value) => saveSettingsKey('cookies_browser', value)} onValueChange={(value) => saveSettingsKey('cookies_browser', value)}
disabled={importCookiesFrom !== "browser" || !useCookies} disabled={importCookiesFrom !== "browser" || !useCookies || useCustomCommands}
> >
<SelectTrigger className="w-[230px] ring-0 focus:ring-0"> <SelectTrigger className="w-[230px] ring-0 focus:ring-0">
<SelectValue placeholder="Select browser to import cookies" /> <SelectValue placeholder="Select browser to import cookies" />
@@ -829,7 +909,7 @@ export default function SettingsPage() {
<Input className="focus-visible:ring-0" type="text" placeholder="Select cookies text file" value={cookiesFile ?? ''} disabled={importCookiesFrom !== "file" || !useCookies} readOnly/> <Input className="focus-visible:ring-0" type="text" placeholder="Select cookies text file" value={cookiesFile ?? ''} disabled={importCookiesFrom !== "file" || !useCookies} readOnly/>
<Button <Button
variant="outline" variant="outline"
disabled={importCookiesFrom !== "file" || !useCookies} disabled={importCookiesFrom !== "file" || !useCookies || useCustomCommands}
onClick={async () => { onClick={async () => {
try { try {
const file = await open({ const file = await open({
@@ -854,10 +934,10 @@ export default function SettingsPage() {
</Button> </Button>
</div> </div>
</div> </div>
<Label className="text-xs text-muted-foreground">(Configured: {importCookiesFrom === "browser" ? 'Yes' : cookiesFile ? 'Yes' : 'No'}, From: {importCookiesFrom === "browser" ? 'Browser' : 'Text'}, Status: {useCookies ? 'Enabled' : 'Disabled'})</Label> <Label className="text-xs text-muted-foreground">(Configured: {importCookiesFrom === "browser" ? 'Yes' : cookiesFile ? 'Yes' : 'No'}, From: {importCookiesFrom === "browser" ? 'Browser' : 'Text'}, Status: {useCookies && !useCustomCommands ? 'Enabled' : 'Disabled'})</Label>
</div> </div>
</TabsContent> </TabsContent>
<TabsContent key="sponsorblock" value="sponsorblock" className="flex flex-col gap-4 min-h-[310px]"> <TabsContent key="sponsorblock" value="sponsorblock" className="flex flex-col gap-4 min-h-[350px]">
<div className="sponsorblock"> <div className="sponsorblock">
<h3 className="font-semibold">Sponsor Block</h3> <h3 className="font-semibold">Sponsor Block</h3>
<p className="text-xs text-muted-foreground mb-3">Use sponsorblock to remove/mark unwanted segments in videos (sponsorships, intros, outros, etc.)</p> <p className="text-xs text-muted-foreground mb-3">Use sponsorblock to remove/mark unwanted segments in videos (sponsorships, intros, outros, etc.)</p>
@@ -866,6 +946,7 @@ export default function SettingsPage() {
id="use-sponsorblock" id="use-sponsorblock"
checked={useSponsorblock} checked={useSponsorblock}
onCheckedChange={(checked) => saveSettingsKey('use_sponsorblock', checked)} onCheckedChange={(checked) => saveSettingsKey('use_sponsorblock', checked)}
disabled={useCustomCommands}
/> />
<Label htmlFor="use-sponsorblock">Use Sponsorblock</Label> <Label htmlFor="use-sponsorblock">Use Sponsorblock</Label>
</div> </div>
@@ -874,7 +955,7 @@ export default function SettingsPage() {
className="flex items-center gap-4" className="flex items-center gap-4"
value={sponsorblockMode} value={sponsorblockMode}
onValueChange={(value) => saveSettingsKey('sponsorblock_mode', value)} onValueChange={(value) => saveSettingsKey('sponsorblock_mode', value)}
disabled={!useSponsorblock} disabled={!useSponsorblock || useCustomCommands}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RadioGroupItem value="remove" id="sponsorblock-remove" /> <RadioGroupItem value="remove" id="sponsorblock-remove" />
@@ -892,7 +973,7 @@ export default function SettingsPage() {
className="flex items-center gap-4" className="flex items-center gap-4"
value={sponsorblockRemove} value={sponsorblockRemove}
onValueChange={(value) => saveSettingsKey('sponsorblock_remove', value)} onValueChange={(value) => saveSettingsKey('sponsorblock_remove', value)}
disabled={!useSponsorblock || sponsorblockMode !== "remove"} disabled={!useSponsorblock || sponsorblockMode !== "remove" || useCustomCommands}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RadioGroupItem value="default" id="sponsorblock-remove-default" /> <RadioGroupItem value="default" id="sponsorblock-remove-default" />
@@ -913,7 +994,7 @@ export default function SettingsPage() {
className="flex flex-col items-start gap-2 mt-1" className="flex flex-col items-start gap-2 mt-1"
value={sponsorblockRemove === "custom" ? sponsorblockRemoveCategories : sponsorblockRemove === "default" ? sponsorblockCategories.filter((cat) => cat.code !== 'poi_highlight' && cat.code !== 'filler').map((cat) => cat.code) : sponsorblockRemove === "all" ? sponsorblockCategories.filter((cat) => cat.code !== 'poi_highlight').map((cat) => cat.code) : []} value={sponsorblockRemove === "custom" ? sponsorblockRemoveCategories : sponsorblockRemove === "default" ? sponsorblockCategories.filter((cat) => cat.code !== 'poi_highlight' && cat.code !== 'filler').map((cat) => cat.code) : sponsorblockRemove === "all" ? sponsorblockCategories.filter((cat) => cat.code !== 'poi_highlight').map((cat) => cat.code) : []}
onValueChange={(value) => saveSettingsKey('sponsorblock_remove_categories', value)} onValueChange={(value) => saveSettingsKey('sponsorblock_remove_categories', value)}
disabled={!useSponsorblock || sponsorblockMode !== "remove" || sponsorblockRemove !== "custom"} disabled={!useSponsorblock || sponsorblockMode !== "remove" || sponsorblockRemove !== "custom" || useCustomCommands}
> >
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">
{sponsorblockCategories.map((category) => ( {sponsorblockCategories.map((category) => (
@@ -939,7 +1020,7 @@ export default function SettingsPage() {
className="flex items-center gap-4" className="flex items-center gap-4"
value={sponsorblockMark} value={sponsorblockMark}
onValueChange={(value) => saveSettingsKey('sponsorblock_mark', value)} onValueChange={(value) => saveSettingsKey('sponsorblock_mark', value)}
disabled={!useSponsorblock || sponsorblockMode !== "mark"} disabled={!useSponsorblock || sponsorblockMode !== "mark" || useCustomCommands}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<RadioGroupItem value="default" id="sponsorblock-mark-default" /> <RadioGroupItem value="default" id="sponsorblock-mark-default" />
@@ -960,7 +1041,7 @@ export default function SettingsPage() {
className="flex flex-col items-start gap-2 mt-1 mb-2" className="flex flex-col items-start gap-2 mt-1 mb-2"
value={sponsorblockMark === "custom" ? sponsorblockMarkCategories : sponsorblockMark === "default" ? sponsorblockCategories.map((cat) => cat.code) : sponsorblockMark === "all" ? sponsorblockCategories.map((cat) => cat.code) : []} value={sponsorblockMark === "custom" ? sponsorblockMarkCategories : sponsorblockMark === "default" ? sponsorblockCategories.map((cat) => cat.code) : sponsorblockMark === "all" ? sponsorblockCategories.map((cat) => cat.code) : []}
onValueChange={(value) => saveSettingsKey('sponsorblock_mark_categories', value)} onValueChange={(value) => saveSettingsKey('sponsorblock_mark_categories', value)}
disabled={!useSponsorblock || sponsorblockMode !== "mark" || sponsorblockMark !== "custom"} disabled={!useSponsorblock || sponsorblockMode !== "mark" || sponsorblockMark !== "custom" || useCustomCommands}
> >
<div className="flex gap-2 flex-wrap items-center"> <div className="flex gap-2 flex-wrap items-center">
{sponsorblockCategories.map((category) => ( {sponsorblockCategories.map((category) => (
@@ -977,7 +1058,109 @@ export default function SettingsPage() {
</div> </div>
</ToggleGroup> </ToggleGroup>
</div> </div>
<Label className="text-xs text-muted-foreground">(Configured: {sponsorblockMode === "remove" && sponsorblockRemove === "custom" && sponsorblockRemoveCategories.length <= 0 ? 'No' : sponsorblockMode === "mark" && sponsorblockMark === "custom" && sponsorblockMarkCategories.length <= 0 ? 'No' : 'Yes'}, Mode: {sponsorblockMode === "remove" ? 'Remove' : 'Mark'}, Status: {useSponsorblock ? 'Enabled' : 'Disabled'})</Label> <Label className="text-xs text-muted-foreground">(Configured: {sponsorblockMode === "remove" && sponsorblockRemove === "custom" && sponsorblockRemoveCategories.length <= 0 ? 'No' : sponsorblockMode === "mark" && sponsorblockMark === "custom" && sponsorblockMarkCategories.length <= 0 ? 'No' : 'Yes'}, Mode: {sponsorblockMode === "remove" ? 'Remove' : 'Mark'}, Status: {useSponsorblock && !useCustomCommands ? 'Enabled' : 'Disabled'})</Label>
</div>
</TabsContent>
<TabsContent key="commands" value="commands" className="flex flex-col gap-4 min-h-[350px]">
<div className="custom-commands">
<h3 className="font-semibold">Custom Commands</h3>
<p className="text-xs text-muted-foreground mb-3"> Run custom yt-dlp commands for your downloads</p>
<Alert className="mb-3">
<TriangleAlert />
<AlertTitle className="text-sm">Most Settings will be Disabled!</AlertTitle>
<AlertDescription className="text-xs">
This feature is intended for advanced users only. Turning it on will disable most other settings in the app. Make sure you know what you are doing before using this feature, otherwise things could break easily.
</AlertDescription>
</Alert>
<div className="flex items-center space-x-2 mb-4">
<Switch
id="use-custom-commands"
checked={useCustomCommands}
onCheckedChange={(checked) => {
saveSettingsKey('use_custom_commands', checked)
resetDownloadConfiguration();
}}
/>
<Label htmlFor="use-custom-commands">Use Custom Commands</Label>
</div>
<div className="flex flex-col gap-2">
<Form {...addCustomCommandForm}>
<form onSubmit={addCustomCommandForm.handleSubmit(handleAddCustomCommandSubmit)} className="flex flex-col gap-3" autoComplete="off">
<FormField
control={addCustomCommandForm.control}
name="args"
disabled={!useCustomCommands}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Textarea
className="focus-visible:ring-0"
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)"
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<div className="flex gap-4 w-full">
<FormField
control={addCustomCommandForm.control}
name="label"
disabled={!useCustomCommands}
render={({ field }) => (
<FormItem className="w-full">
<FormControl>
<Input
className="focus-visible:ring-0"
placeholder="Enter template label"
{...field}
/>
</FormControl>
{/* <Label htmlFor="label" className="text-xs text-muted-foreground">(Configured: {rateLimit ? `${rateLimit} = ${formatSpeed(rateLimit)}` : 'No'}, Status: {useRateLimit ? 'Enabled' : 'Disabled'}) (Default: 1048576, Range: 1024-104857600)</Label> */}
<FormMessage />
</FormItem>
)}
/>
<Button
type="submit"
disabled={!watchedLabel || !watchedArgs || Object.keys(addCustomCommandFormErrors).length > 0 || !useCustomCommands}
>
Add
</Button>
</div>
</form>
</Form>
</div>
<div className="flex-flex-col gap-2 mt-4">
<Label className="text-xs mb-3">Custom Command Templates</Label>
{customCommands.length === 0 ? (
<p className="text-sm text-muted-foreground">NO CUSTOM COMMAND TEMPLATE ADDED YET!</p>
) : (
<div className="flex flex-col gap-3 w-full">
{customCommands.map((command) => (
<div key={command.id} className="p-2 flex justify-between gap-2 border border-border rounded-md">
<div className="flex flex-col">
<h5 className="text-sm mb-1">{command.label}</h5>
<p className="text-xs font-mono text-muted-foreground">{command.args}</p>
</div>
<div className="flex">
<Button
variant="destructive"
size="icon"
disabled={!useCustomCommands}
onClick={() => {
handleRemoveCustomCommandSubmit(command.id);
}}
>
<Trash className="w-4 h-4" />
</Button>
</div>
</div>
))}
</div>
)}
</div>
</div> </div>
</TabsContent> </TabsContent>
</div> </div>

View File

@@ -1,10 +1,11 @@
import { DownloadState } from '@/types/download'; import { DownloadState } from '@/types/download';
import { DownloadConfiguration } from '@/types/settings';
import { RawVideoInfo } from '@/types/video'; import { RawVideoInfo } from '@/types/video';
import { createContext, useContext } from 'react'; import { createContext, useContext } from 'react';
interface AppContextType { interface AppContextType {
fetchVideoMetadata: (url: string, formatId?: string) => Promise<RawVideoInfo | null>; fetchVideoMetadata: (url: string, formatId?: string, playlistIndex?: string, selectedSubtitles?: string | null, resumeState?: DownloadState) => Promise<RawVideoInfo | null>;
startDownload: (url: string, selectedFormat: string, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => Promise<void>; startDownload: (url: string, selectedFormat: string, downloadConfig: DownloadConfiguration, selectedSubtitles?: string | null, resumeState?: DownloadState, playlistItems?: string) => Promise<void>;
pauseDownload: (state: DownloadState) => Promise<void>; pauseDownload: (state: DownloadState) => Promise<void>;
resumeDownload: (state: DownloadState) => Promise<void>; resumeDownload: (state: DownloadState) => Promise<void>;
cancelDownload: (state: DownloadState) => Promise<void>; cancelDownload: (state: DownloadState) => Promise<void>;

View File

@@ -202,7 +202,8 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
embed_thumbnail = $28, embed_thumbnail = $28,
sponsorblock_remove = $29, sponsorblock_remove = $29,
sponsorblock_mark = $30, sponsorblock_mark = $30,
use_aria2 = $31 use_aria2 = $31,
custom_command = $32
WHERE download_id = $1`, WHERE download_id = $1`,
[ [
downloadState.download_id, downloadState.download_id,
@@ -235,7 +236,8 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
downloadState.embed_thumbnail, downloadState.embed_thumbnail,
downloadState.sponsorblock_remove, downloadState.sponsorblock_remove,
downloadState.sponsorblock_mark, downloadState.sponsorblock_mark,
downloadState.use_aria2 downloadState.use_aria2,
downloadState.custom_command
] ]
) )
} }
@@ -270,8 +272,9 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
embed_thumbnail, embed_thumbnail,
sponsorblock_remove, sponsorblock_remove,
sponsorblock_mark, sponsorblock_mark,
use_aria2 use_aria2,
) 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)`, custom_command
) 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)`,
[ [
downloadState.download_id, downloadState.download_id,
downloadState.download_status, downloadState.download_status,
@@ -303,7 +306,8 @@ export const saveDownloadState = async (downloadState: DownloadState) => {
downloadState.embed_thumbnail, downloadState.embed_thumbnail,
downloadState.sponsorblock_remove, downloadState.sponsorblock_remove,
downloadState.sponsorblock_mark, downloadState.sponsorblock_mark,
downloadState.use_aria2 downloadState.use_aria2,
downloadState.custom_command
] ]
) )
} }

View File

@@ -47,22 +47,45 @@ export const useCurrentVideoMetadataStore = create<CurrentVideoMetadataStore>((s
export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((set) => ({ export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((set) => ({
activeDownloadModeTab: 'selective', activeDownloadModeTab: 'selective',
activeDownloadConfigurationTab: 'options',
isStartingDownload: false, isStartingDownload: false,
selectedDownloadFormat: 'best', selectedDownloadFormat: 'best',
selectedCombinableVideoFormat: '', selectedCombinableVideoFormat: '',
selectedCombinableAudioFormat: '', selectedCombinableAudioFormat: '',
selectedSubtitles: [], selectedSubtitles: [],
selectedPlaylistVideoIndex: '1', selectedPlaylistVideoIndex: '1',
downloadConfiguration: {
output_format: null,
embed_metadata: null,
embed_thumbnail: null,
custom_command: null
},
isErrored: false, isErrored: false,
isErrorExpected: false, isErrorExpected: false,
erroredDownloadId: null, erroredDownloadId: null,
setActiveDownloadModeTab: (tab) => set(() => ({ activeDownloadModeTab: tab })), setActiveDownloadModeTab: (tab) => set(() => ({ activeDownloadModeTab: tab })),
setActiveDownloadConfigurationTab: (tab) => set(() => ({ activeDownloadConfigurationTab: tab })),
setIsStartingDownload: (isStarting) => set(() => ({ isStartingDownload: isStarting })), setIsStartingDownload: (isStarting) => set(() => ({ isStartingDownload: isStarting })),
setSelectedDownloadFormat: (format) => set(() => ({ selectedDownloadFormat: format })), setSelectedDownloadFormat: (format) => set(() => ({ selectedDownloadFormat: format })),
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 })), setSelectedPlaylistVideoIndex: (index) => set(() => ({ selectedPlaylistVideoIndex: index })),
setDownloadConfigurationKey: (key, value) => set((state) => ({
downloadConfiguration: {
...state.downloadConfiguration,
[key]: value
}
})),
setDownloadConfiguration: (config) => set(() => ({ downloadConfiguration: config })),
resetDownloadConfiguration: () => set(() => ({
downloadConfiguration: {
output_format: null,
embed_metadata: null,
embed_thumbnail: null,
custom_command: null
}
})),
setIsErrored: (isErrored) => set(() => ({ isErrored: isErrored })), setIsErrored: (isErrored) => set(() => ({ isErrored: isErrored })),
setIsErrorExpected: (isErrorExpected) => set(() => ({ isErrorExpected: isErrorExpected })), setIsErrorExpected: (isErrorExpected) => set(() => ({ isErrorExpected: isErrorExpected })),
setErroredDownloadId: (downloadId) => set(() => ({ erroredDownloadId: downloadId })), setErroredDownloadId: (downloadId) => set(() => ({ erroredDownloadId: downloadId })),
@@ -154,6 +177,8 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
use_aria2: false, use_aria2: false,
use_force_internet_protocol: false, use_force_internet_protocol: false,
force_internet_protocol: 'ipv4', force_internet_protocol: 'ipv4',
use_custom_commands: false,
custom_commands: [],
// extension settings // extension settings
websocket_port: 53511 websocket_port: 53511
}, },
@@ -212,6 +237,8 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
use_aria2: false, use_aria2: false,
use_force_internet_protocol: false, use_force_internet_protocol: false,
force_internet_protocol: 'ipv4', force_internet_protocol: 'ipv4',
use_custom_commands: false,
custom_commands: [],
// extension settings // extension settings
websocket_port: 53511 websocket_port: 53511
}, },

View File

@@ -43,6 +43,7 @@ export interface DownloadState {
sponsorblock_remove: string | null; sponsorblock_remove: string | null;
sponsorblock_mark: string | null; sponsorblock_mark: string | null;
use_aria2: number; use_aria2: number;
custom_command: string | null;
created_at?: string; created_at?: string;
updated_at?: string; updated_at?: string;
} }
@@ -79,6 +80,7 @@ export interface Download {
sponsorblock_remove: string | null; sponsorblock_remove: string | null;
sponsorblock_mark: string | null; sponsorblock_mark: string | null;
use_aria2: number; use_aria2: number;
custom_command: string | null;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }

View File

@@ -3,6 +3,12 @@ export interface SettingsTable {
value: string; value: string;
} }
export interface CustomCommand {
id: string;
label: string;
args: string;
}
export interface Settings { export interface Settings {
ytdlp_update_channel: string; ytdlp_update_channel: string;
ytdlp_auto_update: boolean; ytdlp_auto_update: boolean;
@@ -35,6 +41,15 @@ export interface Settings {
use_aria2: boolean; use_aria2: boolean;
use_force_internet_protocol: boolean; use_force_internet_protocol: boolean;
force_internet_protocol: string; force_internet_protocol: string;
use_custom_commands: boolean;
custom_commands: CustomCommand[];
// extension settings // extension settings
websocket_port: number; websocket_port: number;
} }
export interface DownloadConfiguration {
output_format: string | null;
embed_metadata: boolean | null;
embed_thumbnail: boolean | null;
custom_command: string | null;
}

View File

@@ -1,6 +1,6 @@
import { DownloadState } from "@/types/download"; import { DownloadState } from "@/types/download";
import { RawVideoInfo } from "@/types/video"; import { RawVideoInfo } from "@/types/video";
import { Settings } from "@/types/settings"; import { DownloadConfiguration, Settings } from "@/types/settings";
import { KvStore } from "@/types/kvStore"; import { KvStore } from "@/types/kvStore";
import { Update } from "@tauri-apps/plugin-updater"; import { Update } from "@tauri-apps/plugin-updater";
import { Log } from "@/types/logs"; import { Log } from "@/types/logs";
@@ -37,22 +37,28 @@ export interface CurrentVideoMetadataStore {
export interface DownloaderPageStatesStore { export interface DownloaderPageStatesStore {
activeDownloadModeTab: string; activeDownloadModeTab: string;
activeDownloadConfigurationTab: string;
isStartingDownload: boolean; isStartingDownload: boolean;
selectedDownloadFormat: string; selectedDownloadFormat: string;
selectedCombinableVideoFormat: string; selectedCombinableVideoFormat: string;
selectedCombinableAudioFormat: string; selectedCombinableAudioFormat: string;
selectedSubtitles: string[]; selectedSubtitles: string[];
selectedPlaylistVideoIndex: string; selectedPlaylistVideoIndex: string;
downloadConfiguration: DownloadConfiguration;
isErrored: boolean; isErrored: boolean;
isErrorExpected: boolean; isErrorExpected: boolean;
erroredDownloadId: string | null; erroredDownloadId: string | null;
setActiveDownloadModeTab: (tab: string) => void; setActiveDownloadModeTab: (tab: string) => void;
setActiveDownloadConfigurationTab: (tab: string) => void;
setIsStartingDownload: (isStarting: boolean) => void; setIsStartingDownload: (isStarting: boolean) => void;
setSelectedDownloadFormat: (format: string) => void; setSelectedDownloadFormat: (format: string) => void;
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; setSelectedPlaylistVideoIndex: (index: string) => void;
setDownloadConfigurationKey: (key: string, value: unknown) => void;
setDownloadConfiguration: (config: DownloadConfiguration) => void;
resetDownloadConfiguration: () => void;
setIsErrored: (isErrored: boolean) => void; setIsErrored: (isErrored: boolean) => void;
setIsErrorExpected: (isErrorExpected: boolean) => void; setIsErrorExpected: (isErrorExpected: boolean) => void;
setErroredDownloadId: (downloadId: string | null) => void; setErroredDownloadId: (downloadId: string | null) => void;

View File

@@ -209,6 +209,10 @@ export const formatCodec = (codec: string) => {
return codec.toUpperCase(); return codec.toUpperCase();
} }
export const generateID = () => {
return `${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
export const generateDownloadId = (videoId: string, host: string) => { export const generateDownloadId = (videoId: string, host: string) => {
host = host.trim().split('.')[0]; host = host.trim().split('.')[0];
return `${host}_${videoId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; return `${host}_${videoId}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;