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
54 changed files with 10047 additions and 9526 deletions

View File

@@ -13,7 +13,6 @@ from typing import Any
from urllib.parse import urlparse from urllib.parse import urlparse
import requests import requests
from common.src.env_settings import EnvironmentSettings
from common.src.es_connect import IndexPaginate 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: def get_stylesheets() -> list:
"""Get all valid stylesheets from /static/css""" """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 = ["dark.css", "light.css", "matrix.css", "midnight.css"]
stylesheets.sort()
stylesheets = list(filter(lambda x: x.endswith(".css"), stylesheets))
return stylesheets return stylesheets

File diff suppressed because it is too large Load Diff

View File

@@ -10,26 +10,23 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"dompurify": "^3.1.6", "dompurify": "^3.2.3",
"react": "^18.3.1", "react": "^19.0.0",
"react-dom": "^18.3.1", "react-dom": "^19.0.0",
"react-helmet": "^6.1.0", "react-router-dom": "^7.0.2"
"react-router-dom": "^6.25.1"
}, },
"devDependencies": { "devDependencies": {
"@types/dompurify": "^3.0.5", "@types/react": "^19.0.1",
"@types/react": "^18.3.3", "@types/react-dom": "^19.0.2",
"@types/react-dom": "^18.3.0", "@typescript-eslint/eslint-plugin": "^8.18.0",
"@types/react-helmet": "^6.1.11", "@typescript-eslint/parser": "^8.18.0",
"@typescript-eslint/eslint-plugin": "^7.16.1", "@vitejs/plugin-react-swc": "^3.7.2",
"@typescript-eslint/parser": "^7.16.1", "eslint": "^9.16.0",
"@vitejs/plugin-react-swc": "^3.7.0",
"eslint": "^8.57.0",
"eslint-config-prettier": "^9.1.0", "eslint-config-prettier": "^9.1.0",
"eslint-plugin-react-hooks": "^4.6.2", "eslint-plugin-react-hooks": "^5.1.0",
"eslint-plugin-react-refresh": "^0.4.8", "eslint-plugin-react-refresh": "^0.4.16",
"prettier": "3.3.3", "prettier": "3.4.2",
"typescript": "^5.5.3", "typescript": "^5.7.2",
"vite": "^5.3.4" "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; status: boolean;
validated: number; validated: number;
validated_str: string; validated_str: string;
cookie_validated?: boolean;
}; };
const updateCookie = async (): Promise<ValidatedCookieType> => { const updateCookie = async (): Promise<ValidatedCookieType> => {

View File

@@ -12,6 +12,7 @@ const loadApiToken = async (): Promise<ApiTokenResponse> => {
const apiUrl = getApiUrl(); const apiUrl = getApiUrl();
const csrfCookie = getCookie('csrftoken'); const csrfCookie = getCookie('csrftoken');
try {
const response = await fetch(`${apiUrl}/api/appsettings/token/`, { const response = await fetch(`${apiUrl}/api/appsettings/token/`, {
headers: { headers: {
...defaultHeaders, ...defaultHeaders,
@@ -27,6 +28,9 @@ const loadApiToken = async (): Promise<ApiTokenResponse> => {
} }
return apiToken; return apiToken;
} catch (e) {
return { token: '' };
}
}; };
export default loadApiToken; 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; auto_start: boolean;
}; };
downloads: { downloads: {
limit_speed: boolean | number; limit_speed: false | number;
sleep_interval: number; sleep_interval: number;
autodelete_days: boolean | number; autodelete_days: number;
format: boolean | string; format: number | string;
format_sort: boolean | string; format_sort: boolean | string;
add_metadata: boolean; add_metadata: boolean;
add_thumbnail: boolean; add_thumbnail: boolean;
subtitle: boolean | string; subtitle: boolean | string;
subtitle_source: boolean | string; subtitle_source: boolean | string;
subtitle_index: boolean; subtitle_index: boolean;
comment_max: boolean | number; comment_max: string | number;
comment_sort: string; comment_sort: string;
cookie_import: boolean; cookie_import: boolean;
throttledratelimit: boolean | number; throttledratelimit: false | number;
extractor_lang: boolean | string; extractor_lang: boolean | string;
integrate_ryd: boolean; integrate_ryd: boolean;
integrate_sponsorblock: 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 WatchTypes = 'watched' | 'unwatched' | 'continue';
type VideoTypes = 'videos' | 'streams' | 'shorts'; export type VideoTypes = 'videos' | 'streams' | 'shorts';
type FilterType = { type FilterType = {
page?: number; page?: number;

View File

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

View File

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

View File

@@ -2,14 +2,15 @@ import getApiUrl from '../configuration/getApiUrl';
import defaultChannelIcon from '/img/default-channel-icon.jpg'; import defaultChannelIcon from '/img/default-channel-icon.jpg';
type ChannelIconProps = { type ChannelIconProps = {
channel_id: string; channelId: string;
channelThumbUrl: string | undefined;
}; };
const ChannelIcon = ({ channel_id }: ChannelIconProps) => { const ChannelIcon = ({ channelId, channelThumbUrl }: ChannelIconProps) => {
return ( return (
<img <img
src={`${getApiUrl()}/cache/channels/${channel_id}_thumb.jpg`} src={`${getApiUrl()}${channelThumbUrl}`}
alt="channel-thumb" alt={`${channelId}-thumb`}
onError={({ currentTarget }) => { onError={({ currentTarget }) => {
currentTarget.onerror = null; // prevents looping currentTarget.onerror = null; // prevents looping
currentTarget.src = defaultChannelIcon; 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 key={channel.channel_id} className={`channel-item ${viewLayout}`}>
<div className={`channel-banner ${viewLayout}`}> <div className={`channel-banner ${viewLayout}`}>
<Link to={Routes.Channel(channel.channel_id)}> <Link to={Routes.Channel(channel.channel_id)}>
<ChannelBanner channel_id={channel.channel_id} /> <ChannelBanner
channelId={channel.channel_id}
channelBannerUrl={channel.channel_banner_url}
/>
</Link> </Link>
</div> </div>
<div className={`info-box info-box-2 ${viewLayout}`}> <div className={`info-box info-box-2 ${viewLayout}`}>
<div className="info-box-item"> <div className="info-box-item">
<div className="round-img"> <div className="round-img">
<Link to={Routes.Channel(channel.channel_id)}> <Link to={Routes.Channel(channel.channel_id)}>
<ChannelIcon channel_id={channel.channel_id} /> <ChannelIcon
channelId={channel.channel_id}
channelThumbUrl={channel.channel_thumb_url}
/>
</Link> </Link>
</div> </div>
<div> <div>
@@ -59,6 +65,7 @@ const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelLis
}} }}
/> />
)} )}
{!channel.channel_subscribed && ( {!channel.channel_subscribed && (
<Button <Button
label="Subscribe" label="Subscribe"
@@ -66,7 +73,10 @@ const ChannelList = ({ channelList, viewLayout, refreshChannelList }: ChannelLis
title={`Subscribe to ${channel.channel_name}`} title={`Subscribe to ${channel.channel_name}`}
onClick={async () => { onClick={async () => {
await updateChannelSubscription(channel.channel_id, true); await updateChannelSubscription(channel.channel_id, true);
setTimeout(() => {
refreshChannelList(true); refreshChannelList(true);
}, 500);
}} }}
/> />
)} )}

View File

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

View File

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

View File

@@ -48,13 +48,6 @@ const Filterbar = ({
}: FilterbarProps) => { }: FilterbarProps) => {
useEffect(() => { useEffect(() => {
(async () => { (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 = { const userConfig: UserConfigType = {
hide_watched: hideWatched, hide_watched: hideWatched,
[viewStyleName.toString()]: view, [viewStyleName.toString()]: view,
@@ -65,7 +58,6 @@ const Filterbar = ({
await updateUserConfig(userConfig); await updateUserConfig(userConfig);
setRefresh?.(true); setRefresh?.(true);
}
})(); })();
}, [hideWatched, view, gridItems, sortBy, sortOrder, viewStyleName, setRefresh, userMeConfig]); }, [hideWatched, view, gridItems, sortBy, sortOrder, viewStyleName, setRefresh, userMeConfig]);

View File

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

View File

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

View File

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

View File

@@ -99,7 +99,48 @@ const handleTimeUpdate =
} }
}; };
const handleVideoEnd = 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, youtubeId: string,
watched: boolean, watched: boolean,
@@ -120,42 +161,10 @@ const handleVideoEnd =
return segments; 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 ( return (
<> <>
<div id="player" className={embed ? '' : 'player-wrapper'}> <div id="player" className={embed ? '' : 'player-wrapper'}>
@@ -165,8 +174,13 @@ const VideoPlayer = ({ video, videoProgress, sponsorBlock, embed }: VideoPlayerP
onVolumeChange={(videoTag: VideoTag) => { onVolumeChange={(videoTag: VideoTag) => {
localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString()); localStorage.setItem('playerVolume', videoTag.currentTarget.volume.toString());
}} }}
onRateChange={(videoTag: VideoTag) => {
localStorage.setItem('playerSpeed', videoTag.currentTarget.playbackRate.toString());
}}
onLoadStart={(videoTag: VideoTag) => { onLoadStart={(videoTag: VideoTag) => {
videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 1; videoTag.currentTarget.volume = Number(localStorage.getItem('playerVolume')) ?? 1;
videoTag.currentTarget.playbackRate =
Number(localStorage.getItem('playerSpeed')) ?? 1;
}} }}
onTimeUpdate={handleTimeUpdate( onTimeUpdate={handleTimeUpdate(
videoId, videoId,

View File

@@ -1,33 +1,63 @@
import iconUnseen from '/img/icon-unseen.svg'; import iconUnseen from '/img/icon-unseen.svg';
import iconSeen from '/img/icon-seen.svg'; import iconSeen from '/img/icon-seen.svg';
import { useEffect, useState } from 'react';
type WatchedCheckBoxProps = { type WatchedCheckBoxProps = {
watched: boolean; watched: boolean;
onClick?: (status: boolean) => void; 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 ( return (
<> <>
{watched && ( {loading && (
<>
<div className="lds-ring" style={{ color: 'var(--accent-font-dark)' }}>
<div />
</div>
</>
)}
{!loading && watched && (
<img <img
src={iconSeen} src={iconSeen}
alt="seen-icon" alt="seen-icon"
className="watch-button" className="watch-button"
title="Mark as unwatched" title="Mark as unwatched"
onClick={async () => { onClick={async () => {
onClick?.(false); setState(false);
setLoading(true);
}} }}
/> />
)} )}
{!watched && ( {!loading && !watched && (
<img <img
src={iconUnseen} src={iconUnseen}
alt="unseen-icon" alt="unseen-icon"
className="watch-button" className="watch-button"
title="Mark as watched" title="Mark as watched"
onClick={async () => { 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 ChannelVideo from './pages/ChannelVideo';
import ChannelPlaylist from './pages/ChannelPlaylist'; import ChannelPlaylist from './pages/ChannelPlaylist';
import ChannelAbout from './pages/ChannelAbout'; import ChannelAbout from './pages/ChannelAbout';
import ChannelStream from './pages/ChannelStream';
import Download from './pages/Download'; import Download from './pages/Download';
import ChannelShorts from './pages/ChannelShorts';
const router = createBrowserRouter( const router = createBrowserRouter(
[ [
@@ -105,7 +103,7 @@ const router = createBrowserRouter(
{ {
index: true, index: true,
path: Routes.ChannelVideo(':channelId'), path: Routes.ChannelVideo(':channelId'),
element: <ChannelVideo />, element: <ChannelVideo videoType="videos" />,
loader: async () => { loader: async () => {
const authResponse = await loadAuth(); const authResponse = await loadAuth();
if (authResponse.status === 403) { if (authResponse.status === 403) {
@@ -119,26 +117,30 @@ const router = createBrowserRouter(
}, },
{ {
path: Routes.ChannelStream(':channelId'), path: Routes.ChannelStream(':channelId'),
element: <ChannelStream />, element: <ChannelVideo videoType="streams" />,
loader: async () => { loader: async () => {
const authResponse = await loadAuth(); const authResponse = await loadAuth();
if (authResponse.status === 403) { if (authResponse.status === 403) {
return redirect(Routes.Login); return redirect(Routes.Login);
} }
return {}; const userConfig = await loadUserMeConfig();
return { userConfig };
}, },
}, },
{ {
path: Routes.ChannelShorts(':channelId'), path: Routes.ChannelShorts(':channelId'),
element: <ChannelShorts />, element: <ChannelVideo videoType="shorts" />,
loader: async () => { loader: async () => {
const authResponse = await loadAuth(); const authResponse = await loadAuth();
if (authResponse.status === 403) { if (authResponse.status === 403) {
return redirect(Routes.Login); return redirect(Routes.Login);
} }
return {}; const userConfig = await loadUserMeConfig();
return { userConfig };
}, },
}, },
{ {

View File

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

View File

@@ -10,18 +10,16 @@ import queueReindex, { ReindexType, ReindexTypeEnum } from '../api/actions/queue
import formatDate from '../functions/formatDates'; import formatDate from '../functions/formatDates';
import PaginationDummy from '../components/PaginationDummy'; import PaginationDummy from '../components/PaginationDummy';
import FormattedNumber from '../components/FormattedNumber'; import FormattedNumber from '../components/FormattedNumber';
import { Helmet } from 'react-helmet';
import Button from '../components/Button'; import Button from '../components/Button';
import updateChannelSettings, {
ChannelAboutConfigType,
} from '../api/actions/updateChannelSettings';
const handleSponsorBlockIntegrationOverwrite = (integration: boolean | undefined) => { const toStringToBool = (str: string) => {
if (integration === undefined) { try {
return 'False'; return JSON.parse(str);
} } catch {
return null;
if (integration) {
return integration;
} else {
return 'Disabled';
} }
}; };
@@ -51,9 +49,26 @@ const ChannelAbout = () => {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [descriptionExpanded, setDescriptionExpanded] = useState(false); const [descriptionExpanded, setDescriptionExpanded] = useState(false);
const [reindex, setReindex] = useState(false); const [reindex, setReindex] = useState(false);
const [refresh, setRefresh] = useState(false); const [refresh, setRefresh] = useState(true);
const [channelResponse, setChannelResponse] = useState<ChannelResponseType>(); 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; const channel = channelResponse?.data;
@@ -62,16 +77,31 @@ const ChannelAbout = () => {
const handleSubmit = async (event: { preventDefault: () => void }) => { const handleSubmit = async (event: { preventDefault: () => void }) => {
event.preventDefault(); event.preventDefault();
//TODO: implement request to about api endpoint ( when implemented ) await updateChannelSettings(channelId, {
// `/api/channel/${channel.channel_id}/about/` 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(() => { useEffect(() => {
(async () => { (async () => {
if (refresh) {
const channelResponse = await loadChannelById(channelId); const channelResponse = await loadChannelById(channelId);
setChannelResponse(channelResponse); 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); setRefresh(false);
}
})(); })();
}, [refresh, channelId]); }, [refresh, channelId]);
@@ -81,9 +111,7 @@ const ChannelAbout = () => {
return ( return (
<> <>
<Helmet> <title>{`TA | Channel: About ${channel.channel_name}`}</title>
<title>TA | Channel: About {channel.channel_name}</title>
</Helmet>
<div className="boxed-content"> <div className="boxed-content">
<div className="info-box info-box-3"> <div className="info-box info-box-3">
<ChannelOverview <ChannelOverview
@@ -91,6 +119,7 @@ const ChannelAbout = () => {
channelname={channel.channel_name} channelname={channel.channel_name}
channelSubs={channel.channel_subs} channelSubs={channel.channel_subs}
channelSubscribed={channel.channel_subscribed} channelSubscribed={channel.channel_subscribed}
channelThumbUrl={channel.channel_thumb_url}
showSubscribeButton={true} showSubscribeButton={true}
isUserAdmin={isAdmin} isUserAdmin={isAdmin}
setRefresh={setRefresh} setRefresh={setRefresh}
@@ -224,7 +253,22 @@ const ChannelAbout = () => {
{channelOverwrites?.download_format || 'False'} {channelOverwrites?.download_format || 'False'}
</span> </span>
</p> </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 /> <br />
</div> </div>
<div className="overwrite-form-item"> <div className="overwrite-form-item">
@@ -234,7 +278,20 @@ const ChannelAbout = () => {
{channelOverwrites?.autodelete_days || 'False'} {channelOverwrites?.autodelete_days || 'False'}
</span> </span>
</p> </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 /> <br />
</div> </div>
@@ -243,12 +300,21 @@ const ChannelAbout = () => {
<p> <p>
Index playlists:{' '} Index playlists:{' '}
<span className="settings-current"> <span className="settings-current">
{channelOverwrites?.index_playlists || 'False'} {JSON.stringify(channelOverwrites?.index_playlists)}
</span> </span>
</p> </p>
<select name="index_playlists" id="id_index_playlists" defaultValue=""> <select
<option value="">-- change playlist index --</option> 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="false">Disable playlist index</option>
<option value="true">Enable playlist index</option> <option value="true">Enable playlist index</option>
</select> </select>
@@ -264,20 +330,27 @@ const ChannelAbout = () => {
</a> </a>
:{' '} :{' '}
<span className="settings-current"> <span className="settings-current">
{handleSponsorBlockIntegrationOverwrite( {JSON.stringify(channelOverwrites?.integrate_sponsorblock)}
channelOverwrites?.integrate_sponsorblock,
)}
</span> </span>
</p> </p>
<select <select
name="integrate_sponsorblock" name="integrate_sponsorblock"
id="id_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="">-- 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="true">enable sponsorblock integration</option>
<option value="false">unset sponsorblock integration</option> <option value="null">unset sponsorblock integration</option>
</select> </select>
</div> </div>
<h3>Page Size Overrides</h3> <h3>Page Size Overrides</h3>
@@ -301,7 +374,14 @@ const ChannelAbout = () => {
recommended 50. recommended 50.
</i> </i>
<br /> <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 /> <br />
</div> </div>
<div className="overwrite-form-item"> <div className="overwrite-form-item">
@@ -316,7 +396,14 @@ const ChannelAbout = () => {
max recommended 50. max recommended 50.
</i> </i>
<br /> <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 /> <br />
</div> </div>
<div className="overwrite-form-item"> <div className="overwrite-form-item">
@@ -331,7 +418,14 @@ const ChannelAbout = () => {
task, max recommended 50. task, max recommended 50.
</i> </i>
<br /> <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> </div>
<br /> <br />

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1361,3 +1361,45 @@ video:-webkit-full-screen {
display: flex; display: flex;
justify-content: space-between; 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": { "compilerOptions": {
"target": "ES2022", "target": "ESNext",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"], "lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext", "module": "ESNext",
"skipLibCheck": true, "skipLibCheck": true,