mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
The library has been uninstalled for some time, but the codebase still read as though it were there: identifiers named after it, rules written against its class names, and about 180 comments explaining a rule in terms of a thing that is no longer installed. Renamed, keeping the vocabulary the app actually uses: semanticUIColorMap -> navColorHexMap, semanticColorNames -> paletteColorNames, semanticNames -> iconNameAliases, semanticSizes -> sizeAliases, and SemanticUIColors -> NavColors in wrolpi/schema.py, which is what validates nav_color on the way in. ColorToSemanticHexColor had no callers at all and is gone. The baseline ratchet has done its job -- the list reached zero, so the JSON is deleted and the guard keeps only the checks with teeth: no package, no import, no `className="ui "`, no toast library. Its Theme.tsx check went with the wrappers it was watching for. It also asserts its own walk found files, so a clean result cannot mean it scanned nothing. Deleted from App.css: every rule requiring `.ui`, `.inverted`, `.segment` or `.modal`, none of which any component has set since the stylesheet stopped loading, plus the form skeletons, ui-alerts and progress-text rules that nothing renders. Two of them were doing harm rather than nothing: the drag tints had a dark-theme variant under `.inverted` that stopped applying, leaving 15% of a fixed blue over a dark row, and now mix a token instead. Two selectors naming markup that no longer exists were live bugs. The file browser's filter had `.ui.input.file-browser-filter`, so it rendered at the browser default width on a bar laid out to give it 34em -- now qualified by its container, because `.wrolpi-action-input` sets `width: 100%` at the same specificity and is loaded later. And SearchBox takes named props rather than a rest spread, so the `id` DestinationForm hands it was dropped in silence and the label it renders pointed at nothing; the download forms' Destination field has had no associated label since the migration. "Semantic roles" survives in four places, where it means what it says. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1666 lines
60 KiB
JavaScript
1666 lines
60 KiB
JavaScript
import React, {useState} from "react";
|
|
import {fetchVideoDownloadDefaults, getDownloaders, postDownload, putDownload} from "../api";
|
|
import {
|
|
APIButton,
|
|
DirectorySearch,
|
|
InfoHeader,
|
|
InfoPopup,
|
|
mergeDeep,
|
|
RequiredAsterisk,
|
|
SimpleAccordion,
|
|
Toggle,
|
|
useLocalStorage,
|
|
validURL,
|
|
validURLs,
|
|
} from "./Common";
|
|
import {Button, Group, Header, Icon, Message, MultiSelect, NumberInput, Select, Stack, TextInput, Textarea} from "./ui";
|
|
import {Link} from "react-router";
|
|
import {TagsSelector} from "../Tags";
|
|
import {
|
|
commaSeparatedValidator,
|
|
useForm,
|
|
} from "../hooks/useForm";
|
|
import {
|
|
channelFrequencyOptions,
|
|
days30Option,
|
|
defaultAudioFormatOption,
|
|
defaultVideoFormatOption,
|
|
defaultVideoResolutionOptions,
|
|
downloadAudioFormatOptions,
|
|
Downloaders,
|
|
downloadFormatOptions,
|
|
downloadOrderOptions,
|
|
downloadResolutionOptions,
|
|
extendedFrequencyOptions,
|
|
frequencyOptions,
|
|
weeklyOption
|
|
} from "./Vars";
|
|
|
|
/*
|
|
* Download.js was built around the old <Form> family (Form.Input,
|
|
* FormDropdown, TextArea) via `hooks/useForm.js`'s JSX helpers (InputForm,
|
|
* NumberInputForm, ToggleForm, UrlInput, UrlsTextarea). `hooks/useForm.js` is
|
|
* off-limits for this migration and its JSX helpers still render through
|
|
* `./Theme`. Rather than use those helpers, the field
|
|
* components below call `useForm`'s plain data getters (getInputProps,
|
|
* getSelectionProps, getCustomProps) directly and render our own markup with
|
|
* the token-driven `./ui` library. See the migration report for what this
|
|
* leaves out of scope (hooks/useForm.js itself is unmigrated).
|
|
*/
|
|
|
|
function FieldLabel({htmlFor, label, required, helpContent, helpPosition = 'top'}) {
|
|
if (!label) return null;
|
|
return <label htmlFor={htmlFor} style={{display: 'block', marginBottom: 4}}>
|
|
<b>{label} {required && <RequiredAsterisk/>}</b>
|
|
{helpContent && <InfoPopup content={helpContent} position={helpPosition}/>}
|
|
</label>
|
|
}
|
|
|
|
/** Text field backed by `form.getInputProps`. TextInput passes a DOM event, which is exactly
|
|
* what `handleInputEvent` (inside useForm) expects, so the props can be spread through. */
|
|
function TextField({
|
|
form, name, path, type = 'text', validator, required = false,
|
|
placeholder = '', label, helpContent, helpPosition, disabled = false,
|
|
}) {
|
|
const [inputProps] = form.getInputProps({name, path, validator, type, required});
|
|
return <div>
|
|
<FieldLabel htmlFor={`${name}_input`} label={label} required={required}
|
|
helpContent={helpContent} helpPosition={helpPosition}/>
|
|
<TextInput
|
|
id={`${name}_input`}
|
|
name={name}
|
|
type={inputProps.type}
|
|
placeholder={placeholder}
|
|
value={inputProps.value ?? ''}
|
|
onChange={inputProps.onChange}
|
|
disabled={disabled || inputProps.disabled}
|
|
error={inputProps.error}
|
|
data-path={inputProps['data-path']}
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
/** Number field. Mantine's NumberInput hands back the value itself, not an event, so it is
|
|
* wrapped into the event shape `handleInputEvent` expects. */
|
|
function NumberField({
|
|
form, name, path, validator, required = false, placeholder = '',
|
|
label, helpContent, helpPosition, min = 1, max = 9999999999,
|
|
}) {
|
|
validator = validator || ((value) => {
|
|
value = typeof value === 'number' ? value : parseFloat(value);
|
|
if (value < 0) {
|
|
return 'Number must be positive';
|
|
}
|
|
});
|
|
const [inputProps] = form.getInputProps({name, path, validator, type: 'number', required});
|
|
const dataPath = inputProps['data-path'];
|
|
return <div>
|
|
<FieldLabel htmlFor={`${name}_input`} label={label} required={required}
|
|
helpContent={helpContent} helpPosition={helpPosition}/>
|
|
<NumberInput
|
|
id={`${name}_input`}
|
|
placeholder={placeholder}
|
|
min={min}
|
|
max={max}
|
|
value={inputProps.value ?? ''}
|
|
onChange={(value) => inputProps.onChange({target: {value, type: 'number', dataset: {path: dataPath}}})}
|
|
disabled={inputProps.disabled}
|
|
error={inputProps.error}
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
/** A single-select field. `options` are {key, text, value}; Mantine's Select
|
|
* requires string data values, so values are stringified for display and mapped back on change. */
|
|
function SelectField({form, name, path, label, options, required = false, placeholder, disabled = false, afterChange}) {
|
|
const [inputProps] = form.getSelectionProps({name, path, required, afterChange});
|
|
const data = options.map(o => ({value: String(o.value), label: o.text}));
|
|
const stringValue = (inputProps.value === null || inputProps.value === undefined) ? null : String(inputProps.value);
|
|
|
|
const handleChange = (val) => {
|
|
if (val === null || val === undefined) {
|
|
inputProps.onChange(null, {value: null});
|
|
return;
|
|
}
|
|
const match = options.find(o => String(o.value) === val);
|
|
inputProps.onChange(null, {value: match ? match.value : val});
|
|
};
|
|
|
|
return <div>
|
|
<FieldLabel htmlFor={`${name}_select`} label={label} required={required}/>
|
|
<Select
|
|
id={`${name}_select`}
|
|
data={data}
|
|
value={stringValue}
|
|
onChange={handleChange}
|
|
placeholder={placeholder}
|
|
disabled={disabled || inputProps.disabled}
|
|
error={inputProps.error}
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
/** A multi-select field (Video Resolutions). */
|
|
function MultiSelectField({form, name, path, options, onChange: afterChange}) {
|
|
const [inputProps] = form.getSelectionProps({name, path, type: 'array', afterChange});
|
|
const data = options.map(o => ({value: String(o.value), label: o.text}));
|
|
const values = (inputProps.value || []).map(String);
|
|
|
|
const handleChange = (vals) => {
|
|
const mapped = vals.map(v => {
|
|
const match = options.find(o => String(o.value) === v);
|
|
return match ? match.value : v;
|
|
});
|
|
inputProps.onChange(null, {value: mapped});
|
|
};
|
|
|
|
return <MultiSelect
|
|
data={data}
|
|
value={values}
|
|
onChange={handleChange}
|
|
error={inputProps.error}
|
|
/>
|
|
}
|
|
|
|
export function DepthInputForm({form, required, name = 'depth', path = 'settings.depth'}) {
|
|
return <NumberField
|
|
form={form}
|
|
required={required}
|
|
name={name}
|
|
path={path}
|
|
label='Depth'
|
|
max={4}
|
|
helpContent='Search the URLs provided, and any URLs they contain up to this depth. Warning: This can be exponential!'
|
|
/>
|
|
}
|
|
|
|
export function MaximumPagesInputForm({form, required, name = 'max_pages', path = 'settings.max_pages'}) {
|
|
return <NumberField
|
|
form={form}
|
|
required={required}
|
|
name={name}
|
|
path={path}
|
|
label='Maximum Pages'
|
|
max={100000}
|
|
helpContent='Stop searching for files if this many pages have been searched.'
|
|
/>
|
|
}
|
|
|
|
export function DestinationForm({
|
|
form,
|
|
infoContent,
|
|
label = 'Destination',
|
|
name = 'destination',
|
|
path = 'destination',
|
|
required = false,
|
|
}) {
|
|
const [inputProps] = form.getCustomProps({name, path, required});
|
|
const {disabled, value, onChange} = inputProps;
|
|
const infoPopup = infoContent ? <InfoPopup content={infoContent}/> : null;
|
|
return <div>
|
|
<FieldLabel htmlFor='destination_search_form' label={label} required={required}/>
|
|
{infoPopup}
|
|
<DirectorySearch
|
|
required={required}
|
|
value={value}
|
|
onSelect={onChange}
|
|
disabled={disabled}
|
|
id='destination_search_form'
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
export function DownloadTagsSelector({form, limit, path = 'tag_names', name = 'tag_names'}) {
|
|
const [inputProps] = form.getCustomProps({name, path});
|
|
const {value, onChange} = inputProps;
|
|
|
|
return <TagsSelector
|
|
disabled={form.disabled}
|
|
selectedTagNames={value}
|
|
limit={limit}
|
|
onChange={onChange}
|
|
closeAfterAdd={!!limit}
|
|
/>;
|
|
}
|
|
|
|
export function DownloadFrequencySelector({
|
|
form,
|
|
freqOptions = frequencyOptions,
|
|
name = 'frequency',
|
|
path = 'frequency',
|
|
}) {
|
|
return <SelectField
|
|
form={form}
|
|
name={name}
|
|
path={path}
|
|
required
|
|
label='Download Frequency'
|
|
placeholder='Frequency'
|
|
options={freqOptions}
|
|
/>
|
|
}
|
|
|
|
export function DownloaderSelector({form, name = 'sub_downloader', path = 'sub_downloader'}) {
|
|
const [downloaders, setDownloaders] = React.useState([]);
|
|
|
|
const fetchDownloaders = async () => {
|
|
let {downloaders: downloaders_} = await getDownloaders();
|
|
downloaders_ = downloaders_.map((i) => {
|
|
return {key: i.name, text: i.pretty_name || i.name, value: i.name}
|
|
})
|
|
setDownloaders(downloaders_);
|
|
}
|
|
|
|
React.useEffect(() => {
|
|
fetchDownloaders();
|
|
}, []);
|
|
|
|
return <SelectField
|
|
form={form}
|
|
name={name}
|
|
path={path}
|
|
required
|
|
label='Downloader'
|
|
placeholder='Select a downloader'
|
|
options={downloaders}
|
|
/>
|
|
}
|
|
|
|
export function ExcludedUrls({form, name = 'excluded_urls', path = 'settings.excluded_urls'}) {
|
|
return <TextField
|
|
form={form}
|
|
type='text'
|
|
name={name}
|
|
path={path}
|
|
helpContent="Comma-separated list of keywords that will be ignored if they are in any link's URL"
|
|
label='Excluded URLs'
|
|
placeholder='prize,gift'
|
|
validator={commaSeparatedValidator}
|
|
/>
|
|
}
|
|
|
|
export function TitleInclusionInput({form, path = 'settings.title_include'}) {
|
|
return <TextField
|
|
form={form}
|
|
name='title_include'
|
|
path={path}
|
|
label='Title Match Words'
|
|
helpContent='List of words, separated by commas, that titles must contain to be downloaded.'
|
|
placeholder='Shelter,Solar Power'
|
|
validator={commaSeparatedValidator}
|
|
/>
|
|
}
|
|
|
|
export function TitleExclusionInput({form, path = 'settings.title_exclude'}) {
|
|
return <TextField
|
|
form={form}
|
|
name='title_exclude'
|
|
path={path}
|
|
label='Title Exclusion Words'
|
|
helpContent='List of words, separated by commas, that may not appear in titles to be downloaded.'
|
|
placeholder='Giveaway,Prize'
|
|
validator={commaSeparatedValidator}
|
|
/>
|
|
}
|
|
|
|
export function DownloadFormButtons({onCancel, form}) {
|
|
return <Group justify='space-between' mt='sm'>
|
|
<Button role='cancel' onClick={onCancel} type='button'>Cancel</Button>
|
|
<APIButton
|
|
role='primary'
|
|
disabled={form.disabled || !form.ready}
|
|
type='submit'
|
|
onClick={form.onSubmit}
|
|
id='download_form_download_button'
|
|
>Download</APIButton>
|
|
</Group>
|
|
}
|
|
|
|
export function EditDownloadFormButtons({onDelete, onCancel, form}) {
|
|
return <Group justify='space-between' mt='sm'>
|
|
<APIButton
|
|
role='danger'
|
|
confirmButton='Delete'
|
|
confirmContent='Delete this Download?'
|
|
confirmHeader='Delete'
|
|
onClick={onDelete}
|
|
>Delete</APIButton>
|
|
<Group>
|
|
<Button role='cancel' onClick={onCancel}>Cancel</Button>
|
|
<APIButton
|
|
role='save'
|
|
disabled={form.disabled || !form.ready}
|
|
type='submit'
|
|
onClick={form.onSubmit}
|
|
>Save</APIButton>
|
|
</Group>
|
|
</Group>
|
|
}
|
|
|
|
function VideoDownloadOrder({form, path = 'settings.download_order'}) {
|
|
return <SelectField
|
|
form={form}
|
|
name='download_order'
|
|
path={path}
|
|
label='Download Order'
|
|
options={downloadOrderOptions}
|
|
/>
|
|
}
|
|
|
|
function VideoDownloadCountLimit({form, name = 'video_count_limit', path = 'settings.video_count_limit'}) {
|
|
return <NumberField
|
|
form={form}
|
|
helpContent='Stop downloading videos from this channel/playlist when this many have been downloaded.'
|
|
helpPosition='top right'
|
|
name={name}
|
|
path={path}
|
|
label='Video Count Limit'
|
|
placeholder='100'
|
|
/>
|
|
}
|
|
|
|
export function VideoResolutionSelectorForm({
|
|
form,
|
|
name = 'video_resolutions',
|
|
path = 'settings.video_resolutions',
|
|
onChange
|
|
}) {
|
|
return <div>
|
|
<InfoHeader
|
|
headerSize='h5'
|
|
headerContent='Video Resolutions'
|
|
popupContent='Videos will be downloaded in the first available resolution from the list you select.'
|
|
for_='video_resolutions_input'
|
|
/>
|
|
<MultiSelectField
|
|
form={form}
|
|
name={name}
|
|
path={path}
|
|
options={downloadResolutionOptions}
|
|
onChange={onChange}
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
export function VideoFormatSelectorForm({form, name = 'video_format', path = 'settings.video_format'}) {
|
|
return <div>
|
|
<InfoHeader
|
|
headerSize='h5'
|
|
headerContent='Video Format'
|
|
popupContent='Videos will be downloaded in this format, or transcoded if not available.'
|
|
for_='video_format_input'
|
|
/>
|
|
<SelectField form={form} name={name} path={path} options={downloadFormatOptions}/>
|
|
</div>
|
|
}
|
|
|
|
export function AudioOnlyToggle({form, onChange}) {
|
|
const [inputProps] = form.getCustomProps({name: 'audio_only', path: 'settings.audio_only'});
|
|
return <Toggle
|
|
label='Audio only'
|
|
checked={!!inputProps.value}
|
|
disabled={form.disabled}
|
|
onChange={(value) => {
|
|
inputProps.onChange(value);
|
|
if (onChange) onChange(value);
|
|
}}
|
|
info='Download only the audio track from videos.'
|
|
/>
|
|
}
|
|
|
|
export function AudioFormatSelectorForm({form, name = 'audio_format', path = 'settings.audio_format'}) {
|
|
return <div>
|
|
<InfoHeader
|
|
headerSize='h5'
|
|
headerContent='Audio Format'
|
|
popupContent='Audio will be downloaded and converted to this format.'
|
|
for_='audio_format_input'
|
|
/>
|
|
<SelectField form={form} name={name} path={path} options={downloadAudioFormatOptions}/>
|
|
</div>
|
|
}
|
|
|
|
function VideoDurationLimit({form, name, path, label, helpContent, placeholder, helpPosition}) {
|
|
return <NumberField
|
|
form={form}
|
|
helpContent={helpContent}
|
|
helpPosition={helpPosition}
|
|
placeholder={placeholder}
|
|
name={name}
|
|
path={path}
|
|
label={label}
|
|
/>
|
|
}
|
|
|
|
export function VideoTagsForm({form}) {
|
|
return <div>
|
|
<InfoHeader
|
|
headerSize='h4'
|
|
headerContent='Videos Tags'
|
|
popupContent='Tag all Videos with these Tags.'
|
|
/>
|
|
<DownloadTagsSelector form={form}/>
|
|
</div>
|
|
}
|
|
|
|
export function ChannelTagNameForm({form}) {
|
|
return <div>
|
|
<InfoHeader
|
|
headerSize='h4'
|
|
headerContent='Channel Tag'
|
|
popupContent='If the Channel is new, apply this Tag.'
|
|
/>
|
|
<DownloadTagsSelector
|
|
form={form}
|
|
limit={1}
|
|
name='channel_tag_name'
|
|
path='settings.channel_tag_name'
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
function AdvancedVideoSettings({form, isVideoLevel = false, isConfigLoaded = true}) {
|
|
if (!isConfigLoaded) return null;
|
|
|
|
return <SimpleAccordion title='Advanced Settings'>
|
|
<Stack gap='md'>
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<AudioOnlyToggleLikeVideoSetting form={form} name='writesubtitles' path='settings.writesubtitles'
|
|
label='Download subtitles' icon='closed captioning'/>
|
|
<AudioOnlyToggleLikeVideoSetting form={form} name='writeautomaticsub' path='settings.writeautomaticsub'
|
|
label='Download automatic subtitles' icon='closed captioning'/>
|
|
</Group>
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<AudioOnlyToggleLikeVideoSetting form={form} name='writethumbnail' path='settings.writethumbnail'
|
|
label='Download thumbnail' icon='image'/>
|
|
<AudioOnlyToggleLikeVideoSetting form={form} name='writeinfojson' path='settings.writeinfojson'
|
|
label='Download info JSON' icon='file code'/>
|
|
</Group>
|
|
{isVideoLevel &&
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<AudioOnlyToggleLikeVideoSetting form={form} name='continue_dl' path='settings.continue_dl'
|
|
label='Continue partial downloads' icon='play'/>
|
|
<AudioOnlyToggleLikeVideoSetting form={form} name='nooverwrites' path='settings.nooverwrites'
|
|
label='Do not overwrite existing files' icon='file video'/>
|
|
</Group>}
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<NumberField
|
|
form={form}
|
|
label='Sleep between requests (seconds)'
|
|
name='sleep_requests'
|
|
path='settings.sleep_requests'
|
|
placeholder='0.75'
|
|
min={0}
|
|
max={60}
|
|
/>
|
|
<TextField
|
|
form={form}
|
|
label='User agent'
|
|
name='user_agent'
|
|
path='settings.user_agent'
|
|
placeholder='Mozilla/5.0...'
|
|
/>
|
|
</Group>
|
|
<TextField
|
|
form={form}
|
|
label='Extra yt-dlp arguments'
|
|
name='yt_dlp_extra_args'
|
|
path='settings.yt_dlp_extra_args'
|
|
placeholder='--no-playlist --geo-bypass'
|
|
/>
|
|
</Stack>
|
|
</SimpleAccordion>
|
|
}
|
|
|
|
/** A labeled Toggle backed by `form.getCustomProps`. */
|
|
function AudioOnlyToggleLikeVideoSetting({form, name, path, label, icon}) {
|
|
const [inputProps] = form.getCustomProps({name, path});
|
|
return <Toggle
|
|
label={label}
|
|
icon={icon}
|
|
checked={!!inputProps.value}
|
|
disabled={form.disabled}
|
|
onChange={inputProps.onChange}
|
|
/>
|
|
}
|
|
|
|
export function CompressSinglefileToggle({form}) {
|
|
const [inputProps] = form.getCustomProps({name: 'compress_singlefile', path: 'settings.compress_singlefile'});
|
|
return <div>
|
|
<InfoHeader
|
|
headerSize='h4'
|
|
headerContent='Compress'
|
|
popupContent='Create a compressed, self-extracting (SingleFileZ) archive. The HTML file is smaller, but not
|
|
human-readable. Readability files are never compressed.'
|
|
/>
|
|
<Toggle checked={!!inputProps.value} disabled={form.disabled} onChange={inputProps.onChange}/>
|
|
</div>
|
|
}
|
|
|
|
function AdvancedDownloadSettings({form}) {
|
|
const [inputProps] = form.getCustomProps({
|
|
name: 'skip_already_downloaded',
|
|
path: 'settings.skip_already_downloaded'
|
|
});
|
|
|
|
return <SimpleAccordion title='Advanced Settings'>
|
|
<Toggle
|
|
label='Skip URLs already downloaded'
|
|
checked={!!inputProps.value}
|
|
disabled={form.disabled}
|
|
onChange={inputProps.onChange}
|
|
/>
|
|
</SimpleAccordion>
|
|
}
|
|
|
|
export function VideosDownloadForm({
|
|
singleDownload = true,
|
|
onCancel,
|
|
onSuccess: propOnSuccess,
|
|
download,
|
|
submitter: propSubmitter,
|
|
actions
|
|
}) {
|
|
const [showMessage, setShowMessage] = React.useState(false);
|
|
const [userChangedResolutions, setUserChangedResolutions] = React.useState(false);
|
|
const [config, setConfig] = React.useState(null);
|
|
const [isConfigLoaded, setIsConfigLoaded] = React.useState(false);
|
|
|
|
// Keep video format in session to help user start downloads consistently.
|
|
const [defaultVideoFormat, setDefaultVideoFormat] = useLocalStorage('video_format', defaultVideoFormatOption);
|
|
|
|
// Use config video resolutions if available, otherwise use default
|
|
const configResolutions = config && config.video_resolutions ? config.video_resolutions : defaultVideoResolutionOptions;
|
|
|
|
const defaultFormData = {
|
|
urls: '', // Textarea, one URL per line.
|
|
destination: '',
|
|
tag_names: [],
|
|
downloader: Downloaders.Video,
|
|
settings: {
|
|
video_format: defaultVideoFormat,
|
|
video_resolutions: configResolutions,
|
|
audio_only: false,
|
|
audio_format: defaultAudioFormatOption,
|
|
writesubtitles: false,
|
|
writeautomaticsub: false,
|
|
writethumbnail: false,
|
|
writeinfojson: false,
|
|
yt_dlp_extra_args: '',
|
|
sleep_requests: null,
|
|
user_agent: '',
|
|
continue_dl: false,
|
|
nooverwrites: false,
|
|
}
|
|
}
|
|
|
|
const submitter = propSubmitter || (async (formData) => {
|
|
const downloadData = {
|
|
destination: formData.destination,
|
|
downloader: formData.downloader,
|
|
settings: formData.settings,
|
|
tag_names: formData.tag_names,
|
|
urls: formData.urls.split(/r?\n/),
|
|
}
|
|
await postDownload(downloadData);
|
|
});
|
|
|
|
const onSuccess = () => {
|
|
setShowMessage(true);
|
|
if (propOnSuccess) {
|
|
propOnSuccess();
|
|
} else {
|
|
form.reset();
|
|
}
|
|
}
|
|
|
|
const form = useForm({
|
|
submitter,
|
|
defaultFormData: download ? mergeDeep(defaultFormData, download) : defaultFormData,
|
|
onSuccess,
|
|
});
|
|
|
|
// Fetch global defaults to pre-fill the form
|
|
React.useEffect(() => {
|
|
const fetchDefaults = async () => {
|
|
try {
|
|
const defaults = await fetchVideoDownloadDefaults();
|
|
setConfig(defaults);
|
|
|
|
if (defaults && download) {
|
|
// Edit mode: only fill settings not already in the download
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
if (download.settings?.[key] === undefined || download.settings?.[key] === null) {
|
|
form.setValue(`settings.${key}`, value);
|
|
}
|
|
}
|
|
} else if (defaults) {
|
|
// New download: pre-fill all settings from global defaults
|
|
const downloadHasResolutions = download?.settings?.video_resolutions?.length > 0;
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
if (key === 'video_resolutions' && (userChangedResolutions || downloadHasResolutions)) {
|
|
continue;
|
|
}
|
|
if (key === 'video_format') {
|
|
continue; // Handled by localStorage default
|
|
}
|
|
form.setValue(`settings.${key}`, value);
|
|
}
|
|
if (!userChangedResolutions && !downloadHasResolutions && defaults.video_resolutions) {
|
|
form.setValue('settings.video_resolutions', defaults.video_resolutions);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch video download defaults:', error);
|
|
}
|
|
setIsConfigLoaded(true);
|
|
};
|
|
|
|
fetchDefaults();
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const {video_format} = form.formData.settings;
|
|
if (video_format && video_format !== defaultVideoFormat) {
|
|
setDefaultVideoFormat(video_format);
|
|
}
|
|
}, [form.formData]);
|
|
|
|
const localOnCancel = (e) => {
|
|
if (e) e.preventDefault();
|
|
if (onCancel) {
|
|
onCancel();
|
|
}
|
|
}
|
|
|
|
// This form can handle a single Video download, or multiple video downloads.
|
|
const urlInput = singleDownload ?
|
|
<UrlField required form={form} path='urls'/>
|
|
: <UrlsField required form={form}/>;
|
|
|
|
return <form onSubmit={(e) => e.preventDefault()}>
|
|
<Header as='h3' icon='film'>Videos</Header>
|
|
<p>Download each video at the URLs provided below.</p>
|
|
|
|
<Stack gap='md'>
|
|
{urlInput}
|
|
<VideoTagsForm form={form}/>
|
|
<DestinationForm
|
|
form={form}
|
|
infoContent="Videos download into their Channel's directory, by default. If this is provided, then videos in this Channel/Playlist will download to this directory instead."
|
|
/>
|
|
<AudioOnlyToggle form={form}/>
|
|
{form.formData?.settings?.audio_only
|
|
? <AudioFormatSelectorForm form={form}/>
|
|
: <Group align='flex-start' wrap='wrap'>
|
|
<div style={{flex: 3, minWidth: 240}}>
|
|
<VideoResolutionSelectorForm
|
|
form={form}
|
|
onChange={() => setUserChangedResolutions(true)}
|
|
/>
|
|
</div>
|
|
<div style={{flex: 1, minWidth: 140}}>
|
|
<VideoFormatSelectorForm form={form}/>
|
|
</div>
|
|
</Group>
|
|
}
|
|
<ChannelTagNameForm form={form}/>
|
|
<AdvancedVideoSettings form={form} isVideoLevel={true} isConfigLoaded={isConfigLoaded}/>
|
|
{showMessage && <SuccessfulDownloadSubmitMessage/>}
|
|
{actions ? actions({onCancel: localOnCancel, form}) :
|
|
<DownloadFormButtons onCancel={localOnCancel} form={form}/>}
|
|
</Stack>
|
|
</form>
|
|
}
|
|
|
|
|
|
export function VideoMinimumDurationForm({form}) {
|
|
return <VideoDurationLimit
|
|
form={form}
|
|
path='settings.minimum_duration'
|
|
label='Minimum Duration'
|
|
name='minimum_duration'
|
|
helpContent='Download only Videos this many seconds long, or greater.'
|
|
placeholder='60'
|
|
/>
|
|
}
|
|
|
|
export function EditVideosDownloadForm({
|
|
download,
|
|
onCancel,
|
|
onSuccess,
|
|
onDelete,
|
|
actions = EditDownloadFormButtons,
|
|
}) {
|
|
const submitter = async (formData) => {
|
|
// Create a copy of settings without channel_url and channel_id
|
|
const settings = {...formData.settings};
|
|
// These settings are created when downloading the video, we can't submit them again.
|
|
delete settings.channel_url;
|
|
delete settings.channel_id;
|
|
|
|
const downloadData = {
|
|
collection_id: download.collection_id,
|
|
destination: formData.destination,
|
|
downloader: formData.downloader,
|
|
settings: settings,
|
|
tag_names: formData.tag_names,
|
|
urls: formData.urls.split(/r?\n/),
|
|
}
|
|
await putDownload(download.id, downloadData);
|
|
}
|
|
|
|
return <VideosDownloadForm
|
|
singleDownload={true}
|
|
onCancel={onCancel}
|
|
onSuccess={onSuccess}
|
|
download={download}
|
|
submitter={submitter}
|
|
actions={(props) => actions({...props, onDelete})}
|
|
/>
|
|
}
|
|
|
|
export function VideoMaximumDurationForm({form}) {
|
|
return <VideoDurationLimit
|
|
form={form}
|
|
path='settings.maximum_duration'
|
|
label='Maximum Duration'
|
|
name='maximum_duration'
|
|
helpContent='Do not download videos longer than this many seconds.'
|
|
placeholder='3600'
|
|
/>
|
|
}
|
|
|
|
|
|
export function ChannelDownloadForm({
|
|
download,
|
|
onCancel,
|
|
onSuccess,
|
|
onDelete = async () => {
|
|
},
|
|
submitter,
|
|
actions = null,
|
|
clearOnSuccess = true,
|
|
channel_id = null,
|
|
}) {
|
|
const [showMessage, setShowMessage] = React.useState(false);
|
|
const [config, setConfig] = React.useState(null);
|
|
const [isConfigLoaded, setIsConfigLoaded] = React.useState(false);
|
|
const [userChangedResolutions, setUserChangedResolutions] = React.useState(false);
|
|
|
|
// May have received submitter from EditChannelDownloadForm.
|
|
submitter = submitter || (async (formData) => {
|
|
const downloadData = {
|
|
destination: formData.destination,
|
|
downloader: formData.downloader,
|
|
frequency: formData.frequency,
|
|
settings: formData.settings,
|
|
sub_downloader: formData.sub_downloader,
|
|
tag_names: formData.tag_names || [],
|
|
urls: [formData.url,],
|
|
}
|
|
await postDownload(downloadData);
|
|
});
|
|
|
|
const localOnSuccess = async () => {
|
|
setShowMessage(true);
|
|
if (onSuccess) {
|
|
onSuccess();
|
|
}
|
|
}
|
|
|
|
// Keep video format in session to help user start downloads consistently.
|
|
const [defaultVideoFormat, setDefaultVideoFormat] = useLocalStorage('video_format', defaultVideoFormatOption);
|
|
|
|
// Use config video resolutions if available, otherwise use default
|
|
const configResolutions = config && config.video_resolutions ? config.video_resolutions : defaultVideoResolutionOptions;
|
|
|
|
const emptyFormData = {
|
|
destination: '',
|
|
downloader: Downloaders.VideoChannel,
|
|
frequency: days30Option.value,
|
|
url: '',
|
|
settings: {
|
|
channel_id,
|
|
channel_tag_name: [],
|
|
download_order: 'newest',
|
|
maximum_duration: null,
|
|
minimum_duration: null,
|
|
title_exclude: null,
|
|
title_include: null,
|
|
video_count_limit: null,
|
|
video_format: defaultVideoFormat,
|
|
video_resolutions: configResolutions,
|
|
audio_only: false,
|
|
audio_format: defaultAudioFormatOption,
|
|
writesubtitles: false,
|
|
writeautomaticsub: false,
|
|
writethumbnail: false,
|
|
writeinfojson: false,
|
|
yt_dlp_extra_args: '',
|
|
sleep_requests: null,
|
|
user_agent: '',
|
|
},
|
|
sub_downloader: Downloaders.Video,
|
|
tag_names: [],
|
|
};
|
|
|
|
const form = useForm({
|
|
submitter,
|
|
defaultFormData: mergeDeep(emptyFormData, download),
|
|
emptyFormData,
|
|
onSuccess: localOnSuccess,
|
|
clearOnSuccess,
|
|
});
|
|
|
|
// Fetch global defaults to pre-fill the form
|
|
React.useEffect(() => {
|
|
const fetchDefaults = async () => {
|
|
try {
|
|
const defaults = await fetchVideoDownloadDefaults();
|
|
setConfig(defaults);
|
|
setIsConfigLoaded(true);
|
|
|
|
if (defaults && download) {
|
|
// Edit mode: only fill settings not already in the download
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
if (key === 'continue_dl' || key === 'nooverwrites') continue;
|
|
if (download.settings?.[key] === undefined || download.settings?.[key] === null) {
|
|
form.setValue(`settings.${key}`, value);
|
|
}
|
|
}
|
|
} else if (defaults) {
|
|
// New download: pre-fill all inheritable settings from global defaults
|
|
const downloadHasResolutions = download?.settings?.video_resolutions?.length > 0;
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
if (key === 'continue_dl' || key === 'nooverwrites') continue;
|
|
if (key === 'video_resolutions' && (userChangedResolutions || downloadHasResolutions)) continue;
|
|
if (key === 'video_format') continue;
|
|
form.setValue(`settings.${key}`, value);
|
|
}
|
|
if (!userChangedResolutions && !downloadHasResolutions && defaults.video_resolutions) {
|
|
form.setValue('settings.video_resolutions', defaults.video_resolutions);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch video download defaults:', error);
|
|
setIsConfigLoaded(true);
|
|
}
|
|
};
|
|
|
|
fetchDefaults();
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const {video_format} = form.formData.settings;
|
|
if (video_format && video_format !== defaultVideoFormat) {
|
|
setDefaultVideoFormat(video_format);
|
|
}
|
|
}, [form.formData]);
|
|
|
|
const onceMessage = <Message kind='info' title='Download Once'>
|
|
You have selected a frequency of Once, this is useful when you want to download
|
|
all videos in a Playlist, and when you do not want to download any videos added to the playlist
|
|
in the future.
|
|
</Message>;
|
|
|
|
// Default to "new" download buttons.
|
|
actions = actions || DownloadFormButtons;
|
|
const actionsElm = actions({onDelete, onCancel, form});
|
|
|
|
return <form onSubmit={(e) => e.preventDefault()}>
|
|
<Header as='h3' icon='film'>Channel / Playlist</Header>
|
|
|
|
<Stack gap='md'>
|
|
<UrlField required form={form}/>
|
|
<VideoTagsForm form={form}/>
|
|
<Group align='flex-start' wrap='wrap'>
|
|
<div style={{flex: 1, minWidth: 200}}>
|
|
<DownloadFrequencySelector form={form} freqOptions={channelFrequencyOptions}/>
|
|
</div>
|
|
<div style={{flex: 3, minWidth: 240}}>
|
|
<DestinationForm
|
|
form={form}
|
|
infoContent='Destination is not required. Videos will download into the automatically created Channel directory.'
|
|
/>
|
|
</div>
|
|
</Group>
|
|
{form.formData.frequency === 0 && onceMessage}
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<TitleInclusionInput form={form}/>
|
|
<TitleExclusionInput form={form}/>
|
|
</Group>
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<VideoDownloadOrder form={form}/>
|
|
<VideoDownloadCountLimit form={form}/>
|
|
</Group>
|
|
<AudioOnlyToggle form={form}/>
|
|
{form.formData?.settings?.audio_only
|
|
? <AudioFormatSelectorForm form={form}/>
|
|
: <Group align='flex-start' wrap='wrap'>
|
|
<div style={{flex: 3, minWidth: 240}}>
|
|
<VideoResolutionSelectorForm
|
|
form={form}
|
|
onChange={() => setUserChangedResolutions(true)}
|
|
/>
|
|
</div>
|
|
<div style={{flex: 1, minWidth: 140}}>
|
|
<VideoFormatSelectorForm form={form}/>
|
|
</div>
|
|
</Group>
|
|
}
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<VideoMinimumDurationForm form={form}/>
|
|
<VideoMaximumDurationForm form={form}/>
|
|
</Group>
|
|
<ChannelTagNameForm form={form}/>
|
|
<AdvancedVideoSettings form={form} isConfigLoaded={isConfigLoaded}/>
|
|
{showMessage && <SuccessfulDownloadSubmitMessage/>}
|
|
{actionsElm}
|
|
</Stack>
|
|
</form>
|
|
}
|
|
|
|
export function EditChannelDownloadForm({
|
|
download,
|
|
onCancel,
|
|
onSuccess,
|
|
onDelete,
|
|
withTags = true,
|
|
actions = EditDownloadFormButtons,
|
|
}) {
|
|
|
|
const submitter = async (formData) => {
|
|
const downloadData = {
|
|
collection_id: download.collection_id,
|
|
destination: formData.destination,
|
|
downloader: formData.downloader,
|
|
frequency: formData.frequency,
|
|
settings: formData.settings,
|
|
tag_names: formData.tag_names || [],
|
|
urls: [formData.url,],
|
|
}
|
|
await putDownload(download.id, downloadData);
|
|
}
|
|
|
|
return <ChannelDownloadForm
|
|
download={download}
|
|
submitter={submitter}
|
|
onCancel={onCancel}
|
|
onSuccess={onSuccess}
|
|
onDelete={onDelete}
|
|
withTags={withTags}
|
|
actions={actions}
|
|
clearOnSuccess={false}
|
|
/>
|
|
}
|
|
|
|
function SuccessfulDownloadSubmitMessage() {
|
|
return <Message kind='success' title='Download Submitted'>
|
|
<Link to='/admin'><Icon name='checkmark'/> View downloads</Link>
|
|
</Message>
|
|
}
|
|
|
|
export function EditArchiveDownloadForm({
|
|
download,
|
|
onCancel,
|
|
onSuccess,
|
|
onDelete,
|
|
actions = EditDownloadFormButtons,
|
|
}) {
|
|
const submitter = async (formData) => {
|
|
const downloadData = {
|
|
collection_id: download.collection_id,
|
|
downloader: formData.downloader,
|
|
tag_names: formData.tag_names,
|
|
urls: formData.urls.split(/\r?\n/),
|
|
}
|
|
await putDownload(download.id, downloadData);
|
|
}
|
|
|
|
return <ArchiveDownloadForm
|
|
singleDownload={true}
|
|
download={download}
|
|
onCancel={onCancel}
|
|
onSuccess={onSuccess}
|
|
submitter={submitter}
|
|
actions={(props) => actions({...props, onDelete})}
|
|
/>
|
|
}
|
|
|
|
export function ArchiveDownloadForm({
|
|
singleDownload = false,
|
|
download,
|
|
onCancel,
|
|
onSuccess: propOnSuccess,
|
|
submitter: propSubmitter,
|
|
actions
|
|
}) {
|
|
const [showMessage, setShowMessage] = React.useState(false);
|
|
|
|
// Remember the user's compression choice between downloads.
|
|
const [defaultCompressSinglefile, setDefaultCompressSinglefile] = useLocalStorage('compress_singlefile', false);
|
|
|
|
const submitter = propSubmitter || (async (formData) => {
|
|
const downloadData = {
|
|
downloader: formData.downloader,
|
|
tag_names: formData.tag_names,
|
|
urls: formData.urls.split(/\r?\n/),
|
|
settings: formData.settings,
|
|
}
|
|
await postDownload(downloadData);
|
|
});
|
|
|
|
const emptyFormData = {
|
|
downloader: Downloaders.Archive,
|
|
urls: '',
|
|
tag_names: [],
|
|
settings: {
|
|
skip_already_downloaded: false,
|
|
compress_singlefile: defaultCompressSinglefile,
|
|
},
|
|
};
|
|
|
|
const onSuccess = () => {
|
|
setShowMessage(true);
|
|
if (propOnSuccess) {
|
|
propOnSuccess();
|
|
}
|
|
}
|
|
|
|
const form = useForm({
|
|
submitter,
|
|
defaultFormData: mergeDeep(emptyFormData, download),
|
|
emptyFormData,
|
|
clearOnSuccess: !propOnSuccess,
|
|
onSuccess,
|
|
});
|
|
|
|
// Persist the user's compression choice in local storage.
|
|
React.useEffect(() => {
|
|
const {compress_singlefile} = form.formData.settings;
|
|
if (compress_singlefile !== undefined && compress_singlefile !== defaultCompressSinglefile) {
|
|
setDefaultCompressSinglefile(compress_singlefile);
|
|
}
|
|
}, [form.formData, defaultCompressSinglefile]);
|
|
|
|
const localOnCancel = (e) => {
|
|
if (e) e.preventDefault();
|
|
if (onCancel) {
|
|
onCancel();
|
|
}
|
|
}
|
|
|
|
const urlInput = singleDownload ?
|
|
<UrlField required form={form} path='urls'/>
|
|
: <UrlsField required form={form}/>;
|
|
|
|
return <form onSubmit={(e) => e.preventDefault()}>
|
|
<Header as='h3' icon='file text'>Archives</Header>
|
|
<p>Create a Singlefile Archive for each of the URLs provided below.</p>
|
|
|
|
<Stack gap='md'>
|
|
{urlInput}
|
|
<DownloadTagsSelector form={form}/>
|
|
<CompressSinglefileToggle form={form}/>
|
|
<AdvancedDownloadSettings form={form}/>
|
|
{showMessage && <SuccessfulDownloadSubmitMessage/>}
|
|
{actions ? actions({onCancel: localOnCancel, form}) :
|
|
<DownloadFormButtons onCancel={localOnCancel} form={form}/>}
|
|
</Stack>
|
|
</form>
|
|
}
|
|
|
|
export function RSSDownloadForm({download, submitter, onDelete, onCancel, actions, clearOnSuccess = true}) {
|
|
const [showMessage, setShowMessage] = React.useState(false);
|
|
const [config, setConfig] = React.useState(null);
|
|
const [isConfigLoaded, setIsConfigLoaded] = React.useState(false);
|
|
const [userChangedResolutions, setUserChangedResolutions] = React.useState(false);
|
|
|
|
const [defaultVideoFormat, setDefaultVideoFormat] = useLocalStorage('video_format', defaultVideoFormatOption);
|
|
// Remember the user's compression choice between downloads.
|
|
const [defaultCompressSinglefile, setDefaultCompressSinglefile] = useLocalStorage('compress_singlefile', false);
|
|
|
|
submitter = submitter || (async (formData) => {
|
|
const downloadData = {
|
|
destination: formData.destination,
|
|
downloader: formData.downloader,
|
|
frequency: formData.frequency,
|
|
settings: formData.settings,
|
|
sub_downloader: formData.sub_downloader,
|
|
tag_names: formData.tag_names || [],
|
|
urls: [formData.url],
|
|
}
|
|
await postDownload(downloadData);
|
|
});
|
|
|
|
// Use config video resolutions if available, otherwise use default
|
|
const configResolutions = config && config.video_resolutions ? config.video_resolutions : defaultVideoResolutionOptions;
|
|
|
|
const emptyFormData = {
|
|
destination: null,
|
|
downloader: Downloaders.RSS,
|
|
frequency: weeklyOption.value,
|
|
sub_downloader: null,
|
|
settings: {
|
|
compress_singlefile: defaultCompressSinglefile,
|
|
excluded_urls: null,
|
|
title_exclude: null,
|
|
title_include: null,
|
|
video_resolutions: configResolutions,
|
|
video_format: defaultVideoFormat,
|
|
audio_only: false,
|
|
audio_format: defaultAudioFormatOption,
|
|
writesubtitles: false,
|
|
writeautomaticsub: false,
|
|
writethumbnail: false,
|
|
writeinfojson: false,
|
|
yt_dlp_extra_args: '',
|
|
sleep_requests: null,
|
|
user_agent: '',
|
|
},
|
|
tag_names: [],
|
|
url: '',
|
|
};
|
|
|
|
const form = useForm({
|
|
submitter,
|
|
defaultFormData: mergeDeep(emptyFormData, download),
|
|
emptyFormData,
|
|
clearOnSuccess,
|
|
onSuccess: async () => setShowMessage(true),
|
|
});
|
|
|
|
// Fetch global defaults to pre-fill the form
|
|
React.useEffect(() => {
|
|
const fetchDefaults = async () => {
|
|
try {
|
|
const defaults = await fetchVideoDownloadDefaults();
|
|
setConfig(defaults);
|
|
setIsConfigLoaded(true);
|
|
|
|
if (defaults && download) {
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
if (key === 'continue_dl' || key === 'nooverwrites') continue;
|
|
if (download.settings?.[key] === undefined || download.settings?.[key] === null) {
|
|
form.setValue(`settings.${key}`, value);
|
|
}
|
|
}
|
|
} else if (defaults) {
|
|
const downloadHasResolutions = download?.settings?.video_resolutions?.length > 0;
|
|
for (const [key, value] of Object.entries(defaults)) {
|
|
if (key === 'continue_dl' || key === 'nooverwrites') continue;
|
|
if (key === 'video_resolutions' && (userChangedResolutions || downloadHasResolutions)) continue;
|
|
if (key === 'video_format') continue;
|
|
form.setValue(`settings.${key}`, value);
|
|
}
|
|
if (!userChangedResolutions && !downloadHasResolutions && defaults.video_resolutions) {
|
|
form.setValue('settings.video_resolutions', defaults.video_resolutions);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to fetch video download defaults:', error);
|
|
setIsConfigLoaded(true);
|
|
}
|
|
};
|
|
|
|
fetchDefaults();
|
|
}, []);
|
|
|
|
React.useEffect(() => {
|
|
const {video_format} = form.formData.settings;
|
|
if (video_format && video_format !== defaultVideoFormat) {
|
|
setDefaultVideoFormat(video_format);
|
|
}
|
|
}, [form.formData]);
|
|
|
|
// Persist the user's compression choice in local storage.
|
|
React.useEffect(() => {
|
|
const {compress_singlefile} = form.formData.settings;
|
|
if (compress_singlefile !== undefined && compress_singlefile !== defaultCompressSinglefile) {
|
|
setDefaultCompressSinglefile(compress_singlefile);
|
|
}
|
|
}, [form.formData, defaultCompressSinglefile]);
|
|
|
|
// Default to "new" download buttons.
|
|
actions = actions || DownloadFormButtons;
|
|
const actionsElm = actions({onDelete, onCancel, form});
|
|
|
|
let downloaderRows;
|
|
if (form.formData.sub_downloader === Downloaders.Video) {
|
|
downloaderRows = <>
|
|
<DestinationForm
|
|
form={form}
|
|
infoContent="Videos download into their Channel's directory, by default. If this is provided, then videos in this feed will download to this directory instead."
|
|
/>
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<TitleInclusionInput form={form}/>
|
|
<TitleExclusionInput form={form}/>
|
|
</Group>
|
|
<AudioOnlyToggle form={form}/>
|
|
{form.formData?.settings?.audio_only
|
|
? <AudioFormatSelectorForm form={form}/>
|
|
: <Group align='flex-start' wrap='wrap'>
|
|
<div style={{flex: 3, minWidth: 240}}>
|
|
<VideoResolutionSelectorForm
|
|
form={form}
|
|
onChange={() => setUserChangedResolutions(true)}
|
|
/>
|
|
</div>
|
|
<div style={{flex: 1, minWidth: 140}}>
|
|
<VideoFormatSelectorForm form={form}/>
|
|
</div>
|
|
</Group>
|
|
}
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<VideoMinimumDurationForm form={form}/>
|
|
<VideoMaximumDurationForm form={form}/>
|
|
</Group>
|
|
<AdvancedVideoSettings form={form} isConfigLoaded={isConfigLoaded}/>
|
|
</>;
|
|
} else if (form.formData.sub_downloader === Downloaders.Archive) {
|
|
downloaderRows = <>
|
|
<ExcludedUrls form={form}/>
|
|
<CompressSinglefileToggle form={form}/>
|
|
</>;
|
|
}
|
|
|
|
return <form onSubmit={(e) => e.preventDefault()}>
|
|
<Header as='h3' icon='rss'>RSS Feed</Header>
|
|
<p>Download each link provided by this RSS feed using the selected downloader.</p>
|
|
|
|
<Stack gap='md'>
|
|
<UrlField required form={form}/>
|
|
<DownloadTagsSelector form={form}/>
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<DownloadFrequencySelector form={form} freqOptions={extendedFrequencyOptions}/>
|
|
<DownloaderSelector form={form}/>
|
|
</Group>
|
|
{downloaderRows}
|
|
{showMessage && <SuccessfulDownloadSubmitMessage/>}
|
|
{actionsElm}
|
|
</Stack>
|
|
</form>
|
|
}
|
|
|
|
export function EditRSSDownloadForm({download, onDelete, onCancel, actions = EditDownloadFormButtons}) {
|
|
|
|
const submitter = async (formData) => {
|
|
const downloadData = {
|
|
collection_id: download.collection_id,
|
|
destination: formData.destination,
|
|
downloader: formData.downloader,
|
|
frequency: formData.frequency,
|
|
settings: formData.settings,
|
|
sub_downloader: formData.sub_downloader,
|
|
tag_names: formData.tag_names || [],
|
|
urls: [formData.url,],
|
|
}
|
|
await putDownload(download.id, downloadData);
|
|
}
|
|
|
|
return <RSSDownloadForm
|
|
submitter={submitter}
|
|
onDelete={onDelete}
|
|
onCancel={onCancel}
|
|
download={download}
|
|
actions={actions}
|
|
clearOnSuccess={false}
|
|
/>
|
|
}
|
|
|
|
export function EditZimDownloadForm({download, onDelete, onCancel, actions = EditDownloadFormButtons}) {
|
|
|
|
const submitter = async (formData) => {
|
|
const downloadData = {
|
|
collection_id: download.collection_id,
|
|
downloader: formData.downloader,
|
|
frequency: formData.frequency,
|
|
settings: formData.settings,
|
|
sub_downloader: formData.sub_downloader,
|
|
tag_names: formData.tag_names,
|
|
urls: [formData.url,],
|
|
}
|
|
await putDownload(download.id, downloadData);
|
|
}
|
|
const [showMessage, setShowMessage] = React.useState(false);
|
|
|
|
const emptyFormData = {
|
|
downloader: Downloaders.RSS,
|
|
frequency: weeklyOption.value,
|
|
sub_downloader: null,
|
|
tag_names: [],
|
|
url: '',
|
|
};
|
|
|
|
const form = useForm({
|
|
submitter,
|
|
defaultFormData: mergeDeep(emptyFormData, download),
|
|
emptyFormData,
|
|
onSuccess: async () => setShowMessage(true),
|
|
});
|
|
|
|
// Default to "new" download buttons.
|
|
actions = actions || DownloadFormButtons;
|
|
const actionsElm = actions({onDelete, onCancel, form});
|
|
|
|
return <form onSubmit={(e) => e.preventDefault()}>
|
|
<Header as='h3'>Zim File</Header>
|
|
|
|
<Stack gap='md'>
|
|
<UrlField required form={form} disabled={true}/>
|
|
<DownloadFrequencySelector form={form} freqOptions={extendedFrequencyOptions}/>
|
|
{showMessage && <SuccessfulDownloadSubmitMessage/>}
|
|
{actionsElm}
|
|
</Stack>
|
|
</form>
|
|
}
|
|
|
|
export function FilesDownloadForm({
|
|
download,
|
|
submitter,
|
|
onCancel,
|
|
actions = DownloadFormButtons,
|
|
clearOnSuccess = true,
|
|
}) {
|
|
const [showMessage, setShowMessage] = React.useState(false);
|
|
|
|
submitter = submitter || (async (formData) => {
|
|
const downloadData = {
|
|
downloader: Downloaders.File,
|
|
tag_names: formData.tag_names,
|
|
destination: formData.destination,
|
|
urls: formData.urls.split(/\r?\n/),
|
|
settings: formData.settings,
|
|
}
|
|
await postDownload(downloadData);
|
|
});
|
|
|
|
const emptyFormData = {
|
|
destination: null,
|
|
tag_names: [],
|
|
urls: '',
|
|
settings: {
|
|
skip_already_downloaded: false,
|
|
},
|
|
};
|
|
|
|
const form = useForm({
|
|
submitter,
|
|
defaultFormData: mergeDeep(emptyFormData, download),
|
|
emptyFormData,
|
|
clearOnSuccess,
|
|
onSuccess: async () => setShowMessage(true),
|
|
});
|
|
|
|
const actionsElm = actions({onCancel, form});
|
|
|
|
return <form onSubmit={(e) => e.preventDefault()}>
|
|
<Header as='h3' icon='file'>Files</Header>
|
|
<p>Download each file at the URLs provided below.</p>
|
|
|
|
<Stack gap='md'>
|
|
<UrlsField form={form}/>
|
|
<DownloadTagsSelector form={form}/>
|
|
<DestinationForm required form={form}/>
|
|
<AdvancedDownloadSettings form={form}/>
|
|
{showMessage && <SuccessfulDownloadSubmitMessage/>}
|
|
{actionsElm}
|
|
</Stack>
|
|
</form>
|
|
}
|
|
|
|
const suffixValidator = (value) => {
|
|
const error = commaSeparatedValidator(value);
|
|
if (error) {
|
|
return error;
|
|
}
|
|
|
|
const suffixes = value.split(',');
|
|
for (const suffix of suffixes) {
|
|
if (!suffix.startsWith('.')) {
|
|
return 'Suffix must start with .';
|
|
}
|
|
if (suffix.length === 1) {
|
|
return 'Suffix must have characters after .';
|
|
}
|
|
}
|
|
}
|
|
|
|
export function SuffixFormInput({form, name = 'suffix', path = 'settings.suffix'}) {
|
|
return <TextField
|
|
form={form}
|
|
label='File Suffixes'
|
|
helpContent='Comma-separated list of file suffixes that should be downloaded'
|
|
required={true}
|
|
placeholder='.pdf,.mp4'
|
|
name={name}
|
|
path={path}
|
|
validator={suffixValidator}
|
|
/>
|
|
}
|
|
|
|
export function ScrapeFilesDownloadForm({
|
|
download,
|
|
submitter,
|
|
clearOnSuccess,
|
|
onDelete,
|
|
onCancel,
|
|
onSuccess,
|
|
actions = DownloadFormButtons,
|
|
singleDownload = false,
|
|
}) {
|
|
const [showMessage, setShowMessage] = React.useState(false);
|
|
|
|
submitter = submitter || (async (formData) => {
|
|
const downloadData = {
|
|
downloader: Downloaders.ScrapeHtml,
|
|
sub_downloader: Downloaders.File,
|
|
tag_names: formData.tag_names || [],
|
|
destination: formData.destination,
|
|
urls: formData.urls.split(/\r?\n/),
|
|
settings: formData.settings,
|
|
}
|
|
await postDownload(downloadData);
|
|
});
|
|
|
|
const emptyFormData = {
|
|
destination: null,
|
|
tag_names: [],
|
|
urls: '',
|
|
settings: {
|
|
depth: 1,
|
|
max_pages: 1,
|
|
suffix: '',
|
|
}
|
|
};
|
|
|
|
const localOnSuccess = async () => {
|
|
setShowMessage(true);
|
|
if (onSuccess) {
|
|
onSuccess();
|
|
}
|
|
}
|
|
|
|
const form = useForm({
|
|
submitter,
|
|
defaultFormData: mergeDeep(emptyFormData, download),
|
|
emptyFormData,
|
|
clearOnSuccess,
|
|
onSuccess: localOnSuccess,
|
|
});
|
|
|
|
const urlInput = singleDownload ?
|
|
<UrlField required form={form} path='url'/>
|
|
: <UrlsField required form={form}/>;
|
|
|
|
// Default to "new" download buttons.
|
|
actions = actions || DownloadFormButtons;
|
|
const actionsElm = actions({onDelete, onCancel, form});
|
|
|
|
return <form onSubmit={(e) => e.preventDefault()}>
|
|
<Header as='h3' icon='file alternate'>Scrape Files</Header>
|
|
<p>Search each of the URLs for files matching the suffix (.pdf, etc.).</p>
|
|
|
|
<Stack gap='md'>
|
|
{urlInput}
|
|
<Group align='flex-start' wrap='wrap'>
|
|
<div style={{flex: 3, minWidth: 240}}>
|
|
<DestinationForm required={true} form={form}/>
|
|
</div>
|
|
<div style={{flex: 2, minWidth: 200}}>
|
|
<SuffixFormInput required={true} form={form}/>
|
|
</div>
|
|
</Group>
|
|
<Group grow align='flex-start' wrap='wrap'>
|
|
<DepthInputForm form={form} required={true}/>
|
|
<MaximumPagesInputForm form={form} required={true}/>
|
|
</Group>
|
|
{showMessage && <SuccessfulDownloadSubmitMessage/>}
|
|
{actionsElm}
|
|
</Stack>
|
|
</form>
|
|
}
|
|
|
|
export function EditScrapeFilesDownloadForm({download, onDelete, onCancel, onSuccess}) {
|
|
const submitter = async (formData) => {
|
|
const downloadData = {
|
|
collection_id: download.collection_id,
|
|
downloader: Downloaders.ScrapeHtml,
|
|
sub_downloader: Downloaders.File,
|
|
tag_names: formData.tag_names || [],
|
|
destination: formData.destination,
|
|
urls: [formData.url],
|
|
settings: formData.settings,
|
|
}
|
|
await putDownload(download.id, downloadData);
|
|
};
|
|
|
|
return <ScrapeFilesDownloadForm
|
|
download={download}
|
|
submitter={submitter}
|
|
onDelete={onDelete}
|
|
onCancel={onCancel}
|
|
onSuccess={onSuccess}
|
|
actions={EditDownloadFormButtons}
|
|
singleDownload={true}
|
|
/>
|
|
}
|
|
|
|
/** URL field for a single URL. */
|
|
function UrlField({form, required = true, name = 'url', path = 'url', disabled = false}) {
|
|
const validator = (i) => validURL(i) ? null : 'Invalid URL';
|
|
return <TextField
|
|
form={form}
|
|
type='url'
|
|
label='URL'
|
|
required={required}
|
|
name={name}
|
|
path={path}
|
|
validator={validator}
|
|
disabled={disabled}
|
|
/>
|
|
}
|
|
|
|
/** Multiline URLs field: one URL per line. */
|
|
function UrlsField({name = 'urls', required, form}) {
|
|
required = required !== undefined;
|
|
|
|
const validator = (value) => {
|
|
if (!validURLs(value)) {
|
|
return 'Invalid URLs';
|
|
}
|
|
};
|
|
|
|
const [inputProps, inputAttrs] = form.getInputProps({name, validator, required});
|
|
|
|
const handleDrop = (e) => {
|
|
if (e) e.preventDefault();
|
|
const droppedUrl = e.dataTransfer.getData('text');
|
|
let urls = (inputProps.value || '').split('\n');
|
|
urls = [...urls, droppedUrl];
|
|
urls = urls.filter(i => !!i).join('\n');
|
|
inputAttrs.localSetValue(`${urls}\n`);
|
|
};
|
|
|
|
const handleKeyDown = (event) => {
|
|
if ((event.ctrlKey || event.metaKey) && event.key === 'Enter') {
|
|
event.preventDefault();
|
|
form.onSubmit();
|
|
}
|
|
};
|
|
|
|
return <div>
|
|
<FieldLabel htmlFor='urls_textarea' label='URLs' required={required}/>
|
|
<Textarea
|
|
id='urls_textarea'
|
|
placeholder='Enter one URL per line'
|
|
name='urls'
|
|
minRows={4}
|
|
autosize
|
|
value={inputProps.value ?? ''}
|
|
onChange={inputProps.onChange}
|
|
onDrop={handleDrop}
|
|
onKeyDown={handleKeyDown}
|
|
error={inputProps.error}
|
|
data-path={inputProps['data-path']}
|
|
/>
|
|
</div>
|
|
}
|
|
|
|
export function DownloadMenu({onOpen, disabled, initialDownloader, initialUrls}) {
|
|
// initialDownloader and initialUrls support the deep-link flow used by the
|
|
// WROLPi browser extension's "Configure Download" button: visiting
|
|
// `/?downloader=archive&download_url=...` pre-selects a form and pre-fills
|
|
// its URLs textarea via DashboardPage.Getters.
|
|
const [downloader, setDownloader] = useState(initialDownloader || undefined);
|
|
|
|
const localOnOpen = (name) => {
|
|
setDownloader(name);
|
|
if (onOpen) {
|
|
onOpen(name);
|
|
}
|
|
}
|
|
|
|
let body = (<Stack gap='xs'>
|
|
<Button color='blue' disabled={disabled} onClick={() => localOnOpen('video')}>Videos</Button>
|
|
<Button color='green' disabled={disabled} onClick={() => localOnOpen('archive')}>Archives</Button>
|
|
<Button color='blue' disabled={disabled} onClick={() => localOnOpen('video_channel')}>Channel/Playlist</Button>
|
|
<Button disabled={disabled} onClick={() => localOnOpen('rss')}>RSS Feed</Button>
|
|
<Button color='grey' disabled={disabled} onClick={() => localOnOpen('file')}>Files</Button>
|
|
<Button role='danger' disabled={disabled} onClick={() => localOnOpen('scrape')}>Scrape</Button>
|
|
</Stack>);
|
|
|
|
function clearSelected() {
|
|
localOnOpen(null);
|
|
body = null;
|
|
}
|
|
|
|
// Synthesize a partial `download` prop that seeds the form's URL field(s).
|
|
// Multi-URL forms read `urls` (newline-separated string); the single-URL
|
|
// ChannelDownloadForm reads `url`. Setting both is harmless — each form's
|
|
// emptyFormData defines which field actually exists.
|
|
const seed = (initialUrls && initialUrls.length)
|
|
? {urls: initialUrls.join('\n'), url: initialUrls[0]}
|
|
: undefined;
|
|
|
|
const downloaders = {
|
|
archive: <ArchiveDownloadForm onCancel={clearSelected} download={seed}/>,
|
|
video: <VideosDownloadForm singleDownload={false} onCancel={clearSelected} download={seed}/>,
|
|
video_channel: <ChannelDownloadForm onCancel={clearSelected} download={seed}/>,
|
|
rss: <RSSDownloadForm onCancel={clearSelected} download={seed}/>,
|
|
file: <FilesDownloadForm onCancel={clearSelected} download={seed}/>,
|
|
scrape: <ScrapeFilesDownloadForm onCancel={clearSelected} download={seed}/>
|
|
};
|
|
|
|
if (downloader in downloaders) {
|
|
const downloaderForm = downloaders[downloader];
|
|
body = <>
|
|
{downloaderForm}
|
|
</>
|
|
}
|
|
|
|
return <>
|
|
{body}
|
|
</>
|
|
}
|