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;
",
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 { toast } from "sonner";
import { useLogger } from "@/helpers/use-logger";
import { DownloadConfiguration } from "./types/settings";
export default function App({ children }: { children: React.ReactNode }) {
const { data: downloadStates, isSuccess: isSuccessFetchingDownloadStates } = useFetchAllDownloadStates();
@@ -37,7 +38,7 @@ export default function App({ children }: { children: React.ReactNode }) {
const globalDownloadStates = useDownloadStatesStore((state) => state.downloadStates);
const setDownloadStates = useDownloadStatesStore((state) => state.setDownloadStates);
const setPath = useBasePathsStore((state) => state.setPath);
const ffmpegPath = useBasePathsStore((state) => state.ffmpegPath);
const tempDownloadDirPath = useBasePathsStore((state) => state.tempDownloadDirPath);
const downloadDirPath = useBasePathsStore((state) => state.downloadDirPath);
@@ -84,10 +85,13 @@ export default function App({ children }: { children: React.ReactNode }) {
const USE_ARIA2 = useSettingsPageStatesStore(state => state.settings.use_aria2);
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 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 isErrorExpected = useDownloaderPageStatesStore((state) => state.isErrorExpected);
const erroredDownloadId = useDownloaderPageStatesStore((state) => state.erroredDownloadId);
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
const setIsErrored = useDownloaderPageStatesStore((state) => state.setIsErrored);
const setIsErrorExpected = useDownloaderPageStatesStore((state) => state.setIsErrorExpected);
const setErroredDownloadId = useDownloaderPageStatesStore((state) => state.setErroredDownloadId);
@@ -101,7 +105,7 @@ export default function App({ children }: { children: React.ReactNode }) {
const setKvPairsKey = useKvPairsStatesStore((state) => state.setKvPairsKey);
const ytDlpUpdateLastCheck = useKvPairsStatesStore(state => state.kvPairs.ytdlp_update_last_check);
const macOsRegisteredVersion = useKvPairsStatesStore(state => state.kvPairs.macos_registered_version);
const queryClient = useQueryClient();
const downloadStateSaver = useSaveDownloadState();
const downloadStatusUpdater = useUpdateDownloadStatus();
@@ -109,7 +113,7 @@ export default function App({ children }: { children: React.ReactNode }) {
const videoInfoSaver = useSaveVideoInfo();
const downloadStateDeleter = useDeleteDownloadState();
const playlistInfoSaver = useSavePlaylistInfo();
const ongoingDownloads = globalDownloadStates.filter(state => state.download_status === 'downloading' || state.download_status === 'starting');
const queuedDownloads = globalDownloadStates.filter(state => state.download_status === 'queued').sort((a, b) => a.queue_index! - b.queue_index!);
@@ -117,24 +121,26 @@ export default function App({ children }: { children: React.ReactNode }) {
const lastProcessedDownloadIdRef = useRef<string | null>(null);
const hasRunYtDlpAutoUpdateRef = 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 {
const args = [url, '--dump-single-json', '--no-warnings'];
if (formatId) args.push('-f', formatId);
if (selectedSubtitles) args.push('--embed-subs', '--sub-lang', selectedSubtitles);
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-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') {
args.push('--force-ipv4');
} else if (FORCE_INTERNET_PROTOCOL === 'ipv6') {
args.push('--force-ipv6');
}
}
if (USE_COOKIES) {
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) {
if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) {
args.push('--cookies-from-browser', COOKIES_BROWSER);
} else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) {
@@ -196,23 +202,23 @@ export default function App({ children }: { children: React.ReactNode }) {
return null;
}
};
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}`);
// set error states to default
setIsErrored(false);
setIsErrorExpected(false);
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) {
console.error('FFmpeg or download paths not found');
return;
}
const isPlaylist = (playlistItems && typeof playlistItems === 'string') || (resumeState?.playlist_id && resumeState?.playlist_index) ? true : false;
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) {
console.error('Failed to fetch video metadata');
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;
}
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;
const videoId = resumeState?.video_id || generateVideoId(videoMetadata.id, videoMetadata.webpage_url_domain);
@@ -265,73 +276,100 @@ export default function App({ children }: { children: React.ReactNode }) {
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;
if (fileType !== 'unknown' && ((VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto') || 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)));
if ((VIDEO_FORMAT !== 'auto' && fileType === 'video+audio') || (resumeState?.output_format && fileType === 'video+audio')) {
if (ALWAYS_REENCODE_VIDEO) {
args.push('--recode-video', resumeState?.output_format || VIDEO_FORMAT);
} else {
args.push('--merge-output-format', resumeState?.output_format || VIDEO_FORMAT);
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && ((fileType !== 'unknown' && (VIDEO_FORMAT !== 'auto' || AUDIO_FORMAT !== 'auto')) || configOutputFormat || resumeState?.output_format)) {
const format = resumeState?.output_format || configOutputFormat;
if (format) {
outputFormat = format;
} else if (fileType === 'audio' && AUDIO_FORMAT !== 'auto') {
outputFormat = AUDIO_FORMAT;
} else if ((fileType === 'video' || fileType === 'video+audio') && VIDEO_FORMAT !== 'auto') {
outputFormat = VIDEO_FORMAT;
}
}
if ((VIDEO_FORMAT !== 'auto' && fileType === 'video') || (resumeState?.output_format && fileType === 'video')) {
if (ALWAYS_REENCODE_VIDEO) {
args.push('--recode-video', resumeState?.output_format || VIDEO_FORMAT);
} else {
args.push('--remux-video', resumeState?.output_format || 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);
}
// Handle video only
else if (fileType === 'video' && (VIDEO_FORMAT !== 'auto' || format)) {
args.push(recodeOrRemux, formatToUse);
}
// 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;
if (fileType !== 'unknown' && ((EMBED_VIDEO_METADATA || EMBED_AUDIO_METADATA) || resumeState?.embed_metadata)) {
if ((EMBED_VIDEO_METADATA || resumeState?.embed_metadata) && (fileType === 'video+audio' || fileType === 'video')) {
embedMetadata = 1;
args.push('--embed-metadata');
}
if ((EMBED_AUDIO_METADATA || resumeState?.embed_metadata) && fileType === 'audio') {
embedMetadata = 1;
args.push('--embed-metadata');
}
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (downloadConfiguration.embed_metadata || resumeState?.embed_metadata || EMBED_VIDEO_METADATA || EMBED_AUDIO_METADATA)) {
const shouldEmbedForVideo = (fileType === 'video+audio' || fileType === 'video') && (downloadConfiguration.embed_metadata || resumeState?.embed_metadata || (EMBED_VIDEO_METADATA && downloadConfiguration.embed_metadata === null));
const shouldEmbedForAudio = fileType === 'audio' && (downloadConfiguration.embed_metadata || resumeState?.embed_metadata || (EMBED_AUDIO_METADATA && downloadConfiguration.embed_metadata === null));
const shouldEmbedForUnknown = fileType === 'unknown' && (downloadConfiguration.embed_metadata || resumeState?.embed_metadata);
if (shouldEmbedForUnknown || shouldEmbedForVideo || shouldEmbedForAudio) {
embedMetadata = 1;
args.push('--embed-metadata');
}
}
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;
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);
}
if (USE_RATE_LIMIT && RATE_LIMIT) {
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_RATE_LIMIT && 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') {
args.push('--force-ipv4');
} else if (FORCE_INTERNET_PROTOCOL === 'ipv6') {
args.push('--force-ipv6');
}
}
if (USE_COOKIES) {
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && USE_COOKIES) {
if (IMPORT_COOKIES_FROM === 'browser' && COOKIES_BROWSER) {
args.push('--cookies-from-browser', COOKIES_BROWSER);
} else if (IMPORT_COOKIES_FROM === 'file' && COOKIES_FILE) {
args.push('--cookies', COOKIES_FILE);
}
}
let sponsorblockRemove = 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) {
sponsorblockRemove = resumeState?.sponsorblock_remove || (SPONSORBLOCK_REMOVE === 'custom' ? (
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;
if (USE_ARIA2 || resumeState?.use_aria2) {
if ((!USE_CUSTOM_COMMANDS && !resumeState?.custom_command) && (USE_ARIA2 || resumeState?.use_aria2)) {
useAria2 = 1;
args.push(
'--downloader', 'aria2c',
@@ -355,8 +393,8 @@ 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).`);
}
if (resumeState || USE_ARIA2) {
if (resumeState || (!USE_CUSTOM_COMMANDS && USE_ARIA2)) {
args.push('--continue');
} else {
args.push('--no-continue');
@@ -380,7 +418,7 @@ export default function App({ children }: { children: React.ReactNode }) {
await fs.copyFile(tempDownloadPath, downloadFilePath);
await fs.remove(tempDownloadPath);
}
downloadFilePathUpdater.mutate({ download_id: downloadId, filepath: downloadFilePath }, {
onSuccess: (data) => {
console.log("Download filepath updated successfully:", data);
@@ -390,7 +428,7 @@ export default function App({ children }: { children: React.ReactNode }) {
console.error("Failed to update download filepath:", error);
}
})
downloadStatusUpdater.mutate({ download_id: downloadId, download_status: 'completed' }, {
onSuccess: (data) => {
console.log("Download status updated successfully:", data);
@@ -459,7 +497,8 @@ export default function App({ children }: { children: React.ReactNode }) {
embed_thumbnail: embedThumbnail,
sponsorblock_remove: sponsorblockRemove,
sponsorblock_mark: sponsorblockMark,
use_aria2: useAria2
use_aria2: useAria2,
custom_command: customCommandArgs,
};
downloadStateSaver.mutate(state, {
onSuccess: (data) => {
@@ -551,7 +590,8 @@ export default function App({ children }: { children: React.ReactNode }) {
embed_thumbnail: resumeState?.embed_thumbnail || 0,
sponsorblock_remove: resumeState?.sponsorblock_remove || 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, {
onSuccess: (data) => {
@@ -584,7 +624,7 @@ export default function App({ children }: { children: React.ReactNode }) {
throw e;
}
};
const pauseDownload = async (downloadState: DownloadState) => {
try {
LOG.info('NEODLP', `Pausing yt-dlp download with id: ${downloadState.download_id} (as per user request)`);
@@ -597,7 +637,7 @@ export default function App({ children }: { children: React.ReactNode }) {
onSuccess: (data) => {
console.log("Download status updated successfully:", data);
queryClient.invalidateQueries({ queryKey: ['download-states'] });
/* re-check if the download is properly paused (if not try again after a small delay)
as the pause opertion happens within high throughput of operations and have a high chgance of failure.
*/
@@ -636,13 +676,19 @@ export default function App({ children }: { children: React.ReactNode }) {
throw e;
}
};
const resumeDownload = async (downloadState: DownloadState) => {
try {
LOG.info('NEODLP', `Resuming yt-dlp download with id: ${downloadState.download_id} (as per user request)`);
await startDownload(
downloadState.playlist_id && downloadState.playlist_index ? downloadState.playlist_url : downloadState.url,
downloadState.format_id,
{
output_format: null,
embed_metadata: null,
embed_thumbnail: null,
custom_command: null
},
downloadState.subtitle_id,
downloadState
);
@@ -668,7 +714,7 @@ export default function App({ children }: { children: React.ReactNode }) {
queryClient.invalidateQueries({ queryKey: ['download-states'] });
// Reset processing flag and trigger queue processing
isProcessingQueueRef.current = false;
// Process the queue after a short delay
setTimeout(() => {
processQueuedDownloads();
@@ -702,36 +748,36 @@ export default function App({ children }: { children: React.ReactNode }) {
try {
isProcessingQueueRef.current = true;
console.log("Processing download queue...");
// Get the first download in queue
const downloadToStart = queuedDownloads[0];
// Skip if we just processed this download to prevent loops
if (lastProcessedDownloadIdRef.current === downloadToStart.download_id) {
console.log("Skipping recently processed download:", downloadToStart.download_id);
return;
}
// Double-check current state from global state
const currentState = globalDownloadStates.find(
state => state.download_id === downloadToStart.download_id
);
if (!currentState || currentState.download_status !== 'queued') {
console.log("Download no longer in queued state:", downloadToStart.download_id);
return;
}
console.log("Starting queued download:", downloadToStart.download_id);
LOG.info('NEODLP', `Starting queued download with id: ${downloadToStart.download_id}`);
lastProcessedDownloadIdRef.current = downloadToStart.download_id;
// Update status to 'starting' first
await downloadStatusUpdater.mutateAsync({
download_id: downloadToStart.download_id,
download_status: 'starting'
});
// Fetch latest state after status update
await queryClient.invalidateQueries({ queryKey: ['download-states'] });
@@ -739,10 +785,11 @@ export default function App({ children }: { children: React.ReactNode }) {
await startDownload(
downloadToStart.url,
downloadToStart.format_id,
downloadConfiguration,
downloadToStart.subtitle_id,
downloadToStart
);
} catch (error) {
console.error("Error processing download queue:", error);
LOG.error('NEODLP', `Error processing download queue: ${error}`);
@@ -768,7 +815,7 @@ export default function App({ children }: { children: React.ReactNode }) {
console.error("Error checking for app update:", error);
});
}, []);
// Prevent app from closing
useEffect(() => {
const handleCloseRequested = (event: any) => {
@@ -845,7 +892,7 @@ export default function App({ children }: { children: React.ReactNode }) {
setIsKvPairsStatePropagated(true);
}
}, [kvPairs, isSuccessFetchingKvPairs]);
// Initiate/Resolve base app paths
useEffect(() => {
const initPaths = async () => {
@@ -855,13 +902,13 @@ export default function App({ children }: { children: React.ReactNode }) {
const downloadDirPath = await downloadDir();
const tempDirPath = await tempDir();
const resourceDirPath = await resourceDir();
const ffmpegPath = await join(resourceDirPath, 'binaries', `ffmpeg-${currentArch}${currentExeExtension ? '.' + currentExeExtension : ''}`);
const tempDownloadDirPath = await join(tempDirPath, config.appPkgName, 'downloads');
const appDownloadDirPath = await join(downloadDirPath, config.appName);
if(!await fs.exists(tempDownloadDirPath)) fs.mkdir(tempDownloadDirPath, { recursive: true }).then(() => { console.log(`Created DIR: ${tempDownloadDirPath}`) });
setPath('ffmpegPath', ffmpegPath);
setPath('tempDownloadDirPath', tempDownloadDirPath);
if (DOWNLOAD_DIR) {
@@ -982,7 +1029,7 @@ export default function App({ children }: { children: React.ReactNode }) {
});
}
}, [isSettingsStatePropagated, isKvPairsStatePropagated]);
useEffect(() => {
if (isSuccessFetchingDownloadStates && downloadStates) {
console.log("Download States fetched successfully:", downloadStates);
@@ -996,7 +1043,7 @@ export default function App({ children }: { children: React.ReactNode }) {
const timeoutId = setTimeout(() => {
processQueuedDownloads();
}, 500);
// Cleanup timeout if component unmounts or dependencies change
return () => clearTimeout(timeoutId);
}, [processQueuedDownloads, ongoingDownloads, queuedDownloads]);
@@ -1046,4 +1093,4 @@ export default function App({ children }: { children: React.ReactNode }) {
</ThemeProvider>
</AppContext.Provider>
);
}
}

View File

@@ -10,7 +10,7 @@ import { useLogger } from "@/helpers/use-logger";
export default function Navbar() {
const location = useLocation();
const logs = useLogger().getLogs();
return (
<nav className="flex justify-between items-center py-3 px-4 sticky top-0 backdrop-blur supports-[backdrop-filter]:bg-background/60 border-b z-50">
<div className="flex justify-center">
@@ -34,7 +34,7 @@ export default function Navbar() {
<DialogContent className="sm:max-w-[600px]">
<DialogHeader>
<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>
<div className="flex flex-col gap-2 p-2 max-h-[300px] overflow-y-scroll overflow-x-hidden bg-muted">
{logs.length === 0 ? (

View File

@@ -9,7 +9,7 @@ import { toast } from "sonner";
import { useAppContext } from "@/providers/appContextProvider";
import { useCurrentVideoMetadataStore, useDownloaderPageStatesStore, useSettingsPageStatesStore } from "@/services/store";
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 { useEffect, useRef } from "react";
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 { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
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({
url: z.url({
@@ -35,7 +40,7 @@ const searchFormSchema = z.object({
export default function DownloaderPage() {
const { fetchVideoMetadata, startDownload } = useAppContext();
const videoUrl = useCurrentVideoMetadataStore((state) => state.videoUrl);
const videoMetadata = useCurrentVideoMetadataStore((state) => state.videoMetadata);
const isMetadataLoading = useCurrentVideoMetadataStore((state) => state.isMetadataLoading);
@@ -51,23 +56,33 @@ export default function DownloaderPage() {
const setShowSearchError = useCurrentVideoMetadataStore((state) => state.setShowSearchError);
const activeDownloadModeTab = useDownloaderPageStatesStore((state) => state.activeDownloadModeTab);
const activeDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.activeDownloadConfigurationTab);
const isStartingDownload = useDownloaderPageStatesStore((state) => state.isStartingDownload);
const selectedDownloadFormat = useDownloaderPageStatesStore((state) => state.selectedDownloadFormat);
const selectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableVideoFormat);
const selectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.selectedCombinableAudioFormat);
const selectedSubtitles = useDownloaderPageStatesStore((state) => state.selectedSubtitles);
const selectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.selectedPlaylistVideoIndex);
const downloadConfiguration = useDownloaderPageStatesStore((state) => state.downloadConfiguration);
const setActiveDownloadModeTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadModeTab);
const setActiveDownloadConfigurationTab = useDownloaderPageStatesStore((state) => state.setActiveDownloadConfigurationTab);
const setIsStartingDownload = useDownloaderPageStatesStore((state) => state.setIsStartingDownload);
const setSelectedDownloadFormat = useDownloaderPageStatesStore((state) => state.setSelectedDownloadFormat);
const setSelectedCombinableVideoFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableVideoFormat);
const setSelectedCombinableAudioFormat = useDownloaderPageStatesStore((state) => state.setSelectedCombinableAudioFormat);
const setSelectedSubtitles = useDownloaderPageStatesStore((state) => state.setSelectedSubtitles);
const setSelectedPlaylistVideoIndex = useDownloaderPageStatesStore((state) => state.setSelectedPlaylistVideoIndex);
const setDownloadConfigurationKey = useDownloaderPageStatesStore((state) => state.setDownloadConfigurationKey);
const resetDownloadConfiguration = useDownloaderPageStatesStore((state) => state.resetDownloadConfiguration);
const videoFormat = useSettingsPageStatesStore(state => state.settings.video_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 videoOnlyFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video'))) : [];
const combinedFormats = videoMetadata?._type === 'video' ? sortByBitrate(videoMetadata?.formats.filter(fileFormatFilter('video+audio'))) : videoMetadata?._type === 'playlist' ? sortByBitrate(videoMetadata?.entries[Number(selectedPlaylistVideoIndex) - 1].formats.filter(fileFormatFilter('video+audio'))) : [];
@@ -146,7 +161,10 @@ export default function DownloaderPage() {
let selectedFormatExtensionMsg = 'Auto - unknown';
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()}`;
}
else if (selectedAudioFormat?.ext && selectedVideoFormat?.ext) {
@@ -155,10 +173,12 @@ export default function DownloaderPage() {
selectedFormatExtensionMsg = `Combined - unknown`;
}
} else if (selectedFormat?.ext) {
if ((selectedFormatFileType === 'video+audio' || selectedFormatFileType === 'video') && videoFormat !== 'auto') {
selectedFormatExtensionMsg = `Forced - ${videoFormat.toUpperCase()}`;
} else if (selectedFormatFileType === 'audio' && audioFormat !== 'auto') {
selectedFormatExtensionMsg = `Forced - ${audioFormat.toUpperCase()}`;
if ((selectedFormatFileType === 'video+audio' || selectedFormatFileType === 'video') && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || videoFormat !== 'auto')) {
selectedFormatExtensionMsg = `Forced - ${(downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') ? downloadConfiguration.output_format.toUpperCase() : videoFormat.toUpperCase()}`;
} else if (selectedFormatFileType === 'audio' && ((downloadConfiguration.output_format && downloadConfiguration.output_format !== 'auto') || audioFormat !== 'auto')) {
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 {
selectedFormatExtensionMsg = `Auto - ${selectedFormat.ext.toUpperCase()}`;
}
@@ -220,6 +240,7 @@ export default function DownloaderPage() {
setSelectedCombinableAudioFormat('');
setSelectedSubtitles([]);
setSelectedPlaylistVideoIndex('1');
resetDownloadConfiguration();
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)) {
@@ -270,6 +291,10 @@ export default function DownloaderPage() {
};
}, []);
useEffect(() => {
useCustomCommands ? setActiveDownloadConfigurationTab('commands') : setActiveDownloadConfigurationTab('options');
}, []);
useEffect(() => {
if (watchedUrl !== videoUrl) {
setVideoUrl(watchedUrl);
@@ -283,18 +308,18 @@ export default function DownloaderPage() {
searchForm.setValue("url", requestedUrl);
setVideoUrl(requestedUrl);
}
// Auto-submit the form if the flag is set
if (autoSubmitSearch && requestedUrl) {
if (!isMetadataLoading) {
// trigger a validation check on the URL field first then get the result
await searchForm.trigger("url");
const isValidUrl = !searchForm.getFieldState("url").invalid;
if (isValidUrl) {
// Reset the flag first to prevent loops
setAutoSubmitSearch(false);
// Submit the form with a small delay to ensure UI is ready
setTimeout(() => {
handleSearchSubmit({ url: requestedUrl });
@@ -435,7 +460,12 @@ export default function DownloaderPage() {
<Tabs
className=""
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">
<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') {
// setSelectedSubtitles([]);
// }
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}}
>
<p className="text-xs">Suggested</p>
@@ -581,6 +614,9 @@ export default function DownloaderPage() {
value={selectedCombinableAudioFormat}
onValueChange={(value) => {
setSelectedCombinableAudioFormat(value);
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}}
>
{videoOnlyFormats && videoOnlyFormats.length > 0 && audioOnlyFormats && audioOnlyFormats.length > 0 && (
@@ -602,6 +638,9 @@ export default function DownloaderPage() {
value={selectedCombinableVideoFormat}
onValueChange={(value) => {
setSelectedCombinableVideoFormat(value);
setDownloadConfigurationKey('output_format', null);
setDownloadConfigurationKey('embed_metadata', null);
setDownloadConfigurationKey('embed_thumbnail', null);
}}
>
{audioOnlyFormats && audioOnlyFormats.length > 0 && videoOnlyFormats && videoOnlyFormats.length > 0 && (
@@ -653,7 +692,7 @@ export default function DownloaderPage() {
>
{videoMetadata.entries.map((entry) => entry ? (
<PlaylistToggleGroupItem
key={entry.playlist_index}
key={entry.playlist_index}
value={entry.playlist_index.toString()}
video={entry}
/>
@@ -668,6 +707,7 @@ export default function DownloaderPage() {
setSelectedSubtitles([]);
setSelectedCombinableVideoFormat('');
setSelectedCombinableAudioFormat('');
resetDownloadConfiguration();
}}
>
{videoMetadata.entries.map((entry) => entry ? (
@@ -909,51 +949,246 @@ export default function DownloaderPage() {
<span className="text-xs text-muted-foreground">{selectedFormatFinalMsg}</span>
</div>
</div>
<Button
onClick={async () => {
setIsStartingDownload(true);
try {
if (videoMetadata._type === 'playlist') {
await startDownload(
videoMetadata.original_url,
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat,
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
undefined,
selectedPlaylistVideoIndex
);
} else if (videoMetadata._type === 'video') {
await startDownload(
videoMetadata.webpage_url,
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat,
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null
);
<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
onClick={async () => {
setIsStartingDownload(true);
try {
if (videoMetadata._type === 'playlist') {
await startDownload(
videoMetadata.original_url,
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.entries[Number(selectedPlaylistVideoIndex) - 1].requested_downloads[0].format_id : selectedDownloadFormat,
downloadConfiguration,
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null,
undefined,
selectedPlaylistVideoIndex
);
} else if (videoMetadata._type === 'video') {
await startDownload(
videoMetadata.webpage_url,
activeDownloadModeTab === 'combine' ? `${selectedCombinableVideoFormat}+${selectedCombinableAudioFormat}` : selectedDownloadFormat === 'best' ? videoMetadata.requested_downloads[0].format_id : selectedDownloadFormat,
downloadConfiguration,
selectedSubtitles.length > 0 ? selectedSubtitles.join(',') : null
);
}
// toast({
// title: 'Download Initiated',
// description: 'Download initiated, it will start shortly.',
// });
} catch (error) {
console.error('Download failed to start:', error);
toast.error("Failed to Start Download", {
description: "There was an error initiating the download."
});
} finally {
setIsStartingDownload(false);
}
// toast({
// title: 'Download Initiated',
// description: 'Download initiated, it will start shortly.',
// });
} catch (error) {
console.error('Download failed to start:', error);
toast.error("Failed to Start Download", {
description: "There was an error initiating the download."
});
} finally {
setIsStartingDownload(false);
}
}}
disabled={isStartingDownload || !selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !selectedCombinableAudioFormat))}
>
{isStartingDownload ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting Download
</>
) : (
'Start Download'
)}
</Button>
}}
disabled={isStartingDownload || !selectedDownloadFormat || (activeDownloadModeTab === 'combine' && (!selectedCombinableVideoFormat || !selectedCombinableAudioFormat)) || (useCustomCommands && !downloadConfiguration.custom_command)}
>
{isStartingDownload ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Starting Download
</>
) : (
'Start Download'
)}
</Button>
</div>
</div>
)}
</div>
);
}
}

View File

@@ -1,13 +1,13 @@
import Heading from "@/components/heading";
import { Card } from "@/components/ui/card";
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 { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { Button } from "@/components/ui/button";
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 { useEffect } from "react";
import { useTheme } from "@/providers/themeProvider";
@@ -26,8 +26,10 @@ import { SlidingButton } from "@/components/custom/slidingButton";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import * as fs from "@tauri-apps/plugin-fs";
import { join } from "@tauri-apps/api/path";
import { formatSpeed } from "@/utils";
import { formatSpeed, generateID } from "@/utils";
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({
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() {
const { setTheme } = useTheme();
@@ -109,15 +116,20 @@ export default function SettingsPage() {
const useAria2 = useSettingsPageStatesStore(state => state.settings.use_aria2);
const useForceInternetProtocol = useSettingsPageStatesStore(state => state.settings.use_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 isChangingWebSocketPort = useSettingsPageStatesStore(state => state.isChangingWebSocketPort);
const setIsChangingWebSocketPort = useSettingsPageStatesStore(state => state.setIsChangingWebSocketPort);
const isRestartingWebSocketServer = useSettingsPageStatesStore(state => state.isRestartingWebSocketServer);
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 ongoingDownloads = downloadStates.filter(state =>
const ongoingDownloads = downloadStates.filter(state =>
['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 {
port: number;
}
@@ -422,9 +484,14 @@ export default function SettingsPage() {
value="sponsorblock"
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>
<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>
<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">
<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>
@@ -476,10 +543,11 @@ export default function SettingsPage() {
id="aria2"
checked={useAria2}
onCheckedChange={(checked) => saveSettingsKey('use_aria2', checked)}
disabled={useCustomCommands}
/>
</div>
</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">
<h3 className="font-semibold">Theme</h3>
<p className="text-xs text-muted-foreground mb-3">Choose app interface theme</p>
@@ -502,7 +570,7 @@ export default function SettingsPage() {
</div>
</div>
</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">
<h3 className="font-semibold">Download Folder</h3>
<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>
</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">
<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>
@@ -571,6 +639,7 @@ export default function SettingsPage() {
className="flex items-center gap-4"
value={videoFormat}
onValueChange={(value) => saveSettingsKey('video_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="v-auto" />
@@ -598,6 +667,7 @@ export default function SettingsPage() {
className="flex items-center gap-4"
value={audioFormat}
onValueChange={(value) => saveSettingsKey('audio_format', value)}
disabled={useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="auto" id="a-auto" />
@@ -624,10 +694,11 @@ export default function SettingsPage() {
id="always-reencode-video"
checked={alwaysReencodeVideo}
onCheckedChange={(checked) => saveSettingsKey('always_reencode_video', checked)}
disabled={useCustomCommands}
/>
</div>
</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">
<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>
@@ -636,6 +707,7 @@ export default function SettingsPage() {
id="embed-video-metadata"
checked={embedVideoMetadata}
onCheckedChange={(checked) => saveSettingsKey('embed_video_metadata', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="embed-video-metadata">Video</Label>
</div>
@@ -644,6 +716,7 @@ export default function SettingsPage() {
id="embed-audio-metadata"
checked={embedAudioMetadata}
onCheckedChange={(checked) => saveSettingsKey('embed_audio_metadata', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="embed-audio-metadata">Audio</Label>
</div>
@@ -655,10 +728,11 @@ export default function SettingsPage() {
id="embed-audio-thumbnail"
checked={embedAudioThumbnail}
onCheckedChange={(checked) => saveSettingsKey('embed_audio_thumbnail', checked)}
disabled={useCustomCommands}
/>
</div>
</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">
<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>
@@ -667,6 +741,7 @@ export default function SettingsPage() {
id="use-proxy"
checked={useProxy}
onCheckedChange={(checked) => saveSettingsKey('use_proxy', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="use-proxy">Use Proxy</Label>
</div>
@@ -682,10 +757,11 @@ export default function SettingsPage() {
<Input
className="focus-visible:ring-0"
placeholder="Enter proxy URL"
readOnly={useCustomCommands}
{...field}
/>
</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 />
</FormItem>
)}
@@ -707,6 +783,7 @@ export default function SettingsPage() {
id="use-rate-limit"
checked={useRateLimit}
onCheckedChange={(checked) => saveSettingsKey('use_rate_limit', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="use-rate-limit">Use Rate Limit</Label>
</div>
@@ -722,10 +799,11 @@ export default function SettingsPage() {
<Input
className="focus-visible:ring-0"
placeholder="Enter rate limit in bytes/s"
readOnly={useCustomCommands}
{...field}
/>
</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 />
</FormItem>
)}
@@ -741,12 +819,13 @@ export default function SettingsPage() {
</div>
<div className="force-internet-protocol">
<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">
<Switch
id="use-force-internet-protocol"
checked={useForceInternetProtocol}
onCheckedChange={(checked) => saveSettingsKey('use_force_internet_protocol', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="use-force-internet-protocol">Force</Label>
</div>
@@ -755,7 +834,7 @@ export default function SettingsPage() {
className="flex items-center gap-4 mb-2"
value={forceInternetProtocol}
onValueChange={(value) => saveSettingsKey('force_internet_protocol', value)}
disabled={!useForceInternetProtocol}
disabled={!useForceInternetProtocol || useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="ipv4" id="force-ipv4" />
@@ -766,10 +845,10 @@ export default function SettingsPage() {
<Label htmlFor="force-ipv6">Use IPv6 Only</Label>
</div>
</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>
</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">
<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>
@@ -778,6 +857,7 @@ export default function SettingsPage() {
id="use-cookies"
checked={useCookies}
onCheckedChange={(checked) => saveSettingsKey('use_cookies', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="use-cookies">Use Cookies</Label>
</div>
@@ -786,7 +866,7 @@ export default function SettingsPage() {
className="flex items-center gap-4"
value={importCookiesFrom}
onValueChange={(value) => saveSettingsKey('import_cookies_from', value)}
disabled={!useCookies}
disabled={!useCookies || useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="browser" id="cookies-browser" />
@@ -802,7 +882,7 @@ export default function SettingsPage() {
<Select
value={cookiesBrowser}
onValueChange={(value) => saveSettingsKey('cookies_browser', value)}
disabled={importCookiesFrom !== "browser" || !useCookies}
disabled={importCookiesFrom !== "browser" || !useCookies || useCustomCommands}
>
<SelectTrigger className="w-[230px] ring-0 focus:ring-0">
<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/>
<Button
variant="outline"
disabled={importCookiesFrom !== "file" || !useCookies}
disabled={importCookiesFrom !== "file" || !useCookies || useCustomCommands}
onClick={async () => {
try {
const file = await open({
@@ -854,10 +934,10 @@ export default function SettingsPage() {
</Button>
</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>
</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">
<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>
@@ -866,6 +946,7 @@ export default function SettingsPage() {
id="use-sponsorblock"
checked={useSponsorblock}
onCheckedChange={(checked) => saveSettingsKey('use_sponsorblock', checked)}
disabled={useCustomCommands}
/>
<Label htmlFor="use-sponsorblock">Use Sponsorblock</Label>
</div>
@@ -874,7 +955,7 @@ export default function SettingsPage() {
className="flex items-center gap-4"
value={sponsorblockMode}
onValueChange={(value) => saveSettingsKey('sponsorblock_mode', value)}
disabled={!useSponsorblock}
disabled={!useSponsorblock || useCustomCommands}
>
<div className="flex items-center gap-3">
<RadioGroupItem value="remove" id="sponsorblock-remove" />
@@ -892,7 +973,7 @@ export default function SettingsPage() {
className="flex items-center gap-4"
value={sponsorblockRemove}
onValueChange={(value) => saveSettingsKey('sponsorblock_remove', value)}
disabled={!useSponsorblock || sponsorblockMode !== "remove"}
disabled={!useSponsorblock || sponsorblockMode !== "remove" || useCustomCommands}
>
<div className="flex items-center gap-3">
<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"
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)}
disabled={!useSponsorblock || sponsorblockMode !== "remove" || sponsorblockRemove !== "custom"}
disabled={!useSponsorblock || sponsorblockMode !== "remove" || sponsorblockRemove !== "custom" || useCustomCommands}
>
<div className="flex gap-2 flex-wrap items-center">
{sponsorblockCategories.map((category) => (
@@ -939,7 +1020,7 @@ export default function SettingsPage() {
className="flex items-center gap-4"
value={sponsorblockMark}
onValueChange={(value) => saveSettingsKey('sponsorblock_mark', value)}
disabled={!useSponsorblock || sponsorblockMode !== "mark"}
disabled={!useSponsorblock || sponsorblockMode !== "mark" || useCustomCommands}
>
<div className="flex items-center gap-3">
<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"
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)}
disabled={!useSponsorblock || sponsorblockMode !== "mark" || sponsorblockMark !== "custom"}
disabled={!useSponsorblock || sponsorblockMode !== "mark" || sponsorblockMark !== "custom" || useCustomCommands}
>
<div className="flex gap-2 flex-wrap items-center">
{sponsorblockCategories.map((category) => (
@@ -977,7 +1058,109 @@ export default function SettingsPage() {
</div>
</ToggleGroup>
</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>
</TabsContent>
</div>

View File

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

View File

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

View File

@@ -15,7 +15,7 @@ export const useDownloadStatesStore = create<DownloadStatesStore>((set) => ({
const existingIndex = prev.downloadStates.findIndex(
item => item.download_id === state.download_id
);
if (existingIndex !== -1) {
// Update existing state
const updatedStates = [...prev.downloadStates];
@@ -47,22 +47,45 @@ export const useCurrentVideoMetadataStore = create<CurrentVideoMetadataStore>((s
export const useDownloaderPageStatesStore = create<DownloaderPageStatesStore>((set) => ({
activeDownloadModeTab: 'selective',
activeDownloadConfigurationTab: 'options',
isStartingDownload: false,
selectedDownloadFormat: 'best',
selectedCombinableVideoFormat: '',
selectedCombinableAudioFormat: '',
selectedSubtitles: [],
selectedPlaylistVideoIndex: '1',
downloadConfiguration: {
output_format: null,
embed_metadata: null,
embed_thumbnail: null,
custom_command: null
},
isErrored: false,
isErrorExpected: false,
erroredDownloadId: null,
setActiveDownloadModeTab: (tab) => set(() => ({ activeDownloadModeTab: tab })),
setActiveDownloadConfigurationTab: (tab) => set(() => ({ activeDownloadConfigurationTab: tab })),
setIsStartingDownload: (isStarting) => set(() => ({ isStartingDownload: isStarting })),
setSelectedDownloadFormat: (format) => set(() => ({ selectedDownloadFormat: format })),
setSelectedCombinableVideoFormat: (format) => set(() => ({ selectedCombinableVideoFormat: format })),
setSelectedCombinableAudioFormat: (format) => set(() => ({ selectedCombinableAudioFormat: format })),
setSelectedSubtitles: (subtitles) => set(() => ({ selectedSubtitles: subtitles })),
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 })),
setIsErrorExpected: (isErrorExpected) => set(() => ({ isErrorExpected: isErrorExpected })),
setErroredDownloadId: (downloadId) => set(() => ({ erroredDownloadId: downloadId })),
@@ -154,6 +177,8 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
use_aria2: false,
use_force_internet_protocol: false,
force_internet_protocol: 'ipv4',
use_custom_commands: false,
custom_commands: [],
// extension settings
websocket_port: 53511
},
@@ -212,6 +237,8 @@ export const useSettingsPageStatesStore = create<SettingsPageStatesStore>((set)
use_aria2: false,
use_force_internet_protocol: false,
force_internet_protocol: 'ipv4',
use_custom_commands: false,
custom_commands: [],
// extension settings
websocket_port: 53511
},
@@ -245,4 +272,4 @@ export const useLogsStore = create<LogsStore>((set) => ({
setLogs: (logs) => set(() => ({ logs })),
addLog: (log) => set((state) => ({ logs: [...state.logs, log] })),
clearLogs: () => set(() => ({ logs: [] }))
}));
}));

View File

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

View File

@@ -3,6 +3,12 @@ export interface SettingsTable {
value: string;
}
export interface CustomCommand {
id: string;
label: string;
args: string;
}
export interface Settings {
ytdlp_update_channel: string;
ytdlp_auto_update: boolean;
@@ -35,6 +41,15 @@ export interface Settings {
use_aria2: boolean;
use_force_internet_protocol: boolean;
force_internet_protocol: string;
use_custom_commands: boolean;
custom_commands: CustomCommand[];
// extension settings
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 { RawVideoInfo } from "@/types/video";
import { Settings } from "@/types/settings";
import { DownloadConfiguration, Settings } from "@/types/settings";
import { KvStore } from "@/types/kvStore";
import { Update } from "@tauri-apps/plugin-updater";
import { Log } from "@/types/logs";
@@ -37,22 +37,28 @@ export interface CurrentVideoMetadataStore {
export interface DownloaderPageStatesStore {
activeDownloadModeTab: string;
activeDownloadConfigurationTab: string;
isStartingDownload: boolean;
selectedDownloadFormat: string;
selectedCombinableVideoFormat: string;
selectedCombinableAudioFormat: string;
selectedSubtitles: string[];
selectedPlaylistVideoIndex: string;
downloadConfiguration: DownloadConfiguration;
isErrored: boolean;
isErrorExpected: boolean;
erroredDownloadId: string | null;
setActiveDownloadModeTab: (tab: string) => void;
setActiveDownloadConfigurationTab: (tab: string) => void;
setIsStartingDownload: (isStarting: boolean) => void;
setSelectedDownloadFormat: (format: string) => void;
setSelectedCombinableVideoFormat: (format: string) => void;
setSelectedCombinableAudioFormat: (format: string) => void;
setSelectedSubtitles: (subtitles: string[]) => void;
setSelectedPlaylistVideoIndex: (index: string) => void;
setDownloadConfigurationKey: (key: string, value: unknown) => void;
setDownloadConfiguration: (config: DownloadConfiguration) => void;
resetDownloadConfiguration: () => void;
setIsErrored: (isErrored: boolean) => void;
setIsErrorExpected: (isErrorExpected: boolean) => void;
setErroredDownloadId: (downloadId: string | null) => void;

View File

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