mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
Phase 2, part 4. Baseline 50 -> 15.
Migrated: all eight calculators, the rest of inventory and collections, the
Map/Zim/Docs cluster, Videos, Files, Search, Archive, DashboardPage,
DatesSelector, HelpModal, Events, Tags, Flasher's neighbours, admin Settings
and Status, Download, and App.
Also the three shared modules everything imports: api.js and customHooks.js
swap the Semantic toast library for the library's shim (same call shape), and
useForm.js renders the library's Message. useForm accepts both the old
`{content, header, positive|negative}` shape and the new `{kind, title}`,
because the pages building those objects migrate separately.
Two library gaps closed, both found by call sites needing them:
ActionInput is a forwardRef now. Its ref reaches the input rather than the
wrapper, because callers use it to focus or select the field -- a ref to the
surrounding div would do neither, which is why one call site had resorted to
hand-copying the component's markup.
Header takes a `color` token name. Three separate call sites had reached for
an inline style to colour a heading that carries meaning ("3 items to add"
against "2 to remove"), which is how a hex ends up in markup.
Two files needed repair after being interrupted mid-edit: MapViewer had a
Segment left in its error branch, and VideoPlayer had lost the useContext
import its own code still used. Neither jest nor tsc noticed; only
`npm run build` did, which is the whole reason that check exists.
Fifteen files remain: Archive, BulkTagModal, Channels, Docs, FileBrowser,
FilePreview, Flasher, Nav, Playlists, Theme.tsx, Upload, VideoPlayer, Zim,
ControllerPage, admin/Downloads. Theme.tsx is deliberately last -- the
unmigrated files still import its Semantic-backed wrappers, so it can only go
once they have.
930 lines
32 KiB
JavaScript
930 lines
32 KiB
JavaScript
import React, {useEffect, useRef, useState} from 'react';
|
|
import {Link, Outlet, useParams} from "react-router";
|
|
import {useHotkeys} from "react-hotkeys-hook";
|
|
import {
|
|
APIButton,
|
|
CardLink,
|
|
CardPoster,
|
|
Duration,
|
|
encodeMediaPath,
|
|
ErrorMessage,
|
|
FileIcon,
|
|
findPosterPath,
|
|
humanFileSize,
|
|
humanNumber,
|
|
HelpHeader,
|
|
InfoHeader,
|
|
isoDatetimeToAgoPopup,
|
|
PageContainer,
|
|
PreviewLink,
|
|
scrollToTop,
|
|
secondsToFullDuration,
|
|
TabLinks,
|
|
textEllipsis,
|
|
useTitle,
|
|
CookiesUnlockModal
|
|
} from "./Common"
|
|
import VideoPage from "./VideoPlayer";
|
|
import {
|
|
ActionInput,
|
|
Button,
|
|
Card,
|
|
Header,
|
|
Icon,
|
|
Loading,
|
|
Modal,
|
|
NumberInput,
|
|
Panel,
|
|
Placeholder,
|
|
Statistic,
|
|
StatisticGroup,
|
|
Table,
|
|
Textarea,
|
|
TextInput,
|
|
Tooltip,
|
|
} from "./ui";
|
|
import {useChannel, useSearchOrder, useSearchVideos, useVideo, useVideoStatistics} from "../hooks/customHooks";
|
|
import {DeepSearchHint, FileRowTagIcon, FilesView, SearchControlBar} from "./Files";
|
|
import {BulkTagModal} from "./BulkTagModal";
|
|
import {TaggedDeleteConfirmModal} from "./TaggedDeleteConfirmModal";
|
|
import {
|
|
deleteFileGroups,
|
|
deleteCookies,
|
|
fetchSuggestedUserAgent,
|
|
fetchVideoDownloaderConfig,
|
|
getCookiesStatus,
|
|
lockCookies,
|
|
postVideoFileFormat,
|
|
previewBatchReorganization,
|
|
updateVideoDownloaderConfig,
|
|
uploadCookies,
|
|
} from "../api";
|
|
import {QueryContext} from "../contexts/contexts";
|
|
import _ from "lodash";
|
|
import {defaultFileOrder, defaultSearchOrder} from "./Vars";
|
|
import {InputForm, ToggleForm, useForm} from "../hooks/useForm";
|
|
import {VideoFormatSelectorForm, VideoResolutionSelectorForm} from "./Download";
|
|
import {BatchReorganizeModal} from "./collections/BatchReorganizeModal";
|
|
|
|
export function VideoWrapper() {
|
|
const {fileGroupId} = useParams();
|
|
const {videoFile, prevFile, nextFile, fetchVideo} = useVideo(fileGroupId);
|
|
|
|
// Scroll to the top when fileGroupId changes.
|
|
useEffect(scrollToTop, [fileGroupId]);
|
|
|
|
return <VideoPage videoFile={videoFile} prevFile={prevFile} nextFile={nextFile} fetchVideo={fetchVideo}
|
|
autoplay={true}/>
|
|
}
|
|
|
|
export function VideosPage() {
|
|
|
|
const {channelId} = useParams();
|
|
const {searchParams} = React.useContext(QueryContext);
|
|
const [selectedVideos, setSelectedVideos] = useState([]);
|
|
const [bulkTagOpen, setBulkTagOpen] = useState(false);
|
|
const [taggedFileGroups, setTaggedFileGroups] = useState(null);
|
|
const searchInputRef = React.useRef();
|
|
|
|
useHotkeys('f', (e) => {
|
|
e.preventDefault();
|
|
if (searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, {enableOnFormTags: false});
|
|
|
|
let searchOrder = defaultFileOrder;
|
|
if (searchParams.get('order')) {
|
|
// Use whatever order the user specified.
|
|
searchOrder = searchParams.get('order');
|
|
} else if (searchParams.get('q')) {
|
|
// User used a search_str
|
|
searchOrder = defaultSearchOrder;
|
|
}
|
|
|
|
const {searchStr, setSearchStr, videos, activePage, setPage, totalPages, fetchVideos, loading} =
|
|
useSearchVideos(null, channelId, searchOrder);
|
|
|
|
const {channel} = useChannel(channelId);
|
|
|
|
let title = 'Videos';
|
|
if (channel && channel.name) {
|
|
title = `${channel.name} Videos`;
|
|
}
|
|
useTitle(title);
|
|
|
|
let videoOrders = [
|
|
{value: 'published_datetime', text: 'Published Date', short: 'P.Date'},
|
|
{value: 'length', text: 'Length'},
|
|
{value: 'size', text: 'Size'},
|
|
{value: 'size_to_duration', text: 'Size to Duration', short: 'S/Duration'},
|
|
{value: 'view_count', text: 'Views'},
|
|
{value: 'viewed', text: 'Recently Viewed', short: 'R.Viewed'},
|
|
{value: 'download_datetime', text: 'Download Date', short: 'D.Date'},
|
|
]
|
|
|
|
if (searchStr) {
|
|
videoOrders = [{value: 'rank', text: 'Search'}, ...videoOrders];
|
|
}
|
|
|
|
let header;
|
|
if (channel && channel.name) {
|
|
const editLink = `/videos/channel/${channelId}/edit`;
|
|
header = <>
|
|
<Header as='h1'>
|
|
{channel.name}
|
|
<Link to={editLink}>
|
|
<Icon name='edit' style={{marginLeft: '0.5em'}}/>
|
|
</Link>
|
|
</Header>
|
|
</>;
|
|
} else if (channelId) {
|
|
header = <div style={{marginBottom: '1em'}}><Placeholder lines={1}/></div>;
|
|
}
|
|
|
|
const onSelect = (path, checked) => {
|
|
const newSelectedVideos = checked && path ? [...selectedVideos, path] : selectedVideos.filter(i => i !== path);
|
|
console.debug(`Selected Videos: ${newSelectedVideos}`);
|
|
setSelectedVideos(newSelectedVideos);
|
|
}
|
|
|
|
const onDelete = async (force = false) => {
|
|
const fileGroupIds = videos.filter(i => selectedVideos.indexOf(i['primary_path']) >= 0).map(i => i['id']);
|
|
const result = await deleteFileGroups(fileGroupIds, force);
|
|
if (result && result.tagged) {
|
|
setTaggedFileGroups(result.file_groups);
|
|
return;
|
|
}
|
|
await fetchVideos();
|
|
setSelectedVideos([]);
|
|
}
|
|
|
|
const invertSelection = async () => {
|
|
const newSelectedVideos = videos.map(video => video['key']).filter(i => selectedVideos.indexOf(i) < 0);
|
|
setSelectedVideos(newSelectedVideos);
|
|
}
|
|
|
|
const clearSelection = async (e) => {
|
|
if (e) e.preventDefault();
|
|
setSelectedVideos([]);
|
|
}
|
|
|
|
const onBulkTagComplete = async () => {
|
|
await fetchVideos();
|
|
setSelectedVideos([]);
|
|
}
|
|
|
|
const selectElm = <div style={{marginTop: '0.5em'}}>
|
|
<Button
|
|
role='primary'
|
|
disabled={_.isEmpty(selectedVideos)}
|
|
onClick={() => setBulkTagOpen(true)}
|
|
>Tag</Button>
|
|
<APIButton
|
|
role='danger'
|
|
disabled={_.isEmpty(selectedVideos)}
|
|
confirmButton='Delete'
|
|
confirmContent='Are you sure you want to delete these video files? This cannot be undone.'
|
|
onClick={() => onDelete(false)}
|
|
>Delete</APIButton>
|
|
<Button
|
|
role='cancel'
|
|
onClick={() => invertSelection()}
|
|
disabled={_.isEmpty(videos)}
|
|
>
|
|
Invert
|
|
</Button>
|
|
<Button
|
|
role='cancel'
|
|
onClick={() => clearSelection()}
|
|
disabled={_.isEmpty(videos) || _.isEmpty(selectedVideos)}
|
|
>
|
|
Clear
|
|
</Button>
|
|
<BulkTagModal
|
|
open={bulkTagOpen}
|
|
onClose={() => setBulkTagOpen(false)}
|
|
paths={selectedVideos}
|
|
onComplete={onBulkTagComplete}
|
|
/>
|
|
</div>;
|
|
|
|
const {body, paginator, viewButton} = FilesView(
|
|
{
|
|
files: videos,
|
|
activePage: activePage,
|
|
totalPages: totalPages,
|
|
selectElem: selectElm,
|
|
selectedKeys: selectedVideos,
|
|
onSelect: onSelect,
|
|
setPage: setPage,
|
|
headlines: !!searchStr,
|
|
loading: loading,
|
|
},
|
|
);
|
|
|
|
return <>
|
|
{header}
|
|
<SearchControlBar
|
|
searchStr={searchStr}
|
|
setSearchStr={setSearchStr}
|
|
placeholder='Search Videos...'
|
|
inputRef={searchInputRef}
|
|
viewButton={viewButton}
|
|
sorts={videoOrders}
|
|
showCensored={true}
|
|
showDeep={true}
|
|
/>
|
|
{body}
|
|
<DeepSearchHint searchStr={searchStr} files={videos}/>
|
|
{paginator}
|
|
<TaggedDeleteConfirmModal
|
|
open={taggedFileGroups !== null}
|
|
taggedFileGroups={taggedFileGroups}
|
|
onCancel={() => setTaggedFileGroups(null)}
|
|
onConfirm={async () => {
|
|
setTaggedFileGroups(null);
|
|
await onDelete(true);
|
|
}}
|
|
/>
|
|
</>
|
|
}
|
|
|
|
function VideoFileNameForm({form}) {
|
|
const [message, setMessage] = React.useState(null);
|
|
|
|
const debouncedOnChange = React.useCallback(
|
|
_.debounce(async (value) => {
|
|
const response = await postVideoFileFormat(value);
|
|
const {error, preview} = await response.json();
|
|
if (error) {
|
|
setMessage({content: error, header: 'Invalid File Name', negative: true});
|
|
} else {
|
|
setMessage({content: preview, header: 'File Name Preview', positive: true});
|
|
}
|
|
}, 300),
|
|
[]
|
|
);
|
|
|
|
// Cleanup debounce on unmount
|
|
React.useEffect(() => {
|
|
return () => debouncedOnChange.cancel();
|
|
}, [debouncedOnChange]);
|
|
|
|
const onChange = debouncedOnChange;
|
|
|
|
const label = <InfoHeader
|
|
headerSize='h5'
|
|
headerContent='Video File Format'
|
|
popupContent={<>
|
|
<p>Common yt-dlp variables:</p>
|
|
<ul>
|
|
<li><code>%(title)s</code> - Video title</li>
|
|
<li><code>%(uploader)s</code> - Channel/uploader name</li>
|
|
<li><code>%(upload_date)s</code> - Upload date (YYYYMMDD)</li>
|
|
<li><code>%(upload_year)s</code> - Upload year (YYYY)</li>
|
|
<li><code>%(upload_month)s</code> - Upload month (01-12)</li>
|
|
<li><code>%(id)s</code> - Video ID</li>
|
|
<li><code>%(channel)s</code> - Channel name</li>
|
|
<li><code>%(duration)s</code> - Duration in seconds</li>
|
|
<li><code>%(playlist_index)s</code> - Playlist index</li>
|
|
<li><code>%(ext)s</code> - File extension (required, must be at end)</li>
|
|
</ul>
|
|
<p>See yt-dlp docs for all variables. Subdirectories supported.</p>
|
|
</>}
|
|
/>;
|
|
|
|
return <InputForm
|
|
form={form}
|
|
name='file_name_format'
|
|
path='yt_dlp_options.file_name_format'
|
|
label={label}
|
|
onChange={onChange}
|
|
message={message}
|
|
/>
|
|
}
|
|
|
|
const settingsGridStyle = {
|
|
display: 'grid',
|
|
gridTemplateColumns: 'repeat(auto-fit, minmax(240px, 1fr))',
|
|
gap: '1em',
|
|
marginBottom: '1em',
|
|
};
|
|
|
|
export function VideosSettingsPage() {
|
|
useTitle('Videos Settings');
|
|
|
|
const [batchModalOpen, setBatchModalOpen] = useState(false);
|
|
const [channelsNeedingReorg, setChannelsNeedingReorg] = useState(0);
|
|
const [fetchingReorgCount, setFetchingReorgCount] = useState(true);
|
|
|
|
// Check how many channels need reorganization on mount
|
|
useEffect(() => {
|
|
setFetchingReorgCount(true);
|
|
previewBatchReorganization('channel')
|
|
.then(data => {
|
|
setChannelsNeedingReorg(data.total_collections || 0);
|
|
})
|
|
.catch(() => {
|
|
setChannelsNeedingReorg(0);
|
|
})
|
|
.finally(() => {
|
|
setFetchingReorgCount(false);
|
|
});
|
|
}, []);
|
|
|
|
const emptyFormData = {
|
|
video_resolutions: ['1080p', '720p', '480p', 'maximum'],
|
|
yt_dlp_options: {
|
|
continue_dl: true,
|
|
file_name_format: '%(uploader)s_%(upload_date)s_%(id)s_%(title)s.%(ext)s',
|
|
merge_output_format: 'mp4',
|
|
nooverwrites: true,
|
|
writeautomaticsub: true,
|
|
writeinfojson: true,
|
|
writesubtitles: true,
|
|
writethumbnail: true,
|
|
},
|
|
yt_dlp_extra_args: '',
|
|
download_missing_video_comments: false,
|
|
user_agent: '',
|
|
sleep_requests: 0.75,
|
|
};
|
|
|
|
const configSubmitter = async () => {
|
|
return await updateVideoDownloaderConfig(configForm.formData);
|
|
};
|
|
|
|
const configForm = useForm({
|
|
fetcher: fetchVideoDownloaderConfig,
|
|
submitter: configSubmitter,
|
|
emptyFormData,
|
|
});
|
|
|
|
const [suggestedUserAgent, setSuggestedUserAgent] = useState('');
|
|
|
|
React.useEffect(() => {
|
|
const fetchSuggested = async () => {
|
|
const ua = await fetchSuggestedUserAgent();
|
|
setSuggestedUserAgent(ua);
|
|
};
|
|
fetchSuggested();
|
|
}, []);
|
|
|
|
const downloadMissingVideoCommentsLabel = <InfoHeader
|
|
headerSize='h5'
|
|
headerContent='Download Missing Video Comments'
|
|
popupContent='When enabled, WROLPi will automatically download comments for videos that are missing them.'
|
|
/>;
|
|
|
|
return <>
|
|
<Panel>
|
|
<Header as='h3'>Video Downloader Config</Header>
|
|
|
|
<form onSubmit={e => e.preventDefault()}>
|
|
<div style={settingsGridStyle}>
|
|
<VideoResolutionSelectorForm
|
|
form={configForm}
|
|
name='video_resolutions'
|
|
path='video_resolutions'
|
|
/>
|
|
<VideoFormatSelectorForm
|
|
form={configForm}
|
|
name='merge_output_format'
|
|
path='yt_dlp_options.merge_output_format'
|
|
/>
|
|
<VideoFileNameForm form={configForm}/>
|
|
</div>
|
|
<div style={settingsGridStyle}>
|
|
<ToggleForm
|
|
form={configForm}
|
|
label='Do not overwrite existing files'
|
|
name='nooverwrites'
|
|
path='yt_dlp_options.nooverwrites'
|
|
icon='file video'
|
|
/>
|
|
<ToggleForm
|
|
form={configForm}
|
|
label='Download automatic subtitles'
|
|
name='writeautomaticsub'
|
|
path='yt_dlp_options.writeautomaticsub'
|
|
icon='closed captioning'
|
|
/>
|
|
</div>
|
|
<div style={settingsGridStyle}>
|
|
<ToggleForm
|
|
form={configForm}
|
|
label='Download subtitles'
|
|
name='writesubtitles'
|
|
path='yt_dlp_options.writesubtitles'
|
|
icon='closed captioning outline'
|
|
/>
|
|
<ToggleForm
|
|
form={configForm}
|
|
label='Download thumbnail'
|
|
name='writethumbnail'
|
|
path='yt_dlp_options.writethumbnail'
|
|
icon='image'
|
|
/>
|
|
</div>
|
|
<div style={settingsGridStyle}>
|
|
<ToggleForm
|
|
form={configForm}
|
|
label='Download info JSON'
|
|
name='writeinfojson'
|
|
path='yt_dlp_options.writeinfojson'
|
|
icon='file code'
|
|
/>
|
|
<ToggleForm
|
|
form={configForm}
|
|
label='Continue partial downloads'
|
|
name='continue_dl'
|
|
path='yt_dlp_options.continue_dl'
|
|
icon='play'
|
|
/>
|
|
</div>
|
|
<div style={settingsGridStyle}>
|
|
<ToggleForm
|
|
form={configForm}
|
|
label={downloadMissingVideoCommentsLabel}
|
|
name='download_missing_video_comments'
|
|
path='download_missing_video_comments'
|
|
icon='comments'
|
|
/>
|
|
</div>
|
|
<div style={settingsGridStyle}>
|
|
<InputForm
|
|
form={configForm}
|
|
name='yt_dlp_extra_args'
|
|
path='yt_dlp_extra_args'
|
|
label='Extra yt-dlp Arguments'
|
|
placeholder='--prefer-free-formats'
|
|
icon='terminal'
|
|
/>
|
|
<div>
|
|
<InfoHeader
|
|
headerSize='h5'
|
|
headerContent='Sleep Between Requests'
|
|
popupContent='Seconds to sleep between yt-dlp API requests. Helps avoid rate limiting and bot detection. Set to 0 to disable. Does not affect video download speed.'
|
|
/>
|
|
<NumberInput
|
|
step={0.25}
|
|
min={0}
|
|
max={10}
|
|
value={configForm.formData.sleep_requests ?? 0.75}
|
|
onChange={(value) => configForm.setValue('sleep_requests', parseFloat(value) || 0)}
|
|
placeholder='0.75'
|
|
rightSection={<span style={{fontSize: 12, color: 'var(--muted)'}}>sec</span>}
|
|
rightSectionWidth={36}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div style={settingsGridStyle}>
|
|
<div style={{gridColumn: '1 / -1'}}>
|
|
<InfoHeader
|
|
headerSize='h5'
|
|
headerContent='User Agent'
|
|
popupContent="Custom user-agent string for yt-dlp. Should match the browser used to export cookies. Click "Use Current Browser" to auto-fill with your browser's user-agent."
|
|
/>
|
|
<ActionInput
|
|
value={configForm.formData.user_agent || ''}
|
|
onChange={(e) => configForm.setValue('user_agent', e.currentTarget.value)}
|
|
placeholder='Leave empty to use yt-dlp default'
|
|
action={<Button role='cancel'
|
|
onClick={() => configForm.setValue('user_agent', suggestedUserAgent)}>
|
|
Use Current Browser
|
|
</Button>}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{textAlign: 'right'}}>
|
|
<APIButton
|
|
role='primary'
|
|
disabled={configForm.disabled || !configForm.ready}
|
|
type='submit'
|
|
style={{marginTop: '0.5em'}}
|
|
onClick={configForm.onSubmit}
|
|
id='video_settings_save_button'
|
|
>Save</APIButton>
|
|
</div>
|
|
</form>
|
|
</Panel>
|
|
|
|
<Panel>
|
|
<Header as='h4'>File Organization</Header>
|
|
<p>
|
|
{fetchingReorgCount
|
|
? 'Checking for channels that need reorganization...'
|
|
: <>
|
|
<strong>{channelsNeedingReorg}</strong> channel{channelsNeedingReorg !== 1 ? 's' : ''}
|
|
{channelsNeedingReorg > 0
|
|
? ' have files that do not match the current file name format.'
|
|
: '. All channels are organized correctly.'}
|
|
</>
|
|
}
|
|
</p>
|
|
<Button
|
|
role='retry'
|
|
icon='folder open outline'
|
|
onClick={() => setBatchModalOpen(true)}
|
|
id='reorganize_all_channels_button'
|
|
disabled={fetchingReorgCount || channelsNeedingReorg === 0}
|
|
loading={fetchingReorgCount}
|
|
>
|
|
Reorganize All Channels
|
|
</Button>
|
|
</Panel>
|
|
|
|
<CookiesSettingsSection/>
|
|
|
|
<BatchReorganizeModal
|
|
open={batchModalOpen}
|
|
onClose={() => setBatchModalOpen(false)}
|
|
kind='channel'
|
|
onComplete={() => {
|
|
setBatchModalOpen(false);
|
|
setChannelsNeedingReorg(0);
|
|
}}
|
|
/>
|
|
</>
|
|
}
|
|
|
|
function CookiesSettingsSection() {
|
|
const [status, setStatus] = useState({cookies_exist: false, cookies_unlocked: false});
|
|
const [loading, setLoading] = useState(true);
|
|
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
|
const [unlockModalOpen, setUnlockModalOpen] = useState(false);
|
|
const [cookiesContent, setCookiesContent] = useState('');
|
|
const [password, setPassword] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const cookiesContentRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
if (uploadModalOpen) {
|
|
const timer = setTimeout(() => {
|
|
if (cookiesContentRef.current) {
|
|
cookiesContentRef.current.focus();
|
|
}
|
|
}, 100);
|
|
return () => clearTimeout(timer);
|
|
}
|
|
}, [uploadModalOpen]);
|
|
|
|
const fetchStatus = async () => {
|
|
setLoading(true);
|
|
const result = await getCookiesStatus();
|
|
if (result) {
|
|
setStatus(result);
|
|
}
|
|
setLoading(false);
|
|
};
|
|
|
|
useEffect(() => {
|
|
fetchStatus();
|
|
}, []);
|
|
|
|
const handleUpload = async () => {
|
|
if (password.length < 8) {
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
const result = await uploadCookies(cookiesContent, password);
|
|
setSubmitting(false);
|
|
if (result.success) {
|
|
setUploadModalOpen(false);
|
|
setCookiesContent('');
|
|
setPassword('');
|
|
fetchStatus();
|
|
}
|
|
};
|
|
|
|
const handleLock = async () => {
|
|
setSubmitting(true);
|
|
await lockCookies();
|
|
setSubmitting(false);
|
|
fetchStatus();
|
|
};
|
|
|
|
const handleDelete = async () => {
|
|
setSubmitting(true);
|
|
await deleteCookies();
|
|
setSubmitting(false);
|
|
fetchStatus();
|
|
};
|
|
|
|
// Status is encoded by color like any other status label; no filled "success/warning"
|
|
// words map directly to a token, so the wrapper picks the token color explicitly.
|
|
const getStatusLabel = () => {
|
|
if (loading) return <span style={{color: 'var(--muted)'}}>Loading...</span>;
|
|
if (!status.cookies_exist) return <span style={{color: 'var(--muted)'}}>No cookies stored</span>;
|
|
if (status.cookies_unlocked) return <span style={{color: 'var(--green)'}}>Unlocked</span>;
|
|
return <span style={{color: 'var(--amber)'}}>Locked</span>;
|
|
};
|
|
|
|
return <Panel>
|
|
<HelpHeader
|
|
headerSize='h4'
|
|
headerContent='Encrypted Cookies'
|
|
helpPath='/modules/videos/cookies/'
|
|
/>
|
|
|
|
<p>Use this feature at your own risk! You may be suspended or blocked if abused!
|
|
</p>
|
|
|
|
<div style={{marginBottom: '1em'}}>
|
|
<strong>Status:</strong> {getStatusLabel()}
|
|
</div>
|
|
|
|
<div style={{display: 'flex', gap: '0.5em', flexWrap: 'wrap'}}>
|
|
{!status.cookies_exist && (
|
|
<Button
|
|
role='primary'
|
|
icon='upload'
|
|
onClick={() => setUploadModalOpen(true)}
|
|
disabled={loading}
|
|
>
|
|
Upload Cookies
|
|
</Button>
|
|
)}
|
|
|
|
{status.cookies_exist && !status.cookies_unlocked && (
|
|
<Button
|
|
role='save'
|
|
icon='unlock'
|
|
onClick={() => setUnlockModalOpen(true)}
|
|
disabled={loading}
|
|
>
|
|
Unlock Cookies
|
|
</Button>
|
|
)}
|
|
|
|
{status.cookies_exist && status.cookies_unlocked && (
|
|
<Button
|
|
role='retry'
|
|
icon='lock'
|
|
onClick={handleLock}
|
|
loading={submitting}
|
|
disabled={loading || submitting}
|
|
>
|
|
Lock Cookies
|
|
</Button>
|
|
)}
|
|
|
|
{status.cookies_exist && (
|
|
<>
|
|
<Button
|
|
role='primary'
|
|
icon='refresh'
|
|
onClick={() => setUploadModalOpen(true)}
|
|
disabled={loading}
|
|
>
|
|
Replace
|
|
</Button>
|
|
<APIButton
|
|
role='danger'
|
|
icon='trash'
|
|
onClick={handleDelete}
|
|
confirmButton='Delete'
|
|
confirmContent='Are you sure you want to delete the stored cookies? This cannot be undone.'
|
|
disabled={loading || submitting}
|
|
>
|
|
Delete
|
|
</APIButton>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Upload Modal */}
|
|
<Modal
|
|
open={uploadModalOpen}
|
|
onClose={() => setUploadModalOpen(false)}
|
|
size='small'
|
|
>
|
|
<Modal.Header>Upload Cookies</Modal.Header>
|
|
<Modal.Content>
|
|
<form onSubmit={e => e.preventDefault()}>
|
|
<Textarea
|
|
ref={cookiesContentRef}
|
|
label='Cookies Content'
|
|
placeholder='Paste your cookies.txt content here (Netscape/Mozilla format)'
|
|
value={cookiesContent}
|
|
onChange={(e) => setCookiesContent(e.currentTarget.value)}
|
|
minRows={10}
|
|
/>
|
|
<div style={{marginTop: '1em'}}>
|
|
<TextInput
|
|
label='Encryption Password'
|
|
type='password'
|
|
placeholder='Minimum 8 characters'
|
|
value={password}
|
|
onChange={(e) => setPassword(e.currentTarget.value)}
|
|
error={password.length > 0 && password.length < 8
|
|
? 'Password must be at least 8 characters' : null}
|
|
/>
|
|
</div>
|
|
<p style={{color: 'var(--muted)', fontSize: '0.9em'}}>
|
|
Remember this password - you'll need it to unlock cookies after each restart.
|
|
</p>
|
|
</form>
|
|
</Modal.Content>
|
|
<Modal.Actions>
|
|
<Button role='cancel' onClick={() => setUploadModalOpen(false)}>Cancel</Button>
|
|
<Button
|
|
role='save'
|
|
icon='lock'
|
|
onClick={handleUpload}
|
|
loading={submitting}
|
|
disabled={!cookiesContent || password.length < 8 || submitting}
|
|
>
|
|
Encrypt & Save
|
|
</Button>
|
|
</Modal.Actions>
|
|
</Modal>
|
|
|
|
<CookiesUnlockModal
|
|
open={unlockModalOpen}
|
|
onClose={() => setUnlockModalOpen(false)}
|
|
onSuccess={fetchStatus}
|
|
/>
|
|
</Panel>;
|
|
}
|
|
|
|
export function VideosStatistics() {
|
|
useTitle('Video Statistics');
|
|
|
|
const {statistics} = useVideoStatistics();
|
|
|
|
if (statistics === null) {
|
|
// Request is pending.
|
|
return <Loading/>
|
|
} else if (statistics === undefined) {
|
|
return <ErrorMessage>Unable to fetch Video Statistics</ErrorMessage>
|
|
}
|
|
|
|
const {videos, historical, channels} = statistics;
|
|
|
|
const videoNames = [
|
|
{key: 'videos', label: 'Videos'},
|
|
{key: 'sum_size', label: 'Total Size'},
|
|
{key: 'max_size', label: 'Largest Video'},
|
|
{key: 'week', label: 'Downloads Past Week'},
|
|
{key: 'month', label: 'Downloads Past Month'},
|
|
{key: 'year', label: 'Downloads Past Year'},
|
|
{key: 'sum_duration', label: 'Total Duration'},
|
|
{key: 'censored_videos', label: 'Censored Videos'},
|
|
];
|
|
const commentsNames = [
|
|
{key: 'have_comments', label: 'Have Comments'},
|
|
{key: 'missing_comments', label: 'Missing Comments'},
|
|
{key: 'failed_comments', label: 'Failed Comments'},
|
|
];
|
|
const historicalNames = [
|
|
{key: 'average_count', label: 'Average Monthly Downloads'},
|
|
{key: 'average_size', label: 'Average Monthly Usage'},
|
|
];
|
|
const channelNames = [
|
|
{key: 'channels', label: 'Channels'},
|
|
{key: 'tagged_channels', label: 'Tagged Channels'},
|
|
];
|
|
|
|
const buildPanel = (title, names, stats) => {
|
|
return <Panel>
|
|
<Header as='h1' style={{textAlign: 'center'}}>{title}</Header>
|
|
<StatisticGroup>
|
|
{names.map(
|
|
({key, label}) =>
|
|
<Statistic key={key} value={stats[key]} label={label} style={{margin: '2em'}}/>
|
|
)}
|
|
</StatisticGroup>
|
|
</Panel>
|
|
}
|
|
|
|
return <>
|
|
{buildPanel('Videos', videoNames, videos)}
|
|
{buildPanel('Video Comments', commentsNames, videos)}
|
|
{buildPanel('Historical Video', historicalNames, historical)}
|
|
{buildPanel('Channels', channelNames, channels)}
|
|
</>
|
|
}
|
|
|
|
export function VideosTabLayout() {
|
|
const links = [
|
|
{text: 'Videos', to: '/videos', key: 'videos', end: true},
|
|
{text: 'Channels', to: '/videos/channel', key: 'channel'},
|
|
{text: 'Settings', to: '/videos/settings', key: 'settings'},
|
|
{text: 'Statistics', to: '/videos/statistics', key: 'statistics'},
|
|
];
|
|
|
|
return <PageContainer>
|
|
<TabLinks links={links}/>
|
|
<Outlet/>
|
|
</PageContainer>
|
|
}
|
|
|
|
export function VideoCard({file}) {
|
|
const {video} = file;
|
|
const {sort} = useSearchOrder();
|
|
const sortField = sort ? sort.replace(/^-/, '') : null;
|
|
|
|
// Default to video FilePreview for lone video files.
|
|
let video_url = `/videos/${file.id}`;
|
|
|
|
// A video may not have a channel.
|
|
const channel = video.channel ? video.channel : null;
|
|
let channel_url = null;
|
|
if (channel) {
|
|
channel_url = `/videos/channel/${channel.id}/video`;
|
|
}
|
|
|
|
const media = <div style={{position: 'relative'}}>
|
|
<CardPoster to={video_url} file={file}/>
|
|
<Duration totalSeconds={file.length}/>
|
|
</div>;
|
|
|
|
const title = file.title || file.name || video.stem || video.name;
|
|
let titleElm = <Tooltip label={title}>
|
|
<span className='card-title-ellipsis'>{title}</span>
|
|
</Tooltip>;
|
|
if (video_url) {
|
|
// Link to Channel-Video page or Video page.
|
|
titleElm = <Link to={video_url} className="no-link-underscore card-link">{titleElm}</Link>;
|
|
} else {
|
|
// Video is just a lone video file.
|
|
titleElm = <PreviewLink file={file}>
|
|
{titleElm}
|
|
</PreviewLink>;
|
|
}
|
|
|
|
let detailLine;
|
|
if (sortField === 'length') {
|
|
detailLine = secondsToFullDuration(file.length || 0);
|
|
} else if (sortField === 'size') {
|
|
detailLine = humanFileSize(file.size);
|
|
} else if (sortField === 'view_count') {
|
|
detailLine = `${humanNumber(video.view_count || 0)} views`;
|
|
} else if (sortField === 'viewed') {
|
|
detailLine = isoDatetimeToAgoPopup(file.viewed, false);
|
|
} else if (sortField === 'download_datetime') {
|
|
detailLine = isoDatetimeToAgoPopup(file.download_datetime, false);
|
|
} else {
|
|
detailLine = isoDatetimeToAgoPopup(file.published_datetime, false);
|
|
}
|
|
|
|
const meta = <>
|
|
{channel && <div>
|
|
<Link to={channel_url} className="no-link-underscore card-link">
|
|
<b>{channel.name}</b>
|
|
</Link>
|
|
</div>}
|
|
<div>{detailLine}</div>
|
|
</>;
|
|
|
|
return <Card media={media} title={titleElm} meta={meta}/>
|
|
}
|
|
|
|
export function VideoRowCells({file}) {
|
|
const {video} = file;
|
|
let {sort} = useSearchOrder();
|
|
sort = sort ? sort.replace(/^-+/, '') : null;
|
|
|
|
let video_url = `/videos/${file.id}`;
|
|
const poster_path = findPosterPath(file);
|
|
const poster_url = poster_path ? `/media/${encodeMediaPath(poster_path)}` : null;
|
|
|
|
let poster;
|
|
if (poster_url) {
|
|
poster = <CardLink to={video_url}>
|
|
<img alt='' src={poster_url} style={{width: '50px', height: 'auto'}}/>
|
|
</CardLink>
|
|
} else {
|
|
poster = <FileIcon file={file} size='large'/>;
|
|
}
|
|
|
|
let dataCell = file.published_datetime ? isoDatetimeToAgoPopup(file.published_datetime) : '';
|
|
if (sort === 'length') {
|
|
dataCell = secondsToFullDuration(file.length || 0);
|
|
} else if (sort === 'size') {
|
|
dataCell = humanFileSize(file.size);
|
|
} else if (sort === 'view_count') {
|
|
dataCell = humanNumber(video.view_count || 0);
|
|
} else if (sort === 'viewed') {
|
|
dataCell = isoDatetimeToAgoPopup(file.viewed);
|
|
}
|
|
|
|
// Fragment for SelectableRow
|
|
return <React.Fragment>
|
|
<Table.Cell>
|
|
<div style={{textAlign: 'center'}}>
|
|
{poster}
|
|
</div>
|
|
</Table.Cell>
|
|
<Table.Cell>
|
|
<CardLink to={video_url}>
|
|
<FileRowTagIcon file={file}/>
|
|
{textEllipsis(file.title || video.stem || video.video_path)}
|
|
</CardLink>
|
|
</Table.Cell>
|
|
<Table.Cell>{dataCell}</Table.Cell>
|
|
</React.Fragment>
|
|
}
|