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

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

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

View File

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