Refac react frontend (#790)

* Add channel config endpoint

* Add channel aggs

* Add playlist show subscribed only toggle

* Fix refresh always on filterbar toggle

* Add loadingindicator for watchstate change

* Fix missing space in scheduling

* Add schedule request and apprisenotifcation

* Refac upgrade TypeScript target to include 2024 for Object.groupBy

* WIP: Schedule page

* WIP: Schedule page

* Add schedule management ( - notification )

* Fix missing space

* Refac show current selection in input

* Add apprise notifictation url

* Add Stream & Shorts channel pages

* Refac autotarget input on search page

* Fix input requiring 1 instead of 0

* Fix remove unused function

* Chore: npm audit fix

* Refac get channel_overwrites from channelById

* Refac remove defaultvalues form select

* Fix delay content refresh to allow the backend to update subscribed state

* Fix styling selection

* Fix lint

* Fix spelling

* Fix remove unused import

* Chore: update all dependencies - React 19 & vite 6

* Add missing property to ValidatedCookieType

* Refac fix complaints about JSX.Element, used ReactNode instead

* Refac remove unused dependency

* Refac replace react-helmet with react 19 implementation

* Fix Application Settings page

* Chore update dependencies

* Add simple playlist autoplay feature

* Refac use server provided channel images path

* Refac use server provided playlistthumbnail images path

* Add save and restore video playback speed
This commit is contained in:
Merlin 2024-12-22 15:59:30 +01:00 committed by GitHub
parent 75339e479e
commit 5a5d47da9b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
54 changed files with 10047 additions and 9526 deletions

View File

@ -13,7 +13,6 @@ from typing import Any
from urllib.parse import urlparse
import requests
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import IndexPaginate
@ -215,15 +214,8 @@ def ta_host_parser(ta_host: str) -> tuple[list[str], list[str]]:
def get_stylesheets() -> list:
"""Get all valid stylesheets from /static/css"""
app_root = EnvironmentSettings.APP_DIR
try:
stylesheets = os.listdir(os.path.join(app_root, "static/css"))
except FileNotFoundError:
return []
stylesheets.remove("style.css")
stylesheets.sort()
stylesheets = list(filter(lambda x: x.endswith(".css"), stylesheets))
stylesheets = ["dark.css", "light.css", "matrix.css", "midnight.css"]
return stylesheets

File diff suppressed because it is too large Load Diff

View File

@ -10,26 +10,23 @@
"preview": "vite preview"
},
"dependencies": {
"dompurify": "^3.1.6",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-helmet": "^6.1.0",
"react-router-dom": "^6.25.1"
"dompurify": "^3.2.3",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.0.2"
},
"devDependencies": {
"@types/dompurify": "^3.0.5",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
"@types/react-helmet": "^6.1.11",
"@typescript-eslint/eslint-plugin": "^7.16.1",
"@typescript-eslint/parser": "^7.16.1",
"@vitejs/plugin-react-swc": "^3.7.0",
"eslint": "^8.57.0",
"@types/react": "^19.0.1",
"@types/react-dom": "^19.0.2",
"@typescript-eslint/eslint-plugin": "^8.18.0",
"@typescript-eslint/parser": "^8.18.0",
"@vitejs/plugin-react-swc": "^3.7.2",
"eslint": "^9.16.0",
"eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.2",
"eslint-plugin-react-refresh": "^0.4.8",
"prettier": "3.3.3",
"typescript": "^5.5.3",
"vite": "^5.3.4"
"eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.16",
"prettier": "3.4.2",
"typescript": "^5.7.2",
"vite": "^6.0.3"
}
}

View File

@ -0,0 +1,36 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
export type AppriseTaskNameType =
| 'update_subscribed'
| 'extract_download'
| 'download_pending'
| 'check_reindex';
const createAppriseNotificationUrl = async (taskName: AppriseTaskNameType, url: string) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/task/notification/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({ task_name: taskName, url }),
});
const appriseNotificationUrl = await response.json();
if (isDevEnvironment()) {
console.log('createAppriseNotificationUrl', appriseNotificationUrl);
}
return appriseNotificationUrl;
};
export default createAppriseNotificationUrl;

View File

@ -0,0 +1,53 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
export type TaskScheduleNameType =
| 'update_subscribed'
| 'download_pending'
| 'extract_download'
| 'check_reindex'
| 'manual_import'
| 'run_backup'
| 'restore_backup'
| 'rescan_filesystem'
| 'thumbnail_check'
| 'resync_thumbs'
| 'index_playlists'
| 'subscribe_to'
| 'version_check';
type ScheduleConfigType = {
schedule?: string;
config?: {
days?: number;
rotate?: number;
};
};
const createTaskSchedule = async (taskName: TaskScheduleNameType, schedule: ScheduleConfigType) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/task/schedule/${taskName}/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify(schedule),
});
const scheduledTask = await response.json();
if (isDevEnvironment()) {
console.log('createTaskSchedule', scheduledTask);
}
return scheduledTask;
};
export default createTaskSchedule;

View File

@ -0,0 +1,36 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
type AppriseTaskNameType =
| 'update_subscribed'
| 'extract_download'
| 'download_pending'
| 'check_reindex';
const deleteAppriseNotificationUrl = async (taskName: AppriseTaskNameType) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/task/notification/`, {
method: 'DELETE',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({ task_name: taskName }),
});
const appriseNotification = await response.json();
if (isDevEnvironment()) {
console.log('deleteAppriseNotificationUrl', appriseNotification);
}
return appriseNotification;
};
export default deleteAppriseNotificationUrl;

View File

@ -0,0 +1,30 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
import isDevEnvironment from '../../functions/isDevEnvironment';
import { TaskScheduleNameType } from './createTaskSchedule';
const deleteTaskSchedule = async (taskName: TaskScheduleNameType) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/task/schedule/${taskName}/`, {
method: 'DELETE',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
});
const scheduledTask = await response.json();
if (isDevEnvironment()) {
console.log('deleteTaskSchedule', scheduledTask);
}
return scheduledTask;
};
export default deleteTaskSchedule;

View File

@ -0,0 +1,39 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import getCookie from '../../functions/getCookie';
export type ChannelAboutConfigType = {
index_playlists?: boolean;
download_format?: boolean | string;
autodelete_days?: boolean | number;
integrate_sponsorblock?: boolean | null;
subscriptions_channel_size?: number;
subscriptions_live_channel_size?: number;
subscriptions_shorts_channel_size?: number;
};
const updateChannelSettings = async (channelId: string, config: ChannelAboutConfigType) => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
const response = await fetch(`${apiUrl}/api/channel/${channelId}/`, {
method: 'POST',
headers: {
...defaultHeaders,
'X-CSRFToken': csrfCookie || '',
},
credentials: getFetchCredentials(),
body: JSON.stringify({
channel_overwrites: config,
}),
});
const channelSubscription = await response.json();
console.log('updateChannelSettings', channelSubscription);
return channelSubscription;
};
export default updateChannelSettings;

View File

@ -8,6 +8,7 @@ export type ValidatedCookieType = {
status: boolean;
validated: number;
validated_str: string;
cookie_validated?: boolean;
};
const updateCookie = async (): Promise<ValidatedCookieType> => {

View File

@ -12,6 +12,7 @@ const loadApiToken = async (): Promise<ApiTokenResponse> => {
const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken');
try {
const response = await fetch(`${apiUrl}/api/appsettings/token/`, {
headers: {
...defaultHeaders,
@ -27,6 +28,9 @@ const loadApiToken = async (): Promise<ApiTokenResponse> => {
}
return apiToken;
} catch (e) {
return { token: '' };
}
};
export default loadApiToken;

View File

@ -0,0 +1,42 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
export type AppriseNotificationType = {
check_reindex?: {
urls: string[];
title: string;
};
download_pending?: {
urls: string[];
title: string;
};
extract_download?: {
urls: string[];
title: string;
};
update_subscribed?: {
urls: string[];
title: string;
};
};
const loadAppriseNotification = async (): Promise<AppriseNotificationType> => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/task/notification/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const notification = await response.json();
if (isDevEnvironment()) {
console.log('loadAppriseNotification', notification);
}
return notification;
};
export default loadAppriseNotification;

View File

@ -11,20 +11,20 @@ export type AppSettingsConfigType = {
auto_start: boolean;
};
downloads: {
limit_speed: boolean | number;
limit_speed: false | number;
sleep_interval: number;
autodelete_days: boolean | number;
format: boolean | string;
autodelete_days: number;
format: number | string;
format_sort: boolean | string;
add_metadata: boolean;
add_thumbnail: boolean;
subtitle: boolean | string;
subtitle_source: boolean | string;
subtitle_index: boolean;
comment_max: boolean | number;
comment_max: string | number;
comment_sort: string;
cookie_import: boolean;
throttledratelimit: boolean | number;
throttledratelimit: false | number;
extractor_lang: boolean | string;
integrate_ryd: boolean;
integrate_sponsorblock: boolean;

View File

@ -0,0 +1,36 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
export type ChannelAggsType = {
total_items: {
value: number;
};
total_size: {
value: number;
};
total_duration: {
value: number;
value_str: string;
};
};
const loadChannelAggs = async (channelId: string): Promise<ChannelAggsType> => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/channel/${channelId}/aggs/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const channel = await response.json();
if (isDevEnvironment()) {
console.log('loadChannelAggs', channel);
}
return channel;
};
export default loadChannelAggs;

View File

@ -0,0 +1,36 @@
import defaultHeaders from '../../configuration/defaultHeaders';
import getApiUrl from '../../configuration/getApiUrl';
import getFetchCredentials from '../../configuration/getFetchCredentials';
import isDevEnvironment from '../../functions/isDevEnvironment';
type ScheduleType = {
name: string;
schedule: string;
schedule_human: string;
last_run_at: string;
config: {
days?: number;
rotate?: number;
};
};
export type ScheduleResponseType = ScheduleType[];
const loadSchedule = async (): Promise<ScheduleResponseType> => {
const apiUrl = getApiUrl();
const response = await fetch(`${apiUrl}/api/task/schedule/`, {
headers: defaultHeaders,
credentials: getFetchCredentials(),
});
const schedule = await response.json();
if (isDevEnvironment()) {
console.log('loadSchedule', schedule);
}
return schedule;
};
export default loadSchedule;

View File

@ -12,7 +12,7 @@ export type VideoListByFilterResponseType = {
};
type WatchTypes = 'watched' | 'unwatched' | 'continue';
type VideoTypes = 'videos' | 'streams' | 'shorts';
export type VideoTypes = 'videos' | 'streams' | 'shorts';
type FilterType = {
page?: number;

View File

@ -1,10 +1,12 @@
import { ReactNode } from 'react';
export interface ButtonProps {
id?: string;
name?: string;
className?: string;
type?: 'submit' | 'reset' | 'button' | undefined;
label?: string | JSX.Element | JSX.Element[];
children?: string | JSX.Element | JSX.Element[];
label?: string | ReactNode | ReactNode[];
children?: string | ReactNode | ReactNode[];
value?: string;
title?: string;
onClick?: () => void;

View File

@ -2,14 +2,15 @@ import getApiUrl from '../configuration/getApiUrl';
import defaultChannelImage from '/img/default-channel-banner.jpg';
type ChannelIconProps = {
channel_id: string;
channelId: string;
channelBannerUrl: string | undefined;
};
const ChannelBanner = ({ channel_id }: ChannelIconProps) => {
const ChannelBanner = ({ channelId, channelBannerUrl }: ChannelIconProps) => {
return (
<img
src={`${getApiUrl()}/cache/channels/${channel_id}_banner.jpg`}
alt={`${channel_id}-banner`}
src={`${getApiUrl()}${channelBannerUrl}`}
alt={`${channelId}-banner`}
onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping
currentTarget.src = defaultChannelImage;

View File

@ -2,14 +2,15 @@ import getApiUrl from '../configuration/getApiUrl';
import defaultChannelIcon from '/img/default-channel-icon.jpg';
type ChannelIconProps = {
channel_id: string;
channelId: string;
channelThumbUrl: string | undefined;
};
const ChannelIcon = ({ channel_id }: ChannelIconProps) => {
const ChannelIcon = ({ channelId, channelThumbUrl }: ChannelIconProps) => {
return (
<img
src={`${getApiUrl()}/cache/channels/${channel_id}_thumb.jpg`}
alt="channel-thumb"
src={`${getApiUrl()}${channelThumbUrl}`}
alt={`${channelId}-thumb`}
onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping
currentTarget.src = defaultChannelIcon;

View File

@ -27,14 +27,20 @@ const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelLis
<div key={channel.channel_id} className={`channel-item ${viewLayout}`}>
<div className={`channel-banner ${viewLayout}`}>
<Link to={Routes.Channel(channel.channel_id)}>
<ChannelBanner channel_id={channel.channel_id} />
<ChannelBanner
channelId={channel.channel_id}
channelBannerUrl={channel.channel_banner_url}
/>
</Link>
</div>
<div className={`info-box info-box-2 ${viewLayout}`}>
<div className="info-box-item">
<div className="round-img">
<Link to={Routes.Channel(channel.channel_id)}>
<ChannelIcon channel_id={channel.channel_id} />
<ChannelIcon
channelId={channel.channel_id}
channelThumbUrl={channel.channel_thumb_url}
/>
</Link>
</div>
<div>
@ -59,6 +65,7 @@ const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelLis
}}
/>
)}
{!channel.channel_subscribed && (
<Button
label="Subscribe"
@ -66,7 +73,10 @@ const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelLis
title={`Subscribe to ${channel.channel_name}`}
onClick={async () => {
await updateChannelSubscription(channel.channel_id, true);
setTimeout(() => {
refreshChannelList(true);
}, 500);
}}
/>
)}

View File

@ -10,6 +10,7 @@ type ChannelOverviewProps = {
channelname: string;
channelSubs: number;
channelSubscribed: boolean;
channelThumbUrl: string;
showSubscribeButton?: boolean;
isUserAdmin?: boolean;
setRefresh: (status: boolean) => void;
@ -20,6 +21,7 @@ const ChannelOverview = ({
channelSubs,
channelSubscribed,
channelname,
channelThumbUrl,
showSubscribeButton = false,
isUserAdmin,
setRefresh,
@ -29,7 +31,7 @@ const ChannelOverview = ({
<div className="info-box-item">
<div className="round-img">
<Link to={Routes.Channel(channelId)}>
<ChannelIcon channel_id={channelId} />
<ChannelIcon channelId={channelId} channelThumbUrl={channelThumbUrl} />
</Link>
</div>
<div>

View File

@ -115,7 +115,8 @@ const EmbeddableVideoPlayer = ({ videoId }: EmbeddableVideoPlayerProps) => {
id: videoId,
is_watched: status,
});
}}
onDone={() => {
setRefresh(true);
}}
/>

View File

@ -48,13 +48,6 @@ const Filterbar = ({
}: FilterbarProps) => {
useEffect(() => {
(async () => {
if (
userMeConfig.hide_watched !== hideWatched ||
userMeConfig[viewStyleName.toString() as keyof typeof userMeConfig] !== view ||
userMeConfig.grid_items !== gridItems ||
userMeConfig.sort_by !== sortBy ||
userMeConfig.sort_order !== sortOrder
) {
const userConfig: UserConfigType = {
hide_watched: hideWatched,
[viewStyleName.toString()]: view,
@ -65,7 +58,6 @@ const Filterbar = ({
await updateUserConfig(userConfig);
setRefresh?.(true);
}
})();
}, [hideWatched, view, gridItems, sortBy, sortOrder, viewStyleName, setRefresh, userMeConfig]);

View File

@ -1,5 +1,4 @@
import { useCallback, useEffect, useState } from 'react';
import { Helmet } from 'react-helmet';
import { VideoType } from '../pages/Home';
import updateWatchedState from '../api/actions/updateWatchedState';
import updateVideoProgressById from '../api/actions/updateVideoProgressById';
@ -214,12 +213,11 @@ const GoogleCast = ({ video, videoProgress, setRefresh }: GoogleCastProps) => {
return (
<>
<>
<Helmet>
<script
type="text/javascript"
src="https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"
></script>
</Helmet>
{/* @ts-expect-error React does not know what to do with the google-cast-launcher, but it works. */}
<google-cast-launcher id="castbutton"></google-cast-launcher>
</>

View File

@ -1,7 +1,7 @@
const PaginationDummy = () => {
return (
<div className="boxed-content">
<div className="pagination">{/** dummy pagination for padding */}</div>
<div className="pagination">{/** dummy pagination for consistent padding */}</div>
</div>
);
};

View File

@ -5,7 +5,7 @@ import { PlaylistType } from '../pages/Playlist';
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
import formatDate from '../functions/formatDates';
import Button from './Button';
import getApiUrl from '../configuration/getApiUrl';
import PlaylistThumbnail from './PlaylistThumbnail';
type PlaylistListProps = {
playlistList: PlaylistType[] | undefined;
@ -25,9 +25,9 @@ const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProp
<div key={playlist.playlist_id} className={`playlist-item ${viewLayout}`}>
<div className="playlist-thumbnail">
<Link to={Routes.Playlist(playlist.playlist_id)}>
<img
src={`${getApiUrl()}/cache/playlists/${playlist.playlist_id}.jpg`}
alt={`${playlist.playlist_id}-thumbnail`}
<PlaylistThumbnail
playlistId={playlist.playlist_id}
playlistThumbnail={playlist.playlist_thumbnail}
/>
</Link>
</div>
@ -68,7 +68,9 @@ const PlaylistList = ({ playlistList, viewLayout, setRefresh }: PlaylistListProp
onClick={async () => {
await updatePlaylistSubscription(playlist.playlist_id, true);
setTimeout(() => {
setRefresh(true);
}, 500);
}}
/>
)}

View File

@ -0,0 +1,22 @@
import getApiUrl from '../configuration/getApiUrl';
import defaultPlaylistThumbnail from '/img/default-playlist-thumb.jpg';
type PlaylistThumbnailProps = {
playlistId: string;
playlistThumbnail: string | undefined;
};
const PlaylistThumbnail = ({ playlistId, playlistThumbnail }: PlaylistThumbnailProps) => {
return (
<img
src={`${getApiUrl()}${playlistThumbnail}`}
alt={`${playlistId}-thumbnail`}
onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping
currentTarget.src = defaultPlaylistThumbnail;
}}
/>
);
};
export default PlaylistThumbnail;

View File

@ -79,7 +79,8 @@ const VideoListItem = ({
id: video.youtube_id,
is_watched: status,
});
}}
onDone={() => {
refreshVideoList(true);
}}
/>

View File

@ -99,6 +99,47 @@ const handleTimeUpdate =
}
};
export type VideoProgressType = {
youtube_id: string;
user_id: number;
position: number;
};
type VideoPlayerProps = {
video: VideoResponseType;
videoProgress?: VideoProgressType;
sponsorBlock?: SponsorBlockType;
embed?: boolean;
autoplay?: boolean;
onVideoEnd?: () => void;
};
const VideoPlayer = ({
video,
videoProgress,
sponsorBlock,
embed,
autoplay = false,
onVideoEnd,
}: VideoPlayerProps) => {
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
const videoId = video.data.youtube_id;
const videoUrl = video.data.media_url;
const videoThumbUrl = video.data.vid_thumb_url;
const watched = video.data.player.watched;
const duration = video.data.player.duration;
const videoSubtitles = video.data.subtitles;
let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
if (searchParamVideoProgress !== null) {
videoSrcProgress = searchParamVideoProgress;
}
const handleVideoEnd =
(
youtubeId: string,
@ -120,42 +161,10 @@ const handleVideoEnd =
return segments;
});
onVideoEnd?.();
};
export type VideoProgressType = {
youtube_id: string;
user_id: number;
position: number;
};
type VideoPlayerProps = {
video: VideoResponseType;
videoProgress?: VideoProgressType;
sponsorBlock?: SponsorBlockType;
embed?: boolean;
};
const VideoPlayer = ({ video, videoProgress, sponsorBlock, embed }: VideoPlayerProps) => {
const [searchParams] = useSearchParams();
const searchParamVideoProgress = searchParams.get('t');
const [skippedSegments, setSkippedSegments] = useState<SponsorSegmentsSkippedType>({});
const videoId = video.data.youtube_id;
const videoUrl = video.data.media_url;
const videoThumbUrl = video.data.vid_thumb_url;
const watched = video.data.player.watched;
const duration = video.data.player.duration;
const videoSubtitles = video.data.subtitles;
let videoSrcProgress = Number(videoProgress?.position) > 0 ? Number(videoProgress?.position) : '';
if (searchParamVideoProgress !== null) {
videoSrcProgress = searchParamVideoProgress;
}
const autoplay = false;
return (
<>
<div id="player" className={embed ? '' : 'player-wrapper'}>
@ -165,8 +174,13 @@ const VideoPlayer = ({ video, videoProgress, sponsorBlock, embed }: VideoPlayerP
onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
}}
onRateChange={(videoTag: VideoTag) => {
localStorage.setItem('playerSpeed', videoTag.currentTarget.playbackRate.toString());
}}
onLoadStart={(videoTag: VideoTag) => {
videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 1;
videoTag.currentTarget.playbackRate =
Number(localStorage.getItem('playerSpeed')) ?? 1;
}}
onTimeUpdate={handleTimeUpdate(
videoId,

View File

@ -1,33 +1,63 @@
import iconUnseen from '/img/icon-unseen.svg';
import iconSeen from '/img/icon-seen.svg';
import { useEffect, useState } from 'react';
type WatchedCheckBoxProps = {
watched: boolean;
onClick?: (status: boolean) => void;
onDone?: (status: boolean) => void;
};
const WatchedCheckBox = ({ watched, onClick }: WatchedCheckBoxProps) => {
const WatchedCheckBox = ({ watched, onClick, onDone }: WatchedCheckBoxProps) => {
const [loading, setLoading] = useState(false);
const [state, setState] = useState<boolean>(false);
useEffect(() => {
if (loading) {
onClick?.(state);
const timeout = setTimeout(() => {
onDone?.(state);
setLoading(false);
}, 1000);
return () => {
clearTimeout(timeout);
};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loading]);
return (
<>
{watched && (
{loading && (
<>
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
<div />
</div>
</>
)}
{!loading && watched && (
<img
src={iconSeen}
alt="seen-icon"
className="watch-button"
title="Mark as unwatched"
onClick={async () => {
onClick?.(false);
setState(false);
setLoading(true);
}}
/>
)}
{!watched && (
{!loading && !watched && (
<img
src={iconUnseen}
alt="unseen-icon"
className="watch-button"
title="Mark as watched"
onClick={async () => {
onClick?.(true);
setState(true);
setLoading(true);
}}
/>
)}

View File

@ -25,9 +25,7 @@ import ChannelBase from './pages/ChannelBase';
import ChannelVideo from './pages/ChannelVideo';
import ChannelPlaylist from './pages/ChannelPlaylist';
import ChannelAbout from './pages/ChannelAbout';
import ChannelStream from './pages/ChannelStream';
import Download from './pages/Download';
import ChannelShorts from './pages/ChannelShorts';
const router = createBrowserRouter(
[
@ -105,7 +103,7 @@ const router = createBrowserRouter(
{
index: true,
path: Routes.ChannelVideo(':channelId'),
element: <ChannelVideo />,
element: <ChannelVideo videoType="videos" />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
@ -119,26 +117,30 @@ const router = createBrowserRouter(
},
{
path: Routes.ChannelStream(':channelId'),
element: <ChannelStream />,
element: <ChannelVideo videoType="streams" />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{
path: Routes.ChannelShorts(':channelId'),
element: <ChannelShorts />,
element: <ChannelVideo videoType="shorts" />,
loader: async () => {
const authResponse = await loadAuth();
if (authResponse.status === 403) {
return redirect(Routes.Login);
}
return {};
const userConfig = await loadUserMeConfig();
return { userConfig };
},
},
{

View File

@ -1,11 +1,7 @@
import { Helmet } from 'react-helmet';
const About = () => {
return (
<>
<Helmet>
<title>TA | About</title>
</Helmet>
<div className="boxed-content">
<div className="title-bar">

View File

@ -10,18 +10,16 @@ import queueReindex, { ReindexType, ReindexTypeEnum } from '../api/actions/queue
import formatDate from '../functions/formatDates';
import PaginationDummy from '../components/PaginationDummy';
import FormattedNumber from '../components/FormattedNumber';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import updateChannelSettings, {
ChannelAboutConfigType,
} from '../api/actions/updateChannelSettings';
const handleSponsorBlockIntegrationOverwrite = (integration: boolean | undefined) => {
if (integration === undefined) {
return 'False';
}
if (integration) {
return integration;
} else {
return 'Disabled';
const toStringToBool = (str: string) => {
try {
return JSON.parse(str);
} catch {
return null;
}
};
@ -51,9 +49,26 @@ const ChannelAbout = () => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [reindex, setReindex] = useState(false);
const [refresh, setRefresh] = useState(false);
const [refresh, setRefresh] = useState(true);
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
const [channelConfig, setChannelConfig] = useState<ChannelAboutConfigType>();
const [downloadFormat, setDownloadFormat] = useState(channelConfig?.download_format);
const [autoDeleteAfter, setAutoDeleteAfter] = useState(channelConfig?.autodelete_days);
const [indexPlaylists, setIndexPlaylists] = useState(
channelConfig?.index_playlists ? 'true' : 'false',
);
const [enableSponsorblock, setEnableSponsorblock] = useState(
channelConfig?.integrate_sponsorblock,
);
const [pageSizeVideo, setPageSizeVideo] = useState(channelConfig?.subscriptions_channel_size);
const [pageSizeStreams, setPageSizeStreams] = useState(
channelConfig?.subscriptions_live_channel_size,
);
const [pageSizeShorts, setPageSizeShorts] = useState(
channelConfig?.subscriptions_shorts_channel_size,
);
const channel = channelResponse?.data;
@ -62,16 +77,31 @@ const ChannelAbout = () => {
const handleSubmit = async (event: { preventDefault: () => void }) => {
event.preventDefault();
//TODO: implement request to about api endpoint ( when implemented )
// `/api/channel/${channel.channel_id}/about/`
await updateChannelSettings(channelId, {
index_playlists: toStringToBool(indexPlaylists),
download_format: downloadFormat,
autodelete_days: autoDeleteAfter,
integrate_sponsorblock: enableSponsorblock,
subscriptions_channel_size: pageSizeVideo,
subscriptions_live_channel_size: pageSizeStreams,
subscriptions_shorts_channel_size: pageSizeShorts,
});
setRefresh(true);
};
useEffect(() => {
(async () => {
if (refresh) {
const channelResponse = await loadChannelById(channelId);
setChannelResponse(channelResponse);
setChannelConfig(channelResponse?.data?.channel_overwrites);
console.log('channel_overwrites', channelResponse?.data);
console.log('channel_overwrites', channelResponse?.data?.channel_overwrites);
console.log('channel_overwrites', '--------');
setRefresh(false);
}
})();
}, [refresh, channelId]);
@ -81,9 +111,7 @@ const ChannelAbout = () => {
return (
<>
<Helmet>
<title>TA | Channel: About {channel.channel_name}</title>
</Helmet>
<title>{`TA | Channel: About ${channel.channel_name}`}</title>
<div className="boxed-content">
<div className="info-box info-box-3">
<ChannelOverview
@ -91,6 +119,7 @@ const ChannelAbout = () => {
channelname={channel.channel_name}
channelSubs={channel.channel_subs}
channelSubscribed={channel.channel_subscribed}
channelThumbUrl={channel.channel_thumb_url}
showSubscribeButton={true}
isUserAdmin={isAdmin}
setRefresh={setRefresh}
@ -224,7 +253,22 @@ const ChannelAbout = () => {
{channelOverwrites?.download_format || 'False'}
</span>
</p>
<input type="text" name="download_format" id="id_download_format" />
<input
type="text"
name="download_format"
id="id_download_format"
onChange={event => {
const value = event.currentTarget.value;
setDownloadFormat(value);
}}
/>
<Button
label="Reset"
onClick={() => {
setDownloadFormat(false);
}}
/>
<br />
</div>
<div className="overwrite-form-item">
@ -234,7 +278,20 @@ const ChannelAbout = () => {
{channelOverwrites?.autodelete_days || 'False'}
</span>
</p>
<input type="number" name="autodelete_days" id="id_autodelete_days" />
<input
type="number"
name="autodelete_days"
id="id_autodelete_days"
onChange={event => {
const value = Number(event.currentTarget.value);
if (value === 0) {
setAutoDeleteAfter(false);
} else {
setAutoDeleteAfter(Number(value));
}
}}
/>
<br />
</div>
@ -243,12 +300,21 @@ const ChannelAbout = () => {
<p>
Index playlists:{' '}
<span className="settings-current">
{channelOverwrites?.index_playlists || 'False'}
{JSON.stringify(channelOverwrites?.index_playlists)}
</span>
</p>
<select name="index_playlists" id="id_index_playlists" defaultValue="">
<option value="">-- change playlist index --</option>
<select
name="index_playlists"
id="id_index_playlists"
value={indexPlaylists}
onChange={event => {
const value = event.currentTarget.value;
setIndexPlaylists(value);
}}
>
<option value="null">-- change playlist index --</option>
<option value="false">Disable playlist index</option>
<option value="true">Enable playlist index</option>
</select>
@ -264,20 +330,27 @@ const ChannelAbout = () => {
</a>
:{' '}
<span className="settings-current">
{handleSponsorBlockIntegrationOverwrite(
channelOverwrites?.integrate_sponsorblock,
)}
{JSON.stringify(channelOverwrites?.integrate_sponsorblock)}
</span>
</p>
<select
name="integrate_sponsorblock"
id="id_integrate_sponsorblock"
defaultValue=""
value={enableSponsorblock?.toString() || ''}
onChange={event => {
const value = event.currentTarget.value;
if (value !== '') {
setEnableSponsorblock(JSON.parse(value));
} else {
setEnableSponsorblock(undefined);
}
}}
>
<option value="">-- change sponsorblock integrations</option>
<option value="disable">disable sponsorblock integration</option>
<option value="false">disable sponsorblock integration</option>
<option value="true">enable sponsorblock integration</option>
<option value="false">unset sponsorblock integration</option>
<option value="null">unset sponsorblock integration</option>
</select>
</div>
<h3>Page Size Overrides</h3>
@ -301,7 +374,14 @@ const ChannelAbout = () => {
recommended 50.
</i>
<br />
<input type="number" name="channel_size" id="id_channel_size" />
<input
type="number"
name="channel_size"
id="id_channel_size"
onChange={event => {
setPageSizeVideo(Number(event.currentTarget.value));
}}
/>
<br />
</div>
<div className="overwrite-form-item">
@ -316,7 +396,14 @@ const ChannelAbout = () => {
max recommended 50.
</i>
<br />
<input type="number" name="live_channel_size" id="id_live_channel_size" />
<input
type="number"
name="live_channel_size"
id="id_live_channel_size"
onChange={event => {
setPageSizeStreams(Number(event.currentTarget.value));
}}
/>
<br />
</div>
<div className="overwrite-form-item">
@ -331,7 +418,14 @@ const ChannelAbout = () => {
task, max recommended 50.
</i>
<br />
<input type="number" name="shorts_channel_size" id="id_shorts_channel_size" />
<input
type="number"
name="shorts_channel_size"
id="id_shorts_channel_size"
onChange={event => {
setPageSizeShorts(Number(event.currentTarget.value));
}}
/>
</div>
<br />

View File

@ -7,6 +7,7 @@ import Notifications from '../components/Notifications';
import { useEffect, useState } from 'react';
import ChannelBanner from '../components/ChannelBanner';
import loadChannelNav, { ChannelNavResponseType } from '../api/loader/loadChannelNav';
import loadChannelById from '../api/loader/loadChannelById';
type ChannelParams = {
channelId: string;
@ -21,15 +22,19 @@ const ChannelBase = () => {
const { channelId } = useParams() as ChannelParams;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
const [channelNav, setChannelNav] = useState<ChannelNavResponseType>();
const [startNotification, setStartNotification] = useState(false);
const channel = channelResponse?.data;
const { has_streams, has_shorts, has_playlists, has_pending } = channelNav || {};
useEffect(() => {
(async () => {
const channelNavResponse = await loadChannelNav(channelId);
const channelResponse = await loadChannelById(channelId);
setChannelResponse(channelResponse);
setChannelNav(channelNavResponse);
})();
}, [channelId]);
@ -43,7 +48,7 @@ const ChannelBase = () => {
<div className="boxed-content">
<div className="channel-banner">
<Link to={Routes.ChannelVideo(channelId)}>
<ChannelBanner channel_id={channelId} />
<ChannelBanner channelId={channelId} channelBannerUrl={channel?.channel_banner_url} />
</Link>
</div>
<div className="info-box-item child-page-nav">

View File

@ -7,7 +7,6 @@ import { useEffect, useState } from 'react';
import { OutletContextType } from './Base';
import Pagination from '../components/Pagination';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import { Helmet } from 'react-helmet';
import loadPlaylistList from '../api/loader/loadPlaylistList';
import { PlaylistsResponseType } from './Playlists';
import iconGridView from '/img/icon-gridview.svg';
@ -27,7 +26,7 @@ const ChannelPlaylist = () => {
const [showSubedOnly, setShowSubedOnly] = useState(userMeConfig.show_subed_only || false);
const [view, setView] = useState<ViewLayoutType>(userMeConfig.view_style_playlist || 'grid');
const [gridItems, setGridItems] = useState(userMeConfig.grid_items || 3);
const [gridItems] = useState(userMeConfig.grid_items || 3);
const [refreshPlaylists, setRefreshPlaylists] = useState(false);
const [playlistsResponse, setPlaylistsResponse] = useState<PlaylistsResponseType>();
@ -53,9 +52,7 @@ const ChannelPlaylist = () => {
return (
<>
<Helmet>
<title>TA | Channel: Playlists</title>
</Helmet>
<ScrollToTopOnNavigate />
<div className={`boxed-content ${gridView}`}>
<Notifications pageName="channel" includeReindex={true} />

View File

@ -1,16 +0,0 @@
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router-dom';
const ChannelShorts = () => {
const { channelId } = useParams();
return (
<>
<Helmet>
<title>TA | Channel: {channel.channel_name}</title>
</Helmet>
</>
);
};
export default ChannelShorts;

View File

@ -1,16 +0,0 @@
import { Helmet } from 'react-helmet';
import { useParams } from 'react-router-dom';
const ChannelStream = () => {
const { channelId } = useParams();
return (
<>
<Helmet>
<title>TA | Channel: {channel.channel_name}</title>
</Helmet>
</>
);
};
export default ChannelStream;

View File

@ -20,11 +20,13 @@ import { ChannelResponseType } from './ChannelBase';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
import updateWatchedState from '../api/actions/updateWatchedState';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import loadVideoListByFilter, {
VideoListByFilterResponseType,
VideoTypes,
} from '../api/loader/loadVideoListByPage';
import loadChannelAggs, { ChannelAggsType } from '../api/loader/loadChannelAggs';
import humanFileSize from '../functions/humanFileSize';
type ChannelParams = {
channelId: string;
@ -34,7 +36,11 @@ type ChannelVideoLoaderType = {
userConfig: UserMeType;
};
const ChannelVideo = () => {
type ChannelVideoProps = {
videoType: VideoTypes;
};
const ChannelVideo = ({ videoType }: ChannelVideoProps) => {
const { channelId } = useParams() as ChannelParams;
const { userConfig } = useLoaderData() as ChannelVideoLoaderType;
const { isAdmin, currentPage, setCurrentPage } = useOutletContext() as OutletContextType;
@ -52,6 +58,7 @@ const ChannelVideo = () => {
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>();
const [videoResponse, setVideoReponse] = useState<VideoListByFilterResponseType>();
const [videoAggsResponse, setVideoAggsResponse] = useState<ChannelAggsType>();
const channel = channelResponse?.data;
const videoList = videoResponse?.data;
@ -78,10 +85,13 @@ const ChannelVideo = () => {
watch: hideWatched ? 'unwatched' : undefined,
sort: sortBy,
order: sortOrder,
type: videoType,
});
const channelAggs = await loadChannelAggs(channelId);
setChannelResponse(channelResponse);
setVideoReponse(videos);
setVideoAggsResponse(channelAggs);
setRefresh(false);
}
})();
@ -89,12 +99,6 @@ const ChannelVideo = () => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh, currentPage, channelId, pagination?.current_page]);
const aggs = {
total_items: { value: '<debug>' },
total_duration: { value_str: '<debug>' },
total_size: { value: '<debug>' },
};
if (!channel) {
return (
<div className="boxed-content">
@ -106,9 +110,7 @@ const ChannelVideo = () => {
return (
<>
<Helmet>
<title>TA | Channel: {channel.channel_name}</title>
</Helmet>
<title>{`TA | Channel: ${channel.channel_name}`}</title>
<ScrollToTopOnNavigate />
<div className="boxed-content">
<div className="info-box info-box-2">
@ -117,17 +119,20 @@ const ChannelVideo = () => {
channelname={channel.channel_name}
channelSubs={channel.channel_subs}
channelSubscribed={channel.channel_subscribed}
channelThumbUrl={channel.channel_thumb_url}
showSubscribeButton={true}
isUserAdmin={isAdmin}
setRefresh={setRefresh}
/>
<div className="info-box-item">
{aggs && (
{videoAggsResponse && (
<>
<p>
{aggs.total_items.value} videos <span className="space-carrot">|</span>{' '}
{aggs.total_duration.value_str} playback <span className="space-carrot">|</span>{' '}
Total size {aggs.total_size.value}
{videoAggsResponse.total_items.value} videos{' '}
<span className="space-carrot">|</span>{' '}
{videoAggsResponse.total_duration.value_str} playback{' '}
<span className="space-carrot">|</span> Total size{' '}
{humanFileSize(videoAggsResponse.total_size.value, true)}
</p>
<div className="button-box">
<Button

View File

@ -11,7 +11,6 @@ import { OutletContextType } from './Base';
import ChannelList from '../components/ChannelList';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import Notifications from '../components/Notifications';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
type ChannelOverwritesType = {
@ -102,9 +101,7 @@ const Channels = () => {
return (
<>
<Helmet>
<title>TA | Channels</title>
</Helmet>
<ScrollToTopOnNavigate />
<div className="boxed-content">
<div className="title-split">

View File

@ -16,7 +16,6 @@ import updateDownloadQueue from '../api/actions/updateDownloadQueue';
import updateTaskByName from '../api/actions/updateTaskByName';
import Notifications from '../components/Notifications';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import DownloadListItem from '../components/DownloadListItem';
import loadDownloadAggs, { DownloadAggsType } from '../api/loader/loadDownloadAggs';
@ -90,11 +89,6 @@ const Download = () => {
useEffect(() => {
(async () => {
if (
userMeConfig.show_ignored_only !== showIgnored ||
userMeConfig.view_style_downloads !== view ||
userMeConfig.grid_items !== gridItems
) {
const userConfig: UserConfigType = {
show_ignored_only: showIgnored,
[ViewStyleNames.downloads]: view,
@ -103,7 +97,6 @@ const Download = () => {
await updateUserConfig(userConfig);
setRefresh(true);
}
})();
}, [
view,
@ -152,9 +145,7 @@ const Download = () => {
return (
<>
<Helmet>
<title>TA | Downloads</title>
</Helmet>
<ScrollToTopOnNavigate />
<div className="boxed-content">
<div className="title-bar">

View File

@ -1,4 +1,3 @@
import { Helmet } from 'react-helmet';
import { useRouteError } from 'react-router-dom';
import importColours, { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
@ -16,9 +15,7 @@ const ErrorPage = () => {
return (
<>
<Helmet>
<title>TA | Oops!</title>
</Helmet>
<div id="error-page" style={{ margin: '10%' }}>
<h1>Oops!</h1>

View File

@ -13,7 +13,6 @@ import Filterbar from '../components/Filterbar';
import { ViewStyleNames, ViewStyles } from '../configuration/constants/ViewStyle';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
import { Helmet } from 'react-helmet';
import { SponsorBlockType } from './Video';
export type PlayerType = {
@ -169,9 +168,7 @@ const Home = () => {
return (
<>
<Helmet>
<title>TubeArchivist</title>
</Helmet>
<ScrollToTopOnNavigate />
<div className={`boxed-content ${gridView}`}>
{continueVideos && continueVideos.length > 0 && (

View File

@ -2,7 +2,6 @@ import { useState } from 'react';
import Routes from '../configuration/routes/RouteList';
import { useNavigate } from 'react-router-dom';
import importColours, { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import signIn from '../api/actions/signIn';
@ -32,9 +31,7 @@ const Login = () => {
return (
<>
<Helmet>
<title>TA | Welcome</title>
</Helmet>
<div className="boxed-content login-page">
<img alt="tube-archivist-logo" />
<h1>Tube Archivist</h1>

View File

@ -28,7 +28,6 @@ import queueReindex from '../api/actions/queueReindex';
import updateWatchedState from '../api/actions/updateWatchedState';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import loadVideoListByFilter from '../api/loader/loadVideoListByPage';
@ -139,9 +138,7 @@ const Playlist = () => {
return (
<>
<Helmet>
<title>TA | Playlist: {playlist.playlist_name}</title>
</Helmet>
<title>{`TA | Playlist: ${playlist.playlist_name}`}</title>
<ScrollToTopOnNavigate />
<div className="boxed-content">
<div className="title-bar">
@ -154,6 +151,7 @@ const Playlist = () => {
channelname={channel?.channel_name}
channelSubs={channel?.channel_subs}
channelSubscribed={channel?.channel_subscribed}
channelThumbUrl={channel.channel_thumb_url}
setRefresh={setRefresh}
/>
)}

View File

@ -15,7 +15,6 @@ import { PlaylistType } from './Playlist';
import updatePlaylistSubscription from '../api/actions/updatePlaylistSubscription';
import createCustomPlaylist from '../api/actions/createCustomPlaylist';
import ScrollToTopOnNavigate from '../components/ScrollToTop';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
export type PlaylistEntryType = {
@ -68,6 +67,7 @@ const Playlists = () => {
};
await updateUserConfig(userConfig);
setRefresh(true);
}
})();
}, [showSubedOnly, userMeConfig.show_subed_only, userMeConfig.view_style_playlist, view]);
@ -79,19 +79,22 @@ const Playlists = () => {
pagination?.current_page === undefined ||
currentPage !== pagination?.current_page
) {
const playlist = await loadPlaylistList({ page: currentPage });
const playlist = await loadPlaylistList({
page: currentPage,
subscribed: showSubedOnly,
});
setPlaylistReponse(playlist);
setRefresh(false);
}
})();
}, [refresh, currentPage, showSubedOnly, view, pagination?.current_page]);
// Do not add showSubedOnly, view this will not work as expected!
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refresh, currentPage, pagination?.current_page]);
return (
<>
<Helmet>
<title>TA | Playlists</title>
</Helmet>
<ScrollToTopOnNavigate />
<div className="boxed-content">
<div className="title-split">

View File

@ -11,7 +11,6 @@ import PlaylistList from '../components/PlaylistList';
import SubtitleList from '../components/SubtitleList';
import { ViewStyles } from '../configuration/constants/ViewStyle';
import EmbeddableVideoPlayer from '../components/EmbeddableVideoPlayer';
import { Helmet } from 'react-helmet';
import SearchExampleQueries from '../components/SearchExampleQueries';
const EmptySearchResponse: SearchResultsType = {
@ -94,9 +93,7 @@ const Search = () => {
return (
<>
<Helmet>
<title>TubeArchivist</title>
</Helmet>
{showEmbeddedVideo && <EmbeddableVideoPlayer videoId={videoId} />}
<div className={`boxed-content ${gridView}`}>
<div className="title-bar">
@ -106,7 +103,7 @@ const Search = () => {
<div>
<input
type="text"
name="searchInput"
autoFocus
autoComplete="off"
value={searchQuery}
onChange={event => {

View File

@ -6,7 +6,6 @@ import updateTaskByName from '../api/actions/updateTaskByName';
import queueBackup from '../api/actions/queueBackup';
import restoreBackup from '../api/actions/restoreBackup';
import Notifications from '../components/Notifications';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
type Backup = {
@ -43,9 +42,7 @@ const SettingsActions = () => {
return (
<>
<Helmet>
<title>TA | Actions</title>
</Helmet>
<div className="boxed-content">
<SettingsNavigation />
<Notifications

View File

@ -7,7 +7,6 @@ import restoreSnapshot from '../api/actions/restoreSnapshot';
import queueSnapshot from '../api/actions/queueSnapshot';
import updateCookie, { ValidatedCookieType } from '../api/actions/updateCookie';
import deleteApiToken from '../api/actions/deleteApiToken';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import loadAppsettingsConfig, { AppSettingsConfigType } from '../api/loader/loadAppsettingsConfig';
import updateAppsettingsConfig from '../api/actions/updateAppsettingsConfig';
@ -33,102 +32,63 @@ type SnapshotListType = {
type SettingsApplicationReponses = {
snapshots?: SnapshotListType;
appSettingsConfig?: AppSettingsConfigType;
apiToken: string;
apiToken?: string;
};
const SettingsApplication = () => {
const [response, setResponse] = useState<SettingsApplicationReponses>({
snapshots: undefined,
appSettingsConfig: undefined,
apiToken: '',
});
const [response, setResponse] = useState<SettingsApplicationReponses>();
const [refresh, setRefresh] = useState(false);
const snapshots = response?.snapshots;
const appSettingsConfig = response?.appSettingsConfig;
const apiToken = response.apiToken;
const apiToken = response?.apiToken;
// Subscriptions
const [videoPageSize, setVideoPageSize] = useState(
appSettingsConfig?.subscriptions.channel_size || 0,
);
const [livePageSize, setLivePageSize] = useState(
appSettingsConfig?.subscriptions.live_channel_size || 0,
);
const [shortPageSize, setShortPageSize] = useState(
appSettingsConfig?.subscriptions.shorts_channel_size || 0,
);
const [isAutostart, setIsAutostart] = useState(
appSettingsConfig?.subscriptions.auto_start || false,
);
const [videoPageSize, setVideoPageSize] = useState(0);
const [livePageSize, setLivePageSize] = useState(0);
const [shortPageSize, setShortPageSize] = useState(0);
const [isAutostart, setIsAutostart] = useState(false);
// Downloads
const [currentDownloadSpeed, setCurrentDownloadSpeed] = useState(
appSettingsConfig?.downloads.limit_speed || 0,
);
const [currentThrottledRate, setCurrentThrottledRate] = useState(
appSettingsConfig?.downloads.throttledratelimit || 0,
);
const [currentScrapingSleep, setCurrentScrapingSleep] = useState(
appSettingsConfig?.downloads.sleep_interval || 0,
);
const [currentAutodelete, setCurrentAutodelete] = useState(
appSettingsConfig?.downloads.autodelete_days || 0,
);
const [currentDownloadSpeed, setCurrentDownloadSpeed] = useState(0);
const [currentThrottledRate, setCurrentThrottledRate] = useState(0);
const [currentScrapingSleep, setCurrentScrapingSleep] = useState(0);
const [currentAutodelete, setCurrentAutodelete] = useState(0);
// Download Format
const [downloadsFormat, setDownloadsFormat] = useState(appSettingsConfig?.downloads.format || '');
const [downloadsFormatSort, setDownloadsFormatSort] = useState(
appSettingsConfig?.downloads.format_sort || '',
);
const [downloadsExtractorLang, setDownloadsExtractorLang] = useState(
appSettingsConfig?.downloads.extractor_lang || '',
);
const [embedMetadata, setEmbedMetadata] = useState(
appSettingsConfig?.downloads.add_metadata || false,
);
const [embedThumbnail, setEmbedThumbnail] = useState(
appSettingsConfig?.downloads.add_thumbnail || false,
);
const [downloadsFormat, setDownloadsFormat] = useState('');
const [downloadsFormatSort, setDownloadsFormatSort] = useState('');
const [downloadsExtractorLang, setDownloadsExtractorLang] = useState('');
const [embedMetadata, setEmbedMetadata] = useState(false);
const [embedThumbnail, setEmbedThumbnail] = useState(false);
// Subtitles
const [subtitleLang, setSubtitleLang] = useState(appSettingsConfig?.downloads.subtitle || '');
const [subtitleSource, setSubtitleSource] = useState(
appSettingsConfig?.downloads.subtitle_source || '',
);
const [indexSubtitles, setIndexSubtitles] = useState(
appSettingsConfig?.downloads.subtitle_index || false,
);
const [subtitleLang, setSubtitleLang] = useState('');
const [subtitleSource, setSubtitleSource] = useState('');
const [indexSubtitles, setIndexSubtitles] = useState(false);
// Comments
const [commentsMax, setCommentsMax] = useState(appSettingsConfig?.downloads.comment_max || 0);
const [commentsSort, setCommentsSort] = useState(appSettingsConfig?.downloads.comment_sort || '');
const [commentsMax, setCommentsMax] = useState('');
const [commentsSort, setCommentsSort] = useState('');
// Cookie
const [cookieImport, setCookieImport] = useState(
appSettingsConfig?.downloads.cookie_import || false,
);
const [cookieImport, setCookieImport] = useState(false);
const [validatingCookie, setValidatingCookie] = useState(false);
const [cookieResponse, setCookieResponse] = useState<ValidatedCookieType>();
// Integrations
const [showApiToken, setShowApiToken] = useState(false);
const [downloadDislikes, setDownloadDislikes] = useState(
appSettingsConfig?.downloads.integrate_ryd || false,
);
const [enableSponsorBlock, setEnableSponsorBlock] = useState(
appSettingsConfig?.downloads.integrate_sponsorblock || false,
);
const [downloadDislikes, setDownloadDislikes] = useState(false);
const [enableSponsorBlock, setEnableSponsorBlock] = useState(false);
const [resetTokenResponse, setResetTokenResponse] = useState({});
// Snapshots
const [enableSnapshots, setEnableSnapshots] = useState(
appSettingsConfig?.application.enable_snapshot || false,
);
const [enableSnapshots, setEnableSnapshots] = useState(false);
const [isSnapshotQueued, setIsSnapshotQueued] = useState(false);
const [restoringSnapshot, setRestoringSnapshot] = useState(false);
const onSubmit = async () => {
return await updateAppsettingsConfig({
await updateAppsettingsConfig({
application: {
enable_snapshot: enableSnapshots,
},
@ -158,27 +118,74 @@ const SettingsApplication = () => {
shorts_channel_size: shortPageSize,
},
});
setRefresh(true);
};
useEffect(() => {
(async () => {
const fetchData = async () => {
const snapshotResponse = await loadSnapshots();
const appSettingsConfig = await loadAppsettingsConfig();
const apiToken = await loadApiToken();
// Subscriptions
setVideoPageSize(appSettingsConfig?.subscriptions.channel_size);
setLivePageSize(appSettingsConfig?.subscriptions.live_channel_size);
setShortPageSize(appSettingsConfig?.subscriptions.shorts_channel_size);
setIsAutostart(appSettingsConfig?.subscriptions.auto_start);
// Downloads
setCurrentDownloadSpeed(appSettingsConfig?.downloads.limit_speed || 0);
setCurrentThrottledRate(appSettingsConfig?.downloads.throttledratelimit || 0);
setCurrentScrapingSleep(appSettingsConfig?.downloads.sleep_interval);
setCurrentAutodelete(appSettingsConfig?.downloads.autodelete_days);
// Download Format
setDownloadsFormat(appSettingsConfig?.downloads.format.toString());
setDownloadsFormatSort(appSettingsConfig?.downloads.format_sort.toString());
setDownloadsExtractorLang(appSettingsConfig?.downloads.extractor_lang.toString());
setEmbedMetadata(appSettingsConfig?.downloads.add_metadata);
setEmbedThumbnail(appSettingsConfig?.downloads.add_thumbnail);
// Subtitles
setSubtitleLang(appSettingsConfig?.downloads.subtitle.toString());
setSubtitleSource(appSettingsConfig?.downloads.subtitle_source.toString());
setIndexSubtitles(appSettingsConfig?.downloads.subtitle_index);
// Comments
setCommentsMax(appSettingsConfig?.downloads.comment_max.toString());
setCommentsSort(appSettingsConfig?.downloads.comment_sort);
// Cookie
setCookieImport(appSettingsConfig?.downloads.cookie_import);
// Integrations
setDownloadDislikes(appSettingsConfig?.downloads.integrate_ryd);
setEnableSponsorBlock(appSettingsConfig?.downloads.integrate_sponsorblock);
// Snapshots
setEnableSnapshots(appSettingsConfig?.application.enable_snapshot);
setResponse({
snapshots: snapshotResponse,
appSettingsConfig,
apiToken: apiToken.token,
});
})();
};
useEffect(() => {
fetchData();
}, []);
useEffect(() => {
if (refresh) {
fetchData();
setRefresh(false);
}
}, [refresh]);
return (
<>
<Helmet>
<title>TA | Application Settings</title>
</Helmet>
<div className="boxed-content">
<SettingsNavigation />
<Notifications pageName={'all'} />
@ -200,7 +207,7 @@ const SettingsApplication = () => {
<p>
YouTube page size:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.subscriptions.channel_size}
{appSettingsConfig?.subscriptions.channel_size}
</span>
</p>
<i>
@ -211,7 +218,7 @@ const SettingsApplication = () => {
<input
type="number"
name="subscriptions_channel_size"
min="1"
min="0"
id="id_subscriptions_channel_size"
value={videoPageSize}
onChange={event => {
@ -224,7 +231,7 @@ const SettingsApplication = () => {
<p>
YouTube Live page size:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.subscriptions.live_channel_size}
{appSettingsConfig?.subscriptions.live_channel_size}
</span>
</p>
<i>
@ -248,7 +255,7 @@ const SettingsApplication = () => {
<p>
YouTube Shorts page size:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.subscriptions.shorts_channel_size}
{appSettingsConfig?.subscriptions.shorts_channel_size}
</span>
</p>
<i>
@ -272,7 +279,7 @@ const SettingsApplication = () => {
<p>
Auto start download from your subscriptions:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.subscriptions.auto_start}
{appSettingsConfig?.subscriptions.auto_start.toString()}
</span>
</p>
<i>
@ -282,7 +289,6 @@ const SettingsApplication = () => {
<select
name="subscriptions_auto_start"
id="id_subscriptions_auto_start"
defaultValue=""
value={isAutostart.toString()}
onChange={event => {
setIsAutostart(event.target.value === 'true');
@ -300,9 +306,7 @@ const SettingsApplication = () => {
<div className="settings-item">
<p>
Current download speed limit in KB/s:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.limit_speed}
</span>
<span className="settings-current">{appSettingsConfig?.downloads.limit_speed}</span>
</p>
<i>
Limit download speed. 0 (zero) to deactivate, e.g. 1000 (1MB/s). Speeds are in KB/s.
@ -324,7 +328,7 @@ const SettingsApplication = () => {
<p>
Current throttled rate limit in KB/s:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.throttledratelimit}
{appSettingsConfig?.downloads.throttledratelimit}
</span>
</p>
<i>
@ -347,7 +351,7 @@ const SettingsApplication = () => {
<p>
Current scraping sleep interval:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.sleep_interval}
{appSettingsConfig?.downloads.sleep_interval}
</span>
</p>
<i>
@ -371,7 +375,7 @@ const SettingsApplication = () => {
<span className="danger-zone">Danger Zone</span>: Current auto delete watched
videos:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.autodelete_days}
{appSettingsConfig?.downloads.autodelete_days}
</span>
</p>
<i>Auto delete watched videos after x days, 0 (zero) to deactivate:</i>
@ -395,9 +399,7 @@ const SettingsApplication = () => {
Limit video and audio quality format for yt-dlp.
<br />
Currently:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.format}
</span>
<span className="settings-current">{appSettingsConfig?.downloads.format}</span>
</p>
<p>Example configurations:</p>
<ul>
@ -450,9 +452,7 @@ const SettingsApplication = () => {
Force sort order to have precedence over all yt-dlp fields.
<br />
Currently:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.format_sort}
</span>
<span className="settings-current">{appSettingsConfig?.downloads.format_sort}</span>
</p>
<p>Example configurations:</p>
<ul>
@ -490,7 +490,7 @@ const SettingsApplication = () => {
<p>
Prefer translated metadata language:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.extractor_lang}
{appSettingsConfig?.downloads.extractor_lang}
</span>
</p>
<i>
@ -518,7 +518,7 @@ const SettingsApplication = () => {
<p>
Current metadata embed setting:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.add_metadata}
{appSettingsConfig?.downloads.add_metadata.toString()}
</span>
</p>
<i>Metadata is not embedded into the downloaded files by default.</i>
@ -526,7 +526,6 @@ const SettingsApplication = () => {
<select
name="downloads_add_metadata"
id="id_downloads_add_metadata"
defaultValue=""
value={embedMetadata.toString()}
onChange={event => {
setEmbedMetadata(event.target.value === 'true');
@ -542,7 +541,7 @@ const SettingsApplication = () => {
<p>
Current thumbnail embed setting:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.add_thumbnail}
{appSettingsConfig?.downloads.add_thumbnail.toString()}
</span>
</p>
<i>Embed thumbnail into the mediafile.</i>
@ -568,9 +567,7 @@ const SettingsApplication = () => {
<div className="settings-item">
<p>
Subtitles download setting:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.subtitle}
</span>
<span className="settings-current">{appSettingsConfig?.downloads.subtitle}</span>
<br />
<i>
Choose which subtitles to download, add comma separated language codes,
@ -593,7 +590,7 @@ const SettingsApplication = () => {
<p>
Subtitle source settings:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.subtitle_source}
{appSettingsConfig?.downloads.subtitle_source}
</span>
</p>
<i>Download only user generated, or also less accurate auto generated subtitles.</i>
@ -616,7 +613,7 @@ const SettingsApplication = () => {
<p>
Index and make subtitles searchable:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.subtitle_index}
{appSettingsConfig?.downloads.subtitle_index.toString()}
</span>
</p>
<i>Store subtitle lines in Elasticsearch. Not recommended for low-end hardware.</i>
@ -642,9 +639,7 @@ const SettingsApplication = () => {
<div className="settings-item">
<p>
Download and index comments:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.comment_max}
</span>
<span className="settings-current">{appSettingsConfig?.downloads.comment_max}</span>
<br />
<i>
Follow the yt-dlp max_comments documentation,{' '}
@ -669,9 +664,9 @@ const SettingsApplication = () => {
type="text"
name="downloads_comment_max"
id="id_downloads_comment_max"
value={commentsMax.toString()}
value={commentsMax}
onChange={event => {
setCommentsMax(Number(event.target.value));
setCommentsMax(event.target.value);
}}
/>
</p>
@ -680,7 +675,7 @@ const SettingsApplication = () => {
<p>
Selected comment sort method:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.comment_sort}
{appSettingsConfig?.downloads.comment_sort}
</span>
<br />
<i>Select how many comments and threads to download:</i>
@ -689,7 +684,7 @@ const SettingsApplication = () => {
name="downloads_comment_sort"
id="id_downloads_comment_sort"
defaultValue=""
value={commentsSort.toString()}
value={commentsSort}
onChange={event => {
setCommentsSort(event.target.value);
}}
@ -708,7 +703,7 @@ const SettingsApplication = () => {
<p>
Import YouTube cookie:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.cookie_import}
{appSettingsConfig?.downloads.cookie_import}
</span>
<br />
</p>
@ -755,7 +750,7 @@ const SettingsApplication = () => {
)}
{!validatingCookie && (
<>
{appSettingsConfig && appSettingsConfig.downloads.cookie_import && (
{appSettingsConfig?.downloads.cookie_import && (
<div id="cookieMessage">
<Button
id="cookieButton"
@ -813,7 +808,7 @@ const SettingsApplication = () => {
</a>{' '}
to get dislikes and average ratings back:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.integrate_ryd}
{appSettingsConfig?.downloads.integrate_ryd.toString()}
</span>
</p>
<i>
@ -844,7 +839,7 @@ const SettingsApplication = () => {
</a>{' '}
to get sponsored timestamps:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.downloads.integrate_sponsorblock}
{appSettingsConfig?.downloads.integrate_sponsorblock.toString()}
</span>
</p>
<i>
@ -874,7 +869,7 @@ const SettingsApplication = () => {
<p>
Current system snapshot:{' '}
<span className="settings-current">
{appSettingsConfig && appSettingsConfig.application.enable_snapshot}
{appSettingsConfig?.application.enable_snapshot}
</span>
</p>
<i>
@ -955,6 +950,7 @@ const SettingsApplication = () => {
name="application-settings"
label="Update Application Configurations"
onClick={async () => {
window.scrollTo(0, 0);
await onSubmit();
}}
/>

View File

@ -15,7 +15,6 @@ import DownloadHistoryStats from '../components/DownloadHistoryStats';
import BiggestChannelsStats from '../components/BiggestChannelsStats';
import Notifications from '../components/Notifications';
import PaginationDummy from '../components/PaginationDummy';
import { Helmet } from 'react-helmet';
export type VideoStatsType = {
doc_count: number;
@ -184,9 +183,7 @@ const SettingsDashboard = () => {
return (
<>
<Helmet>
<title>TA | Settings Dashboard</title>
</Helmet>
<div className="boxed-content">
<SettingsNavigation />
<Notifications pageName={'all'} />

View File

@ -1,148 +1,69 @@
import { Helmet } from 'react-helmet';
import Notifications from '../components/Notifications';
import SettingsNavigation from '../components/SettingsNavigation';
import Button from '../components/Button';
type CronTabType = {
minute: number;
hour: number;
day_of_week: number;
};
type SchedulerErrorType = {
errors: string[];
};
type NotificationItemType = {
task: string;
notification: {
title: string;
urls: string[];
};
};
type SettingsSchedulingResponseType = {
update_subscribed: {
crontab: CronTabType;
};
check_reindex: {
crontab: CronTabType;
task_config: {
days: 0;
};
};
thumbnail_check: {
crontab: CronTabType;
};
download_pending: {
crontab: CronTabType;
};
run_backup: {
crontab: CronTabType;
task_config: {
rotate: false;
};
};
notifications: {
items: NotificationItemType[];
};
scheduler_form: {
update_subscribed: SchedulerErrorType;
download_pending: SchedulerErrorType;
check_reindex: SchedulerErrorType;
thumbnail_check: SchedulerErrorType;
run_backup: SchedulerErrorType;
};
};
import PaginationDummy from '../components/PaginationDummy';
import { useEffect, useState } from 'react';
import loadSchedule, { ScheduleResponseType } from '../api/loader/loadSchedule';
import loadAppriseNotification, {
AppriseNotificationType,
} from '../api/loader/loadAppriseNotification';
import deleteTaskSchedule from '../api/actions/deleteTaskSchedule';
import createTaskSchedule from '../api/actions/createTaskSchedule';
import createAppriseNotificationUrl, {
AppriseTaskNameType,
} from '../api/actions/createAppriseNotificationUrl';
import deleteAppriseNotificationUrl from '../api/actions/deleteAppriseNotificationUrl';
const SettingsScheduling = () => {
const response: SettingsSchedulingResponseType = {
update_subscribed: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
},
check_reindex: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
task_config: {
days: 0,
},
},
thumbnail_check: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
},
download_pending: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
},
run_backup: {
crontab: {
minute: 0,
hour: 0,
day_of_week: 0,
},
task_config: {
rotate: false,
},
},
notifications: {
items: [
{
task: '',
notification: {
title: '',
urls: [''],
},
},
],
},
scheduler_form: {
update_subscribed: {
errors: ['error?'],
},
download_pending: {
errors: ['error?'],
},
check_reindex: {
errors: ['error?'],
},
thumbnail_check: {
errors: ['error?'],
},
run_backup: {
errors: ['error?'],
},
},
};
const [refresh, setRefresh] = useState(false);
const {
check_reindex,
download_pending,
notifications,
run_backup,
scheduler_form,
thumbnail_check,
update_subscribed,
} = response;
const [scheduleResponse, setScheduleResponse] = useState<ScheduleResponseType>([]);
const [appriseNotification, setAppriseNotification] = useState<AppriseNotificationType>();
const [updateSubscribed, setUpdateSubscribed] = useState<string | undefined>();
const [downloadPending, setDownloadPending] = useState<string | undefined>();
const [checkReindex, setCheckReindex] = useState<string | undefined>();
const [checkReindexDays, setCheckReindexDays] = useState<number | undefined>(undefined);
const [thumbnailCheck, setThumbnailCheck] = useState<string | undefined>();
const [zipBackup, setZipBackup] = useState<string | undefined>();
const [zipBackupDays, setZipBackupDays] = useState<number | undefined>(undefined);
const [notificationUrl, setNotificationUrl] = useState<string | undefined>(undefined);
const [notificationTask, setNotificationTask] = useState<AppriseTaskNameType | string>('');
useEffect(() => {
(async () => {
if (refresh) {
const scheduleResponse = await loadSchedule();
const appriseNotificationResponse = await loadAppriseNotification();
setScheduleResponse(scheduleResponse);
setAppriseNotification(appriseNotificationResponse);
setRefresh(false);
}
})();
}, [refresh]);
useEffect(() => {
setRefresh(true);
}, []);
const groupedSchedules = Object.groupBy(scheduleResponse, ({ name }) => name);
console.log(groupedSchedules);
const { update_subscribed, download_pending, run_backup, check_reindex, thumbnail_check } =
groupedSchedules;
const updateSubscribedSchedule = update_subscribed?.pop();
const downloadPendingSchedule = download_pending?.pop();
const runBackup = run_backup?.pop();
const checkReindexSchedule = check_reindex?.pop();
const thumbnailCheckSchedule = thumbnail_check?.pop();
return (
<>
<Helmet>
<title>TA | Scheduling Settings</title>
</Helmet>
<div className="boxed-content">
<SettingsNavigation />
<Notifications pageName={'all'} />
@ -178,7 +99,7 @@ const SettingsScheduling = () => {
</ul>
</div>
</div>
<form action="{% url 'settings_scheduling' %}" method="POST" name="scheduler-update">
<div className="settings-group">
<h2>Rescan Subscriptions</h2>
<div className="settings-item">
@ -187,38 +108,51 @@ const SettingsScheduling = () => {
<a href="https://members.tubearchivist.com/" target="_blank">
members.tubearchivist.com
</a>{' '}
to get access to <span className="settings-current">real time</span> notifications
for new videos uploaded by your favorite channels.
to get access to <span className="settings-current">real time</span> notifications for
new videos uploaded by your favorite channels.
</p>
<p>
Current rescan schedule:{' '}
<span className="settings-current">
{update_subscribed && (
{!updateSubscribedSchedule && 'False'}
{updateSubscribedSchedule && (
<>
{update_subscribed.crontab.minute} {update_subscribed.crontab.hour}{' '}
{update_subscribed.crontab.day_of_week}
{updateSubscribedSchedule?.schedule}{' '}
<Button
label="Delete"
data-schedule="update_subscribed"
onclick="deleteSchedule(this)"
onClick={async () => {
await deleteTaskSchedule('update_subscribed');
setRefresh(true);
}}
className="danger-button"
/>
</>
)}
{!update_subscribed && 'False'}
</span>
</p>
<p>Periodically rescan your subscriptions:</p>
{scheduler_form.update_subscribed.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="text" name="update_subscribed" id="id_update_subscribed" />
<input
type="text"
value={updateSubscribed || updateSubscribedSchedule?.schedule}
onChange={e => {
setUpdateSubscribed(e.currentTarget.value);
}}
/>
<Button
label="Save"
onClick={async () => {
await createTaskSchedule('update_subscribed', {
schedule: updateSubscribed,
});
setUpdateSubscribed('');
setRefresh(true);
}}
/>
</div>
</div>
<div className="settings-group">
@ -227,109 +161,167 @@ const SettingsScheduling = () => {
<p>
Current Download schedule:{' '}
<span className="settings-current">
{download_pending && (
{!download_pending && 'False'}
{downloadPendingSchedule && (
<>
{download_pending.crontab.minute} {download_pending.crontab.hour}{' '}
{download_pending.crontab.day_of_week}
{downloadPendingSchedule?.schedule}{' '}
<Button
label="Delete"
data-schedule="download_pending"
onclick="deleteSchedule(this)"
className="danger-button"
onClick={async () => {
await deleteTaskSchedule('download_pending');
setRefresh(true);
}}
/>
</>
)}
{!download_pending && 'False'}
</span>
</p>
<p>Automatic video download schedule:</p>
{scheduler_form.download_pending.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="text" name="download_pending" id="id_download_pending" />
<input
type="text"
value={downloadPending || downloadPendingSchedule?.schedule}
onChange={e => {
setDownloadPending(e.currentTarget.value);
}}
/>
<Button
label="Save"
onClick={async () => {
await createTaskSchedule('download_pending', {
schedule: downloadPending,
});
setDownloadPending('');
setRefresh(true);
}}
/>
</div>
</div>
<div className="settings-group">
<h2>Refresh Metadata</h2>
<div className="settings-item">
<p>
Current Metadata refresh schedule:{' '}
<span className="settings-current">
{check_reindex && (
{!checkReindexSchedule && 'False'}
{checkReindexSchedule && (
<>
{check_reindex.crontab.minute} {check_reindex.crontab.hour}{' '}
{check_reindex.crontab.day_of_week}
{checkReindexSchedule?.schedule}{' '}
<Button
label="Delete"
data-schedule="check_reindex"
onclick="deleteSchedule(this)"
className="danger-button"
onClick={async () => {
await deleteTaskSchedule('check_reindex');
setRefresh(true);
}}
/>
</>
)}
{!check_reindex && 'False'}
</span>
</p>
<p>Daily schedule to refresh metadata from YouTube:</p>
<input type="text" name="check_reindex" id="id_check_reindex" />
<input
type="text"
value={checkReindex || checkReindexSchedule?.schedule}
onChange={e => {
setCheckReindex(e.currentTarget.value);
}}
/>
<Button
label="Save"
onClick={async () => {
await createTaskSchedule('check_reindex', {
schedule: checkReindex,
});
setCheckReindex('');
setRefresh(true);
}}
/>
</div>
<div className="settings-item">
<p>
Current refresh for metadata older than x days:{' '}
<span className="settings-current">{check_reindex.task_config.days}</span>
<span className="settings-current">{checkReindexSchedule?.config?.days}</span>
</p>
<p>Refresh older than x days, recommended 90:</p>
{scheduler_form.check_reindex.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="number" name="check_reindex_days" id="id_check_reindex_days" />
<input
type="number"
value={checkReindexDays || checkReindexSchedule?.config?.days}
onChange={e => {
setCheckReindexDays(Number(e.currentTarget.value));
}}
/>
<Button
label="Save"
onClick={async () => {
await createTaskSchedule('check_reindex', {
config: {
days: checkReindexDays,
},
});
setCheckReindexDays(undefined);
setRefresh(true);
}}
/>
</div>
</div>
<div className="settings-group">
<h2>Thumbnail Check</h2>
<div className="settings-item">
<p>
Current thumbnail check schedule:{' '}
<span className="settings-current">
{thumbnail_check && (
{!thumbnailCheckSchedule && 'False'}
{thumbnailCheckSchedule && (
<>
{thumbnail_check.crontab.minute} {thumbnail_check.crontab.hour}{' '}
{thumbnail_check.crontab.day_of_week}
{thumbnailCheckSchedule?.schedule}{' '}
<Button
label="Delete"
data-schedule="thumbnail_check"
onclick="deleteSchedule(this)"
className="danger-button"
onClick={async () => {
await deleteTaskSchedule('thumbnail_check');
setRefresh(true);
}}
/>
</>
)}
{!thumbnail_check && 'False'}
</span>
</p>
<p>Periodically check and cleanup thumbnails:</p>
{scheduler_form.thumbnail_check.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="text" name="thumbnail_check" id="id_thumbnail_check" />
<input
type="text"
value={thumbnailCheck || thumbnailCheckSchedule?.schedule}
onChange={e => {
setThumbnailCheck(e.currentTarget.value);
}}
/>
<Button
label="Save"
onClick={async () => {
await createTaskSchedule('thumbnail_check', {
schedule: thumbnailCheck,
});
setThumbnailCheck('');
setRefresh(true);
}}
/>
</div>
</div>
<div className="settings-group">
@ -337,82 +329,108 @@ const SettingsScheduling = () => {
<div className="settings-item">
<p>
<i>
Zip file backups are very slow for large archives and consistency is not
guaranteed, use snapshots instead. Make sure no other tasks are running when
creating a Zip file backup.
Zip file backups are very slow for large archives and consistency is not guaranteed,
use snapshots instead. Make sure no other tasks are running when creating a Zip file
backup.
</i>
</p>
<p>
Current index backup schedule:{' '}
<span className="settings-current">
{run_backup && (
{!runBackup && 'False'}
{runBackup && (
<>
{run_backup.crontab.minute} {run_backup.crontab.hour}{' '}
{run_backup.crontab.day_of_week}
{runBackup.schedule}{' '}
<Button
label="Delete"
data-schedule="run_backup"
onclick="deleteSchedule(this)"
className="danger-button"
onClick={async () => {
await deleteTaskSchedule('run_backup');
setRefresh(true);
}}
/>
</>
)}
{!run_backup && 'False'}
</span>
</p>
<p>Automatically backup metadata to a zip file:</p>
{scheduler_form.run_backup.errors.map(error => {
return (
<p key={error} className="danger-zone">
{error}
</p>
);
})}
<input type="text" name="run_backup" id="id_run_backup" />
<input
type="text"
value={zipBackup || runBackup?.schedule}
onChange={e => {
setZipBackup(e.currentTarget.value);
}}
/>
<Button
label="Save"
onClick={async () => {
await createTaskSchedule('run_backup', {
schedule: zipBackup,
});
setZipBackup('');
setRefresh(true);
}}
/>
</div>
<div className="settings-item">
<p>
Current backup files to keep:{' '}
<span className="settings-current">{run_backup.task_config.rotate}</span>
<span className="settings-current">{runBackup?.config?.rotate}</span>
</p>
<p>Max auto backups to keep:</p>
<input type="number" name="run_backup_rotate" id="id_run_backup_rotate" />
<input
type="number"
value={(zipBackupDays || runBackup?.config?.rotate)?.toString()}
onChange={e => {
setZipBackupDays(Number(e.currentTarget.value));
}}
/>
<Button
label="Save"
onClick={async () => {
await createTaskSchedule('run_backup', {
config: {
rotate: zipBackupDays,
},
});
setZipBackupDays(undefined);
setRefresh(true);
}}
/>
</div>
</div>
<div className="settings-group">
<h2>Add Notification URL</h2>
<div className="settings-item">
{notifications && (
{!appriseNotification && <p>No notifications stored</p>}
{appriseNotification && (
<>
<p>
<Button
label="Show"
type="button"
onclick="textReveal(this)"
id="text-reveal-button"
/>{' '}
stored notification links
</p>
<div id="text-reveal" className="description-text">
{notifications.items.map(({ task, notification }) => {
<div className="description-text">
{Object.entries(appriseNotification)?.map(([key, { urls, title }]) => {
return (
<>
<h3 key={task}>{notification.title}</h3>
{notification.urls.map((url: string) => {
<h3 key={key}>{title}</h3>
{urls.map((url: string) => {
return (
<p>
<span>{url} </span>
<Button
type="button"
className="danger-button"
label="Delete"
data-url={url}
data-task={task}
onclick="deleteNotificationUrl(this)"
onClick={async () => {
await deleteAppriseNotificationUrl(key as AppriseTaskNameType);
setRefresh(true);
}}
/>
<span> {url}</span>
</p>
);
})}
@ -422,8 +440,6 @@ const SettingsScheduling = () => {
</div>
</>
)}
{!notifications && <p>No notifications stored</p>}
</div>
<div className="settings-item">
<p>
@ -435,7 +451,13 @@ const SettingsScheduling = () => {
library.
</i>
</p>
<select name="task" id="id_task" defaultValue="">
<select
defaultValue=""
value={notificationTask}
onChange={e => {
setNotificationTask(e.currentTarget.value);
}}
>
<option value="">-- select task --</option>
<option value="update_subscribed">Rescan your Subscriptions</option>
<option value="extract_download">Add to download queue</option>
@ -445,15 +467,27 @@ const SettingsScheduling = () => {
<input
type="text"
name="notification_url"
placeholder="Apprise notification URL"
id="id_notification_url"
value={notificationUrl}
onChange={e => {
setNotificationUrl(e.currentTarget.value);
}}
/>
<Button
label="Save"
onClick={async () => {
await createAppriseNotificationUrl(
notificationTask as AppriseTaskNameType,
notificationUrl || '',
);
setRefresh(true);
}}
/>
</div>
</div>
<Button type="submit" name="scheduler-settings" label="Update Scheduler Settings" />
</form>
<PaginationDummy />
</div>
</>
);

View File

@ -5,7 +5,6 @@ import loadUserMeConfig from '../api/loader/loadUserConfig';
import { ColourConstant, ColourVariants } from '../configuration/colours/getColours';
import SettingsNavigation from '../components/SettingsNavigation';
import Notifications from '../components/Notifications';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import { OutletContextType } from './Base';
@ -45,9 +44,7 @@ const SettingsUser = () => {
return (
<>
<Helmet>
<title>TA | User Settings</title>
</Helmet>
<div className="boxed-content">
<SettingsNavigation />
<Notifications pageName={'all'} />

View File

@ -35,7 +35,6 @@ import updateCustomPlaylist from '../api/actions/updateCustomPlaylist';
import { PlaylistType } from './Playlist';
import loadCommentsbyVideoId from '../api/loader/loadCommentsbyVideoId';
import CommentBox, { CommentsType } from '../components/CommentBox';
import { Helmet } from 'react-helmet';
import Button from '../components/Button';
import { OutletContextType } from './Base';
import getApiUrl from '../configuration/getApiUrl';
@ -122,6 +121,14 @@ const Video = () => {
const { videoId } = useParams() as VideoParams;
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [videoEnded, setVideoEnded] = useState(false);
const [playlistAutoplay, setPlaylistAutoplay] = useState(
localStorage.getItem('playlistAutoplay') === 'true',
);
const [playlistIdForAutoplay, setPlaylistIDForAutoplay] = useState<string | undefined>(
localStorage.getItem('playlistIdForAutoplay') ?? '',
);
const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showAddToPlaylist, setShowAddToPlaylist] = useState(false);
@ -137,6 +144,8 @@ const Video = () => {
useEffect(() => {
(async () => {
setLoading(true);
const videoResponse = await loadVideoById(videoId);
const simmilarVideosResponse = await loadSimmilarVideosById(videoId);
const videoProgressResponse = await loadVideoProgressById(videoId);
@ -151,9 +160,40 @@ const Video = () => {
setCustomPlaylistsResponse(customPlaylistsResponse);
setCommentsResponse(commentsResponse);
setRefreshVideoList(false);
setLoading(false);
})();
}, [videoId, refreshVideoList]);
useEffect(() => {
localStorage.setItem('playlistAutoplay', playlistAutoplay.toString());
if (!playlistAutoplay) {
localStorage.setItem('playlistIdForAutoplay', '');
return;
}
localStorage.setItem('playlistIdForAutoplay', playlistIdForAutoplay || '');
}, [playlistAutoplay, playlistIdForAutoplay]);
useEffect(() => {
if (videoEnded && playlistAutoplay) {
const playlist = videoPlaylistNav?.find(playlist => {
return playlist.playlist_meta.playlist_id === playlistIdForAutoplay;
});
if (playlist) {
const nextYoutubeId = playlist.playlist_next?.youtube_id;
if (nextYoutubeId) {
setVideoEnded(false);
navigate(Routes.Video(nextYoutubeId));
}
}
}
}, [videoEnded, playlistAutoplay]);
if (videoResponse === undefined) {
return [];
}
@ -173,16 +213,20 @@ const Video = () => {
return (
<>
<Helmet>
<title>TA | {video.title}</title>
</Helmet>
<title>{`TA | ${video.title}`}</title>
<ScrollToTopOnNavigate />
{!loading && (
<VideoPlayer
video={videoResponse}
videoProgress={videoProgress}
sponsorBlock={sponsorBlock}
autoplay={playlistAutoplay}
onVideoEnd={() => {
setVideoEnded(true);
}}
/>
)}
<div className="boxed-content">
<div className="title-bar">
@ -203,6 +247,7 @@ const Video = () => {
channelname={video.channel.channel_name}
channelSubs={video.channel.channel_subs}
channelSubscribed={video.channel.channel_subscribed}
channelThumbUrl={video.channel.channel_thumb_url}
setRefresh={setRefreshVideoList}
/>
@ -219,7 +264,8 @@ const Video = () => {
id: videoId,
is_watched: status,
});
}}
onDone={() => {
setRefreshVideoList(true);
}}
/>
@ -438,6 +484,34 @@ const Video = () => {
]: {playlistItem.playlist_meta.playlist_name}
</h3>
</Link>
<div className="toggle">
<p>Autoplay:</p>
<div className="toggleBox">
<input
checked={playlistAutoplay}
onChange={() => {
if (!playlistAutoplay) {
setPlaylistIDForAutoplay(playlistItem.playlist_meta.playlist_id);
}
setPlaylistAutoplay(!playlistAutoplay);
}}
type="checkbox"
/>
{!playlistAutoplay && (
<label htmlFor="" className="ofbtn">
Off
</label>
)}
{playlistAutoplay && (
<label htmlFor="" className="onbtn">
On
</label>
)}
</div>
</div>
<div className="playlist-nav">
<div className="playlist-nav-item">
{playlistItem.playlist_previous && (

View File

@ -1361,3 +1361,45 @@ video:-webkit-full-screen {
display: flex;
justify-content: space-between;
}
/** loading indicator */
/** source: https://github.com/loadingio/css-spinner/ */
.lds-ring,
.lds-ring div {
box-sizing: border-box;
}
.lds-ring {
display: inline-block;
position: relative;
width: 20px;
height: 20px;
margin-right: 10px;
}
.lds-ring div {
box-sizing: border-box;
display: block;
position: absolute;
width: 20px;
height: 20px;
border: 4px solid currentColor;
border-radius: 50%;
animation: lds-ring 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite;
border-color: currentColor transparent transparent transparent;
}
.lds-ring div:nth-child(1) {
animation-delay: -0.45s;
}
.lds-ring div:nth-child(2) {
animation-delay: -0.3s;
}
.lds-ring div:nth-child(3) {
animation-delay: -0.15s;
}
@keyframes lds-ring {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}

View File

@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2022",
"target": "ESNext",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,