mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
* Add Playlists: ordered, mixed-type collections A playlist (Collection kind='playlist') is a manually-curated, ordered list that mixes item types. Squash of the playlists feature branch (24 commits). Data model & API: - CollectionItem is polymorphic via item_kind: 'file' (FileGroup), 'zim' (zim_id + zim_entry), or 'url' (link to anything the WROLPi browser opens, e.g. /map?lat=&lon=&z=). CHECK constraint enforces exactly one kind; per-kind unique constraints; migration a7b1c2d3e4f5. - Endpoints: create playlist; add/remove/reorder items (idempotent adds: 201 new, 200 existing); GET returns the ordered items. All mutations wrol_mode_check'd. Config (source of truth): - playlists.yaml (PlaylistsConfig) stores each playlist's metadata AND ordered items (file by relative path, zim by path+entry, url inline); round-trips a DB wipe. Supports backup import (DB_CONFIG_FILE_NAMES). - The redundant generic CollectionsConfig (collections.yaml) is deleted: channels/domains own their configs, authors/subjects are re-derived. On-disk sync (sync_playlists_directory switch): - Each playlist materializes into the configurable Playlists Directory (playlists_destination, Settings UI input, excluded from indexing) with position-prefixed entries: NNNN_<file> hard links (copy fallback across filesystems) and NNNN_<title>.html stubs for zim/url items. - Tagged playlists are namespaced like channels: <playlists>/<tag>/<name>; a tag change moves the directory and prunes the empty old tag dir. - Deletion safety: managed files are only removed when they are our marked stubs or still have another hard link (st_nlink > 1) -- never a last copy. Stray loose files at the playlists root are removed; stub URLs are scheme-allowlisted (no javascript:/data:). Frontend: - /playlists list page (CollectionTable, name filter, New Playlist modal, Tag column) between Files and Zim in the nav. - View-only detail page (Name link) and edit page (Edit button): themed table with model icons/colors + poster thumbnails, items link to their model page (video/archive/doc) or preview, up/down reorder, remove, add-URL form, single-tag selector; all editing disabled in WROL Mode. - Reusable green "Add to Playlist" button on Video/Archive/Doc detail pages, the FilePreview toolbar, Zim search results, and Map pins (added as a /map URL item with a map-marker icon). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Playlist edit page parity: rename, custom directory, tag modal move suggestion Match the ChannelEditPage/DomainEditPage UX on the playlist edit page: Back/View buttons, an edit form (name, description, directory), and the shared CollectionTagModal with the violet "Tag" button. The tag modal now suggests moving the playlist to its managed tag location (playlists/<tag>/<name>); accepting the suggestion stores directory=None so the sync keeps auto-managing the location. Tag buttons on the Channel and Domain edit pages are now violet to match. Backend: update_collection learns rename (with per-kind validation and duplicate checks) and playlist custom directories (cleanup of the abandoned directory, config round-trip, commit-before-switch to avoid the background sync racing pre-commit state). tag_collection's duplicated untag/tag branches are merged, and the per-kind config-save dispatch is consolidated into _activate_config_save(). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Deduplicate Collection error handling and Tag creation Delete the duplicate UnknownCollection in wrolpi/collections/errors.py and re-export the canonical one from wrolpi/errors.py, which carries status_code=NOT_FOUND (the local copy inherited 500). Replace the repeated query/raise pattern in reorganize.py with Collection.find_by_id. Add Tag.get_or_create_tag and use it for collection tagging; flush the new Tag before returning so callers can read .id immediately -- previously tagging a collection with a brand-new tag name set tag_id=None. Regression test included. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
589 lines
20 KiB
JavaScript
589 lines
20 KiB
JavaScript
import React, {useState} from "react";
|
|
import {useHotkeys} from "react-hotkeys-hook";
|
|
import {Grid, StatisticLabel, StatisticValue,} from "semantic-ui-react";
|
|
import {createChannel, deleteChannel, refreshChannel, tagChannel, tagChannelInfo} from "../api";
|
|
import {CollectionTagModal} from "./collections/CollectionTagModal";
|
|
import {CollectionReorganizeModal} from "./collections/CollectionReorganizeModal";
|
|
import {
|
|
APIButton,
|
|
BackButton,
|
|
ErrorMessage,
|
|
humanFileSize,
|
|
humanNumber,
|
|
InfoPopup,
|
|
SearchInput,
|
|
secondsToFullDuration,
|
|
SimpleAccordion,
|
|
useTitle,
|
|
WROLModeMessage
|
|
} from "./Common";
|
|
import {Link, useNavigate, useParams} from "react-router";
|
|
import Message from "semantic-ui-react/dist/commonjs/collections/Message";
|
|
import {useChannel, useChannels, useOneQuery} from "../hooks/customHooks";
|
|
import _ from "lodash";
|
|
import {Button, Form, Header, Loader, Modal, Segment, Statistic} from "./Theme";
|
|
import {toast} from "react-semantic-toasts-2";
|
|
import {RecurringDownloadsTable} from "./admin/Downloads";
|
|
import {InputForm, ToggleForm} from "../hooks/useForm";
|
|
import {ChannelDownloadForm, DestinationForm, DownloadTagsSelector} from "./Download";
|
|
import {CollectionTable} from "./collections/CollectionTable";
|
|
import {CollectionEditForm} from "./collections/CollectionEditForm";
|
|
|
|
// Channel table column configuration
|
|
const CHANNEL_COLUMNS = [
|
|
{key: 'name', label: 'Name', sortable: true, width: 7},
|
|
{key: 'tag_name', label: 'Tag', sortable: true, width: 2},
|
|
{key: 'video_count', label: 'Videos', sortable: true, align: 'right', width: 2},
|
|
{
|
|
key: 'min_download_frequency',
|
|
label: 'Download Frequency',
|
|
sortable: true,
|
|
format: 'frequency',
|
|
width: 2,
|
|
hideOnMobile: true
|
|
},
|
|
{key: 'total_size', label: 'Size', sortable: true, align: 'right', format: 'bytes', width: 2, hideOnMobile: true},
|
|
{key: 'actions', label: 'Manage', sortable: false, type: 'actions', width: 1}
|
|
];
|
|
|
|
const CHANNEL_ROUTES = {
|
|
list: '/videos/channel',
|
|
edit: '/videos/channel/:id/edit',
|
|
search: '/videos/channel/:id/video',
|
|
id_field: 'channel_id'
|
|
};
|
|
|
|
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>
|
|
<Statistic>
|
|
<StatisticValue>{humanNumber(statistics.video_tags)}</StatisticValue>
|
|
<StatisticLabel>Video Tags</StatisticLabel>
|
|
</Statistic>
|
|
</Segment>
|
|
}
|
|
|
|
|
|
export function ChannelEditPage() {
|
|
const navigate = useNavigate();
|
|
const {channelId} = useParams();
|
|
|
|
const [tagEditModalOpen, setTagEditModalOpen] = useState(false);
|
|
const [downloadModalOpen, setDownloadModalOpen] = useState(false);
|
|
const [reorganizeModalOpen, setReorganizeModalOpen] = useState(false);
|
|
|
|
const {channel, form, fetchChannel} = useChannel(channelId);
|
|
|
|
useTitle(_.isEmpty(channel) ? null : `${channel.name} Channel`);
|
|
|
|
if (!form.ready) {
|
|
return <Loader active/>;
|
|
}
|
|
|
|
const handleRefreshChannel = async (e) => {
|
|
if (e) {
|
|
e.preventDefault();
|
|
}
|
|
await refreshChannel(channelId);
|
|
}
|
|
|
|
const handleDelete = async () => {
|
|
try {
|
|
let response = await deleteChannel(channelId);
|
|
if (response.status === 204) {
|
|
navigate('/videos/channel');
|
|
}
|
|
} catch (e) {
|
|
console.error('Failed to delete channel', e);
|
|
}
|
|
}
|
|
|
|
// Handler for tag modal save
|
|
const handleTagSave = async (tagName, directory) => {
|
|
try {
|
|
await tagChannel(channelId, tagName, directory);
|
|
toast({
|
|
type: 'success',
|
|
title: 'Channel Tagged',
|
|
description: `Channel has been tagged with "${tagName}"`,
|
|
time: 3000,
|
|
});
|
|
} catch (e) {
|
|
console.error('Failed to tag channel', e);
|
|
} finally {
|
|
setTimeout(async () => {
|
|
await fetchChannel();
|
|
}, 500);
|
|
}
|
|
};
|
|
|
|
// Handler for fetching tag info
|
|
const handleGetTagInfo = async (tagName) => {
|
|
return await tagChannelInfo(channelId, tagName);
|
|
};
|
|
|
|
const afterNewDownloadSave = async () => {
|
|
setDownloadModalOpen(false);
|
|
await form.fetcher();
|
|
}
|
|
|
|
const onDelete = async () => {
|
|
await form.fetcher();
|
|
setDownloadModalOpen(false);
|
|
}
|
|
|
|
const handleSubmit = async (e) => {
|
|
if (e) e.preventDefault();
|
|
try {
|
|
await form.onSubmit();
|
|
toast({
|
|
type: 'success',
|
|
title: 'Channel Updated',
|
|
description: 'Channel was successfully updated',
|
|
time: 3000,
|
|
});
|
|
await fetchChannel();
|
|
} catch (e) {
|
|
console.error('Failed to update channel:', e);
|
|
}
|
|
};
|
|
|
|
const deleteButton = <APIButton
|
|
color='red'
|
|
size='small'
|
|
confirmContent='Are you sure you want to delete this channel? No video files will be deleted.'
|
|
confirmButton='Delete'
|
|
confirmHeader='Delete Channel?'
|
|
onClick={handleDelete}
|
|
obeyWROLMode={true}
|
|
style={{marginTop: '1em'}}
|
|
>Delete</APIButton>;
|
|
|
|
const refreshButton = <APIButton
|
|
color='blue'
|
|
size='small'
|
|
onClick={handleRefreshChannel}
|
|
obeyWROLMode={true}
|
|
style={{marginTop: '1em'}}
|
|
>Refresh</APIButton>;
|
|
|
|
const tagButton = <Button
|
|
type="button"
|
|
size='small'
|
|
onClick={() => setTagEditModalOpen(true)}
|
|
color='violet'
|
|
style={{marginTop: '1em'}}
|
|
>Tag</Button>;
|
|
|
|
const reorganizeButton = (
|
|
<APIButton
|
|
color='orange'
|
|
size='small'
|
|
onClick={() => setReorganizeModalOpen(true)}
|
|
obeyWROLMode={true}
|
|
style={{marginTop: '1em'}}
|
|
>Reorganize Files</APIButton>
|
|
);
|
|
|
|
const actionButtons = <>
|
|
{deleteButton}
|
|
{refreshButton}
|
|
{tagButton}
|
|
{reorganizeButton}
|
|
</>;
|
|
|
|
const downloadMissingDataInfo = 'Automatically download missing comments, etc, in the background.';
|
|
const downloadMissingDataLabel = <>Download Missing Data<InfoPopup content={downloadMissingDataInfo}/></>;
|
|
|
|
return <>
|
|
<BackButton/>
|
|
<Link to={`/videos/channel/${channel.id}/video`}>
|
|
<Button>Videos</Button>
|
|
</Link>
|
|
|
|
{channel?.needs_reorganization && (
|
|
<Message warning>
|
|
<Message.Header>File Format Changed</Message.Header>
|
|
<p>
|
|
The file name format has changed. Click "Reorganize Files" to move existing files
|
|
to match the new format.
|
|
</p>
|
|
</Message>
|
|
)}
|
|
|
|
<CollectionEditForm
|
|
form={form}
|
|
title="Edit Channel"
|
|
wrolModeContent='Channel editing is disabled while in WROL Mode.'
|
|
actionButtons={actionButtons}
|
|
appliedTagName={channel?.tag_name}
|
|
onSubmit={handleSubmit}
|
|
>
|
|
<Grid.Row>
|
|
<Grid.Column width={8}>
|
|
<InputForm
|
|
form={form}
|
|
label="Channel Name"
|
|
name="name"
|
|
placeholder="Short Channel Name"
|
|
required={true}
|
|
/>
|
|
</Grid.Column>
|
|
<Grid.Column width={8}>
|
|
<DestinationForm
|
|
form={form}
|
|
label='Directory'
|
|
name='directory'
|
|
path='directory'
|
|
required={true}
|
|
/>
|
|
</Grid.Column>
|
|
</Grid.Row>
|
|
<Grid.Row>
|
|
<Grid.Column>
|
|
<ToggleForm
|
|
form={form}
|
|
label={downloadMissingDataLabel}
|
|
path='download_missing_data'
|
|
/>
|
|
</Grid.Column>
|
|
</Grid.Row>
|
|
{(channel.url || channel.rss_url) &&
|
|
<Grid.Row>
|
|
<Grid.Column>
|
|
<SimpleAccordion title='Details'>
|
|
<Grid>
|
|
{channel.url && <Grid.Row columns={1}>
|
|
<Grid.Column>
|
|
<Header as='h4'>URL</Header>
|
|
<a href={channel.url}>{channel.url}</a>
|
|
</Grid.Column>
|
|
</Grid.Row>}
|
|
{channel.rss_url && <Grid.Row columns={1}>
|
|
<Grid.Column>
|
|
<Header as='h4'>RSS URL</Header>
|
|
<a href={channel.rss_url}>{channel.rss_url}</a>
|
|
</Grid.Column>
|
|
</Grid.Row>}
|
|
</Grid>
|
|
</SimpleAccordion>
|
|
</Grid.Column>
|
|
</Grid.Row>}
|
|
</CollectionEditForm>
|
|
|
|
{/* Tag Modal */}
|
|
<CollectionTagModal
|
|
open={tagEditModalOpen}
|
|
onClose={() => setTagEditModalOpen(false)}
|
|
currentTagName={channel.tag_name}
|
|
originalDirectory={channel.directory}
|
|
getTagInfo={handleGetTagInfo}
|
|
onSave={handleTagSave}
|
|
collectionName="Channel"
|
|
hasDirectory={!!channel.directory}
|
|
/>
|
|
|
|
{/* Reorganize Modal */}
|
|
<CollectionReorganizeModal
|
|
open={reorganizeModalOpen}
|
|
onClose={() => setReorganizeModalOpen(false)}
|
|
collectionId={channel?.collection_id}
|
|
collectionName={channel?.name}
|
|
onComplete={fetchChannel}
|
|
needsReorganization={channel?.needs_reorganization}
|
|
/>
|
|
|
|
{/* Downloads Segment */}
|
|
<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)}>
|
|
<Modal.Content>
|
|
<Header as='h2'>New Channel Download</Header>
|
|
<ChannelDownloadForm
|
|
channel_id={channelId}
|
|
onSuccess={afterNewDownloadSave}
|
|
onCancel={() => setDownloadModalOpen(false)}
|
|
onDelete={onDelete}
|
|
/>
|
|
</Modal.Content>
|
|
</Modal>
|
|
|
|
<RecurringDownloadsTable downloads={channel?.downloads} fetchDownloads={fetchChannel} onDelete={onDelete}/>
|
|
</Segment>
|
|
|
|
{channel && channel.statistics && <ChannelStatistics statistics={channel.statistics}/>}
|
|
</>;
|
|
}
|
|
|
|
export function ChannelNewPage() {
|
|
useTitle('New Channel');
|
|
|
|
// Used to display messages to maintainer.
|
|
const [error, setError] = useState(false);
|
|
const [success, setSuccess] = useState(false);
|
|
const [messageHeader, setMessageHeader] = useState();
|
|
const [messageContent, setMessageContent] = useState();
|
|
|
|
const {channel, form} = useChannel(null);
|
|
|
|
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 () => {
|
|
const body = {
|
|
name: channel.name,
|
|
directory: channel.directory,
|
|
url: channel.url,
|
|
download_missing_data: channel.download_missing_data,
|
|
tag_name: _.isEmpty(channel.tag_name) ? null : channel.tag_name[0],
|
|
};
|
|
|
|
let response = null;
|
|
try {
|
|
response = await createChannel(body);
|
|
} catch (e) {
|
|
console.error(e);
|
|
toast({
|
|
type: 'error',
|
|
title: 'Unexpected server response',
|
|
description: 'Could not save channel',
|
|
time: 5000,
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (response && response.ok) {
|
|
let location = response.headers.get('Location');
|
|
let channelResponse = await fetch(location);
|
|
let data = await channelResponse.json();
|
|
let newChannel = data['channel'];
|
|
|
|
setSuccessMessage(
|
|
'Channel created',
|
|
<span>
|
|
Your channel was created. View it <Link to={`/videos/channel/${newChannel.id}/edit`}>here</Link>
|
|
</span>
|
|
);
|
|
} else if (response) {
|
|
let error = await response.json();
|
|
let cause = error.cause;
|
|
if (cause && cause.code === 'CHANNEL_DIRECTORY_CONFLICT') {
|
|
setErrorMessage(
|
|
'Invalid directory',
|
|
'This directory is already used by another channel',
|
|
);
|
|
} else if (cause && cause.code === 'CHANNEL_NAME_CONFLICT') {
|
|
setErrorMessage(
|
|
'Invalid name',
|
|
'This channel name is already taken',
|
|
);
|
|
} else {
|
|
setErrorMessage('Invalid channel', error.message || error.error || 'Unable to save channel. See logs.');
|
|
}
|
|
} else {
|
|
console.error('Did not get a response for channel!');
|
|
}
|
|
}
|
|
|
|
const downloadMissingDataInfo = 'Automatically download missing comments, etc, in the background.';
|
|
const downloadMissingDataLabel = <>Download Missing Data<InfoPopup content={downloadMissingDataInfo}/></>;
|
|
|
|
let messageRow;
|
|
if (error || success) {
|
|
messageRow = <Grid.Row columns={1}>
|
|
<Grid.Column>
|
|
{error &&
|
|
<Message negative
|
|
header={messageHeader}
|
|
content={messageContent}
|
|
/>}
|
|
{success &&
|
|
<Message positive
|
|
header={messageHeader}
|
|
content={messageContent}
|
|
/>}
|
|
</Grid.Column>
|
|
</Grid.Row>
|
|
}
|
|
|
|
return <>
|
|
<BackButton/>
|
|
|
|
<Segment>
|
|
<Header as="h1">New Channel</Header>
|
|
<WROLModeMessage content='Channel creation is disabled while WROL Mode is enabled.'/>
|
|
<Form
|
|
id="newChannel"
|
|
error={error}
|
|
success={success}
|
|
autoComplete="off"
|
|
>
|
|
<Grid stackable columns={2}>
|
|
<Grid.Row>
|
|
<Grid.Column>
|
|
<InputForm
|
|
form={form}
|
|
label="Channel Name"
|
|
name="name"
|
|
placeholder="Short Channel Name"
|
|
required={true}
|
|
/>
|
|
</Grid.Column>
|
|
<Grid.Column>
|
|
<DestinationForm
|
|
form={form}
|
|
label='Directory'
|
|
name='directory'
|
|
path='directory'
|
|
required={true}
|
|
/>
|
|
</Grid.Column>
|
|
</Grid.Row>
|
|
<Grid.Row>
|
|
<Grid.Column>
|
|
<ToggleForm
|
|
form={form}
|
|
label={downloadMissingDataLabel}
|
|
path='download_missing_data'
|
|
/>
|
|
</Grid.Column>
|
|
</Grid.Row>
|
|
{messageRow}
|
|
<Grid.Row>
|
|
<Grid.Column width={8}>
|
|
<DownloadTagsSelector
|
|
form={form}
|
|
limit={1}
|
|
path='tag_name'
|
|
name='tag_name'
|
|
/>
|
|
</Grid.Column>
|
|
<Grid.Column>
|
|
<APIButton
|
|
color='violet'
|
|
size='big'
|
|
floated='right'
|
|
onClick={handleSubmit}
|
|
disabled={form.disabled}
|
|
obeyWROLMode={true}
|
|
>Save</APIButton>
|
|
</Grid.Column>
|
|
</Grid.Row>
|
|
</Grid>
|
|
</Form>
|
|
</Segment>
|
|
</>;
|
|
}
|
|
|
|
export function ChannelsPage() {
|
|
useTitle('Channels');
|
|
|
|
const [channels] = useChannels();
|
|
const [searchStr, setSearchStr] = useOneQuery('name');
|
|
const searchInputRef = React.useRef();
|
|
|
|
useHotkeys('f', (e) => {
|
|
e.preventDefault();
|
|
if (searchInputRef.current) {
|
|
searchInputRef.current.focus();
|
|
}
|
|
}, {enableOnFormTags: false});
|
|
|
|
// Header section matching DomainsPage pattern
|
|
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}
|
|
inputRef={searchInputRef}
|
|
/>
|
|
</Grid.Column>
|
|
<Grid.Column textAlign='right'>
|
|
<Link to='/videos/channel/new'>
|
|
<Button secondary>New Channel</Button>
|
|
</Link>
|
|
</Grid.Column>
|
|
</Grid.Row>
|
|
</Grid>
|
|
</div>;
|
|
|
|
// Empty state
|
|
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>
|
|
</>;
|
|
}
|
|
|
|
// Error state
|
|
if (channels === undefined) {
|
|
return <>
|
|
{header}
|
|
<ErrorMessage>Could not fetch Channels</ErrorMessage>
|
|
</>;
|
|
}
|
|
|
|
return <>
|
|
{header}
|
|
<CollectionTable
|
|
collections={channels}
|
|
columns={CHANNEL_COLUMNS}
|
|
routes={CHANNEL_ROUTES}
|
|
searchStr={searchStr}
|
|
/>
|
|
</>;
|
|
}
|