579 lines
20 KiB
TypeScript
579 lines
20 KiB
TypeScript
import iconRescan from '/img/icon-rescan.svg';
|
|
import iconDownload from '/img/icon-download.svg';
|
|
import iconAdd from '/img/icon-add.svg';
|
|
import iconSubstract from '/img/icon-substract.svg';
|
|
import iconGridView from '/img/icon-gridview.svg';
|
|
import iconListView from '/img/icon-listview.svg';
|
|
import { Fragment, useEffect, useState } from 'react';
|
|
import { useOutletContext, useSearchParams } from 'react-router-dom';
|
|
import { ConfigType } from './Home';
|
|
import loadDownloadQueue from '../api/loader/loadDownloadQueue';
|
|
import { OutletContextType } from './Base';
|
|
import Pagination, { PaginationType } from '../components/Pagination';
|
|
import { ViewStylesEnum, ViewStylesType } from '../configuration/constants/ViewStyle';
|
|
import updateDownloadQueue from '../api/actions/updateDownloadQueue';
|
|
import updateTaskByName from '../api/actions/updateTaskByName';
|
|
import Notifications from '../components/Notifications';
|
|
import ScrollToTopOnNavigate from '../components/ScrollToTop';
|
|
import Button from '../components/Button';
|
|
import DownloadListItem from '../components/DownloadListItem';
|
|
import loadDownloadAggs, { DownloadAggsType } from '../api/loader/loadDownloadAggs';
|
|
import { useUserConfigStore } from '../stores/UserConfigStore';
|
|
import updateUserConfig, { UserConfigType } from '../api/actions/updateUserConfig';
|
|
import { ApiResponseType } from '../functions/APIClient';
|
|
import deleteDownloadQueueByFilter from '../api/actions/deleteDownloadQueueByFilter';
|
|
import updateDownloadQueueByFilter, {
|
|
DownloadQueueStatus,
|
|
} from '../api/actions/updateDownloadQueueByFilter';
|
|
|
|
type Download = {
|
|
auto_start: boolean;
|
|
channel_id: string;
|
|
channel_indexed: boolean;
|
|
channel_name: string;
|
|
duration: string;
|
|
message?: string;
|
|
published: string | null;
|
|
status: string;
|
|
timestamp: number | null;
|
|
title: string;
|
|
vid_thumb_url: string;
|
|
vid_type: string;
|
|
youtube_id: string;
|
|
_index: string;
|
|
_score: number;
|
|
};
|
|
|
|
export type DownloadResponseType = {
|
|
data?: Download[];
|
|
config?: ConfigType;
|
|
paginate?: PaginationType;
|
|
};
|
|
|
|
const Download = () => {
|
|
const [searchParams, setSearchParams] = useSearchParams();
|
|
const { userConfig, setUserConfig } = useUserConfigStore();
|
|
const { currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
|
|
|
|
const channelFilterFromUrl = searchParams.get('channel');
|
|
const ignoredOnlyParam = searchParams.get('ignored');
|
|
const vidTypeFilterFromUrl = searchParams.get('vid-type');
|
|
const errorFilterFromUrl = searchParams.get('error');
|
|
|
|
const [refreshNonce, setRefreshNonce] = useState(0);
|
|
const [showHiddenForm, setShowHiddenForm] = useState(false);
|
|
const [addAsAutoStart, setAddAsAutoStart] = useState(false);
|
|
const [addAsFlat, setAddAsFlat] = useState(false);
|
|
const [addAsForce, setAddAsForce] = useState(false);
|
|
const [showQueueActions, setShowQueueActions] = useState(false);
|
|
const [searchInput, setSearchInput] = useState('');
|
|
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
|
const [downloadPending, setDownloadPending] = useState(false);
|
|
const [rescanPending, setRescanPending] = useState(false);
|
|
|
|
const [lastVideoCount, setLastVideoCount] = useState(0);
|
|
|
|
const [downloadQueueText, setDownloadQueueText] = useState('');
|
|
|
|
const [downloadResponse, setDownloadResponse] = useState<ApiResponseType<DownloadResponseType>>();
|
|
const [downloadAggsResponse, setDownloadAggsResponse] =
|
|
useState<ApiResponseType<DownloadAggsType>>();
|
|
|
|
const { data: downloadResponseData } = downloadResponse ?? {};
|
|
const { data: downloadAggsResponseData } = downloadAggsResponse ?? {};
|
|
|
|
const downloadList = downloadResponseData?.data;
|
|
const pagination = downloadResponseData?.paginate;
|
|
const channelAggsList = downloadAggsResponseData?.buckets;
|
|
|
|
const downloadCount = pagination?.total_hits;
|
|
|
|
const channel_filter_name =
|
|
downloadResponseData?.data?.length && downloadResponseData?.data?.length > 0
|
|
? downloadResponseData?.data[0].channel_name
|
|
: '';
|
|
|
|
const viewStyle = userConfig.view_style_downloads;
|
|
const gridItems = userConfig.grid_items;
|
|
const showIgnored =
|
|
ignoredOnlyParam !== null ? ignoredOnlyParam === 'true' : userConfig.show_ignored_only;
|
|
|
|
const showIgnoredFilter = showIgnored ? 'ignore' : 'pending';
|
|
|
|
const isGridView = viewStyle === ViewStylesEnum.Grid;
|
|
const gridView = isGridView ? `boxed-${gridItems}` : '';
|
|
const gridViewGrid = isGridView ? `grid-${gridItems}` : '';
|
|
|
|
const handleUserConfigUpdate = async (config: Partial<UserConfigType>) => {
|
|
const updatedUserConfig = await updateUserConfig(config);
|
|
const { data: updatedUserConfigData } = updatedUserConfig;
|
|
|
|
if (updatedUserConfigData) {
|
|
setUserConfig(updatedUserConfigData);
|
|
}
|
|
};
|
|
|
|
const refreshDownloadQueue = () => {
|
|
setRefreshNonce(current => current + 1);
|
|
};
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
const videosResponse = await loadDownloadQueue(
|
|
currentPage,
|
|
channelFilterFromUrl,
|
|
vidTypeFilterFromUrl,
|
|
errorFilterFromUrl,
|
|
showIgnored,
|
|
searchInput,
|
|
);
|
|
const { data: channelResponseData } = videosResponse ?? {};
|
|
const videoCount = channelResponseData?.paginate?.total_hits;
|
|
|
|
if (videoCount && lastVideoCount !== videoCount) {
|
|
setLastVideoCount(videoCount);
|
|
}
|
|
|
|
setDownloadResponse(videosResponse);
|
|
})();
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [
|
|
channelFilterFromUrl,
|
|
vidTypeFilterFromUrl,
|
|
errorFilterFromUrl,
|
|
currentPage,
|
|
showIgnored,
|
|
searchInput,
|
|
refreshNonce,
|
|
]);
|
|
|
|
useEffect(() => {
|
|
(async () => {
|
|
const downloadAggs = await loadDownloadAggs(showIgnored);
|
|
|
|
setDownloadAggsResponse(downloadAggs);
|
|
})();
|
|
}, [lastVideoCount, showIgnored]);
|
|
|
|
const handleBulkStatusUpdate = async (status: DownloadQueueStatus) => {
|
|
await updateDownloadQueueByFilter(
|
|
showIgnoredFilter,
|
|
channelFilterFromUrl,
|
|
vidTypeFilterFromUrl,
|
|
errorFilterFromUrl,
|
|
status,
|
|
);
|
|
refreshDownloadQueue();
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<title>TA | Downloads</title>
|
|
<ScrollToTopOnNavigate />
|
|
<div className="boxed-content">
|
|
<div className="title-bar">
|
|
<h1>Downloads {channelFilterFromUrl && ` for ${channel_filter_name}`}</h1>
|
|
</div>
|
|
<Notifications
|
|
pageName="download"
|
|
update={rescanPending || downloadPending}
|
|
setShouldRefresh={isDone => {
|
|
if (!isDone) {
|
|
setRescanPending(false);
|
|
setDownloadPending(false);
|
|
refreshDownloadQueue();
|
|
}
|
|
}}
|
|
/>
|
|
<div id="downloadControl"></div>
|
|
<div className="info-box info-box-3">
|
|
<div className="icon-text">
|
|
<img
|
|
id="rescan-icon"
|
|
className={rescanPending ? 'rotate-img' : ''}
|
|
onClick={async () => {
|
|
setRescanPending(!rescanPending);
|
|
await updateTaskByName('update_subscribed');
|
|
}}
|
|
src={iconRescan}
|
|
alt="rescan-icon"
|
|
/>
|
|
<p>Rescan subscriptions</p>
|
|
</div>
|
|
<div className="icon-text">
|
|
<img
|
|
id="download-icon"
|
|
className={downloadPending ? 'bounce-img' : ''}
|
|
onClick={async () => {
|
|
setDownloadPending(!downloadPending);
|
|
await updateTaskByName('download_pending');
|
|
}}
|
|
src={iconDownload}
|
|
alt="download-icon"
|
|
/>
|
|
<p>Start download</p>
|
|
</div>
|
|
<div className="icon-text">
|
|
<img
|
|
className={showHiddenForm ? 'pulse-img' : ''}
|
|
onClick={() => {
|
|
setShowHiddenForm(!showHiddenForm);
|
|
}}
|
|
src={iconAdd}
|
|
alt="add-icon"
|
|
/>
|
|
<p>Add to download queue</p>
|
|
|
|
{showHiddenForm && (
|
|
<div className="show-form">
|
|
<div>
|
|
<div className="toggle">
|
|
<div className="toggleBox">
|
|
<input
|
|
id="hide_watched"
|
|
type="checkbox"
|
|
checked={addAsFlat}
|
|
onChange={() => setAddAsFlat(!addAsFlat)}
|
|
/>
|
|
{addAsFlat ? (
|
|
<label htmlFor="" className="onbtn">
|
|
On
|
|
</label>
|
|
) : (
|
|
<label htmlFor="" className="ofbtn">
|
|
Off
|
|
</label>
|
|
)}
|
|
</div>
|
|
<span>Fast add</span>
|
|
</div>
|
|
<div className="toggle">
|
|
<div className="toggleBox">
|
|
<input
|
|
id="add_force"
|
|
type="checkbox"
|
|
checked={addAsForce}
|
|
onChange={() => setAddAsForce(!addAsForce)}
|
|
/>
|
|
{addAsForce ? (
|
|
<label htmlFor="" className="onbtn">
|
|
On
|
|
</label>
|
|
) : (
|
|
<label htmlFor="" className="ofbtn">
|
|
Off
|
|
</label>
|
|
)}
|
|
</div>
|
|
<span>Re-Download</span>
|
|
</div>
|
|
<div className="toggle">
|
|
<div className="toggleBox">
|
|
<input
|
|
id="hide_watched"
|
|
type="checkbox"
|
|
checked={addAsAutoStart}
|
|
onChange={() => setAddAsAutoStart(!addAsAutoStart)}
|
|
/>
|
|
{addAsAutoStart ? (
|
|
<label htmlFor="" className="onbtn">
|
|
On
|
|
</label>
|
|
) : (
|
|
<label htmlFor="" className="ofbtn">
|
|
Off
|
|
</label>
|
|
)}
|
|
</div>
|
|
<span>Auto Download</span>
|
|
</div>
|
|
<textarea
|
|
value={downloadQueueText}
|
|
onChange={e => setDownloadQueueText(e.target.value)}
|
|
cols={40}
|
|
rows={4}
|
|
placeholder="Enter at least one video, channel or playlist id/URL here..."
|
|
/>
|
|
<Button
|
|
label="Add to queue"
|
|
onClick={async () => {
|
|
if (downloadQueueText.trim()) {
|
|
await updateDownloadQueue({
|
|
youtubeIdStrings: downloadQueueText,
|
|
autostart: addAsAutoStart,
|
|
flat: addAsFlat,
|
|
force: addAsForce,
|
|
});
|
|
setDownloadQueueText('');
|
|
refreshDownloadQueue();
|
|
setShowHiddenForm(false);
|
|
}
|
|
}}
|
|
/>{' '}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="view-controls three">
|
|
<div className="toggle">
|
|
<span>Show only ignored videos:</span>
|
|
<div className="toggleBox">
|
|
<input
|
|
id="showIgnored"
|
|
onChange={() => {
|
|
handleUserConfigUpdate({ show_ignored_only: !showIgnored });
|
|
const newParams = new URLSearchParams();
|
|
newParams.set('ignored', String(!showIgnored));
|
|
setSearchParams(newParams);
|
|
refreshDownloadQueue();
|
|
}}
|
|
type="checkbox"
|
|
checked={showIgnored}
|
|
/>
|
|
{!showIgnored && (
|
|
<label htmlFor="" className="ofbtn">
|
|
Off
|
|
</label>
|
|
)}
|
|
{showIgnored && (
|
|
<label htmlFor="" className="onbtn">
|
|
On
|
|
</label>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="view-icons">
|
|
<Button onClick={() => setShowQueueActions(!showQueueActions)}>
|
|
{showQueueActions ? 'Hide Advanced' : 'Show Advanced'}
|
|
</Button>
|
|
|
|
{isGridView && (
|
|
<div className="grid-count">
|
|
{gridItems < 7 && (
|
|
<img
|
|
src={iconAdd}
|
|
onClick={() => {
|
|
handleUserConfigUpdate({ grid_items: gridItems + 1 });
|
|
}}
|
|
alt="grid plus row"
|
|
/>
|
|
)}
|
|
{gridItems > 3 && (
|
|
<img
|
|
src={iconSubstract}
|
|
onClick={() => {
|
|
handleUserConfigUpdate({ grid_items: gridItems - 1 });
|
|
}}
|
|
alt="grid minus row"
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<img
|
|
src={iconGridView}
|
|
onClick={() => {
|
|
handleUserConfigUpdate({
|
|
view_style_downloads: ViewStylesEnum.Grid as ViewStylesType,
|
|
});
|
|
}}
|
|
alt="grid view"
|
|
/>
|
|
<img
|
|
src={iconListView}
|
|
onClick={() => {
|
|
handleUserConfigUpdate({
|
|
view_style_downloads: ViewStylesEnum.List as ViewStylesType,
|
|
});
|
|
}}
|
|
alt="list view"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<h3>
|
|
Total videos in queue: {downloadCount}
|
|
{downloadCount == 10000 && '+'}{' '}
|
|
{channelFilterFromUrl && (
|
|
<>
|
|
{' - from channel '}
|
|
<i>{channel_filter_name}</i>
|
|
</>
|
|
)}
|
|
{vidTypeFilterFromUrl && (
|
|
<>
|
|
{' - by type '}
|
|
<i>{vidTypeFilterFromUrl}</i>
|
|
</>
|
|
)}
|
|
</h3>
|
|
{showQueueActions && (
|
|
<div className="settings-group">
|
|
<h3>Search & Filter</h3>
|
|
<select
|
|
name="vid_type_filter"
|
|
id="vid_type_filter"
|
|
value={vidTypeFilterFromUrl || 'all'}
|
|
onChange={async event => {
|
|
const value = event.currentTarget.value;
|
|
const params = searchParams;
|
|
if (value !== 'all') {
|
|
params.set('vid-type', value);
|
|
} else {
|
|
params.delete('vid-type');
|
|
}
|
|
setSearchParams(params);
|
|
}}
|
|
>
|
|
<option value="all">all types</option>
|
|
<option value="videos">Videos</option>
|
|
<option value="streams">Streams</option>
|
|
<option value="shorts">Shorts</option>
|
|
</select>
|
|
{channelAggsList && channelAggsList.length > 1 && (
|
|
<select
|
|
name="channel_filter"
|
|
id="channel_filter"
|
|
value={channelFilterFromUrl || 'all'}
|
|
onChange={async event => {
|
|
const value = event.currentTarget.value;
|
|
|
|
const params = searchParams;
|
|
if (value !== 'all') {
|
|
params.set('channel', value);
|
|
} else {
|
|
params.delete('channel');
|
|
}
|
|
|
|
setSearchParams(params);
|
|
}}
|
|
>
|
|
<option value="all">all channels</option>
|
|
{channelAggsList.map(channel => {
|
|
const [name, id] = channel.key;
|
|
const count = channel.doc_count;
|
|
|
|
return (
|
|
<option key={id} value={id}>
|
|
{name} ({count})
|
|
</option>
|
|
);
|
|
})}
|
|
</select>
|
|
)}
|
|
<select
|
|
name="error_filter"
|
|
id="error_filter"
|
|
value={errorFilterFromUrl || 'all'}
|
|
onChange={async event => {
|
|
const value = event.currentTarget.value;
|
|
const params = searchParams;
|
|
if (value !== 'all') {
|
|
params.set('error', value);
|
|
} else {
|
|
params.delete('error');
|
|
}
|
|
setSearchParams(params);
|
|
}}
|
|
>
|
|
<option value="all">all error state</option>
|
|
<option value="true">has error</option>
|
|
<option value="false">has no error</option>
|
|
</select>
|
|
<input
|
|
type="text"
|
|
placeholder="Search..."
|
|
value={searchInput}
|
|
onChange={e => setSearchInput(e.target.value)}
|
|
/>
|
|
{searchInput && <Button onClick={() => setSearchInput('')}>Clear</Button>}
|
|
|
|
<h3>Bulk actions</h3>
|
|
<p>
|
|
Applied filtered by status <i>'{showIgnoredFilter}'</i>
|
|
{vidTypeFilterFromUrl && (
|
|
<span>
|
|
{' '}
|
|
and by type: <i>'{vidTypeFilterFromUrl}'</i>
|
|
</span>
|
|
)}
|
|
{channelFilterFromUrl && (
|
|
<span>
|
|
{' '}
|
|
and by channel: <i>'{channel_filter_name}'</i>
|
|
</span>
|
|
)}
|
|
{errorFilterFromUrl && (
|
|
<span>
|
|
{' '}
|
|
and by error state: <i>'{errorFilterFromUrl}'</i>
|
|
</span>
|
|
)}
|
|
</p>
|
|
<div>
|
|
{showIgnored ? (
|
|
<div className="button-box">
|
|
<Button onClick={() => handleBulkStatusUpdate('pending')}>Add to Queue</Button>
|
|
</div>
|
|
) : (
|
|
<div className="button-box">
|
|
<Button onClick={() => handleBulkStatusUpdate('ignore')}>Ignore</Button>
|
|
<Button onClick={() => handleBulkStatusUpdate('priority')}>Download Now</Button>
|
|
<Button onClick={() => handleBulkStatusUpdate('clear_error')}>
|
|
Clear Errors
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="button-box">
|
|
{showDeleteConfirm ? (
|
|
<>
|
|
<Button
|
|
className="danger-button"
|
|
onClick={async () => {
|
|
await deleteDownloadQueueByFilter(
|
|
showIgnoredFilter,
|
|
channelFilterFromUrl,
|
|
vidTypeFilterFromUrl,
|
|
);
|
|
refreshDownloadQueue();
|
|
setShowDeleteConfirm(false);
|
|
}}
|
|
>
|
|
Confirm
|
|
</Button>
|
|
<Button onClick={() => setShowDeleteConfirm(false)}>Cancel</Button>
|
|
</>
|
|
) : (
|
|
<Button onClick={() => setShowDeleteConfirm(!showDeleteConfirm)}>Forget</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className={`boxed-content ${gridView}`}>
|
|
<div className={`video-list ${viewStyle} ${gridViewGrid}`}>
|
|
{downloadList &&
|
|
downloadList?.map(download => {
|
|
return (
|
|
<Fragment
|
|
key={`${download.channel_id}_${download.timestamp}_${download.youtube_id}`}
|
|
>
|
|
<DownloadListItem download={download} setRefresh={refreshDownloadQueue} />
|
|
</Fragment>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="boxed-content">
|
|
{pagination && <Pagination pagination={pagination} setPage={setCurrentPage} />}
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default Download;
|