wrolpi/app/src/components/Channels.js

840 lines
29 KiB
JavaScript

import React, {useState} from "react";
import {Dimmer, Dropdown, Grid, Label, StatisticLabel, StatisticValue, TableCell, TableRow,} from "semantic-ui-react";
import {
createChannel,
createChannelDownload,
deleteChannel,
deleteDownload,
refreshChannel,
tagChannel,
tagChannelInfo,
updateChannel,
updateChannelDownload
} from "../api";
import {
APIButton,
BackButton,
DirectoryInput,
ErrorMessage,
frequencyOptions,
HelpHeader,
humanFileSize,
RequiredAsterisk,
SearchInput,
secondsToFrequency,
secondsToFullDuration,
Toggle,
useTitle,
validURL,
WROLModeMessage
} from "./Common";
import {Link, useNavigate, useParams} from "react-router-dom";
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
import {useChannel, useChannels, useOneQuery, useWROLMode} from "../hooks/customHooks";
import _ from "lodash";
import {
Button,
Form,
FormField,
FormGroup,
FormInput,
Header,
Loader,
Modal,
ModalActions,
ModalContent,
ModalHeader,
Segment,
Statistic
} from "./Theme";
import {Media, ThemeContext} from "../contexts/contexts";
import {SortableTable} from "./SortableTable";
import {toast} from "react-semantic-toasts-2";
import {RecurringDownloadsTable} from "./admin/Downloads";
import {TagsContext, TagsSelector} from "../Tags";
function ChannelStatistics({statistics}) {
if (!statistics) {
return <></>
}
return <Segment>
<Header as='h1'>Statistics</Header>
<Statistic>
<StatisticValue>{statistics.video_count}</StatisticValue>
<StatisticLabel>Videos</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanFileSize(statistics.size, true)}</StatisticValue>
<StatisticLabel>Total Size</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{humanFileSize(statistics.largest_video, true)}</StatisticValue>
<StatisticLabel>Largest Video</StatisticLabel>
</Statistic>
<Statistic>
<StatisticValue>{secondsToFullDuration(statistics.length)}</StatisticValue>
<StatisticLabel>Total Duration</StatisticLabel>
</Statistic>
</Segment>
}
function ChannelPage({create, header}) {
const [disabled, setDisabled] = useState(false);
const [errors, setErrors] = useState({});
const [error, setError] = useState(false);
const [success, setSuccess] = useState(false);
const [messageHeader, setMessageHeader] = useState();
const [messageContent, setMessageContent] = useState();
const [downloadModalOpen, setDownloadModalOpen] = useState(false);
const navigate = useNavigate();
const {channelId} = useParams();
const {channel, changeValue, fetchChannel} = useChannel(channelId);
const {SingleTag} = React.useContext(TagsContext);
const [tagModalOpen, setTagModalOpen] = useState(false);
const [newTagName, setNewTagName] = useState(channel ? channel.tag_name : null);
const [moveToTagDirectory, setMoveToTagDirectory] = useState(true);
const [newTagDirectory, setNewTagDirectory] = useState('');
useTitle(_.isEmpty(channel) ? null : `${channel.name} Channel`);
React.useEffect(() => {
console.debug(channel);
if (!_.isEmpty(channel)) {
setNewTagName(channel.tag_name);
}
}, [JSON.stringify(channel)]);
if (_.isEmpty(channel)) {
return <Loader active/>;
}
const handleCheckbox = (e, {name, checked}) => {
if (e) {
e.preventDefault();
}
changeValue(name, checked);
}
const handleInputChange = (e, {name, value}) => {
if (e) {
e.preventDefault();
}
changeValue(name, value);
}
const setErrorMessage = (header, message) => {
setError(true);
setSuccess(false);
setMessageHeader(header);
setMessageContent(message);
}
const setSuccessMessage = (header, message) => {
setError(false);
setSuccess(true);
setMessageHeader(header);
setMessageContent(message);
}
const handleSubmit = async (e) => {
if (e) {
e.preventDefault();
}
const body = {
name: channel.name,
directory: channel.directory,
mkdir: channel.mkdir,
url: channel.url,
};
setDisabled(true);
let response = null;
try {
if (create !== undefined) {
response = await createChannel(body);
} else {
response = await updateChannel(channelId, body);
}
} catch (e) {
console.error(e);
toast({
type: 'error',
title: 'Unexpected server response',
description: 'Could not save channel',
time: 5000,
});
return;
} finally {
setDisabled(false);
}
if (response && response.status >= 200 && response.status < 300) {
let location = response.headers.get('Location');
let channelResponse = await fetch(location);
let data = await channelResponse.json();
let channel = data['channel'];
if (response.status === 201) {
setSuccessMessage(
'Channel created',
<span>
Your channel was created. View it <Link to={`/videos/channel/${channel.id}/edit`}>here</Link>
</span>
);
} else {
await fetchChannel();
toast({
type: 'success',
title: 'Channel updated',
description: 'The channel was updated',
})
}
} else if (response) {
// Some error occurred.
let error = await response.json();
let cause = error.cause;
if (error.code === 'UNKNOWN_DIRECTORY' || (cause && cause.code === 'UNKNOWN_DIRECTORY')) {
setErrorMessage(
'Invalid directory',
'This directory does not exist.',
'directory',
);
} else if (cause && cause.code === 'CHANNEL_DIRECTORY_CONFLICT') {
setErrorMessage(
'Invalid directory',
'This directory is already used by another channel',
'directory',
);
} else if (cause && cause.code === 'CHANNEL_NAME_CONFLICT') {
setErrorMessage(
'Invalid name',
'This channel name is already taken',
'name',
);
} else {
setErrorMessage('Invalid channel', 'Unable to save channel. See logs.');
}
} else {
console.error('Did not get a response for channel!');
}
}
const handleRefreshChannel = async (e) => {
if (e) {
e.preventDefault();
}
const response = await refreshChannel(channelId);
if (response.status !== 204) {
toast({
type: 'error',
title: 'Failed to refresh',
description: "Failed to refresh this channel's directory",
time: 5000,
})
}
}
const handleDelete = async () => {
try {
let response = await deleteChannel(channelId);
if (response.status === 204) {
navigate('/videos/channel');
}
} catch (e) {
setErrorMessage('Failed to delete', 'Failed to delete this channel, check logs.');
}
}
let downloads = channel && channel.downloads ? channel.downloads : null;
const afterNewDownloadSave = async () => {
await fetchChannel();
setDownloadModalOpen(false);
}
const handleTagModalOpen = (e) => {
if (e) {
e.preventDefault();
}
setTagModalOpen(true);
}
const handleTagChannel = async () => {
try {
await tagChannel(channelId, newTagName, moveToTagDirectory ? newTagDirectory : null);
setTagModalOpen(false);
} catch (e) {
console.error('Failed to tag channel', e);
} finally {
await fetchChannel();
}
}
const handleTagSelect = async (newTagName_) => {
setNewTagName(newTagName_);
try {
const videosDestination = await tagChannelInfo(channelId, newTagName_);
setNewTagDirectory(videosDestination);
} catch (e) {
console.error('Failed to tag channel', e);
}
}
const tagModal = <Modal
open={tagModalOpen}
onClose={() => setTagModalOpen(false)}
closeIcon
>
<ModalHeader>{channel.tag_name ? 'Modify Tag' : 'Add Tag'}</ModalHeader>
<ModalContent>
<TagsSelector
limit={1}
selectedTagNames={newTagName ? [newTagName] : []}
onAdd={handleTagSelect}
onRemove={() => handleTagSelect(null)}
/>
<br/>
<Toggle
label='Move to directory: '
checked={moveToTagDirectory}
onChange={setMoveToTagDirectory}
/>
{newTagDirectory && <Label size='large'>{newTagDirectory}</Label>}
</ModalContent>
<ModalActions>
<Button onClick={() => setTagModalOpen(false)}>Cancel</Button>
<APIButton
color='violet'
onClick={handleTagChannel}
obeyWROLMode={true}
>Save</APIButton>
</ModalActions>
</Modal>;
return <>
<BackButton/>
{!create &&
<Link to={`/videos/channel/${channel.id}/video`}>
<Button>Videos</Button>
</Link>}
<Segment>
<Header as="h1">{header}</Header>
<WROLModeMessage content='Channel page is disabled while WROL Mode is enabled.'/>
<Form
id="editChannel"
error={error}
success={success}
autoComplete="off"
onSubmit={handleSubmit}
>
<FormGroup>
<FormField width={8}>
<FormInput required
label="Channel Name"
name="name"
type="text"
placeholder="Short Channel Name"
disabled={disabled}
error={errors.name}
value={channel.name}
onChange={(e, {value}) => changeValue('name', value)}
/>
</FormField>
<FormField width={8}>
<label>
Directory <RequiredAsterisk/>
</label>
<DirectoryInput required
disabled={create === undefined}
value={channel.directory}
setInput={value => changeValue('directory', value)}
placeholder='videos/channel directory'
/>
</FormField>
</FormGroup>
{
create !== undefined &&
<FormGroup>
<FormField width={8}/>{/* Empty field*/}
<FormField width={8}>
<FormField>
<Toggle
toggle
label="Create this directory, if it doesn't exist."
name="mkdir"
disabled={disabled}
error={errors.mkdir}
checked={channel.mkdir}
onChange={i => {
handleCheckbox(null, {name: 'mkdir', checked: i})
}}
/>
</FormField>
</FormField>
</FormGroup>
}
<FormGroup>
<FormField width={16}>
<FormInput
label="URL"
name="url"
type="url"
placeholder='https://example.com/channel/videos'
error={errors.url}
value={channel.url}
onChange={handleInputChange}
/>
</FormField>
</FormGroup>
{channel.tag_name && <SingleTag name={channel.tag_name}/>}
<Message error
header={messageHeader}
content={messageContent}
/>
<Message success
header={messageHeader}
content={messageContent}
/>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
{!create &&
<>
<APIButton
color='red'
size='small'
confirmContent='Are you sure you want to delete this channel? No video files will be deleted.'
confirmButton='Delete'
onClick={handleDelete}
obeyWROLMode={true}
style={{marginTop: '1em'}}
>Delete</APIButton>
<APIButton
color='blue'
size='small'
onClick={handleRefreshChannel}
obeyWROLMode={true}
style={{marginTop: '1em'}}
>Refresh</APIButton>
</>
}
<Button
size='small'
onClick={handleTagModalOpen}
color='green'
>Tag</Button>
{tagModal}
</Grid.Column>
<Grid.Column>
<APIButton
color='violet'
size='big'
floated='right'
onClick={handleSubmit}
disabled={disabled}
obeyWROLMode={true}
>Save</APIButton>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
</Segment>
{!create && <Segment>
<Grid columns={2}>
<Grid.Row>
<Grid.Column>
<Header as='h1'>Downloads</Header>
</Grid.Column>
<Grid.Column>
<Button floated='right'
onClick={() => setDownloadModalOpen(!downloadModalOpen)}
>
New Download
</Button>
</Grid.Column>
</Grid.Row>
</Grid>
<Modal open={downloadModalOpen} closeIcon onClose={() => setDownloadModalOpen(false)}>
<ModalContent>
<Header as='h2'>New Channel Download</Header>
<ChannelDownloadForm
channel_id={channelId}
afterSave={afterNewDownloadSave}
closeModal={() => setDownloadModalOpen(false)}
/>
</ModalContent>
</Modal>
<RecurringDownloadsTable downloads={downloads} fetchDownloads={fetchChannel}/>
</Segment>}
<div style={{marginTop: '2em'}}>
{channel.statistics && <ChannelStatistics statistics={channel.statistics}/>}
</div>
</>
}
export function ChannelDownloadForm({channel_id, afterSave, closeModal, download}) {
download = download || {};
channel_id = download ? download.channel_id || channel_id : channel_id;
const editing = download && !_.isEmpty(download);
const settings = download.settings ? download.settings : {};
const oldTagNames = download && download.settings && download.settings.tag_names ? download.settings.tag_names : [];
const [state, setState] = React.useState({
frequency: download.frequency ? download.frequency : 604800,
title_exclude: settings.title_exclude ? settings.title_exclude : '',
title_include: settings.title_include ? settings.title_include : '',
url: download ? download.url : '',
})
const [disabled, setDisabled] = React.useState(useWROLMode());
const [loading, setLoading] = React.useState(false);
const [urlValid, setUrlValid] = React.useState(true);
const [tagNames, setTagNames] = React.useState(oldTagNames);
const handleInputChange = (e, {name, value}) => {
if (e) {
e.preventDefault();
}
if (name === 'url') {
setUrlValid(validURL(value));
}
setState({...state, [name]: value});
}
const handleSubmit = async () => {
setLoading(true);
setDisabled(true);
try {
let response;
if (editing) {
response = await updateChannelDownload(
channel_id,
download.id,
state.url,
state.frequency,
state.title_include,
state.title_exclude,
tagNames,
);
} else {
response = await createChannelDownload(
channel_id,
state.url,
state.frequency,
state.title_include,
state.title_exclude,
tagNames,
);
}
if (!response.ok) {
throw 'Creating download failed';
}
if (afterSave) {
await afterSave();
}
} finally {
setLoading(false);
setDisabled(false);
}
}
const handleClose = (e) => {
if (e) e.preventDefault();
closeModal();
}
const handleDelete = async () => {
await deleteDownload(download.id);
if (afterSave) {
afterSave();
}
if (closeModal) {
closeModal();
}
};
const deleteDownloadButton = <APIButton
color='red'
floated='left'
onClick={handleDelete}
confirmContent='Are you sure you want to delete this download?'
confirmButton='Delete'
disabled={disabled}
obeyWROLMode={true}
>Delete</APIButton>;
return <Form onSubmit={handleSubmit}>
{loading && <Dimmer active><Loader/></Dimmer>}
<Grid columns={2} stackable>
<Grid.Row>
<Grid.Column width={12}>
<FormInput
label='URL'
name='url'
type='url'
value={state.url}
placeholder='https://example.com/videos'
onChange={handleInputChange}
error={!urlValid}
/>
</Grid.Column>
<Grid.Column width={4}>
<FormField>
<label>Download Frequency</label>
<Dropdown selection
name='frequency'
placeholder='Frequency'
value={state.frequency}
disabled={disabled}
options={frequencyOptions.slice(1)}
onChange={handleInputChange}
/>
</FormField>
</Grid.Column>
</Grid.Row>
<Grid.Row>
<Grid.Column>
<HelpHeader
headerSize='h4'
headerContent='Title Match Words'
popupContent='List of words, separated by commas, that Video titles must contain to be downloaded.'
popupPosition='bottom center'
/>
<FormInput
name="title_include"
type="text"
disabled={disabled}
placeholder='Shelter,Solar Power'
value={state.title_include}
onChange={handleInputChange}
/>
</Grid.Column>
<Grid.Column>
<HelpHeader
headerSize='h4'
headerContent='Title Exclusion Words'
popupContent='List of words, separated by commas, that may not appear in video titles to be downloaded.'
popupPosition='bottom center'
/>
<FormInput
name="title_exclude"
type="text"
disabled={disabled}
placeholder='Giveaway,Prize'
value={state.title_exclude}
onChange={handleInputChange}
/>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={1}>
<Grid.Column>
<TagsSelector selectedTagNames={tagNames} onChange={(i, j) => setTagNames(i)}/>
</Grid.Column>
</Grid.Row>
<Grid.Row columns={1}>
<Grid.Column textAlign='right'>
{editing && deleteDownloadButton}
<Button onClick={handleClose}>Cancel</Button>
<Button color='violet' disabled={!urlValid}>Save</Button>
</Grid.Column>
</Grid.Row>
</Grid>
</Form>
}
export function ChannelEditPage(props) {
return <ChannelPage header="Edit Channel" {...props}/>;
}
export function ChannelNewPage(props) {
useTitle('New Channel');
return <ChannelPage header='New Channel' {...props} create/>
}
function ChannelRow({channel}) {
const {SingleTag} = React.useContext(TagsContext);
const videosTo = `/videos/channel/${channel.id}/video`;
const {inverted} = React.useContext(ThemeContext);
const editTo = `/videos/channel/${channel.id}/edit`;
const buttonClass = `ui button secondary ${inverted}`;
return <TableRow>
<TableCell>
<Link to={videosTo}>{channel.name}</Link>
</TableCell>
<TableCell>
{channel.tag_name ? <SingleTag name={channel.tag_name}/> : null}
</TableCell>
<TableCell>
{channel.video_count}
</TableCell>
<TableCell>
{channel.url && channel.minimum_frequency ? secondsToFrequency(channel.minimum_frequency) : null}
</TableCell>
<TableCell>
{channel.size ? humanFileSize(channel.size) : null}
</TableCell>
<TableCell textAlign='right'>
<Link className={buttonClass} to={editTo}>Edit</Link>
</TableCell>
</TableRow>;
}
function MobileChannelRow({channel}) {
const {SingleTag} = React.useContext(TagsContext);
const editTo = `/videos/channel/${channel.id}/edit`;
const videosTo = `/videos/channel/${channel.id}/video`;
return <TableRow verticalAlign='top'>
<TableCell width={10} colSpan={2}>
<Link as='h3' to={videosTo}>
<h3>
{channel.name}
</h3>
{channel.tag_name ? <SingleTag name={channel.tag_name}/> : null}
</Link>
<p>
Videos: {channel.video_count}
</p>
</TableCell>
<TableCell width={6} colSpan={2} textAlign='right'>
<p>
<Link className="ui button secondary" to={editTo}>Edit</Link>
</p>
</TableCell>
</TableRow>;
}
export function ChannelsPage() {
useTitle('Channels');
const {channels} = useChannels();
const [searchStr, setSearchStr] = useOneQuery('name');
// Hides Channels with few videos.
const [hideSmall, setHideSmall] = React.useState(true);
const enoughChannelsToHideSmall = channels && channels.length > 20;
const header = <div style={{marginBottom: '1em'}}>
<Grid stackable columns={2}>
<Grid.Row>
<Grid.Column>
<SearchInput
placeholder='Name filter...'
size='large'
searchStr={searchStr}
disabled={!Array.isArray(channels) || channels.length === 0}
onClear={() => setSearchStr('')}
onChange={setSearchStr}
onSubmit={null}
/>
</Grid.Column>
<Grid.Column textAlign='right'>
<Link to='/videos/channel/new'>
<Button secondary>New Channel</Button>
</Link>
</Grid.Column>
</Grid.Row>
</Grid>
</div>;
const headers = [
{key: 'name', text: 'Name', sortBy: 'name', width: 8},
{key: 'tag', text: 'Tag', sortBy: 'tag_name', width: 2},
{key: 'video_count', text: 'Videos', sortBy: 'video_count', width: 2},
{key: 'download_frequency', text: 'Download Frequency', sortBy: 'minimum_frequency', width: 2},
{key: 'size', text: 'Size', sortBy: 'size', width: 2},
{key: 'manage', text: 'Manage', width: 2},
];
const mobileHeaders = [
{key: 'name', text: 'Name', sortBy: 'name'},
{key: 'video_count', text: 'Videos', sortBy: 'video_count'},
{key: 'manage', text: 'Manage'},
];
if (channels && channels.length === 0) {
return <>
{header}
<Message>
<Message.Header>No channels exist yet!</Message.Header>
<Message.Content><Link to='/videos/channel/new'>Create one.</Link></Message.Content>
</Message>
</>
} else if (channels === undefined) {
return <>
{header}
<ErrorMessage>Could not fetch Channels</ErrorMessage>
</>
}
let filteredChannels = channels;
if (searchStr && Array.isArray(channels)) {
const re = new RegExp(_.escapeRegExp(searchStr), 'i');
filteredChannels = channels.filter(i => re.test(i['name']));
} else if (channels && hideSmall && enoughChannelsToHideSmall) {
// Get the top 80% of Channels.
let index80 = Math.floor(channels.length * 0.8);
const sortedChannels = channels.sort((a, b) => b.video_count - a.video_count);
let percentile = sortedChannels[index80].video_count;
filteredChannels = channels.filter(i => {
return i.video_count > percentile || i.name.toLowerCase() === 'wrolpi'
});
if (filteredChannels.length < 21) {
// Filtering hid too many Channels, show them all.
filteredChannels = channels;
}
}
return <>
{header}
<Media at='mobile'>
<SortableTable
tableProps={{striped: true, size: 'small', unstackable: true}}
data={filteredChannels}
tableHeaders={mobileHeaders}
defaultSortColumn='name'
rowKey='id'
rowFunc={(i, sortData) => <MobileChannelRow key={i.id} channel={i} sortData={sortData}/>}
/>
</Media>
<Media greaterThanOrEqual='tablet'>
<SortableTable
tableProps={{striped: true, size: 'large', unstackable: true, compact: true}}
data={filteredChannels}
tableHeaders={headers}
defaultSortColumn='name'
rowKey='id'
rowFunc={(i, sortData) => <ChannelRow key={i.id} channel={i} sortData={sortData}/>}
/>
</Media>
<Grid textAlign='center'>
<Grid.Row>
<Grid.Column>
<Button
style={{marginTop: '1em'}}
onClick={() => setHideSmall(!hideSmall)}
size='big'
disabled={!enoughChannelsToHideSmall}
>Show {hideSmall ? 'More' : 'Less'}</Button>
</Grid.Column>
</Grid.Row>
</Grid>
</>
}